o2o-site-AEO/solution/frontend/src/features/builder/CanvasView.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

194 lines
9.8 KiB
TypeScript

import {useState, type CSSProperties} from 'react';
import {Bed, Calendar, Home, MapPin, Monitor, Smartphone, Sparkles, Tablet} from 'lucide-react';
import type {SectionItem, ViewportMode} from '@o2o/shared';
import {publishUrlString, toSlug} from '@o2o/shared';
import {deriveSurfaces} from '@/lib/color';
import {cn} from '@/lib/utils';
import {useBuilderStore, useCurrentTemplate} from '@/stores/builder';
import {SitePreview} from './SitePreview';
import {PUBLISH_HOST} from '@/lib/site';
// ★ 호스트를 상수로 박지 않는다. PublishModal 과 **다른 주소**를 보여주면 사장님은
// 미리보기에서 본 주소와 발행 후 안내받는 주소가 달라 어느 쪽이 진짜인지 알 수 없다.
// 같은 규칙(VITE_PUBLISH_HOST → 없으면 현재 호스트)을 쓴다.
// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌
// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다.
// (PUBLISH_HOST 는 @/lib/site 가 유일한 출처다)
const VIEWPORT_FRAME: Record<ViewportMode, string> = {
pc: 'w-full max-w-5xl shadow-md',
tablet: 'w-[768px] max-w-full border-x border-border shadow-lg',
mobile: 'w-[390px] max-w-full overflow-hidden rounded-lg border-x border-border shadow-xl',
};
const VIEWPORT_TOOLBAR: {id: ViewportMode; label: string; icon: typeof Monitor}[] = [
{id: 'pc', label: 'PC (100%)', icon: Monitor},
{id: 'tablet', label: '태블릿 (768px)', icon: Tablet},
{id: 'mobile', label: '모바일 (390px)', icon: Smartphone},
];
const MOBILE_TABS = [
{key: 'home', label: '홈', anchor: 'canvas-section-hero', icon: Home},
{key: 'rooms', label: '객실', anchor: 'canvas-section-rooms', icon: Bed},
{key: 'guide', label: '가이드', anchor: 'canvas-section-local', icon: Sparkles},
{key: 'map', label: '위치', anchor: 'canvas-section-map', icon: MapPin},
];
export function CanvasView() {
const industry = useBuilderStore((s) => s.industry);
const storeName = useBuilderStore((s) => s.storeName);
const location = useBuilderStore((s) => s.location);
const weatherLocation = useBuilderStore((s) => s.weatherLocation);
const sections = useBuilderStore((s) => s.sections);
const infoFields = useBuilderStore((s) => s.infoFields);
const photos = useBuilderStore((s) => s.photos);
const selectedSectionId = useBuilderStore((s) => s.selectedSectionId);
const viewport = useBuilderStore((s) => s.viewport);
const isPreviewMode = useBuilderStore((s) => s.isPreviewMode);
const placeId = useBuilderStore((s) => s.placeId);
const setViewport = useBuilderStore((s) => s.setViewport);
const selectSection = useBuilderStore((s) => s.selectSection);
const setRightTab = useBuilderStore((s) => s.setRightTab);
const template = useCurrentTemplate();
const [activeMobileTab, setActiveMobileTab] = useState('home');
const scrollToSection = (anchor: string, tabKey: string) => {
setActiveMobileTab(tabKey);
document.getElementById(anchor)?.scrollIntoView({behavior: 'smooth', block: 'start'});
};
// 템플릿 색은 CSS 변수로 내려보낸다 — 관리자 토큰(--primary 등)과 이름이 겹치지 않게 --tpl-* 접두어.
// ★ 면 토큰(--tpl-surface / -alt / inverse / border)은 템플릿 색에서 유도한다.
// 이걸 안 내려보내면 SectionFrame 이 폴백(고정 stone 색)으로 떨어져서,
// 템플릿을 바꿔도 화면 면적의 대부분이 그대로다 — 쇼케이스와 같은 식(lib/color.ts)을 쓴다.
/*
* ★ 색·서체 토큰을 여기서 만들지 않는다(2026-09-09).
* 캔버스가 빌더 상태로 --tpl-* 를 따로 만들던 동안, 그 값이 발행본과 갈라졌다 —
* 실측: 편집 캔버스 #ffffff·Pretendard ↔ 발행본 #e4dac0·Gugi.
* 이제 미리보기·편집 둘 다 iframe 안 발행본 렌더러가 그리고, 토큰은 payload 하나에서 온다.
*/
return (
<div className="relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-muted">
{!isPreviewMode && (
<div className="z-10 flex h-10 min-w-0 select-none items-center justify-between gap-2 border-b border-border bg-card px-4 text-xs">
{/* ★ min-w-0 — 이 줄이 안 줄어들면 캔버스 칸 전체의 최소 너비가 올라가고,
그 여파로 오른쪽 패널이 화면 밖으로 밀린다(EditorLayout 주석 참조). */}
<div className="flex min-w-0 items-center gap-1.5">
<span className="hidden shrink-0 font-semibold md:inline">미리보기 해상도:</span>
<div className="flex shrink-0 items-center gap-0.5 rounded border border-border bg-muted p-0.5">
{VIEWPORT_TOOLBAR.map(({id, label, icon: Icon}) => (
<button
key={id}
type="button"
onClick={() => setViewport(id)}
aria-pressed={viewport === id}
className={cn(
'flex cursor-pointer items-center gap-1 rounded px-2.5 py-1 transition-colors',
viewport === id
? 'bg-card font-semibold text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground',
)}
>
<Icon className="size-3.5" />
<span>{label}</span>
</button>
))}
</div>
</div>
<div className="hidden shrink-0 items-center gap-2 text-[11px] text-muted-foreground xl:flex">
<span className="inline-block size-2 rounded-full bg-success" />
<span>실시간 동기화 캔버스</span>
</div>
</div>
)}
<div className="flex flex-1 items-start justify-center overflow-y-auto bg-muted/70 p-2 sm:p-6">
{/* ★ 이 껍데기는 토큰도 폭도 정하지 않는다(2026-09-09).
안쪽 iframe 이 **자기 뷰포트**를 만들고 색·서체는 payload 가 준다.
바깥이 한 번 더 얹으면 두 겹이 돼 바깥 값이 안쪽을 덧칠한다 — 실제로 그랬다. */}
<div className="my-auto w-full min-w-0 transition-all duration-300">
{viewport !== 'pc' && (
<div className="flex items-center justify-between bg-foreground px-4 py-2 font-mono text-[11px] text-background">
<span className="flex items-center gap-1.5">
<span className="inline-block size-2.5 rounded-full bg-red-500" />
<span className="inline-block size-2.5 rounded-full bg-amber-500" />
<span className="inline-block size-2.5 rounded-full bg-emerald-500" />
</span>
<span className="max-w-[200px] truncate opacity-70">
{publishUrlString(toSlug(storeName) || 'my-store', PUBLISH_HOST)}
</span>
<span className="text-[10px] opacity-70">{viewport.toUpperCase()}</span>
</div>
)}
{/* ★ 편집 모드도 **발행본 렌더러**가 그린다(2026-09-09).
캔버스 전용 컴포넌트를 따로 두는 동안 두 화면이 아예 다른 트리였다 — 실측:
발행본 15섹션 · 에디터 12섹션, 겹치는 건 4개뿐이고 이름도 달랐다
(gallery↔photos · location↔map · guide↔local). 사장님이 편집한 화면과
발행된 화면이 서로 다른 물건이었다.
고르는 일은 iframe 안 섹션을 눌러서 한다 — SitePreview 가 배선한다. */}
<SitePreview
placeId={placeId}
viewport={viewport}
interactive={!isPreviewMode}
selectedId={selectedSectionId}
onSelect={(id) => {
selectSection(id);
setRightTab('content');
}}
/>
<footer
className="border-t border-black/10 p-6 text-center text-xs"
style={{backgroundColor: template.colors.bg, color: template.colors.secondary}}
>
<div className="mx-auto max-w-4xl space-y-2">
<p className="font-bold" style={{color: template.colors.text}}>
{storeName}
</p>
<p className="text-[11px]">
{infoFields.find((f) => f.id === 'address')?.value || location}
</p>
<p className="pt-2 text-[10px] opacity-60">
© {new Date().getFullYear()} {storeName}. Powered by Web4Ai.
</p>
</div>
</footer>
{viewport === 'mobile' && (
<nav
aria-label="모바일 하단 내비게이션 미리보기"
className="sticky inset-x-0 bottom-0 z-30 flex items-center justify-around border-t border-black/10 bg-white/95 px-2 py-1.5 backdrop-blur-md"
>
{MOBILE_TABS.map(({key, label, anchor, icon: Icon}) => (
<button
key={key}
type="button"
onClick={() => scrollToSection(anchor, key)}
className={cn(
'flex cursor-pointer flex-col items-center justify-center rounded-lg px-2 py-1 transition-all',
activeMobileTab === key ? 'font-bold text-zinc-950' : 'text-zinc-500',
)}
>
<Icon className="size-4" />
<span className="mt-0.5 text-[10px]">{label}</span>
</button>
))}
<span
className="flex items-center gap-1 rounded-lg px-3 py-1.5 text-[11px] font-bold text-white"
style={{backgroundColor: template.colors.primary}}
>
<Calendar className="size-3.5" />
<span>예약하기</span>
</span>
</nav>
)}
</div>
</div>
</div>
);
}