에디터에서 본 화면과 발행된 화면이 달랐다. 렌더러를 두 벌 들고 있었기 때문이다 —
캔버스는 `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>
210 lines
10 KiB
TypeScript
210 lines
10 KiB
TypeScript
import {ArrowUpRight, BedDouble, CalendarCheck, Phone, ShieldCheck} from 'lucide-react';
|
|
import {useSite} from '@site/lib/site-context';
|
|
import {bookingCtaLabel, channelLabel, sectionName, stayBookingView} from '@site/lib/derive';
|
|
import {StayBookingDemo} from './StayBookingDemo';
|
|
|
|
/**
|
|
* 숙박 예약 구성 — 손님이 **이 방을 · 이 값에 · 이 창구로** 예약할 수 있는 자리.
|
|
*
|
|
* ★ 왜 `BookingSection` 과 따로 있나
|
|
* 그쪽이 읽는 fact(`reservation_required`·`reservation_channel`)는 **숙박 스키마에 없다.**
|
|
* 그래서 펜션·민박으로 발행하면 서버 기본표가 켜 두는 "실시간 예약" 섹션에 전화번호
|
|
* 한 줄만 남았다 — 요금도 인원도 취소 규정도 없는 예약 섹션이었다. 숙박은 예약이
|
|
* 곧 매출이고, "얼마예요 / 몇 명까지 / 어떻게 예약해요" 가 이 업종 질의의 대부분이다.
|
|
*
|
|
* ★ 예약을 **처리하지 않는다.** 우리는 빈 방 재고도 결제도 갖지 않는다(PRODUCT.md 6절 —
|
|
* "사이트는 예약 채널로 보낸다. 거래를 품지 않는다"). 그래서 날짜 선택기·예약 폼을
|
|
* 그리지 않는다. 없는 기능을 화면으로 흉내내면 손님은 예약한 줄 알고 안 오고,
|
|
* 그 클레임은 사장님이 받는다. 대신 **예약에 필요한 사실**과 **실제로 예약이 되는 창구**를
|
|
* 한자리에 모은다.
|
|
*
|
|
* ★ 지어낸 값이 없다. 요금·인원·취소 규정은 확인된 fact 뿐이고(`stayBookingView`),
|
|
* 예약 버튼은 확정된 채널 URL 뿐이다. 근거가 하나도 없으면 섹션째 그리지 않는다 —
|
|
* 그 판단도 `stayBookingView()` 한 곳이 한다(상단 내비·하단 탭이 같은 함수를 본다).
|
|
*
|
|
* ★ 기준 요금 숫자는 `unitBaseRate`(seo/jsonld.ts)가 고른 값이다. JSON-LD `makesOffer.price`
|
|
* 와 같은 숫자여야 절대규칙 3(화면 = 구조화 데이터) 대조를 통과한다.
|
|
*/
|
|
export function StayBookingSection() {
|
|
const payload = useSite();
|
|
const view = stayBookingView(payload);
|
|
|
|
if (!view) return null;
|
|
|
|
const {offers, notices, links, contacts, phone} = view;
|
|
|
|
return (
|
|
<section
|
|
id="booking"
|
|
aria-labelledby="booking-heading"
|
|
className="w-full border-b border-black/8 py-16 sm:py-24"
|
|
style={{backgroundColor: 'var(--color-surface-alt)'}}
|
|
>
|
|
<div className="shell">
|
|
<p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50">
|
|
<CalendarCheck className="size-4" />
|
|
<span>Reservation</span>
|
|
</p>
|
|
|
|
<h2
|
|
id="booking-heading"
|
|
className="serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl"
|
|
>
|
|
{sectionName(payload, 'booking', '예약 안내')}
|
|
</h2>
|
|
{/* ★ "여기서 결제되지 않는다"를 먼저 말한다. 예약 버튼을 누른 뒤에 알게 되면
|
|
손님은 속은 것으로 느끼고, 그 인상은 업소가 가져간다. */}
|
|
<p className="mb-8 max-w-2xl text-xs leading-relaxed opacity-60 sm:text-sm">
|
|
빈 방 확인과 결제는 아래 예약 창구에서 진행됩니다. 이 페이지에서는 요금과 이용 조건만
|
|
안내합니다.
|
|
</p>
|
|
|
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5 lg:gap-6">
|
|
{/* ── 객실별 요금 · 인원 ───────────────────────────────── */}
|
|
{offers.length > 0 && (
|
|
<div
|
|
className="overflow-hidden rounded-2xl border border-black/8 lg:col-span-3"
|
|
style={{backgroundColor: 'var(--color-surface)'}}
|
|
>
|
|
<p className="flex items-center gap-2 border-b border-black/8 px-4 py-3 text-xs font-bold sm:px-5">
|
|
<BedDouble className="size-3.5 opacity-50" />
|
|
<span>객실별 요금 · 인원</span>
|
|
</p>
|
|
|
|
<ul className="divide-y divide-black/5">
|
|
{offers.map((offer) => (
|
|
<li key={offer.unitId} className="p-4 sm:p-5">
|
|
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
|
|
<h3 className="serif text-base font-bold sm:text-lg">{offer.name}</h3>
|
|
{offer.baseRateText && (
|
|
<span className="text-sm font-bold" style={{color: 'var(--color-brand)'}}>
|
|
{offer.baseRateText}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{offer.capacityText && (
|
|
<p className="mt-1 text-xs opacity-60">{offer.capacityText}</p>
|
|
)}
|
|
|
|
{offer.rateRows.length > 0 && (
|
|
<dl className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5">
|
|
{offer.rateRows.map((row) => (
|
|
<div key={row.label} className="flex items-baseline gap-1.5 text-xs">
|
|
<dt className="opacity-50">{row.label}</dt>
|
|
<dd className="font-semibold">{row.value}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
)}
|
|
|
|
{/* 사진·전체 스펙은 객실 섹션이 갖고 있다. 한 장 사이트라 앵커다. */}
|
|
<a
|
|
href={offer.href}
|
|
className="mt-3 inline-flex items-center gap-1 text-xs font-semibold underline decoration-black/20 underline-offset-4 transition-opacity hover:opacity-70"
|
|
>
|
|
<span>{offer.name} 사진 · 상세 보기</span>
|
|
</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── 예약 창구 ──────────────────────────────────────── */}
|
|
<div
|
|
className="flex h-fit flex-col gap-3 rounded-2xl border border-black/8 p-4 sm:p-5 lg:col-span-2"
|
|
style={{backgroundColor: 'var(--color-surface)'}}
|
|
>
|
|
<p className="text-xs font-bold">예약 창구</p>
|
|
|
|
{phone && (
|
|
<a
|
|
href={`tel:${phone}`}
|
|
className="flex items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-bold text-white transition-opacity hover:opacity-90"
|
|
style={{backgroundColor: 'var(--color-brand)'}}
|
|
>
|
|
<Phone className="size-4" />
|
|
<span>전화 예약 {phone}</span>
|
|
</a>
|
|
)}
|
|
|
|
{/* ★ 확정된 채널만 나간다 — 확정 전 URL 은 동명 업소의 예약 페이지일 수 있다.
|
|
눌렀는데 남의 숙소가 뜨면 그 예약은 영영 우리 것이 아니다. */}
|
|
{links.map((link) => (
|
|
<a
|
|
key={link.url}
|
|
href={link.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center justify-between gap-2 rounded-xl border border-black/10 px-4 py-3 text-xs font-semibold transition-colors hover:bg-black/5"
|
|
style={{backgroundColor: 'var(--color-surface-alt)'}}
|
|
>
|
|
<span>{bookingCtaLabel(link)}</span>
|
|
<ArrowUpRight className="size-3.5 shrink-0" />
|
|
</a>
|
|
))}
|
|
|
|
{contacts.length > 0 && (
|
|
<div className="mt-1 border-t border-black/8 pt-3">
|
|
<p className="mb-2 text-xs opacity-50">문의</p>
|
|
<ul className="flex flex-wrap gap-2">
|
|
{contacts.map((link) => (
|
|
<li key={link.url}>
|
|
<a
|
|
href={link.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1 rounded-lg border border-black/10 px-2.5 py-1.5 text-xs transition-colors hover:bg-black/5"
|
|
>
|
|
<span>{channelLabel(link)}</span>
|
|
<ArrowUpRight className="size-3" />
|
|
</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{links.length === 0 && (
|
|
// 채널이 하나도 확정되지 않은 업소 — 전화가 유일한 창구다. 그 사실을 말해 준다.
|
|
<p className="text-xs leading-relaxed opacity-55">
|
|
온라인 예약 채널은 등록되지 않았습니다. 예약은 전화로 문의해 주세요.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── 날짜 · 시간 선택 (화면 안에서만 도는 목업) ────────── */}
|
|
<StayBookingDemo />
|
|
|
|
{/* ── 예약 전 확인 ────────────────────────────────────── */}
|
|
{notices.length > 0 && (
|
|
<div
|
|
className="mt-4 overflow-hidden rounded-2xl border border-black/8 lg:mt-6"
|
|
style={{backgroundColor: 'var(--color-surface)'}}
|
|
>
|
|
<p className="flex items-center gap-2 border-b border-black/8 px-4 py-3 text-xs font-bold sm:px-5">
|
|
<ShieldCheck className="size-3.5 opacity-50" />
|
|
<span>예약 전 확인</span>
|
|
</p>
|
|
|
|
{/* 확인된 fact 만 줄이 된다 — "확인 중" 이라는 빈 줄을 그리면 손님은 그걸 규정으로 읽는다. */}
|
|
<dl className="grid grid-cols-1 divide-y divide-black/5 sm:grid-cols-2 sm:divide-y-0">
|
|
{notices.map((row) => (
|
|
<div
|
|
key={row.label}
|
|
className="flex flex-col gap-1 p-4 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sm:p-5"
|
|
>
|
|
<dt className="shrink-0 text-xs opacity-55">{row.label}</dt>
|
|
<dd className="text-xs font-semibold sm:max-w-[60%] sm:text-right">{row.value}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|