에디터에서 본 화면과 발행된 화면이 달랐다. 렌더러를 두 벌 들고 있었기 때문이다 —
캔버스는 `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>
147 lines
6.2 KiB
TypeScript
147 lines
6.2 KiB
TypeScript
/**
|
|
* 미리보기 — 발행본을 **iframe 으로** 띄운다.
|
|
*
|
|
* ★ 왜 iframe 인가 (2026-09-09)
|
|
* 처음엔 발행본 컴포넌트를 빌더 안에 직접 그렸다. 렌더러도 payload 도 토큰도 같게 맞췄고
|
|
* 섹션 15개의 계산 스타일(색·서체·여백)까지 동일해졌는데, **레이아웃 폭이 어긋났다.**
|
|
* 미디어 쿼리는 창 폭을 보는데 미리보기의 실제 사이트 폭은 그 안의 프레임이기 때문이다.
|
|
* 실측(1400px 창 · 1024px 프레임, Playwright):
|
|
* festival 2560px → 6027px · guide 1168 → 2168 · location 586 → 1135
|
|
* 내용(글자 수)은 완전히 같은데 그리드 컬럼 수만 달라 두 배씩 길어졌다. 전체 픽셀 차이 89%.
|
|
* "같은 렌더러를 쓴다" 만으로는 안 되고 **뷰포트가 같아야** 한다.
|
|
*
|
|
* iframe 은 자체 뷰포트를 가진다 — 폭을 390/768/1024 로 주면 발행본이 그 폭에서 보는 것과
|
|
* 같은 미디어 쿼리가 걸린다. 해상도 전환도 그제서야 진짜가 된다.
|
|
*
|
|
* ★ iframe 이 여는 것은 `/preview?placeId=…` — 프리렌더가 굽는 CSR 셸이다
|
|
* (`site/scripts/prerender.writePreviewShell`). 그 안에서 발행본 앱이 그대로 돈다.
|
|
* ★ 같은 오리진이라 토큰(localStorage)을 iframe 이 그대로 읽는다. 따로 넘기지 않는다.
|
|
*/
|
|
import {useCallback, useEffect, useRef, useState} from 'react';
|
|
import type {ViewportMode} from '@o2o/shared';
|
|
|
|
/**
|
|
* 각 해상도에서 iframe 에 줄 **크기**. 발행본이 그 기기에서 보는 것과 같아야 한다.
|
|
*
|
|
* ★ 높이도 준다. 폭만 맞추면 `vh` 를 쓰는 자리가 어긋난다 — 히어로가
|
|
* `clamp(24rem, 62vh, 36rem)` 이라(`HeroSection`), iframe 이 낮으면 하한 384px 에 걸린다.
|
|
* 실측: 발행본 576px ↔ 미리보기 384px. 지연 로딩 이미지도 화면에 덜 들어와 적게 뜬다.
|
|
*/
|
|
const FRAME_SIZE: Record<ViewportMode, {w: number; h: number}> = {
|
|
pc: {w: 1024, h: 800},
|
|
tablet: {w: 768, h: 1024},
|
|
mobile: {w: 390, h: 844},
|
|
};
|
|
|
|
export function SitePreview({
|
|
placeId,
|
|
viewport,
|
|
interactive = false,
|
|
selectedId = null,
|
|
onSelect,
|
|
}: {
|
|
placeId: string | null;
|
|
viewport: ViewportMode;
|
|
/** 편집 모드 — 섹션을 눌러 고를 수 있게 한다. 미리보기에서는 끈다. */
|
|
interactive?: boolean;
|
|
selectedId?: string | null;
|
|
onSelect?: (sectionId: string) => void;
|
|
}) {
|
|
const ref = useRef<HTMLIFrameElement>(null);
|
|
const boxRef = useRef<HTMLDivElement>(null);
|
|
const [scale, setScale] = useState(1);
|
|
|
|
/*
|
|
* ★ iframe 은 **진짜 폭(1024·768·390)** 을 유지하고, 자리에 안 들어가면 축소해서 넣는다.
|
|
* 폭을 줄여 맞추면 미디어 쿼리가 그 좁은 폭을 보고 발행본과 다른 그리드가 된다 —
|
|
* 그걸 피하려고 iframe 을 쓴 것이라 여기서 무너뜨리면 안 된다.
|
|
* 기기 미리보기 도구가 쓰는 방식과 같다: 크기는 그대로, 그림만 줄인다.
|
|
*/
|
|
useEffect(() => {
|
|
const box = boxRef.current;
|
|
if (!box) return;
|
|
const fit = () => {
|
|
const avail = box.clientWidth;
|
|
setScale(avail > 0 ? Math.min(1, avail / FRAME_SIZE[viewport].w) : 1);
|
|
};
|
|
fit();
|
|
const ro = new ResizeObserver(fit);
|
|
ro.observe(box);
|
|
return () => ro.disconnect();
|
|
}, [viewport]);
|
|
|
|
/**
|
|
* 편집 모드에서 iframe 안 섹션을 고를 수 있게 한다.
|
|
*
|
|
* ★ 같은 오리진이라 안쪽 문서를 그대로 만질 수 있다. 굳이 postMessage 를 쓰지 않는다.
|
|
* ★ 어느 섹션인지는 `data-editor-id` 로 안다 — 화면 id(`gallery`)와 설정 id(`photos`)가
|
|
* 달라서, 그 다리를 발행본 렌더러가 놓아 준다(`site/pages/HomePage`).
|
|
*/
|
|
const wire = useCallback(() => {
|
|
const doc = ref.current?.contentDocument;
|
|
if (!doc || !interactive) return;
|
|
|
|
let style = doc.getElementById('editor-outline') as HTMLStyleElement | null;
|
|
if (!style) {
|
|
style = doc.createElement('style');
|
|
style.id = 'editor-outline';
|
|
doc.head.appendChild(style);
|
|
}
|
|
// ★ outline 을 쓴다(border 아님). 상자 크기를 바꾸지 않아 발행본과 레이아웃이 그대로다.
|
|
style.textContent = `
|
|
[data-editor-id] > * { cursor: pointer; }
|
|
[data-editor-id]:hover > * { outline: 2px dashed rgb(59 130 246 / .5); outline-offset: -2px; }
|
|
[data-editor-id][data-selected='true'] > * { outline: 2px solid rgb(59 130 246); outline-offset: -2px; }
|
|
`;
|
|
|
|
doc.querySelectorAll<HTMLElement>('[data-editor-id]').forEach((el) => {
|
|
el.dataset.selected = String(el.dataset.editorId === selectedId);
|
|
});
|
|
|
|
const onClick = (event: Event) => {
|
|
const host = (event.target as HTMLElement | null)?.closest<HTMLElement>('[data-editor-id]');
|
|
const id = host?.dataset.editorId;
|
|
if (id) onSelect?.(id);
|
|
};
|
|
doc.addEventListener('click', onClick);
|
|
return () => doc.removeEventListener('click', onClick);
|
|
}, [interactive, selectedId, onSelect]);
|
|
|
|
useEffect(() => wire(), [wire]);
|
|
|
|
if (!placeId) {
|
|
return (
|
|
<div className="text-muted-foreground flex min-h-[40vh] items-center justify-center text-sm">
|
|
사업장을 먼저 만들어 주세요
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const {w, h} = FRAME_SIZE[viewport];
|
|
|
|
return (
|
|
<div ref={boxRef} className="flex w-full justify-center overflow-hidden">
|
|
{/* 축소한 만큼 실제 차지하는 크기도 줄여 준다 — 안 그러면 아래쪽에 빈 공간이 남는다. */}
|
|
<div style={{width: w * scale, height: h * scale}}>
|
|
<iframe
|
|
ref={ref}
|
|
onLoad={wire}
|
|
// ★ key 에 해상도를 넣어 바뀌면 새로 띄운다. 같은 문서를 리사이즈만 하면 이미 지나간
|
|
// 미디어 쿼리 분기(그리드 컬럼 수)가 그대로 남는 경우가 있다.
|
|
key={viewport}
|
|
title="발행본 미리보기"
|
|
src={`/preview?placeId=${encodeURIComponent(placeId)}`}
|
|
// ★ 발행본과 같은 오리진이라 sandbox 를 걸지 않는다 — 걸면 토큰을 못 읽는다.
|
|
className="block shrink-0 border-0 bg-white"
|
|
style={{
|
|
width: w,
|
|
height: h,
|
|
transform: `scale(${scale})`,
|
|
transformOrigin: 'top left',
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|