o2o-site-AEO/solution/site/src/sections/FestivalSection.tsx
Mina Choi 64ce467f21 [refactor] postgres-init,solution: DB 구조 재편 — 스키마 해체 · 공용 콘텐츠 한 벌 · 마이그레이션 체계
도메인별 스키마(company·place·fact·local·site·job)를 걷어내고 public 한 벌로 폈다.
스키마 한정자가 붙은 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.

- 공용 콘텐츠를 한 테이블로 되돌린다. spots·region_stories 를 따로 파 놓고 보니
  같은 성격이 세 곳으로 갈라져 있었다 — `area_contents` 가 처음부터 content_type 으로
  종류를 가르는 설계였고 그걸 쓰면 됐다. 관계(거리·숨김)만 `place_area_refs` 로 남긴다.
- migrations/ + scripts/migrate.py: `init.sql` 은 **DB 를 처음 만들 때만** 돈다. 파일에
  컬럼을 더해도 이미 데이터가 든 DB 에는 반영되지 않는다 — 실제로 TourAPI 가 주변 정보를
  받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고, 화면에는 "그냥 안 나오는 것" 으로만 보였다.
  DECISIONS.md 가 예고한 그대로다("운영 DB 가 생기는 순간 다시 필요해진다").
  Alembic 을 쓰지 않는 이유는 스키마 정의가 이미 두 곳(ORM·init.sql)이라 세 번째를
  더하면 어긋날 자리가 하나 더 생기기 때문이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:08:02 +09:00

205 lines
9.8 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, MapPin} from 'lucide-react';
import type {FestivalEntry} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {naverSearchUrl} from '@/lib/format';
import {useLiveSeasons} from '@/lib/use-live-seasons';
import {Section} from '@/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={
active && !showAll ? (
<p className="text-muted text-[length:var(--fs-xs)]">지금은 {active} 축제입니다</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-80"
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>
<FestivalGrid items={items} />
</div>
);
})}
{evergreen.length > 0 && (
<div>
<h3 className="border-line mb-4 border-b pb-2 text-[length:var(--fs-sm)] font-bold">
계절 없이 열리는 행사
</h3>
<FestivalGrid items={evergreen} />
</div>
)}
</div>
</Section>
);
}
/**
* ★ 좁은 화면에서는 **가로줄**, 넓어지면 세로 카드 (2026-09-04, 사장님: "모바일에서 너무 길다")
* 실측 390px 에서 이 섹션 하나가 1,274px — 화면 1.7개였다. 큰 사진을 세로로 쌓은 탓이다.
* 축제 카드에서 손님이 찾는 건 **언제·어디서** 셋 줄이지 사진이 아니다. 사진은 썸네일로 줄이고
* 글을 옆에 붙이면 한 장이 400px 에서 120px 이 된다.
* ★ 폭으로 컴포넌트를 갈아 끼우지 않고 CSS 로만 가른다 — 서버 렌더는 화면 폭을 모른다.
*/
function FestivalGrid({items}: {items: FestivalEntry[]}) {
return (
<ul className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 sm:gap-3 lg:grid-cols-3 lg:gap-4">
{items.map((festival) => (
<li key={festival.name}>
<a
href={naverSearchUrl(festival.searchQuery)}
target="_blank"
rel="noopener noreferrer nofollow"
className="panel group flex h-full overflow-hidden transition-opacity hover:opacity-85 sm:flex-col"
>
{/* 사진이 없으면 빈 회색 상자 대신 이름 활자 — 주변 안내 카드와 같은 규칙이다. */}
<span className="relative block aspect-square w-28 shrink-0 overflow-hidden sm:aspect-4/3 sm:w-auto">
{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 sm: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-80">
{festival.period}
</span>
)}
{festival.location && (
<span className="text-muted flex items-start gap-1 text-[length:var(--fs-xs)]">
<MapPin className="mt-0.5 size-3 shrink-0 opacity-60" />
<span>{festival.location}</span>
</span>
)}
{festival.description && (
<span className="text-muted line-clamp-2 pt-0.5 text-[length:var(--fs-xs)] leading-relaxed sm:line-clamp-3">
{festival.description}
</span>
)}
<span className="text-muted mt-auto hidden items-center gap-0.5 pt-1.5 text-[length:var(--fs-xs)] opacity-70 transition-opacity group-hover:opacity-100 sm:flex">
<span>검색으로 열기</span>
<ArrowUpRight className="size-3.5" />
</span>
</span>
</a>
</li>
))}
</ul>
);
}