에디터에서 본 화면과 발행된 화면이 달랐다. 렌더러를 두 벌 들고 있었기 때문이다 —
캔버스는 `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>
163 lines
6.6 KiB
TypeScript
163 lines
6.6 KiB
TypeScript
import {useSite} from '@site/lib/site-context';
|
|
import {primaryImage} from '@site/seo/meta';
|
|
import {
|
|
bookingActionLabel,
|
|
bookingLinks,
|
|
contactLinks,
|
|
channelLabel,
|
|
heroFacts,
|
|
lowestPrice,
|
|
unitSpec,
|
|
unitViews,
|
|
} from '@site/lib/derive';
|
|
import {GRAIN} from './SectionHead';
|
|
|
|
/**
|
|
* 편집(잡지)형 첫 화면 — 표지.
|
|
*
|
|
* ★ 사진은 오른쪽에 크게 걸고, 상호는 그 경계를 넘어 왼쪽 여백까지 흘러나온다.
|
|
* 겹침은 12칸 격자 위에서 만든다 — 사진과 상호 띠를 **같은 행 같은 칸**에 놓고
|
|
* 띠만 아래로 붙였다(absolute 를 쓰지 않아 모바일에서 자동으로 위아래로 풀린다).
|
|
* ★ 상호를 사진에 맨몸으로 얹지 않는다. 어느 사진의 어느 쪽이 밝은지 우리는 모른다 —
|
|
* 어두운 띠를 한 겹 깔고 그 위에 얹으면 어떤 사진에서도 대비가 남는다(88% 기준
|
|
* 흰 사진 위 최악값이 약 3.9:1, 큰 글자 기준 3:1 을 넘긴다).
|
|
* ★ 태그라인·요금·이용 정보는 사진 위가 아니라 **왼쪽 종이 면**에 둔다. 작은 글자는
|
|
* 3:1 로 부족하고(4.5:1 이 기준이다), 무엇보다 인용되는 문장을 사진에 태우면 안 된다.
|
|
*/
|
|
export function Hero() {
|
|
const payload = useSite();
|
|
const {place, narrative} = payload;
|
|
const image = primaryImage(payload);
|
|
const spec = unitSpec(payload);
|
|
const units = unitViews(payload);
|
|
const facts = heroFacts(payload);
|
|
const price = lowestPrice(payload);
|
|
const booking = bookingLinks(payload)[0];
|
|
const contact = contactLinks(payload)[0];
|
|
const tagline = narrative.tagline ?? narrative.heroSubline;
|
|
// 검색창에 치는 이름을 먼저 쓴다 — "제주시" 보다 "애월" 이다.
|
|
const locality = place.addressSubLocality ?? place.addressLocality ?? place.addressRegion;
|
|
|
|
// 메타 한 줄 — 지역 · 객실 수 · 최저가. 확인된 값만 · 로 잇는다.
|
|
const meta = [
|
|
locality,
|
|
units.length > 0 ? `${spec.label} ${units.length}개` : undefined,
|
|
price,
|
|
].filter((part): part is string => Boolean(part));
|
|
|
|
return (
|
|
<section id="top" className="relative w-full" style={{backgroundImage: GRAIN}}>
|
|
<div className="grid grid-cols-12">
|
|
{image && (
|
|
<div
|
|
className="relative col-span-12 xl:col-span-8 xl:col-start-5 xl:row-start-1"
|
|
style={{minHeight: 'clamp(15rem, 52vh, 34rem)', backgroundColor: 'var(--tpl-inverse)'}}
|
|
>
|
|
<img
|
|
src={image.url}
|
|
alt={image.alt}
|
|
// 첫 화면 이미지는 지연 로딩하지 않는다 — LCP 가 그만큼 늦어진다.
|
|
fetchPriority="high"
|
|
decoding="async"
|
|
width={image.width}
|
|
height={image.height}
|
|
className="absolute inset-0 size-full object-cover"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/*
|
|
상호 띠 — 데스크톱에서는 격자 1~12칸 같은 행에 놓고 아래에 붙인다(self-end).
|
|
사진이 5~12칸이므로 왼쪽 4칸은 종이 위, 나머지는 사진 위를 지난다.
|
|
★ z-10: 격자에서 뒤에 오는 형제(왼쪽 칸)가 이 띠를 덮지 않게 한다.
|
|
*/}
|
|
<div
|
|
className="z-10 col-span-12 xl:col-start-1 xl:row-start-1 xl:mb-12 xl:mr-16 xl:self-end"
|
|
style={{
|
|
backgroundColor: 'color-mix(in srgb, var(--tpl-inverse) 88%, transparent)',
|
|
color: 'var(--tpl-bg)',
|
|
}}
|
|
>
|
|
{/* h1 은 페이지에 하나. 가게 이름이 들어가야 "○○ 홈페이지" 질의에 잡힌다. */}
|
|
<h1
|
|
className="serif px-4 py-5 sm:px-6 xl:py-8 xl:pl-10 xl:pr-16"
|
|
style={{
|
|
fontSize: 'clamp(2.25rem, 7.5vw, 5.5rem)',
|
|
fontWeight: 800,
|
|
lineHeight: 0.98,
|
|
letterSpacing: '-0.035em',
|
|
}}
|
|
>
|
|
{place.name}
|
|
</h1>
|
|
</div>
|
|
|
|
{/* 왼쪽 종이 면 — 읽는 값이 전부 여기 있다. 아래는 상호 띠가 지나가는 자리다. */}
|
|
{/* pb-56 은 상호 띠 높이(제목 5.5rem + 여백)에서 나온 값이다 — 줄이면 글자가 겹친다. */}
|
|
<div className="col-span-12 flex flex-col gap-5 px-4 py-8 sm:px-6 xl:col-span-4 xl:col-start-1 xl:row-start-1 xl:py-12 xl:pb-56 xl:pl-10 xl:pr-8">
|
|
{tagline && (
|
|
<p className="measure font-serif text-[length:var(--fs-lead)] leading-relaxed">
|
|
{tagline}
|
|
</p>
|
|
)}
|
|
|
|
{meta.length > 0 && (
|
|
<p className="text-[length:var(--fs-sm)] font-semibold tabular-nums">
|
|
{meta.join(' · ')}
|
|
</p>
|
|
)}
|
|
|
|
{/* 버튼을 세우지 않는다 — 잡지의 규칙은 밑줄 링크다. 대신 .tap 으로 높이를 지킨다. */}
|
|
{(booking || place.phone || contact) && (
|
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-1 text-[length:var(--fs-sm)] font-bold">
|
|
{booking && (
|
|
<a
|
|
href={booking.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="tap inline-flex items-center underline underline-offset-4"
|
|
>
|
|
{bookingActionLabel(booking)}
|
|
</a>
|
|
)}
|
|
{place.phone && (
|
|
<a
|
|
href={`tel:${place.phone}`}
|
|
className="tap inline-flex items-center underline underline-offset-4"
|
|
>
|
|
{place.phone}
|
|
</a>
|
|
)}
|
|
{contact && (
|
|
<a
|
|
href={contact.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="tap inline-flex items-center underline underline-offset-4"
|
|
>
|
|
{channelLabel(contact)} 문의
|
|
</a>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 확인된 값만. 없는 업종에서는 칸 자체가 안 생긴다. */}
|
|
{facts.length > 0 && (
|
|
<dl className="mt-1">
|
|
{facts.map((row) => (
|
|
<div
|
|
key={row.label}
|
|
className="border-line flex items-baseline justify-between gap-4 border-t py-2 text-[length:var(--fs-sm)]"
|
|
>
|
|
<dt className="text-muted shrink-0">{row.label}</dt>
|
|
<dd className="text-right font-semibold">{row.value}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|