모달 onClose 가 inline 함수라 부모 리렌더마다 useEffect 가 다시 걸려 scrollY 를 0으로 덮어써 X 버튼으로 닫으면 페이지가 맨 위로 튀었다. - lib/ui/Modal.tsx: onClose 를 ref 로 들고 [open] 에만 의존하도록 수정 (BlogSection·ReviewSection 등 Modal 쓰는 7곳 전부 적용) - sections/EssentialInfoSection.tsx: 체크인·체크아웃 행을 한 줄로 병합 - sections/GallerySection.tsx, UnitsSection.tsx, MobileTabBar.tsx, SiteFooter.tsx, items/*: 표시 폭·간격·라벨 정리 - lib/ui/Carousel.tsx: loop 이음매 간격 재점검 - scripts/prerender.ts: countUniqueContent 가 socialPosts(자체 출력)를 세지 않도록 — 콘텐츠 0건 사이트가 게이트를 우회하던 경로 tsc 통과, vitest 100/102 passed (use-live-weather 실패 2건은 기존·무관) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
247 lines
12 KiB
TypeScript
247 lines
12 KiB
TypeScript
/**
|
||
* 계절별 축제 — 지역 축제·행사만 따로 세운 섹션.
|
||
*
|
||
* ★ 왜 '주변 안내'에서 떼어냈나
|
||
* 맛집·명소는 아무 때나 가는 곳이고 축제는 **날짜가 지나면 못 가는 것**이다. 성격이 다른데
|
||
* 같은 목록에 섞여 있으면 손님은 기간을 안 읽고, 우리는 "이번 계절에 뭐 있어요?"라는
|
||
* 질문에 답할 자리를 잃는다. 계절을 축으로 세우면 그 질문이 곧 화면 구조가 된다.
|
||
*
|
||
* ★ 지금 계절만 열고 나머지 계절은 **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<string | null>(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 (
|
||
<Section
|
||
id="festival"
|
||
title="계절별 축제"
|
||
lead={`${payload.place.addressLocality ?? '이 지역'}의 축제와 행사를 계절로 묶었습니다.`}
|
||
aside={
|
||
auto && active === auto && !showAll ? (
|
||
<p className="text-muted text-[length:var(--fs-xs)]">지금은 {auto} 축제입니다</p>
|
||
) : undefined
|
||
}
|
||
footnote={
|
||
<>한국관광공사 TourAPI 기준. 일정은 주최 측 사정으로 바뀔 수 있습니다.</>
|
||
}
|
||
>
|
||
{tabs.length > 1 && (
|
||
<div className="mb-6 flex flex-wrap gap-1.5" role="tablist" aria-label="계절">
|
||
{[...tabs, ALL].map((season) => {
|
||
const on = active === season;
|
||
return (
|
||
<button
|
||
key={season}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={on}
|
||
onClick={() => setPicked(season)}
|
||
className="border-line rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-100"
|
||
style={
|
||
on
|
||
? {backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)', borderColor: 'transparent'}
|
||
: undefined
|
||
}
|
||
>
|
||
{season}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-8">
|
||
{tabs.map((season) => {
|
||
const items = festivals.filter((f) => f.season === season);
|
||
return (
|
||
<div key={season} hidden={!showAll && active !== null && active !== season}>
|
||
{/* 탭이 접혀 있어도 소제목은 남는다 — 펼쳐 놓고 읽는 기계에게는 이게 계절 표지다. */}
|
||
<h3 className="border-line mb-4 border-b pb-2 text-[length:var(--fs-sm)] font-bold">
|
||
{season}
|
||
</h3>
|
||
<FestivalRail items={items} label={`${season} 축제`} remount={active} />
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{evergreen.length > 0 && (
|
||
<div>
|
||
<h3 className="border-line mb-4 border-b pb-2 text-[length:var(--fs-sm)] font-bold">
|
||
계절 없이 열리는 행사
|
||
</h3>
|
||
<FestivalRail items={evergreen} label="계절 없이 열리는 행사" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Section>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* ★ 격자에서 **레일**로 바꿨다 (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<FestivalEntry | null>(null);
|
||
return (
|
||
<>
|
||
<Carousel key={remount ?? 'all'} label={label}>
|
||
{items.map((festival) => (
|
||
<CarouselSlide key={festival.name} basis="basis-[76%] sm:basis-1/3 lg:basis-1/4">
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen(festival)}
|
||
className="panel group flex h-full w-full flex-col overflow-hidden text-left transition-opacity hover:opacity-100"
|
||
>
|
||
{/* 사진이 없으면 빈 회색 상자 대신 이름 활자 — 주변 안내 카드와 같은 규칙이다. */}
|
||
<span className="relative block aspect-4/3 overflow-hidden">
|
||
{festival.imageUrl ? (
|
||
<img
|
||
src={festival.imageUrl}
|
||
alt={`${festival.name} 사진`}
|
||
loading="lazy"
|
||
decoding="async"
|
||
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||
/>
|
||
) : (
|
||
<span
|
||
className="serif grid size-full place-items-center px-3 text-center text-[length:var(--fs-lead)] leading-tight"
|
||
style={{backgroundColor: 'color-mix(in oklab, currentColor 9%, transparent)'}}
|
||
aria-hidden
|
||
>
|
||
{festival.name}
|
||
</span>
|
||
)}
|
||
{/* 언제인지는 사진 위에 얹는다 — 축제 카드에서 제일 먼저 찾는 값이다. */}
|
||
<span
|
||
className="absolute bottom-2 left-2 rounded-md px-2 py-0.5 text-[length:var(--fs-xs)] font-bold"
|
||
style={{
|
||
backgroundColor: 'color-mix(in srgb, var(--tpl-inverse, #1c1917) 78%, transparent)',
|
||
color: 'var(--tpl-bg, #fff)',
|
||
}}
|
||
>
|
||
{festival.month}
|
||
</span>
|
||
</span>
|
||
|
||
<span className="flex min-w-0 flex-1 flex-col gap-1 p-3.5">
|
||
<span className="text-[length:var(--fs-sm)] font-bold">{festival.name}</span>
|
||
{/*
|
||
★ <time> 을 걷었다 (2026-09-04, AEO 진단 '미래 날짜 표기')
|
||
기간("2026년 10월 2일 – 5일")을 <time dateTime="2026-10-02"> 하나에 담고 있었다.
|
||
<time> 은 **시점 하나**를 가리키는 요소라 기간을 넣으면 마크업이 틀린 것이고,
|
||
실제로 그 미래 날짜가 페이지에서 가장 최근 날짜로 잡혀 **최신성 신호를 먹었다**
|
||
(진단: "가장 최근 날짜가 2026-10-02 로 오늘보다 뒤"). 축제 날짜는 이 문서가
|
||
갱신된 날이 아니다 — 사람이 읽는 글자로만 남기고, 기계가 읽을 행사 일정은
|
||
구조화 데이터(Event)가 맡을 자리다.
|
||
*/}
|
||
{festival.period && (
|
||
<span className="text-[length:var(--fs-xs)] font-medium opacity-100">
|
||
{festival.period}
|
||
</span>
|
||
)}
|
||
{festival.description && (
|
||
<span className="text-muted line-clamp-2 pt-0.5 text-[length:var(--fs-xs)] leading-relaxed">
|
||
{festival.description}
|
||
</span>
|
||
)}
|
||
<span className="text-muted mt-auto flex items-center gap-0.5 pt-1.5 text-[length:var(--fs-xs)] opacity-100 transition-opacity group-hover:opacity-100">
|
||
<span>자세히 보기</span>
|
||
<ArrowUpRight className="size-3.5" />
|
||
</span>
|
||
</span>
|
||
</button>
|
||
</CarouselSlide>
|
||
))}
|
||
</Carousel>
|
||
|
||
<Modal open={open != null} onClose={() => setOpen(null)} label={open?.name ?? '축제'} title={open?.name}>
|
||
{open && (
|
||
<div className="flex flex-col gap-4">
|
||
{open.imageUrl && (
|
||
<img
|
||
src={open.imageUrl}
|
||
alt={`${open.name} 사진`}
|
||
className="aspect-4/3 w-full rounded-lg object-cover"
|
||
/>
|
||
)}
|
||
{open.period && (
|
||
<p className="text-[length:var(--fs-sm)] font-semibold opacity-100">{open.period}</p>
|
||
)}
|
||
{open.description && (
|
||
<p className="text-[length:var(--fs-sm)] leading-relaxed opacity-100">
|
||
{open.description}
|
||
</p>
|
||
)}
|
||
<a
|
||
href={naverSearchUrl(open.searchQuery)}
|
||
target="_blank"
|
||
rel="noopener noreferrer nofollow"
|
||
className="tap flex items-center justify-center gap-1.5 rounded-lg px-4 text-[length:var(--fs-sm)] font-bold"
|
||
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
|
||
>
|
||
<span>검색으로 열기</span>
|
||
<ArrowUpRight className="size-4" />
|
||
</a>
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
</>
|
||
);
|
||
}
|