에디터에서 본 화면과 발행된 화면이 달랐다. 렌더러를 두 벌 들고 있었기 때문이다 —
캔버스는 `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>
179 lines
7.6 KiB
TypeScript
179 lines
7.6 KiB
TypeScript
import {Phone} from 'lucide-react';
|
|
import {useSite} from '@site/lib/site-context';
|
|
import {bookingActionLabel, bookingLinks, essentialRows, RULE_FACT_KEYS} from '@site/lib/derive';
|
|
import {formatKoreanDate} from '@site/lib/format';
|
|
import type {InfoRow} from '@site/lib/derive';
|
|
import {Section} from '@site/lib/ui';
|
|
|
|
/**
|
|
* 이용 정보 표 전체.
|
|
*
|
|
* AnswerBlock 이 상위 6개만 보여준다면 여기는 확인된 것 전부를 낸다.
|
|
* ★ "확인 안 된 항목이 N개 있다"를 숨기지 않는다 — 없는 척하면
|
|
* 손님이 다른 데서 틀린 값을 찾아 온다. 있다고 말하고 문의로 보낸다.
|
|
*
|
|
* ★ 이용 규정을 여기로 들여왔다 (2026-09-03)
|
|
* 따로 '이용 규정' 섹션이 있었는데, 그 줄(체크인·체크아웃·취소규정·취사·반려동물·흡연·
|
|
* 추가요금)이 **이 표의 완전한 부분집합**이었다 — 같은 fact 를 두 섹션이 두 번 그렸다.
|
|
* 나눈 이유는 "취소·환불은 몰랐다가 클레임이 나니 따로 세운다"였는데, 양쪽에 다 나오니
|
|
* 그 목적도 못 이루고 중복만 남았다. 한 섹션 안에서 **규정만 따로 묶어** 눈에 띄게 한다.
|
|
*
|
|
* ★ 카드 격자를 버리고 **줄 목록 하나**로 통일했다 (2026-09-07, 사장님 지시: "박스라서 가독성이 떨어진다")
|
|
* 값이 짧으면 항목마다 테두리 상자를 세워 2~4열로 벌렸는데, 상자가 상자 안에 들어가
|
|
* 테두리가 두 겹이 되고 — 정작 읽어야 할 것은 라벨과 값 한 쌍뿐인데 — 눈이 상자를 먼저 셌다.
|
|
* 값의 길이로 열 수를 바꾸는 규칙도 같이 버린다. 같은 섹션 안에서 규정 덩이는 목록,
|
|
* 시설 덩이는 격자로 갈리니 한 화면에 두 가지 문법이 섰다.
|
|
* 이용안내는 훑는 화면이다 — 라벨이 한 열에 세로로 정렬돼야 값끼리 비교가 된다.
|
|
|
|
/**
|
|
* 규정으로 묶을 항목의 이름.
|
|
*
|
|
* ★ `essentialRows` 는 key 가 아니라 사람이 읽는 label 을 준다(업종 스키마가 붙인 이름).
|
|
* 그래서 key 목록이 아니라 label 로 가른다 — 스키마가 이름을 바꾸면 여기도 같이 고친다.
|
|
*/
|
|
const RULE_LABELS = new Set([
|
|
'체크인 시간',
|
|
'체크아웃 시간',
|
|
'취소·환불 규정',
|
|
'취사 가능',
|
|
'반려동물 동반',
|
|
'흡연 가능',
|
|
'인원 추가 요금',
|
|
]);
|
|
export function EssentialInfoSection() {
|
|
const payload = useSite();
|
|
const rows = essentialRows(payload);
|
|
const hiddenCount = payload.facts.filter(
|
|
(fact) => fact.scope === 'place' && !rows.some((row) => row.label === fact.label),
|
|
).length;
|
|
|
|
if (rows.length === 0) return null;
|
|
|
|
/**
|
|
* 규정과 나머지를 가른다.
|
|
*
|
|
* ★ 순서를 뒤집지 않는다 — 규정이 먼저다. 예약 전에 알아야 클레임이 안 난다.
|
|
*/
|
|
const ruleRows = rows.filter((row) => RULE_LABELS.has(row.label));
|
|
const otherRows = rows.filter((row) => !RULE_LABELS.has(row.label));
|
|
|
|
return (
|
|
<Section
|
|
id="info"
|
|
tone="alt"
|
|
/* ★ 예약 섹션을 여기로 합쳤다 (2026-09-03, 사장님 지시)
|
|
"이용 및 예약 안내" 와 "예약 안내" 두 섹션이 목차에 나란히 떠서 손님은 어느 쪽에서
|
|
예약하는지 몰랐다. 이용 정보를 읽고 그 자리에서 바로 누르는 게 맞다. */
|
|
title="이용안내 및 예약"
|
|
lead="방문 전 확인이 필요한 운영 규정과 시설 안내입니다."
|
|
footnote={
|
|
<span className="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
|
<span>
|
|
{hiddenCount > 0
|
|
? `확인 중인 항목 ${hiddenCount}개는 표시하지 않았습니다. 필요하시면 전화로 문의해 주세요.`
|
|
: '모든 항목이 사업자 확인을 거쳤습니다.'}
|
|
</span>
|
|
<span className="font-medium">{formatKoreanDate(payload.site.updatedAt)} 기준</span>
|
|
</span>
|
|
}
|
|
>
|
|
<div className="space-y-8">
|
|
{ruleRows.length > 0 && <Rows title="예약 전 확인" rows={ruleRows} emphasis />}
|
|
{otherRows.length > 0 && <Rows title="시설 · 편의" rows={otherRows} />}
|
|
<BookingRow />
|
|
</div>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 줄 목록 한 덩이.
|
|
*
|
|
* ★ 값이 길면 카드 격자가 아니라 **표**다. 1열 격자에 카드를 쌓으면 카드 하나가
|
|
* 1200px 폭을 가로지르고 오른쪽이 통째로 빈다 — 국내 사이트의 이용안내는 예외 없이
|
|
* 한 판 안의 줄 목록이다.
|
|
* ★ 규정 덩이(emphasis)는 강조색 선을 왼쪽에 세운다. 몰랐다가 클레임이 나는 항목이다.
|
|
*/
|
|
function Rows({title, rows, emphasis}: {title: string; rows: InfoRow[]; emphasis?: boolean}) {
|
|
return (
|
|
<section>
|
|
<h3
|
|
className="border-line mb-3 flex items-center gap-2 border-b pb-2 text-[length:var(--fs-sm)] font-bold"
|
|
>
|
|
{emphasis && (
|
|
<i
|
|
aria-hidden
|
|
className="h-[1em] w-[3px] shrink-0"
|
|
style={{backgroundColor: 'var(--color-accent)'}}
|
|
/>
|
|
)}
|
|
<span>{title}</span>
|
|
</h3>
|
|
|
|
{/* ★ 테두리는 판 하나에만 준다. 줄 사이는 선(divide)이지 상자가 아니다. */}
|
|
<dl className="divide-line divide-y">
|
|
{rows.map((row) => (
|
|
<div
|
|
key={row.label}
|
|
className="flex flex-col gap-1 py-3.5 sm:flex-row sm:items-baseline sm:gap-6"
|
|
>
|
|
<dt className="text-muted text-[length:var(--fs-sm)] sm:w-40 sm:shrink-0">
|
|
{row.label}
|
|
</dt>
|
|
{/* measure: 긴 규정 문장이 화면 끝까지 늘어나면 다음 줄 첫 글자를 못 찾는다. */}
|
|
<dd className="measure text-[length:var(--fs-sm)] font-semibold">
|
|
{row.value}
|
|
{row.note && <span className="text-muted mt-0.5 block font-normal">{row.note}</span>}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 예약 창구 — 전화와 확정 채널.
|
|
*
|
|
* ★ 따로 서 있던 '예약 안내' 섹션을 이 자리로 들여왔다. 규정을 읽은 **바로 그 자리**에
|
|
* 누를 것이 있어야 한다 — 아래로 한 번 더 내려가야 하면 절반은 안 내려간다.
|
|
* ★ 확정된 채널만 나간다. 확정 전 URL 은 동명 업소의 예약 페이지일 수 있다.
|
|
*/
|
|
function BookingRow() {
|
|
const payload = useSite();
|
|
const links = bookingLinks(payload);
|
|
const phone = payload.place.phone;
|
|
if (!phone && links.length === 0) return null;
|
|
|
|
return (
|
|
<div className="border-line flex flex-col gap-3 border-t pt-6 sm:flex-row sm:items-center sm:justify-between">
|
|
<p className="text-[length:var(--fs-sm)] font-semibold">
|
|
{payload.place.name} 예약은 아래로 받습니다.
|
|
</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{phone && (
|
|
<a
|
|
href={`tel:${phone}`}
|
|
className="tap border-line tpl-border inline-flex items-center justify-center gap-1.5 rounded-lg border px-4 text-[length:var(--fs-sm)] font-semibold"
|
|
>
|
|
<Phone className="size-4" />
|
|
<span>{phone}</span>
|
|
</a>
|
|
)}
|
|
{links.map((link) => (
|
|
<a
|
|
key={link.url}
|
|
href={link.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="tap inline-flex items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90"
|
|
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
|
|
>
|
|
{bookingActionLabel(link)}
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|