/** * 소식 — 발행본. 이벤트와 공지를 한 자리에서 받는다. * * ★ 인스타에 올라온 것을 **손으로 옮겨 적는다.** 계정 페이지는 로그인·봇 탐지 뒤에 있어 * 읽어 올 수 없고, 우회는 이 레포에서 영구 금지다(DECISIONS 1-1). 대신 원문 주소를 달아 * 손님이 정확한 것을 그쪽에서 보게 한다. * ★ 이벤트와 공지를 나누지 않는다 (2026-09-04, 사장님 결정) * 손님이 볼 곳이 둘로 갈리면 어디를 봐야 하는지 모른다 — '이용 및 예약 안내'와 '예약 안내'가 * 나란히 서서 겪었던 그 일이다. 한 섹션에 두고 **배지로만** 가른다. * ★ 끝난 것도 **HTML 에서 지우지 않는다.** 이 사이트의 존재 이유가 인용이라, 지난 이벤트가 * 남아 있어야 "이 집은 이런 걸 한다"를 검색·AI 가 읽는다. 화면에서는 빛바랜 전단으로 눕힌다. * ★ 진행 여부는 **브라우저에서** 판정한다. 정적으로 구운 페이지는 몇 달을 사는데, 굽는 날의 * '오늘'을 박으면 12월에도 9월 이벤트가 진행 중으로 뜬다(계절별 코스와 같은 수법). * 첫 렌더에는 배지를 안 그린다 — 서버 렌더와 달라지면 하이드레이션이 어긋난다. * * ★ 생김새는 **게시판에 붙은 전단**이다. 갱지 결 위에 도장 하나, 참여 방법은 절취선 안에. * 이 팩의 다른 섹션(가요 다방의 도넛판, 계절별 하루의 승차권)과 같은 규칙이다 — * 카드가 무엇을 흉내 내는지 한눈에 보여야 목록이 아니라 물건으로 읽힌다. */ import {useCallback, useEffect, useState} from 'react'; import {ShowMore, useScrollLock} from '@site/lib/ui'; import {X} from 'lucide-react'; import type {EventItem} from '@o2o/shared'; import {useSite} from '@site/lib/site-context'; import {sectionItems, sectionName} from '@site/lib/derive'; import { ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ITEM_INK, ITEM_INVERSE_INK, ItemSection, } from './common'; type Phase = 'open' | 'soon' | 'closed'; const PHASE_LABEL: Record = {open: '진행 중', soon: '곧 시작', closed: '종료'}; /** 'YYYY-MM-DD' 두 개로 오늘이 어디쯤인지. 문자열 비교로 끝난다 — 시간대 계산이 필요 없다. */ function phaseOf(item: EventItem, today: string): Phase { if (item.endDate && item.endDate < today) return 'closed'; if (item.startDate && item.startDate > today) return 'soon'; return 'open'; } /** * 도장 한 칸. * * ★ 날짜가 지났으면 **종류와 무관하게 '종료'** 다 (2026-09-04, 사장님 지적: "디자인이 서로 맞지 않음") * 전에는 kind 가 '공지' 면 무조건 '공지' 를 찍었다. 그런데 작년 축제 안내가 흑백으로 바래 * 지나간 것처럼 보이면서 도장만 '공지' 라, 카드가 두 가지 말을 동시에 했다. * 날짜가 있는 것은 날짜가 말하게 하고, 날짜가 없는 상시 공지만 '공지' 로 남는다. */ function badgeOf(item: EventItem, phase: Phase | undefined): string | undefined { if (item.kind === '공지' && !item.startDate && !item.endDate) return '공지'; return phase && PHASE_LABEL[phase]; } /** "9월 1일 – 9월 30일". 끝 날짜가 없으면 상시다. */ function periodText(item: EventItem): string | undefined { const short = (iso?: string) => { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso ?? ''); return m ? `${Number(m[2])}월 ${Number(m[3])}일` : undefined; }; const from = short(item.startDate); const to = short(item.endDate); // 하루짜리 행사는 "8월 16일 – 8월 16일" 이 된다 — 같은 날이면 한 번만 적는다. if (from && to) return from === to ? from : `${from} – ${to}`; if (from) return `${from}부터`; if (to) return `${to}까지`; return undefined; } export function EventSection() { const payload = useSite(); const parsed = sectionItems(payload, 'event'); const [today, setToday] = useState(); // 'sv-SE' 로케일이 'YYYY-MM-DD' 를 준다 — toISOString 은 UTC 라 한국 자정 뒤 하루가 밀린다. useEffect(() => setToday(new Date().toLocaleDateString('sv-SE')), []); /* * ★ 눌러서 펴는 창 (2026-09-04, 사장님 지시) * 공지는 지켜야 할 것이 여러 줄이라 카드 안에 다 담으면 카드가 세로로 길어지고, * 그러면 옆 카드까지 같이 늘어나 섹션이 화면 몇 개가 된다. 카드는 요약만 세우고 * 전문은 창에서 편다. **본문은 카드 DOM 에 이미 다 있다**(line-clamp 로 시각만 자름) — * 창이 닫혀 있어도 검색·AI 는 전문을 읽는다. */ const [open, setOpen] = useState(null); const close = useCallback(() => setOpen(null), []); // 창이 열려 있는 동안 Esc 로 닫고 뒤 배경 스크롤을 잠근다 — 안 잠그면 창 위에서 뒤가 밀린다. useScrollLock(open !== null); useEffect(() => { if (open === null) return; const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') close(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [open, close]); if (parsed.items.length === 0) return null; const shown = open === null ? undefined : parsed.items[open]; const shownPhase = shown && today ? phaseOf(shown, today) : undefined; return ( {/* ★ 키를 맞춘다 (2026-09-04, 사장님 지적: "디자인이 서로 맞지 않음 · 통일되게") 한때 items-start 로 제각각 두었다 — 전단은 원래 크기가 다르니까. 그런데 사진 있는 카드와 없는 카드가 섞이자 줄마다 아래가 들쭉날쭉해져서 게시판이 아니라 어질러진 것으로 보였다. 같은 줄에서는 키를 맞추고, 대신 **카드 안 순서를 사진 유무와 무관하게 똑같이** 만든다. */}
    `소식 ${rest}개 더 보기`}> {parsed.items.map((item, index) => { const phase = today ? phaseOf(item, today) : undefined; const badge = badgeOf(item, phase); const period = periodText(item); const done = phase === 'closed'; return (
  • {/* 카드 전체가 누르는 자리다 — 글은 그대로 읽히고 위에 투명한 버튼만 덮는다. */}
  • ); })}
{shown && (
event.stopPropagation()} className="w4-paper max-h-[88vh] w-full max-w-2xl overflow-y-auto border-2" style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER, color: ITEM_INK}} > {shown.imageUrl && ( {`${shown.title} )}
{badgeOf(shown, shownPhase) && ( {badgeOf(shown, shownPhase)} )} {periodText(shown) && ( {periodText(shown)} )}

{shown.title}

{shown.summary && (

{shown.summary}

)} {shown.howTo && (

{shown.howTo}

)} {/* 줄바꿈을 살린다 — 공지는 항목이 줄로 갈려 있어야 읽힌다. */} {shown.body && (

{shown.body}

)}
{shown.postUrl ? ( 원문 보기 → ) : ( )}
)}
); }