에디터에서 본 화면과 발행된 화면이 달랐다. 렌더러를 두 벌 들고 있었기 때문이다 —
캔버스는 `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>
203 lines
7.9 KiB
TypeScript
203 lines
7.9 KiB
TypeScript
import {useSite} from '@site/lib/site-context';
|
|
import {bookingActionLabel, bookingLinks, heroFacts, lowestPrice} from '@site/lib/derive';
|
|
|
|
/**
|
|
* 파스텔의 첫 화면 — **연노랑 판 위에 둥근 사진**.
|
|
*
|
|
* ★ 사진을 쓴다 (2026-09-03, 두 번째 반려)
|
|
* 처음엔 상호를 담은 CSS 배지를 벽지처럼 깔았다. 레퍼런스(아이스크림 브랜드)는 그게 맞다 —
|
|
* **그쪽은 파는 게 브랜드고, 여기는 파는 게 집이다.** 손님이 첫 화면에서 보고 싶은 건
|
|
* 상호를 반복한 무늬가 아니라 방이 어떻게 생겼는가다. 파스텔은 사진을 감싸는 판으로 남기고,
|
|
* 원 무늬는 사진 뒤에 몇 개만 흘려 이 안의 결만 유지한다.
|
|
* ★ 사진 모서리를 크게 굴린다(--tpl-radius 가 24px 인 안이다). 각진 사진 하나만 딱딱해 보인다.
|
|
* ★ 장식 원은 aria-hidden 이고 사진 **뒤**에 둔다. 사진을 가리면 장식이 아니라 방해다.
|
|
*/
|
|
export function Hero() {
|
|
const payload = useSite();
|
|
const {place, narrative} = payload;
|
|
const facts = heroFacts(payload);
|
|
const price = lowestPrice(payload);
|
|
const links = bookingLinks(payload);
|
|
const line = narrative.tagline ?? narrative.summary;
|
|
const locality = place.addressLocality ?? place.addressRegion;
|
|
|
|
// 대표 사진이 먼저. og:image 와 첫 장이 다르면 공유 카드와 화면이 어긋난다.
|
|
const shots = [...payload.media]
|
|
.filter((image) => image.alt?.trim())
|
|
.sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary))
|
|
.slice(0, 3);
|
|
|
|
// id="top" — 상단 워드마크와 하단 바가 여기로 돌아온다.
|
|
return (
|
|
<section id="top" data-pastel="own" className="w-full">
|
|
{/* 판 1 — 연노랑 바탕. 왼쪽에 말, 오른쪽에 사진. */}
|
|
<div className="relative w-full overflow-hidden" style={{backgroundColor: 'var(--pastel-1)'}}>
|
|
{/* 장식 원 — 사진 뒤에서 살짝만 삐져나온다. 화면 밖으로 잘려도 된다. */}
|
|
<div aria-hidden className="pointer-events-none absolute inset-0">
|
|
<span
|
|
className="absolute rounded-full"
|
|
style={{
|
|
width: 'clamp(12rem, 26vw, 22rem)',
|
|
aspectRatio: '1',
|
|
backgroundColor: 'var(--pastel-2)',
|
|
right: '-6%',
|
|
top: '-6%',
|
|
opacity: 0.75,
|
|
}}
|
|
/>
|
|
<span
|
|
className="absolute rounded-full"
|
|
style={{
|
|
width: 'clamp(8rem, 18vw, 15rem)',
|
|
aspectRatio: '1',
|
|
backgroundColor: 'var(--pastel-3)',
|
|
left: '46%',
|
|
bottom: '-10%',
|
|
opacity: 0.7,
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
className="shell relative grid items-center gap-8 lg:grid-cols-2 lg:gap-14"
|
|
style={{paddingBlock: 'var(--section-space)'}}
|
|
>
|
|
<div className="order-2 lg:order-1">
|
|
{locality && (
|
|
<p
|
|
className="inline-flex items-center rounded-full px-3 py-1 text-[length:var(--fs-xs)] font-extrabold"
|
|
style={{backgroundColor: 'var(--tpl-bg, #fff)', color: 'var(--pastel-ink)'}}
|
|
>
|
|
{locality}
|
|
</p>
|
|
)}
|
|
|
|
{/* h1 은 페이지에 하나. 가게 이름이 들어가야 "○○ 홈페이지" 질의에 잡힌다. */}
|
|
<h1
|
|
className="mt-4 break-keep"
|
|
style={{
|
|
fontSize: 'var(--fs-display)',
|
|
fontWeight: 800,
|
|
lineHeight: 1.2,
|
|
letterSpacing: 'var(--tpl-heading-tracking, -0.03em)',
|
|
}}
|
|
>
|
|
{place.name}
|
|
</h1>
|
|
|
|
{line && (
|
|
<p className="measure mt-4 text-[length:var(--fs-lead)] font-medium leading-relaxed opacity-80">
|
|
{line}
|
|
</p>
|
|
)}
|
|
|
|
{links.length > 0 && (
|
|
<a
|
|
href={links[0].url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="tap mt-7 inline-flex items-center justify-center rounded-full px-8 text-[length:var(--fs-sm)] font-extrabold transition-opacity hover:opacity-90"
|
|
style={{backgroundColor: 'var(--pastel-ink)', color: 'var(--tpl-bg, #fff)'}}
|
|
>
|
|
{bookingActionLabel(links[0])}
|
|
</a>
|
|
)}
|
|
</div>
|
|
|
|
<div className="order-1 lg:order-2">
|
|
{shots[0] && (
|
|
<div
|
|
className="relative w-full overflow-hidden"
|
|
style={{
|
|
aspectRatio: '1 / 1',
|
|
borderRadius: 'calc(var(--tpl-radius, 24px) * 1.6)',
|
|
// 그림자 대신 흰 테두리로 띄운다 — 이 안의 규칙이다.
|
|
boxShadow: '0 0 0 clamp(6px, 1.4vw, 12px) var(--tpl-bg, #fff)',
|
|
}}
|
|
>
|
|
<img
|
|
src={shots[0].url}
|
|
alt={shots[0].alt}
|
|
fetchPriority="high"
|
|
decoding="async"
|
|
className="size-full object-cover"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 나머지 두 장은 아래 작게. 사진 문장(alt)이 문서에서 사라지지 않게 남긴다. */}
|
|
{shots.length > 1 && (
|
|
<ul className="mt-3 grid grid-cols-2 gap-3">
|
|
{shots.slice(1).map((image) => (
|
|
<li
|
|
key={image.mediaId}
|
|
className="relative overflow-hidden"
|
|
style={{
|
|
aspectRatio: '4 / 3',
|
|
borderRadius: 'var(--tpl-radius, 24px)',
|
|
boxShadow: '0 0 0 clamp(4px, 1vw, 8px) var(--tpl-bg, #fff)',
|
|
}}
|
|
>
|
|
<img
|
|
src={image.url}
|
|
alt={image.alt}
|
|
loading="lazy"
|
|
decoding="async"
|
|
className="size-full object-cover"
|
|
/>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/*
|
|
판 2 — 가격 · 핵심 값.
|
|
★ 국내 숙박 사이트에서 첫 화면이 답해야 하는 건 셋이다: 얼마인가 · 언제 드나 ·
|
|
어디서 예약하나. 예약은 위 버튼이 받고 나머지 둘이 이 띠다.
|
|
값이 하나도 없으면(수집 전 가게) 빈 칸 네 개 대신 판을 지운다.
|
|
*/}
|
|
{(price || facts.length > 0) && (
|
|
<div className="w-full" style={{backgroundColor: 'var(--pastel-2)'}}>
|
|
<dl className="shell flex flex-wrap items-center justify-center gap-x-12 gap-y-5 py-8 text-center">
|
|
{price && (
|
|
<div>
|
|
<dt
|
|
className="text-[length:var(--fs-xs)] font-extrabold"
|
|
style={{color: 'var(--tpl-bg, #fff)', opacity: 0.85}}
|
|
>
|
|
최저가
|
|
</dt>
|
|
<dd
|
|
className="mt-1 font-extrabold tabular-nums"
|
|
style={{fontSize: 'var(--fs-h3)', color: 'var(--tpl-bg, #fff)'}}
|
|
>
|
|
{price}
|
|
</dd>
|
|
</div>
|
|
)}
|
|
{facts.map((row) => (
|
|
<div key={row.label}>
|
|
<dt
|
|
className="text-[length:var(--fs-xs)] font-extrabold"
|
|
style={{color: 'var(--tpl-bg, #fff)', opacity: 0.85}}
|
|
>
|
|
{row.label}
|
|
</dt>
|
|
<dd
|
|
className="mt-1 font-extrabold"
|
|
style={{fontSize: 'var(--fs-h3)', color: 'var(--tpl-bg, #fff)'}}
|
|
>
|
|
{row.value}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|