o2o-site-AEO/solution/site/src/sections/FestivalSection.tsx
Mina Choi 4260e20a70 [fix] site: 모달 닫기 스크롤 버그 수정 — 이용안내 체크인·체크아웃 병합, UI 다듬기
모달 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>
2026-09-18 17:23:20 +09:00

247 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 계절별 축제 — 지역 축제·행사만 따로 세운 섹션.
*
* ★ 왜 '주변 안내'에서 떼어냈나
* 맛집·명소는 아무 때나 가는 곳이고 축제는 **날짜가 지나면 못 가는 것**이다. 성격이 다른데
* 같은 목록에 섞여 있으면 손님은 기간을 안 읽고, 우리는 "이번 계절에 뭐 있어요?"라는
* 질문에 답할 자리를 잃는다. 계절을 축으로 세우면 그 질문이 곧 화면 구조가 된다.
*
* ★ 지금 계절만 열고 나머지 계절은 **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>
</>
);
}