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 = { 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 (
{!isPreviewMode && (
{/* ★ min-w-0 — 이 줄이 안 줄어들면 캔버스 칸 전체의 최소 너비가 올라가고, 그 여파로 오른쪽 패널이 화면 밖으로 밀린다(EditorLayout 주석 참조). */}
미리보기 해상도:
{VIEWPORT_TOOLBAR.map(({id, label, icon: Icon}) => ( ))}
실시간 동기화 캔버스
)}
{/* ★ 이 껍데기는 토큰도 폭도 정하지 않는다(2026-09-09). 안쪽 iframe 이 **자기 뷰포트**를 만들고 색·서체는 payload 가 준다. 바깥이 한 번 더 얹으면 두 겹이 돼 바깥 값이 안쪽을 덧칠한다 — 실제로 그랬다. */}
{viewport !== 'pc' && (
{publishUrlString(toSlug(storeName) || 'my-store', PUBLISH_HOST)} {viewport.toUpperCase()}
)} {/* ★ 편집 모드도 **발행본 렌더러**가 그린다(2026-09-09). 캔버스 전용 컴포넌트를 따로 두는 동안 두 화면이 아예 다른 트리였다 — 실측: 발행본 15섹션 · 에디터 12섹션, 겹치는 건 4개뿐이고 이름도 달랐다 (gallery↔photos · location↔map · guide↔local). 사장님이 편집한 화면과 발행된 화면이 서로 다른 물건이었다. 고르는 일은 iframe 안 섹션을 눌러서 한다 — SitePreview 가 배선한다. */} { selectSection(id); setRightTab('content'); }} />

{storeName}

{infoFields.find((f) => f.id === 'address')?.value || location}

© {new Date().getFullYear()} {storeName}. Powered by Web4Ai.

{viewport === 'mobile' && ( )}
); }