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 {resolveVariant} from './canvas/registry'; 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 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)을 쓴다. const surfaces = deriveSurfaces(template.colors); const look = template.look; const themeVars = { '--tpl-surface': surfaces.surface, '--tpl-surface-alt': surfaces.surfaceAlt, '--tpl-inverse': surfaces.inverse, '--tpl-border': surfaces.border, '--tpl-primary': template.colors.primary, '--tpl-secondary': template.colors.secondary, '--tpl-bg': template.colors.bg, '--tpl-card': template.colors.card, '--tpl-text': template.colors.text, '--tpl-accent': template.colors.accent, // 색만 바꾸면 템플릿이 다 같아 보인다 — 생김새를 가르는 건 아래쪽이다. '--tpl-font-heading': look.fontHeading, '--tpl-font-body': look.fontBody, '--tpl-heading-tracking': look.headingTracking, '--tpl-heading-weight': look.headingWeight, '--tpl-section-space': look.sectionSpace, '--tpl-border-width': look.borderWidth, // ★ Tailwind v4 의 테마 변수를 캔버스 안에서만 덮는다. 이러면 변이 파일 40여 개에 흩어진 // rounded-* · shadow-* 를 하나도 안 고치고 전부 템플릿을 따르게 된다. // 배수는 Tailwind 기본 비율(0.25/0.375/0.5/0.75/1/1.5rem)을 그대로 옮긴 것이라, // 기준값이 0.75rem 이면 지금까지와 픽셀 단위로 같고 0 이면 전부 각진다. '--radius-sm': `calc(${look.radius} * 0.34)`, '--radius-md': `calc(${look.radius} * 0.5)`, '--radius-lg': `calc(${look.radius} * 0.67)`, '--radius-xl': look.radius, '--radius-2xl': `calc(${look.radius} * 1.34)`, '--radius-3xl': `calc(${look.radius} * 2)`, '--shadow-xs': look.shadow, '--shadow-sm': look.shadow, '--shadow-md': look.shadow, } as CSSProperties; /** * 섹션 하나를 그린다. * * 어떤 레이아웃으로 그릴지는 레지스트리가 정한다 — 여기에 switch 를 두면 * 배리에이션을 하나 추가할 때마다 이 파일을 같이 고쳐야 한다. */ const renderSection = (section: SectionItem) => { if (!section.isEnabled) return null; const variant = resolveVariant(section, industry); // 아직 배리에이션이 없는 섹션 타입 — 캔버스를 깨뜨리지 않고 조용히 건너뛴다. if (!variant) return null; const {Component} = variant; return ( { selectSection(section.id); setRightTab('content'); }} /> ); }; return (
{!isPreviewMode && (
{/* ★ min-w-0 — 이 줄이 안 줄어들면 캔버스 칸 전체의 최소 너비가 올라가고, 그 여파로 오른쪽 패널이 화면 밖으로 밀린다(EditorLayout 주석 참조). */}
미리보기 해상도:
{VIEWPORT_TOOLBAR.map(({id, label, icon: Icon}) => ( ))}
실시간 동기화 캔버스
)}
{viewport !== 'pc' && (
{publishUrlString(toSlug(storeName) || 'my-store', PUBLISH_HOST)} {viewport.toUpperCase()}
)} {/* 섹션은 좌측 패널이 정한 순서 그대로 그린다. */}
{sections.map((section) => renderSection(section))}

{storeName}

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

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

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