실측(2026-09-07): `curl /` 가 3,021바이트에 본문 0자·`<a>` 0개였다. 같은 호스트의
발행본은 48,072바이트다. 구글은 JS 를 실행하지만 **렌더링 큐가 따로** 돌고 신규
도메인은 뒤로 밀린다 — 그동안 색인에는 "제목만 있고 내용 없는 페이지"로 들어가 있다.
서치콘솔이 "URL이 Google에 등록되어 있음"이라고 답하면서도 브랜드명 검색에조차 안
걸리던 이유다.
스크립트를 새로 짜지 않았다. react-router 7.17 에 프리렌더가 내장돼 있고
`ssr: false` 와 함께 쓰면 런타임 Node 서버 없이 지정한 경로만 HTML 로 굽는다 —
나머지는 지금까지처럼 SPA 폴백이다. 배포 구조가 그대로다.
- react-router.config.ts: `ssr:false` + `prerender: ['/', '/pricing', '/showcase']`.
로그인 뒤에만 의미가 있는 화면은 굽지 않는다(구울 내용이 사용자별이다)
- src/root.tsx · src/routes.ts: 예전 index.html + app/router.tsx 가 하던 일.
가드는 페이지마다 감싸지 않고 RequireAuthLayout 레이아웃 라우트 하나로 모았다
- 랜딩·요금·사례에 meta export: 제목을 브랜드가 아니라 **검색어**로 시작하게 바꿨다.
예전 제목("Web4Ai · AI 웹 빌더")에는 사람이 치는 말이 한 단어도 없었다.
랜딩에 Organization JSON-LD 추가 — 발행본에는 있는데 정작 랜딩엔 없었다
- src/lib/site.ts: 발행 호스트의 단일 출처. 모듈 최상위의 `window.location` 폴백을
전부 걷었다 — 서버 번들은 라우트를 한 파일로 묶어서 프리렌더 대상이 아닌 화면의
최상위 코드도 빌드 때 실행된다(실측: BuilderPage 에서 빌드가 죽었다)
- LoginPage: homePath 기본값 `/` → `/sites`. 예전엔 router.tsx 가 넘기던 값이라
라우트 모듈로 옮기면서 그대로 두면 로그인 후 랜딩으로 갔다
- nginx: SPA 폴백을 `/index.html` → `/__spa-fallback.html`. 프리렌더 뒤로
`/index.html` 은 **랜딩이 구워진 파일**이라, 그리로 넘기면 `/builder` 에 랜딩
HTML 이 내려가고 클라이언트가 다른 주소로 하이드레이트한다
- nginx/Dockerfile: 산출물이 `dist` → `build/client`. 경로가 어긋나면 COPY 가
조용히 빈 디렉토리를 만들고 컨테이너는 정상으로 뜬다
- site/seo/robots.ts: `/builder` `/login` `/signup` `/sites` `/account` Disallow.
이 경로들은 빈 SPA 폴백을 받는다 — 긁히면 호스트 전체에 저품질 신호가 쌓인다
검증: tsc·eslint·react-router build 통과.
랜딩 3,021B → 21,799B, 본문 1,278자, 링크 6개.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fteiJNvAEbTnUKq8fSqoj
248 lines
11 KiB
TypeScript
248 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';
|
|
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 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>
|
|
);
|
|
}
|