에디터에서 본 화면과 발행된 화면이 달랐다. 렌더러를 두 벌 들고 있었기 때문이다 —
캔버스는 `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>
110 lines
4.7 KiB
TypeScript
110 lines
4.7 KiB
TypeScript
import {useSite} from '@site/lib/site-context';
|
||
import {primaryImage} from '@site/seo/meta';
|
||
import {bookingActionLabel, bookingLinks, essentialRows, heroFacts, lowestPrice} from '@site/lib/derive';
|
||
|
||
/**
|
||
* oasi 첫 화면 — 사진 한 장과 아주 작은 글씨.
|
||
*
|
||
* ★ 사진 위에 글자를 얹지 않는다. 이 안은 폭을 제한한 사진을 가운데 한 장 놓고
|
||
* **바깥 여백에 세로쓰기 캡션**을 세우는 편집형이다 — 사진을 화면 끝까지 늘리면
|
||
* 그 여백이 사라져 안 자체가 성립하지 않는다.
|
||
* ★ 상호는 `<h1>` 이지만 크게 쓰지 않는다. 크기는 왼쪽 워드마크가 이미 맡았고,
|
||
* 여기서 큰 활자를 한 번 더 쓰면 화면에 큰 글자가 둘이 된다.
|
||
* ★ 세로쓰기 캡션은 좁은 화면에서 감춘다 — 390px 에서는 사진 밖 여백이 없어
|
||
* 그대로 두면 가로 스크롤이 생긴다.
|
||
* ★ 사진 틀의 **높이를 고정한다**. 자연 비율로 두면 소스가 세로 사진일 때 첫 화면을
|
||
* 사진 하나가 통째로 먹는다 — 실측(stay3, 1000×1333): 544×725px.
|
||
* 사장님이 어떤 사진을 올리든 첫 화면 높이가 같아야 한다.
|
||
*/
|
||
export function Hero() {
|
||
const payload = useSite();
|
||
const {place, narrative} = payload;
|
||
const image = primaryImage(payload);
|
||
const price = lowestPrice(payload);
|
||
const links = bookingLinks(payload);
|
||
/* 첫 화면 우선순위 key(체크인·영업시간…)가 하나도 없는 업종이 있다 —
|
||
그때 띠를 비우지 않고 확인된 이용 정보 앞 넷으로 채운다. */
|
||
const priority = heroFacts(payload);
|
||
const facts = priority.length > 0 ? priority : essentialRows(payload).slice(0, 4);
|
||
const caption = image?.caption ?? place.name;
|
||
|
||
return (
|
||
/* data-oasi: 껍데기의 판 지우개(Shell PLATE_CSS)를 건너뛴다 — 여기는 이미 투명하고
|
||
위 여백도 스스로 정한다(첫 섹션이라 다른 섹션보다 좁다). */
|
||
<section id="top" data-oasi aria-labelledby="top-heading" className="pt-12 lg:pt-16">
|
||
<h1
|
||
id="top-heading"
|
||
className="text-[length:var(--fs-sm)] font-normal tracking-[0.08em] leading-[1.9]"
|
||
>
|
||
{place.name}
|
||
</h1>
|
||
|
||
{narrative.heroHeadline && (
|
||
<p className="text-muted measure mt-3 text-[length:var(--fs-xs)] leading-[1.9]">
|
||
{narrative.heroHeadline}
|
||
</p>
|
||
)}
|
||
|
||
{image && (
|
||
<figure className="relative mx-auto mt-10 w-full max-w-[34rem]">
|
||
<img
|
||
src={image.url}
|
||
alt={image.alt}
|
||
// 첫 화면 이미지는 지연 로딩하지 않는다 — LCP 가 그만큼 늦어진다.
|
||
fetchPriority="high"
|
||
decoding="async"
|
||
width={image.width}
|
||
height={image.height}
|
||
className="block h-[clamp(17rem,27vw,24rem)] w-full object-cover"
|
||
/>
|
||
{/* 캡션 높이를 액자에 묶고(max-h-full) 한 줄로 묶는다(nowrap).
|
||
22rem 로 박아 두면 액자보다 캡션이 길어져 사진 아래로 흘러내리고,
|
||
긴 캡션은 세로쓰기가 여러 단으로 갈려 액자 옆이 글자 벽이 된다. */}
|
||
<figcaption
|
||
className="text-muted absolute left-full top-0 ml-5 hidden max-h-full overflow-hidden text-ellipsis whitespace-nowrap text-[length:var(--fs-xs)] tracking-[0.14em] xl:block"
|
||
style={{writingMode: 'vertical-rl'}}
|
||
>
|
||
{caption}
|
||
</figcaption>
|
||
</figure>
|
||
)}
|
||
|
||
{(price || facts.length > 0) && (
|
||
<dl className="border-line mt-10 flex flex-wrap gap-x-10 gap-y-4 border-t pt-5 text-[length:var(--fs-xs)] leading-[1.9]">
|
||
{price && (
|
||
<div>
|
||
<dt className="text-muted">최저가</dt>
|
||
<dd>{price}</dd>
|
||
</div>
|
||
)}
|
||
{facts.map((row) => (
|
||
<div key={row.label}>
|
||
<dt className="text-muted">{row.label}</dt>
|
||
<dd>{row.value}</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
)}
|
||
|
||
{/* 확정된 예약 채널만. 버튼 상자를 만들지 않는다 — 이 안에는 면이 없다. */}
|
||
{links.length > 0 && (
|
||
<ul className="mt-6 flex flex-wrap items-center gap-x-8">
|
||
{links.map((link) => (
|
||
<li key={link.url}>
|
||
<a
|
||
href={link.url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="tap inline-flex items-center text-[length:var(--fs-sm)] underline underline-offset-[6px]"
|
||
style={{color: 'var(--color-brand)'}}
|
||
>
|
||
{bookingActionLabel(link)}
|
||
</a>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|