o2o-site-AEO/solution/site/src/sections/FestivalSection.tsx
Mina Choi b1386dd3ce [refactor] solution,nginx: 에디터와 발행본을 한 렌더러로 — 보는 그대로 나간다
에디터에서 본 화면과 발행된 화면이 달랐다. 렌더러를 두 벌 들고 있었기 때문이다 —
캔버스는 `builder/canvas/variants/*` 25종, 발행본은 `site/src/sections/*`.
Playwright 로 재 보니 아예 다른 물건이었다(2026-09-09, 1024px):

  발행본 15섹션 · 에디터 12섹션 · 겹치는 건 4개뿐, 이름도 달랐다
  (gallery↔photos · location↔map · guide↔local)
  겹치는 4개조차 높이가 달랐다(info 488↔535 · booking 242↔487 · itinerary 881↔383)

소스를 하나로 모은다. 편집·미리보기 둘 다 발행본 렌더러가 그린다.

**데이터도 한 벌** — `GET /v1/place/{id}/site/preview` 가 발행이 굽는 것과 **같은 함수**
(`build_snapshot` → `to_site_payload`)로 payload 를 만든다. DB 도 파일도 건드리지 않는다.

**왜 iframe 인가** — 컴포넌트만 같게 해서는 안 됐다. 미디어 쿼리는 창 폭을 보는데 실제
사이트 폭은 그 안의 프레임이라, 그리드 컬럼 수가 어긋나 섹션이 두 배씩 길어졌다
(festival 2560→6027 · guide 1168→2168). iframe 은 자체 뷰포트를 가져 발행본과 같은 폭을 본다.
폭만이 아니라 **높이도** 준다 — 히어로가 `clamp(24rem, 62vh, 36rem)` 이라 낮은 iframe 에서는
하한에 걸렸다(384 ↔ 발행본 576). 자리에 안 들어가면 transform 으로 줄인다: 크기는 그대로,
그림만 줄여야 미디어 쿼리가 안 흔들린다.

**색·서체도 한 벌** — `themeVars(payload)` · `fontHref(payload)`. 셸에는 발행본 `<head>` 의
폰트 링크가 없어 글자만 기본 산세리프로 떨어졌다(지오메트리는 같은데 픽셀 차이 92%).

**에디터가 저장된 템플릿을 안 읽던 것** — `applyTheme` 이 섹션·색팔레트는 되살리는데
templateId 를 빠뜨렸다. templateId 는 theme JSON 이 아니라 `sites.template_id` **컬럼**이라
저장 경로가 다른데 읽는 쪽이 theme 만 봤다. 사장님이 '옛 항구' 를 골라 발행해도 다시
들어오면 편집 화면만 흰 바탕·고딕이었다.

**고르기는 iframe 안에서** — 같은 오리진이라 안쪽 문서에 직접 리스너를 건다. 어느 섹션인지는
`data-editor-id` 로 안다(화면 id `gallery` ↔ 설정 id `photos`; `display:contents` 라 레이아웃
무영향). 표시는 outline 이다 — 상자 크기를 바꾸지 않아 발행본과 픽셀이 그대로다.

곁들여 정리한 것
- 켤 수 없는 섹션 둘(`pricing`·`planner`)을 뗐다 — 기본표에도 [+섹션 추가]에도 없고 DB 참조 0건.
- 반대로 `event`(소식)는 기본표가 켜서 **발행되는데** 채울 UI 가 없었다. 명세를 넣는다.
  이 아이템만 프롬프트가 "찾아라" 가 아니라 **"옮겨 적어라"** 다 — 이 가게에서 지금 하는
  일이라 모델이 알 수 없고, 지어내면 손님이 없는 행사를 보고 찾아온다.
- 예약 버튼이 "네이버 예약 예약" 이었다. `{bookingLabel} 예약` 을 13개 파일에서 각자 이어
  붙이고 있었다 — `bookingActionLabel()` 하나로 모은다.
- `solution/site` 의 별칭을 `@` → `@site` 로 옮겼다(60파일 195건). 두 앱이 '@' 를 각자 자기
  src 로 두면 발행본 컴포넌트를 빌더에서 부를 때 **조용히 다른 파일을 잡는다.**

검증(Playwright, 같은 사업장·1024px):
  섹션 15 = 15 · 순서 일치 · **한쪽에만 있는 섹션 0개**
  15개 전부 높이·글자 수·제목이 정확히 같다
  편집·미리보기·발행본 셋 다 --tpl-bg #e4dac0 · Gugi
  `/preview` ↔ 발행본 문서 높이 9029 = 9029, 픽셀 차이 2.88%(축제 카드 지연 로딩 타이밍)
tsc -b 통과 · eslint 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:09:28 +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 '@site/lib/site-context';
import {naverSearchUrl} from '@site/lib/format';
import {useLiveSeasons} from '@site/lib/use-live-seasons';
import {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={
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>
);
}