에디터에서 본 화면과 발행된 화면이 달랐다. 렌더러를 두 벌 들고 있었기 때문이다 —
캔버스는 `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>
333 lines
15 KiB
TypeScript
333 lines
15 KiB
TypeScript
/**
|
||
* 소식 — 발행본. 이벤트와 공지를 한 자리에서 받는다.
|
||
*
|
||
* ★ 인스타에 올라온 것을 **손으로 옮겨 적는다.** 계정 페이지는 로그인·봇 탐지 뒤에 있어
|
||
* 읽어 올 수 없고, 우회는 이 레포에서 영구 금지다(DECISIONS 1-1). 대신 원문 주소를 달아
|
||
* 손님이 정확한 것을 그쪽에서 보게 한다.
|
||
* ★ 이벤트와 공지를 나누지 않는다 (2026-09-04, 사장님 결정)
|
||
* 손님이 볼 곳이 둘로 갈리면 어디를 봐야 하는지 모른다 — '이용 및 예약 안내'와 '예약 안내'가
|
||
* 나란히 서서 겪었던 그 일이다. 한 섹션에 두고 **배지로만** 가른다.
|
||
* ★ 끝난 것도 **HTML 에서 지우지 않는다.** 이 사이트의 존재 이유가 인용이라, 지난 이벤트가
|
||
* 남아 있어야 "이 집은 이런 걸 한다"를 검색·AI 가 읽는다. 화면에서는 빛바랜 전단으로 눕힌다.
|
||
* ★ 진행 여부는 **브라우저에서** 판정한다. 정적으로 구운 페이지는 몇 달을 사는데, 굽는 날의
|
||
* '오늘'을 박으면 12월에도 9월 이벤트가 진행 중으로 뜬다(계절별 코스와 같은 수법).
|
||
* 첫 렌더에는 배지를 안 그린다 — 서버 렌더와 달라지면 하이드레이션이 어긋난다.
|
||
*
|
||
* ★ 생김새는 **게시판에 붙은 전단**이다. 갱지 결 위에 도장 하나, 참여 방법은 절취선 안에.
|
||
* 이 팩의 다른 섹션(가요 다방의 도넛판, 계절별 하루의 승차권)과 같은 규칙이다 —
|
||
* 카드가 무엇을 흉내 내는지 한눈에 보여야 목록이 아니라 물건으로 읽힌다.
|
||
*/
|
||
import {useCallback, useEffect, useState} from 'react';
|
||
import {X} from 'lucide-react';
|
||
import type {EventItem} from '@o2o/shared';
|
||
import {useSite} from '@site/lib/site-context';
|
||
import {sectionItems, sectionName} from '@site/lib/derive';
|
||
import {
|
||
ITEM_ACCENT,
|
||
ITEM_BORDER,
|
||
ITEM_CARD,
|
||
ITEM_INK,
|
||
ITEM_INVERSE_INK,
|
||
ItemSection,
|
||
} from './common';
|
||
|
||
type Phase = 'open' | 'soon' | 'closed';
|
||
|
||
const PHASE_LABEL: Record<Phase, string> = {open: '진행 중', soon: '곧 시작', closed: '종료'};
|
||
|
||
/** 'YYYY-MM-DD' 두 개로 오늘이 어디쯤인지. 문자열 비교로 끝난다 — 시간대 계산이 필요 없다. */
|
||
function phaseOf(item: EventItem, today: string): Phase {
|
||
if (item.endDate && item.endDate < today) return 'closed';
|
||
if (item.startDate && item.startDate > today) return 'soon';
|
||
return 'open';
|
||
}
|
||
|
||
/**
|
||
* 도장 한 칸.
|
||
*
|
||
* ★ 날짜가 지났으면 **종류와 무관하게 '종료'** 다 (2026-09-04, 사장님 지적: "디자인이 서로 맞지 않음")
|
||
* 전에는 kind 가 '공지' 면 무조건 '공지' 를 찍었다. 그런데 작년 축제 안내가 흑백으로 바래
|
||
* 지나간 것처럼 보이면서 도장만 '공지' 라, 카드가 두 가지 말을 동시에 했다.
|
||
* 날짜가 있는 것은 날짜가 말하게 하고, 날짜가 없는 상시 공지만 '공지' 로 남는다.
|
||
*/
|
||
function badgeOf(item: EventItem, phase: Phase | undefined): string | undefined {
|
||
if (item.kind === '공지' && !item.startDate && !item.endDate) return '공지';
|
||
return phase && PHASE_LABEL[phase];
|
||
}
|
||
|
||
/** "9월 1일 – 9월 30일". 끝 날짜가 없으면 상시다. */
|
||
function periodText(item: EventItem): string | undefined {
|
||
const short = (iso?: string) => {
|
||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso ?? '');
|
||
return m ? `${Number(m[2])}월 ${Number(m[3])}일` : undefined;
|
||
};
|
||
const from = short(item.startDate);
|
||
const to = short(item.endDate);
|
||
// 하루짜리 행사는 "8월 16일 – 8월 16일" 이 된다 — 같은 날이면 한 번만 적는다.
|
||
if (from && to) return from === to ? from : `${from} – ${to}`;
|
||
if (from) return `${from}부터`;
|
||
if (to) return `${to}까지`;
|
||
return undefined;
|
||
}
|
||
|
||
export function EventSection() {
|
||
const payload = useSite();
|
||
const parsed = sectionItems<EventItem>(payload, 'event');
|
||
|
||
const [today, setToday] = useState<string>();
|
||
// 'sv-SE' 로케일이 'YYYY-MM-DD' 를 준다 — toISOString 은 UTC 라 한국 자정 뒤 하루가 밀린다.
|
||
useEffect(() => setToday(new Date().toLocaleDateString('sv-SE')), []);
|
||
|
||
/*
|
||
* ★ 눌러서 펴는 창 (2026-09-04, 사장님 지시)
|
||
* 공지는 지켜야 할 것이 여러 줄이라 카드 안에 다 담으면 카드가 세로로 길어지고,
|
||
* 그러면 옆 카드까지 같이 늘어나 섹션이 화면 몇 개가 된다. 카드는 요약만 세우고
|
||
* 전문은 창에서 편다. **본문은 카드 DOM 에 이미 다 있다**(line-clamp 로 시각만 자름) —
|
||
* 창이 닫혀 있어도 검색·AI 는 전문을 읽는다.
|
||
*/
|
||
const [open, setOpen] = useState<number | null>(null);
|
||
const close = useCallback(() => setOpen(null), []);
|
||
|
||
// 창이 열려 있는 동안 Esc 로 닫고 뒤 배경 스크롤을 잠근다 — 안 잠그면 창 위에서 뒤가 밀린다.
|
||
useEffect(() => {
|
||
if (open === null) return;
|
||
const onKey = (event: KeyboardEvent) => {
|
||
if (event.key === 'Escape') close();
|
||
};
|
||
const previous = document.body.style.overflow;
|
||
document.body.style.overflow = 'hidden';
|
||
window.addEventListener('keydown', onKey);
|
||
return () => {
|
||
document.body.style.overflow = previous;
|
||
window.removeEventListener('keydown', onKey);
|
||
};
|
||
}, [open, close]);
|
||
|
||
if (parsed.items.length === 0) return null;
|
||
|
||
const shown = open === null ? undefined : parsed.items[open];
|
||
const shownPhase = shown && today ? phaseOf(shown, today) : undefined;
|
||
|
||
return (
|
||
<ItemSection
|
||
id="event"
|
||
name={parsed.title || sectionName(payload, 'event', `${payload.place.name} 소식`)}
|
||
subtitle={parsed.subtitle}
|
||
link={parsed.linkUrl ? {url: parsed.linkUrl, label: parsed.linkLabel ?? '더 보기'} : undefined}
|
||
count="자세한 내용과 최신 소식은 원문에서 확인해 주세요."
|
||
>
|
||
{/*
|
||
★ 키를 맞춘다 (2026-09-04, 사장님 지적: "디자인이 서로 맞지 않음 · 통일되게")
|
||
한때 items-start 로 제각각 두었다 — 전단은 원래 크기가 다르니까. 그런데 사진 있는 카드와
|
||
없는 카드가 섞이자 줄마다 아래가 들쭉날쭉해져서 게시판이 아니라 어질러진 것으로 보였다.
|
||
같은 줄에서는 키를 맞추고, 대신 **카드 안 순서를 사진 유무와 무관하게 똑같이** 만든다.
|
||
*/}
|
||
<ul className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||
{parsed.items.map((item, index) => {
|
||
const phase = today ? phaseOf(item, today) : undefined;
|
||
const badge = badgeOf(item, phase);
|
||
const period = periodText(item);
|
||
const done = phase === 'closed';
|
||
|
||
return (
|
||
<li
|
||
key={`${item.title}-${index}`}
|
||
className="w4-paper relative flex flex-col border-2"
|
||
style={{
|
||
backgroundColor: ITEM_CARD,
|
||
borderColor: ITEM_BORDER,
|
||
// 지난 전단은 빛이 바랜다. 지우지는 않는다 — 문서에는 그대로 남아 있다.
|
||
opacity: done ? 0.6 : 1,
|
||
boxShadow: done ? 'none' : '3px 3px 0 color-mix(in oklab, currentcolor 12%, transparent)',
|
||
}}
|
||
>
|
||
{/* 카드 전체가 누르는 자리다 — 글은 그대로 읽히고 위에 투명한 버튼만 덮는다. */}
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen(index)}
|
||
aria-label={`${item.title} 자세히 보기`}
|
||
className="absolute inset-0 z-20 cursor-pointer"
|
||
/>
|
||
|
||
{/*
|
||
★ 사진 자리를 활자 판으로 채워 봤다가 접었다 (2026-09-04)
|
||
주변 안내 카드처럼 "사진 없으면 이름 활자"를 넣었더니, 바로 아래 <h3> 와
|
||
같은 문장이 두 번 서서 카드 절반이 제목이 됐다. 공지는 **읽을 글이 많은 쪽**이라
|
||
남는 자리를 장식이 아니라 본문으로 채우는 게 맞다 — 아래 line-clamp 를 늘린다.
|
||
*/}
|
||
<div
|
||
className={item.imageUrl ? 'relative overflow-hidden border-b-2' : 'contents'}
|
||
style={item.imageUrl ? {borderColor: ITEM_BORDER} : undefined}
|
||
>
|
||
{item.imageUrl ? (
|
||
<img
|
||
src={item.imageUrl}
|
||
alt={`${item.title} 안내 이미지`}
|
||
loading="lazy"
|
||
decoding="async"
|
||
className="aspect-4/3 w-full object-cover"
|
||
// 끝난 것은 흑백으로. 진행 중인 것 하나만 색을 갖는다.
|
||
style={done ? {filter: 'grayscale(1)'} : undefined}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="flex flex-1 flex-col gap-2.5 p-5">
|
||
{/*
|
||
도장과 날짜가 한 줄. ★ 예전에는 도장을 사진 위 왼쪽 어깨에 절대배치했는데,
|
||
사진 없는 카드에서는 그 자리가 빈 여백이라 두 종류 카드의 생김새가 갈렸다.
|
||
본문 첫 줄로 내리면 사진이 있든 없든 **읽는 순서가 똑같다**.
|
||
★ 살짝 기울인다 — 손으로 찍은 도장은 반듯하지 않다. 각도가 커지면 장난이 된다.
|
||
*/}
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{badge && (
|
||
<span
|
||
className="border-2 px-2 py-0.5 text-[11px] font-bold tracking-[0.08em]"
|
||
style={{
|
||
transform: 'rotate(-3deg)',
|
||
backgroundColor: phase === 'open' && badge !== '공지' ? ITEM_ACCENT : ITEM_CARD,
|
||
color: phase === 'open' && badge !== '공지' ? ITEM_INVERSE_INK : ITEM_INK,
|
||
borderColor: phase === 'open' && badge !== '공지' ? ITEM_ACCENT : ITEM_BORDER,
|
||
}}
|
||
>
|
||
{badge}
|
||
</span>
|
||
)}
|
||
{period && (
|
||
<span className="text-[11px] font-semibold tabular-nums opacity-55">{period}</span>
|
||
)}
|
||
</div>
|
||
|
||
<h3 className="serif text-lg font-bold leading-snug">{item.title}</h3>
|
||
|
||
{item.summary && (
|
||
<p className="line-clamp-2 text-[13px] leading-relaxed opacity-80">{item.summary}</p>
|
||
)}
|
||
|
||
{/* 참여 방법은 절취선 안에 — 손님이 오려서 들고 오는 칸이라는 뜻이다. */}
|
||
{item.howTo && (
|
||
<p
|
||
className="border-2 border-dashed px-3 py-2 text-[12px] leading-relaxed"
|
||
style={{borderColor: ITEM_BORDER}}
|
||
>
|
||
{item.howTo}
|
||
</p>
|
||
)}
|
||
|
||
{/*
|
||
★ 긴 본문은 카드에서 잘라 보인다 — **자르는 건 화면뿐이고 DOM 에는 다 있다**
|
||
(line-clamp 는 시각 처리다). 검색·AI 는 전문을 읽고, 손님은 눌러서 편다.
|
||
*/}
|
||
{item.body && (
|
||
<p
|
||
className={`whitespace-pre-line text-[12px] leading-relaxed opacity-70 ${
|
||
item.imageUrl ? 'line-clamp-3' : 'line-clamp-[9]'
|
||
}`}
|
||
>
|
||
{item.body}
|
||
</p>
|
||
)}
|
||
|
||
<p className="mt-auto pt-2 text-[12px] font-bold opacity-70">자세히 보기 →</p>
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
|
||
{shown && (
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={shown.title}
|
||
className="fixed inset-0 z-50 flex items-end justify-center bg-black/70 p-0 backdrop-blur-sm sm:items-center sm:p-6"
|
||
onClick={close}
|
||
>
|
||
<article
|
||
onClick={(event) => event.stopPropagation()}
|
||
className="w4-paper max-h-[88vh] w-full max-w-2xl overflow-y-auto border-2"
|
||
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER, color: ITEM_INK}}
|
||
>
|
||
{shown.imageUrl && (
|
||
<img
|
||
src={shown.imageUrl}
|
||
alt={`${shown.title} 안내 이미지`}
|
||
className="max-h-[46vh] w-full object-contain"
|
||
style={{backgroundColor: 'color-mix(in oklab, currentcolor 6%, transparent)'}}
|
||
/>
|
||
)}
|
||
|
||
<div className="space-y-4 p-6 sm:p-8">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{badgeOf(shown, shownPhase) && (
|
||
<span
|
||
className="border-2 px-2.5 py-1 text-[11px] font-bold tracking-[0.08em]"
|
||
style={{borderColor: ITEM_BORDER}}
|
||
>
|
||
{badgeOf(shown, shownPhase)}
|
||
</span>
|
||
)}
|
||
{periodText(shown) && (
|
||
<span className="text-[12px] font-semibold tabular-nums opacity-60">
|
||
{periodText(shown)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<h3 className="serif text-[length:var(--fs-h3)] font-bold leading-snug">
|
||
{shown.title}
|
||
</h3>
|
||
|
||
{shown.summary && (
|
||
<p className="text-[length:var(--fs-sm)] leading-relaxed opacity-85">
|
||
{shown.summary}
|
||
</p>
|
||
)}
|
||
|
||
{shown.howTo && (
|
||
<p
|
||
className="border-2 border-dashed px-4 py-3 text-[length:var(--fs-sm)] leading-relaxed"
|
||
style={{borderColor: ITEM_BORDER}}
|
||
>
|
||
{shown.howTo}
|
||
</p>
|
||
)}
|
||
|
||
{/* 줄바꿈을 살린다 — 공지는 항목이 줄로 갈려 있어야 읽힌다. */}
|
||
{shown.body && (
|
||
<p className="whitespace-pre-line text-[length:var(--fs-sm)] leading-relaxed opacity-85">
|
||
{shown.body}
|
||
</p>
|
||
)}
|
||
|
||
<div className="flex items-center justify-between gap-4 pt-2">
|
||
{shown.postUrl ? (
|
||
<a
|
||
href={shown.postUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-[length:var(--fs-sm)] font-bold underline-offset-4 hover:underline"
|
||
>
|
||
원문 보기 →
|
||
</a>
|
||
) : (
|
||
<span />
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={close}
|
||
className="tap inline-flex items-center gap-1.5 border-2 px-4 text-[length:var(--fs-sm)] font-bold"
|
||
style={{borderColor: ITEM_BORDER}}
|
||
>
|
||
<X className="size-4" />
|
||
<span>닫기</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
)}
|
||
</ItemSection>
|
||
);
|
||
}
|