[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>
This commit is contained in:
Mina Choi 2026-09-09 17:09:28 +09:00
parent 387783b766
commit b1386dd3ce
77 changed files with 710 additions and 585 deletions

View File

@ -77,6 +77,17 @@ server {
}
# 파일명에 해시가 박혀 있다. 내용이 바뀌면 이름이 바뀌므로 영구 캐시가 안전하다.
# 빌더 미리보기 셸. **발행본 번들**을 띄우는 CSR 한 장이다
# (`solution/site/scripts/prerender.writePreviewShell`).
# ★ 빌더 SPA(`location /`)로 떨어지면 안 된다 — 거기로 가면 미리보기 안에 빌더가 또 뜬다.
# ★ iframe 으로 여는 이유는 뷰포트다. 빌더 안에 직접 그리면 미디어 쿼리가 창 폭을 봐서
# 그리드 컬럼 수가 발행본과 달라진다(실측 89% 픽셀 차이 — SitePreview 머리주석).
location = /preview {
root /srv/sites;
try_files /preview/index.html =404;
add_header Cache-Control "no-store" always;
}
location ^~ /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;

View File

@ -1,5 +1,6 @@
from uuid import UUID
from fastapi.responses import JSONResponse
from fastapi import APIRouter, Depends, Query
from common.models.gmodel import PageParams, UserInfo
@ -159,6 +160,24 @@ async def start_build(
return RemoveNoneResponse(await service.start_build(user_info, str(place_id), req))
@router.get(
path="/preview",
summary="에디터 미리보기 payload — 발행본과 같은 것",
description="발행이 굽는 것과 **같은 함수**로 만든 SitePayload 를 그대로 준다. "
"미리보기가 이 하나만 먹으면 캔버스와 발행본이 갈릴 자리가 없다. "
"★ DB 도 파일도 건드리지 않는다 — 버전을 만들지 않으므로 눌러도 발행 이력이 안 쌓인다.",
)
async def site_preview(
place_id: UUID,
service: SiteService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
):
payload = await service.preview_payload(user_info, str(place_id))
if payload is None:
return JSONResponse(status_code=404, content={"detail": "사업장을 찾지 못했습니다"})
return JSONResponse(content=payload)
@router.get(path="/version/list", response_model=Res_SiteVersions, summary="빌드 버전 목록")
async def list_versions(place_id: UUID, service: SiteService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.list_versions(user_info, str(place_id)))

View File

@ -150,6 +150,37 @@ class SiteService:
)
return Res_SeoAudit(**report)
async def preview_payload(self, user_info: UserInfo, place_id: str) -> dict | None:
"""에디터 미리보기가 쓰는 **발행본과 똑같은 payload**. DB 도 파일도 건드리지 않는다.
★ 왜 필요한가 (2026-09-09)
미리보기와 발행본이 **렌더러를 두 벌** 쓰고 있었다 — 캔버스는
`frontend/features/builder/canvas/variants/*`, 발행본은 `site/src/sections/*`.
둘이 공유하는 건 타입과 CSS 토큰뿐이라 같은 데이터로도 다른 그림이 나왔다.
실측(2026-09-09): 캔버스는 소개 섹션을 **설명 문구를 자리표시로** 그리는데
발행본은 데이터가 0자면 섹션째 뺀다 — 사장님은 채워진 화면을 보고 발행해
절반이 사라진 페이지를 받는다. `shared/lib/section-data.ts` 가 경고해 둔
"빌더에서는 보이는데 발행하면 없다"가 파서가 아니라 **렌더러**에서 났다.
★ 그래서 미리보기도 이 payload 하나만 먹는다. 발행이 굽는 것과 같은 함수
(`snapshot.build_snapshot` → `site_payload.to_site_payload`)를 그대로 거치므로,
여기서 갈릴 자리가 없다. 버전은 아직 없으니 0 으로 넘긴다 — 화면에 안 쓰인다.
"""
from services.build_service import ensure_site, _load_links
from services.site_payload import to_site_payload
from services.snapshot import build_snapshot
err_type, place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS or place is None:
return None
site = await ensure_site(place_id)
links = await _load_links(place_id)
snapshot = await build_snapshot(place)
# ★ version 은 None 이다. 발행 전이라 버전 행이 없고, payload 의 site.version 은
# 캐시 무효화 키라 미리보기에서는 뜻이 없다(to_site_payload 가 0 으로 떨어뜨린다).
return to_site_payload(place, snapshot, site, None, links)
# ---- 사이트 주소(네임스페이스) ---------------------------------------
# 규칙(정규식·예약어)은 services/site_slug 한 곳에만 있다. 확인과 저장이 그것을 같이 쓴다.

View File

@ -5,7 +5,7 @@ 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 {SitePreview} from './SitePreview';
import {PUBLISH_HOST} from '@/lib/site';
// ★ 호스트를 상수로 박지 않는다. PublishModal 과 **다른 주소**를 보여주면 사장님은
@ -45,6 +45,7 @@ export function CanvasView() {
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);
@ -61,82 +62,12 @@ export function CanvasView() {
// ★ 면 토큰(--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,
// 종이 결. 색·서체만으로는 '인쇄물'이 안 된다 — 갱지 면이 있어야 레트로가 레트로로 읽힌다.
// 없는 템플릿에서는 undefined 라 변수 자체가 안 걸리고, `.paper` 가 폴백(none)으로 떨어진다.
'--tpl-texture': look.texture,
// ★ 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 를 두면
* 배리에이션을 하나 추가할 때마다 이 파일을 같이 고쳐야 한다.
/*
* ★ 색·서체 토큰을 여기서 만들지 않는다(2026-09-09).
* 캔버스가 빌더 상태로 --tpl-* 를 따로 만들던 동안, 그 값이 발행본과 갈라졌다 —
* 실측: 편집 캔버스 #ffffff·Pretendard ↔ 발행본 #e4dac0·Gugi.
* 이제 미리보기·편집 둘 다 iframe 안 발행본 렌더러가 그리고, 토큰은 payload 하나에서 온다.
*/
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">
@ -175,10 +106,10 @@ export function CanvasView() {
)}
<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])}
>
{/* ★ 이 껍데기는 토큰도 폭도 정하지 않는다(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">
@ -193,10 +124,22 @@ export function CanvasView() {
</div>
)}
{/* 섹션은 좌측 패널이 정한 순서 그대로 그린다. */}
<div className="divide-y divide-black/5">
{sections.map((section) => renderSection(section))}
</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"

View File

@ -0,0 +1,146 @@
/**
* 미리보기 — 발행본을 **iframe 으로** 띄운다.
*
* ★ 왜 iframe 인가 (2026-09-09)
* 처음엔 발행본 컴포넌트를 빌더 안에 직접 그렸다. 렌더러도 payload 도 토큰도 같게 맞췄고
* 섹션 15개의 계산 스타일(색·서체·여백)까지 동일해졌는데, **레이아웃 폭이 어긋났다.**
* 미디어 쿼리는 창 폭을 보는데 미리보기의 실제 사이트 폭은 그 안의 프레임이기 때문이다.
* 실측(1400px 창 · 1024px 프레임, Playwright):
* festival 2560px → 6027px · guide 1168 → 2168 · location 586 → 1135
* 내용(글자 수)은 완전히 같은데 그리드 컬럼 수만 달라 두 배씩 길어졌다. 전체 픽셀 차이 89%.
* "같은 렌더러를 쓴다" 만으로는 안 되고 **뷰포트가 같아야** 한다.
*
* iframe 은 자체 뷰포트를 가진다 — 폭을 390/768/1024 로 주면 발행본이 그 폭에서 보는 것과
* 같은 미디어 쿼리가 걸린다. 해상도 전환도 그제서야 진짜가 된다.
*
* ★ iframe 이 여는 것은 `/preview?placeId=…` — 프리렌더가 굽는 CSR 셸이다
* (`site/scripts/prerender.writePreviewShell`). 그 안에서 발행본 앱이 그대로 돈다.
* ★ 같은 오리진이라 토큰(localStorage)을 iframe 이 그대로 읽는다. 따로 넘기지 않는다.
*/
import {useCallback, useEffect, useRef, useState} from 'react';
import type {ViewportMode} from '@o2o/shared';
/**
* 각 해상도에서 iframe 에 줄 **크기**. 발행본이 그 기기에서 보는 것과 같아야 한다.
*
* ★ 높이도 준다. 폭만 맞추면 `vh` 를 쓰는 자리가 어긋난다 — 히어로가
* `clamp(24rem, 62vh, 36rem)` 이라(`HeroSection`), iframe 이 낮으면 하한 384px 에 걸린다.
* 실측: 발행본 576px ↔ 미리보기 384px. 지연 로딩 이미지도 화면에 덜 들어와 적게 뜬다.
*/
const FRAME_SIZE: Record<ViewportMode, {w: number; h: number}> = {
pc: {w: 1024, h: 800},
tablet: {w: 768, h: 1024},
mobile: {w: 390, h: 844},
};
export function SitePreview({
placeId,
viewport,
interactive = false,
selectedId = null,
onSelect,
}: {
placeId: string | null;
viewport: ViewportMode;
/** 편집 모드 — 섹션을 눌러 고를 수 있게 한다. 미리보기에서는 끈다. */
interactive?: boolean;
selectedId?: string | null;
onSelect?: (sectionId: string) => void;
}) {
const ref = useRef<HTMLIFrameElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
/*
* ★ iframe 은 **진짜 폭(1024·768·390)** 을 유지하고, 자리에 안 들어가면 축소해서 넣는다.
* 폭을 줄여 맞추면 미디어 쿼리가 그 좁은 폭을 보고 발행본과 다른 그리드가 된다 —
* 그걸 피하려고 iframe 을 쓴 것이라 여기서 무너뜨리면 안 된다.
* 기기 미리보기 도구가 쓰는 방식과 같다: 크기는 그대로, 그림만 줄인다.
*/
useEffect(() => {
const box = boxRef.current;
if (!box) return;
const fit = () => {
const avail = box.clientWidth;
setScale(avail > 0 ? Math.min(1, avail / FRAME_SIZE[viewport].w) : 1);
};
fit();
const ro = new ResizeObserver(fit);
ro.observe(box);
return () => ro.disconnect();
}, [viewport]);
/**
* 편집 모드에서 iframe 안 섹션을 고를 수 있게 한다.
*
* ★ 같은 오리진이라 안쪽 문서를 그대로 만질 수 있다. 굳이 postMessage 를 쓰지 않는다.
* ★ 어느 섹션인지는 `data-editor-id` 로 안다 — 화면 id(`gallery`)와 설정 id(`photos`)가
* 달라서, 그 다리를 발행본 렌더러가 놓아 준다(`site/pages/HomePage`).
*/
const wire = useCallback(() => {
const doc = ref.current?.contentDocument;
if (!doc || !interactive) return;
let style = doc.getElementById('editor-outline') as HTMLStyleElement | null;
if (!style) {
style = doc.createElement('style');
style.id = 'editor-outline';
doc.head.appendChild(style);
}
// ★ outline 을 쓴다(border 아님). 상자 크기를 바꾸지 않아 발행본과 레이아웃이 그대로다.
style.textContent = `
[data-editor-id] > * { cursor: pointer; }
[data-editor-id]:hover > * { outline: 2px dashed rgb(59 130 246 / .5); outline-offset: -2px; }
[data-editor-id][data-selected='true'] > * { outline: 2px solid rgb(59 130 246); outline-offset: -2px; }
`;
doc.querySelectorAll<HTMLElement>('[data-editor-id]').forEach((el) => {
el.dataset.selected = String(el.dataset.editorId === selectedId);
});
const onClick = (event: Event) => {
const host = (event.target as HTMLElement | null)?.closest<HTMLElement>('[data-editor-id]');
const id = host?.dataset.editorId;
if (id) onSelect?.(id);
};
doc.addEventListener('click', onClick);
return () => doc.removeEventListener('click', onClick);
}, [interactive, selectedId, onSelect]);
useEffect(() => wire(), [wire]);
if (!placeId) {
return (
<div className="text-muted-foreground flex min-h-[40vh] items-center justify-center text-sm">
사업장을 먼저 만들어 주세요
</div>
);
}
const {w, h} = FRAME_SIZE[viewport];
return (
<div ref={boxRef} className="flex w-full justify-center overflow-hidden">
{/* 축소한 만큼 실제 차지하는 크기도 줄여 준다 — 안 그러면 아래쪽에 빈 공간이 남는다. */}
<div style={{width: w * scale, height: h * scale}}>
<iframe
ref={ref}
onLoad={wire}
// ★ key 에 해상도를 넣어 바뀌면 새로 띄운다. 같은 문서를 리사이즈만 하면 이미 지나간
// 미디어 쿼리 분기(그리드 컬럼 수)가 그대로 남는 경우가 있다.
key={viewport}
title="발행본 미리보기"
src={`/preview?placeId=${encodeURIComponent(placeId)}`}
// ★ 발행본과 같은 오리진이라 sandbox 를 걸지 않는다 — 걸면 토큰을 못 읽는다.
className="block shrink-0 border-0 bg-white"
style={{
width: w,
height: h,
transform: `scale(${scale})`,
transformOrigin: 'top left',
}}
/>
</div>
</div>
);
}

View File

@ -495,6 +495,77 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
],
},
event: {
kind: 'event',
label: '소식',
sample: JSON.stringify(
{
kind: 'event',
version: 1,
title: '소식',
items: [
{
title: '가을 평일 2박 할인',
kind: '이벤트',
startDate: '2026-09-01',
endDate: '2026-10-31',
summary: '평일 2박 이상 예약하시면 1박 요금의 20%를 빼 드립니다.',
howTo: "예약 시 요청사항에 '가을 평일' 이라고 적어 주세요.",
postUrl: 'https://www.instagram.com/p/xxxxxxxxxxx/',
verified: '확인',
},
{
title: '10월 정기 휴무 안내',
kind: '공지',
startDate: '2026-10-14',
endDate: '2026-10-16',
body: '설비 점검으로 사흘간 쉽니다. 이 기간 예약은 받지 않습니다.',
verified: '확인',
},
],
},
null,
2,
),
/*
* ★ 이 아이템만 프롬프트가 다르다. 나머지는 지역을 리서치해 오는 것이지만
* 소식은 **이 가게에서 지금 실제로 하는 일**이다 — 모델이 알 수 없고, 지어내면
* 손님이 없는 행사를 보고 찾아온다. 그래서 "찾아라" 가 아니라 "옮겨 적어라" 다.
*/
task: `[해야 할 일]
아래 붙여넣은 인스타그램 게시물·공지 글을 소식 JSON 으로 **옮겨 적는다.** 찾지 말고 옮긴다.
[여기에 원문을 붙여넣으세요]
[스키마]
{ "kind":"event", "version":1, "title":"소식", "items":[
{ "title":"소식 제목", "kind":"이벤트|공지",
"startDate":"2026-09-01", "endDate":"2026-10-31",
"summary":"카드에 보일 한두 문장", "body":"긴 본문(공지처럼 여러 줄일 때만)",
"howTo":"어떻게 참여하나",
"postUrl":"https://www.instagram.com/p/...",
"verified":"확인|확인필요" } ] }`,
rules: `
[이 아이템만의 규칙]
· 원문에 없는 날짜·혜택·조건을 만들지 않는다. 안 적혀 있으면 그 필드를 뺀다.
· 기간이 지난 소식은 넣지 않는다 — 끝난 행사를 걸어 두면 손님이 헛걸음한다.
· postUrl 은 붙여넣은 원문의 주소 그대로다. 없으면 넣지 않는다.
· 사장님이 직접 옮긴 것이므로 verified 는 "확인" 이다.
`,
fields: [
{key: 'title', label: '제목'},
{key: 'kind', label: '종류', type: 'text', options: ['이벤트', '공지'], half: true},
{key: 'startDate', label: '시작일', type: 'text', hint: '2026-09-01', half: true},
{key: 'endDate', label: '종료일', type: 'text', hint: '2026-10-31', half: true},
{key: 'summary', label: '요약', type: 'area', hint: '카드에 보이는 한두 문장'},
{key: 'body', label: '본문', type: 'area', hint: '공지처럼 여러 줄일 때만'},
{key: 'howTo', label: '참여 방법', type: 'area'},
{key: 'postUrl', label: '원문 주소', type: 'text'},
...SOURCE_FIELDS,
],
},
itinerary: {
kind: 'itinerary',
label: '추천 일정',

View File

@ -69,10 +69,14 @@ export function usePlaceSync(placeId: string | null, options?: ApplyPlaceOptions
* `npm run orval` 을 돌리면 이 캐스팅은 지운다.
*/
const savedTheme = (siteQuery.data?.site as {theme?: SiteThemePayload} | undefined)?.theme;
// ★ templateId 는 theme 안이 아니라 **sites.template_id 컬럼**이다(Req_SiteTheme 주석).
// 같이 얹지 않으면 에디터가 늘 업종 첫 템플릿으로 그려진다 — 사장님이 '옛 항구'를 골라
// 발행해도 다시 들어오면 편집 화면만 흰 바탕·고딕이었다(실측 2026-09-09).
const savedTemplateId = siteQuery.data?.site?.template_id ?? null;
useEffect(() => {
if (!placeId) return;
applyTheme(savedTheme);
}, [placeId, savedTheme, applyTheme]);
applyTheme(savedTheme, savedTemplateId);
}, [placeId, savedTheme, savedTemplateId, applyTheme]);
return {
/** 아직 다 못 읽었다 — 이 동안 데모 화면을 보여주면 남의 가게를 자기 가게로 오해한다. */

View File

@ -139,7 +139,7 @@ interface BuilderState {
setSectionVariant: (sectionId: string, variantId: string) => void;
resetSectionVariants: () => void;
/** 서버에 저장돼 있던 디자인을 화면에 얹는다(새로고침·다른 기기 복원). */
applyTheme: (theme: SiteThemePayload | null | undefined) => void;
applyTheme: (theme: SiteThemePayload | null | undefined, savedTemplateId?: string | null) => void;
updateField: (id: string, value: string) => void;
updateFieldLabel: (id: string, label: string) => void;
@ -615,11 +615,11 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
* 반대로 저장값에 없는 시드 섹션은 **뒤에 붙인다**: 새로 생긴 섹션이 조용히 사라지면
* 사장님은 그 기능이 있는 줄도 모른다.
*/
applyTheme: (theme) => {
if (!theme) return;
applyTheme: (theme, savedTemplateId) => {
if (!theme && !savedTemplateId) return;
set((state) => {
const seedById = new Map(state.sections.map((sec) => [sec.id, sec]));
const ordered = (theme.sections ?? [])
const ordered = (theme?.sections ?? [])
// ★ 반환 타입을 못 박는다. 추론에 맡기면 description 이 **필수** 키로 좁혀져
// (SectionItem 에서는 선택 키다) 아래 filter 의 타입 술어가 컴파일되지 않는다.
.map((saved): SectionItem | null => {
@ -646,18 +646,32 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
.filter((sec): sec is SectionItem => sec !== null);
return {
/*
* ★ 저장된 템플릿을 **되살린다** (2026-09-09).
* 이게 없어서 에디터가 늘 업종 첫 템플릿(심플)으로 그려졌다 — 사장님이 '옛 항구'를
* 골라 발행해도, 다시 들어오면 편집 화면만 흰 바탕·고딕이었다.
* 실측: 편집 캔버스 --tpl-bg #ffffff · Pretendard ↔ 발행본 #e4dac0 · Gugi.
* templateId 는 theme 안이 아니라 **sites.template_id 컬럼**에 있다
* (protocol.Req_SiteTheme 주석 — theme 저장 body 에는 없다). 그래서 따로 받는다.
* ★ 시드에 없는 id 는 무시한다. 옛 이름이 남아 있을 수 있고, 없는 템플릿으로
* 두면 resolveTemplate 이 첫 항목으로 떨어져 지금과 같은 상태가 된다.
*/
...(savedTemplateId &&
INDUSTRY_CONFIGS[state.industry].templates.some((t) => t.id === savedTemplateId)
? {templateId: savedTemplateId}
: {}),
sections: [...ordered, ...seedById.values()],
colorPaletteId: theme.colorPaletteId ?? null,
colorPaletteId: theme?.colorPaletteId ?? null,
infoFields: [
...state.infoFields,
...(theme.customInfoFields ?? []).filter(
...(theme?.customInfoFields ?? []).filter(
(custom) => !state.infoFields.some((field) => field.id === custom.id),
),
],
photos: theme.visiblePhotoIds
photos: theme?.visiblePhotoIds
? state.photos.map((photo) => ({
...photo,
isVisible: theme.visiblePhotoIds?.includes(photo.id),
isVisible: theme?.visiblePhotoIds?.includes(photo.id),
}))
: state.photos,
};

View File

@ -4,6 +4,7 @@
"types": ["vite/client", "node"],
"paths": {
"@/*": ["./src/*"],
"@site/*": ["../site/src/*"],
"@o2o/shared": ["../shared/src/index.ts"],
"@o2o/shared/*": ["../shared/src/*"]
}

View File

@ -18,6 +18,12 @@ export default defineConfig({
// 워크스페이스 심볼릭 링크를 타지 않고 소스를 직접 가리킨다 —
// shared 를 고치면 빌드 없이 HMR 이 바로 돈다.
'@o2o/shared': path.resolve(__dirname, '../shared/src'),
// ★ 발행본 렌더러를 **그대로** 쓴다(2026-09-09). 미리보기와 발행본이 컴포넌트를 두 벌
// 두면 같은 데이터로도 다른 그림이 나온다 — 실측으로 캔버스는 소개 섹션을 설명
// 문구로 채워 그렸는데 발행본은 데이터가 0자라 섹션째 뺐다. 사장님은 채워진 화면을
// 보고 발행해 절반이 사라진 페이지를 받았다.
// 저쪽 별칭이 '@' 가 아니라 '@site' 인 것도 이 때문이다(solution/site/vite.config.ts).
'@site': path.resolve(__dirname, '../site/src'),
},
},
// ★ 산출물이 `dist/` 가 아니라 `build/client/` 로 나간다(프레임워크 모드 기본값).

View File

@ -18,7 +18,7 @@ import {
sanitizePayloadForPublish,
SECTION_ITEM_REQUIRED_KEY,
} from '@o2o/shared';
import {render} from '@/entry-server';
import {render} from '@site/entry-server';
import {
collectJsonLd,
homeMeta,
@ -34,8 +34,8 @@ import {
type SiteEntry,
verifyGeo,
verifyJsonLd,
} from '@/seo';
import {MOONLIGHT_STAY_PAYLOAD} from '@/fixtures/moonlight-stay';
} from '@site/seo';
import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay';
/**
* 발행 사이트 프리렌더.
@ -667,6 +667,45 @@ function writeSharedAssets(destRoot: string, referenced: Set<string>) {
* 발행은 **바뀐 사이트 하나만** 굽는다(scripts/watch-payloads.mjs). 이번 실행분만 인덱스에
* 담으면 나머지 사이트가 인덱스에서 사라진다. 디스크에 실제로 존재하는 발행본이 곧 정답이다.
*/
/**
* 빌더 미리보기가 iframe 으로 띄우는 CSR 셸.
*
* ★ 왜 iframe 인가 (2026-09-09)
* 빌더 안에 발행본 컴포넌트를 **직접** 그려 봤는데, 색·서체·섹션 구성이 같아져도
* **레이아웃 폭이 어긋났다.** 미디어 쿼리는 창 폭을 보는데 미리보기의 실제 사이트 폭은
* 그 안의 프레임(max-w-5xl)이기 때문이다. 실측(1400px 창 · 1024px 프레임):
* festival 2560px → 6027px, guide 1168 → 2168, location 586 → 1135.
* 내용(글자 수)은 완전히 같은데 그리드 컬럼 수만 달라 두 배씩 길어졌다.
* iframe 은 자체 뷰포트를 가져서 미디어 쿼리가 발행본과 **정확히 같은 폭**을 본다.
* PC/태블릿/모바일 전환도 iframe 폭만 바꾸면 그대로 맞는다.
*
* ★ payload 는 셸이 아니라 브라우저가 가져온다(`entry-client`). 굽는 시점에는
* 어느 사업장을 미리 볼지 모르고, 미리보기는 **발행 전 최신 값**을 봐야 한다.
*/
function writePreviewShell(outRoot: string, assets: {script: string; css: string[]}) {
const lines = [
'<!doctype html>',
'<html lang="ko">',
' <head>',
' <meta charset="utf-8" />',
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
// ★ 미리보기는 색인 대상이 아니다. 발행 전 값이라 검색에 걸리면 안 된다.
' <meta name="robots" content="noindex, nofollow" />',
' <title>미리보기</title>',
...assets.css.map((file) => ` <link rel="stylesheet" href="/assets/${file}" />`),
' </head>',
' <body class="site">',
' <div id="root"></div>',
` <script type="module" src="/assets/${assets.script}"></script>`,
' </body>',
'</html>',
];
const dir = join(outRoot, 'preview');
mkdirSync(dir, {recursive: true});
writeFileSync(join(dir, 'index.html'), lines.join('\n'), 'utf-8');
console.log(' ✓ 빌더 미리보기 셸 (/preview)');
}
function writeRootMachineFiles(outRoot: string, origin: string) {
const sitesDir = join(outRoot, SITE_DIR);
if (!existsSync(sitesDir)) return;
@ -834,6 +873,7 @@ function main() {
// ★ 실패한 사이트가 있어도 루트 파일은 갱신한다 — 성공한 사이트까지 색인에서 빠질 이유가 없다.
// (인덱스는 디스크를 훑으므로 실패한 사이트는 애초에 들어가지 않는다.)
writePreviewShell(args.out, assets);
if (origin) writeRootMachineFiles(args.out, origin);
if (failed > 0) {

View File

@ -1,14 +1,14 @@
import type {ReactNode} from 'react';
import type {SitePayload} from '@o2o/shared';
import {MobileTabBar, SiteFooter, SiteHeader} from '@/sections';
import {SiteProvider} from '@/lib/site-context';
import {layoutOf} from '@/lib/layout';
import {Shell as ReservationShell} from '@/layouts/reservation/Shell';
import {Shell as OasiShell} from '@/layouts/oasi/Shell';
import {Shell as StudioShell} from '@/layouts/studio/Shell';
import {Shell as PastelShell} from '@/layouts/pastel/Shell';
import {Shell as EditorialShell} from '@/layouts/editorial/Shell';
import {HomePage} from '@/pages';
import {MobileTabBar, SiteFooter, SiteHeader} from '@site/sections';
import {SiteProvider} from '@site/lib/site-context';
import {layoutOf} from '@site/lib/layout';
import {Shell as ReservationShell} from '@site/layouts/reservation/Shell';
import {Shell as OasiShell} from '@site/layouts/oasi/Shell';
import {Shell as StudioShell} from '@site/layouts/studio/Shell';
import {Shell as PastelShell} from '@site/layouts/pastel/Shell';
import {Shell as EditorialShell} from '@site/layouts/editorial/Shell';
import {HomePage} from '@site/pages';
/**
* 발행 사이트 — **한 장짜리다.**

View File

@ -2,6 +2,7 @@ import {StrictMode} from 'react';
import {createRoot, hydrateRoot} from 'react-dom/client';
import type {SitePayload} from '@o2o/shared';
import {App} from './App';
import {fontHref, themeVars} from '@site/seo/head';
import './index.css';
declare global {
@ -14,6 +15,65 @@ declare global {
const container = document.getElementById('root')!;
const injected = window.__SITE_PAYLOAD__;
/**
* 빌더 미리보기(iframe)가 여는 셸에서만 쓰는 경로 — `/preview?placeId=…`.
*
* ★ 왜 여기서 payload 를 가져오나
* 미리보기는 **발행 전 최신 값**을 봐야 한다. 굽는 시점에는 어느 사업장을 미리 볼지
* 모르므로 셸에 payload 를 심을 수 없다(prerender `writePreviewShell`).
* ★ 토큰은 부모(빌더)와 **같은 오리진의 localStorage** 에서 읽는다. 미리보기는 자기 서버가
* 아니라 빌더가 쓰는 것과 같은 API 를 부르므로, 키도 그쪽과 같아야 한다
* (`frontend/src/api/mutator/custom-fetch.ts`).
* ★ 색·서체 토큰과 **웹폰트 링크**를 발행본이 `<head>` 에 굽는 것과 같은 함수로 만든다
* (themeVars · fontHref). 셸은 어느 템플릿인지 모른 채 구워지므로 둘 다 여기서 얹는다.
* ★ 폰트를 빠뜨리면 조용히 틀린다 — 레이아웃·색은 그대로인데 글자만 기본 산세리프로
* 떨어진다. 실측(2026-09-09): 간판체(Gugi)가 안 실려 픽셀 차이가 92% 였는데
* 지오메트리(header·hero·h1 위치)는 발행본과 완전히 같았다.
*/
async function renderPreview(placeId: string) {
const token = (() => {
try {
return localStorage.getItem('o2o-web4ai.accessToken');
} catch {
return null;
}
})();
const res = await fetch(`/v1/place/${placeId}/site/preview`, {
headers: token ? {Authorization: `Bearer ${token}`} : {},
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const payload = (await res.json()) as SitePayload;
for (const [name, value] of Object.entries(themeVars(payload))) {
document.documentElement.style.setProperty(name, value);
}
const font = document.createElement('link');
font.rel = 'stylesheet';
font.href = fontHref(payload);
document.head.appendChild(font);
// 글자가 기본 서체로 한 번 그려졌다 바뀌는 것을 줄인다. 못 받아도 렌더는 계속한다.
await new Promise<void>((resolve) => {
font.addEventListener('load', () => resolve(), {once: true});
font.addEventListener('error', () => resolve(), {once: true});
setTimeout(resolve, 2500);
});
try {
await document.fonts.ready;
} catch {
/* 폰트 API 가 없거나 실패해도 그린다 */
}
createRoot(container).render(
<StrictMode>
<App payload={payload} />
</StrictMode>,
);
}
const previewPlaceId = new URLSearchParams(window.location.search).get('placeId');
if (injected) {
// 정적 HTML 위에 하이드레이션. 서버가 그린 마크업과 1:1 이어야 하므로
// 여기서 payload 를 바꾸거나 fetch 를 걸지 않는다.
@ -23,6 +83,11 @@ if (injected) {
<App payload={injected} />
</StrictMode>,
);
} else if (previewPlaceId) {
void renderPreview(previewPlaceId).catch((ex: unknown) => {
// 미리보기가 못 떠도 편집은 계속돼야 한다. 부모 창이 읽을 수 있게 이유를 남긴다.
container.textContent = `미리보기를 불러오지 못했습니다 — ${ex instanceof Error ? ex.message : String(ex)}`;
});
} else {
// 개발 서버(vite dev) — 데모 payload 로 CSR 렌더. 프로덕션 경로가 아니다.
void import('./fixtures/moonlight-stay').then(({MOONLIGHT_STAY_PAYLOAD}) => {

View File

@ -1,7 +1,7 @@
import {useSite} from '@/lib/site-context';
import {primaryImage} from '@/seo/meta';
import {useSite} from '@site/lib/site-context';
import {primaryImage} from '@site/seo/meta';
import {
bookingLabel,
bookingActionLabel,
bookingLinks,
contactLinks,
channelLabel,
@ -9,7 +9,7 @@ import {
lowestPrice,
unitSpec,
unitViews,
} from '@/lib/derive';
} from '@site/lib/derive';
import {GRAIN} from './SectionHead';
/**
@ -117,7 +117,7 @@ export function Hero() {
rel="noopener noreferrer"
className="tap inline-flex items-center underline underline-offset-4"
>
{bookingLabel(booking)} 예약
{bookingActionLabel(booking)}
</a>
)}
{place.phone && (

View File

@ -1,13 +1,13 @@
import type {ChannelLink} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {useSite} from '@site/lib/site-context';
import {
bookingLabel,
bookingActionLabel,
bookingLinks,
sectionName,
unitSpec,
unitViews,
type UnitView,
} from '@/lib/derive';
} from '@site/lib/derive';
import {GRAIN, SectionHead} from './SectionHead';
/**
@ -153,7 +153,7 @@ function UnitSpread({
rel="noopener noreferrer"
className="tap inline-flex items-center underline underline-offset-4"
>
{bookingLabel(booking)} 예약
{bookingActionLabel(booking)}
</a>
)}
{phone && (

View File

@ -1,6 +1,6 @@
import type {ReactNode} from 'react';
import {useSite} from '@/lib/site-context';
import {enabledSections, unitSpec} from '@/lib/derive';
import {useSite} from '@site/lib/site-context';
import {enabledSections, unitSpec} from '@site/lib/derive';
/**
* 편집(잡지)형 — 섹션 제목 · 차례.

View File

@ -1,9 +1,9 @@
import {useEffect, useState, type ReactNode} from 'react';
import {useSite} from '@/lib/site-context';
import {bookingLabel, bookingLinks, channelLabel} from '@/lib/derive';
import {formatKoreanDate, isoDate} from '@/lib/format';
import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, channelLabel} from '@site/lib/derive';
import {formatKoreanDate, isoDate} from '@site/lib/format';
// 배럴(@/sections)이 아니라 파일을 직접 가리킨다 — 배럴을 타면 섹션 전부가 딸려 온다.
import {MobileTabBar} from '@/sections/MobileTabBar';
import {MobileTabBar} from '@site/sections/MobileTabBar';
import {GRAIN, useSectionIndex, type IndexEntry} from './SectionHead';
/**
@ -60,7 +60,7 @@ export function Shell({children}: {children: ReactNode}) {
rel="noopener noreferrer"
className="tap inline-flex items-center font-bold underline underline-offset-4"
>
{bookingLabel(booking)} 예약
{bookingActionLabel(booking)}
</a>
)}
</div>

View File

@ -1,6 +1,6 @@
import {useSite} from '@/lib/site-context';
import {primaryImage} from '@/seo/meta';
import {bookingLabel, bookingLinks, essentialRows, heroFacts, lowestPrice} from '@/lib/derive';
import {useSite} from '@site/lib/site-context';
import {primaryImage} from '@site/seo/meta';
import {bookingActionLabel, bookingLinks, essentialRows, heroFacts, lowestPrice} from '@site/lib/derive';
/**
* oasi 첫 화면 — 사진 한 장과 아주 작은 글씨.
@ -98,7 +98,7 @@ export function Hero() {
className="tap inline-flex items-center text-[length:var(--fs-sm)] underline underline-offset-[6px]"
style={{color: 'var(--color-brand)'}}
>
{bookingLabel(link)} 예약
{bookingActionLabel(link)}
</a>
</li>
))}

View File

@ -1,5 +1,5 @@
import {useSite} from '@/lib/site-context';
import {bookingLabel, bookingLinks, sectionName, unitSpec, unitViews} from '@/lib/derive';
import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, sectionName, unitSpec, unitViews} from '@site/lib/derive';
import {SectionHead} from './SectionHead';
/**
@ -116,7 +116,7 @@ export function Rooms() {
className="tap inline-flex items-center underline underline-offset-[6px]"
style={{color: 'var(--color-brand)'}}
>
{bookingLabel(booking)} 예약
{bookingActionLabel(booking)}
</a>
)}
{payload.place.phone && (

View File

@ -1,17 +1,18 @@
import type {CSSProperties, ReactNode} from 'react';
import {isoDate} from '@/lib/format';
import {useSite} from '@/lib/site-context';
import {isoDate} from '@site/lib/format';
import {useSite} from '@site/lib/site-context';
import {
bookingActionLabel,
bookingLabel,
bookingLinks,
contactLinks,
heroFacts,
isSectionEnabled,
unitSpec,
} from '@/lib/derive';
} from '@site/lib/derive';
// ★ 배럴(`@/sections`)을 거치면 껍데기 하나가 사이트의 모든 섹션을 끌고 들어온다.
// 껍데기는 하단 고정 바 하나만 필요하다 — 파일을 직접 가리킨다.
import {MobileTabBar} from '@/sections/MobileTabBar';
import {MobileTabBar} from '@site/sections/MobileTabBar';
/**
* 본문 섹션의 색 띠를 걷어낸다 (2026-09-04, 사장님 지적).
@ -299,7 +300,7 @@ export function Shell({children}: {children: ReactNode}) {
className="tap mt-2 inline-flex items-center text-[length:var(--fs-sm)] underline underline-offset-[6px]"
style={{color: 'var(--color-brand)'}}
>
{bookingLabel(booking)} 예약
{bookingActionLabel(booking)}
</a>
)}
</header>

View File

@ -1,5 +1,5 @@
import {useSite} from '@/lib/site-context';
import {bookingLabel, bookingLinks, heroFacts, lowestPrice} from '@/lib/derive';
import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, heroFacts, lowestPrice} from '@site/lib/derive';
/**
* 파스텔의 첫 화면 — **연노랑 판 위에 둥근 사진**.
@ -99,7 +99,7 @@ export function Hero() {
className="tap mt-7 inline-flex items-center justify-center rounded-full px-8 text-[length:var(--fs-sm)] font-extrabold transition-opacity hover:opacity-90"
style={{backgroundColor: 'var(--pastel-ink)', color: 'var(--tpl-bg, #fff)'}}
>
{bookingLabel(links[0])}로 예약
{bookingActionLabel(links[0])}
</a>
)}
</div>

View File

@ -1,6 +1,6 @@
import {ChevronDown, Phone} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {bookingLabel, bookingLinks, sectionName, unitSpec, unitViews} from '@/lib/derive';
import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, sectionName, unitSpec, unitViews} from '@site/lib/derive';
import {SectionHead} from './SectionHead';
/**
@ -112,7 +112,7 @@ export function Rooms() {
className="tap flex flex-1 items-center justify-center rounded-full text-[length:var(--fs-sm)] font-extrabold transition-opacity hover:opacity-90"
style={{backgroundColor: 'var(--pastel-ink)', color: 'var(--tpl-bg, #fff)'}}
>
{bookingLabel(booking)} 예약
{bookingActionLabel(booking)}
</a>
)}
{payload.place.phone && (

View File

@ -1,11 +1,11 @@
import type {CSSProperties, ReactNode} from 'react';
import {Instagram, Menu, MessageCircle, Phone} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {channelLabel, contactLinks, isSectionEnabled, unitSpec} from '@/lib/derive';
import {useSite} from '@site/lib/site-context';
import {channelLabel, contactLinks, isSectionEnabled, unitSpec} from '@site/lib/derive';
// ★ 배럴(@/sections)로 들어가면 UnitsSection → layouts/pastel/Rooms 로 돌아와 순환 참조다.
// 쓰는 파일을 직접 가리킨다.
import {SiteFooter} from '@/sections/SiteFooter';
import {MobileTabBar} from '@/sections/MobileTabBar';
import {SiteFooter} from '@site/sections/SiteFooter';
import {MobileTabBar} from '@site/sections/MobileTabBar';
/**
* 파스텔 — 색면(色面) 블록형 껍데기.

View File

@ -1,5 +1,5 @@
import {useSite} from '@/lib/site-context';
import {bookingLabel, bookingLinks, unitSpec} from '@/lib/derive';
import {useSite} from '@site/lib/site-context';
import {bookingLabel, bookingLinks, unitSpec} from '@site/lib/derive';
/**
* 예약형 첫 화면 — 사진이 화면을 채우고 저절로 넘어가며, 정중앙에 이름이 선다.

View File

@ -1,13 +1,13 @@
import {useSite} from '@/lib/site-context';
import {useSite} from '@site/lib/site-context';
import {
bookingLabel,
bookingActionLabel,
bookingLinks,
essentialRows,
isSectionEnabled,
sectionName,
unitSpec,
unitViews,
} from '@/lib/derive';
} from '@site/lib/derive';
import {SectionHead} from './SectionHead';
/**
@ -175,7 +175,7 @@ export function Rooms() {
rel="noopener noreferrer"
className="tap border-line inline-flex items-center justify-center border px-9 text-[length:var(--fs-sm)] font-light tracking-[0.14em] transition-opacity hover:opacity-70"
>
{bookingLabel(booking)} 예약
{bookingActionLabel(booking)}
</a>
)}
{payload.place.phone && (

View File

@ -1,5 +1,5 @@
import {useEffect, useState, type ReactNode} from 'react';
import {useSite} from '@/lib/site-context';
import {useSite} from '@site/lib/site-context';
import {
bookingLabel,
bookingLinks,
@ -7,11 +7,11 @@ import {
isSectionEnabled,
sectionName,
unitSpec,
} from '@/lib/derive';
import {formatKoreanDate, isoDate} from '@/lib/format';
} from '@site/lib/derive';
import {formatKoreanDate, isoDate} from '@site/lib/format';
// ★ `@/sections` 배럴이 아니라 파일을 직접 가리킨다. 배럴은 섹션 전부를 끌고 오는데,
// 그 안의 UnitsSection 이 이 폴더의 Rooms 를 다시 부른다 — 배럴을 타면 순환이 된다.
import {MobileTabBar} from '@/sections/MobileTabBar';
import {MobileTabBar} from '@site/sections/MobileTabBar';
/**
* 예약형(reservation) 껍데기 — 머리 · 본문 · 꼬리.
@ -59,7 +59,6 @@ function useNavItems(): NavItem[] {
[
{id: 'intro', href: '#about', fallback: '소개'},
{id: spec.path, href: '#units', fallback: spec.label},
{id: 'pricing', href: '#pricing', fallback: '요금'},
{id: 'info', href: '#info', fallback: '이용 정보'},
{id: 'booking', href: '#booking', fallback: '예약 안내'},
{id: 'photos', href: '#gallery', fallback: '사진'},

View File

@ -1,6 +1,6 @@
import {useSite} from '@/lib/site-context';
import {galleryImages} from '@/lib/derive';
import {primaryImage} from '@/seo/meta';
import {useSite} from '@site/lib/site-context';
import {galleryImages} from '@site/lib/derive';
import {primaryImage} from '@site/seo/meta';
/**
* 첫 화면 — **큰 사진 한 장이 아니라 사진 격자다.**

View File

@ -1,5 +1,5 @@
import {useSite} from '@/lib/site-context';
import {sectionName, unitSpec, unitViews} from '@/lib/derive';
import {useSite} from '@site/lib/site-context';
import {sectionName, unitSpec, unitViews} from '@site/lib/derive';
import {SectionHead} from './SectionHead';
/**

View File

@ -1,18 +1,18 @@
import type {ReactNode} from 'react';
import {useSite} from '@/lib/site-context';
import {useSite} from '@site/lib/site-context';
import {
bookingLabel,
bookingActionLabel,
bookingLinks,
channelLabel,
heroFacts,
isSectionEnabled,
lowestPrice,
unitSpec,
} from '@/lib/derive';
import {formatKoreanDate, isoDate} from '@/lib/format';
} from '@site/lib/derive';
import {formatKoreanDate, isoDate} from '@site/lib/format';
// ★ 배럴(@/sections)이 아니라 파일을 직접 가리킨다 — 배럴은 섹션 열여덟을 전부 끌고 오고,
// 그 안에 lib/ui 가 있어 이 폴더로 순환 참조가 돌아온다.
import {MobileTabBar} from '@/sections/MobileTabBar';
import {MobileTabBar} from '@site/sections/MobileTabBar';
/**
* 스튜디오형 껍데기 — **가로 메뉴 바가 없다.**
@ -114,7 +114,7 @@ export function Shell({children}: {children: ReactNode}) {
className="tap mt-5 inline-flex w-full items-center justify-center px-4 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90"
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg)'}}
>
{bookingLabel(booking)}로 예약
{bookingActionLabel(booking)}
</a>
)}

View File

@ -14,7 +14,7 @@ import {
type UnitInfo,
type WeatherMood,
} from '@o2o/shared';
import {BOOKING_CHANNELS, UNIT_SPEC, unitBaseRate} from '@/seo/jsonld';
import {BOOKING_CHANNELS, UNIT_SPEC, unitBaseRate} from '@site/seo/jsonld';
/**
* payload → 화면이 바로 쓰는 모양.
@ -618,6 +618,19 @@ export function contactLinks(payload: SitePayload): ChannelLink[] {
/* ── 숙박 예약(origin/main 의 feature/stay-booking) ─────────────────────
* ★ 이 블록은 예약 작업이 넣은 것이다. DB 재구성 병합 때 derive.ts 를 이쪽 버전으로
* 가져오면서 빠질 뻔했다 — 예약 화면(StayBookingSection)이 이 셋에 의존한다. */
/**
* "○○ 예약" 한 줄. 라벨이 이미 '예약' 으로 끝나면 그대로 둔다.
*
* ★ 실측(2026-09-09, 스테이,머뭄): 네이버 예약 채널이 붙자 버튼이 **"네이버 예약 예약"** 이 됐다.
* `{bookingLabel(link)} 예약` 을 네 곳에서 각자 이어 붙이고 있었기 때문이다 —
* 문구를 만드는 자리가 여럿이면 채널이 하나 늘 때 그중 몇 곳만 고쳐진다.
*/
export function bookingActionLabel(link: ChannelLink, prefix?: string): string {
const name = bookingLabel(link);
const phrase = name.endsWith('예약') ? name : `${name} 예약`;
return prefix ? `${prefix} ${phrase}` : phrase;
}
/**
* 예약 버튼에 찍을 말.
*

View File

@ -1,4 +1,4 @@
import {useSite} from '@/lib/site-context';
import {useSite} from '@site/lib/site-context';
/**
* 레이아웃(=템플릿의 뼈대) 하나.

View File

@ -14,12 +14,12 @@
* ★ 바탕(tone)은 세 가지뿐이다. 섹션마다 새 배경색을 만들면 페이지가 색 띠의 나열이 된다.
*/
import type {ReactNode} from 'react';
import {useLayout} from '@/lib/layout';
import {SectionHead as ReservationHead} from '@/layouts/reservation/SectionHead';
import {SectionHead as OasiHead} from '@/layouts/oasi/SectionHead';
import {SectionHead as StudioHead} from '@/layouts/studio/SectionHead';
import {SectionHead as PastelHead} from '@/layouts/pastel/SectionHead';
import {SectionHead as EditorialHead} from '@/layouts/editorial/SectionHead';
import {useLayout} from '@site/lib/layout';
import {SectionHead as ReservationHead} from '@site/layouts/reservation/SectionHead';
import {SectionHead as OasiHead} from '@site/layouts/oasi/SectionHead';
import {SectionHead as StudioHead} from '@site/layouts/studio/SectionHead';
import {SectionHead as PastelHead} from '@site/layouts/pastel/SectionHead';
import {SectionHead as EditorialHead} from '@site/layouts/editorial/SectionHead';
export type SectionTone = 'base' | 'alt' | 'dark';

View File

@ -12,16 +12,14 @@ import {
InquirySection,
ITEM_SECTIONS,
LocalGuideSection,
PricingSection,
WeatherSection,
LocationSection,
SpaceSection,
UnitsSection,
StayBookingSection,
} from '@/sections';
import {Fragment} from 'react';
import {useSite} from '@/lib/site-context';
import {hasSection, isSectionEnabled} from '@/lib/derive';
} from '@site/sections';
import {useSite} from '@site/lib/site-context';
import {hasSection, isSectionEnabled} from '@site/lib/derive';
/**
* 홈.
@ -59,7 +57,6 @@ export function HomePage() {
intro: AboutSection,
info: EssentialInfoSection,
rooms: UnitsSection,
pricing: PricingSection,
menu: UnitsSection,
programs: UnitsSection,
booking: BookingSection,
@ -94,10 +91,16 @@ export function HomePage() {
if (rendered.has(key)) return null;
rendered.add(key);
return (
<Fragment key={section.id}>
/*
* ★ `display: contents` 라 상자를 만들지 않는다 — 레이아웃에 손대지 않고
* **어느 섹션 설정에서 나온 것인지**만 표시한다. 빌더 편집 모드가 이 값으로
* 클릭한 자리를 왼쪽 목록의 섹션과 잇는다(`builder/SitePreview`).
* 화면 id(`gallery`)와 설정 id(`photos`)가 달라서 필요하다.
*/
<div key={section.id} data-editor-id={section.id} style={{display: 'contents'}}>
<Component />
{section.id === 'intro' && <AnswerBlock />}
</Fragment>
</div>
);
})}

View File

@ -1,6 +1,6 @@
import {useSite} from '@/lib/site-context';
import {galleryImages, sectionBody} from '@/lib/derive';
import {Section} from '@/lib/ui';
import {useSite} from '@site/lib/site-context';
import {galleryImages, sectionBody} from '@site/lib/derive';
import {Section} from '@site/lib/ui';
/**
* 소개.

View File

@ -1,7 +1,7 @@
import {useSite} from '@/lib/site-context';
import {factualSummary} from '@/seo/meta';
import {essentialRows} from '@/lib/derive';
import {formatKoreanDate} from '@/lib/format';
import {useSite} from '@site/lib/site-context';
import {factualSummary} from '@site/seo/meta';
import {essentialRows} from '@site/lib/derive';
import {formatKoreanDate} from '@site/lib/format';
/**
* 핵심 정보 요약 — AEO 의 핵심 블록.

View File

@ -1,7 +1,7 @@
import {Phone} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {bookingLinks, bookingRows, bookingCtaLabel, sectionName} from '@/lib/derive';
import {ActionLink, Field, Section} from '@/lib/ui';
import {useSite} from '@site/lib/site-context';
import {bookingLinks, bookingRows, bookingCtaLabel, sectionName} from '@site/lib/derive';
import {ActionLink, Field, Section} from '@site/lib/ui';
/**
* 예약 안내.

View File

@ -1,9 +1,9 @@
import {Phone} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {bookingLabel, bookingLinks, essentialRows, RULE_FACT_KEYS} from '@/lib/derive';
import {formatKoreanDate} from '@/lib/format';
import type {InfoRow} from '@/lib/derive';
import {Section} from '@/lib/ui';
import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, essentialRows, RULE_FACT_KEYS} from '@site/lib/derive';
import {formatKoreanDate} from '@site/lib/format';
import type {InfoRow} from '@site/lib/derive';
import {Section} from '@site/lib/ui';
/**
* 이용 정보 표 전체.
@ -169,7 +169,7 @@ function BookingRow() {
className="tap inline-flex items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90"
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
>
{bookingLabel(link)}로 예약
{bookingActionLabel(link)}
</a>
))}
</div>

View File

@ -1,6 +1,6 @@
import {useSite} from '@/lib/site-context';
import {exhibitionRows, sectionName} from '@/lib/derive';
import {Field, Section} from '@/lib/ui';
import {useSite} from '@site/lib/site-context';
import {exhibitionRows, sectionName} from '@site/lib/derive';
import {Field, Section} from '@site/lib/ui';
/**
* 전시 · 관람 안내.

View File

@ -1,6 +1,6 @@
import {useSite} from '@/lib/site-context';
import {faqList} from '@/lib/derive';
import {Section} from '@/lib/ui';
import {useSite} from '@site/lib/site-context';
import {faqList} from '@site/lib/derive';
import {Section} from '@site/lib/ui';
/**
* FAQ.

View File

@ -16,10 +16,10 @@
import {useState} from 'react';
import {ArrowUpRight, MapPin} from 'lucide-react';
import type {FestivalEntry} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {naverSearchUrl} from '@/lib/format';
import {useLiveSeasons} from '@/lib/use-live-seasons';
import {Section} from '@/lib/ui';
import {useSite} from '@site/lib/site-context';
import {naverSearchUrl} from '@site/lib/format';
import {useLiveSeasons} from '@site/lib/use-live-seasons';
import {Section} from '@site/lib/ui';
// shared 의 SEASON_ORDER 는 내보내지 않는다. 순서는 달력이 정한 것이라 갈릴 일이 없다.
const SEASONS = ['봄', '여름', '가을', '겨울'];

View File

@ -1,8 +1,8 @@
import {useCallback, useEffect, useRef, useState} from 'react';
import {ChevronLeft, ChevronRight, X} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {galleryImages} from '@/lib/derive';
import {Section} from '@/lib/ui';
import {useSite} from '@site/lib/site-context';
import {galleryImages} from '@site/lib/derive';
import {Section} from '@site/lib/ui';
/**
* 사진 갤러리.

View File

@ -1,7 +1,7 @@
import {useCallback, useEffect, useRef, useState} from 'react';
import {ChevronLeft, ChevronRight, Phone} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {bookingLabel, bookingLinks, heroFacts, lowestPrice} from '@/lib/derive';
import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, heroFacts, lowestPrice} from '@site/lib/derive';
/**
* 첫 화면 — 국내 펜션 사이트의 정석(`hero.slideshow`).
@ -196,7 +196,7 @@ export function HeroPension() {
className="tap inline-flex items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90"
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
>
{bookingLabel(links[0])} 예약
{bookingActionLabel(links[0])}
</a>
)}
</div>

View File

@ -1,15 +1,15 @@
import {ChevronDown, MapPin} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {primaryImage} from '@/seo/meta';
import {bookingLinks, unitSpec} from '@/lib/derive';
import {useSite} from '@site/lib/site-context';
import {primaryImage} from '@site/seo/meta';
import {bookingLinks, unitSpec} from '@site/lib/derive';
import {HeroPension} from './HeroPension';
import {HeroSplit} from './HeroSplit';
import {useLayout} from '@/lib/layout';
import {Hero as ReservationHero} from '@/layouts/reservation/Hero';
import {Hero as OasiHero} from '@/layouts/oasi/Hero';
import {Hero as StudioHero} from '@/layouts/studio/Hero';
import {Hero as PastelHero} from '@/layouts/pastel/Hero';
import {Hero as EditorialHero} from '@/layouts/editorial/Hero';
import {useLayout} from '@site/lib/layout';
import {Hero as ReservationHero} from '@site/layouts/reservation/Hero';
import {Hero as OasiHero} from '@site/layouts/oasi/Hero';
import {Hero as StudioHero} from '@site/layouts/studio/Hero';
import {Hero as PastelHero} from '@site/layouts/pastel/Hero';
import {Hero as EditorialHero} from '@site/layouts/editorial/Hero';
/**
* 첫 화면.

View File

@ -1,6 +1,6 @@
import {Phone} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {bookingLabel, bookingLinks, heroFacts, lowestPrice, placeRow} from '@/lib/derive';
import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, heroFacts, lowestPrice, placeRow} from '@site/lib/derive';
/**
* 첫 화면 — 반으로 가른 형(`hero.split`).
@ -102,7 +102,7 @@ export function HeroSplit() {
className="tap flex flex-1 items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90"
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
>
{bookingLabel(links[0])} 예약
{bookingActionLabel(links[0])}
</a>
)}
{place.phone && (

View File

@ -1,7 +1,7 @@
import {ArrowUpRight, Mail, MessageCircle, Phone} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {channelLabel, contactLinks, sectionName} from '@/lib/derive';
import {Section} from '@/lib/ui';
import {useSite} from '@site/lib/site-context';
import {channelLabel, contactLinks, sectionName} from '@site/lib/derive';
import {Section} from '@site/lib/ui';
/**
* 문의 · 연락.

View File

@ -1,9 +1,9 @@
import {ArrowUpRight, MapPin, Utensils} from 'lucide-react';
import type {LocalPlace} from '@o2o/shared';
import {useState} from 'react';
import {useSite} from '@/lib/site-context';
import {formatKoreanDate, naverSearchUrl} from '@/lib/format';
import {Carousel, CarouselSlide, Section} from '@/lib/ui';
import {useSite} from '@site/lib/site-context';
import {formatKoreanDate, naverSearchUrl} from '@site/lib/format';
import {Carousel, CarouselSlide, Section} from '@site/lib/ui';
/**
* 거리 탭.

View File

@ -1,8 +1,8 @@
import {useState} from 'react';
import {Car, Check, Copy, Navigation} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {kakaoDirectionsUrl, naverDirectionsUrl, osmEmbedUrl, tmapDirectionsUrl} from '@/lib/format';
import {Section} from '@/lib/ui';
import {useSite} from '@site/lib/site-context';
import {kakaoDirectionsUrl, naverDirectionsUrl, osmEmbedUrl, tmapDirectionsUrl} from '@site/lib/format';
import {Section} from '@site/lib/ui';
/**
* 오시는 길.

View File

@ -1,6 +1,6 @@
import {MapPin, MessageCircle, Phone} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {bookingLabel, bookingLinks, channelLabel} from '@/lib/derive';
import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, channelLabel} from '@site/lib/derive';
/**
* 좁은 화면 하단 고정 바.
@ -70,7 +70,7 @@ export function MobileTabBar() {
>
{/* ★ channelLabel() 은 수집된 제목("스테이,머뭄 네이버 플레이스")을 준다 —
390px 바에서 버튼 밖으로 넘쳤다. 버튼이 답할 건 '어디서 예약하나' 하나다. */}
{bookingLabel(booking)} 예약
{bookingActionLabel(booking)}
</a>
)}
</nav>

View File

@ -1,86 +0,0 @@
import {useSite} from '@/lib/site-context';
import {placeRow, sectionName, unitViews} from '@/lib/derive';
import {Section} from '@/lib/ui';
/**
* 요금 안내.
*
* ★ 왜 객실 카드 안의 세 칸으로 부족한가
* 손님이 실제로 하는 일은 **동끼리 값을 견주는 것**이다. 카드마다 흩어 놓으면
* 두 동의 주말 값을 비교하려고 눈이 화면을 왕복한다. 국내 펜션 사이트가 예외 없이
* 객실을 행으로, 요일·시기를 열로 둔 표 한 장을 따로 두는 이유다.
*
* ★ 없는 값은 '문의' 로 세워 둔다. 칸을 지우면 손님은 "주말도 같은 값인가" 를 묻지 못한다.
* 지어내지도 않는다 — 확인된 값만 숫자로 적힌다.
* ★ 확인된 요금이 하나도 없으면 섹션째 그리지 않는다. 전부 '문의' 인 표는 표가 아니다.
*/
export function PricingSection() {
const payload = useSite();
const units = unitViews(payload).filter((unit) => unit.prices.length > 0);
if (units.length === 0) return null;
const columns = units[0].prices.map((price) => price.label);
// 요금과 함께 읽어야 하는 값 — 기준 인원을 넘기면 값이 달라진다.
const notes = [
placeRow(payload, 'extra_person_fee'),
placeRow(payload, 'check_in_time'),
placeRow(payload, 'check_out_time'),
].filter((row) => row !== undefined);
return (
<Section
id="pricing"
tone="alt"
title={sectionName(payload, 'pricing', '요금 안내')}
footnote="'문의' 로 표시된 요금은 시기에 따라 달라져 전화로 안내드립니다."
>
<div className="panel overflow-x-auto">
<table className="w-full min-w-[30rem] border-collapse text-[length:var(--fs-sm)]">
<caption className="sr-only">{payload.place.name} 객실별 1박 요금</caption>
<thead>
<tr className="border-line border-b">
<th scope="col" className="label px-5 py-3 text-left font-medium">
객실
</th>
{columns.map((column) => (
<th key={column} scope="col" className="label px-5 py-3 text-right font-medium">
{column}
</th>
))}
</tr>
</thead>
<tbody className="divide-line divide-y">
{units.map((unit) => (
<tr key={unit.unitId}>
<th scope="row" className="px-5 py-3.5 text-left font-semibold">
{unit.name}
</th>
{unit.prices.map((price) => (
<td
key={price.label}
className={`px-5 py-3.5 text-right tabular-nums ${
/\d/.test(price.value) ? 'font-bold' : 'text-muted'
}`}
>
{price.value}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{notes.length > 0 && (
<dl className="divide-line mt-4 grid gap-x-8 gap-y-2 sm:grid-cols-3 sm:divide-x">
{notes.map((row) => (
<div key={row.label} className="sm:px-5 sm:first:pl-0">
<dt className="label">{row.label}</dt>
<dd className="text-[length:var(--fs-lead)] font-bold tabular-nums">{row.value}</dd>
</div>
))}
</dl>
)}
</Section>
);
}

View File

@ -1,8 +1,8 @@
import {ExternalLink, Mail, MapPin, Phone} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {isoDate} from '@/lib/format';
import {useSite} from '@site/lib/site-context';
import {isoDate} from '@site/lib/format';
// 채널 이름표는 예약·문의 섹션과 같은 표를 쓴다 — 같은 채널이 자리마다 다른 이름으로 뜨면 안 된다.
import {channelLabel} from '@/lib/derive';
import {channelLabel} from '@site/lib/derive';
/**
* 푸터.

View File

@ -1,6 +1,6 @@
import {Menu, Phone} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {isSectionEnabled, stayBookingView, unitSpec} from '@/lib/derive';
import {useSite} from '@site/lib/site-context';
import {isSectionEnabled, stayBookingView, unitSpec} from '@site/lib/derive';
/**
* 상단 내비.

View File

@ -1,6 +1,6 @@
import {useSite} from '@/lib/site-context';
import {sectionName, spaceRows} from '@/lib/derive';
import {Card, Section} from '@/lib/ui';
import {useSite} from '@site/lib/site-context';
import {sectionName, spaceRows} from '@site/lib/derive';
import {Card, Section} from '@site/lib/ui';
/**
* 공간 안내.

View File

@ -1,8 +1,8 @@
import {useEffect, useMemo, useState} from 'react';
import {CalendarDays, Check, Clock, Minus, Phone, Plus, RotateCcw} from 'lucide-react';
import {factText, sanitizeUnits, selectPublishable, type SitePayload} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {unitBaseRate} from '@/seo/jsonld';
import {useSite} from '@site/lib/site-context';
import {unitBaseRate} from '@site/seo/jsonld';
/**
* 예약 데모 — **화면 안에서만 도는 목업이다.**

View File

@ -1,6 +1,6 @@
import {ArrowUpRight, BedDouble, CalendarCheck, Phone, ShieldCheck} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {bookingCtaLabel, channelLabel, sectionName, stayBookingView} from '@/lib/derive';
import {useSite} from '@site/lib/site-context';
import {bookingCtaLabel, channelLabel, sectionName, stayBookingView} from '@site/lib/derive';
import {StayBookingDemo} from './StayBookingDemo';
/**

View File

@ -1,7 +1,7 @@
import {ChevronDown, Phone} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {bookingLabel, bookingLinks, sectionName, unitSpec, unitViews} from '@/lib/derive';
import {Chip, Section} from '@/lib/ui';
import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, sectionName, unitSpec, unitViews} from '@site/lib/derive';
import {Chip, Section} from '@site/lib/ui';
/**
* 객실 안내 — 좌우 교차 밴드(`rooms.bands`).
@ -94,7 +94,7 @@ export function UnitsBands() {
className="tap inline-flex flex-1 items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90"
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
>
{unit.name} {bookingLabel(booking)} 예약
{bookingActionLabel(booking, unit.name)}
</a>
)}
{payload.place.phone && (

View File

@ -1,17 +1,17 @@
import {useCallback, useRef, useState} from 'react';
import {ChevronDown, ChevronLeft, ChevronRight} from 'lucide-react';
import type {MediaItem} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {sectionName, unitSpec, unitViews} from '@/lib/derive';
import {Card, Chip, Section} from '@/lib/ui';
import {useSite} from '@site/lib/site-context';
import {sectionName, unitSpec, unitViews} from '@site/lib/derive';
import {Card, Chip, Section} from '@site/lib/ui';
import {UnitsBands} from './UnitsBands';
import {UnitsTabs} from './UnitsTabs';
import {useLayout} from '@/lib/layout';
import {Rooms as ReservationRooms} from '@/layouts/reservation/Rooms';
import {Rooms as OasiRooms} from '@/layouts/oasi/Rooms';
import {Rooms as StudioRooms} from '@/layouts/studio/Rooms';
import {Rooms as PastelRooms} from '@/layouts/pastel/Rooms';
import {Rooms as EditorialRooms} from '@/layouts/editorial/Rooms';
import {useLayout} from '@site/lib/layout';
import {Rooms as ReservationRooms} from '@site/layouts/reservation/Rooms';
import {Rooms as OasiRooms} from '@site/layouts/oasi/Rooms';
import {Rooms as StudioRooms} from '@site/layouts/studio/Rooms';
import {Rooms as PastelRooms} from '@site/layouts/pastel/Rooms';
import {Rooms as EditorialRooms} from '@site/layouts/editorial/Rooms';
/**
* 객실 · 메뉴 · 프로그램 — 업종에 따라 이름만 달라진다.

View File

@ -1,8 +1,8 @@
import {useState} from 'react';
import {Phone} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {bookingLabel, bookingLinks, sectionName, unitSpec, unitViews} from '@/lib/derive';
import {Chip, Section} from '@/lib/ui';
import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, sectionName, unitSpec, unitViews} from '@site/lib/derive';
import {Chip, Section} from '@site/lib/ui';
/**
* 객실 안내 — 탭으로 갈아 끼우는 형(`rooms.tabs`).
@ -129,7 +129,7 @@ export function UnitsTabs() {
className="tap flex flex-1 items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90"
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
>
{unit.name} {bookingLabel(booking)} 예약
{bookingActionLabel(booking, unit.name)}
</a>
)}
{payload.place.phone && (

View File

@ -9,10 +9,10 @@
* 굽는 순간의 문장을 박으면 비 오는 날 "마당에 앉기 좋습니다"가 뜬다.
* ★ 관측 시각을 반드시 낸다. 시각 없는 숫자는 못 믿는다 — 캐시가 어긋나면 어제 값이다.
*/
import {useSite} from '@/lib/site-context';
import {useLiveWeather} from '@/lib/use-live-weather';
import {weatherMood} from '@/lib/derive';
import {Card, Section} from '@/lib/ui';
import {useSite} from '@site/lib/site-context';
import {useLiveWeather} from '@site/lib/use-live-weather';
import {weatherMood} from '@site/lib/derive';
import {Card, Section} from '@site/lib/ui';
import './weather.css';
/** "2026-09-03T12:30" → "9월 3일 12:30". 날짜가 오늘이면 시각만 남긴다. */

View File

@ -4,7 +4,6 @@ export {AnswerBlock} from './AnswerBlock';
export {AboutSection} from './AboutSection';
export {EssentialInfoSection} from './EssentialInfoSection';
export {UnitsSection} from './UnitsSection';
export {PricingSection} from './PricingSection';
export {BookingSection} from './BookingSection';
// 숙박은 예약 구성이 통째로 다르다 — 이유는 StayBookingSection 머리주석.
export {StayBookingSection} from './StayBookingSection';

View File

@ -3,8 +3,8 @@
* ★ 붉은 점은 도시의 성격이 바뀐 해다. 점의 색이 장식이 아니라 정보라 범례를 함께 낸다.
*/
import type {ChronicleItem} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {sectionItems, sectionName} from '@/lib/derive';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ItemSection, Rail, SourceLine} from './common';
export function ChronicleSection() {

View File

@ -15,8 +15,8 @@
*/
import {useEffect, useState} from 'react';
import type {DailyItem} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {sectionItems, sectionName} from '@/lib/derive';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ItemSection, SourceLine, tally} from './common';
export function DailySection() {

View File

@ -20,8 +20,8 @@
import {useCallback, useEffect, useState} from 'react';
import {X} from 'lucide-react';
import type {EventItem} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {sectionItems, sectionName} from '@/lib/derive';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
import {
ITEM_ACCENT,
ITEM_BORDER,

View File

@ -21,8 +21,8 @@
*/
import {useState} from 'react';
import {planDay, type ItineraryItem, type PlannerStop} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {sectionItems, sectionName} from '@/lib/derive';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
import {ITEM_BORDER, ITEM_CARD, ITEM_INK, ITEM_INVERSE_INK, ItemSection, Rail, SourceLine, StopNo} from './common';
import {RouteMap} from './RouteMap';

View File

@ -3,8 +3,8 @@
* ★ 프레임 안은 사진이 있으면 사진, 없으면 활판 이니셜이다 — 빈 회색 상자를 두지 않는다.
*/
import type {PeopleItem} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {sectionItems, sectionName} from '@/lib/derive';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
import {ITEM_INVERSE, ItemSection, Rail, SourceLine, tally} from './common';
export function PeopleSection() {

View File

@ -1,169 +0,0 @@
/**
* 계절별 추천 하루 — 발행본.
*
* ★ 손님에게는 **지금 계절만** 보인다(간절기에는 두 계절). 12월에 온 손님에게
* 봄·여름 코스까지 늘어놓으면 고를 것이 열두 개가 되고, 그건 추천이 아니라 목록이다.
* ★ 그런데 HTML 에는 **전 계절을 굽는다.** 두 가지 이유다 —
* ① 발행본은 정적이라 한 번 구우면 몇 달을 산다. 굽는 시점의 계절을 박으면 12월에도
* 가을 코스가 걸린다. 그래서 계절 판정은 **브라우저에서** 한다(일력의 '오늘'과 같은 수법).
* ② 이 사이트의 존재 이유가 인용이다. HTML 에서 지우면 검색·AI 가 나머지 계절을 못 읽는다.
* → 안 맞는 계절은 `hidden` 으로 접어 둔다. 문서에는 있고 화면에는 없다.
* ★ 시각은 화면이 계산한다(shared planDay) — 사장님은 머무는 시간만 적는다.
* 조립 규칙이 빌더와 한 벌이라 사장님이 본 일정과 손님이 보는 일정이 같다.
*/
import {inSeason, planDay, plannerSeasons, plannerTop, type PlannerItem} from '@o2o/shared';
import {useLiveSeasons} from '@/lib/use-live-seasons';
import {useSite} from '@/lib/site-context';
import {sectionItems, sectionName} from '@/lib/derive';
import {ITEM_BORDER, ITEM_CARD, ITEM_INK, ITEM_INVERSE_INK, ItemSection, Rail, SourceLine, StopNo} from './common';
import {RouteMap} from './RouteMap';
const RANK_LABEL = ['1위', '2위', '3위'];
/** 총 소요를 "7시간 10분"으로. 분만 쓰면 430분이 얼마인지 아무도 모른다. */
function spanText(minutes: number): string {
const hour = Math.floor(minutes / 60);
const rest = minutes % 60;
return [hour > 0 ? `${hour}시간` : '', rest > 0 ? `${rest}분` : ''].filter(Boolean).join(' ') || '0분';
}
function PlanCard({item, rank}: {item: PlannerItem; rank: number}) {
const day = planDay(item);
return (
<article
className="w4-paper w-[320px] shrink-0 snap-center border"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
>
<div
className="flex items-center justify-between gap-2 border-b px-4 py-2.5"
style={{borderColor: ITEM_BORDER}}
>
{/* ★ 1위만 채운다. 셋 다 채우면 순위가 안 읽히고, 채움색을 강조색으로 두면
팔레트에 따라 글자가 안 보인다(연한 accent 위의 밝은 글자) — 글자색으로 채운다. */}
<span
className="border px-2 py-0.5 text-[11px] font-bold"
style={
rank === 0
? {backgroundColor: ITEM_INK, color: ITEM_INVERSE_INK, borderColor: ITEM_INK}
: {borderColor: ITEM_BORDER}
}
>
{RANK_LABEL[rank] ?? `${rank + 1}위`}
</span>
<span className="text-[11px] opacity-60">
{day.from}–{day.to} · {spanText(day.totalMinutes)}
</span>
</div>
<div className="space-y-1.5 px-4 pt-3.5">
<h4 className="serif text-lg font-bold">{item.name}</h4>
{item.audience && <p className="text-[11px] opacity-60">{item.audience}</p>}
{item.why && <p className="text-[13px] leading-relaxed opacity-80">{item.why}</p>}
</div>
<RouteMap stops={day.stops.map((planned) => planned.stop)} />
<ol className="mt-3 px-4 pb-3">
{day.stops.map((planned, index) => (
<li key={`${planned.stop.name}-${index}`} className="grid grid-cols-[46px_minmax(0,1fr)] gap-2.5">
{/* 시각 열 — 왼쪽에 붙어 정렬돼야 '시간표'로 읽힌다 */}
<span className="serif pt-2 text-[12px] tabular-nums opacity-75">{planned.time}</span>
<div className="border-l pb-2 pl-3" style={{borderColor: ITEM_BORDER}}>
{planned.move > 0 && <p className="pt-1 text-[10px] opacity-50">↓ {planned.move}분 이동</p>}
{/*
★ 사진은 있을 때만. 없으면 빈 회색 상자 대신 지금처럼 활자만 선다 —
이 팩의 규칙이다(주변 안내·인물 열전과 같다).
★ 시간표의 리듬을 깨지 않게 작게. 사진이 커지면 '하루 계획'이 아니라 사진첩이 된다.
*/}
<div className="flex items-start gap-2.5 pt-1">
{planned.stop.imageUrl && (
<img
src={planned.stop.imageUrl}
alt={`${planned.stop.name} 사진`}
loading="lazy"
decoding="async"
className="size-12 shrink-0 object-cover"
style={{border: `1px solid ${ITEM_BORDER}`}}
/>
)}
<span className="min-w-0">
<span className="block text-sm font-bold">
<StopNo n={index + 1} />
{planned.stop.name}
</span>
{planned.stop.note && (
<span className="mt-0.5 block text-[12px] leading-relaxed opacity-75">
{planned.stop.note}
</span>
)}
</span>
</div>
<p className="mt-0.5 text-[10px] opacity-50">
{planned.time}–{planned.until}
{planned.stop.searchQuery && ` · 지도 검색 ${planned.stop.searchQuery}`}
</p>
</div>
</li>
))}
</ol>
<div className="border-t border-dashed px-4 py-2.5" style={{borderColor: ITEM_BORDER}}>
<SourceLine source={item.source} verified={item.verified} />
</div>
</article>
);
}
export function PlannerSection() {
const payload = useSite();
const parsed = sectionItems<PlannerItem>(payload, 'planner');
const now = useLiveSeasons();
if (parsed.items.length === 0) return null;
const seasons = plannerSeasons(parsed.items);
// 계절을 안 적었으면 계절 묶음 없이 전체에서 top3 를 뽑는다 — 빈 소제목을 만들지 않는다.
const groups = seasons.length > 0 ? seasons : [undefined];
// 지금 계절에 코스가 하나도 없으면(사장님이 그 계절을 안 채웠다) 접지 않고 전부 보여준다 —
// 빈 섹션보다 철 지난 코스가 낫다.
const shown = now.filter((season) => seasons.includes(season));
const filtering = shown.length > 0;
return (
<ItemSection
id="planner"
name={parsed.title || sectionName(payload, 'planner', '계절별 추천 하루')}
subtitle={parsed.subtitle}
count={
filtering
? `지금은 ${shown.join(' · ')} 코스입니다 · 시각은 출발 시각과 머무는 시간으로 계산한 것입니다`
: '시각은 출발 시각과 머무는 시간으로 계산한 것입니다'
}
>
<div className="space-y-10">
{groups.map((season) => {
const top = plannerTop(parsed.items, season);
if (top.length === 0) return null;
return (
<div
key={season ?? 'all'}
className="space-y-3"
// 문서에는 남기고 화면에서만 접는다 — 검색·AI 는 전 계절을 읽는다.
hidden={filtering && !inSeason(season, shown)}
>
{season && <h3 className="serif text-lg font-bold">{season}</h3>}
<Rail label={`${season ?? '추천'} 코스`}>
{top.map((item, index) => (
<PlanCard key={`${item.name}-${index}`} item={item} rank={index} />
))}
</Rail>
</div>
);
})}
</div>
</ItemSection>
);
}

View File

@ -4,8 +4,8 @@
* ([복사] 버튼은 두지 않는다 — 스크립트가 없어도 되는 화면을 스크립트로 만들 이유가 없다).
*/
import type {PostcardItem} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {sectionItems, sectionName} from '@/lib/derive';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ITEM_INK, ItemSection, Rail, SourceLine, tally} from './common';
export function PostcardSection() {

View File

@ -10,8 +10,8 @@
*/
import {useState} from 'react';
import type {QuizItem} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {sectionItems, sectionName} from '@/lib/derive';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ITEM_INK, ItemSection, Rail, SourceLine} from './common';
export function QuizSection() {

View File

@ -12,8 +12,8 @@
* ★ 타일은 회색으로 눕힌다. 읽어야 하는 건 지도가 아니라 **번호와 순서**다.
*/
import type {PlannerStop} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {naverDirectionsUrl, naverRouteUrl} from '@/lib/format';
import {useSite} from '@site/lib/site-context';
import {naverDirectionsUrl, naverRouteUrl} from '@site/lib/format';
import {ITEM_ACCENT, ITEM_BORDER, ITEM_INVERSE_INK} from './common';
const TILE = 256;

View File

@ -12,9 +12,9 @@
import {useState} from 'react';
import type {SongItem} from '@o2o/shared';
import {Youtube} from 'lucide-react';
import {youtubeSearchUrl} from '@/lib/format';
import {useSite} from '@/lib/site-context';
import {sectionItems, sectionName} from '@/lib/derive';
import {youtubeSearchUrl} from '@site/lib/format';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
import {
ITEM_ACCENT,
ITEM_INVERSE,

View File

@ -12,8 +12,8 @@
* 그 제목이 다섯 덩이를 가르는 유일한 표지다.
*/
import {useState} from 'react';
import {useSite} from '@/lib/site-context';
import {sectionItems, sectionName} from '@/lib/derive';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
import {SongsSection} from './SongsSection';
import {DailySection} from './DailySection';
import {PeopleSection} from './PeopleSection';

View File

@ -33,8 +33,8 @@ import {
youtubePoster,
type VideoItem,
} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {sectionItems, sectionName} from '@/lib/derive';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
import {ITEM_BORDER, ITEM_CARD, ItemSection, SourceLine} from './common';
export function VideoSection() {

View File

@ -10,15 +10,15 @@
*/
import type {ReactNode} from 'react';
import type {DataSource, DataVerified} from '@o2o/shared';
import {Carousel} from '@/lib/ui';
import {useLayout} from '@/lib/layout';
import {Carousel} from '@site/lib/ui';
import {useLayout} from '@site/lib/layout';
// 안별 제목은 `lib/ui/Section` 과 **같은 파일**을 직접 가리킨다.
// `@/sections` 배럴로 돌아가면 이 파일이 그 배럴 안에 있어 순환이다.
import {SectionHead as ReservationHead} from '@/layouts/reservation/SectionHead';
import {SectionHead as OasiHead} from '@/layouts/oasi/SectionHead';
import {SectionHead as StudioHead} from '@/layouts/studio/SectionHead';
import {SectionHead as PastelHead} from '@/layouts/pastel/SectionHead';
import {SectionHead as EditorialHead} from '@/layouts/editorial/SectionHead';
import {SectionHead as ReservationHead} from '@site/layouts/reservation/SectionHead';
import {SectionHead as OasiHead} from '@site/layouts/oasi/SectionHead';
import {SectionHead as StudioHead} from '@site/layouts/studio/SectionHead';
import {SectionHead as PastelHead} from '@site/layouts/pastel/SectionHead';
import {SectionHead as EditorialHead} from '@site/layouts/editorial/SectionHead';
import './items.css';
/** 카드·종이 면. 섹션 바탕보다 한 단계 앞이다. */

View File

@ -11,7 +11,6 @@ import {ChronicleSection} from './ChronicleSection';
import {PostcardSection} from './PostcardSection';
import {QuizSection} from './QuizSection';
import {ItinerarySection} from './ItinerarySection';
import {PlannerSection} from './PlannerSection';
import {VideoSection} from './VideoSection';
import {StorySection} from './StorySection';
import {EventSection} from './EventSection';
@ -24,8 +23,6 @@ export const ITEM_SECTIONS: Record<string, () => React.ReactElement | null> = {
postcard: PostcardSection,
quiz: QuizSection,
itinerary: ItinerarySection,
// ★ 계절별 추천 하루. 여행일정 개편 때 표에서 빠져 화면에서 사라졌었다 — 되살린다.
planner: PlannerSection,
video: VideoSection,
// 인스타에 올라오는 그때그때의 행사. 사장님이 옮겨 적고 원문 주소를 단다.
event: EventSection,

View File

@ -18,14 +18,14 @@ import {
sanitizePayloadForPublish,
type SitePayload,
} from '@o2o/shared';
import {MOONLIGHT_STAY_PAYLOAD} from '@/fixtures/moonlight-stay';
import {stayBookingView} from '@/lib/derive';
import {collectJsonLd} from '@/seo/jsonld';
import {renderLlmsTxt} from '@/seo/llms';
import {verifyJsonLd, visibleText} from '@/seo/verify';
import {render} from '@/entry-server';
import {homeMeta} from '@/seo/meta';
import {renderHead} from '@/seo/head';
import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay';
import {stayBookingView} from '@site/lib/derive';
import {collectJsonLd} from '@site/seo/jsonld';
import {renderLlmsTxt} from '@site/seo/llms';
import {verifyJsonLd, visibleText} from '@site/seo/verify';
import {render} from '@site/entry-server';
import {homeMeta} from '@site/seo/meta';
import {renderHead} from '@site/seo/head';
const PAGE = {title: '달빛스테이 제주', description: '제주 애월 독채 펜션'};

View File

@ -59,21 +59,28 @@ export interface HeadOptions {
* ★ 면 토큰(surface/-alt/inverse/border)은 캔버스와 **같은 식**으로 유도한다(shared/lib/color).
* 식이 두 벌이면 미리보기와 발행본의 바탕색이 갈린다.
*/
export function themeStyle(payload: SitePayload): string {
/**
* 템플릿 토큰 — **값**으로 돌려준다.
*
* ★ 발행본은 이걸 `<head>` 의 `:root` 에 굽고(themeStyle), 빌더 미리보기는 같은 값을
* 미리보기 요소의 inline style 로 얹는다(`builder/SitePreview`). 두 곳이 각자 만들면
* 에디터에서 본 색·서체와 발행된 색·서체가 조용히 갈라진다 — 실제로 그 자리였다.
*/
export function themeVars(payload: SitePayload): Record<string, string> {
const {colors, look} = payload.theme;
const surfaces = deriveSurfaces(colors);
const lines = [
`--tpl-primary: ${colors.primary};`,
`--tpl-secondary: ${colors.secondary};`,
`--tpl-bg: ${colors.bg};`,
`--tpl-card: ${colors.card};`,
`--tpl-text: ${colors.text};`,
`--tpl-accent: ${colors.accent};`,
`--tpl-surface: ${surfaces.surface};`,
`--tpl-surface-alt: ${surfaces.surfaceAlt};`,
`--tpl-inverse: ${surfaces.inverse};`,
`--tpl-border: ${surfaces.border};`,
];
const vars: Record<string, string> = {
'--tpl-primary': colors.primary,
'--tpl-secondary': colors.secondary,
'--tpl-bg': colors.bg,
'--tpl-card': colors.card,
'--tpl-text': colors.text,
'--tpl-accent': colors.accent,
'--tpl-surface': surfaces.surface,
'--tpl-surface-alt': surfaces.surfaceAlt,
'--tpl-inverse': surfaces.inverse,
'--tpl-border': surfaces.border,
};
if (look) {
// ★ 값은 그대로 CSS 에 들어간다. `<`·`}` 이 섞이면 <style> 을 깨뜨릴 수 있어 걸러 낸다 —
// 서버가 해석하지 않고 실어 보내는 값이라 여기서 한 번은 봐야 한다.
@ -91,8 +98,13 @@ export function themeStyle(payload: SitePayload): string {
// 토큰이 비어 있으면 아무것도 깔지 않는다(질감을 원하지 않는 템플릿이 있다).
['--tpl-texture', safe(look.texture)],
];
for (const [name, value] of pairs) if (value) lines.push(`${name}: ${value};`);
for (const [name, value] of pairs) if (value) vars[name] = value;
}
return vars;
}
export function themeStyle(payload: SitePayload): string {
const lines = Object.entries(themeVars(payload)).map(([name, value]) => `${name}: ${value};`);
return [' <style>', ' :root {', ...lines.map((line) => ` ${line}`), ' }', ' </style>'].join('\n');
}
@ -110,7 +122,8 @@ const WEB_FONTS: [RegExp, string][] = [
[/Nanum Pen Script/i, 'family=Nanum+Pen+Script'],
];
function fontHref(payload: SitePayload): string {
/** 템플릿이 요구하는 웹폰트 주소. 발행본 <head> 와 빌더 미리보기가 같은 것을 쓴다. */
export function fontHref(payload: SitePayload): string {
const stacks = [
'Noto Sans KR',
'Noto Serif KR',

View File

@ -3,7 +3,7 @@
"compilerOptions": {
"types": ["vite/client", "node"],
"paths": {
"@/*": ["./src/*"],
"@site/*": ["./src/*"],
"@o2o/shared": ["../shared/src/index.ts"],
"@o2o/shared/*": ["../shared/src/*"]
}

View File

@ -18,7 +18,11 @@ export default defineConfig(({isSsrBuild}) => ({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
// ★ 별칭이 '@' 가 아니라 '@site' 인 이유(2026-09-09)
// 빌더 미리보기가 **이 렌더러를 그대로** 쓴다(solution/frontend). 두 앱이 '@' 를
// 각자 자기 src 로 두면, 여기 컴포넌트를 저쪽에서 불러올 때 내부 import 가
// 저쪽 src 로 떨어져 조용히 다른 파일을 잡는다. 이름을 갈라 두면 어디서 불러도 같다.
'@site': path.resolve(__dirname, 'src'),
'@o2o/shared': path.resolve(__dirname, '../shared/src'),
},
},