업종마다 템플릿이 다섯인데 넷이 "흰 바탕 + 고딕 + 둥근 모서리"에 색조만 달랐다. 고르는 화면의 미리보기도 회색 막대 세 줄 + 색 동그라미라 다섯 장이 전부 같은 그림이었다 — 뭐가 다른지 알 수 없으니 아무거나 골랐다. - shared/TemplateItem.look: 제목·본문 서체, 모서리, 테두리 두께, 그림자, 제목 자간·굵기, 섹션 여백. CSS 에 그대로 들어가는 문자열로 들고 있다 — 숫자로 두면 쓰는 쪽에서 단위를 빠뜨린 곳이 조용히 0 이 된다 - CanvasView: Tailwind v4 의 --radius-* · --shadow-* 를 캔버스 안에서만 덮는다. 변이 파일 40여 개에 흩어진 rounded-* · shadow-* 를 한 줄도 안 고치고 전부 템플릿을 따르게 된다. 배수는 Tailwind 기본 비율 그대로라 기준값 0.75rem 이면 지금까지와 픽셀 단위로 같다 - index.css: .site-canvas 는 --tpl-font-heading/body 를 이미 읽고 있었는데 아무도 넣지 않았다. 서체가 안 갈리던 진짜 이유가 이 빠진 고리다. 제목 굵기도 토큰으로 뺐다 — 간판체(Gugi)는 굵기가 한 벌뿐이라 700 을 주면 브라우저가 가짜 볼드를 씌워 획이 뭉갠다 - industryData: templatesFor() 팩토리 하나로 심플·매거진·레트로 셋. 업종은 accent 하나만 바꾼다 — 생김새는 업종이 아니라 취향의 문제다. 537줄 → 237줄 - Step4Template: 미리보기를 그 템플릿의 서체·모서리·테두리·그림자로 실제로 그린다 - index.html: Noto Serif KR 추가. 매거진 제목이 Batang 으로 떨어지는데 맥에는 그 서체가 없다 - SectionFrame: 세로 여백을 --tpl-section-space 로. 이 값 하나로 페이지의 호흡이 바뀐다 옛 템플릿 id 가 DB 에 남아 있어도 resolveTemplate 이 첫 템플릿으로 떨어뜨린다. tsc·eslint·vite build 통과(frontend·admin·site), 템플릿 12벌 look 전량 대조 + 폴백 확인
245 lines
11 KiB
TypeScript
245 lines
11 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 {resolveVariant} from './canvas/registry';
|
|
|
|
// ★ 호스트를 상수로 박지 않는다. PublishModal 과 **다른 주소**를 보여주면 사장님은
|
|
// 미리보기에서 본 주소와 발행 후 안내받는 주소가 달라 어느 쪽이 진짜인지 알 수 없다.
|
|
// 같은 규칙(VITE_PUBLISH_HOST → 없으면 현재 호스트)을 쓴다.
|
|
const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host;
|
|
|
|
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 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 (
|
|
<Component
|
|
key={section.id}
|
|
section={section}
|
|
industryId={industry}
|
|
storeName={storeName}
|
|
location={location}
|
|
weatherLocation={weatherLocation}
|
|
template={template}
|
|
infoFields={infoFields}
|
|
photos={photos}
|
|
isSelected={!isPreviewMode && selectedSectionId === section.id}
|
|
onSelect={() => {
|
|
selectSection(section.id);
|
|
setRightTab('content');
|
|
}}
|
|
/>
|
|
);
|
|
};
|
|
|
|
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">
|
|
<div
|
|
style={themeVars}
|
|
className={cn('site-canvas my-auto transition-all duration-300', VIEWPORT_FRAME[viewport])}
|
|
>
|
|
{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>
|
|
)}
|
|
|
|
{/* 섹션은 좌측 패널이 정한 순서 그대로 그린다. */}
|
|
<div className="divide-y divide-black/5">
|
|
{sections.map((section) => renderSection(section))}
|
|
</div>
|
|
|
|
<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>
|
|
);
|
|
}
|