/** * 계절별 축제 — 지역 축제·행사만 따로 세운 섹션. * * ★ 왜 '주변 안내'에서 떼어냈나 * 맛집·명소는 아무 때나 가는 곳이고 축제는 **날짜가 지나면 못 가는 것**이다. 성격이 다른데 * 같은 목록에 섞여 있으면 손님은 기간을 안 읽고, 우리는 "이번 계절에 뭐 있어요?"라는 * 질문에 답할 자리를 잃는다. 계절을 축으로 세우면 그 질문이 곧 화면 구조가 된다. * * ★ 지금 계절만 열고 나머지 계절은 **HTML 에 남겨 둔 채 접는다**(`hidden`). * 지우면 검색·AI 가 나머지 세 계절을 못 읽는다 — 이 레포의 규칙이다. * ★ 첫 렌더에서는 계절이 비어 있다(`useLiveSeasons`). 그때는 아무것도 접지 않는다 — * SSR 과 어긋나면 하이드레이션이 깨지고, 구운 HTML 에는 사계절이 다 남아야 한다. * ★ 링크는 검색으로만 보낸다. 공공데이터가 준 공식 홈페이지가 있어도 우리가 붙이지 않는다 — * 축제 사이트는 해마다 주소가 바뀌고, 죽은 링크의 책임은 이 홈페이지가 진다. */ import {useState} from 'react'; import {ArrowUpRight} from 'lucide-react'; import type {FestivalEntry} from '@o2o/shared'; import {useSite} from '@site/lib/site-context'; import {naverSearchUrl} from '@site/lib/format'; import {useLiveSeasons} from '@site/lib/use-live-seasons'; import {Carousel, CarouselSlide, Modal, Section} from '@site/lib/ui'; // shared 의 SEASON_ORDER 는 내보내지 않는다. 순서는 달력이 정한 것이라 갈릴 일이 없다. const SEASONS = ['봄', '여름', '가을', '겨울']; export function FestivalSection() { const payload = useSite(); const festivals = payload.local.festivals; const live = useLiveSeasons(); const [picked, setPicked] = useState(null); if (festivals.length === 0) return null; // 데이터에 실제로 있는 계절만 탭이 된다. 없는 계절 탭을 눌러 빈 화면을 보게 하지 않는다. const tabs = SEASONS.filter((season) => festivals.some((f) => f.season === season)); // 계절을 안 적은 행사(연중·상시)는 어느 탭에서도 접지 않는다. const evergreen = festivals.filter((f) => !f.season?.trim()); // 간절기에는 `live` 가 둘이고 뒤가 지금 계절이다 — 뒤에서부터 찾는다. const auto = [...live].reverse().find((season) => tabs.includes(season)); /* * ★ '전체' 를 넣는다 (2026-09-04, 사장님 지시) * 지금 계절만 열어 두면 "가을 말고 다른 때 오면 뭐가 있나"를 보려고 탭을 네 번 눌러야 했다. * 여행은 대개 몇 달 뒤를 잡는 일이라, 사계절을 한 번에 보는 길이 있어야 한다. * 기본값은 그대로 지금 계절이다 — 처음 온 손님에게 열두 개를 늘어놓지 않는다. */ const ALL = '전체'; const active = picked ?? auto ?? null; const showAll = active === ALL; return (
지금은 {auto} 축제입니다

) : undefined } footnote={ <>한국관광공사 TourAPI 기준. 일정은 주최 측 사정으로 바뀔 수 있습니다. } > {tabs.length > 1 && (
{[...tabs, ALL].map((season) => { const on = active === season; return ( ); })}
)}
{tabs.map((season) => { const items = festivals.filter((f) => f.season === season); return ( ); })} {evergreen.length > 0 && (

계절 없이 열리는 행사

)}
); } /** * ★ 격자에서 **레일**로 바꿨다 (2026-09-09, 사장님: "계절별 축제 카로셀로") * 한 계절에 열 개가 넘으면 격자는 네 줄이 되고, 그 아래 섹션이 화면 밖으로 밀린다. * 레일은 계절이 몇 개든 세로 길이가 한 장으로 고정된다. 자동 넘김은 Carousel 기본값이다. * ★ 접힌 계절 안에 있는 레일은 폭이 0 이다 — embla 가 그 상태로 스냅을 재면 어긋난다. * `remount` 로 활성 탭이 바뀔 때 다시 태운다(LocalGuideSection 의 `key={shown.length}` 와 같은 이유). * ★ 카드 모양을 **주변 안내와 같게** 맞췄다 (2026-09-09, 사장님: "축제만 왜 ui가 달라 주변맛집이랑?") * 격자 시절에는 좁은 화면에서 가로줄(썸네일 왼쪽·글 오른쪽)로 눕혔다 — 세로로 열두 장이 * 쌓이니 섹션 하나가 화면 1.7개였기 때문이다(2026-09-04). 레일이 되면서 그 이유가 없어졌다. * 세로 길이는 이미 카드 한 장으로 고정되는데 모양만 옆 섹션과 달라, 같은 페이지에서 * 같은 성격의 카드(사진·이름·설명·검색 링크)가 두 벌로 보였다. * 지금은 사진 위 배지 · aspect-4/3 · 슬라이드 폭까지 `LocalGuideSection` 과 같다. */ function FestivalRail({ items, label, remount, }: { items: FestivalEntry[]; label: string; remount?: string | null; }) { const [open, setOpen] = useState(null); return ( <> {items.map((festival) => ( ))} setOpen(null)} label={open?.name ?? '축제'} title={open?.name}> {open && (
{open.imageUrl && ( {`${open.name} )} {open.period && (

{open.period}

)} {open.description && (

{open.description}

)} 검색으로 열기
)}
); }