에디터에서 본 화면과 발행된 화면이 달랐다. 렌더러를 두 벌 들고 있었기 때문이다 —
캔버스는 `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>
225 lines
9.6 KiB
TypeScript
225 lines
9.6 KiB
TypeScript
import {useCallback, useEffect, useRef, useState} from 'react';
|
|
import {ChevronLeft, ChevronRight, Phone} from 'lucide-react';
|
|
import {useSite} from '@site/lib/site-context';
|
|
import {bookingActionLabel, bookingLinks, heroFacts, lowestPrice} from '@site/lib/derive';
|
|
|
|
/**
|
|
* 첫 화면 — 국내 펜션 사이트의 정석(`hero.slideshow`).
|
|
*
|
|
* ★ 기본 히어로와 다른 건 색이 아니라 **골격**이다.
|
|
* 기본형은 사진 한 장 위에 이름이 왼쪽 아래 붙는다. 국내 펜션은 예외 없이
|
|
* **화면을 꽉 채운 사진이 저절로 넘어가고**, 이름이 한가운데 서고, 그 아래 띠에서
|
|
* 값과 예약 창구가 한 번에 읽힌다. 첫 화면에서 "어떤 집인가 · 얼마인가 · 어디로 예약하나"
|
|
* 셋이 끝나야 한다.
|
|
*
|
|
* ★ 가로 스크롤 상자로 만들지 않는다 (실측)
|
|
* 처음엔 scroll-snap 트랙으로 짰는데, 첫 화면의 8할을 차지하는 그 상자 위에서
|
|
* **휠을 굴려도 페이지가 안 내려갔다.** 가로 스크롤 컨테이너가 휠을 먹는다.
|
|
* 첫 화면에서 스크롤이 막히는 건 사이트가 고장 난 것과 같다.
|
|
* 그래서 슬라이드를 겹쳐 두고 불투명도로 바꾼다 — 휠을 가로채는 요소가 아예 없다.
|
|
*
|
|
* ★ 자동으로 넘기되 손을 대면 멈춘다. 읽는 중에 사진이 바뀌면 그건 방해다.
|
|
* ★ 다섯 장이 **전부 HTML 에 있다.** 한 장씩 갈아 끼우면 나머지 alt 문장이 문서에서
|
|
* 사라진다 — 이미지 검색이 그 문장을 읽는다.
|
|
*/
|
|
|
|
/** 자동 전환 간격. 3초는 글을 읽기 전에 바뀌고, 10초는 넘어가는 걸 아무도 못 본다. */
|
|
const ROTATE_MS = 6000;
|
|
/** 다섯 장이면 충분하다. 열 장을 자동으로 돌리면 마지막 장은 아무도 못 본다. */
|
|
const MAX_SLIDES = 5;
|
|
|
|
export function HeroPension() {
|
|
const payload = useSite();
|
|
const {place, narrative} = payload;
|
|
const facts = heroFacts(payload);
|
|
const price = lowestPrice(payload);
|
|
const links = bookingLinks(payload);
|
|
const locality = place.addressLocality ?? place.addressRegion;
|
|
|
|
// 대표 사진이 먼저 온다 — og:image 와 첫 장이 다르면 공유 카드와 화면이 어긋난다.
|
|
const slides = [...payload.media]
|
|
.filter((image) => image.alt?.trim())
|
|
.sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary))
|
|
.slice(0, MAX_SLIDES);
|
|
|
|
const [index, setIndex] = useState(0);
|
|
const [paused, setPaused] = useState(false);
|
|
const touchX = useRef<number | null>(null);
|
|
|
|
const step = useCallback(
|
|
(delta: number) => setIndex((now) => (now + delta + slides.length) % slides.length),
|
|
[slides.length],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (paused || slides.length < 2) return;
|
|
const timer = setInterval(() => step(1), ROTATE_MS);
|
|
return () => clearInterval(timer);
|
|
}, [paused, step, slides.length]);
|
|
|
|
return (
|
|
<section id="top" className="w-full">
|
|
<div
|
|
className="relative w-full overflow-hidden"
|
|
style={{
|
|
backgroundColor: 'var(--tpl-inverse, #1c1917)',
|
|
color: 'var(--tpl-bg, #ffffff)',
|
|
// 모바일 주소창이 접혔다 펴질 때 100vh 는 출렁여 글자가 뛴다 — 상·하한을 둔다.
|
|
height: 'clamp(24rem, 76vh, 42rem)',
|
|
}}
|
|
onMouseEnter={() => setPaused(true)}
|
|
onMouseLeave={() => setPaused(false)}
|
|
onFocusCapture={() => setPaused(true)}
|
|
onBlurCapture={() => setPaused(false)}
|
|
onTouchStart={(event) => {
|
|
touchX.current = event.touches[0].clientX;
|
|
}}
|
|
onTouchEnd={(event) => {
|
|
const from = touchX.current;
|
|
touchX.current = null;
|
|
if (from === null) return;
|
|
const moved = event.changedTouches[0].clientX - from;
|
|
// 40px 아래는 세로로 넘기다 손가락이 살짝 흐른 것이다.
|
|
if (Math.abs(moved) > 40) step(moved < 0 ? 1 : -1);
|
|
}}
|
|
>
|
|
{slides.map((image, i) => (
|
|
<img
|
|
key={image.mediaId}
|
|
src={image.url}
|
|
alt={image.alt}
|
|
// 첫 장은 LCP 다 — 지연 로딩하면 첫 화면이 그만큼 늦게 뜬다.
|
|
fetchPriority={i === 0 ? 'high' : 'low'}
|
|
loading={i === 0 ? undefined : 'lazy'}
|
|
decoding="async"
|
|
className="absolute inset-0 size-full object-cover object-center transition-opacity duration-700"
|
|
style={{opacity: i === index ? 1 : 0}}
|
|
/>
|
|
))}
|
|
|
|
{/*
|
|
★ 사진을 고르게 덮지 않는다 — 통째로 덮으면 팔려는 물건이 탁해진다.
|
|
가운데 글자가 놓이는 만큼만 옅게, 아래는 짙게.
|
|
*/}
|
|
<div
|
|
className="pointer-events-none absolute inset-0"
|
|
style={{
|
|
background:
|
|
'linear-gradient(to bottom, color-mix(in srgb, var(--tpl-inverse, #1c1917) 34%, transparent) 0%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 16%, transparent) 45%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 60%, transparent) 100%)',
|
|
}}
|
|
/>
|
|
|
|
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center px-6 text-center">
|
|
{locality && <p className="label opacity-85">{locality}</p>}
|
|
{/* h1 은 페이지에 하나. 가게 이름이 들어가야 "○○ 홈페이지" 질의에 잡힌다. */}
|
|
<h1
|
|
className="serif mt-2"
|
|
style={{
|
|
fontSize: 'var(--fs-display)',
|
|
fontWeight: 'var(--tpl-heading-weight, 700)',
|
|
lineHeight: 1.15,
|
|
}}
|
|
>
|
|
{place.name}
|
|
</h1>
|
|
{(narrative.tagline ?? narrative.heroSubline) && (
|
|
<p className="measure mt-3 text-[length:var(--fs-lead)] leading-relaxed opacity-90">
|
|
{narrative.tagline ?? narrative.heroSubline}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{slides.length > 1 && (
|
|
<>
|
|
<Arrow dir="prev" onClick={() => step(-1)} />
|
|
<Arrow dir="next" onClick={() => step(1)} />
|
|
<ul className="absolute inset-x-0 bottom-5 flex items-center justify-center gap-2">
|
|
{slides.map((image, i) => (
|
|
<li key={image.mediaId}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIndex(i)}
|
|
aria-label={`${i + 1}번째 사진`}
|
|
aria-current={i === index}
|
|
className={`h-1.5 rounded-full bg-current transition-all ${
|
|
i === index ? 'w-7 opacity-90' : 'w-1.5 opacity-45 hover:opacity-70'
|
|
}`}
|
|
/>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/*
|
|
사진 아래 띠 — 국내 펜션 사이트가 첫 화면에서 반드시 답하는 것.
|
|
★ 사진 위에 얹지 않는다. 밝은 사진에서는 글도 사진도 죽는다.
|
|
*/}
|
|
{(price || facts.length > 0 || links.length > 0 || place.phone) && (
|
|
<div
|
|
className="border-line paper w-full border-b"
|
|
style={{backgroundColor: 'var(--tpl-surface-alt, #f5f5f4)'}}
|
|
>
|
|
<div className="shell flex flex-col items-center gap-4 py-4 lg:flex-row lg:justify-between lg:gap-8">
|
|
<dl className="divide-line flex flex-wrap justify-center gap-y-2 sm:divide-x">
|
|
{price && (
|
|
<div className="px-5 text-center sm:first:pl-0">
|
|
<dt className="label">1박 최저가</dt>
|
|
<dd className="text-[length:var(--fs-lead)] font-bold tabular-nums">{price}</dd>
|
|
</div>
|
|
)}
|
|
{facts.map((row) => (
|
|
<div key={row.label} className="px-5 text-center">
|
|
<dt className="label">{row.label}</dt>
|
|
<dd className="text-[length:var(--fs-lead)] font-bold">{row.value}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
|
|
<div className="flex shrink-0 gap-2">
|
|
{place.phone && (
|
|
<a
|
|
href={`tel:${place.phone}`}
|
|
className="tap border-line tpl-border inline-flex items-center gap-1.5 rounded-lg border px-4 text-[length:var(--fs-sm)] font-semibold"
|
|
>
|
|
<Phone className="size-4" />
|
|
<span>{place.phone}</span>
|
|
</a>
|
|
)}
|
|
{/* 확정된 예약 채널만 — 확정 전 URL 은 동명 업소의 예약 페이지일 수 있다. */}
|
|
{links.length > 0 && (
|
|
<a
|
|
href={links[0].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(links[0])}
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Arrow({dir, onClick}: {dir: 'prev' | 'next'; onClick: () => void}) {
|
|
const Icon = dir === 'prev' ? ChevronLeft : ChevronRight;
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
aria-label={dir === 'prev' ? '이전 사진' : '다음 사진'}
|
|
className={`tap absolute top-1/2 hidden -translate-y-1/2 items-center justify-center rounded-full bg-current/15 px-3 backdrop-blur-sm transition-colors hover:bg-current/25 sm:inline-flex ${
|
|
dir === 'prev' ? 'left-4' : 'right-4'
|
|
}`}
|
|
>
|
|
<Icon className="size-5" />
|
|
</button>
|
|
);
|
|
}
|