[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:
parent
387783b766
commit
b1386dd3ce
@ -77,6 +77,17 @@ server {
|
||||
}
|
||||
|
||||
# 파일명에 해시가 박혀 있다. 내용이 바뀌면 이름이 바뀌므로 영구 캐시가 안전하다.
|
||||
# 빌더 미리보기 셸. **발행본 번들**을 띄우는 CSR 한 장이다
|
||||
# (`solution/site/scripts/prerender.writePreviewShell`).
|
||||
# ★ 빌더 SPA(`location /`)로 떨어지면 안 된다 — 거기로 가면 미리보기 안에 빌더가 또 뜬다.
|
||||
# ★ iframe 으로 여는 이유는 뷰포트다. 빌더 안에 직접 그리면 미디어 쿼리가 창 폭을 봐서
|
||||
# 그리드 컬럼 수가 발행본과 달라진다(실측 89% 픽셀 차이 — SitePreview 머리주석).
|
||||
location = /preview {
|
||||
root /srv/sites;
|
||||
try_files /preview/index.html =404;
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
|
||||
location ^~ /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
access_log off;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from common.models.gmodel import PageParams, UserInfo
|
||||
@ -159,6 +160,24 @@ async def start_build(
|
||||
return RemoveNoneResponse(await service.start_build(user_info, str(place_id), req))
|
||||
|
||||
|
||||
@router.get(
|
||||
path="/preview",
|
||||
summary="에디터 미리보기 payload — 발행본과 같은 것",
|
||||
description="발행이 굽는 것과 **같은 함수**로 만든 SitePayload 를 그대로 준다. "
|
||||
"미리보기가 이 하나만 먹으면 캔버스와 발행본이 갈릴 자리가 없다. "
|
||||
"★ DB 도 파일도 건드리지 않는다 — 버전을 만들지 않으므로 눌러도 발행 이력이 안 쌓인다.",
|
||||
)
|
||||
async def site_preview(
|
||||
place_id: UUID,
|
||||
service: SiteService = Depends(),
|
||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||
):
|
||||
payload = await service.preview_payload(user_info, str(place_id))
|
||||
if payload is None:
|
||||
return JSONResponse(status_code=404, content={"detail": "사업장을 찾지 못했습니다"})
|
||||
return JSONResponse(content=payload)
|
||||
|
||||
|
||||
@router.get(path="/version/list", response_model=Res_SiteVersions, summary="빌드 버전 목록")
|
||||
async def list_versions(place_id: UUID, service: SiteService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.list_versions(user_info, str(place_id)))
|
||||
|
||||
@ -150,6 +150,37 @@ class SiteService:
|
||||
)
|
||||
return Res_SeoAudit(**report)
|
||||
|
||||
async def preview_payload(self, user_info: UserInfo, place_id: str) -> dict | None:
|
||||
"""에디터 미리보기가 쓰는 **발행본과 똑같은 payload**. DB 도 파일도 건드리지 않는다.
|
||||
|
||||
★ 왜 필요한가 (2026-09-09)
|
||||
미리보기와 발행본이 **렌더러를 두 벌** 쓰고 있었다 — 캔버스는
|
||||
`frontend/features/builder/canvas/variants/*`, 발행본은 `site/src/sections/*`.
|
||||
둘이 공유하는 건 타입과 CSS 토큰뿐이라 같은 데이터로도 다른 그림이 나왔다.
|
||||
실측(2026-09-09): 캔버스는 소개 섹션을 **설명 문구를 자리표시로** 그리는데
|
||||
발행본은 데이터가 0자면 섹션째 뺀다 — 사장님은 채워진 화면을 보고 발행해
|
||||
절반이 사라진 페이지를 받는다. `shared/lib/section-data.ts` 가 경고해 둔
|
||||
"빌더에서는 보이는데 발행하면 없다"가 파서가 아니라 **렌더러**에서 났다.
|
||||
|
||||
★ 그래서 미리보기도 이 payload 하나만 먹는다. 발행이 굽는 것과 같은 함수
|
||||
(`snapshot.build_snapshot` → `site_payload.to_site_payload`)를 그대로 거치므로,
|
||||
여기서 갈릴 자리가 없다. 버전은 아직 없으니 0 으로 넘긴다 — 화면에 안 쓰인다.
|
||||
"""
|
||||
from services.build_service import ensure_site, _load_links
|
||||
from services.site_payload import to_site_payload
|
||||
from services.snapshot import build_snapshot
|
||||
|
||||
err_type, place = await self._load_place(user_info, place_id)
|
||||
if err_type != ErrorType.SUCCESS or place is None:
|
||||
return None
|
||||
|
||||
site = await ensure_site(place_id)
|
||||
links = await _load_links(place_id)
|
||||
snapshot = await build_snapshot(place)
|
||||
# ★ version 은 None 이다. 발행 전이라 버전 행이 없고, payload 의 site.version 은
|
||||
# 캐시 무효화 키라 미리보기에서는 뜻이 없다(to_site_payload 가 0 으로 떨어뜨린다).
|
||||
return to_site_payload(place, snapshot, site, None, links)
|
||||
|
||||
# ---- 사이트 주소(네임스페이스) ---------------------------------------
|
||||
# 규칙(정규식·예약어)은 services/site_slug 한 곳에만 있다. 확인과 저장이 그것을 같이 쓴다.
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import {publishUrlString, toSlug} from '@o2o/shared';
|
||||
import {deriveSurfaces} from '@/lib/color';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {useBuilderStore, useCurrentTemplate} from '@/stores/builder';
|
||||
import {resolveVariant} from './canvas/registry';
|
||||
import {SitePreview} from './SitePreview';
|
||||
import {PUBLISH_HOST} from '@/lib/site';
|
||||
|
||||
// ★ 호스트를 상수로 박지 않는다. PublishModal 과 **다른 주소**를 보여주면 사장님은
|
||||
@ -45,6 +45,7 @@ export function CanvasView() {
|
||||
const selectedSectionId = useBuilderStore((s) => s.selectedSectionId);
|
||||
const viewport = useBuilderStore((s) => s.viewport);
|
||||
const isPreviewMode = useBuilderStore((s) => s.isPreviewMode);
|
||||
const placeId = useBuilderStore((s) => s.placeId);
|
||||
const setViewport = useBuilderStore((s) => s.setViewport);
|
||||
const selectSection = useBuilderStore((s) => s.selectSection);
|
||||
const setRightTab = useBuilderStore((s) => s.setRightTab);
|
||||
@ -61,82 +62,12 @@ export function CanvasView() {
|
||||
// ★ 면 토큰(--tpl-surface / -alt / inverse / border)은 템플릿 색에서 유도한다.
|
||||
// 이걸 안 내려보내면 SectionFrame 이 폴백(고정 stone 색)으로 떨어져서,
|
||||
// 템플릿을 바꿔도 화면 면적의 대부분이 그대로다 — 쇼케이스와 같은 식(lib/color.ts)을 쓴다.
|
||||
const surfaces = deriveSurfaces(template.colors);
|
||||
|
||||
const look = template.look;
|
||||
|
||||
const themeVars = {
|
||||
'--tpl-surface': surfaces.surface,
|
||||
'--tpl-surface-alt': surfaces.surfaceAlt,
|
||||
'--tpl-inverse': surfaces.inverse,
|
||||
'--tpl-border': surfaces.border,
|
||||
'--tpl-primary': template.colors.primary,
|
||||
'--tpl-secondary': template.colors.secondary,
|
||||
'--tpl-bg': template.colors.bg,
|
||||
'--tpl-card': template.colors.card,
|
||||
'--tpl-text': template.colors.text,
|
||||
'--tpl-accent': template.colors.accent,
|
||||
|
||||
// 색만 바꾸면 템플릿이 다 같아 보인다 — 생김새를 가르는 건 아래쪽이다.
|
||||
'--tpl-font-heading': look.fontHeading,
|
||||
'--tpl-font-body': look.fontBody,
|
||||
'--tpl-heading-tracking': look.headingTracking,
|
||||
'--tpl-heading-weight': look.headingWeight,
|
||||
'--tpl-section-space': look.sectionSpace,
|
||||
'--tpl-border-width': look.borderWidth,
|
||||
// 종이 결. 색·서체만으로는 '인쇄물'이 안 된다 — 갱지 면이 있어야 레트로가 레트로로 읽힌다.
|
||||
// 없는 템플릿에서는 undefined 라 변수 자체가 안 걸리고, `.paper` 가 폴백(none)으로 떨어진다.
|
||||
'--tpl-texture': look.texture,
|
||||
|
||||
// ★ Tailwind v4 의 테마 변수를 캔버스 안에서만 덮는다. 이러면 변이 파일 40여 개에 흩어진
|
||||
// rounded-* · shadow-* 를 하나도 안 고치고 전부 템플릿을 따르게 된다.
|
||||
// 배수는 Tailwind 기본 비율(0.25/0.375/0.5/0.75/1/1.5rem)을 그대로 옮긴 것이라,
|
||||
// 기준값이 0.75rem 이면 지금까지와 픽셀 단위로 같고 0 이면 전부 각진다.
|
||||
'--radius-sm': `calc(${look.radius} * 0.34)`,
|
||||
'--radius-md': `calc(${look.radius} * 0.5)`,
|
||||
'--radius-lg': `calc(${look.radius} * 0.67)`,
|
||||
'--radius-xl': look.radius,
|
||||
'--radius-2xl': `calc(${look.radius} * 1.34)`,
|
||||
'--radius-3xl': `calc(${look.radius} * 2)`,
|
||||
'--shadow-xs': look.shadow,
|
||||
'--shadow-sm': look.shadow,
|
||||
'--shadow-md': look.shadow,
|
||||
} as CSSProperties;
|
||||
|
||||
/**
|
||||
* 섹션 하나를 그린다.
|
||||
*
|
||||
* 어떤 레이아웃으로 그릴지는 레지스트리가 정한다 — 여기에 switch 를 두면
|
||||
* 배리에이션을 하나 추가할 때마다 이 파일을 같이 고쳐야 한다.
|
||||
/*
|
||||
* ★ 색·서체 토큰을 여기서 만들지 않는다(2026-09-09).
|
||||
* 캔버스가 빌더 상태로 --tpl-* 를 따로 만들던 동안, 그 값이 발행본과 갈라졌다 —
|
||||
* 실측: 편집 캔버스 #ffffff·Pretendard ↔ 발행본 #e4dac0·Gugi.
|
||||
* 이제 미리보기·편집 둘 다 iframe 안 발행본 렌더러가 그리고, 토큰은 payload 하나에서 온다.
|
||||
*/
|
||||
const renderSection = (section: SectionItem) => {
|
||||
if (!section.isEnabled) return null;
|
||||
|
||||
const variant = resolveVariant(section, industry);
|
||||
// 아직 배리에이션이 없는 섹션 타입 — 캔버스를 깨뜨리지 않고 조용히 건너뛴다.
|
||||
if (!variant) return null;
|
||||
|
||||
const {Component} = variant;
|
||||
|
||||
return (
|
||||
<Component
|
||||
key={section.id}
|
||||
section={section}
|
||||
industryId={industry}
|
||||
storeName={storeName}
|
||||
location={location}
|
||||
weatherLocation={weatherLocation}
|
||||
template={template}
|
||||
infoFields={infoFields}
|
||||
photos={photos}
|
||||
isSelected={!isPreviewMode && selectedSectionId === section.id}
|
||||
onSelect={() => {
|
||||
selectSection(section.id);
|
||||
setRightTab('content');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-muted">
|
||||
@ -175,10 +106,10 @@ export function CanvasView() {
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 items-start justify-center overflow-y-auto bg-muted/70 p-2 sm:p-6">
|
||||
<div
|
||||
style={themeVars}
|
||||
className={cn('site-canvas my-auto transition-all duration-300', VIEWPORT_FRAME[viewport])}
|
||||
>
|
||||
{/* ★ 이 껍데기는 토큰도 폭도 정하지 않는다(2026-09-09).
|
||||
안쪽 iframe 이 **자기 뷰포트**를 만들고 색·서체는 payload 가 준다.
|
||||
바깥이 한 번 더 얹으면 두 겹이 돼 바깥 값이 안쪽을 덧칠한다 — 실제로 그랬다. */}
|
||||
<div className="my-auto w-full min-w-0 transition-all duration-300">
|
||||
{viewport !== 'pc' && (
|
||||
<div className="flex items-center justify-between bg-foreground px-4 py-2 font-mono text-[11px] text-background">
|
||||
<span className="flex items-center gap-1.5">
|
||||
@ -193,10 +124,22 @@ export function CanvasView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 섹션은 좌측 패널이 정한 순서 그대로 그린다. */}
|
||||
<div className="divide-y divide-black/5">
|
||||
{sections.map((section) => renderSection(section))}
|
||||
</div>
|
||||
{/* ★ 편집 모드도 **발행본 렌더러**가 그린다(2026-09-09).
|
||||
캔버스 전용 컴포넌트를 따로 두는 동안 두 화면이 아예 다른 트리였다 — 실측:
|
||||
발행본 15섹션 · 에디터 12섹션, 겹치는 건 4개뿐이고 이름도 달랐다
|
||||
(gallery↔photos · location↔map · guide↔local). 사장님이 편집한 화면과
|
||||
발행된 화면이 서로 다른 물건이었다.
|
||||
고르는 일은 iframe 안 섹션을 눌러서 한다 — SitePreview 가 배선한다. */}
|
||||
<SitePreview
|
||||
placeId={placeId}
|
||||
viewport={viewport}
|
||||
interactive={!isPreviewMode}
|
||||
selectedId={selectedSectionId}
|
||||
onSelect={(id) => {
|
||||
selectSection(id);
|
||||
setRightTab('content');
|
||||
}}
|
||||
/>
|
||||
|
||||
<footer
|
||||
className="border-t border-black/10 p-6 text-center text-xs"
|
||||
|
||||
146
solution/frontend/src/features/builder/SitePreview.tsx
Normal file
146
solution/frontend/src/features/builder/SitePreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -495,6 +495,77 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
|
||||
],
|
||||
},
|
||||
|
||||
event: {
|
||||
kind: 'event',
|
||||
label: '소식',
|
||||
sample: JSON.stringify(
|
||||
{
|
||||
kind: 'event',
|
||||
version: 1,
|
||||
title: '소식',
|
||||
items: [
|
||||
{
|
||||
title: '가을 평일 2박 할인',
|
||||
kind: '이벤트',
|
||||
startDate: '2026-09-01',
|
||||
endDate: '2026-10-31',
|
||||
summary: '평일 2박 이상 예약하시면 1박 요금의 20%를 빼 드립니다.',
|
||||
howTo: "예약 시 요청사항에 '가을 평일' 이라고 적어 주세요.",
|
||||
postUrl: 'https://www.instagram.com/p/xxxxxxxxxxx/',
|
||||
verified: '확인',
|
||||
},
|
||||
{
|
||||
title: '10월 정기 휴무 안내',
|
||||
kind: '공지',
|
||||
startDate: '2026-10-14',
|
||||
endDate: '2026-10-16',
|
||||
body: '설비 점검으로 사흘간 쉽니다. 이 기간 예약은 받지 않습니다.',
|
||||
verified: '확인',
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
/*
|
||||
* ★ 이 아이템만 프롬프트가 다르다. 나머지는 지역을 리서치해 오는 것이지만
|
||||
* 소식은 **이 가게에서 지금 실제로 하는 일**이다 — 모델이 알 수 없고, 지어내면
|
||||
* 손님이 없는 행사를 보고 찾아온다. 그래서 "찾아라" 가 아니라 "옮겨 적어라" 다.
|
||||
*/
|
||||
task: `[해야 할 일]
|
||||
아래 붙여넣은 인스타그램 게시물·공지 글을 소식 JSON 으로 **옮겨 적는다.** 찾지 말고 옮긴다.
|
||||
|
||||
[여기에 원문을 붙여넣으세요]
|
||||
|
||||
|
||||
[스키마]
|
||||
{ "kind":"event", "version":1, "title":"소식", "items":[
|
||||
{ "title":"소식 제목", "kind":"이벤트|공지",
|
||||
"startDate":"2026-09-01", "endDate":"2026-10-31",
|
||||
"summary":"카드에 보일 한두 문장", "body":"긴 본문(공지처럼 여러 줄일 때만)",
|
||||
"howTo":"어떻게 참여하나",
|
||||
"postUrl":"https://www.instagram.com/p/...",
|
||||
"verified":"확인|확인필요" } ] }`,
|
||||
rules: `
|
||||
[이 아이템만의 규칙]
|
||||
· 원문에 없는 날짜·혜택·조건을 만들지 않는다. 안 적혀 있으면 그 필드를 뺀다.
|
||||
· 기간이 지난 소식은 넣지 않는다 — 끝난 행사를 걸어 두면 손님이 헛걸음한다.
|
||||
· postUrl 은 붙여넣은 원문의 주소 그대로다. 없으면 넣지 않는다.
|
||||
· 사장님이 직접 옮긴 것이므로 verified 는 "확인" 이다.
|
||||
`,
|
||||
fields: [
|
||||
{key: 'title', label: '제목'},
|
||||
{key: 'kind', label: '종류', type: 'text', options: ['이벤트', '공지'], half: true},
|
||||
{key: 'startDate', label: '시작일', type: 'text', hint: '2026-09-01', half: true},
|
||||
{key: 'endDate', label: '종료일', type: 'text', hint: '2026-10-31', half: true},
|
||||
{key: 'summary', label: '요약', type: 'area', hint: '카드에 보이는 한두 문장'},
|
||||
{key: 'body', label: '본문', type: 'area', hint: '공지처럼 여러 줄일 때만'},
|
||||
{key: 'howTo', label: '참여 방법', type: 'area'},
|
||||
{key: 'postUrl', label: '원문 주소', type: 'text'},
|
||||
...SOURCE_FIELDS,
|
||||
],
|
||||
},
|
||||
|
||||
itinerary: {
|
||||
kind: 'itinerary',
|
||||
label: '추천 일정',
|
||||
|
||||
@ -69,10 +69,14 @@ export function usePlaceSync(placeId: string | null, options?: ApplyPlaceOptions
|
||||
* `npm run orval` 을 돌리면 이 캐스팅은 지운다.
|
||||
*/
|
||||
const savedTheme = (siteQuery.data?.site as {theme?: SiteThemePayload} | undefined)?.theme;
|
||||
// ★ templateId 는 theme 안이 아니라 **sites.template_id 컬럼**이다(Req_SiteTheme 주석).
|
||||
// 같이 얹지 않으면 에디터가 늘 업종 첫 템플릿으로 그려진다 — 사장님이 '옛 항구'를 골라
|
||||
// 발행해도 다시 들어오면 편집 화면만 흰 바탕·고딕이었다(실측 2026-09-09).
|
||||
const savedTemplateId = siteQuery.data?.site?.template_id ?? null;
|
||||
useEffect(() => {
|
||||
if (!placeId) return;
|
||||
applyTheme(savedTheme);
|
||||
}, [placeId, savedTheme, applyTheme]);
|
||||
applyTheme(savedTheme, savedTemplateId);
|
||||
}, [placeId, savedTheme, savedTemplateId, applyTheme]);
|
||||
|
||||
return {
|
||||
/** 아직 다 못 읽었다 — 이 동안 데모 화면을 보여주면 남의 가게를 자기 가게로 오해한다. */
|
||||
|
||||
@ -139,7 +139,7 @@ interface BuilderState {
|
||||
setSectionVariant: (sectionId: string, variantId: string) => void;
|
||||
resetSectionVariants: () => void;
|
||||
/** 서버에 저장돼 있던 디자인을 화면에 얹는다(새로고침·다른 기기 복원). */
|
||||
applyTheme: (theme: SiteThemePayload | null | undefined) => void;
|
||||
applyTheme: (theme: SiteThemePayload | null | undefined, savedTemplateId?: string | null) => void;
|
||||
|
||||
updateField: (id: string, value: string) => void;
|
||||
updateFieldLabel: (id: string, label: string) => void;
|
||||
@ -615,11 +615,11 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
* 반대로 저장값에 없는 시드 섹션은 **뒤에 붙인다**: 새로 생긴 섹션이 조용히 사라지면
|
||||
* 사장님은 그 기능이 있는 줄도 모른다.
|
||||
*/
|
||||
applyTheme: (theme) => {
|
||||
if (!theme) return;
|
||||
applyTheme: (theme, savedTemplateId) => {
|
||||
if (!theme && !savedTemplateId) return;
|
||||
set((state) => {
|
||||
const seedById = new Map(state.sections.map((sec) => [sec.id, sec]));
|
||||
const ordered = (theme.sections ?? [])
|
||||
const ordered = (theme?.sections ?? [])
|
||||
// ★ 반환 타입을 못 박는다. 추론에 맡기면 description 이 **필수** 키로 좁혀져
|
||||
// (SectionItem 에서는 선택 키다) 아래 filter 의 타입 술어가 컴파일되지 않는다.
|
||||
.map((saved): SectionItem | null => {
|
||||
@ -646,18 +646,32 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
.filter((sec): sec is SectionItem => sec !== null);
|
||||
|
||||
return {
|
||||
/*
|
||||
* ★ 저장된 템플릿을 **되살린다** (2026-09-09).
|
||||
* 이게 없어서 에디터가 늘 업종 첫 템플릿(심플)으로 그려졌다 — 사장님이 '옛 항구'를
|
||||
* 골라 발행해도, 다시 들어오면 편집 화면만 흰 바탕·고딕이었다.
|
||||
* 실측: 편집 캔버스 --tpl-bg #ffffff · Pretendard ↔ 발행본 #e4dac0 · Gugi.
|
||||
* templateId 는 theme 안이 아니라 **sites.template_id 컬럼**에 있다
|
||||
* (protocol.Req_SiteTheme 주석 — theme 저장 body 에는 없다). 그래서 따로 받는다.
|
||||
* ★ 시드에 없는 id 는 무시한다. 옛 이름이 남아 있을 수 있고, 없는 템플릿으로
|
||||
* 두면 resolveTemplate 이 첫 항목으로 떨어져 지금과 같은 상태가 된다.
|
||||
*/
|
||||
...(savedTemplateId &&
|
||||
INDUSTRY_CONFIGS[state.industry].templates.some((t) => t.id === savedTemplateId)
|
||||
? {templateId: savedTemplateId}
|
||||
: {}),
|
||||
sections: [...ordered, ...seedById.values()],
|
||||
colorPaletteId: theme.colorPaletteId ?? null,
|
||||
colorPaletteId: theme?.colorPaletteId ?? null,
|
||||
infoFields: [
|
||||
...state.infoFields,
|
||||
...(theme.customInfoFields ?? []).filter(
|
||||
...(theme?.customInfoFields ?? []).filter(
|
||||
(custom) => !state.infoFields.some((field) => field.id === custom.id),
|
||||
),
|
||||
],
|
||||
photos: theme.visiblePhotoIds
|
||||
photos: theme?.visiblePhotoIds
|
||||
? state.photos.map((photo) => ({
|
||||
...photo,
|
||||
isVisible: theme.visiblePhotoIds?.includes(photo.id),
|
||||
isVisible: theme?.visiblePhotoIds?.includes(photo.id),
|
||||
}))
|
||||
: state.photos,
|
||||
};
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
"types": ["vite/client", "node"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@site/*": ["../site/src/*"],
|
||||
"@o2o/shared": ["../shared/src/index.ts"],
|
||||
"@o2o/shared/*": ["../shared/src/*"]
|
||||
}
|
||||
|
||||
@ -18,6 +18,12 @@ export default defineConfig({
|
||||
// 워크스페이스 심볼릭 링크를 타지 않고 소스를 직접 가리킨다 —
|
||||
// shared 를 고치면 빌드 없이 HMR 이 바로 돈다.
|
||||
'@o2o/shared': path.resolve(__dirname, '../shared/src'),
|
||||
// ★ 발행본 렌더러를 **그대로** 쓴다(2026-09-09). 미리보기와 발행본이 컴포넌트를 두 벌
|
||||
// 두면 같은 데이터로도 다른 그림이 나온다 — 실측으로 캔버스는 소개 섹션을 설명
|
||||
// 문구로 채워 그렸는데 발행본은 데이터가 0자라 섹션째 뺐다. 사장님은 채워진 화면을
|
||||
// 보고 발행해 절반이 사라진 페이지를 받았다.
|
||||
// 저쪽 별칭이 '@' 가 아니라 '@site' 인 것도 이 때문이다(solution/site/vite.config.ts).
|
||||
'@site': path.resolve(__dirname, '../site/src'),
|
||||
},
|
||||
},
|
||||
// ★ 산출물이 `dist/` 가 아니라 `build/client/` 로 나간다(프레임워크 모드 기본값).
|
||||
|
||||
@ -18,7 +18,7 @@ import {
|
||||
sanitizePayloadForPublish,
|
||||
SECTION_ITEM_REQUIRED_KEY,
|
||||
} from '@o2o/shared';
|
||||
import {render} from '@/entry-server';
|
||||
import {render} from '@site/entry-server';
|
||||
import {
|
||||
collectJsonLd,
|
||||
homeMeta,
|
||||
@ -34,8 +34,8 @@ import {
|
||||
type SiteEntry,
|
||||
verifyGeo,
|
||||
verifyJsonLd,
|
||||
} from '@/seo';
|
||||
import {MOONLIGHT_STAY_PAYLOAD} from '@/fixtures/moonlight-stay';
|
||||
} from '@site/seo';
|
||||
import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay';
|
||||
|
||||
/**
|
||||
* 발행 사이트 프리렌더.
|
||||
@ -667,6 +667,45 @@ function writeSharedAssets(destRoot: string, referenced: Set<string>) {
|
||||
* 발행은 **바뀐 사이트 하나만** 굽는다(scripts/watch-payloads.mjs). 이번 실행분만 인덱스에
|
||||
* 담으면 나머지 사이트가 인덱스에서 사라진다. 디스크에 실제로 존재하는 발행본이 곧 정답이다.
|
||||
*/
|
||||
/**
|
||||
* 빌더 미리보기가 iframe 으로 띄우는 CSR 셸.
|
||||
*
|
||||
* ★ 왜 iframe 인가 (2026-09-09)
|
||||
* 빌더 안에 발행본 컴포넌트를 **직접** 그려 봤는데, 색·서체·섹션 구성이 같아져도
|
||||
* **레이아웃 폭이 어긋났다.** 미디어 쿼리는 창 폭을 보는데 미리보기의 실제 사이트 폭은
|
||||
* 그 안의 프레임(max-w-5xl)이기 때문이다. 실측(1400px 창 · 1024px 프레임):
|
||||
* festival 2560px → 6027px, guide 1168 → 2168, location 586 → 1135.
|
||||
* 내용(글자 수)은 완전히 같은데 그리드 컬럼 수만 달라 두 배씩 길어졌다.
|
||||
* iframe 은 자체 뷰포트를 가져서 미디어 쿼리가 발행본과 **정확히 같은 폭**을 본다.
|
||||
* PC/태블릿/모바일 전환도 iframe 폭만 바꾸면 그대로 맞는다.
|
||||
*
|
||||
* ★ payload 는 셸이 아니라 브라우저가 가져온다(`entry-client`). 굽는 시점에는
|
||||
* 어느 사업장을 미리 볼지 모르고, 미리보기는 **발행 전 최신 값**을 봐야 한다.
|
||||
*/
|
||||
function writePreviewShell(outRoot: string, assets: {script: string; css: string[]}) {
|
||||
const lines = [
|
||||
'<!doctype html>',
|
||||
'<html lang="ko">',
|
||||
' <head>',
|
||||
' <meta charset="utf-8" />',
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1" />',
|
||||
// ★ 미리보기는 색인 대상이 아니다. 발행 전 값이라 검색에 걸리면 안 된다.
|
||||
' <meta name="robots" content="noindex, nofollow" />',
|
||||
' <title>미리보기</title>',
|
||||
...assets.css.map((file) => ` <link rel="stylesheet" href="/assets/${file}" />`),
|
||||
' </head>',
|
||||
' <body class="site">',
|
||||
' <div id="root"></div>',
|
||||
` <script type="module" src="/assets/${assets.script}"></script>`,
|
||||
' </body>',
|
||||
'</html>',
|
||||
];
|
||||
const dir = join(outRoot, 'preview');
|
||||
mkdirSync(dir, {recursive: true});
|
||||
writeFileSync(join(dir, 'index.html'), lines.join('\n'), 'utf-8');
|
||||
console.log(' ✓ 빌더 미리보기 셸 (/preview)');
|
||||
}
|
||||
|
||||
function writeRootMachineFiles(outRoot: string, origin: string) {
|
||||
const sitesDir = join(outRoot, SITE_DIR);
|
||||
if (!existsSync(sitesDir)) return;
|
||||
@ -834,6 +873,7 @@ function main() {
|
||||
|
||||
// ★ 실패한 사이트가 있어도 루트 파일은 갱신한다 — 성공한 사이트까지 색인에서 빠질 이유가 없다.
|
||||
// (인덱스는 디스크를 훑으므로 실패한 사이트는 애초에 들어가지 않는다.)
|
||||
writePreviewShell(args.out, assets);
|
||||
if (origin) writeRootMachineFiles(args.out, origin);
|
||||
|
||||
if (failed > 0) {
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import type {SitePayload} from '@o2o/shared';
|
||||
import {MobileTabBar, SiteFooter, SiteHeader} from '@/sections';
|
||||
import {SiteProvider} from '@/lib/site-context';
|
||||
import {layoutOf} from '@/lib/layout';
|
||||
import {Shell as ReservationShell} from '@/layouts/reservation/Shell';
|
||||
import {Shell as OasiShell} from '@/layouts/oasi/Shell';
|
||||
import {Shell as StudioShell} from '@/layouts/studio/Shell';
|
||||
import {Shell as PastelShell} from '@/layouts/pastel/Shell';
|
||||
import {Shell as EditorialShell} from '@/layouts/editorial/Shell';
|
||||
import {HomePage} from '@/pages';
|
||||
import {MobileTabBar, SiteFooter, SiteHeader} from '@site/sections';
|
||||
import {SiteProvider} from '@site/lib/site-context';
|
||||
import {layoutOf} from '@site/lib/layout';
|
||||
import {Shell as ReservationShell} from '@site/layouts/reservation/Shell';
|
||||
import {Shell as OasiShell} from '@site/layouts/oasi/Shell';
|
||||
import {Shell as StudioShell} from '@site/layouts/studio/Shell';
|
||||
import {Shell as PastelShell} from '@site/layouts/pastel/Shell';
|
||||
import {Shell as EditorialShell} from '@site/layouts/editorial/Shell';
|
||||
import {HomePage} from '@site/pages';
|
||||
|
||||
/**
|
||||
* 발행 사이트 — **한 장짜리다.**
|
||||
|
||||
@ -2,6 +2,7 @@ import {StrictMode} from 'react';
|
||||
import {createRoot, hydrateRoot} from 'react-dom/client';
|
||||
import type {SitePayload} from '@o2o/shared';
|
||||
import {App} from './App';
|
||||
import {fontHref, themeVars} from '@site/seo/head';
|
||||
import './index.css';
|
||||
|
||||
declare global {
|
||||
@ -14,6 +15,65 @@ declare global {
|
||||
const container = document.getElementById('root')!;
|
||||
const injected = window.__SITE_PAYLOAD__;
|
||||
|
||||
/**
|
||||
* 빌더 미리보기(iframe)가 여는 셸에서만 쓰는 경로 — `/preview?placeId=…`.
|
||||
*
|
||||
* ★ 왜 여기서 payload 를 가져오나
|
||||
* 미리보기는 **발행 전 최신 값**을 봐야 한다. 굽는 시점에는 어느 사업장을 미리 볼지
|
||||
* 모르므로 셸에 payload 를 심을 수 없다(prerender `writePreviewShell`).
|
||||
* ★ 토큰은 부모(빌더)와 **같은 오리진의 localStorage** 에서 읽는다. 미리보기는 자기 서버가
|
||||
* 아니라 빌더가 쓰는 것과 같은 API 를 부르므로, 키도 그쪽과 같아야 한다
|
||||
* (`frontend/src/api/mutator/custom-fetch.ts`).
|
||||
* ★ 색·서체 토큰과 **웹폰트 링크**를 발행본이 `<head>` 에 굽는 것과 같은 함수로 만든다
|
||||
* (themeVars · fontHref). 셸은 어느 템플릿인지 모른 채 구워지므로 둘 다 여기서 얹는다.
|
||||
* ★ 폰트를 빠뜨리면 조용히 틀린다 — 레이아웃·색은 그대로인데 글자만 기본 산세리프로
|
||||
* 떨어진다. 실측(2026-09-09): 간판체(Gugi)가 안 실려 픽셀 차이가 92% 였는데
|
||||
* 지오메트리(header·hero·h1 위치)는 발행본과 완전히 같았다.
|
||||
*/
|
||||
async function renderPreview(placeId: string) {
|
||||
const token = (() => {
|
||||
try {
|
||||
return localStorage.getItem('o2o-web4ai.accessToken');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const res = await fetch(`/v1/place/${placeId}/site/preview`, {
|
||||
headers: token ? {Authorization: `Bearer ${token}`} : {},
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const payload = (await res.json()) as SitePayload;
|
||||
|
||||
for (const [name, value] of Object.entries(themeVars(payload))) {
|
||||
document.documentElement.style.setProperty(name, value);
|
||||
}
|
||||
|
||||
const font = document.createElement('link');
|
||||
font.rel = 'stylesheet';
|
||||
font.href = fontHref(payload);
|
||||
document.head.appendChild(font);
|
||||
// 글자가 기본 서체로 한 번 그려졌다 바뀌는 것을 줄인다. 못 받아도 렌더는 계속한다.
|
||||
await new Promise<void>((resolve) => {
|
||||
font.addEventListener('load', () => resolve(), {once: true});
|
||||
font.addEventListener('error', () => resolve(), {once: true});
|
||||
setTimeout(resolve, 2500);
|
||||
});
|
||||
try {
|
||||
await document.fonts.ready;
|
||||
} catch {
|
||||
/* 폰트 API 가 없거나 실패해도 그린다 */
|
||||
}
|
||||
|
||||
createRoot(container).render(
|
||||
<StrictMode>
|
||||
<App payload={payload} />
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
const previewPlaceId = new URLSearchParams(window.location.search).get('placeId');
|
||||
|
||||
if (injected) {
|
||||
// 정적 HTML 위에 하이드레이션. 서버가 그린 마크업과 1:1 이어야 하므로
|
||||
// 여기서 payload 를 바꾸거나 fetch 를 걸지 않는다.
|
||||
@ -23,6 +83,11 @@ if (injected) {
|
||||
<App payload={injected} />
|
||||
</StrictMode>,
|
||||
);
|
||||
} else if (previewPlaceId) {
|
||||
void renderPreview(previewPlaceId).catch((ex: unknown) => {
|
||||
// 미리보기가 못 떠도 편집은 계속돼야 한다. 부모 창이 읽을 수 있게 이유를 남긴다.
|
||||
container.textContent = `미리보기를 불러오지 못했습니다 — ${ex instanceof Error ? ex.message : String(ex)}`;
|
||||
});
|
||||
} else {
|
||||
// 개발 서버(vite dev) — 데모 payload 로 CSR 렌더. 프로덕션 경로가 아니다.
|
||||
void import('./fixtures/moonlight-stay').then(({MOONLIGHT_STAY_PAYLOAD}) => {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {primaryImage} from '@/seo/meta';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {primaryImage} from '@site/seo/meta';
|
||||
import {
|
||||
bookingLabel,
|
||||
bookingActionLabel,
|
||||
bookingLinks,
|
||||
contactLinks,
|
||||
channelLabel,
|
||||
@ -9,7 +9,7 @@ import {
|
||||
lowestPrice,
|
||||
unitSpec,
|
||||
unitViews,
|
||||
} from '@/lib/derive';
|
||||
} from '@site/lib/derive';
|
||||
import {GRAIN} from './SectionHead';
|
||||
|
||||
/**
|
||||
@ -117,7 +117,7 @@ export function Hero() {
|
||||
rel="noopener noreferrer"
|
||||
className="tap inline-flex items-center underline underline-offset-4"
|
||||
>
|
||||
{bookingLabel(booking)} 예약
|
||||
{bookingActionLabel(booking)}
|
||||
</a>
|
||||
)}
|
||||
{place.phone && (
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import type {ChannelLink} from '@o2o/shared';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {
|
||||
bookingLabel,
|
||||
bookingActionLabel,
|
||||
bookingLinks,
|
||||
sectionName,
|
||||
unitSpec,
|
||||
unitViews,
|
||||
type UnitView,
|
||||
} from '@/lib/derive';
|
||||
} from '@site/lib/derive';
|
||||
import {GRAIN, SectionHead} from './SectionHead';
|
||||
|
||||
/**
|
||||
@ -153,7 +153,7 @@ function UnitSpread({
|
||||
rel="noopener noreferrer"
|
||||
className="tap inline-flex items-center underline underline-offset-4"
|
||||
>
|
||||
{bookingLabel(booking)} 예약
|
||||
{bookingActionLabel(booking)}
|
||||
</a>
|
||||
)}
|
||||
{phone && (
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {enabledSections, unitSpec} from '@/lib/derive';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {enabledSections, unitSpec} from '@site/lib/derive';
|
||||
|
||||
/**
|
||||
* 편집(잡지)형 — 섹션 제목 · 차례.
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import {useEffect, useState, type ReactNode} from 'react';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {bookingLabel, bookingLinks, channelLabel} from '@/lib/derive';
|
||||
import {formatKoreanDate, isoDate} from '@/lib/format';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingActionLabel, bookingLinks, channelLabel} from '@site/lib/derive';
|
||||
import {formatKoreanDate, isoDate} from '@site/lib/format';
|
||||
// 배럴(@/sections)이 아니라 파일을 직접 가리킨다 — 배럴을 타면 섹션 전부가 딸려 온다.
|
||||
import {MobileTabBar} from '@/sections/MobileTabBar';
|
||||
import {MobileTabBar} from '@site/sections/MobileTabBar';
|
||||
import {GRAIN, useSectionIndex, type IndexEntry} from './SectionHead';
|
||||
|
||||
/**
|
||||
@ -60,7 +60,7 @@ export function Shell({children}: {children: ReactNode}) {
|
||||
rel="noopener noreferrer"
|
||||
className="tap inline-flex items-center font-bold underline underline-offset-4"
|
||||
>
|
||||
{bookingLabel(booking)} 예약
|
||||
{bookingActionLabel(booking)}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {primaryImage} from '@/seo/meta';
|
||||
import {bookingLabel, bookingLinks, essentialRows, heroFacts, lowestPrice} from '@/lib/derive';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {primaryImage} from '@site/seo/meta';
|
||||
import {bookingActionLabel, bookingLinks, essentialRows, heroFacts, lowestPrice} from '@site/lib/derive';
|
||||
|
||||
/**
|
||||
* oasi 첫 화면 — 사진 한 장과 아주 작은 글씨.
|
||||
@ -98,7 +98,7 @@ export function Hero() {
|
||||
className="tap inline-flex items-center text-[length:var(--fs-sm)] underline underline-offset-[6px]"
|
||||
style={{color: 'var(--color-brand)'}}
|
||||
>
|
||||
{bookingLabel(link)} 예약
|
||||
{bookingActionLabel(link)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {bookingLabel, bookingLinks, sectionName, unitSpec, unitViews} from '@/lib/derive';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingActionLabel, bookingLinks, sectionName, unitSpec, unitViews} from '@site/lib/derive';
|
||||
import {SectionHead} from './SectionHead';
|
||||
|
||||
/**
|
||||
@ -116,7 +116,7 @@ export function Rooms() {
|
||||
className="tap inline-flex items-center underline underline-offset-[6px]"
|
||||
style={{color: 'var(--color-brand)'}}
|
||||
>
|
||||
{bookingLabel(booking)} 예약
|
||||
{bookingActionLabel(booking)}
|
||||
</a>
|
||||
)}
|
||||
{payload.place.phone && (
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
import type {CSSProperties, ReactNode} from 'react';
|
||||
import {isoDate} from '@/lib/format';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {isoDate} from '@site/lib/format';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {
|
||||
bookingActionLabel,
|
||||
bookingLabel,
|
||||
bookingLinks,
|
||||
contactLinks,
|
||||
heroFacts,
|
||||
isSectionEnabled,
|
||||
unitSpec,
|
||||
} from '@/lib/derive';
|
||||
} from '@site/lib/derive';
|
||||
// ★ 배럴(`@/sections`)을 거치면 껍데기 하나가 사이트의 모든 섹션을 끌고 들어온다.
|
||||
// 껍데기는 하단 고정 바 하나만 필요하다 — 파일을 직접 가리킨다.
|
||||
import {MobileTabBar} from '@/sections/MobileTabBar';
|
||||
import {MobileTabBar} from '@site/sections/MobileTabBar';
|
||||
|
||||
/**
|
||||
* 본문 섹션의 색 띠를 걷어낸다 (2026-09-04, 사장님 지적).
|
||||
@ -299,7 +300,7 @@ export function Shell({children}: {children: ReactNode}) {
|
||||
className="tap mt-2 inline-flex items-center text-[length:var(--fs-sm)] underline underline-offset-[6px]"
|
||||
style={{color: 'var(--color-brand)'}}
|
||||
>
|
||||
{bookingLabel(booking)} 예약
|
||||
{bookingActionLabel(booking)}
|
||||
</a>
|
||||
)}
|
||||
</header>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {bookingLabel, bookingLinks, heroFacts, lowestPrice} from '@/lib/derive';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingActionLabel, bookingLinks, heroFacts, lowestPrice} from '@site/lib/derive';
|
||||
|
||||
/**
|
||||
* 파스텔의 첫 화면 — **연노랑 판 위에 둥근 사진**.
|
||||
@ -99,7 +99,7 @@ export function Hero() {
|
||||
className="tap mt-7 inline-flex items-center justify-center rounded-full px-8 text-[length:var(--fs-sm)] font-extrabold transition-opacity hover:opacity-90"
|
||||
style={{backgroundColor: 'var(--pastel-ink)', color: 'var(--tpl-bg, #fff)'}}
|
||||
>
|
||||
{bookingLabel(links[0])}로 예약
|
||||
{bookingActionLabel(links[0])}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import {ChevronDown, Phone} from 'lucide-react';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {bookingLabel, bookingLinks, sectionName, unitSpec, unitViews} from '@/lib/derive';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingActionLabel, bookingLinks, sectionName, unitSpec, unitViews} from '@site/lib/derive';
|
||||
import {SectionHead} from './SectionHead';
|
||||
|
||||
/**
|
||||
@ -112,7 +112,7 @@ export function Rooms() {
|
||||
className="tap flex flex-1 items-center justify-center rounded-full text-[length:var(--fs-sm)] font-extrabold transition-opacity hover:opacity-90"
|
||||
style={{backgroundColor: 'var(--pastel-ink)', color: 'var(--tpl-bg, #fff)'}}
|
||||
>
|
||||
{bookingLabel(booking)} 예약
|
||||
{bookingActionLabel(booking)}
|
||||
</a>
|
||||
)}
|
||||
{payload.place.phone && (
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import type {CSSProperties, ReactNode} from 'react';
|
||||
import {Instagram, Menu, MessageCircle, Phone} from 'lucide-react';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {channelLabel, contactLinks, isSectionEnabled, unitSpec} from '@/lib/derive';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {channelLabel, contactLinks, isSectionEnabled, unitSpec} from '@site/lib/derive';
|
||||
// ★ 배럴(@/sections)로 들어가면 UnitsSection → layouts/pastel/Rooms 로 돌아와 순환 참조다.
|
||||
// 쓰는 파일을 직접 가리킨다.
|
||||
import {SiteFooter} from '@/sections/SiteFooter';
|
||||
import {MobileTabBar} from '@/sections/MobileTabBar';
|
||||
import {SiteFooter} from '@site/sections/SiteFooter';
|
||||
import {MobileTabBar} from '@site/sections/MobileTabBar';
|
||||
|
||||
/**
|
||||
* 파스텔 — 색면(色面) 블록형 껍데기.
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {bookingLabel, bookingLinks, unitSpec} from '@/lib/derive';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingLabel, bookingLinks, unitSpec} from '@site/lib/derive';
|
||||
|
||||
/**
|
||||
* 예약형 첫 화면 — 사진이 화면을 채우고 저절로 넘어가며, 정중앙에 이름이 선다.
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {
|
||||
bookingLabel,
|
||||
bookingActionLabel,
|
||||
bookingLinks,
|
||||
essentialRows,
|
||||
isSectionEnabled,
|
||||
sectionName,
|
||||
unitSpec,
|
||||
unitViews,
|
||||
} from '@/lib/derive';
|
||||
} from '@site/lib/derive';
|
||||
import {SectionHead} from './SectionHead';
|
||||
|
||||
/**
|
||||
@ -175,7 +175,7 @@ export function Rooms() {
|
||||
rel="noopener noreferrer"
|
||||
className="tap border-line inline-flex items-center justify-center border px-9 text-[length:var(--fs-sm)] font-light tracking-[0.14em] transition-opacity hover:opacity-70"
|
||||
>
|
||||
{bookingLabel(booking)} 예약
|
||||
{bookingActionLabel(booking)}
|
||||
</a>
|
||||
)}
|
||||
{payload.place.phone && (
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import {useEffect, useState, type ReactNode} from 'react';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {
|
||||
bookingLabel,
|
||||
bookingLinks,
|
||||
@ -7,11 +7,11 @@ import {
|
||||
isSectionEnabled,
|
||||
sectionName,
|
||||
unitSpec,
|
||||
} from '@/lib/derive';
|
||||
import {formatKoreanDate, isoDate} from '@/lib/format';
|
||||
} from '@site/lib/derive';
|
||||
import {formatKoreanDate, isoDate} from '@site/lib/format';
|
||||
// ★ `@/sections` 배럴이 아니라 파일을 직접 가리킨다. 배럴은 섹션 전부를 끌고 오는데,
|
||||
// 그 안의 UnitsSection 이 이 폴더의 Rooms 를 다시 부른다 — 배럴을 타면 순환이 된다.
|
||||
import {MobileTabBar} from '@/sections/MobileTabBar';
|
||||
import {MobileTabBar} from '@site/sections/MobileTabBar';
|
||||
|
||||
/**
|
||||
* 예약형(reservation) 껍데기 — 머리 · 본문 · 꼬리.
|
||||
@ -59,7 +59,6 @@ function useNavItems(): NavItem[] {
|
||||
[
|
||||
{id: 'intro', href: '#about', fallback: '소개'},
|
||||
{id: spec.path, href: '#units', fallback: spec.label},
|
||||
{id: 'pricing', href: '#pricing', fallback: '요금'},
|
||||
{id: 'info', href: '#info', fallback: '이용 정보'},
|
||||
{id: 'booking', href: '#booking', fallback: '예약 안내'},
|
||||
{id: 'photos', href: '#gallery', fallback: '사진'},
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {galleryImages} from '@/lib/derive';
|
||||
import {primaryImage} from '@/seo/meta';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {galleryImages} from '@site/lib/derive';
|
||||
import {primaryImage} from '@site/seo/meta';
|
||||
|
||||
/**
|
||||
* 첫 화면 — **큰 사진 한 장이 아니라 사진 격자다.**
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {sectionName, unitSpec, unitViews} from '@/lib/derive';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {sectionName, unitSpec, unitViews} from '@site/lib/derive';
|
||||
import {SectionHead} from './SectionHead';
|
||||
|
||||
/**
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {
|
||||
bookingLabel,
|
||||
bookingActionLabel,
|
||||
bookingLinks,
|
||||
channelLabel,
|
||||
heroFacts,
|
||||
isSectionEnabled,
|
||||
lowestPrice,
|
||||
unitSpec,
|
||||
} from '@/lib/derive';
|
||||
import {formatKoreanDate, isoDate} from '@/lib/format';
|
||||
} from '@site/lib/derive';
|
||||
import {formatKoreanDate, isoDate} from '@site/lib/format';
|
||||
// ★ 배럴(@/sections)이 아니라 파일을 직접 가리킨다 — 배럴은 섹션 열여덟을 전부 끌고 오고,
|
||||
// 그 안에 lib/ui 가 있어 이 폴더로 순환 참조가 돌아온다.
|
||||
import {MobileTabBar} from '@/sections/MobileTabBar';
|
||||
import {MobileTabBar} from '@site/sections/MobileTabBar';
|
||||
|
||||
/**
|
||||
* 스튜디오형 껍데기 — **가로 메뉴 바가 없다.**
|
||||
@ -114,7 +114,7 @@ export function Shell({children}: {children: ReactNode}) {
|
||||
className="tap mt-5 inline-flex w-full items-center justify-center px-4 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90"
|
||||
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg)'}}
|
||||
>
|
||||
{bookingLabel(booking)}로 예약
|
||||
{bookingActionLabel(booking)}
|
||||
</a>
|
||||
)}
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@ import {
|
||||
type UnitInfo,
|
||||
type WeatherMood,
|
||||
} from '@o2o/shared';
|
||||
import {BOOKING_CHANNELS, UNIT_SPEC, unitBaseRate} from '@/seo/jsonld';
|
||||
import {BOOKING_CHANNELS, UNIT_SPEC, unitBaseRate} from '@site/seo/jsonld';
|
||||
|
||||
/**
|
||||
* payload → 화면이 바로 쓰는 모양.
|
||||
@ -618,6 +618,19 @@ export function contactLinks(payload: SitePayload): ChannelLink[] {
|
||||
/* ── 숙박 예약(origin/main 의 feature/stay-booking) ─────────────────────
|
||||
* ★ 이 블록은 예약 작업이 넣은 것이다. DB 재구성 병합 때 derive.ts 를 이쪽 버전으로
|
||||
* 가져오면서 빠질 뻔했다 — 예약 화면(StayBookingSection)이 이 셋에 의존한다. */
|
||||
/**
|
||||
* "○○ 예약" 한 줄. 라벨이 이미 '예약' 으로 끝나면 그대로 둔다.
|
||||
*
|
||||
* ★ 실측(2026-09-09, 스테이,머뭄): 네이버 예약 채널이 붙자 버튼이 **"네이버 예약 예약"** 이 됐다.
|
||||
* `{bookingLabel(link)} 예약` 을 네 곳에서 각자 이어 붙이고 있었기 때문이다 —
|
||||
* 문구를 만드는 자리가 여럿이면 채널이 하나 늘 때 그중 몇 곳만 고쳐진다.
|
||||
*/
|
||||
export function bookingActionLabel(link: ChannelLink, prefix?: string): string {
|
||||
const name = bookingLabel(link);
|
||||
const phrase = name.endsWith('예약') ? name : `${name} 예약`;
|
||||
return prefix ? `${prefix} ${phrase}` : phrase;
|
||||
}
|
||||
|
||||
/**
|
||||
* 예약 버튼에 찍을 말.
|
||||
*
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
|
||||
/**
|
||||
* 레이아웃(=템플릿의 뼈대) 하나.
|
||||
|
||||
@ -14,12 +14,12 @@
|
||||
* ★ 바탕(tone)은 세 가지뿐이다. 섹션마다 새 배경색을 만들면 페이지가 색 띠의 나열이 된다.
|
||||
*/
|
||||
import type {ReactNode} from 'react';
|
||||
import {useLayout} from '@/lib/layout';
|
||||
import {SectionHead as ReservationHead} from '@/layouts/reservation/SectionHead';
|
||||
import {SectionHead as OasiHead} from '@/layouts/oasi/SectionHead';
|
||||
import {SectionHead as StudioHead} from '@/layouts/studio/SectionHead';
|
||||
import {SectionHead as PastelHead} from '@/layouts/pastel/SectionHead';
|
||||
import {SectionHead as EditorialHead} from '@/layouts/editorial/SectionHead';
|
||||
import {useLayout} from '@site/lib/layout';
|
||||
import {SectionHead as ReservationHead} from '@site/layouts/reservation/SectionHead';
|
||||
import {SectionHead as OasiHead} from '@site/layouts/oasi/SectionHead';
|
||||
import {SectionHead as StudioHead} from '@site/layouts/studio/SectionHead';
|
||||
import {SectionHead as PastelHead} from '@site/layouts/pastel/SectionHead';
|
||||
import {SectionHead as EditorialHead} from '@site/layouts/editorial/SectionHead';
|
||||
|
||||
export type SectionTone = 'base' | 'alt' | 'dark';
|
||||
|
||||
|
||||
@ -12,16 +12,14 @@ import {
|
||||
InquirySection,
|
||||
ITEM_SECTIONS,
|
||||
LocalGuideSection,
|
||||
PricingSection,
|
||||
WeatherSection,
|
||||
LocationSection,
|
||||
SpaceSection,
|
||||
UnitsSection,
|
||||
StayBookingSection,
|
||||
} from '@/sections';
|
||||
import {Fragment} from 'react';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {hasSection, isSectionEnabled} from '@/lib/derive';
|
||||
} from '@site/sections';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {hasSection, isSectionEnabled} from '@site/lib/derive';
|
||||
|
||||
/**
|
||||
* 홈.
|
||||
@ -59,7 +57,6 @@ export function HomePage() {
|
||||
intro: AboutSection,
|
||||
info: EssentialInfoSection,
|
||||
rooms: UnitsSection,
|
||||
pricing: PricingSection,
|
||||
menu: UnitsSection,
|
||||
programs: UnitsSection,
|
||||
booking: BookingSection,
|
||||
@ -94,10 +91,16 @@ export function HomePage() {
|
||||
if (rendered.has(key)) return null;
|
||||
rendered.add(key);
|
||||
return (
|
||||
<Fragment key={section.id}>
|
||||
/*
|
||||
* ★ `display: contents` 라 상자를 만들지 않는다 — 레이아웃에 손대지 않고
|
||||
* **어느 섹션 설정에서 나온 것인지**만 표시한다. 빌더 편집 모드가 이 값으로
|
||||
* 클릭한 자리를 왼쪽 목록의 섹션과 잇는다(`builder/SitePreview`).
|
||||
* 화면 id(`gallery`)와 설정 id(`photos`)가 달라서 필요하다.
|
||||
*/
|
||||
<div key={section.id} data-editor-id={section.id} style={{display: 'contents'}}>
|
||||
<Component />
|
||||
{section.id === 'intro' && <AnswerBlock />}
|
||||
</Fragment>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {galleryImages, sectionBody} from '@/lib/derive';
|
||||
import {Section} from '@/lib/ui';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {galleryImages, sectionBody} from '@site/lib/derive';
|
||||
import {Section} from '@site/lib/ui';
|
||||
|
||||
/**
|
||||
* 소개.
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {factualSummary} from '@/seo/meta';
|
||||
import {essentialRows} from '@/lib/derive';
|
||||
import {formatKoreanDate} from '@/lib/format';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {factualSummary} from '@site/seo/meta';
|
||||
import {essentialRows} from '@site/lib/derive';
|
||||
import {formatKoreanDate} from '@site/lib/format';
|
||||
|
||||
/**
|
||||
* 핵심 정보 요약 — AEO 의 핵심 블록.
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import {Phone} from 'lucide-react';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {bookingLinks, bookingRows, bookingCtaLabel, sectionName} from '@/lib/derive';
|
||||
import {ActionLink, Field, Section} from '@/lib/ui';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingLinks, bookingRows, bookingCtaLabel, sectionName} from '@site/lib/derive';
|
||||
import {ActionLink, Field, Section} from '@site/lib/ui';
|
||||
|
||||
/**
|
||||
* 예약 안내.
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import {Phone} from 'lucide-react';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {bookingLabel, bookingLinks, essentialRows, RULE_FACT_KEYS} from '@/lib/derive';
|
||||
import {formatKoreanDate} from '@/lib/format';
|
||||
import type {InfoRow} from '@/lib/derive';
|
||||
import {Section} from '@/lib/ui';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingActionLabel, bookingLinks, essentialRows, RULE_FACT_KEYS} from '@site/lib/derive';
|
||||
import {formatKoreanDate} from '@site/lib/format';
|
||||
import type {InfoRow} from '@site/lib/derive';
|
||||
import {Section} from '@site/lib/ui';
|
||||
|
||||
/**
|
||||
* 이용 정보 표 전체.
|
||||
@ -169,7 +169,7 @@ function BookingRow() {
|
||||
className="tap inline-flex items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90"
|
||||
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
|
||||
>
|
||||
{bookingLabel(link)}로 예약
|
||||
{bookingActionLabel(link)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {exhibitionRows, sectionName} from '@/lib/derive';
|
||||
import {Field, Section} from '@/lib/ui';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {exhibitionRows, sectionName} from '@site/lib/derive';
|
||||
import {Field, Section} from '@site/lib/ui';
|
||||
|
||||
/**
|
||||
* 전시 · 관람 안내.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {faqList} from '@/lib/derive';
|
||||
import {Section} from '@/lib/ui';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {faqList} from '@site/lib/derive';
|
||||
import {Section} from '@site/lib/ui';
|
||||
|
||||
/**
|
||||
* FAQ.
|
||||
|
||||
@ -16,10 +16,10 @@
|
||||
import {useState} from 'react';
|
||||
import {ArrowUpRight, MapPin} from 'lucide-react';
|
||||
import type {FestivalEntry} from '@o2o/shared';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {naverSearchUrl} from '@/lib/format';
|
||||
import {useLiveSeasons} from '@/lib/use-live-seasons';
|
||||
import {Section} from '@/lib/ui';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {naverSearchUrl} from '@site/lib/format';
|
||||
import {useLiveSeasons} from '@site/lib/use-live-seasons';
|
||||
import {Section} from '@site/lib/ui';
|
||||
|
||||
// shared 의 SEASON_ORDER 는 내보내지 않는다. 순서는 달력이 정한 것이라 갈릴 일이 없다.
|
||||
const SEASONS = ['봄', '여름', '가을', '겨울'];
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {ChevronLeft, ChevronRight, X} from 'lucide-react';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {galleryImages} from '@/lib/derive';
|
||||
import {Section} from '@/lib/ui';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {galleryImages} from '@site/lib/derive';
|
||||
import {Section} from '@site/lib/ui';
|
||||
|
||||
/**
|
||||
* 사진 갤러리.
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {ChevronLeft, ChevronRight, Phone} from 'lucide-react';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {bookingLabel, bookingLinks, heroFacts, lowestPrice} from '@/lib/derive';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingActionLabel, bookingLinks, heroFacts, lowestPrice} from '@site/lib/derive';
|
||||
|
||||
/**
|
||||
* 첫 화면 — 국내 펜션 사이트의 정석(`hero.slideshow`).
|
||||
@ -196,7 +196,7 @@ export function HeroPension() {
|
||||
className="tap inline-flex items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90"
|
||||
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
|
||||
>
|
||||
{bookingLabel(links[0])} 예약
|
||||
{bookingActionLabel(links[0])}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
import {ChevronDown, MapPin} from 'lucide-react';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {primaryImage} from '@/seo/meta';
|
||||
import {bookingLinks, unitSpec} from '@/lib/derive';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {primaryImage} from '@site/seo/meta';
|
||||
import {bookingLinks, unitSpec} from '@site/lib/derive';
|
||||
import {HeroPension} from './HeroPension';
|
||||
import {HeroSplit} from './HeroSplit';
|
||||
import {useLayout} from '@/lib/layout';
|
||||
import {Hero as ReservationHero} from '@/layouts/reservation/Hero';
|
||||
import {Hero as OasiHero} from '@/layouts/oasi/Hero';
|
||||
import {Hero as StudioHero} from '@/layouts/studio/Hero';
|
||||
import {Hero as PastelHero} from '@/layouts/pastel/Hero';
|
||||
import {Hero as EditorialHero} from '@/layouts/editorial/Hero';
|
||||
import {useLayout} from '@site/lib/layout';
|
||||
import {Hero as ReservationHero} from '@site/layouts/reservation/Hero';
|
||||
import {Hero as OasiHero} from '@site/layouts/oasi/Hero';
|
||||
import {Hero as StudioHero} from '@site/layouts/studio/Hero';
|
||||
import {Hero as PastelHero} from '@site/layouts/pastel/Hero';
|
||||
import {Hero as EditorialHero} from '@site/layouts/editorial/Hero';
|
||||
|
||||
/**
|
||||
* 첫 화면.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import {Phone} from 'lucide-react';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {bookingLabel, bookingLinks, heroFacts, lowestPrice, placeRow} from '@/lib/derive';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingActionLabel, bookingLinks, heroFacts, lowestPrice, placeRow} from '@site/lib/derive';
|
||||
|
||||
/**
|
||||
* 첫 화면 — 반으로 가른 형(`hero.split`).
|
||||
@ -102,7 +102,7 @@ export function HeroSplit() {
|
||||
className="tap flex flex-1 items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90"
|
||||
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
|
||||
>
|
||||
{bookingLabel(links[0])} 예약
|
||||
{bookingActionLabel(links[0])}
|
||||
</a>
|
||||
)}
|
||||
{place.phone && (
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import {ArrowUpRight, Mail, MessageCircle, Phone} from 'lucide-react';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {channelLabel, contactLinks, sectionName} from '@/lib/derive';
|
||||
import {Section} from '@/lib/ui';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {channelLabel, contactLinks, sectionName} from '@site/lib/derive';
|
||||
import {Section} from '@site/lib/ui';
|
||||
|
||||
/**
|
||||
* 문의 · 연락.
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import {ArrowUpRight, MapPin, Utensils} from 'lucide-react';
|
||||
import type {LocalPlace} from '@o2o/shared';
|
||||
import {useState} from 'react';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {formatKoreanDate, naverSearchUrl} from '@/lib/format';
|
||||
import {Carousel, CarouselSlide, Section} from '@/lib/ui';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {formatKoreanDate, naverSearchUrl} from '@site/lib/format';
|
||||
import {Carousel, CarouselSlide, Section} from '@site/lib/ui';
|
||||
|
||||
/**
|
||||
* 거리 탭.
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import {useState} from 'react';
|
||||
import {Car, Check, Copy, Navigation} from 'lucide-react';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {kakaoDirectionsUrl, naverDirectionsUrl, osmEmbedUrl, tmapDirectionsUrl} from '@/lib/format';
|
||||
import {Section} from '@/lib/ui';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {kakaoDirectionsUrl, naverDirectionsUrl, osmEmbedUrl, tmapDirectionsUrl} from '@site/lib/format';
|
||||
import {Section} from '@site/lib/ui';
|
||||
|
||||
/**
|
||||
* 오시는 길.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import {MapPin, MessageCircle, Phone} from 'lucide-react';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {bookingLabel, bookingLinks, channelLabel} from '@/lib/derive';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingActionLabel, bookingLinks, channelLabel} from '@site/lib/derive';
|
||||
|
||||
/**
|
||||
* 좁은 화면 하단 고정 바.
|
||||
@ -70,7 +70,7 @@ export function MobileTabBar() {
|
||||
>
|
||||
{/* ★ channelLabel() 은 수집된 제목("스테이,머뭄 네이버 플레이스")을 준다 —
|
||||
390px 바에서 버튼 밖으로 넘쳤다. 버튼이 답할 건 '어디서 예약하나' 하나다. */}
|
||||
{bookingLabel(booking)} 예약
|
||||
{bookingActionLabel(booking)}
|
||||
</a>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
import {ExternalLink, Mail, MapPin, Phone} from 'lucide-react';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {isoDate} from '@/lib/format';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {isoDate} from '@site/lib/format';
|
||||
// 채널 이름표는 예약·문의 섹션과 같은 표를 쓴다 — 같은 채널이 자리마다 다른 이름으로 뜨면 안 된다.
|
||||
import {channelLabel} from '@/lib/derive';
|
||||
import {channelLabel} from '@site/lib/derive';
|
||||
|
||||
/**
|
||||
* 푸터.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import {Menu, Phone} from 'lucide-react';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {isSectionEnabled, stayBookingView, unitSpec} from '@/lib/derive';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {isSectionEnabled, stayBookingView, unitSpec} from '@site/lib/derive';
|
||||
|
||||
/**
|
||||
* 상단 내비.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {sectionName, spaceRows} from '@/lib/derive';
|
||||
import {Card, Section} from '@/lib/ui';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {sectionName, spaceRows} from '@site/lib/derive';
|
||||
import {Card, Section} from '@site/lib/ui';
|
||||
|
||||
/**
|
||||
* 공간 안내.
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {CalendarDays, Check, Clock, Minus, Phone, Plus, RotateCcw} from 'lucide-react';
|
||||
import {factText, sanitizeUnits, selectPublishable, type SitePayload} from '@o2o/shared';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {unitBaseRate} from '@/seo/jsonld';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {unitBaseRate} from '@site/seo/jsonld';
|
||||
|
||||
/**
|
||||
* 예약 데모 — **화면 안에서만 도는 목업이다.**
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import {ArrowUpRight, BedDouble, CalendarCheck, Phone, ShieldCheck} from 'lucide-react';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {bookingCtaLabel, channelLabel, sectionName, stayBookingView} from '@/lib/derive';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingCtaLabel, channelLabel, sectionName, stayBookingView} from '@site/lib/derive';
|
||||
import {StayBookingDemo} from './StayBookingDemo';
|
||||
|
||||
/**
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import {ChevronDown, Phone} from 'lucide-react';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {bookingLabel, bookingLinks, sectionName, unitSpec, unitViews} from '@/lib/derive';
|
||||
import {Chip, Section} from '@/lib/ui';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingActionLabel, bookingLinks, sectionName, unitSpec, unitViews} from '@site/lib/derive';
|
||||
import {Chip, Section} from '@site/lib/ui';
|
||||
|
||||
/**
|
||||
* 객실 안내 — 좌우 교차 밴드(`rooms.bands`).
|
||||
@ -94,7 +94,7 @@ export function UnitsBands() {
|
||||
className="tap inline-flex flex-1 items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90"
|
||||
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
|
||||
>
|
||||
{unit.name} {bookingLabel(booking)} 예약
|
||||
{bookingActionLabel(booking, unit.name)}
|
||||
</a>
|
||||
)}
|
||||
{payload.place.phone && (
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
import {useCallback, useRef, useState} from 'react';
|
||||
import {ChevronDown, ChevronLeft, ChevronRight} from 'lucide-react';
|
||||
import type {MediaItem} from '@o2o/shared';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {sectionName, unitSpec, unitViews} from '@/lib/derive';
|
||||
import {Card, Chip, Section} from '@/lib/ui';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {sectionName, unitSpec, unitViews} from '@site/lib/derive';
|
||||
import {Card, Chip, Section} from '@site/lib/ui';
|
||||
import {UnitsBands} from './UnitsBands';
|
||||
import {UnitsTabs} from './UnitsTabs';
|
||||
import {useLayout} from '@/lib/layout';
|
||||
import {Rooms as ReservationRooms} from '@/layouts/reservation/Rooms';
|
||||
import {Rooms as OasiRooms} from '@/layouts/oasi/Rooms';
|
||||
import {Rooms as StudioRooms} from '@/layouts/studio/Rooms';
|
||||
import {Rooms as PastelRooms} from '@/layouts/pastel/Rooms';
|
||||
import {Rooms as EditorialRooms} from '@/layouts/editorial/Rooms';
|
||||
import {useLayout} from '@site/lib/layout';
|
||||
import {Rooms as ReservationRooms} from '@site/layouts/reservation/Rooms';
|
||||
import {Rooms as OasiRooms} from '@site/layouts/oasi/Rooms';
|
||||
import {Rooms as StudioRooms} from '@site/layouts/studio/Rooms';
|
||||
import {Rooms as PastelRooms} from '@site/layouts/pastel/Rooms';
|
||||
import {Rooms as EditorialRooms} from '@site/layouts/editorial/Rooms';
|
||||
|
||||
/**
|
||||
* 객실 · 메뉴 · 프로그램 — 업종에 따라 이름만 달라진다.
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import {useState} from 'react';
|
||||
import {Phone} from 'lucide-react';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {bookingLabel, bookingLinks, sectionName, unitSpec, unitViews} from '@/lib/derive';
|
||||
import {Chip, Section} from '@/lib/ui';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingActionLabel, bookingLinks, sectionName, unitSpec, unitViews} from '@site/lib/derive';
|
||||
import {Chip, Section} from '@site/lib/ui';
|
||||
|
||||
/**
|
||||
* 객실 안내 — 탭으로 갈아 끼우는 형(`rooms.tabs`).
|
||||
@ -129,7 +129,7 @@ export function UnitsTabs() {
|
||||
className="tap flex flex-1 items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90"
|
||||
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
|
||||
>
|
||||
{unit.name} {bookingLabel(booking)} 예약
|
||||
{bookingActionLabel(booking, unit.name)}
|
||||
</a>
|
||||
)}
|
||||
{payload.place.phone && (
|
||||
|
||||
@ -9,10 +9,10 @@
|
||||
* 굽는 순간의 문장을 박으면 비 오는 날 "마당에 앉기 좋습니다"가 뜬다.
|
||||
* ★ 관측 시각을 반드시 낸다. 시각 없는 숫자는 못 믿는다 — 캐시가 어긋나면 어제 값이다.
|
||||
*/
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {useLiveWeather} from '@/lib/use-live-weather';
|
||||
import {weatherMood} from '@/lib/derive';
|
||||
import {Card, Section} from '@/lib/ui';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {useLiveWeather} from '@site/lib/use-live-weather';
|
||||
import {weatherMood} from '@site/lib/derive';
|
||||
import {Card, Section} from '@site/lib/ui';
|
||||
import './weather.css';
|
||||
|
||||
/** "2026-09-03T12:30" → "9월 3일 12:30". 날짜가 오늘이면 시각만 남긴다. */
|
||||
|
||||
@ -4,7 +4,6 @@ export {AnswerBlock} from './AnswerBlock';
|
||||
export {AboutSection} from './AboutSection';
|
||||
export {EssentialInfoSection} from './EssentialInfoSection';
|
||||
export {UnitsSection} from './UnitsSection';
|
||||
export {PricingSection} from './PricingSection';
|
||||
export {BookingSection} from './BookingSection';
|
||||
// 숙박은 예약 구성이 통째로 다르다 — 이유는 StayBookingSection 머리주석.
|
||||
export {StayBookingSection} from './StayBookingSection';
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
* ★ 붉은 점은 도시의 성격이 바뀐 해다. 점의 색이 장식이 아니라 정보라 범례를 함께 낸다.
|
||||
*/
|
||||
import type {ChronicleItem} from '@o2o/shared';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {sectionItems, sectionName} from '@/lib/derive';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {sectionItems, sectionName} from '@site/lib/derive';
|
||||
import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ItemSection, Rail, SourceLine} from './common';
|
||||
|
||||
export function ChronicleSection() {
|
||||
|
||||
@ -15,8 +15,8 @@
|
||||
*/
|
||||
import {useEffect, useState} from 'react';
|
||||
import type {DailyItem} from '@o2o/shared';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {sectionItems, sectionName} from '@/lib/derive';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {sectionItems, sectionName} from '@site/lib/derive';
|
||||
import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ItemSection, SourceLine, tally} from './common';
|
||||
|
||||
export function DailySection() {
|
||||
|
||||
@ -20,8 +20,8 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {X} from 'lucide-react';
|
||||
import type {EventItem} from '@o2o/shared';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {sectionItems, sectionName} from '@/lib/derive';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {sectionItems, sectionName} from '@site/lib/derive';
|
||||
import {
|
||||
ITEM_ACCENT,
|
||||
ITEM_BORDER,
|
||||
|
||||
@ -21,8 +21,8 @@
|
||||
*/
|
||||
import {useState} from 'react';
|
||||
import {planDay, type ItineraryItem, type PlannerStop} from '@o2o/shared';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {sectionItems, sectionName} from '@/lib/derive';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {sectionItems, sectionName} from '@site/lib/derive';
|
||||
import {ITEM_BORDER, ITEM_CARD, ITEM_INK, ITEM_INVERSE_INK, ItemSection, Rail, SourceLine, StopNo} from './common';
|
||||
import {RouteMap} from './RouteMap';
|
||||
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
* ★ 프레임 안은 사진이 있으면 사진, 없으면 활판 이니셜이다 — 빈 회색 상자를 두지 않는다.
|
||||
*/
|
||||
import type {PeopleItem} from '@o2o/shared';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {sectionItems, sectionName} from '@/lib/derive';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {sectionItems, sectionName} from '@site/lib/derive';
|
||||
import {ITEM_INVERSE, ItemSection, Rail, SourceLine, tally} from './common';
|
||||
|
||||
export function PeopleSection() {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -4,8 +4,8 @@
|
||||
* ([복사] 버튼은 두지 않는다 — 스크립트가 없어도 되는 화면을 스크립트로 만들 이유가 없다).
|
||||
*/
|
||||
import type {PostcardItem} from '@o2o/shared';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {sectionItems, sectionName} from '@/lib/derive';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {sectionItems, sectionName} from '@site/lib/derive';
|
||||
import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ITEM_INK, ItemSection, Rail, SourceLine, tally} from './common';
|
||||
|
||||
export function PostcardSection() {
|
||||
|
||||
@ -10,8 +10,8 @@
|
||||
*/
|
||||
import {useState} from 'react';
|
||||
import type {QuizItem} from '@o2o/shared';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {sectionItems, sectionName} from '@/lib/derive';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {sectionItems, sectionName} from '@site/lib/derive';
|
||||
import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ITEM_INK, ItemSection, Rail, SourceLine} from './common';
|
||||
|
||||
export function QuizSection() {
|
||||
|
||||
@ -12,8 +12,8 @@
|
||||
* ★ 타일은 회색으로 눕힌다. 읽어야 하는 건 지도가 아니라 **번호와 순서**다.
|
||||
*/
|
||||
import type {PlannerStop} from '@o2o/shared';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {naverDirectionsUrl, naverRouteUrl} from '@/lib/format';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {naverDirectionsUrl, naverRouteUrl} from '@site/lib/format';
|
||||
import {ITEM_ACCENT, ITEM_BORDER, ITEM_INVERSE_INK} from './common';
|
||||
|
||||
const TILE = 256;
|
||||
|
||||
@ -12,9 +12,9 @@
|
||||
import {useState} from 'react';
|
||||
import type {SongItem} from '@o2o/shared';
|
||||
import {Youtube} from 'lucide-react';
|
||||
import {youtubeSearchUrl} from '@/lib/format';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {sectionItems, sectionName} from '@/lib/derive';
|
||||
import {youtubeSearchUrl} from '@site/lib/format';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {sectionItems, sectionName} from '@site/lib/derive';
|
||||
import {
|
||||
ITEM_ACCENT,
|
||||
ITEM_INVERSE,
|
||||
|
||||
@ -12,8 +12,8 @@
|
||||
* 그 제목이 다섯 덩이를 가르는 유일한 표지다.
|
||||
*/
|
||||
import {useState} from 'react';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {sectionItems, sectionName} from '@/lib/derive';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {sectionItems, sectionName} from '@site/lib/derive';
|
||||
import {SongsSection} from './SongsSection';
|
||||
import {DailySection} from './DailySection';
|
||||
import {PeopleSection} from './PeopleSection';
|
||||
|
||||
@ -33,8 +33,8 @@ import {
|
||||
youtubePoster,
|
||||
type VideoItem,
|
||||
} from '@o2o/shared';
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {sectionItems, sectionName} from '@/lib/derive';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {sectionItems, sectionName} from '@site/lib/derive';
|
||||
import {ITEM_BORDER, ITEM_CARD, ItemSection, SourceLine} from './common';
|
||||
|
||||
export function VideoSection() {
|
||||
|
||||
@ -10,15 +10,15 @@
|
||||
*/
|
||||
import type {ReactNode} from 'react';
|
||||
import type {DataSource, DataVerified} from '@o2o/shared';
|
||||
import {Carousel} from '@/lib/ui';
|
||||
import {useLayout} from '@/lib/layout';
|
||||
import {Carousel} from '@site/lib/ui';
|
||||
import {useLayout} from '@site/lib/layout';
|
||||
// 안별 제목은 `lib/ui/Section` 과 **같은 파일**을 직접 가리킨다.
|
||||
// `@/sections` 배럴로 돌아가면 이 파일이 그 배럴 안에 있어 순환이다.
|
||||
import {SectionHead as ReservationHead} from '@/layouts/reservation/SectionHead';
|
||||
import {SectionHead as OasiHead} from '@/layouts/oasi/SectionHead';
|
||||
import {SectionHead as StudioHead} from '@/layouts/studio/SectionHead';
|
||||
import {SectionHead as PastelHead} from '@/layouts/pastel/SectionHead';
|
||||
import {SectionHead as EditorialHead} from '@/layouts/editorial/SectionHead';
|
||||
import {SectionHead as ReservationHead} from '@site/layouts/reservation/SectionHead';
|
||||
import {SectionHead as OasiHead} from '@site/layouts/oasi/SectionHead';
|
||||
import {SectionHead as StudioHead} from '@site/layouts/studio/SectionHead';
|
||||
import {SectionHead as PastelHead} from '@site/layouts/pastel/SectionHead';
|
||||
import {SectionHead as EditorialHead} from '@site/layouts/editorial/SectionHead';
|
||||
import './items.css';
|
||||
|
||||
/** 카드·종이 면. 섹션 바탕보다 한 단계 앞이다. */
|
||||
|
||||
@ -11,7 +11,6 @@ import {ChronicleSection} from './ChronicleSection';
|
||||
import {PostcardSection} from './PostcardSection';
|
||||
import {QuizSection} from './QuizSection';
|
||||
import {ItinerarySection} from './ItinerarySection';
|
||||
import {PlannerSection} from './PlannerSection';
|
||||
import {VideoSection} from './VideoSection';
|
||||
import {StorySection} from './StorySection';
|
||||
import {EventSection} from './EventSection';
|
||||
@ -24,8 +23,6 @@ export const ITEM_SECTIONS: Record<string, () => React.ReactElement | null> = {
|
||||
postcard: PostcardSection,
|
||||
quiz: QuizSection,
|
||||
itinerary: ItinerarySection,
|
||||
// ★ 계절별 추천 하루. 여행일정 개편 때 표에서 빠져 화면에서 사라졌었다 — 되살린다.
|
||||
planner: PlannerSection,
|
||||
video: VideoSection,
|
||||
// 인스타에 올라오는 그때그때의 행사. 사장님이 옮겨 적고 원문 주소를 단다.
|
||||
event: EventSection,
|
||||
|
||||
@ -18,14 +18,14 @@ import {
|
||||
sanitizePayloadForPublish,
|
||||
type SitePayload,
|
||||
} from '@o2o/shared';
|
||||
import {MOONLIGHT_STAY_PAYLOAD} from '@/fixtures/moonlight-stay';
|
||||
import {stayBookingView} from '@/lib/derive';
|
||||
import {collectJsonLd} from '@/seo/jsonld';
|
||||
import {renderLlmsTxt} from '@/seo/llms';
|
||||
import {verifyJsonLd, visibleText} from '@/seo/verify';
|
||||
import {render} from '@/entry-server';
|
||||
import {homeMeta} from '@/seo/meta';
|
||||
import {renderHead} from '@/seo/head';
|
||||
import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay';
|
||||
import {stayBookingView} from '@site/lib/derive';
|
||||
import {collectJsonLd} from '@site/seo/jsonld';
|
||||
import {renderLlmsTxt} from '@site/seo/llms';
|
||||
import {verifyJsonLd, visibleText} from '@site/seo/verify';
|
||||
import {render} from '@site/entry-server';
|
||||
import {homeMeta} from '@site/seo/meta';
|
||||
import {renderHead} from '@site/seo/head';
|
||||
|
||||
const PAGE = {title: '달빛스테이 제주', description: '제주 애월 독채 펜션'};
|
||||
|
||||
|
||||
@ -59,21 +59,28 @@ export interface HeadOptions {
|
||||
* ★ 면 토큰(surface/-alt/inverse/border)은 캔버스와 **같은 식**으로 유도한다(shared/lib/color).
|
||||
* 식이 두 벌이면 미리보기와 발행본의 바탕색이 갈린다.
|
||||
*/
|
||||
export function themeStyle(payload: SitePayload): string {
|
||||
/**
|
||||
* 템플릿 토큰 — **값**으로 돌려준다.
|
||||
*
|
||||
* ★ 발행본은 이걸 `<head>` 의 `:root` 에 굽고(themeStyle), 빌더 미리보기는 같은 값을
|
||||
* 미리보기 요소의 inline style 로 얹는다(`builder/SitePreview`). 두 곳이 각자 만들면
|
||||
* 에디터에서 본 색·서체와 발행된 색·서체가 조용히 갈라진다 — 실제로 그 자리였다.
|
||||
*/
|
||||
export function themeVars(payload: SitePayload): Record<string, string> {
|
||||
const {colors, look} = payload.theme;
|
||||
const surfaces = deriveSurfaces(colors);
|
||||
const lines = [
|
||||
`--tpl-primary: ${colors.primary};`,
|
||||
`--tpl-secondary: ${colors.secondary};`,
|
||||
`--tpl-bg: ${colors.bg};`,
|
||||
`--tpl-card: ${colors.card};`,
|
||||
`--tpl-text: ${colors.text};`,
|
||||
`--tpl-accent: ${colors.accent};`,
|
||||
`--tpl-surface: ${surfaces.surface};`,
|
||||
`--tpl-surface-alt: ${surfaces.surfaceAlt};`,
|
||||
`--tpl-inverse: ${surfaces.inverse};`,
|
||||
`--tpl-border: ${surfaces.border};`,
|
||||
];
|
||||
const vars: Record<string, string> = {
|
||||
'--tpl-primary': colors.primary,
|
||||
'--tpl-secondary': colors.secondary,
|
||||
'--tpl-bg': colors.bg,
|
||||
'--tpl-card': colors.card,
|
||||
'--tpl-text': colors.text,
|
||||
'--tpl-accent': colors.accent,
|
||||
'--tpl-surface': surfaces.surface,
|
||||
'--tpl-surface-alt': surfaces.surfaceAlt,
|
||||
'--tpl-inverse': surfaces.inverse,
|
||||
'--tpl-border': surfaces.border,
|
||||
};
|
||||
if (look) {
|
||||
// ★ 값은 그대로 CSS 에 들어간다. `<`·`}` 이 섞이면 <style> 을 깨뜨릴 수 있어 걸러 낸다 —
|
||||
// 서버가 해석하지 않고 실어 보내는 값이라 여기서 한 번은 봐야 한다.
|
||||
@ -91,8 +98,13 @@ export function themeStyle(payload: SitePayload): string {
|
||||
// 토큰이 비어 있으면 아무것도 깔지 않는다(질감을 원하지 않는 템플릿이 있다).
|
||||
['--tpl-texture', safe(look.texture)],
|
||||
];
|
||||
for (const [name, value] of pairs) if (value) lines.push(`${name}: ${value};`);
|
||||
for (const [name, value] of pairs) if (value) vars[name] = value;
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
|
||||
export function themeStyle(payload: SitePayload): string {
|
||||
const lines = Object.entries(themeVars(payload)).map(([name, value]) => `${name}: ${value};`);
|
||||
return [' <style>', ' :root {', ...lines.map((line) => ` ${line}`), ' }', ' </style>'].join('\n');
|
||||
}
|
||||
|
||||
@ -110,7 +122,8 @@ const WEB_FONTS: [RegExp, string][] = [
|
||||
[/Nanum Pen Script/i, 'family=Nanum+Pen+Script'],
|
||||
];
|
||||
|
||||
function fontHref(payload: SitePayload): string {
|
||||
/** 템플릿이 요구하는 웹폰트 주소. 발행본 <head> 와 빌더 미리보기가 같은 것을 쓴다. */
|
||||
export function fontHref(payload: SitePayload): string {
|
||||
const stacks = [
|
||||
'Noto Sans KR',
|
||||
'Noto Serif KR',
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
"compilerOptions": {
|
||||
"types": ["vite/client", "node"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@site/*": ["./src/*"],
|
||||
"@o2o/shared": ["../shared/src/index.ts"],
|
||||
"@o2o/shared/*": ["../shared/src/*"]
|
||||
}
|
||||
|
||||
@ -18,7 +18,11 @@ export default defineConfig(({isSsrBuild}) => ({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
// ★ 별칭이 '@' 가 아니라 '@site' 인 이유(2026-09-09)
|
||||
// 빌더 미리보기가 **이 렌더러를 그대로** 쓴다(solution/frontend). 두 앱이 '@' 를
|
||||
// 각자 자기 src 로 두면, 여기 컴포넌트를 저쪽에서 불러올 때 내부 import 가
|
||||
// 저쪽 src 로 떨어져 조용히 다른 파일을 잡는다. 이름을 갈라 두면 어디서 불러도 같다.
|
||||
'@site': path.resolve(__dirname, 'src'),
|
||||
'@o2o/shared': path.resolve(__dirname, '../shared/src'),
|
||||
},
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user