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