[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/ { location ^~ /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable"; add_header Cache-Control "public, max-age=31536000, immutable";
access_log off; access_log off;

View File

@ -1,5 +1,6 @@
from uuid import UUID from uuid import UUID
from fastapi.responses import JSONResponse
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from common.models.gmodel import PageParams, UserInfo 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)) 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="빌드 버전 목록") @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)): 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))) return RemoveNoneResponse(await service.list_versions(user_info, str(place_id)))

View File

@ -150,6 +150,37 @@ class SiteService:
) )
return Res_SeoAudit(**report) 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 한 곳에만 있다. 확인과 저장이 그것을 같이 쓴다. # 규칙(정규식·예약어)은 services/site_slug 한 곳에만 있다. 확인과 저장이 그것을 같이 쓴다.

View File

@ -5,7 +5,7 @@ import {publishUrlString, toSlug} from '@o2o/shared';
import {deriveSurfaces} from '@/lib/color'; import {deriveSurfaces} from '@/lib/color';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import {useBuilderStore, useCurrentTemplate} from '@/stores/builder'; import {useBuilderStore, useCurrentTemplate} from '@/stores/builder';
import {resolveVariant} from './canvas/registry'; import {SitePreview} from './SitePreview';
import {PUBLISH_HOST} from '@/lib/site'; import {PUBLISH_HOST} from '@/lib/site';
// ★ 호스트를 상수로 박지 않는다. PublishModal 과 **다른 주소**를 보여주면 사장님은 // ★ 호스트를 상수로 박지 않는다. PublishModal 과 **다른 주소**를 보여주면 사장님은
@ -45,6 +45,7 @@ export function CanvasView() {
const selectedSectionId = useBuilderStore((s) => s.selectedSectionId); const selectedSectionId = useBuilderStore((s) => s.selectedSectionId);
const viewport = useBuilderStore((s) => s.viewport); const viewport = useBuilderStore((s) => s.viewport);
const isPreviewMode = useBuilderStore((s) => s.isPreviewMode); const isPreviewMode = useBuilderStore((s) => s.isPreviewMode);
const placeId = useBuilderStore((s) => s.placeId);
const setViewport = useBuilderStore((s) => s.setViewport); const setViewport = useBuilderStore((s) => s.setViewport);
const selectSection = useBuilderStore((s) => s.selectSection); const selectSection = useBuilderStore((s) => s.selectSection);
const setRightTab = useBuilderStore((s) => s.setRightTab); const setRightTab = useBuilderStore((s) => s.setRightTab);
@ -61,82 +62,12 @@ export function CanvasView() {
// ★ 면 토큰(--tpl-surface / -alt / inverse / border)은 템플릿 색에서 유도한다. // ★ 면 토큰(--tpl-surface / -alt / inverse / border)은 템플릿 색에서 유도한다.
// 이걸 안 내려보내면 SectionFrame 이 폴백(고정 stone 색)으로 떨어져서, // 이걸 안 내려보내면 SectionFrame 이 폴백(고정 stone 색)으로 떨어져서,
// 템플릿을 바꿔도 화면 면적의 대부분이 그대로다 — 쇼케이스와 같은 식(lib/color.ts)을 쓴다. // 템플릿을 바꿔도 화면 면적의 대부분이 그대로다 — 쇼케이스와 같은 식(lib/color.ts)을 쓴다.
const surfaces = deriveSurfaces(template.colors); /*
* ★ 색·서체 토큰을 여기서 만들지 않는다(2026-09-09).
const look = template.look; * 캔버스가 빌더 상태로 --tpl-* 를 따로 만들던 동안, 그 값이 발행본과 갈라졌다 —
* 실측: 편집 캔버스 #ffffff·Pretendard ↔ 발행본 #e4dac0·Gugi.
const themeVars = { * 이제 미리보기·편집 둘 다 iframe 안 발행본 렌더러가 그리고, 토큰은 payload 하나에서 온다.
'--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 를 두면
* 배리에이션을 하나 추가할 때마다 이 파일을 같이 고쳐야 한다.
*/ */
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 ( return (
<div className="relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-muted"> <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 className="flex flex-1 items-start justify-center overflow-y-auto bg-muted/70 p-2 sm:p-6">
<div {/* ★ 이 껍데기는 토큰도 폭도 정하지 않는다(2026-09-09).
style={themeVars} 안쪽 iframe 이 **자기 뷰포트**를 만들고 색·서체는 payload 가 준다.
className={cn('site-canvas my-auto transition-all duration-300', VIEWPORT_FRAME[viewport])} 바깥이 한 번 더 얹으면 두 겹이 돼 바깥 값이 안쪽을 덧칠한다 — 실제로 그랬다. */}
> <div className="my-auto w-full min-w-0 transition-all duration-300">
{viewport !== 'pc' && ( {viewport !== 'pc' && (
<div className="flex items-center justify-between bg-foreground px-4 py-2 font-mono text-[11px] text-background"> <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="flex items-center gap-1.5">
@ -193,10 +124,22 @@ export function CanvasView() {
</div> </div>
)} )}
{/* 섹션은 좌측 패널이 정한 순서 그대로 그린다. */} {/* ★ 편집 모드도 **발행본 렌더러**가 그린다(2026-09-09).
<div className="divide-y divide-black/5"> 캔버스 전용 컴포넌트를 따로 두는 동안 두 화면이 아예 다른 트리였다 — 실측:
{sections.map((section) => renderSection(section))} 발행본 15섹션 · 에디터 12섹션, 겹치는 건 4개뿐이고 이름도 달랐다
</div> (gallery↔photos · location↔map · guide↔local). 사장님이 편집한 화면과
발행된 화면이 서로 다른 물건이었다.
고르는 일은 iframe 안 섹션을 눌러서 한다 — SitePreview 가 배선한다. */}
<SitePreview
placeId={placeId}
viewport={viewport}
interactive={!isPreviewMode}
selectedId={selectedSectionId}
onSelect={(id) => {
selectSection(id);
setRightTab('content');
}}
/>
<footer <footer
className="border-t border-black/10 p-6 text-center text-xs" 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: { itinerary: {
kind: 'itinerary', kind: 'itinerary',
label: '추천 일정', label: '추천 일정',

View File

@ -69,10 +69,14 @@ export function usePlaceSync(placeId: string | null, options?: ApplyPlaceOptions
* `npm run orval` 을 돌리면 이 캐스팅은 지운다. * `npm run orval` 을 돌리면 이 캐스팅은 지운다.
*/ */
const savedTheme = (siteQuery.data?.site as {theme?: SiteThemePayload} | undefined)?.theme; 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(() => { useEffect(() => {
if (!placeId) return; if (!placeId) return;
applyTheme(savedTheme); applyTheme(savedTheme, savedTemplateId);
}, [placeId, savedTheme, applyTheme]); }, [placeId, savedTheme, savedTemplateId, applyTheme]);
return { return {
/** 아직 다 못 읽었다 — 이 동안 데모 화면을 보여주면 남의 가게를 자기 가게로 오해한다. */ /** 아직 다 못 읽었다 — 이 동안 데모 화면을 보여주면 남의 가게를 자기 가게로 오해한다. */

View File

@ -139,7 +139,7 @@ interface BuilderState {
setSectionVariant: (sectionId: string, variantId: string) => void; setSectionVariant: (sectionId: string, variantId: string) => void;
resetSectionVariants: () => void; resetSectionVariants: () => void;
/** 서버에 저장돼 있던 디자인을 화면에 얹는다(새로고침·다른 기기 복원). */ /** 서버에 저장돼 있던 디자인을 화면에 얹는다(새로고침·다른 기기 복원). */
applyTheme: (theme: SiteThemePayload | null | undefined) => void; applyTheme: (theme: SiteThemePayload | null | undefined, savedTemplateId?: string | null) => void;
updateField: (id: string, value: string) => void; updateField: (id: string, value: string) => void;
updateFieldLabel: (id: string, label: string) => void; updateFieldLabel: (id: string, label: string) => void;
@ -615,11 +615,11 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
* 반대로 저장값에 없는 시드 섹션은 **뒤에 붙인다**: 새로 생긴 섹션이 조용히 사라지면 * 반대로 저장값에 없는 시드 섹션은 **뒤에 붙인다**: 새로 생긴 섹션이 조용히 사라지면
* 사장님은 그 기능이 있는 줄도 모른다. * 사장님은 그 기능이 있는 줄도 모른다.
*/ */
applyTheme: (theme) => { applyTheme: (theme, savedTemplateId) => {
if (!theme) return; if (!theme && !savedTemplateId) return;
set((state) => { set((state) => {
const seedById = new Map(state.sections.map((sec) => [sec.id, sec])); const seedById = new Map(state.sections.map((sec) => [sec.id, sec]));
const ordered = (theme.sections ?? []) const ordered = (theme?.sections ?? [])
// ★ 반환 타입을 못 박는다. 추론에 맡기면 description 이 **필수** 키로 좁혀져 // ★ 반환 타입을 못 박는다. 추론에 맡기면 description 이 **필수** 키로 좁혀져
// (SectionItem 에서는 선택 키다) 아래 filter 의 타입 술어가 컴파일되지 않는다. // (SectionItem 에서는 선택 키다) 아래 filter 의 타입 술어가 컴파일되지 않는다.
.map((saved): SectionItem | null => { .map((saved): SectionItem | null => {
@ -646,18 +646,32 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
.filter((sec): sec is SectionItem => sec !== null); .filter((sec): sec is SectionItem => sec !== null);
return { 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()], sections: [...ordered, ...seedById.values()],
colorPaletteId: theme.colorPaletteId ?? null, colorPaletteId: theme?.colorPaletteId ?? null,
infoFields: [ infoFields: [
...state.infoFields, ...state.infoFields,
...(theme.customInfoFields ?? []).filter( ...(theme?.customInfoFields ?? []).filter(
(custom) => !state.infoFields.some((field) => field.id === custom.id), (custom) => !state.infoFields.some((field) => field.id === custom.id),
), ),
], ],
photos: theme.visiblePhotoIds photos: theme?.visiblePhotoIds
? state.photos.map((photo) => ({ ? state.photos.map((photo) => ({
...photo, ...photo,
isVisible: theme.visiblePhotoIds?.includes(photo.id), isVisible: theme?.visiblePhotoIds?.includes(photo.id),
})) }))
: state.photos, : state.photos,
}; };

View File

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

View File

@ -18,6 +18,12 @@ export default defineConfig({
// 워크스페이스 심볼릭 링크를 타지 않고 소스를 직접 가리킨다 — // 워크스페이스 심볼릭 링크를 타지 않고 소스를 직접 가리킨다 —
// shared 를 고치면 빌드 없이 HMR 이 바로 돈다. // shared 를 고치면 빌드 없이 HMR 이 바로 돈다.
'@o2o/shared': path.resolve(__dirname, '../shared/src'), '@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/` 로 나간다(프레임워크 모드 기본값). // ★ 산출물이 `dist/` 가 아니라 `build/client/` 로 나간다(프레임워크 모드 기본값).

View File

@ -18,7 +18,7 @@ import {
sanitizePayloadForPublish, sanitizePayloadForPublish,
SECTION_ITEM_REQUIRED_KEY, SECTION_ITEM_REQUIRED_KEY,
} from '@o2o/shared'; } from '@o2o/shared';
import {render} from '@/entry-server'; import {render} from '@site/entry-server';
import { import {
collectJsonLd, collectJsonLd,
homeMeta, homeMeta,
@ -34,8 +34,8 @@ import {
type SiteEntry, type SiteEntry,
verifyGeo, verifyGeo,
verifyJsonLd, verifyJsonLd,
} from '@/seo'; } from '@site/seo';
import {MOONLIGHT_STAY_PAYLOAD} from '@/fixtures/moonlight-stay'; import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay';
/** /**
* 발행 사이트 프리렌더. * 발행 사이트 프리렌더.
@ -667,6 +667,45 @@ function writeSharedAssets(destRoot: string, referenced: Set<string>) {
* 발행은 **바뀐 사이트 하나만** 굽는다(scripts/watch-payloads.mjs). 이번 실행분만 인덱스에 * 발행은 **바뀐 사이트 하나만** 굽는다(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) { function writeRootMachineFiles(outRoot: string, origin: string) {
const sitesDir = join(outRoot, SITE_DIR); const sitesDir = join(outRoot, SITE_DIR);
if (!existsSync(sitesDir)) return; if (!existsSync(sitesDir)) return;
@ -834,6 +873,7 @@ function main() {
// ★ 실패한 사이트가 있어도 루트 파일은 갱신한다 — 성공한 사이트까지 색인에서 빠질 이유가 없다. // ★ 실패한 사이트가 있어도 루트 파일은 갱신한다 — 성공한 사이트까지 색인에서 빠질 이유가 없다.
// (인덱스는 디스크를 훑으므로 실패한 사이트는 애초에 들어가지 않는다.) // (인덱스는 디스크를 훑으므로 실패한 사이트는 애초에 들어가지 않는다.)
writePreviewShell(args.out, assets);
if (origin) writeRootMachineFiles(args.out, origin); if (origin) writeRootMachineFiles(args.out, origin);
if (failed > 0) { if (failed > 0) {

View File

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

View File

@ -2,6 +2,7 @@ import {StrictMode} from 'react';
import {createRoot, hydrateRoot} from 'react-dom/client'; import {createRoot, hydrateRoot} from 'react-dom/client';
import type {SitePayload} from '@o2o/shared'; import type {SitePayload} from '@o2o/shared';
import {App} from './App'; import {App} from './App';
import {fontHref, themeVars} from '@site/seo/head';
import './index.css'; import './index.css';
declare global { declare global {
@ -14,6 +15,65 @@ declare global {
const container = document.getElementById('root')!; const container = document.getElementById('root')!;
const injected = window.__SITE_PAYLOAD__; 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) { if (injected) {
// 정적 HTML 위에 하이드레이션. 서버가 그린 마크업과 1:1 이어야 하므로 // 정적 HTML 위에 하이드레이션. 서버가 그린 마크업과 1:1 이어야 하므로
// 여기서 payload 를 바꾸거나 fetch 를 걸지 않는다. // 여기서 payload 를 바꾸거나 fetch 를 걸지 않는다.
@ -23,6 +83,11 @@ if (injected) {
<App payload={injected} /> <App payload={injected} />
</StrictMode>, </StrictMode>,
); );
} else if (previewPlaceId) {
void renderPreview(previewPlaceId).catch((ex: unknown) => {
// 미리보기가 못 떠도 편집은 계속돼야 한다. 부모 창이 읽을 수 있게 이유를 남긴다.
container.textContent = `미리보기를 불러오지 못했습니다 — ${ex instanceof Error ? ex.message : String(ex)}`;
});
} else { } else {
// 개발 서버(vite dev) — 데모 payload 로 CSR 렌더. 프로덕션 경로가 아니다. // 개발 서버(vite dev) — 데모 payload 로 CSR 렌더. 프로덕션 경로가 아니다.
void import('./fixtures/moonlight-stay').then(({MOONLIGHT_STAY_PAYLOAD}) => { void import('./fixtures/moonlight-stay').then(({MOONLIGHT_STAY_PAYLOAD}) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import {useSite} from '@/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {bookingLabel, bookingLinks, heroFacts, lowestPrice} from '@/lib/derive'; 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" 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)'}} style={{backgroundColor: 'var(--pastel-ink)', color: 'var(--tpl-bg, #fff)'}}
> >
{bookingLabel(links[0])}로 예약 {bookingActionLabel(links[0])}
</a> </a>
)} )}
</div> </div>

View File

@ -1,6 +1,6 @@
import {ChevronDown, Phone} from 'lucide-react'; import {ChevronDown, Phone} from 'lucide-react';
import {useSite} from '@/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {bookingLabel, bookingLinks, sectionName, unitSpec, unitViews} from '@/lib/derive'; import {bookingActionLabel, bookingLinks, sectionName, unitSpec, unitViews} from '@site/lib/derive';
import {SectionHead} from './SectionHead'; 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" 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)'}} style={{backgroundColor: 'var(--pastel-ink)', color: 'var(--tpl-bg, #fff)'}}
> >
{bookingLabel(booking)} 예약 {bookingActionLabel(booking)}
</a> </a>
)} )}
{payload.place.phone && ( {payload.place.phone && (

View File

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

View File

@ -1,5 +1,5 @@
import {useSite} from '@/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {bookingLabel, bookingLinks, unitSpec} from '@/lib/derive'; 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 { import {
bookingLabel, bookingActionLabel,
bookingLinks, bookingLinks,
essentialRows, essentialRows,
isSectionEnabled, isSectionEnabled,
sectionName, sectionName,
unitSpec, unitSpec,
unitViews, unitViews,
} from '@/lib/derive'; } from '@site/lib/derive';
import {SectionHead} from './SectionHead'; import {SectionHead} from './SectionHead';
/** /**
@ -175,7 +175,7 @@ export function Rooms() {
rel="noopener noreferrer" 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" 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> </a>
)} )}
{payload.place.phone && ( {payload.place.phone && (

View File

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

View File

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

View File

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

View File

@ -1,18 +1,18 @@
import type {ReactNode} from 'react'; import type {ReactNode} from 'react';
import {useSite} from '@/lib/site-context'; import {useSite} from '@site/lib/site-context';
import { import {
bookingLabel, bookingActionLabel,
bookingLinks, bookingLinks,
channelLabel, channelLabel,
heroFacts, heroFacts,
isSectionEnabled, isSectionEnabled,
lowestPrice, lowestPrice,
unitSpec, unitSpec,
} from '@/lib/derive'; } from '@site/lib/derive';
import {formatKoreanDate, isoDate} from '@/lib/format'; import {formatKoreanDate, isoDate} from '@site/lib/format';
// ★ 배럴(@/sections)이 아니라 파일을 직접 가리킨다 — 배럴은 섹션 열여덟을 전부 끌고 오고, // ★ 배럴(@/sections)이 아니라 파일을 직접 가리킨다 — 배럴은 섹션 열여덟을 전부 끌고 오고,
// 그 안에 lib/ui 가 있어 이 폴더로 순환 참조가 돌아온다. // 그 안에 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" 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)'}} style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg)'}}
> >
{bookingLabel(booking)}로 예약 {bookingActionLabel(booking)}
</a> </a>
)} )}

View File

@ -14,7 +14,7 @@ import {
type UnitInfo, type UnitInfo,
type WeatherMood, type WeatherMood,
} from '@o2o/shared'; } from '@o2o/shared';
import {BOOKING_CHANNELS, UNIT_SPEC, unitBaseRate} from '@/seo/jsonld'; import {BOOKING_CHANNELS, UNIT_SPEC, unitBaseRate} from '@site/seo/jsonld';
/** /**
* payload → 화면이 바로 쓰는 모양. * payload → 화면이 바로 쓰는 모양.
@ -618,6 +618,19 @@ export function contactLinks(payload: SitePayload): ChannelLink[] {
/* ── 숙박 예약(origin/main 의 feature/stay-booking) ───────────────────── /* ── 숙박 예약(origin/main 의 feature/stay-booking) ─────────────────────
* ★ 이 블록은 예약 작업이 넣은 것이다. DB 재구성 병합 때 derive.ts 를 이쪽 버전으로 * ★ 이 블록은 예약 작업이 넣은 것이다. DB 재구성 병합 때 derive.ts 를 이쪽 버전으로
* 가져오면서 빠질 뻔했다 — 예약 화면(StayBookingSection)이 이 셋에 의존한다. */ * 가져오면서 빠질 뻔했다 — 예약 화면(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)은 세 가지뿐이다. 섹션마다 새 배경색을 만들면 페이지가 색 띠의 나열이 된다. * ★ 바탕(tone)은 세 가지뿐이다. 섹션마다 새 배경색을 만들면 페이지가 색 띠의 나열이 된다.
*/ */
import type {ReactNode} from 'react'; import type {ReactNode} from 'react';
import {useLayout} from '@/lib/layout'; import {useLayout} from '@site/lib/layout';
import {SectionHead as ReservationHead} from '@/layouts/reservation/SectionHead'; import {SectionHead as ReservationHead} from '@site/layouts/reservation/SectionHead';
import {SectionHead as OasiHead} from '@/layouts/oasi/SectionHead'; import {SectionHead as OasiHead} from '@site/layouts/oasi/SectionHead';
import {SectionHead as StudioHead} from '@/layouts/studio/SectionHead'; import {SectionHead as StudioHead} from '@site/layouts/studio/SectionHead';
import {SectionHead as PastelHead} from '@/layouts/pastel/SectionHead'; import {SectionHead as PastelHead} from '@site/layouts/pastel/SectionHead';
import {SectionHead as EditorialHead} from '@/layouts/editorial/SectionHead'; import {SectionHead as EditorialHead} from '@site/layouts/editorial/SectionHead';
export type SectionTone = 'base' | 'alt' | 'dark'; export type SectionTone = 'base' | 'alt' | 'dark';

View File

@ -12,16 +12,14 @@ import {
InquirySection, InquirySection,
ITEM_SECTIONS, ITEM_SECTIONS,
LocalGuideSection, LocalGuideSection,
PricingSection,
WeatherSection, WeatherSection,
LocationSection, LocationSection,
SpaceSection, SpaceSection,
UnitsSection, UnitsSection,
StayBookingSection, StayBookingSection,
} from '@/sections'; } from '@site/sections';
import {Fragment} from 'react'; import {useSite} from '@site/lib/site-context';
import {useSite} from '@/lib/site-context'; import {hasSection, isSectionEnabled} from '@site/lib/derive';
import {hasSection, isSectionEnabled} from '@/lib/derive';
/** /**
* 홈. * 홈.
@ -59,7 +57,6 @@ export function HomePage() {
intro: AboutSection, intro: AboutSection,
info: EssentialInfoSection, info: EssentialInfoSection,
rooms: UnitsSection, rooms: UnitsSection,
pricing: PricingSection,
menu: UnitsSection, menu: UnitsSection,
programs: UnitsSection, programs: UnitsSection,
booking: BookingSection, booking: BookingSection,
@ -94,10 +91,16 @@ export function HomePage() {
if (rendered.has(key)) return null; if (rendered.has(key)) return null;
rendered.add(key); rendered.add(key);
return ( 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 /> <Component />
{section.id === 'intro' && <AnswerBlock />} {section.id === 'intro' && <AnswerBlock />}
</Fragment> </div>
); );
})} })}

View File

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

View File

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

View File

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

View File

@ -1,9 +1,9 @@
import {Phone} from 'lucide-react'; import {Phone} from 'lucide-react';
import {useSite} from '@/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {bookingLabel, bookingLinks, essentialRows, RULE_FACT_KEYS} from '@/lib/derive'; import {bookingActionLabel, bookingLinks, essentialRows, RULE_FACT_KEYS} from '@site/lib/derive';
import {formatKoreanDate} from '@/lib/format'; import {formatKoreanDate} from '@site/lib/format';
import type {InfoRow} from '@/lib/derive'; import type {InfoRow} from '@site/lib/derive';
import {Section} from '@/lib/ui'; 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" 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)'}} style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
> >
{bookingLabel(link)}로 예약 {bookingActionLabel(link)}
</a> </a>
))} ))}
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import {useCallback, useEffect, useRef, useState} from 'react'; import {useCallback, useEffect, useRef, useState} from 'react';
import {ChevronLeft, ChevronRight, Phone} from 'lucide-react'; import {ChevronLeft, ChevronRight, Phone} from 'lucide-react';
import {useSite} from '@/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {bookingLabel, bookingLinks, heroFacts, lowestPrice} from '@/lib/derive'; import {bookingActionLabel, bookingLinks, heroFacts, lowestPrice} from '@site/lib/derive';
/** /**
* 첫 화면 — 국내 펜션 사이트의 정석(`hero.slideshow`). * 첫 화면 — 국내 펜션 사이트의 정석(`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" 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)'}} style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
> >
{bookingLabel(links[0])} 예약 {bookingActionLabel(links[0])}
</a> </a>
)} )}
</div> </div>

View File

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

View File

@ -1,6 +1,6 @@
import {Phone} from 'lucide-react'; import {Phone} from 'lucide-react';
import {useSite} from '@/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {bookingLabel, bookingLinks, heroFacts, lowestPrice, placeRow} from '@/lib/derive'; import {bookingActionLabel, bookingLinks, heroFacts, lowestPrice, placeRow} from '@site/lib/derive';
/** /**
* 첫 화면 — 반으로 가른 형(`hero.split`). * 첫 화면 — 반으로 가른 형(`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" 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)'}} style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
> >
{bookingLabel(links[0])} 예약 {bookingActionLabel(links[0])}
</a> </a>
)} )}
{place.phone && ( {place.phone && (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import {ChevronDown, Phone} from 'lucide-react'; import {ChevronDown, Phone} from 'lucide-react';
import {useSite} from '@/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {bookingLabel, bookingLinks, sectionName, unitSpec, unitViews} from '@/lib/derive'; import {bookingActionLabel, bookingLinks, sectionName, unitSpec, unitViews} from '@site/lib/derive';
import {Chip, Section} from '@/lib/ui'; import {Chip, Section} from '@site/lib/ui';
/** /**
* 객실 안내 — 좌우 교차 밴드(`rooms.bands`). * 객실 안내 — 좌우 교차 밴드(`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" 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)'}} style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
> >
{unit.name} {bookingLabel(booking)} 예약 {bookingActionLabel(booking, unit.name)}
</a> </a>
)} )}
{payload.place.phone && ( {payload.place.phone && (

View File

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

View File

@ -1,8 +1,8 @@
import {useState} from 'react'; import {useState} from 'react';
import {Phone} from 'lucide-react'; import {Phone} from 'lucide-react';
import {useSite} from '@/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {bookingLabel, bookingLinks, sectionName, unitSpec, unitViews} from '@/lib/derive'; import {bookingActionLabel, bookingLinks, sectionName, unitSpec, unitViews} from '@site/lib/derive';
import {Chip, Section} from '@/lib/ui'; import {Chip, Section} from '@site/lib/ui';
/** /**
* 객실 안내 — 탭으로 갈아 끼우는 형(`rooms.tabs`). * 객실 안내 — 탭으로 갈아 끼우는 형(`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" 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)'}} style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
> >
{unit.name} {bookingLabel(booking)} 예약 {bookingActionLabel(booking, unit.name)}
</a> </a>
)} )}
{payload.place.phone && ( {payload.place.phone && (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -59,21 +59,28 @@ export interface HeadOptions {
* ★ 면 토큰(surface/-alt/inverse/border)은 캔버스와 **같은 식**으로 유도한다(shared/lib/color). * ★ 면 토큰(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 {colors, look} = payload.theme;
const surfaces = deriveSurfaces(colors); const surfaces = deriveSurfaces(colors);
const lines = [ const vars: Record<string, string> = {
`--tpl-primary: ${colors.primary};`, '--tpl-primary': colors.primary,
`--tpl-secondary: ${colors.secondary};`, '--tpl-secondary': colors.secondary,
`--tpl-bg: ${colors.bg};`, '--tpl-bg': colors.bg,
`--tpl-card: ${colors.card};`, '--tpl-card': colors.card,
`--tpl-text: ${colors.text};`, '--tpl-text': colors.text,
`--tpl-accent: ${colors.accent};`, '--tpl-accent': colors.accent,
`--tpl-surface: ${surfaces.surface};`, '--tpl-surface': surfaces.surface,
`--tpl-surface-alt: ${surfaces.surfaceAlt};`, '--tpl-surface-alt': surfaces.surfaceAlt,
`--tpl-inverse: ${surfaces.inverse};`, '--tpl-inverse': surfaces.inverse,
`--tpl-border: ${surfaces.border};`, '--tpl-border': surfaces.border,
]; };
if (look) { if (look) {
// ★ 값은 그대로 CSS 에 들어간다. `<`·`}` 이 섞이면 <style> 을 깨뜨릴 수 있어 걸러 낸다 — // ★ 값은 그대로 CSS 에 들어간다. `<`·`}` 이 섞이면 <style> 을 깨뜨릴 수 있어 걸러 낸다 —
// 서버가 해석하지 않고 실어 보내는 값이라 여기서 한 번은 봐야 한다. // 서버가 해석하지 않고 실어 보내는 값이라 여기서 한 번은 봐야 한다.
@ -91,8 +98,13 @@ export function themeStyle(payload: SitePayload): string {
// 토큰이 비어 있으면 아무것도 깔지 않는다(질감을 원하지 않는 템플릿이 있다). // 토큰이 비어 있으면 아무것도 깔지 않는다(질감을 원하지 않는 템플릿이 있다).
['--tpl-texture', safe(look.texture)], ['--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'); 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'], [/Nanum Pen Script/i, 'family=Nanum+Pen+Script'],
]; ];
function fontHref(payload: SitePayload): string { /** 템플릿이 요구하는 웹폰트 주소. 발행본 <head> 와 빌더 미리보기가 같은 것을 쓴다. */
export function fontHref(payload: SitePayload): string {
const stacks = [ const stacks = [
'Noto Sans KR', 'Noto Sans KR',
'Noto Serif KR', 'Noto Serif KR',

View File

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

View File

@ -18,7 +18,11 @@ export default defineConfig(({isSsrBuild}) => ({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
resolve: { resolve: {
alias: { 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'), '@o2o/shared': path.resolve(__dirname, '../shared/src'),
}, },
}, },