o2o-site-AEO/solution/site/src/layouts/editorial/Hero.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

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>
);
}