o2o-site-AEO/solution/site/src/sections/items/EventSection.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

333 lines
15 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.

/**
* 소식 — 발행본. 이벤트와 공지를 한 자리에서 받는다.
*
* ★ 인스타에 올라온 것을 **손으로 옮겨 적는다.** 계정 페이지는 로그인·봇 탐지 뒤에 있어
* 읽어 올 수 없고, 우회는 이 레포에서 영구 금지다(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>
);
}