diff --git a/AGENTS.md b/AGENTS.md
index 8643398..2109b7a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -19,6 +19,7 @@
| 장애가 나면 누가·어떻게 아나 | [docs/ALERTS.md](docs/ALERTS.md) |
| **미니 블로그**(AI 자동 포스트) 기획 | [docs/MINI_BLOG.md](docs/MINI_BLOG.md) |
| **사장님 에이전트**(카톡으로 관리) · 신원 연결 | [docs/AGENT.md](docs/AGENT.md) |
+| **템플릿** 추가 · 렌더링 순서 · frontend/shared/site 역할 | [docs/TEMPLATES.md](docs/TEMPLATES.md) |
---
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 73b51e1..e7ed3e2 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -126,7 +126,7 @@ o2o-web4ai/
│ ├─ backend/ FastAPI + 워커 · 이 레포의 유일한 백엔드
│ ├─ frontend/ 빌더 (위저드 + 에디터 + 발행 게이트)
│ ├─ site/ 발행 정적 사이트 (SSR 엔트리 + 프리렌더)
-│ └─ shared/ frontend·site·백엔드 계약 (SitePayload · slug · 토큰)
+│ └─ shared/ frontend·site·백엔드 계약 (템플릿 목록 · SitePayload · slug · 토큰)
│
├─ admin/ 우리 — 전체 사이트 운영
│ ├─ backend/ 어드민 API 진입점(:9801). 도메인 코드는 solution/backend
@@ -142,6 +142,8 @@ negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를
### `frontend`(빌더)와 `site`(발행물)는 요구가 정반대다
+세 폴더가 각각 무엇을 하는지, 템플릿이 그려지는 순서는 [TEMPLATES.md](TEMPLATES.md)에 있다.
+
같은 `solution/` 안에 있지만 빌드를 나눈 이유가 이 표다. 보일러플레이트는 순수 CSR 이라
발행 사이트에 그대로 쓰면 크롤러가 `
` 만 읽고 떠난다.
diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md
index 9b83f95..77cdb1c 100644
--- a/docs/DATA_MODEL.md
+++ b/docs/DATA_MODEL.md
@@ -70,7 +70,7 @@ jobs 작업 큐 — 수집 · 비전 · 소개문 ·
[에디터]
템플릿 고르기 ─────────────→ sites.template_id
- 색·서체·섹션 순서/on-off ──→ sites.theme (JSONB)
+ 색·섹션 순서/on-off ───────→ sites.theme (JSONB)
섹션 내용 편집 ────────────→ site_sections.data (JSONB, 섹션당 1행)
주변정보 숨김·거리 ────────→ place_area_refs.hidden / distance_m
미리보기 ──────────────────→ GET /v1/place/{id}/site/preview
@@ -225,8 +225,8 @@ Gemini 가 쓰고, 곡은 Suno 가 붙인다.
| 칸 | 무엇 | 왜 서버에 두나 |
|---|---|---|
-| `template_id` | 사장님이 고른 템플릿 키 | 서버는 **해석하지 않고 보관·반환만** 한다. 템플릿 목록은 프론트가 소유하므로, 서버가 검증하면 템플릿을 늘릴 때마다 백엔드를 고쳐야 한다 |
-| `theme` (JSONB) | 색·서체·**섹션 순서/on-off/배리에이션** | 브라우저에만 두면 발행 잡이 읽을 곳이 없어 업종 기본으로 굽고, 고른 디자인과 발행본이 갈린다. 컬럼으로 펼치지 않는 이유는 항목이 늘 때마다 마이그레이션이 따라오기 때문 |
+| `template_id` | 사장님이 고른 템플릿 id(`simple` `magazine` `retro` `paper`). NULL 이면 업종 기본 템플릿 | 템플릿 목록은 `solution/shared/src/data/templates.json` 한 파일이고, 서버도 그 파일을 읽어 업종이 못 쓰는 값은 저장·발행 때 거절한다([TEMPLATES.md](TEMPLATES.md)) |
+| `theme` (JSONB) | 색·**섹션 순서/on-off** (서체·모서리 같은 모양은 템플릿이 정하므로 저장값을 쓰지 않는다) | 브라우저에만 두면 발행 잡이 읽을 곳이 없어 업종 기본으로 굽고, 고른 디자인과 발행본이 갈린다. 컬럼으로 펼치지 않는 이유는 항목이 늘 때마다 마이그레이션이 따라오기 때문 |
| `status` | 1 DRAFT / 2 REVIEW / 3 PUBLISHED / 4 SUSPENDED / 5 UNPUBLISHED | ★ 해지는 **물리 삭제가 아니라 상태 전이**다 — 색인된 페이지를 갑자기 404 로 만들지 않는다 |
| `current_version_id` | 지금 나가 있는 버전 | |
| `thumbnail_url` | 쇼케이스 카드 그림 | ★ **발행에 성공한 뒤에만** 채운다. 스크린샷이 아니라 그 사이트의 대표 사진(og:image)이다 — 헤드리스 브라우저는 영구 금지 |
diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
index 6d22c82..292fbba 100644
--- a/docs/DECISIONS.md
+++ b/docs/DECISIONS.md
@@ -226,7 +226,7 @@
골라도 그 자리가 비었다. 그래서 서버가 채운다.
**2026-09-10 — 일력(`daily`)을 여섯 번째로 넣는다.** 이 종류만 프롬프트가 빌더
-(`canvas/dataSpec.ts`)에 손으로 적혀 있어 `shared/section-prompts.ts` 에 없었고, 서버는
+(당시 `canvas/dataSpec.ts`, 지금은 `builder/sections/dataSpec.ts`)에 손으로 적혀 있어 `shared/section-prompts.ts` 에 없었고, 서버는
그 종류의 존재 자체를 몰랐다. 렌더러에는 '오늘의 한 장' 탭 자리가 있고 '옛 항구' 템플릿
설명도 일력을 약속하는데 채우는 쪽만 없어서, 그 탭은 손으로 넣은 시안에만 있었다.
→ 종류 목록의 단일 출처는 `STORY_KINDS` 하나다. 뽑는 스크립트도 그 상수를 읽는다
@@ -394,7 +394,7 @@ JWT 를 쓰지 않는 이유는 JWT 가 서명만 검증하고 **소비를 못
key 만 보면 사장님 입력(근거 없음)을 놓치고, 키워드만 보면 "주차 및 와이파이" 처럼 묶인 문항의 한쪽을 놓친다.
- 답은 **문의 안내뿐**이다("…은 전화(…)로 문의해 주시면 안내해 드립니다"). 값·가능 여부를 적지 않는다.
업종 시드 FAQ 가 "숯과 그릴 세트(25,000원)" 같은 가공의 값을 사이트에 내보낸 일을 반복하지 않기 위해서다
- (`frontend … canvas/variants/faq/useFaqList.ts` 주석).
+ (`frontend … canvas/variants/faq/useFaqList.ts` 주석, 이 파일은 2026-09-28 배치 고르기와 함께 지웠다).
- 출처는 `SourceType.TEMPLATE`(5). 재생성 때 LLM 생성분과 함께 내리고 다시 고른다. fact 에는 쓸 수 없다.
- ★ **fact 가 0건이어도 20개다.** 근거가 없으면 LLM 은 부르지 않고(환각·유료 호출 방지) 채우기만 돈다.
그 경로는 API 키도 필요 없다. 예전에는 `start_copy` 가 `FAQ_UNGROUNDED` 로 잡을 만들지 않아 FAQ 가 0개였다 —
diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md
index 0aa391d..d022d6e 100644
--- a/docs/DEVLOG.md
+++ b/docs/DEVLOG.md
@@ -1,5 +1,45 @@
# 개발 일지
+## 2026-09-28 — 템플릿 정의를 한 파일로 모았다
+
+템플릿 정보가 빌더, 렌더러, 백엔드에 따로따로 적혀 있어서 서로 어긋나 있었다. 백엔드 기본값이
+존재하지 않는 템플릿 id를 가리켰고, 음식점 강조색에 오타가 있었고, 섹션 간격이 빌더와 서버에서
+달랐다. 병원 "클린" 템플릿은 이름과 실제 모양이 맞지 않았다.
+
+- 템플릿 목록은 `solution/shared/src/data/templates.json` 하나다. TS와 파이썬이 같은 파일을 읽는다.
+- 템플릿 id에서 업종을 뗐다. `stay-retro` → `retro`. 기존 DB 값은 마이그레이션 `0023`으로 바꾼다(운영 미적용).
+- 모르는 템플릿 id는 저장·미리보기·발행에서 모두 거절한다. 기본값으로 슬쩍 굽지 않는다.
+- 레이아웃은 `basic`과 `paper` 둘만 남겼다. 연결 안 된 레이아웃 5개, 배치 고르기, 서체 선택, 빌더 캔버스를 지웠다.
+- 템플릿을 바꾸면 이전 템플릿이 켠 섹션은 꺼지고, 빌더에 그 안내가 뜬다.
+- 바뀐 동작: 저장된 모양(look)과 배치 선택은 무시한다. 레트로 사진은 캐러셀에서 그리드로 바뀐다.
+ 병원은 날씨·주변 정보가 기본으로 꺼진다. 모두 재발행할 때부터 적용된다.
+
+구조와 새 템플릿 추가 방법은 [TEMPLATES.md](TEMPLATES.md).
+
+**검증** — shared·site·frontend·admin `tsc`, site `eslint`·`vitest` 105개, frontend `vite build` 통과.
+백엔드는 DB 없이 도는 테스트 41개 통과, DB가 필요한 테스트는 로컬 DB 접속 문제로 못 돌렸다.
+
+## 2026-09-23 — 개발자 전용 사이트관리·유저관리를 solution 앱에 경량으로
+
+admin/frontend(:9801)를 새 메뉴로 키우려면 새 도메인이 필요하고 아직 그럴 기능도 안
+갖춰졌다(대표 지시) — 그래서 대신 solution 앱(:9800)에 얹었다. `UserRole.DEVELOPER` 게이트
+하나로, 회사 스코프를 걷어낸(2026-09-08, DECISIONS.md) 전 계정 사이트·유저 목록(읽기 전용)을 본다.
+
+- **백엔드**: `router/v1/ops/ops.py`(`GET /v1/ops/sites`, `GET /v1/ops/users`, 전부
+ `RequireDeveloper`) + `services/ops_service.py` + `crud/site_crud.py:list_all_sites` /
+ `crud/user_crud.py:list_users`. 유저 목록은 USER/OWNER 만 — 개발자 계정은 여기서도 뺀다
+ (`UserRole` 주석 원칙을 내부 화면에도 지킨다).
+- **프론트**: `pages/OpsSitesPage.tsx` · `OpsUsersPage.tsx`(`/ops/sites` · `/ops/users`).
+ `AppShell.tsx` 의 기본 nav(`OWNER_NAV`)에 `role===DEVELOPER` 일 때만 두 줄을 더 붙인다.
+ ★ 이 문자열은 role 과 무관하게 사장님에게 나가는 번들에도 실린다(런타임 조건부 렌더일 뿐,
+ 빌드 타임에 갈라지지 않는다) — AppShell 주석의 "메뉴가 섞이면 새어 나간다"가 그대로 적용된다.
+ 실제 데이터 접근은 백엔드 게이트가 막으므로 새는 것은 경로 이름 정도다.
+- 액션(재발행·상태 토글·강제 로그아웃 등)은 다음 단계 — 이번엔 조회만.
+
+**검증** — DB 접속이 안 되는 환경이라 pytest 는 못 돌렸다: `app.openapi()` 로 라우터 임포트·
+스키마 생성 확인, `scripts/export_openapi.py` → `orval` 코드젠 성공, 프론트 `tsc --noEmit` ·
+`eslint src` 통과. 실제 DB 조회 동작은 미검증 — docker compose 로 띄운 뒤 확인 필요.
+
## 2026-09-23 — 개발자 전용 사이트관리·유저관리를 solution 앱에 경량으로
admin/frontend(:9801)를 새 메뉴로 키우려면 새 도메인이 필요하고 아직 그럴 기능도 안
diff --git a/docs/SOCIAL.md b/docs/SOCIAL.md
index 2057475..ce4fd61 100644
--- a/docs/SOCIAL.md
+++ b/docs/SOCIAL.md
@@ -27,7 +27,7 @@ DECISIONS 7-1-2 개정 — 문구를 그대로 재사용하는 경우에 한정)
- 알림톡이 없거나 번호가 없으면 화면만 사용한다. 야간 21:00~08:00 KST에는 화면만 사용한다.
- 발송 실패는 HTTP 502와 카드 오류로 남는다. 초안/승인은 보존하고 재요청은 nonce를 교체한다.
알림톡은 별도 명시적 요청에서 보내므로 초안 잡의 성공이 알림톡 성공을 뜻하지 않는다.
-- 사이트 섹션 `social`은 기본 OFF. 켜면 모든 Shell의 main 마지막, footer 앞에 최신 3건을 굽는다.
+- 사이트 섹션 `social`은 기본 OFF. 켜면 모든 레이아웃의 본문 마지막, footer 앞에 최신 3건을 굽는다.
Threads 글 삭제와 별개인 같은 원고의 사본이며 링크 문구는 **SNS에 올린 글 보기**다.
## 연동 준비 — 한 번만 하는 일
diff --git a/docs/TEMPLATES.md b/docs/TEMPLATES.md
new file mode 100644
index 0000000..2b80df1
--- /dev/null
+++ b/docs/TEMPLATES.md
@@ -0,0 +1,177 @@
+# 템플릿과 렌더링
+
+사장님이 고르는 "템플릿"이 어디에 정의돼 있고, 화면에 어떤 순서로 그려지는지 적은 문서다.
+2026-09-28에 구조를 한 번 갈아엎었고, 이 문서는 그 뒤의 모습이다.
+
+## 1. 세 폴더가 하는 일
+
+`solution/` 밑의 세 폴더는 하는 일이 다르다. 한 줄로 말하면 이렇다.
+
+| 폴더 | 누가 보나 | 하는 일 |
+|---|---|---|
+| `shared/` | 아무도 직접 안 본다 | 나머지 둘과 백엔드가 **같이 쓰는 약속**을 둔다. 템플릿 목록, payload 모양, 슬러그 규칙 |
+| `frontend/` | 사장님 | **빌더.** 템플릿·색·섹션을 고르고 내용을 고친다. 고른 값은 서버에 저장만 한다 |
+| `site/` | 손님, 검색엔진, AI | **발행된 사이트를 그리는 쪽.** 서버가 만든 payload를 받아 HTML로 굽는다 |
+
+조금 더 풀면:
+
+- **shared** 는 코드라기보다 계약서다. 템플릿이 몇 개인지, 이름이 뭔지, 어느 업종이 뭘 쓸 수
+ 있는지는 전부 `shared/src/data/templates.json` 한 파일에 있다. TS 쪽은
+ `shared/src/lib/catalog.ts`가, 파이썬 쪽은 `backend/common/template_catalog.py`가 이 파일을
+ 그대로 읽는다. 그래서 템플릿 정보가 두 군데로 갈라질 수 없다.
+- **frontend** 는 사이트를 직접 그리지 않는다. 미리보기도 site가 그린 화면을 iframe으로
+ 띄울 뿐이다. 빌더 안에 따로 그리는 코드를 두면 미리보기와 발행본이 조금씩 달라지는데,
+ 예전에 실제로 그랬다.
+- **site** 는 DB도 API도 모른다. payload JSON 하나만 받으면 사이트 한 장을 그린다. 발행 때는
+ 워커가 부르는 Node 스크립트로 HTML을 굽고, 미리보기 때는 브라우저에서 같은 코드로 그린다.
+
+## 2. 템플릿은 무엇으로 이뤄지나
+
+`templates.json`에 템플릿 하나는 이렇게 생겼다.
+
+| 칸 | 뜻 |
+|---|---|
+| `name` · `tag` · `description` | 빌더에서 사장님이 보는 이름과 설명 |
+| `layout` | 어떤 뼈대로 그릴지. 지금은 `basic`과 `paper` 두 개 |
+| `colors` | 기본 색. 사장님이 팔레트를 고르면 그 색이 위에 덮인다 |
+| `look` | 서체, 모서리, 그림자, 섹션 간격. 사장님이 못 바꾼다 |
+| `addSections` | 이 템플릿을 고르면 새로 켜지는 섹션. 레트로의 일력·영상 같은 것 |
+
+지금 템플릿은 네 개다.
+
+| id | 이름 | 뼈대 |
+|---|---|---|
+| `simple` | 심플 | basic |
+| `magazine` | 매거진 | basic |
+| `retro` | 레트로 | basic |
+| `paper` | 고택 | paper |
+
+심플·매거진·레트로는 뼈대가 같고 색과 서체만 다르다. 고택만 머리글, 첫 화면, 객실 목록,
+섹션 제목 모양이 따로 있다.
+
+업종마다 쓸 수 있는 템플릿과 기본 템플릿은 같은 파일의 `industries`에 적는다. 숙박은 레트로가
+기본이고, 병원은 심플과 매거진만 쓸 수 있다.
+
+### 뼈대(레이아웃)는 필요한 것만 바꾼다
+
+레이아웃은 `site/src/layouts/`에 폴더 하나씩이다. 한 레이아웃이 가질 수 있는 건 셋이다.
+
+- `Frame`: 머리글, 본문 자리, 바닥글. 이건 꼭 있어야 한다.
+- `SectionHead`: 섹션 제목 모양. 없으면 공용 제목을 쓴다.
+- `sections`: 섹션별로 바꿔 그릴 컴포넌트. 여기 안 적은 섹션은 공용 컴포넌트를 그대로 쓴다.
+
+고택은 첫 화면·객실·제목만 바꿨고, FAQ나 오시는 길은 심플과 같은 컴포넌트를 쓴다. 새
+레이아웃을 만들 때도 전부 새로 그릴 필요 없이 다르게 보여야 하는 섹션만 만들면 된다.
+
+### 이상한 값이 들어오면 멈춘다
+
+DB에 모르는 템플릿 id가 들어 있으면, 조용히 기본값으로 굽지 않고 멈춘다.
+
+- 저장할 때: 그 업종이 못 쓰는 템플릿이면 저장을 거절한다.
+- 미리보기: 서버가 422를 주고, 화면에 에러가 뜬다.
+- 발행: 잡이 실패로 끝난다.
+
+예전에는 모르는 값이 오면 기본 템플릿으로 슬쩍 구웠다. 그러면 사장님이 고른 디자인과 다른
+사이트가 나가도 아무도 모른다.
+
+## 3. 새 템플릿을 추가할 때
+
+### 기존 뼈대를 쓰는 경우 (색·서체만 다른 템플릿)
+
+`templates.json` 한 파일만 고치면 된다.
+
+1. `templates`에 새 항목을 넣는다. id는 영어 소문자로 짓고, 업종 이름은 붙이지 않는다.
+2. 쓸 수 있게 할 업종의 `industries.<업종>.templates` 목록에 그 id를 넣는다.
+3. 기본 템플릿으로 삼을 거면 `defaultTemplate`도 바꾼다.
+
+타입(`TemplateId`)은 JSON 키에서 자동으로 뽑히므로 손댈 곳이 없다. 빌더 목록, 미리보기, 백엔드
+검증에 자동으로 들어간다. 레이아웃 이름을 틀리게 적거나 업종 목록에 없는 id를 적으면, 앱이
+뜰 때 바로 에러가 난다.
+
+### 새 뼈대가 필요한 경우
+
+위 세 단계에 더해서 다음을 한다.
+
+1. `site/src/layouts/<새이름>/Frame.tsx`를 만든다. 바꿔 그릴 섹션이 있으면 같은 폴더에 둔다.
+2. `site/src/layouts/index.ts`의 `LAYOUTS`에 한 줄 넣는다.
+3. `shared/src/types/builder.ts`의 `LayoutId`에 이름을 넣는다.
+4. `shared/src/lib/catalog.ts`의 `LAYOUT_IDS`에도 넣는다.
+
+2~4를 하나라도 빠뜨리면 타입체크나 앱 시작 단계에서 걸린다.
+
+### 새 섹션이 딸려 오는 경우
+
+`addSections`에 적는 섹션은 이미 있는 섹션이어야 한다. 섹션 자체를 새로 만드는 건 템플릿과
+별개의 일이다. site의 섹션 컴포넌트, 빌더의 섹션 목록, payload 모양을 다 만져야 한다.
+
+### 올릴 때
+
+JSON은 빌드할 때 번들과 이미지에 들어간다. 그래서 템플릿을 추가하면 backend·worker·site·
+frontend를 전부 다시 빌드해야 한다. 하나만 올리면 빌더에는 보이는데 저장이 거절되는 식으로
+어긋난다.
+
+DB는 건드릴 필요가 없다. 이미 발행된 사이트는 사장님이 다시 발행하기 전까지 그대로다.
+
+## 4. 렌더링 순서
+
+### 빌더에서 고칠 때 (미리보기)
+
+```
+사장님이 빌더에서 템플릿·색·섹션을 바꾼다
+ │ frontend stores/builder.ts (템플릿을 바꾸면 이전 템플릿이 켠 섹션은 꺼진다)
+ ▼
+서버에 저장한다
+ │ 템플릿 POST /v1/place/{id}/site/template → sites.template_id
+ │ 색·섹션 POST /v1/place/{id}/site/theme → sites.theme
+ ▼
+저장이 끝나면 미리보기 iframe을 다시 연다
+ │ frontend features/builder/SitePreview.tsx (features/publish/siteTheme.ts 의 저장 완료 신호를 듣는다)
+ │ iframe 주소 /preview?placeId=… (site가 미리 구워 둔 빈 껍데기 페이지)
+ ▼
+껍데기 안의 site 코드가 서버에 payload를 달라고 한다
+ │ site entry-client.tsx renderPreview
+ │ GET /v1/place/{id}/site/preview
+ │ backend services/site_payload.py — 발행 때와 같은 함수로 payload를 만든다
+ ▼
+템플릿 id를 확인하고 그린다
+ │ 모르는 id면 여기서 에러 문구를 띄우고 멈춘다
+ │ App.tsx → templates.json의 layout을 보고 LAYOUTS에서 뼈대를 고른다
+ │ Frame 안에 SectionList가 섹션을 순서대로 그린다
+ ▼
+다 그렸다고 빌더에 알린다 (postMessage) → 빌더가 로딩 표시를 걷는다
+```
+
+### 발행할 때
+
+```
+사장님이 "발행하기"를 누른다
+ ▼
+jobs 표에 BUILD 잡이 들어간다
+ ▼
+워커가 잡을 집는다 backend services/build_service.py run_build
+ │ 1. 상호명·업종이 있는지, 사실 값이 확인됐는지 본다. 아니면 발행 실패
+ │ 2. 템플릿 id 확인 common/template_catalog.py — 모르면 발행 실패
+ │ 3. payload JSON 만들기 services/site_payload.py
+ │ → site/payloads/.json 에 떨어뜨린다
+ ▼
+워커가 Node 렌더러를 실행한다 site scripts/prerender.ts
+ │ 1. 공개하면 안 되는 값을 한 번 더 걸러 낸다 shared lib/facts.ts
+ │ 2. 남의 도메인 사진을 우리 서버로 내려받는다
+ │ 3. React로 HTML 문자열을 만든다 site entry-server.tsx → App.tsx
+ │ 4. 검색용 JSON-LD, llms.txt를 같이 만든다
+ │ 5. payload를 HTML 안에 심는다 (window.__SITE_PAYLOAD__)
+ │ → out/versions//<버전>/ 에 쓴다
+ ▼
+결과를 확인하고 공개 주소를 새 버전으로 바꾼다
+ │ out/s/ 링크를 새 버전 폴더로 갈아 끼운다 (PUBLISH_VERSION.md)
+ │ DB에 버전과 발행 기록을 남긴다
+ ▼
+손님이 /s/ 에 들어온다
+ │ nginx가 구워 둔 HTML을 그대로 준다. 검색엔진은 여기까지만 읽는다
+ ▼
+브라우저가 JS를 받아 화면을 이어받는다 site entry-client.tsx hydrateRoot
+ 심어 둔 payload로 같은 화면을 다시 만들어서, 버튼·달력 같은 동작을 붙인다
+```
+
+두 흐름의 차이는 하나다. 미리보기는 브라우저가 처음부터 그리고, 발행은 서버에서 미리 그려 둔
+HTML에 브라우저가 동작만 붙인다. 그리는 코드(`App.tsx`)는 같다.
diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql
index 52e94df..1188f67 100644
--- a/postgres-init/init-data/init.sql
+++ b/postgres-init/init-data/init.sql
@@ -396,7 +396,7 @@ CREATE TABLE IF NOT EXISTS public.sites (
place_id uuid NOT NULL, -- 사업장과 1:1
domain VARCHAR(255) NULL,
path_prefix VARCHAR(100) NULL,
- template_id VARCHAR(100) NULL, -- 사장님이 고른 템플릿 키. ★ 서버는 해석하지 않고 보관·반환만 한다 — 목록은 프론트가 소유한다
+ template_id VARCHAR(100) NULL, -- 템플릿 id(solution/shared/src/data/templates.json). NULL 이면 업종 기본 템플릿
theme JSONB NULL, -- ★ 색·서체·섹션 순서/on-off/배리에이션. 내용은 site_sections 로 나갔다. templateId 는 위 컬럼이 소유한다(중복 보관 금지)
status SMALLINT NOT NULL DEFAULT 1, -- SiteStatus: 1=draft 2=review 3=published 4=suspended 5=unpublished
current_version_id uuid NULL, -- site_versions.site_version_id
@@ -407,8 +407,8 @@ CREATE TABLE IF NOT EXISTS public.sites (
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-COMMENT ON COLUMN public.sites.template_id IS '사장님이 고른 템플릿 키. NULL 이면 업종 기본 템플릿으로 굽는다.';
-COMMENT ON COLUMN public.sites.theme IS '에디터가 정한 디자인. {"colors":{...},"fontStyle":"...","sections":[{"id","name","enabled","locked","variantId"}]} — 서버는 해석하지 않고 그대로 보관·반환한다(목록은 프론트가 소유). NULL 이면 발행 잡이 업종 기본 색·서체·섹션으로 굽는다. templateId 는 sites.template_id 가 소유한다.';
+COMMENT ON COLUMN public.sites.template_id IS '템플릿 id(solution/shared/src/data/templates.json). NULL 이면 업종 기본 템플릿으로 굽는다.';
+COMMENT ON COLUMN public.sites.theme IS '색·섹션. {"colors":{...},"look":{...},"colorPaletteId":"...","sections":[{"id","name","enabled","locked","body","data"}]}. 모양(look)은 발행 때 템플릿 정의가 정한다.';
-- 섹션 하나의 콘텐츠. ★ **JSON import/export 의 단위**다.
-- 실측(2026-09-09, /s/stay): theme 42,150 B 중 디자인은 636 B(1.5%)이고 콘텐츠가 39,645 B(94%).
diff --git a/postgres-init/migrations/0023_template_ids_without_industry.sql b/postgres-init/migrations/0023_template_ids_without_industry.sql
new file mode 100644
index 0000000..80a6f0d
--- /dev/null
+++ b/postgres-init/migrations/0023_template_ids_without_industry.sql
@@ -0,0 +1,21 @@
+-- 템플릿 id에서 업종 접두어를 뗀다(stay-retro → retro). 모르는 값과 업종 허용 목록 밖의 값은 NULL(업종 기본)로 되돌린다.
+-- 허용 목록은 solution/shared/src/data/templates.json 이다. 재실행해도 결과가 같다.
+
+UPDATE sites
+SET template_id = CASE
+ WHEN template_id ~ '^(stay|cafe|restaurant|clinic)-(simple|magazine|retro|paper)$'
+ THEN regexp_replace(template_id, '^[a-z]+-', '')
+ ELSE NULL
+END
+WHERE template_id IS NOT NULL
+ AND template_id NOT IN ('simple', 'magazine', 'retro', 'paper');
+
+UPDATE sites AS s
+SET template_id = NULL
+FROM places AS p
+WHERE p.place_id = s.place_id
+ AND p.category = 4
+ AND s.template_id NOT IN ('simple', 'magazine');
+
+COMMENT ON COLUMN public.sites.template_id IS '템플릿 id(solution/shared/src/data/templates.json). NULL 이면 업종 기본 템플릿으로 굽는다.';
+COMMENT ON COLUMN public.sites.theme IS '색·섹션. {"colors":{...},"look":{...},"colorPaletteId":"...","sections":[{"id","name","enabled","locked","body","data"}]}. 모양(look)은 발행 때 템플릿 정의가 정한다.';
diff --git a/solution/backend/Dockerfile b/solution/backend/Dockerfile
index 3e49c19..d4c289f 100644
--- a/solution/backend/Dockerfile
+++ b/solution/backend/Dockerfile
@@ -24,6 +24,7 @@ RUN playwright install --with-deps chromium
COPY solution/backend ./solution/backend
COPY admin/backend ./admin/backend
+COPY solution/shared/src/data ./solution/shared/src/data
ENV APP_ENV=local
diff --git a/solution/backend/Dockerfile.worker b/solution/backend/Dockerfile.worker
index 4f1ecd8..b774640 100644
--- a/solution/backend/Dockerfile.worker
+++ b/solution/backend/Dockerfile.worker
@@ -56,6 +56,7 @@ RUN playwright install --with-deps chromium
COPY --from=node-runtime /usr/local/bin/node /usr/local/bin/node
COPY solution/backend ./solution/backend
+COPY solution/shared/src/data ./solution/shared/src/data
# ★ SITE_ROOT(solution/site/scripts/prerender.ts)가 자기 파일 위치 기준 상대경로로
# payloads·songs·out 을 찾는다 — dist·public 이 이 자리(/app/solution/site/)에 있어야
# 워커가 컨테이너 안에서 렌더러를 그대로 실행할 수 있다.
diff --git a/solution/backend/common/database/model/models.py b/solution/backend/common/database/model/models.py
index 592342b..e65846e 100644
--- a/solution/backend/common/database/model/models.py
+++ b/solution/backend/common/database/model/models.py
@@ -504,16 +504,9 @@ class sites(MainTableMixin, MAIN_BASE):
place_id = Column(UUID(as_uuid=True), nullable=False)
domain = Column(String(255), nullable=True)
path_prefix = Column(String(100), nullable=True)
- # 사장님이 고른 템플릿 키(프론트 배리에이션 레지스트리의 id). 서버는 해석하지 않고 보관·반환만 한다 —
- # 템플릿 목록은 프론트가 소유하므로, 서버가 값을 검증하면 템플릿을 늘릴 때마다 백엔드를 고쳐야 한다.
- # NULL 이면 발행 잡이 업종 기본 템플릿으로 굽는다(services/site_payload).
+ # 템플릿 id(solution/shared/src/data/templates.json). NULL이면 업종 기본 템플릿으로 굽는다.
template_id = Column(String(100), nullable=True)
- # 에디터가 정한 색·서체·섹션(순서·on/off·배리에이션). template_id 와 같은 이유로 서버에 저장한다 —
- # 브라우저에만 두면 발행 잡이 읽을 곳이 없어 업종 기본 모양으로 굽고, 고른 디자인과 발행본이 갈린다.
- # ★ 컬럼으로 펼치지 않고 jsonb 로 통째로 담는 이유: 섹션 목록·배리에이션 키·색 토큰 이름은
- # 프론트가 소유한다. 펼치면 프론트가 항목 하나 늘릴 때마다 마이그레이션이 따라와야 한다.
- # ★ templateId 는 여기 넣지 않는다 — 위 template_id 컬럼이 소유한다. 두 곳에 두면 어느 쪽이 진짜인지 갈린다.
- # NULL 이면 발행 잡이 업종 기본 색·서체·섹션으로 굽는다(services/site_payload).
+ # 색·섹션(순서·on/off·본문). 내용 키는 프론트가 소유하므로 jsonb로 통째로 담는다.
theme = Column(JSONB, nullable=True)
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=SiteStatus.DRAFT.value)
current_version_id = Column(UUID(as_uuid=True), nullable=True) # site_versions.site_version_id
diff --git a/solution/backend/common/template_catalog.py b/solution/backend/common/template_catalog.py
new file mode 100644
index 0000000..fe8c5c2
--- /dev/null
+++ b/solution/backend/common/template_catalog.py
@@ -0,0 +1,41 @@
+"""업종·템플릿 정의. 프론트와 같은 파일(solution/shared/src/data/templates.json)을 읽는다."""
+import json
+from pathlib import Path
+
+from common.enums import PlaceCategory
+
+CATALOG_PATH = Path(__file__).resolve().parents[2] / "shared" / "src" / "data" / "templates.json"
+
+_CATALOG = json.loads(CATALOG_PATH.read_text(encoding="utf-8"))
+
+TEMPLATES: dict = _CATALOG["templates"]
+INDUSTRIES: dict = _CATALOG["industries"]
+
+_INDUSTRY_BY_CATEGORY = {
+ PlaceCategory.LODGING.value: "stay",
+ PlaceCategory.CAFE.value: "cafe",
+ PlaceCategory.RESTAURANT.value: "restaurant",
+ PlaceCategory.CLINIC.value: "clinic",
+}
+
+
+class UnknownTemplate(ValueError):
+ pass
+
+
+def industry_of(category: int) -> dict:
+ key = _INDUSTRY_BY_CATEGORY.get(category)
+ if key is None:
+ raise ValueError(f"업종 코드에 맞는 정의가 없다: {category}")
+ return INDUSTRIES[key]
+
+
+def is_allowed(category: int, template_id: str) -> bool:
+ return template_id in industry_of(category)["templates"]
+
+
+def resolve_template_id(category: int, stored: str | None) -> str:
+ template_id = (stored or "").strip() or industry_of(category)["defaultTemplate"]
+ if not is_allowed(category, template_id):
+ raise UnknownTemplate(f"업종 {category}에서 쓸 수 없는 템플릿: {template_id}")
+ return template_id
diff --git a/solution/backend/router/v1/site/protocol.py b/solution/backend/router/v1/site/protocol.py
index 77f86bc..087ef04 100644
--- a/solution/backend/router/v1/site/protocol.py
+++ b/solution/backend/router/v1/site/protocol.py
@@ -22,10 +22,7 @@ class SiteProtocol(WebPacketProtocol):
class Req_StartBuild(SiteProtocol):
- """정적 빌드 시작.
-
- publish=true 면 발행 검수 게이트를 통과했을 때 바로 발행까지 한다.
- ★ 게이트를 통과하지 못하면 발행되지 않는다 — 우회 옵션은 없다."""
+ """정적 빌드 시작."""
publish: bool = False
@@ -37,12 +34,7 @@ class Res_StartBuild(Res_WebPacketProtocol):
class Req_Rollback(SiteProtocol):
- """예전 버전으로 공개 주소를 되돌린다.
-
- ★ 재굽기가 아니다 — 대상 버전이 디스크에 아직 있으면 심볼릭 링크만 돌린다. 지워졌으면
- (보관 정책, prerender.ts pruneOldVersions) site_versions.snapshot 으로 다시 굽고 나서
- 돌린다. 어느 경우든 게이트를 다시 통과해야 한다(사장님이 이미 확인한 값이라 대개는
- 그대로 통과한다)."""
+ """재굽기가 아니다 — 대상 버전이 디스크에 아직 있으면 심볼릭 링크만 돌린다."""
target_version: int
@@ -66,23 +58,18 @@ class SiteData(WebPacketProtocol):
place_id: uuid.UUID
status: SiteStatus
domain: Optional[str] = None
- # 사장님이 고른 템플릿. 화면이 발행 전에 "지금 어느 템플릿으로 나가는지"를 보여줄 근거다.
+ # 사장님이 고른 템플릿.
template_id: Optional[str] = None
- # 저장된 디자인(색·서체·섹션). ★ 반드시 응답으로 내려줘야 한다 —
- # 에디터가 다시 열렸을 때 저장된 값을 읽을 곳이 없으면, 저장은 됐는데 화면은 기본값으로 돌아간다.
- # 저장할 때와 같은 모양 그대로 돌려준다(서버가 해석하지 않으므로 변형할 이유도 없다).
+ # 저장된 디자인(색·서체·섹션).
theme: Optional[dict[str, Any]] = None
current_version_id: Optional[uuid.UUID] = None
published_at: Optional[datetime] = None
- # 발행 썸네일(Azure Blob 공개 URL). 대표 사진을 옮긴 것이고, 만들지 못했으면 없다.
+ # 발행 썸네일(Azure Blob 공개 URL).
thumbnail_url: Optional[str] = None
class MySiteData(WebPacketProtocol):
- """내 사이트 목록의 한 줄 — 사업장(place) + 사이트(site).
-
- ★ render 는 여기 없다 — 보고서 **파일**을 읽는 값이라 줄 수만큼 파일 IO 가 된다(단건이 소유).
- ★ site_id 아래가 전부 None 이면 아직 사이트가 없는 사업장이다."""
+ """내 사이트 목록의 한 줄 — 사업장(place) + 사이트(site)."""
place_id: uuid.UUID
name: str
@@ -96,9 +83,7 @@ class MySiteData(WebPacketProtocol):
domain: Optional[str] = None
template_id: Optional[str] = None
published_at: Optional[datetime] = None
- # 목록 카드의 그림. 발행에 성공해야 채워지고, 발행마다 `?v=` 가 바뀐다(site_thumbnail.public_url).
- # Azure 썸네일 저장소가 안 꺼져 있으면(로컬 개발) 빌더가 쓰는 대표 사진으로 대신 채운다
- # (site_service._my_site_row) — 이때는 `?v=` 가 없다.
+ # 목록 카드의 그림.
thumbnail_url: Optional[str] = None
# 단건과 같은 규칙 — 노출값이 마지막 빌드보다 나중에 바뀌었으면 재발행 대상이다.
needs_rebuild: bool = False
@@ -120,52 +105,19 @@ class PublishLogData(WebPacketProtocol):
class Req_SiteTemplate(SiteProtocol):
- """템플릿 선택 저장.
-
- ★ 서버는 값을 검증하지 않는다. 템플릿 목록은 프론트(배리에이션 레지스트리)가 소유하므로
- 여기서 화이트리스트를 두면 템플릿을 하나 늘릴 때마다 백엔드를 같이 고쳐야 한다.
- 잘못된 키가 들어와도 발행 잡이 업종 기본으로 떨어뜨린다 — 화면이 깨지지 않는다."""
+ """템플릿 선택 저장. 업종 허용 목록(solution/shared/src/data/templates.json)에 없는 id는 거절한다."""
template_id: str = ""
class Req_SiteTheme(SiteProtocol):
- """디자인(색·서체·섹션) 저장. 에디터 좌측 패널과 [디자인] 탭이 만든 결과 그대로 온다.
-
- ★ Req_SiteTemplate 과 같은 철학이다 — 서버는 값을 해석하지도 검증하지도 않는다.
- 섹션 목록도, 배리에이션 키도, 색 토큰 이름도 프론트(배리에이션 레지스트리)가 소유한다.
- 여기에 화이트리스트를 두면 프론트에 섹션이나 배리에이션이 하나 늘 때마다 백엔드를 같이 고쳐야 하고,
- 그 사이 사장님이 고른 값은 조용히 버려진다. 모르는 값이 들어와도 발행 잡이 업종 기본으로
- 떨어뜨리므로 화면은 깨지지 않는다.
-
- ★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다:
- {"theme": {"colors": {...}, "fontStyle": "...", "look": {...}, "colorPaletteId": "...", "sections": [...]}}
- sections 는 {id, name, enabled, locked, variantId?, body?, data?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
- (별도 order 필드가 없다). variantId·본문 body·붙여넣기 JSON data 는 값이 있을 때만 키가 붙는다.
- pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 —
- 서버는 배달부지 심판이 아니다.
-
- ★ colorPaletteId 는 **에디터 복원 전용**이다. 사장님이 고른 색 프리셋 id 이고,
- 발행 렌더러는 이걸 안 쓰고 해석된 colors 만 쓴다. DB 에는 저장하고 응답으로도 그대로 돌려주지만,
- 발행 payload 의 theme 에는 싣지 않는다 — 발행 계약(SitePayload.SiteTheme)에 없는 필드다.
-
- ★ templateId 는 이 body 에 없다. sites.template_id 컬럼과 POST /template 이 계속 담당한다 —
- 두 곳에 두면 어느 쪽이 진짜인지 갈린다.
-
- ★ 딱 하나 막는 것은 크기다. 해석하지 않는 값을 그대로 보관한다는 건 곧 무엇이든 들어올 수 있다는
- 뜻이라, 상한이 없으면 jsonb 컬럼 하나가 DB 와 스냅샷을 통째로 부풀린다.
- 상한(services/site_service._THEME_MAX_BYTES)은 서비스가 직렬화 크기로 잰다 —
- 필드 개수로 재면 값 하나가 긴 경우를 못 막는다.
- """
+ """색·섹션 저장."""
theme: dict[str, Any] = {}
class RenderStatusData(WebPacketProtocol):
- """정적 페이지가 실제로 구워졌는지. 프리렌더가 남긴 보고서를 그대로 옮긴다.
-
- ★ 발행 기록(DB)과 실제 페이지(파일)는 다른 곳에 산다. 이게 없으면 프리렌더가 깨져도
- DB 는 "발행됨"이라 말하고 사장님은 [사이트 열기] 를 눌러야 404 로 알게 된다."""
+ """정적 페이지가 실제로 구워졌는지."""
# PENDING(아직) · STALE(낡음) · OK · FAILED
state: str = "PENDING"
@@ -177,9 +129,9 @@ class RenderStatusData(WebPacketProtocol):
class Res_Site(Res_WebPacketProtocol):
site: Optional[SiteData] = None
current_version: Optional[SiteVersionData] = None
- # ★ 노출값이 바뀐 뒤 다시 빌드하지 않았으면 True — 개별 재빌드 대상이라는 표시.
+ # 노출값이 바뀐 뒤 다시 빌드하지 않았으면 True — 개별 재빌드 대상이라는 표시.
needs_rebuild: bool = False
- # ★ 빌드(DB)와 렌더(정적 파일)는 다른 단계다. 빌드가 됐다고 페이지가 있는 게 아니다.
+ # 빌드(DB)와 렌더(정적 파일)는 다른 단계다.
render: RenderStatusData = RenderStatusData()
@@ -213,10 +165,7 @@ class Res_SeoAudit(Res_WebPacketProtocol):
class Req_SiteSlug(SiteProtocol):
- """사이트 주소(네임스페이스) 예약.
-
- ★ 서버가 상호명으로 자동 확정하지 않는다 — 사장님이 고른다.
- 주소는 AI 검색이 색인하는 영구 식별자라, 한 번 정해지면 되돌리는 비용이 사장님 몫이 된다."""
+ """사이트 주소(네임스페이스) 예약."""
slug: str = ""
@@ -240,29 +189,19 @@ class Res_SiteSlug(Res_WebPacketProtocol):
class Req_SiteStatus(SiteProtocol):
- """발행 상태 전이. ★ 해지는 삭제가 아니라 상태 전이다 —
- 색인된 페이지를 갑자기 404 로 만들면 그 자리를 다시 OTA 가 가져간다.
-
- ★ 기본값을 두지 않는다. SUSPEND 가 기본이던 동안에는 필드 이름을 틀리게 보내도
- (`{"status": 5}` 처럼) 422 가 아니라 **발행 중지가 실행됐다** — 파괴적인 전이가
- '아무것도 안 적었을 때' 의 자리에 있었다(실측 2026-09-15).
- 무엇을 할지는 부르는 쪽이 적는다."""
+ """발행 상태 전이."""
action: PublishAction
class ShowcaseItem(WebPacketProtocol):
- """랜딩 쇼케이스 카드 한 장. **로그인 없이 나가는 값이다.**
-
- ★ 여기 있는 것은 전부 이미 발행된 페이지에 적혀 있는 것뿐이다.
- place_id·소유자·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과
- 발행 업소 명단을 통째로 긁는 것은 다른 일이다. 지역도 시·군·구까지만 준다."""
+ """랜딩 쇼케이스 카드 한 장."""
name: str
category: PlaceCategory
- # "강원특별자치도 양양군" 수준. 주소를 못 읽으면 없다.
+ # "강원특별자치도 양양군" 수준.
region: Optional[str] = None
- # 발행 주소. 랜딩과 발행본이 한 오리진이라 루트 상대경로로 준다(`/s/`).
+ # 발행 주소.
url: str
# 없으면 화면이 글자 카드로 떨어진다(썸네일은 발행의 부수 효과라 실패할 수 있다).
thumbnail_url: Optional[str] = None
@@ -286,8 +225,7 @@ class PostData(WebPacketProtocol):
sent_at: Optional[datetime] = None
approved_at: Optional[datetime] = None
published_at: Optional[datetime] = None
- # 화면은 발행완료/발행실패만 보여준다(발행 전 상태는 안 보여준다) — 승인됐는데
- # BUILD 잡이 dead-letter 로 끝났을 때만 true(PostService._latest_build_failed).
+ # 화면은 발행완료/발행실패만 보여준다(발행 전 상태는 안 보여준다) — 승인됐는데 BUILD 잡이 dead-letter 로 끝났을 때만 true(PostService._latest_build_failed).
build_failed: bool = False
@@ -321,8 +259,6 @@ class Res_GenerationHistory(Res_WebPacketProtocol):
class Res_GenerateOne(Res_WebPacketProtocol):
- """개별 생성 결과 — 달력에서 빈 날짜 하나를 콕 집어 만들었을 때(2026-09-17, 사장님
- 지시: "개별적으로 새로 만들수있게 해줘"). 실패하면 post 가 없다(그 날짜가 이미 찼거나
- 소재가 바닥났다)."""
+ """개별 생성 결과 — 달력에서 빈 날짜 하나를 콕 집어 만들었을 때."""
post: Optional[PostData] = None
diff --git a/solution/backend/router/v1/site/site.py b/solution/backend/router/v1/site/site.py
index 76dc87c..40a9774 100644
--- a/solution/backend/router/v1/site/site.py
+++ b/solution/backend/router/v1/site/site.py
@@ -4,6 +4,7 @@ from fastapi.responses import JSONResponse
from fastapi import APIRouter, Depends, Query
from common.models.gmodel import PageParams, UserInfo
+from common.template_catalog import UnknownTemplate
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
from services.site_service import SiteService
from .protocol import (
@@ -23,11 +24,10 @@ from .protocol import (
Res_StartBuild,
)
-# 사이트/발행 라우터. 사업장 하위 리소스이며 회사 스코프는 service 가 사업장 조회로 강제한다.
+# 사이트/발행 라우터.
router = APIRouter(prefix="/v1/place/{place_id}/site", tags=["Site"], responses={404: {"description": "Not found"}})
-# ★ 내 사이트 목록은 사업장 하위가 아니라 계정 하위다 — 위 라우터는 접두어에 place_id 가 박혀 있어
-# "내 것 전부"가 들어갈 자리가 없다. 라우터 객체를 하나 더 둔다(router.py 에서 같이 등록).
+# 내 사이트 목록은 사업장 하위가 아니라 계정 하위다 — 위 라우터는 접두어에 place_id 가 박혀 있어 "내 것 전부"가 들어갈 자리가 없다.
my_router = APIRouter(prefix="/v1/site", tags=["Site"], responses={404: {"description": "Not found"}})
@@ -107,10 +107,8 @@ async def set_slug(
path="/template",
response_model=Res_Site,
summary="템플릿(디자인) 선택 저장",
- description="위저드에서 고른 템플릿을 sites.template_id 에 저장한다(사이트 행이 없으면 만든다). "
- "★ 서버는 값을 검증하지 않는다 — 템플릿 목록은 프론트가 소유한다. 길이(100자)만 막는다. "
- "★ 주소와 달리 발행 뒤에도 바꿀 수 있다: 디자인이 바뀌어도 URL 은 그대로라 색인이 깨지지 않는다. "
- "이미 발행된 사이트면 재빌드가 필요하다는 표시로 content_updated_at 을 찍는다(needs_rebuild=true).",
+ description="sites.template_id 에 저장한다(사이트 행이 없으면 만든다). 업종 허용 목록에 없는 id는 거절한다. "
+ "이미 발행된 사이트면 재빌드 표시(content_updated_at)를 찍는다.",
)
async def set_template(
place_id: UUID,
@@ -124,20 +122,10 @@ async def set_template(
@router.post(
path="/theme",
response_model=Res_Site,
- summary="디자인(색·서체·섹션) 저장",
- description="에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다"
- "(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: "
- "{\"theme\":{\"colors\":{...},\"fontStyle\":\"...\",\"look\":{...},\"colorPaletteId\":\"...\","
- "\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\",\"body\",\"data\"}]}}. "
- "★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). "
- "★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. "
- "직렬화 크기(64KB)만 막는다. "
- "★ templateId 는 여기 담지 않는다 — sites.template_id 와 POST /template 이 담당한다. "
- "★ colorPaletteId 는 에디터 복원 전용이라 저장·반환만 하고 발행 payload 에는 싣지 않는다. "
- "★ 빈 값({})을 보내면 NULL 로 되돌아가 업종 기본 색·서체·섹션으로 떨어진다. "
- "★ 템플릿과 같이 발행 뒤에도 바꿀 수 있다(디자인이 바뀌어도 URL 은 그대로다). "
- "이미 발행된 사이트면 재빌드가 필요하다는 표시로 content_updated_at 을 찍는다"
- "(needs_rebuild=true).",
+ summary="디자인(색·섹션) 저장",
+ description="sites.theme 에 저장한다(사이트 행이 없으면 만든다). body: "
+ "{\"theme\":{\"colors\",\"look\",\"colorPaletteId\",\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"body\",\"data\"}]}}. "
+ "배열 순서가 곧 섹션 순서다. 크기(64KB)만 막는다. 빈 값({})이면 업종 기본으로 되돌린다.",
)
async def set_theme(
place_id: UUID,
@@ -173,7 +161,10 @@ async def site_preview(
service: SiteService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
):
- payload = await service.preview_payload(user_info, str(place_id))
+ try:
+ payload = await service.preview_payload(user_info, str(place_id))
+ except UnknownTemplate as ex:
+ return JSONResponse(status_code=422, content={"detail": str(ex)})
if payload is None:
return JSONResponse(status_code=404, content={"detail": "사업장을 찾지 못했습니다"})
return JSONResponse(content=payload)
diff --git a/solution/backend/services/build_service.py b/solution/backend/services/build_service.py
index a3bdf8a..66798f2 100644
--- a/solution/backend/services/build_service.py
+++ b/solution/backend/services/build_service.py
@@ -1,13 +1,4 @@
-"""정적 빌드 + 발행 — BUILD 잡이 하는 일.
-
- 스냅샷 조립 → 빌드(HTML + JSON-LD) → 발행 검수 게이트 → site_version 기록
-
-★ 정적 빌드다. DB 는 여기서만 읽고, 그 결과가 site_versions.snapshot 에 박제된다.
- 방문자는 DB 와 만나지 않는다.
-★ 개별 재빌드 단위다. 사이트 1,000개에서 전체 재빌드는 못 쓴다 —
- places.content_updated_at 이 바뀐 사업장만 다시 빌드하면 된다.
-★ 게이트를 통과하지 못하면 버전은 FAILED 로 남고 발행되지 않는다. 사유가 publish_logs 에 남는다.
-"""
+"""정적 빌드 + 발행 — BUILD 잡이 하는 일."""
import os
import uuid
@@ -28,6 +19,7 @@ from common.enums import (
SiteStatus,
)
from common.logger import LOG
+from common.template_catalog import UnknownTemplate, resolve_template_id
from common.utils.gtime import GTime
from crud.site_crud import SiteCRUD
from crud.place_crud import PlaceCRUD
@@ -70,8 +62,6 @@ async def _stamp_reviews(session, place_id, version_id):
return ErrorType.SUCCESS
# 렌더러 subprocess 가 끝나기를 기다리는 시간(사진 내려받기 포함).
-# ★ 넉넉해야 한다. 짧으면 멀쩡한 발행이 "렌더 시간 초과"로 실패한다 — 처음 보는 사진을
-# 내려받는 발행은 몇 초가 더 걸린다(mirrorMedia, prerender.ts).
RENDER_TIMEOUT_SEC = float(os.environ.get("RENDER_TIMEOUT_SEC") or 180)
@@ -97,13 +87,7 @@ async def ensure_site(place_id: str) -> "sites":
async def load_channel_links(place_id: str) -> list:
- """채널 링크(야놀자·네이버 플레이스·인스타…).
-
- 스냅샷에 담기지 않는 유일한 발행 재료라 여기서 읽어 payload 로 넘긴다 — 재빌드(run_build)
- 도 롤백(rollback_service.run_rollback)도 같은 함수를 쓴다. **스냅샷에는 안 싣는다** —
- 롤백이 옛 스냅샷으로 다시 구워도 링크는 항상 지금 확정된 것을 보여줘야 한다(끊긴 링크를
- 옛 버전째 되살리면 안 된다).
- ★ 실패해도 빈 목록으로 진행한다 — 링크가 없다고 발행을 막을 이유가 없다."""
+ """채널 링크(야놀자·네이버 플레이스·인스타…)."""
err, rows = await DB_SESSION_MNG.execute_lambda(
place_channels.DBType(),
DBWRType.DB_READ.value,
@@ -133,9 +117,7 @@ async def _log(site_id, version_id, action: PublishAction, result: PublishResult
async def run_build(job: dict) -> dict:
- """BUILD 잡 핸들러. payload: {place_id, owner_user_id, publish?, requested_by?}
-
- publish=True 면 게이트를 통과했을 때 바로 발행까지 한다."""
+ """BUILD 잡 핸들러."""
payload = job["payload"]
place_id = payload["place_id"]
owner_user_id = payload["owner_user_id"]
@@ -151,9 +133,7 @@ async def run_build(job: dict) -> dict:
site = await ensure_site(place_id)
- # ★ 주변 정보(맛집·관광지·축제·코스)는 빌드 시점에 업장 좌표로 새로 받는다 — 발행본은 정적이라
- # 이때 받은 값이 실린다. 실패해도 빌드는 계속한다: 곁들이 정보가 사장님 사이트 발행을 막을 이유가 없고,
- # place_contents 는 직전 값을 그대로 갖고 있다.
+ # 주변 정보(맛집·관광지·축제·코스)는 빌드 시점에 업장 좌표로 새로 받는다 — 발행본은 정적이라 이때 받은 값이 실린다.
try:
synced = await LocalContentService().sync_place(place)
if not synced.result.success:
@@ -161,14 +141,7 @@ async def run_build(job: dict) -> dict:
except Exception as ex: # noqa: BLE001 — 곁들이 정보 실패가 빌드를 죽이면 안 된다
LOG.w(f"[build] place={place_id} 주변정보 갱신 실패(직전 값 사용): {type(ex).__name__}: {ex}")
- # ★ 발행이면 **노래를 먼저 만들고** 스냅샷을 뜬다 (2026-09-11 결정).
- # 순서가 뒤집히면(먼저 굽고 나중에 붙이기) 발행 직후의 사이트에는 노래가 없고 몇 분 뒤
- # 조용히 생긴다 — 사장님이 [사이트 열기] 로 보는 첫 화면에 그 기능이 빠져 있다.
- # 값은 발행이 30초~3분 늦어지는 것이고(Suno 폴링 상한 5분), 그건 감수한다.
- # ★ 실패해도 빌드는 계속한다. 주변 정보와 같은 규칙이다 — 곁들이 하나가 사장님 사이트
- # 발행을 막을 이유가 없다. 노래 없이 나가고, 사유는 아래 로그와 place_songs 에 남는다.
- # ★ 미리보기 빌드(publish=False)에는 만들지 않는다 — 유료 호출이라 눌러 보는 것만으로
- # 비용이 나가면 안 된다.
+ # 발행이면 **노래를 먼저 만들고** 스냅샷을 뜬다.
song_result: dict | None = None
if want_publish:
try:
@@ -177,9 +150,7 @@ async def run_build(job: dict) -> dict:
except Exception as ex: # noqa: BLE001 — 노래 실패가 발행을 죽이면 안 된다
song_result = {"error": f"{type(ex).__name__}: {ex}"}
LOG.w(f"[build] place={place_id} 노래 실패(노래 없이 발행): {type(ex).__name__}: {ex}")
- # ★ 발행 자체는 계속되므로(사이트는 노래 없이 나간다) 이건 REJECTED 도 FAILED 도
- # 아니다 — 별도 종류(partial_failure)로 알린다. 발행이 실패한 게 아니라는 걸
- # 운영자가 첫 줄만 보고 알아야 한다.
+ # 발행 자체는 계속되므로(사이트는 노래 없이 나간다) 이건 REJECTED 도 FAILED 도 아니다 — 별도 종류(partial_failure)로 알린다.
await alert_service.send_alert(
kind="partial_failure",
title=f"노래 생성 실패(발행은 계속) — {place_id}",
@@ -187,9 +158,7 @@ async def run_build(job: dict) -> dict:
dedupe_key=f"song_failed:{place_id}",
)
- # ★ 일정(LLM)은 **여기서 직접** 부른다. 이건 잡이라 기다리는 사람이 없다 —
- # 캔버스 경로가 잡으로 넘기는 것과 사정이 다르다(local_content_service._ensure_region_stories).
- # 이미 있는 기간은 부르지 않으므로 매 빌드가 유료 호출이 되지는 않는다.
+ # 일정(LLM)은 **여기서 직접** 부른다.
try:
from services.itinerary_llm_service import ensure_generated
@@ -201,11 +170,7 @@ async def run_build(job: dict) -> dict:
snapshot = await build_snapshot(place)
- # ★ 메타 태그용 검색 키워드(SiteOntology). **스냅샷에 싣는다** — payload 는 스냅샷만 보고 만들고,
- # "이 버전에 어떤 키워드가 나갔나" 가 site_versions.snapshot 에 남는다(services/seo_keywords 머리주석).
- # ★ 실패해도 빌드는 계속한다. 주변 정보·노래와 같은 규칙이다 — 키워드 없이 예전 제목·메타로 나간다.
- # ★ 재빌드(publish=False)에도 부른다. 로컬 임베딩이라 비용이 없고, 재빌드한 버전과 발행한 버전의
- # 제목이 갈리면 "눌러 본 것과 나간 것이 다르다" 가 된다.
+ # 메타 태그용 검색 키워드(SiteOntology).
seo: dict | None = None
try:
seo = await seo_keywords.fetch(place_id, snapshot)
@@ -237,7 +202,6 @@ async def run_build(job: dict) -> dict:
# 잡 결과에 남긴다 — "노래가 왜 없나" 를 잡 하나만 열어 보면 알 수 있어야 한다.
if song_result is not None:
result["song"] = song_result
- # "제목이 왜 예전 그대로인가" 도 같다 — 키워드가 실렸으면 잡 결과에 보인다(없으면 로그의 [seo] 줄).
if seo is not None:
result["seo"] = seo
now = GTime.UTC()
@@ -257,8 +221,7 @@ async def run_build(job: dict) -> dict:
result["build_status"] = "FAILED"
result["error"] = reason
LOG.w(f"[build] place={place_id} v{version_no} 실패: {reason}")
- # ★ 게이트 반려(gate is not None)는 알리지 않는다 — 사장님이 값을 안 채웠다고
- # 운영자를 부르면 안 된다. 여기서 알리는 건 렌더·인프라가 죽은 "업무 실패"뿐이다.
+ # 게이트 반려(gate is not None)는 알리지 않는다 — 사장님이 값을 안 채웠다고 운영자를 부르면 안 된다.
if gate is None:
await alert_service.send_alert(
kind="build_failed",
@@ -269,8 +232,6 @@ async def run_build(job: dict) -> dict:
return result
# ---- 1차 게이트: 렌더 없이 판정 가능한 것 ----
- # ★ 미검증 fact 는 payload 를 쓰기 전에 막는다. 렌더러에 넘긴 뒤에 막으면 검증 안 된 값이
- # 디스크에 한 번 나갔다 들어오는 셈이 된다.
place_name = str((snapshot.get("place") or {}).get("name") or "").strip()
if not place_name:
return await _fail("상호명이 없다 — 사이트를 만들 수 없다")
@@ -282,13 +243,12 @@ async def run_build(job: dict) -> dict:
result["gate"] = {"passed": False, **facts_gate.as_log()}
return await _fail(f"{facts_gate.reason.name}: {facts_gate.as_log()}", facts_gate)
+ try:
+ resolve_template_id(int(snapshot["place"]["category"]), site.template_id)
+ except UnknownTemplate as ex:
+ return await _fail(str(ex))
+
# ---- 렌더러에 넘긴다 ----
- # ★ 여기가 "발행 기록"과 "실제 페이지"를 잇는 자리다. 렌더러(solution/site)의 유일한 입력이
- # 이 payload JSON 이고, 그게 굽는 HTML 이 방문자와 크롤러가 보는 유일한 페이지다.
- # ★ payload 에 실릴 발행 상태를 미리 맞춘다. 렌더러는 이 값으로 datePublished 를 굽는데,
- # 발행 뒤에 payload 를 쓰던 예전 순서에서는 그게 채워져 있었다. 순서가 바뀌었다고
- # 페이지의 발행일이 비면 AI 검색이 보는 신선도 신호가 사라진다.
- # ★ 게이트에서 떨어지면 DB 에는 반영하지 않는다(아래에서 실제로 쓸 때만 저장한다).
if want_publish:
site.status = SiteStatus.PUBLISHED.value
site.published_at = site.published_at or now
@@ -302,20 +262,13 @@ async def run_build(job: dict) -> dict:
slug = site_payload.publish_slug(place, site)
# ---- 렌더러를 직접 돌린다 ----
- # ★ 게이트는 **실제로 나갈 HTML** 을 보고 판정해야 한다. 렌더러가 자기 산출물을 대조해
- # 구조화 데이터 불일치와 고유 콘텐츠 수를 보고서로 돌려준다.
- # ★ payload=False(미발행)면 렌더러는 `out/versions///` 에만 굽고 공개
- # 주소(`out/s/`)는 건드리지 않는다 — emit_payload 에 실은 publish 플래그가 정한다.
try:
report = await render_service.render_site(payload_path, version_no, RENDER_TIMEOUT_SEC)
except render_service.RenderFailed as ex:
- # ★ 발행하지 않는다. 페이지가 있는지 확인하지 못한 채 "발행됨"으로 남기면
- # 사장님은 [사이트 열기] 를 눌러야 404 로 알게 된다.
+ # 발행하지 않는다.
return await _fail(str(ex))
mismatches = list(report.get("mismatches") or [])
- # ★ None(재지 못했다)과 0(재 봤더니 0건)을 뭉개지 않는다. 게이트는 raw 를 보고,
- # 기록·화면에는 0 으로 떨어뜨린다. 뭉개면 디스크 오류가 NO_UNIQUE_CONTENT 로 둔갑한다.
unique_count_raw = report.get("uniqueContentCount")
unique_count = unique_count_raw or 0
jsonld = report.get("jsonld") or []
@@ -324,10 +277,6 @@ async def run_build(job: dict) -> dict:
stamp = {"jsonld": jsonld, "unique_content_count": unique_count}
# ---- 2차 게이트: 렌더 산출물 기준 ----
- # ★ 렌더가 실패했더라도 게이트를 **먼저** 돌린다. 렌더러가 페이지 쓰기를 거부한 이유가
- # 대개 게이트 사유(고유 콘텐츠 0건·구조화 데이터 불일치)이기 때문이다.
- # 여기서 사유를 정확히 골라야 site_publish_logs 에 '무엇을 고쳐야 하는지' 가 남는다 —
- # 전부 "렌더 실패"로 뭉뚱그리면 운영자가 손댈 곳을 알 수 없다.
gate = publish_gate.evaluate(
PlaceCategory(place.category), snapshot["facts"], unique_count_raw, mismatches
)
@@ -337,16 +286,13 @@ async def run_build(job: dict) -> dict:
return await _fail(f"{gate.reason.name}: {gate.as_log()}", gate, stamp)
if not report.get("ok"):
- # ★ 렌더러가 거부한 이유에 사유 코드를 붙인다. evaluate() 는 얇은 콘텐츠로 막지
- # 않지만 렌더러는 스팸 판정을 피하려 페이지 쓰기를 거부한다 — 그 사유를 "렌더 실패"
- # 로 뭉개면 화면이 NO_UNIQUE_CONTENT 문구를 못 고르고, 사장님은 손댈 곳을 모른다.
+ # 렌더러가 거부한 이유에 사유 코드를 붙인다.
thin = publish_gate.check_unique_content(unique_count_raw)
if not thin.passed:
- # 위에서 evaluate 결과로 채워 둔 gate 를 덮는다 — 화면(GateRejectCard)은 이 값으로
- # 문구를 고르는데, passed=True 인 채로 두면 "서버 검수를 통과하지 못했습니다" 만 뜬다.
+ # 위에서 evaluate 결과로 채워 둔 gate 를 덮는다 — 화면(GateRejectCard)은 이 값으로 문구를 고르는데, passed=True 인 채로 두면 "서버 검수를 통과하지 못했습니다" 만 뜬다.
result["gate"] = {"passed": False, **thin.as_log()}
return await _fail(f"{thin.reason.name}: {thin.as_log()}", thin, stamp)
- # 나머지는 게이트로 설명되지 않는 실패(디스크·번들·payload 파손). 재시도가 의미 있다.
+ # 나머지는 게이트로 설명되지 않는 실패(디스크·번들·payload 파손).
return await _fail(str(report.get("error") or "렌더 실패"), None, stamp)
# DB 발행 상태를 바꾸기 전에 정적 파일을 외부 저장소에 올린다.
@@ -359,20 +305,16 @@ async def run_build(job: dict) -> dict:
return await _fail(str(ex), None, stamp)
if azure_result:
result["azure"] = azure_result
- # ★ 페이지가 실제로 올라간 뒤에 썸네일을 남긴다 — 없는 페이지의 그림을 쇼케이스에 걸지 않는다.
- # 실패해도 발행은 성공이다(스크린샷이 아니라 대표 사진이라, 없으면 글자 카드로 떨어진다).
+ # 페이지가 실제로 올라간 뒤에 썸네일을 남긴다 — 없는 페이지의 그림을 쇼케이스에 걸지 않는다.
thumbnail_url = await site_thumbnail.store(slug, snapshot, version_no)
if thumbnail_url:
result["thumbnail_url"] = thumbnail_url
- # ★ 정적 파일이 올라간 **뒤에** 통보한다. 먼저 알리면 크롤러가 옛 파일을 가져간다.
- # 실패해도 발행은 성공이다 — 색인 통보는 부수 효과이고, 다음 발행에서 다시 보낸다.
+ # 정적 파일이 올라간 **뒤에** 통보한다.
indexnow_result = await indexnow.submit(slug)
if indexnow_result:
result["indexnow"] = indexnow_result
# ---- 빌드 성공 ----
- # ★ jsonld·고유콘텐츠 수는 **렌더러가 실제로 내보낸 값**이다. 백엔드가 따로 계산하지 않는다 —
- # 따로 계산하던 시절엔 게이트가 통과시킨 근거와 실제 페이지가 어긋날 수 있었다.
await DB_SESSION_MNG.execute_lambda_claim(
site_versions.DBType(),
lambda s: _site_crud.finish_version(
@@ -388,14 +330,11 @@ async def run_build(job: dict) -> dict:
)
result["build_status"] = "BUILT"
result["routes"] = report.get("routes")
- # ★ 빌드가 렌더·인프라 실패 없이 끝났다 — 직전에 build_failed 알림이 안 풀린 채 있었으면
- # 지금 풀렸다는 뜻이다(정상 발행이 재개됐다). 알림이 없었으면 resolve_alert 가 조용히
- # 아무것도 안 한다(파일 머리주석).
+ # 빌드가 렌더·인프라 실패 없이 끝났다 — 직전에 build_failed 알림이 안 풀린 채 있었으면 지금 풀렸다는 뜻이다(정상 발행이 재개됐다).
await alert_service.resolve_alert(f"build_failed:{place_id}", f"발행 재개 — {place_name or place_id}")
if want_publish:
- # 썸네일은 발행 상태 전이와 같은 UPDATE 에 싣는다 — 못 만들었으면 키를 넣지 않아
- # 지난 발행의 그림이 그대로 남는다(NULL 로 밀어 카드를 비우지 않는다).
+ # 썸네일은 발행 상태 전이와 같은 UPDATE 에 싣는다 — 못 만들었으면 키를 넣지 않아 지난 발행의 그림이 그대로 남는다(NULL 로 밀어 카드를 비우지 않는다).
site_update = {
"status": SiteStatus.PUBLISHED.value,
"current_version_id": version.site_version_id,
@@ -406,9 +345,7 @@ async def run_build(job: dict) -> dict:
sites.DBType(),
lambda s: _site_crud.update_site(s, site.site_id, site_update),
)
- # ★ 사업장 상태도 같이 올린다. 여기서 안 올리면 places.status 는 영원히 REVIEW 라,
- # 발행을 마친 가게가 사업장 목록에서 '발행 전'으로 남는다 — 사장님은 목록만 보고
- # 자기 사이트가 나갔는지 알 수 없다. 목록은 사이트 행을 읽지 않는다(N+1).
+ # 사업장 상태도 같이 올린다.
await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(),
lambda s: _place_crud.update_place(
diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py
index b85095a..fec8d19 100644
--- a/solution/backend/services/site_payload.py
+++ b/solution/backend/services/site_payload.py
@@ -1,19 +1,4 @@
-"""발행 payload — 정적 렌더러(solution/site)가 먹는 유일한 입력 JSON.
-
-★ 왜 필요한가
- 빌드 잡은 지금까지 HTML 을 굽고 그 **길이만** 재고 버렸다(발행 기록은 남는데 페이지가 없었다).
- 실제로 방문자에게 보여줄 페이지는 solution/site 의 SSG 가 굽는다. 그 렌더러의 유일한 입력이
- 이 payload 이므로, 빌드 잡이 이 JSON 만 파일로 떨어뜨리면 발행이 실제 페이지로 이어진다.
-
-★ 스키마는 solution/shared/src/types/site-payload.ts 의 `SitePayload` 다.
- 필드명이 camelCase 인 이유는 그쪽이 원본이기 때문이다 — 여기서 스네이크로 바꾸면 렌더러가 못 읽는다.
- 스키마가 바뀌면 schemaVersion 을 올린다(렌더러는 모르는 버전을 조용히 반쪽 렌더하지 않고 실패한다).
-
-★ DB 를 여기서 다시 읽지 않는다.
- 입력은 이미 박제된 스냅샷(site_versions.snapshot)과 그 빌드가 만든 행들뿐이다.
- 스냅샷이 정적 빌드의 경계다 — 여기서 DB 를 한 번 더 읽으면 '스냅샷과 다른 페이지'가 나올 수 있다.
- (channel 링크만 스냅샷에 없어서 호출측이 읽어 넘긴다.)
-"""
+"""발행 payload — 정적 렌더러(solution/site)가 읽는 유일한 입력 JSON."""
import json
import re
import os
@@ -32,38 +17,27 @@ from common.enums import (
SourceType,
)
from common.logger import LOG
+from common.template_catalog import TEMPLATES, industry_of, resolve_template_id
from services.intro_summary import summarize_intro
from services.stay_guide import nol_stay_guide
from services.weather_notes import weather_notes
-# 렌더러가 확인하는 스키마 버전. 모양이 바뀌면 여기와 site-payload.ts 를 같이 올린다.
+# 렌더러가 확인하는 스키마 버전.
SCHEMA_VERSION = 1
-# 출력 디렉토리. 컨테이너 밖(볼륨·오브젝트 스토리지)으로 빼기 쉬우라고 env 로 둔다.
-# ★ 기본값이 `solution/site/payloads` 아래인 이유 — 렌더러(prerender.ts)가 songs·out 디렉토리를
-# **자기 파일 위치 기준 상대경로**로 찾는다(SITE_ROOT = dist/prerender/../..). 워커가 그
-# 렌더러를 subprocess 로 직접 띄우면서(render_service.py) 세 디렉토리(payloads·songs·out)가
-# 그 렌더러가 실제로 설치된 자리(`/app/solution/site/`) 아래에 나란히 있어야 한다 —
-# 어긋나면 워커는 payload 를 잘 쓰는데 렌더러는 다른 곳에서 songs 를 찾다가 못 찾는다.
+# 출력 디렉토리.
PAYLOAD_DIR_ENV = "SITE_PAYLOAD_DIR"
DEFAULT_PAYLOAD_DIR = "/app/solution/site/payloads"
-# 커스텀 도메인이 없을 때 쓰는 기본 호스트. sites.domain 이 채워지면 그 값이 이긴다.
-#
-# ★ env 로 뺀 이유: 이 값이 canonical·og:url·사이트맵·IndexNow 통보에 **전부** 들어간다.
-# 상수로 박아 두면 스테이징에 올릴 때마다 코드를 고쳐야 하고, 안 고치면 발행은 성공하는데
-# 검색엔진에는 열리지도 않는 주소가 등록된다(조용히 틀린다 — 아무도 눈치채지 못한다).
-# 프론트도 같은 이유로 VITE_PUBLISH_HOST 를 쓴다. 두 값은 **같아야 한다**.
+# 커스텀 도메인이 없을 때 쓰는 기본 호스트(sites.domain이 채워지면 그 값이 이긴다).
SITE_HOST_ENV = "SITE_PUBLIC_HOST"
-# 기본값은 localhost. 운영 도메인을 기본으로 두면 설정을 빠뜨린 환경이 조용히 운영 주소로
-# canonical·sitemap 을 굽는다 — 틀렸다는 걸 아무도 모른다.
DEFAULT_HOST = os.environ.get(SITE_HOST_ENV, "").strip() or "localhost"
def _scheme(host: str) -> str:
"""localhost 는 http 다. shared/lib/slug.ts publishUrl 과 같은 규칙."""
return "http" if re.match(r"^(localhost|127\.0\.0\.1)(:\d+)?$", host) else "https"
-# 링크 제목이 비었을 때 채우는 채널 이름. 없는 채널명을 지어내지 않기 위한 고정 표다.
+# 링크 제목이 비었을 때 채우는 채널 이름.
_CHANNEL_TITLE = {
LinkChannel.YANOLJA.value: "야놀자",
LinkChannel.GOODCHOICE.value: "여기어때",
@@ -74,148 +48,9 @@ _CHANNEL_TITLE = {
LinkChannel.ETC.value: "기타 채널",
}
-# 저장된 look 이 없을 때 쓰는 기본 생김새 — 에디터의 '심플' 템플릿
-# (`solution/frontend/src/data/industryData.ts` 의 LOOK.simple)과 같은 값이다.
-# ★ 왜 필요한가 (실측 2026-09-08, `/s/stay-mumum-gunsan`)
-# 이 키가 없으면 `` 에 --tpl-font-heading·--tpl-radius·--tpl-texture 가 아예
-# 안 실리고, 발행본은 렌더러 CSS 의 폴백으로 떨어진다. 그 폴백의 제목 서체는
-# `--font-serif`(명조)다 — 그래서 위저드를 안 돈 사업장의 발행본만 제목이 명조로,
-# 모서리는 렌더러 기본값으로 나가 에디터 미리보기(고딕)와 눈에 띄게 갈렸다.
-# 색은 업종 기본이 있는데 생김새만 없어서 생긴 구멍이라, 고르지 않았을 때의 모습도 정해 둔다.
-_DEFAULT_LOOK = {
- "fontHeading": "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif",
- "fontBody": "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif",
- "radius": "0.75rem",
- "borderWidth": "1px",
- "shadow": "0 1px 2px rgb(0 0 0 / 0.06)",
- "headingTracking": "-0.02em",
- "headingWeight": "700",
- "sectionSpace": "4rem",
-}
-
-# '옛 항구'(stay-retro)의 생김새. 프론트 `industryData.ts` 의 `LOOK.retro` 와 **같은 값이어야 한다.**
-#
-# ★ 왜 서버에도 두나 — 이 값은 프론트가 소유하지만, 백엔드는 TS 를 읽을 수 없고
-# 저장값이 없는 사업장에는 이 표가 곧 발행본이다. 값이 없으면 `_DEFAULT_LOOK`(고딕·둥근
-# 모서리)으로 떨어져 **색만 갱지고 서체는 고딕인** 페이지가 나간다 — 옛 항구가 아니게 된다.
-# ★ `texture` 가 이 템플릿의 정체성이다(갱지 결). `_DEFAULT_LOOK` 에는 이 칸 자체가 없다.
-_LOOK_RETRO = {
- "fontHeading": "'Gugi', 'Noto Sans KR', sans-serif",
- "fontBody": "'Gowun Batang', 'Noto Serif KR', serif",
- "radius": "0px",
- "borderWidth": "2px",
- "shadow": "4px 4px 0 rgb(27 26 21 / 0.16)",
- # 간판체는 자간을 벌리면 글자가 흩어지고, 굵기가 한 벌뿐이라 700 을 주면 가짜 볼드가 씌워진다.
- "headingTracking": "0em",
- "headingWeight": "400",
- "sectionSpace": "4rem",
- "texture": (
- "repeating-linear-gradient(0deg,rgba(27,26,21,.028) 0 1px,transparent 1px 3px),"
- "repeating-linear-gradient(90deg,rgba(27,26,21,.02) 0 1px,transparent 1px 4px)"
- ),
-}
-
-
-# 업종별 **기본 디자인**. 사장님이 아직 아무것도 고르지 않았을 때 쓰는 폴백이다.
-# ★ 이제 여섯 가지가 모두 저장되는 자리를 갖는다:
-# templateId ← sites.template_id (POST /v1/place/{id}/site/template)
-# 색·서체·섹션 on/off·순서·배리에이션 ← sites.theme (POST /v1/place/{id}/site/theme)
-# 저장된 값이 있으면 **그게 이긴다**. 이 표는 저장값이 없을 때만 쓰인다 —
-# 고르지 않은 값을 고른 것처럼 굽지 않는다.
-# ★ 이 표의 섹션 목록은 에디터의 업종별 기본 목록
-# (admin `src/data/industryData.ts` 의 `sections`)과 **id·순서·이름·잠금이 1:1로 같아야 한다.**
-# 여기가 에디터보다 적으면, 사장님이 에디터에서 본 섹션이 발행본에서 통째로 사라진다 —
-# 저장값이 없는 사업장은 이 표가 곧 발행본이기 때문이다(실측: 날씨·실시간 예약·대관 문의).
-# 에디터에 섹션을 늘릴 때는 이 표도 같이 늘린다. 어긋나면 tests/test_site_theme.py 가 잡는다.
-# ★ 이 표가 여전히 필요한 이유: 위저드를 끝까지 돌지 않은 사업장, 그리고 sections 의
-# locked 판정 근거다(아래 _theme 주석 참조). 표의 세 번째 값이 locked 다.
-_DEFAULT_THEME = {
- PlaceCategory.LODGING.value: {
- # ★ 숙박의 기본 템플릿은 '옛 항구'(stay-retro)다 (2026-09-10, 사장님 지시).
- # 예전 값 "stay-o2o-editorial" 은 **어느 목록에도 없는 id** 였다 — 프론트가 가진
- # 숙박 템플릿은 stay-simple · stay-magazine · stay-retro 셋뿐이라, 이 값이 실린
- # 발행본은 에디터로 돌아왔을 때 고른 칩이 하나도 안 맞아 늘 첫 템플릿으로 그려졌다.
- "templateId": "stay-retro",
- "fontStyle": "옛 간판체",
- # 시안(/s/stay)의 :root 값 그대로 — paper / ink-soft / paper-2.
- # 주(朱) 잉크 accent 는 레트로의 정체성이라 업종 accent 로 갈아끼우지 않는다.
- "colors": {"primary": "#1b1a15", "secondary": "#4c4739", "bg": "#e4dac0",
- "card": "#efe7d3", "text": "#1b1a15", "accent": "#bf2f1b"},
- "look": _LOOK_RETRO,
- # ★ 순서·구성이 시안(/s/stay)과 같다. 여기가 시안보다 적으면 새로 만든 사업장은
- # 수집이 다 됐어도 그 섹션이 아예 안 나온다 — 저장값이 없는 사업장에는 이 표가 곧 발행본이다.
- # ★ "이용 규정"은 뺐다 (2026-09-09) — 발행본에 그 섹션이 없다. 체크인·취소·취사·
- # 반려동물 줄은 기본 정보 안에서 규정 덩이로 묶여 나간다(EssentialInfoSection).
- # ★ 가요·일력·인물·연표·읽기·엽서는 여기 넣지 않는다. 이 표의 항목은 전부 켜서 나가는데
- # (`_sections`), 그것들은 '지역 이야기'(story) 탭 **안에서** 그려지는 것이라
- # 켜면 탭 밖에 한 번 더 선다. story 하나만 두면 데이터가 있는 것만 탭이 된다.
- # ★ 퀴즈(quiz)도 넣지 않지만 사정이 다르다 — 탭이 아니라 **독립 섹션**이라
- # ([+ 섹션 추가] 의 '뒤집어 보는 질문'), 켜지 않으면 지역 생성분이 어디에도 안 선다.
- # 기본으로 켜지 않는 것은 의도다: 손님이 예약하러 온 화면에 퀴즈를 기본값으로
- # 세우지 않는다. 넣고 싶은 사장님이 직접 넣는다.
- "sections": [
- ("hero", "히어로", True), ("intro", "소개", False), ("rooms", "객실 안내", False),
- ("event", "소식", False),
- ("info", "기본 정보", True), ("booking", "예약 안내", False),
- ("video", "영상", False),
- ("photos", "사진 갤러리", False), ("map", "오시는 길", True),
- ("festival", "계절별 축제", False), ("local", "지역 정보", False),
- ("itinerary", "추천 일정", False), ("story", "지역 이야기", False),
- ("faq", "자주 묻는 질문", False), ("weather", "날씨", False),
- ("social", "SNS 게시글", False),
- ],
- },
- PlaceCategory.CAFE.value: {
- "templateId": "cafe-modern-espresso",
- "fontStyle": "Sleek Roast",
- "colors": {"primary": "#1c1917", "secondary": "#78716c", "bg": "#ffffff",
- "card": "#fafaf9", "text": "#0c0a09", "accent": "#b45309"},
- "sections": [
- ("hero", "히어로", True), ("intro", "소개", False), ("menu", "시그니처 메뉴", False),
- ("info", "기본 정보", True), ("space", "공간 · 좌석 안내", False), ("photos", "사진 갤러리", False),
- ("inquiry", "대관 및 단체 문의", False), ("map", "오시는 길", True), ("weather", "날씨", False),
- ("local", "주변 나들이", False), ("faq", "자주 묻는 질문", False),
- ("social", "SNS 게시글", False),
- ],
- },
- PlaceCategory.RESTAURANT.value: {
- "templateId": "rest-neat-table",
- "fontStyle": "Sophisticated Table",
- "colors": {"primary": "#1c1917", "secondary": "#57534e", "bg": "#ffffff",
- "card": "#fafaf9", "text": "#0c0a09", "accent": "#b45309"},
- "sections": [
- ("hero", "히어로", True), ("intro", "소개", False), ("menu", "코스 및 메뉴", False),
- ("info", "기본 정보", True), ("booking", "예약 · 포장 안내", False), ("photos", "사진 갤러리", False),
- ("inquiry", "단체 행사 문의", False), ("map", "오시는 길", True), ("weather", "날씨", False),
- ("local", "주변 안내", False), ("faq", "자주 묻는 질문", False),
- ("social", "SNS 게시글", False),
- ],
- },
- PlaceCategory.CLINIC.value: {
- "templateId": "clinic-visual-clinic",
- "fontStyle": "Visual Journey",
- "colors": {"primary": "#0f172a", "secondary": "#475569", "bg": "#ffffff",
- "card": "#f8fafc", "text": "#020617", "accent": "#4A9DC4"},
- "sections": [
- ("hero", "히어로", True), ("intro", "병원 소개", False), ("programs", "시술 안내", False),
- ("info", "기본 정보", True), ("exhibition", "진료 안내", False), ("photos", "사진 갤러리", False),
- ("inquiry", "상담 문의", False), ("map", "오시는 길", True), ("weather", "날씨", False),
- ("local", "주변 정보", False), ("faq", "자주 묻는 질문", False),
- ("social", "SNS 게시글", False),
- ],
- },
-}
-
-
# ── 값 변환 헬퍼 ──────────────────────────────────────────────────────────
def _first_sentence(text: str) -> str:
- """첫 문장. 마침표가 없으면 통째로 돌려준다.
-
- ★ 왜 필요한가: 히어로 아래 한 줄과 meta description 이 같은 값을 쓴다. 둘 다 **한 문장**
- 자리라, 문단이 들어가면 히어로는 세 줄로 부풀고 검색 결과에서는 뒤가 잘린다.
- ★ 마침표 뒤에 공백이 없어도 자른다("…입니다.일본식" 같은 생성물이 실제로 온다).
- 다만 숫자 사이의 점(1.5km)은 자르지 않는다 — 뒤가 숫자면 문장 끝이 아니다.
- """
+ """첫 문장."""
import re as _re
match = _re.search(r"[.!?](?![0-9])", text or "")
@@ -235,10 +70,7 @@ def _text(value) -> str:
def _iso(value) -> str | None:
- """ISO8601 문자열. ★ 화면·JSON-LD 의 dateModified 로 나가는 값이라 시간대를 명시한다.
-
- DB 의 timestamptz 는 여기서 naive UTC 로 올라오므로(GTime 규약) UTC 를 붙인다.
- naive 로 내보내면 렌더러·검색엔진이 로컬시각으로 오해한다."""
+ """ISO8601 문자열."""
if value is None:
return None
if isinstance(value, str):
@@ -251,11 +83,7 @@ def _iso(value) -> str | None:
def slugify(text: str) -> str:
- """solution/shared 의 toSlug 와 같은 규칙(로마자 음차하지 않는다).
-
- ★ 한글을 음차하면 같은 가게가 두 주소를 갖는다. 한글은 그대로 두고 퍼센트 인코딩에 맡긴다.
- NFC 로 정규화하는 이유: macOS 가 준 NFD 문자열이 그대로 디렉토리명이 되면
- 같은 이름이 서로 다른 경로로 갈린다."""
+ """solution/shared 의 toSlug 와 같은 규칙(로마자 음차하지 않는다)."""
normalized = unicodedata.normalize("NFC", str(text or "")).strip().lower()
out = []
for ch in normalized:
@@ -270,9 +98,7 @@ def slugify(text: str) -> str:
return slug.strip("-")
-# ── 주소 구성요소 ──────────────────────────────────────────────
-# 시·도 이름 → ISO 3166-2:KR 코드. `geo.region` 메타에 쓰인다(네이버·다음이 읽는 자리).
-# 약칭과 정식명을 둘 다 적는다 — 외부 장소 API 가 둘을 섞어 준다("경기" / "경기도").
+# ── 주소 구성요소 ────────────────────────────────────────────── 시·도 이름 → ISO 3166-2:KR 코드.
_SIDO_ISO = {
"서울": "KR-11", "서울특별시": "KR-11",
"부산": "KR-26", "부산광역시": "KR-26",
@@ -295,18 +121,7 @@ _SIDO_ISO = {
def _parse_address_parts(*addresses: str | None) -> dict:
- """주소 문자열에서 시·도 / 시·군·구 / 읍·면 을 뽑는다.
-
- ★ **원문에 있는 조각을 그대로만 쓴다.** "경기" 를 "경기도" 로 펴지 않는다.
- addressRegion·addressLocality 는 JSON-LD 로 나가고, JSON-LD 는 화면 텍스트와
- 대조된다(solution/site/src/seo/verify.ts). 화면에는 원문 주소가 그대로 찍히므로
- 정식명으로 펴는 순간 '화면에 없는 값'이 되어 발행 게이트가 막는다.
- 정규화가 필요한 곳은 ISO 코드 하나뿐이고, 그건 화면 대조 대상이 아니다.
-
- ★ 첫 토큰이 아는 시·도가 아니면 **아무것도 돌려주지 않는다** — 추측해서 채우지 않는다.
-
- 도로명 주소를 먼저 보고, 못 읽으면 지번 주소로 넘어간다.
- """
+ """주소 문자열에서 시·도 / 시·군·구 / 읍·면 을 뽑는다."""
for raw in addresses:
tokens = str(raw or "").split()
if len(tokens) < 2 or tokens[0] not in _SIDO_ISO:
@@ -315,7 +130,7 @@ def _parse_address_parts(*addresses: str | None) -> dict:
region = tokens[0]
rest = tokens[1:]
- # 시·군 → (있으면) 그 아래 구. 광역시·특별시는 구가 바로 온다.
+ # 시·군 → (있으면) 그 아래 구.
locality: list[str] = []
i = 0
while i < len(rest):
@@ -342,10 +157,7 @@ def _parse_address_parts(*addresses: str | None) -> dict:
def _fact_entry(spec, row: dict) -> dict:
- """스냅샷 fact 1건 → FactEntry.
-
- label/type/unit/critical/required 는 업종 스키마가 유일한 소스다 —
- 스냅샷에 라벨이 박제돼 있어도 스키마가 있으면 스키마를 따른다(라벨 오탈자 수정이 재빌드로 반영된다)."""
+ """스냅샷 fact 1건 → FactEntry."""
key = row.get("key")
return {
"key": key,
@@ -354,7 +166,7 @@ def _fact_entry(spec, row: dict) -> dict:
"unit": row.get("unit") or (spec.unit if spec else None),
"type": (spec.type if spec else "text"),
"scope": row.get("scope") or (spec.scope if spec else "place"),
- # ★ status 를 그대로 싣는다. 렌더러가 selectPublishable() 로 한 번 더 거른다(2중 방어).
+ # status 를 그대로 싣는다.
"status": row.get("status") or FactStatus.VERIFIED.value,
# facts.source_type 은 NOT NULL 이라 여기 기본값은 옛 스냅샷용 안전망이다.
"sourceType": row.get("source_type") or SourceType.OWNER.value,
@@ -367,89 +179,33 @@ def _fact_entry(spec, row: dict) -> dict:
}
-def _theme(site, theme_spec: dict) -> dict:
- """SiteTheme — 저장된 디자인이 이기고, 없는 것만 업종 기본으로 떨어진다.
-
- ★ 저장값 우선이 이 함수의 존재 이유다. 예전에는 이 자리가 업종 기본 표를 그대로 굽고
- enabled 를 True 로 박아 넣었다 — 사장님이 섹션을 끄고 순서를 바꿔도 발행본은 언제나
- 업종 기본 모양이었다. 저장할 자리(sites.theme)가 생겼으니 여기서 읽는다.
-
- ★ 서버는 값을 해석하지 않는다. 섹션 id 도 배리에이션 키도 색 토큰도 프론트가 소유하므로
- 모르는 값이 와도 그대로 싣는다 — 렌더러가 모르는 키를 만나면 자기 기본으로 떨어진다.
-
- ★ 딱 하나 서버가 우기는 것이 locked 다. 아래 _sections 주석 참조."""
+def _theme(site, category: int) -> dict:
+ """SiteTheme. 모양(look)은 템플릿 정의가, 색·섹션은 저장된 theme이 정한다. 잘못된 templateId면 UnknownTemplate."""
+ template_id = resolve_template_id(category, _text(_get(site, "template_id")))
+ template = TEMPLATES[template_id]
saved = _get(site, "theme")
if not isinstance(saved, dict):
saved = {}
- # 색: 저장값이 이기되 **업종 기본 위에 덮는다**.
- # ★ 렌더러 타입(SiteTheme.colors)은 6개 키를 모두 요구한다. 저장값이 일부만 담고 있을 때
- # 그것만 실으면 나머지 색이 undefined 로 나가 화면이 깨진다 — 빠진 자리는 업종 기본이 메운다.
- colors = dict(theme_spec["colors"])
+ # 렌더러는 색 6개를 모두 요구한다.
+ colors = dict(template["colors"])
for key, value in (saved.get("colors") or {}).items():
if isinstance(value, str) and value.strip():
colors[key] = value.strip()
- font_style = _text(saved.get("fontStyle")) or theme_spec["fontStyle"]
-
- # ★ colorPaletteId 는 여기 싣지 않는다. 에디터 복원 전용 값이고 발행 계약(SiteTheme)에 없다 —
- # 계약에 없는 필드를 payload 에 흘리면 렌더러가 모르는 것이 발행본에 섞인다.
- out = {
- # 저장된 템플릿이 있으면 그것으로 굽는다(sites.template_id). 비어 있으면 업종 기본이다.
- # 여기서 안 읽으면 사장님이 고른 디자인과 실제 발행본이 갈린다(그게 이 컬럼이 생긴 이유다).
- "templateId": _text(_get(site, "template_id")) or theme_spec["templateId"],
+ return {
+ "templateId": template_id,
"colors": colors,
- "fontStyle": font_style,
- "sections": _sections(saved.get("sections"), theme_spec["sections"]),
+ "look": dict(template["look"]),
+ "sections": _sections(saved.get("sections"), industry_of(category)["sections"]),
}
- # ★ 템플릿의 생김새(서체·모서리·테두리·그림자·여백). 색과 달리 업종 기본이 없다 —
- # 프론트가 소유하는 값이라 서버가 지어낼 수 없고, 없으면 렌더러가 자기 기본 서체로 떨어진다.
- # 이걸 안 실으면 발행본은 색만 템플릿을 따르고 서체는 늘 같은 것으로 나간다.
- # 저장된 look 이 있으면 그게 이긴다. 다만 **덮어쓰기가 아니라 덧칠이다** — 프론트가
- # 일부 키만 보낸 옛 저장값에 빈칸이 생기면 그 칸만 명조·렌더러 기본값으로 떨어진다.
- look = saved.get("look")
- cleaned = (
- {k: v for k, v in look.items() if isinstance(v, str) and v.strip()}
- if isinstance(look, dict)
- else {}
- )
- # 업종 기본 look 이 있으면 그것을 바닥에 깐다(숙박 = 옛 항구). 없으면 공통 폴백이다.
- out["look"] = {**theme_spec.get("look", _DEFAULT_LOOK), **cleaned}
- return out
-def _sections(saved_sections, default_spec) -> list:
- """섹션 목록 — 저장된 **배열 순서**가 곧 발행본의 섹션 순서다.
-
- ★ locked 는 서버가 우긴다. 잠긴 섹션(히어로·기본 정보·오시는 길)은 SEO·필수 마크업 때문에
- 잠긴 것이라, 저장값이 껐다고 해도 켜서 내보낸다. 그리고 잠금 판정은 **업종 기본 표**가 하고
- 저장값의 locked 는 잠그는 방향으로만 더한다 — 저장값의 locked:false 를 그대로 믿으면
- 클라이언트가 locked 를 내려 보내는 것만으로 필수 섹션을 끌 수 있어 잠금 자체가 무의미해진다.
-
- ★ 저장값에 없는 기본 섹션은 **켜서** 목록 끝에 덧붙인다.
-
- 한때 잠기지 않은 섹션은 꺼서 붙였다 — "사장님이 목록에서 뺐다 = 안 쓰겠다는 뜻"이라고 봤다.
- 그 전제가 틀렸다. 에디터에는 섹션을 **빼는 기능이 없다**(toggleSection·reorderSection 뿐,
- admin/src/stores/builder.ts). 그러니 저장값에 없다는 건 "뺐다"가 아니라
- **저장할 당시 그 섹션이 아직 없었다**는 뜻이다 — 우리가 나중에 추가한 섹션이다.
-
- 꺼서 붙이면 새 섹션은 기존 사업장에 영원히 나오지 않는다. 에디터에는 보이는데
- 발행본에는 없는 상태가 되고(실측: 날씨 섹션), 사장님은 켠 적도 끈 적도 없는 것이
- 안 나온다고 본다. 저장값이 아예 없을 때 전부 켜서 내보내는 것과 같은 규칙으로 맞춘다.
-
- ★ 통째로 버리지는 않는다 — 렌더러가 섹션 이름을 알아야 에디터에서 껐을 때 같은 이름으로
- 붙고, 무엇이 꺼져 있는지도 payload 만 보고 알 수 있다.
-
- ★ 저장값에만 있고 업종 기본에 없는 섹션(프론트가 새로 추가한 것)은 그대로 싣는다.
- 섹션 목록은 프론트가 소유한다 — 서버가 모른다고 버리면 새 섹션이 발행되지 않는다."""
- defaults = {sid: (label, locked) for sid, label, locked in default_spec}
-
- # 저장값이 없으면(아직 아무것도 고르지 않았다) 업종 기본을 전부 켜서 내보낸다.
+def _sections(saved_sections, defaults: list[dict]) -> list:
+ """섹션 목록."""
+ by_id = {d["id"]: d for d in defaults}
if not isinstance(saved_sections, list) or not saved_sections:
- return [
- {"id": sid, "name": label, "enabled": sid != "social", "locked": locked}
- for sid, label, locked in default_spec
- ]
+ return [{"id": d["id"], "name": d["name"], "enabled": d["enabled"], "locked": d["locked"]} for d in defaults]
out = []
used = set()
@@ -460,51 +216,29 @@ def _sections(saved_sections, default_spec) -> list:
if not sid or sid in used:
continue
used.add(sid)
- default_label, default_locked = defaults.get(sid, ("", False))
- # 서버가 아는 잠금(업종 기본)이 항상 이긴다. 저장값은 잠그는 방향으로만 보탠다.
- locked = bool(default_locked) or bool(item.get("locked"))
+ default = by_id.get(sid, {})
+ locked = bool(default.get("locked")) or bool(item.get("locked"))
entry = {
"id": sid,
- # 사장님이 붙인 제목이 있으면 그게 발행본의 소제목이다. 없으면 업종 기본 이름, 그것도 없으면 id.
- "name": _text(item.get("name")) or default_label or sid,
- # ★ 잠긴 섹션은 꺼진 채로 나갈 수 없다.
+ "name": _text(item.get("name")) or default.get("name") or sid,
"enabled": bool(item.get("enabled", True)) or locked,
"locked": locked,
}
- # ★ 고른 배리에이션이 있을 때만 키를 붙인다. 서버는 이 값을 해석하지 않는다 —
- # 비어 있으면 렌더러가 그 섹션의 기본 레이아웃으로 떨어진다(null 을 실으면 타입이 안 맞는다).
- variant_id = _text(item.get("variantId"))
- if variant_id:
- entry["variantId"] = variant_id
- # ★ 사장님이 에디터에 직접 쓴 섹션 본문. variantId 와 같은 이유로 그대로 싣는다 —
- # 이 필드가 없던 동안 캔버스에 쓴 소개문은 payload 경계에서 통째로 버려졌다.
- # 저장(sites.theme)은 되는데 발행본에는 안 나오고, 고유 콘텐츠로도 세지 않아
- # "소개를 썼는데 발행이 고유 콘텐츠 0건으로 막힌다" 가 됐다.
- # ★ fact 가 아니라 검증 대상이 아니다. 사장님이 자기 가게에 대해 쓴 자기 문장이고,
- # 섹션 제목(name)이 이미 같은 경로로 나간다.
body = _text(item.get("body"))
if body:
entry["body"] = body
- # ★ 붙여넣기 아이템(가요·일력·승차권·인물…)의 원문 JSON. body 와 같은 이유로 그대로 싣는다.
- # 서버는 파싱하지 않는다 — 모양을 검사하면 프론트가 필드를 하나 늘린 날 조용히 떨어뜨린다.
- # 깨진 JSON 은 렌더러가 그 섹션만 비우고 넘어간다(shared/lib/section-data.ts).
data = _text(item.get("data"))
if data:
entry["data"] = data
out.append(entry)
- for sid, label, locked in default_spec:
- if sid in used:
- continue
- # ★ 켜서 붙인다. 저장값에 없는 건 사장님이 뺀 게 아니라 저장 당시 없던 섹션이다(위 주석).
- out.append({"id": sid, "name": label, "enabled": sid != "social", "locked": bool(locked)})
+ for d in defaults:
+ if d["id"] not in used:
+ out.append({"id": d["id"], "name": d["name"], "enabled": d["enabled"], "locked": d["locked"]})
return out
-# WMO weather code → 한 줄 날씨. 코드 하나마다 고유 문구다(뭉치지 않는다) — ★ solution/site/src/lib/
-# use-live-weather.ts 의 WEATHER_CONDITION_BY_CODE 와 **같은 표**여야 한다. 렌더러는 프리렌더된
-# 이 값으로 그리다가 하이드레이션 뒤 최신 캐시로 덮어쓰는데, 두 곳이 다른 표를 쓰면 같은 날씨인데
-# 화면 문구가 바뀐다(사장님 눈에는 버그로 보인다).
+# WMO weather code → 한 줄 날씨.
_WEATHER_CONDITION_BY_CODE = {
0: "맑음",
1: "대체로 맑음",
@@ -565,7 +299,7 @@ def _yyyymmdd(value) -> str:
return digits if len(digits) == 8 else ""
-# 시작 월 → 계절. 경계는 기상학 기준(3·6·9·12월 시작)이다 — 축제는 "몇 월에 가나"로 찾는다.
+# 시작 월 → 계절.
_SEASON_BY_MONTH = {
3: "봄", 4: "봄", 5: "봄",
6: "여름", 7: "여름", 8: "여름",
@@ -582,7 +316,7 @@ def _festival(row: dict):
return None
start, end = _yyyymmdd(body.get("eventstartdate")), _yyyymmdd(body.get("eventenddate"))
- # month 는 화면의 배지다(예: "10월"). 시작일이 없으면 만들지 않는다.
+ # month 는 화면의 배지다(예: "10월").
month = f"{int(start[4:6])}월" if start else ""
if start and end and start != end:
period = f"{start[:4]}.{start[4:6]}.{start[6:]} ~ {end[:4]}.{end[4:6]}.{end[6:]}"
@@ -595,18 +329,10 @@ def _festival(row: dict):
entry = {
"name": name,
"month": month,
- # ★ searchQuery 만 있고 우리가 URL 을 지어내지 않는다 — 틀린 링크는 방문자를 엉뚱한 데로 보내고
- # 그 책임을 이 홈페이지가 진다(렌더러 LocalGuideSection 주석과 같은 규칙).
+ # searchQuery 만 있고 우리가 URL 을 지어내지 않는다 — 틀린 링크는 방문자를 엉뚱한 데로 보내고 그 책임을 이 홈페이지가 진다(렌더러 LocalGuideSection 주석과 같은 규칙).
"searchQuery": name,
}
if start:
- # ★ 계절은 **여기서 한 번만** 정한다 (실측 2026-09-10)
- # `FestivalEntry.season` 계약이 "시작일에서 한 번만 정해 payload 에 싣는다" 인데
- # 아무도 안 실었다 — 그 결과 발행된 모든 사이트에서 계절 탭이 0개였다(군산·성남 모두
- # 20건 전부 빈 값). 화면은 계절이 있는 것만 탭으로 세우므로, 축제가 스무 건 있어도
- # "계절 없이 열리는 행사" 한 덩이로 쏟아졌다. `/s/stay` 시안에 4탭이 서 있는 건
- # 그 payload 의 계절을 손으로 넣었기 때문이다.
- # 렌더러에서 월을 계절로 되돌리지 않는다 — 계약 주석이 금지한 자리다(수집과 갈라진다).
entry["season"] = _SEASON_BY_MONTH[int(start[4:6])]
# 정렬·계절 산출의 근거를 기계가 읽는 형식으로도 남긴다(`period` 는 사람이 읽는 문구다).
entry["startDate"] = f"{start[:4]}-{start[4:6]}-{start[6:]}"
@@ -618,11 +344,10 @@ def _festival(row: dict):
description = _text(body.get("overview"))
if description:
entry["description"] = description
- # 공식 홈페이지는 출처가 준 값일 때만 싣는다. 형식이 URL 이 아니면 링크로 걸지 않는다.
+ # 공식 홈페이지는 출처가 준 값일 때만 싣는다.
if homepage.startswith("http://") or homepage.startswith("https://"):
entry["officialUrl"] = homepage
- # 업장 반경 캐시(place_contents)에서 온 축제는 거리·사진도 있다 — 카드 캐러셀이 맛집·명소와
- # 같은 모양으로 그리려면 필요하다(2026-09-07, 도보 시간 필터 형식 결정).
+ # 업장 반경 캐시(place_contents)에서 온 축제는 거리·사진도 있다 — 카드 캐러셀이 맛집·명소와 같은 모양으로 그리려면 필요하다.
_put_distance(entry, body)
image = _text(body.get("imageUrl"))
if image:
@@ -631,14 +356,7 @@ def _festival(row: dict):
def _local_place(row: dict, category: str):
- """LocalPlace.
-
- ★ 2026-09-09 부터 `body` 가 **이미 렌더러 모양**이다(`name`·`location`·`imageUrl`) —
- 수집 시점에 바꿔 넣는다(`external/tour_api._normalize`). 예전에는 TourAPI 원문 이름을
- 저장하고 빌드마다 여기서 바꿔 실었다. 같은 변환을 발행할 때마다 다시 하는 셈이었고,
- 캔버스와 발행본이 각자 바꾸면 갈릴 자리였다.
- 그래서 여기가 하는 일은 둘뿐이다 — 업종 라벨을 붙이고, 사이트별 거리를 표기로 바꾼다.
- """
+ """LocalPlace."""
body = row.get("body") or {}
name = _text(body.get("name")) or _text(row.get("title"))
if not name:
@@ -659,8 +377,7 @@ def _local_place(row: dict, category: str):
def _put_distance(entry: dict, body: dict) -> None:
"""distanceMeters → distanceText("850m") + distanceMeters(850). 값이 없거나 음수면 둘 다 넣지 않는다."""
- # ★ 원값은 사이트 개인화(site_sections.data.places[].distanceMeters)에서 온다 —
- # 공용 실체에는 거리가 없다(업장마다 다르다). 스냅샷이 그 값을 body 에 얹어 준다.
+ # 원값은 사이트 개인화(site_sections.data.places[].distanceMeters)에서 온다 — 공용 실체에는 거리가 없다(업장마다 다르다).
meters = body.get("distanceMeters")
distance = _distance_text(meters)
if not distance:
@@ -684,21 +401,9 @@ def _distance_text(meters) -> str:
def _local(snapshot_local: dict, base_lat: float | None, base_lng: float | None) -> tuple[dict, str | None]:
- """스냅샷의 지역 정보 → LocalContents.
-
- ★ 스냅샷이 이미 걸렀다(PUBLISHED + 노출 기간 안). 여기서 더 거르지 않고 모양만 바꾼다 —
- fact·사진과 같은 분업이다.
- ★ 예전에는 이 자리가 무조건 빈 배열이었다. local_contents 에 검수·발행된 지역 정보가 있어도
- payload 경계에서 통째로 버려져, 모든 발행 사이트의 지역 정보 섹션이 영구히 안 나왔다.
- ★ itineraries(1박2일·2박3일 각 5개)는 **스냅샷에서 읽는다.** 예전에는 이 자리에서
- 거리 기반으로 즉석 계산했다(services/itinerary.py) — 거리 계산은 공짜라 그게 맞았다.
- LLM 생성으로 바뀌면서 건당 20~50초·유료가 되어 표에 저장하고 그걸 읽는다
- (services/itinerary_llm_service · tmp/superpowers/specs/2026-09-11-llm-itinerary-design.md).
- ★ 여기서 DB 를 읽지 않는다. 읽는 곳은 services/snapshot._local_contents 하나다 —
- 이 함수가 순수해야 "스냅샷과 다른 페이지"가 생기지 않는다(파일 상단 원칙).
- ★ services/itinerary.py 는 지우지 않았다. 고도화해서 되살릴 때 이 블록을 되돌린다."""
+ """스냅샷의 지역 정보 → LocalContents."""
contents = (snapshot_local or {}).get("contents") or []
- # courses(여행코스)는 백엔드만 채운다 — 렌더러 타입에 아직 자리가 없어 화면은 무시한다(2026-09-07).
+ # courses(여행코스)는 백엔드만 채운다 — 렌더러 타입에 아직 자리가 없어 화면은 무시한다.
local = {"attractions": [], "restaurants": [], "festivals": [], "courses": []}
synced_at = None
@@ -707,7 +412,6 @@ def _local(snapshot_local: dict, base_lat: float | None, base_lng: float | None)
continue
collected_at = _text(row.get("collected_at"))
# syncedAt 은 화면에 "○○ 갱신"으로 그대로 노출된다 — 가장 최근 수집 시각을 쓴다.
- # ★ 오래된 정보를 숨기지 않는다(렌더러 타입 주석). 그래서 최신값이 아니라 '실제 최신 수집 시각'이다.
if collected_at and (synced_at is None or collected_at > synced_at):
synced_at = collected_at
@@ -734,10 +438,7 @@ def _local(snapshot_local: dict, base_lat: float | None, base_lng: float | None)
if entry:
local["courses"].append(entry)
elif content_type == LocalContentType.STORY.value:
- # ★ 지역 이야기는 **모양을 바꾸지 않는다.** body 가 이미 렌더러 계약
- # (`shared/lib/section-data.ts` 의 SongItem·PeopleItem…) 그대로다.
- # 여기서 키를 손대면 사장님이 손으로 붙여넣은 같은 종류의 JSON 과 모양이 갈린다 —
- # 화면은 둘을 한 배열로 이어 그린다.
+ # 지역 이야기는 **모양을 바꾸지 않는다.
kind = _text(row.get("kind"))
items = (row.get("body") or {}).get("items")
if kind and isinstance(items, list) and items:
@@ -755,22 +456,11 @@ def _local(snapshot_local: dict, base_lat: float | None, base_lng: float | None)
def _publish_target(site, place_id: str, name: str) -> dict:
- """발행 주소(origin/basePath/slug).
-
- ★ sites.domain 이 있으면 그게 사장님이 고른 주소다. 없으면 **임시값**이다 —
- 상호명은 유일하지 않으므로 place_id 앞자리를 붙여 사이트끼리 겹치지 않게 한다.
- 이 임시값이 SNS 같은 외부로 새면 되돌릴 수 없다 — SNS는 domain 확정을 요구한다.
-
- ★ **언제나 경로형**(`https:///s/`)이다. 서브도메인을 쓰지 않는 이유는
- 사이트가 하나 늘 때마다 DNS 레코드와 TLS 인증서를 새로 만들어야 해서다 —
- 발행 시점에 그걸 대신 만들어 줄 방법이 없으니 서브도메인 주소는 화면에만 있고
- 실제로는 열리지 않는다. shared/lib/slug.ts 의 publishUrl 과 **같은 규칙**이어야
- 화면이 보여준 주소와 발행본의 주소가 갈리지 않는다."""
+ """발행 주소(origin/basePath/slug)."""
domain = _text(_get(site, "domain"))
if domain:
- # domain 컬럼에는 slug 만 들어온다(site_slug 가 검증한 값). 옛 데이터가 호스트 형태로
- # 남아 있을 수 있어 첫 라벨만 취한다.
+ # domain 컬럼에는 slug 만 들어온다(site_slug 가 검증한 값).
slug = slugify(domain.split(".")[0]) or slugify(name) or place_id
else:
name_slug = slugify(name)
@@ -780,31 +470,22 @@ def _publish_target(site, place_id: str, name: str) -> dict:
def publish_slug(place, site) -> str:
- """이 사업장 사이트의 발행 슬러그.
-
- ★ 렌더 보고서(payloads/.status/.json)를 찾으려면 payload 를 만들 때와 **같은 규칙**으로
- 슬러그를 구해야 한다. 그래서 여기 한 곳에서만 계산하고 밖에서는 이 함수를 부른다 —
- 규칙을 두 군데 두면 보고서를 못 찾아 "아직 안 구워졌다"고 잘못 답하게 된다."""
+ """이 사업장 사이트의 발행 슬러그."""
return _publish_target(site, str(_get(place, "place_id") or ""), _text(_get(place, "name")))["slug"]
def publish_origin() -> str:
- """발행본이 사는 오리진. 썸네일 URL 도 여기서 나온다 —
- 호스트를 새 env 로 또 두면 canonical 과 갈릴 수 있다(CLAUDE.md '발행 호스트는 두 곳')."""
+ """발행본이 사는 오리진."""
return f"{_scheme(DEFAULT_HOST)}://{DEFAULT_HOST}"
def publish_url(place, site) -> str:
- """이 사업장 사이트의 전체 발행 주소. 미니 블로그·SNS 초안이 문구 끝에 붙이는 링크가
- 이 값과 갈리면 안 되므로 origin·slug 조합을 여기 한 곳에서만 한다."""
+ """이 사업장 사이트의 전체 발행 주소."""
return f"{publish_origin()}/s/{publish_slug(place, site)}"
def primary_media(snapshot: dict) -> dict | None:
- """대표 사진(og:image) — 객실·메뉴 전용이 아닌 첫 장. 없으면 None.
-
- ★ 썸네일도 이 함수를 쓴다. 규칙을 복제하면 검색 결과에 뜨는 그림과
- 쇼케이스 카드가 다른 사진이 되고, 그건 아무도 눈치채지 못한다."""
+ """대표 사진(og:image) — 객실·메뉴 전용이 아닌 첫 장."""
for row in (snapshot or {}).get("media") or []:
if not row.get("unit_id"):
return row
@@ -812,10 +493,7 @@ def primary_media(snapshot: dict) -> dict | None:
def region_label(*addresses: str | None) -> str | None:
- """"강원특별자치도 양양군" — 시·도 + 시·군·구까지만.
-
- ★ 상세 주소는 붙이지 않는다. 로그인 없이 읽히는 목록(쇼케이스)에 쓰이므로
- '어느 동네인지' 를 넘어서면 안 된다."""
+ """"강원특별자치도 양양군" — 시·도 + 시·군·구까지만."""
parts = _parse_address_parts(*addresses)
label = " ".join(p for p in (parts.get("addressRegion"), parts.get("addressLocality")) if p)
return label or None
@@ -823,14 +501,7 @@ def region_label(*addresses: str | None) -> str | None:
# ── payload 조립 ──────────────────────────────────────────────────────────
def to_site_payload(place, snapshot: dict, site, version, links, publish: bool = False) -> dict:
- """스냅샷 + 사이트/버전 행 + 채널 링크 → SitePayload(dict).
-
- 순수 변환 함수다. DB 도 파일도 건드리지 않는다 — 그래야 테스트가 쉽고,
- 같은 입력이면 언제나 같은 payload 가 나온다.
-
- ★ `publish` 는 렌더러(prerender.ts publishVersion)에게 "이 버전으로 공개 주소를
- 넘겨도 되는가"를 알리는 신호다. False(미리보기·게이트 통과 전 재빌드)면 렌더러가
- `out/versions///` 에만 굽고 `out/s/` 심볼릭 링크는 그대로 둔다."""
+ """스냅샷 + 사이트/버전 행 + 채널 링크 → SitePayload(dict)."""
snapshot = snapshot or {}
snap_place = snapshot.get("place") or {}
place_id = str(_get(place, "place_id"))
@@ -849,9 +520,6 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool =
unit_facts.setdefault(str(entry["unitId"]), []).append(entry)
# ── 사진 ─────────────────────────────────────────────
- # ★ 스냅샷이 이미 걸렀다(APPROVED + alt 있음). 여기서 더 거르지 않고 모양만 바꾼다.
- # ★ sourceType/originUrl 을 반드시 싣는다 — 크롤링 이미지 재게시 권리가 미결이라(DECISIONS 1-2)
- # 결론이 나면 출처로 걸러낼 수 있어야 한다. 출처를 버리면 그때 다시 수집해야 한다.
media = []
primary_row = primary_media(snapshot)
for index, row in enumerate(snapshot.get("media") or []):
@@ -865,8 +533,7 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool =
"width": row.get("width"),
"height": row.get("height"),
"isPrimary": is_primary,
- # 출처가 없는 건 옛 스냅샷뿐이다(media.source_type 은 NOT NULL). 그때는 CRAWL 로 본다 —
- # 재게시 권리가 결론 나면 걸러져야 할 쪽으로 기울이는 게 안전하다.
+ # 출처가 없는 건 옛 스냅샷뿐이다(media.source_type 은 NOT NULL).
"sourceType": row.get("source_type") or SourceType.CRAWL.value,
"originUrl": row.get("origin_url"),
"unitId": unit_id,
@@ -884,7 +551,7 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool =
units.append({
"unitId": unit_id,
"name": unit_name,
- # slug 가 URL 이 된다. 이름이 비거나 기호뿐이면 순번으로 떨어뜨린다(빈 경로를 만들지 않는다).
+ # slug 가 URL 이 된다.
"slug": slugify(unit_name) or f"unit-{index + 1}",
"sortOrder": int(row.get("sort_order") or index),
"facts": unit_facts.get(unit_id, []),
@@ -903,9 +570,7 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool =
"sortOrder": int(row.get("sort_order") or index),
})
- # ── 채널 링크 ────────────────────────────────────────
- # 확정(confirmed_at)되지 않은 링크도 실어 보낸다 — 렌더러가 confirmed 로 한 번 더 거른다.
- # 여기서 미리 빼면 "왜 안 나오는지"가 payload 만 봐서는 안 보인다.
+ # ── 채널 링크 ──────────────────────────────────────── 확정(confirmed_at)되지 않은 링크도 실어 보낸다 — 렌더러가 confirmed 로 한 번 더 거른다.
channel_links = []
for row in links or []:
channel = _get(row, "channel")
@@ -924,27 +589,17 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool =
})
# ── 소개문 ───────────────────────────────────────────
- # ★ 여기서 문장을 지어내지 않는다. intro fact(allow_llm=true 필드)에 있는 것만 옮긴다.
- # heroHeadline·tagline 은 저장되는 자리가 없어서 비운다 — 비면 렌더러가 상호명으로 대체한다.
intro = _text(next((f["value"] for f in place_facts if f["key"] == "intro" and f["value"]), ""))
paragraphs = [p.strip() for p in intro.split("\n") if p.strip()] if intro else []
narrative = {
"about": paragraphs,
- # ★ 요약은 **첫 문장**이다. 문단이 아니다.
- # 계약이 "요약 한 문장"이라 적어 뒀는데(shared/site-payload.ts) 첫 문단을 통째로
- # 넣고 있었다. 그 값은 두 곳으로 나간다 — 히어로 아래 한 줄과 meta description.
- # 문단이 들어가면 히어로가 세 문장을 이고 서고(실측 2026-09-10), meta description 은
- # 검색 결과에서 잘린다. 문장을 새로 생성하지는 않는다 — 있는 글의 첫 문장을 뗄 뿐이다.
+ # 요약은 **첫 문장**이다.
"summary": _first_sentence(paragraphs[0]) if paragraphs else None,
}
- theme_spec = _DEFAULT_THEME.get(category) or _DEFAULT_THEME[PlaceCategory.LODGING.value]
- theme = _theme(site, theme_spec)
+ theme = _theme(site, category)
# ── 지역 정보 ────────────────────────────────────────
- # ★ 스냅샷에서 읽는다 — 여기서 DB 를 다시 읽으면 '스냅샷과 다른 페이지'가 나온다(파일 상단 원칙).
- # 지역 정보를 스냅샷에 담는 필터링은 services/snapshot._local_contents 가 한다.
- # 옛 스냅샷에는 "local" 키가 없다. 그때는 빈 채로 나가고, 다음 빌드에서 채워진다.
local, local_synced_at = _local(
snapshot.get("local") or {},
_as_float(snap_place.get("latitude")), _as_float(snap_place.get("longitude")),
@@ -982,17 +637,13 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool =
"category": category,
"roadAddress": snap_place.get("road_address") or None,
"address": snap_place.get("address") or None,
- # 시·도 / 시·군·구 / 읍·면. PostalAddress 의 지역 필드와 title·geo 메타가 이걸 쓴다 —
- # 없으면 지역 질의("성남 소금빵")에 걸릴 자리를 통째로 버리게 된다.
+ # 시·도 / 시·군·구 / 읍·면.
**_parse_address_parts(snap_place.get("road_address"), snap_place.get("address")),
"phone": snap_place.get("phone") or None,
- # 스냅샷은 좌표를 문자열로 박제한다(Numeric 직렬화). 렌더러 타입은 number 라 여기서 되돌린다.
+ # 스냅샷은 좌표를 문자열로 박제한다(Numeric 직렬화).
"latitude": _as_float(snap_place.get("latitude")),
"longitude": _as_float(snap_place.get("longitude")),
- # ★ 스냅샷이 실제로 쓴 지역 키가 이긴다. places.region_code 가 비어 있어도
- # 스냅샷이 주소에서 유도했으면(services/snapshot._local_contents) 그 값이 여기 실려야
- # 렌더러의 실시간 날씨 조회가 산다 — use-live-weather 는 regionCode 없이는 fetch 하지 않고,
- # 그러면 날씨 섹션이 통째로 사라진다(에디터에는 보이는데 사이트에는 없는 그 자리).
+ # 스냅샷이 실제로 쓴 지역 키가 이긴다.
"regionCode": (
_text((snapshot.get("local") or {}).get("region_code"))
or _text(_get(place, "region_code"))
@@ -1006,15 +657,9 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool =
"faqs": faqs,
"links": channel_links,
"local": local,
- # ★ 가는 길(routes)은 비어 있다. local.routes 테이블에 행이 0이고, 그 테이블에 쓰는 코드 경로가
- # 아직 어디에도 없다(모델과 DDL 만 있고 수집기·입력 API 가 없다). 여기서 조회 배선을 만들어 봐야
- # 영원히 빈 결과를 도는 코드가 되고, 실제 수집기가 붙는 날 그 모양에 맞을지도 알 수 없다.
- # ★ 없는 것을 지어내지 않는다 — 틀린 경로 안내는 방문자에게 헛걸음을 만든다(모델 주석).
- # 렌더러는 비면 해당 섹션을 그리지 않는다.
+ # 가는 길(routes)은 비어 있다.
"routes": [],
- # ★ 이 숙소의 노래. `audioUrl` 은 **우리 쪽 경로**다 — Suno 가 준 주소는 만료되므로
- # 파일을 받아 두고(song_service) 프리렌더가 사이트 디렉토리로 복사한 것을 가리킨다.
- # 경로를 여기서 만드는 이유: 발행본의 주소 규칙(basePath + /s/)을 아는 곳이 여기다.
+ # 이 숙소의 노래.
"songs": [
{
"songId": row["song_id"],
@@ -1022,8 +667,7 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool =
"lyrics": row.get("lyrics") or None,
"style": row.get("style") or None,
"durationSec": row.get("duration_sec"),
- # 프리렌더가 복사해 놓을 자리. 파일명은 그대로 쓴다.
- # basePath 자체가 이미 `/s/`(또는 서브패스 마운트라면 그 앞에 접두어)다.
+ # 프리렌더가 복사해 놓을 자리.
"audioUrl": f"{target['basePath']}/{row['file_name']}",
# 프리렌더가 원본을 찾을 때 쓰는 이름(솔루션 밖으로는 안 나간다).
"fileName": row["file_name"],
@@ -1031,8 +675,7 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool =
for row in (snapshot.get("songs") or [])
if (row.get("file_name") or "").strip()
],
- # 미니 블로그 — 숙소 소개 아래 게시판(docs/MINI_BLOG.md). 글 전부가 HTML 에 들어가고
- # 화면은 JS 로 나눠 보여준다. 페이지를 눌러 더 불러오면 크롤러가 2페이지를 못 본다.
+ # 미니 블로그 — 숙소 소개 아래 게시판(docs/MINI_BLOG.md).
"posts": [
{
"postId": row["post_id"],
@@ -1042,7 +685,7 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool =
for row in (snapshot.get("posts") or [])
if (row.get("body") or "").strip()
],
- # 이용 후기 — 별점 없이 글만(2026-09-16 회의). JSON-LD 로는 내보내지 않는다.
+ # 이용 후기 — 별점 없이 글만.
"reviews": [
{
"reviewId": row["review_id"],
@@ -1058,17 +701,13 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool =
for r in snapshot.get("social_posts", [])],
"narrative": narrative,
"theme": theme,
- # ★ 검색 키워드(SiteOntology). 스냅샷에 있을 때만 싣는다 — 옛 스냅샷·SiteOntology 가 꺼진 빌드에는 없고,
- # 그때 렌더러는 제목·메타를 예전 그대로 굽는다(solution/site/src/seo/meta.ts).
+ # 검색 키워드(SiteOntology).
**_seo_entry(snapshot.get("seo")),
}
def _seo_entry(value) -> dict:
- """스냅샷의 seo(services/seo_keywords) → payload 의 `seo`. 없으면 키 자체를 만들지 않는다.
-
- ★ 여기서 다시 거르지 않는다 — 이 가게 자료로 거르는 곳은 seo_keywords 한 곳이다. 모양만 확인한다.
- ★ 빈 seo 를 만들지 않는다. 빈 배열은 '받았는데 비었다' 로 읽힌다(itineraries 와 같은 규칙)."""
+ """스냅샷의 seo(services/seo_keywords) → payload 의 `seo`."""
if not isinstance(value, dict):
return {}
keywords = [_text(k) for k in value.get("keywords") or [] if _text(k)]
@@ -1092,10 +731,7 @@ def payload_dir() -> Path:
def write_payload(payload: dict) -> str:
- """payload 를 `.json` 으로 쓴다. 경로를 돌려준다.
-
- ★ 임시파일에 쓰고 rename 한다 — 렌더러가 디렉토리를 통째로 읽는 구조라
- 반쯤 쓰인 JSON 을 집어 빌드가 깨지는 일이 없어야 한다(rename 은 같은 파일시스템에서 원자적)."""
+ """payload 를 `.json` 으로 쓴다."""
directory = payload_dir()
directory.mkdir(parents=True, exist_ok=True)
@@ -1127,11 +763,7 @@ async def prepare_site_payload(place, snapshot: dict, site, version, links, publ
async def emit_payload(place, snapshot: dict, site, version, links, publish: bool = False) -> str | None:
- """payload 조립 + 파일 쓰기. 실패해도 예외를 밖으로 내보내지 않는다.
-
- ★ 발행 자체를 실패시키면 안 된다 — 게이트를 통과해 DB 에 남은 발행 기록은 이미 정확하고,
- payload 는 그것을 화면으로 옮기는 부수 산출물이다. 디스크가 없거나 권한이 없어서
- 발행이 되돌려지는 게 더 나쁘다. 대신 경고 로그로 반드시 드러낸다."""
+ """payload 조립 + 파일 쓰기."""
try:
payload = await prepare_site_payload(place, snapshot, site, version, links, publish)
path = write_payload(payload)
diff --git a/solution/backend/services/site_service.py b/solution/backend/services/site_service.py
index 213e328..cabcb95 100644
--- a/solution/backend/services/site_service.py
+++ b/solution/backend/services/site_service.py
@@ -16,6 +16,7 @@ from common.enums import (
SiteStatus,
)
from common.logger import LOG
+from common.template_catalog import is_allowed
from common.models.gmodel import PageParams, UserInfo
from common.utils.gtime import GTime
from crud.job_crud import JobQueue
@@ -45,7 +46,7 @@ from router.v1.site.protocol import (
from services import render_report, site_payload, site_slug
from services.job_service import enqueue_job
-# 주소 저장을 거부할 때 쓰는 결과 코드. 사유(reason)는 응답에 따로 실어 프론트가 문구를 고르게 한다.
+# 주소 저장을 거부할 때 쓰는 결과 코드.
_ERROR_BY_SLUG_REASON = {
site_slug.REASON_LENGTH: ErrorType.INVALID_REQUEST_DATA,
site_slug.REASON_FORMAT: ErrorType.INVALID_REQUEST_DATA,
@@ -54,27 +55,13 @@ _ERROR_BY_SLUG_REASON = {
site_slug.REASON_LOCKED: ErrorType.SITE_SLUG_LOCKED,
}
-# 템플릿 키 길이 상한. sites.template_id 가 varchar(100) 이라 여기서 막지 않으면 DB 가 트랜잭션째로 튕긴다.
-# ★ 값 자체(무슨 템플릿인가)는 검증하지 않는다 — 목록은 프론트가 소유한다(protocol.Req_SiteTemplate 주석).
-_TEMPLATE_ID_MAX = 100
-
# 테마 JSON 직렬화 크기 상한(64KB).
-# ★ 왜 상한만 두는가: 테마의 내용(섹션 id·배리에이션 키·색 토큰)은 프론트가 소유하므로 서버가
-# 해석하지 않는다. 해석하지 않는다는 건 무엇이든 들어올 수 있다는 뜻이라, 크기까지 놓으면
-# jsonb 컬럼 하나가 DB 를 부풀리고 그대로 site_versions.snapshot 을 타고 빌드까지 번진다.
-# ★ 왜 64KB 인가: 섹션은 업종당 10개 남짓이고 한 섹션이 200바이트를 넘기 어렵다 — 실제 테마는 3KB 안쪽이다.
-# 64KB 면 정상 값의 20배쯤 되는 여유라 프론트가 항목을 늘려도 걸리지 않고,
-# 그걸 넘는 건 '디자인 설정'이 아니라 다른 것이 흘러든 것이다.
-# ★ 필드 개수가 아니라 직렬화 바이트로 잰다. 개수로 재면 값 하나가 긴 경우를 못 막는다.
_THEME_MAX_BYTES = 64 * 1024
-# ★ theme 안에 들어와도 저장하지 않는 키. sites.template_id 컬럼이 소유하는 값이라
-# theme 에 같이 담기면 어느 쪽이 진짜인지 갈리고, payload 가 읽는 쪽과 화면이 보는 쪽이 어긋난다.
-# 화이트리스트가 아니라 '이 한 개만 뺀다'는 블랙리스트다 — 나머지는 전부 그대로 보관한다.
+# theme 안에 들어와도 저장하지 않는 키.
_THEME_OWNED_ELSEWHERE = ("templateId", "template_id")
-# '거부' 표식. None 은 '빈 값 → NULL 로 되돌린다'는 정상 결과라서, 거부를 None 으로 표현하면
-# 잘못된 요청이 조용히 '디자인 초기화'로 처리된다 — 둘을 반드시 구분한다.
+# '거부' 표식.
_THEME_REJECTED = object()
# 상태 전이만으로 처리하는 액션 — ★ 물리 삭제 경로는 만들지 않는다.
@@ -86,10 +73,7 @@ _STATUS_BY_ACTION = {
class SiteService:
- """사이트 조회 · 빌드 트리거 · 발행 상태 전이.
-
- 빌드와 발행 판정은 BUILD 잡(services/build_service)이 한다 — 여기는 조회와 상태 전이만.
- """
+ """사이트 조회 · 빌드 트리거 · 발행 상태 전이."""
def __init__(
self,
@@ -141,10 +125,7 @@ class SiteService:
if v_err != ErrorType.SUCCESS:
version = None
- # ★ AI 노출 점검 이력은 읽지 않는다. `place_ai_checks` 는 한 번도 쓰지 않아
- # 마이그레이션 0006 이 뗐다(JobType.AI_CHECK 도 아직 미배선이다).
- # 여기서 그 표를 계속 부르면 SEO 진단이 통째로 죽는다 — 지금 그랬다.
- # reports 모듈이 붙는 날 표와 함께 되살린다.
+ # AI 노출 점검 이력은 읽지 않는다.
report = evaluate(
await build_snapshot(place), verified=place.verified_at is not None,
site=site, version=version, ai_checks=[],
@@ -152,21 +133,7 @@ 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.prepare_site_payload`)를 그대로 거치므로,
- 여기서 갈릴 자리가 없다. 버전은 아직 없으니 0 으로 넘긴다 — 화면에 안 쓰인다.
- """
+ """에디터 미리보기가 쓰는 **발행본과 똑같은 payload**."""
from services.build_service import ensure_site, load_channel_links
from services.site_payload import prepare_site_payload
from services.snapshot import build_snapshot
@@ -178,12 +145,10 @@ class SiteService:
site = await ensure_site(place_id)
links = await load_channel_links(place_id)
snapshot = await build_snapshot(place)
- # ★ version 은 None 이다. 발행 전이라 버전 행이 없고, payload 의 site.version 은
- # 캐시 무효화 키라 미리보기에서는 뜻이 없다(to_site_payload 가 0 으로 떨어뜨린다).
+ # version 은 None 이다.
return await prepare_site_payload(place, snapshot, site, None, links)
- # ---- 사이트 주소(네임스페이스) ---------------------------------------
- # 규칙(정규식·예약어)은 services/site_slug 한 곳에만 있다. 확인과 저장이 그것을 같이 쓴다.
+ # ---- 사이트 주소(네임스페이스) --------------------------------------- 규칙(정규식·예약어)은 services/site_slug 한 곳에만 있다.
async def _domain_owner(self, slug: str):
"""이 주소를 이미 쓰는 사업장 id. 아무도 안 쓰면 None."""
@@ -207,36 +172,26 @@ class SiteService:
return next((c for c in candidates if c not in taken), None)
async def _judge_slug(self, place_id: str, slug: str):
- """(불가 사유, 대안)을 돌려준다. 쓸 수 있으면 (None, None).
-
- ★ 확인(check)과 저장(POST)이 이 하나를 같이 쓴다 — 판정이 갈리면
- "된다고 해놓고 저장에서 튕기는" 화면이 나온다."""
+ """(불가 사유, 대안)을 돌려준다."""
reason = site_slug.validate_slug(slug)
if reason in (site_slug.REASON_LENGTH, site_slug.REASON_FORMAT):
- # 형식이 깨진 값에 -2 를 붙여 봐야 여전히 못 쓴다. 사유만 돌려준다.
+ # 형식이 깨진 값에 -2 를 붙여 봐야 여전히 못 쓴다.
return reason, None
value = (slug or "").strip()
- # ★ 주인 확인이 예약어보다 먼저다. 예약어 목록은 나중에 늘어나는데(목업 슬러그를 막는
- # 것처럼), 늘린 이름을 이미 쓰고 있던 사장님이 자기 편집 화면을 여는 순간
- # "이 주소는 못 씁니다" 가 뜬다 — 바꿀 수도 없는 주소다(발행 뒤 SITE_SLUG_LOCKED).
+ # 주인 확인이 예약어보다 먼저다.
owner = await self._domain_owner(value)
if owner is not None and owner == str(uuid.UUID(place_id)):
return None, None
if reason is None:
- # ★ 이미 자기 주소면 쓸 수 있다 — 저장해 둔 화면을 다시 열었을 때 '중복'이라고 하면 안 된다.
+ # 이미 자기 주소면 쓸 수 있다 — 저장해 둔 화면을 다시 열었을 때 '중복'이라고 하면 안 된다.
if owner is None:
return None, None
reason = site_slug.REASON_TAKEN
return reason, await self._suggest(value)
async def check_slug(self, user_info: UserInfo, place_id: str, slug: str) -> Res_SlugCheck:
- """주소를 쓸 수 있는지 미리 본다.
-
- ★ 서버가 상호명으로 자동 확정하지 않는다 — 사람이 고르고, 겹치면 고르기 전에 알려 준다.
- ★ 확인과 저장은 같은 판정을 해야 한다. 발행 잠금(SITE_SLUG_LOCKED)을 여기서 안 보면
- "쓸 수 있습니다" 라고 답해 놓고 저장에서 1707 로 튕긴다 — 지금은 발행 모달이
- 입력칸을 잠가 가려져 있을 뿐이고, API 를 직접 쓰는 쪽에는 그대로 드러난다."""
+ """주소를 쓸 수 있는지 미리 본다."""
res = Res_SlugCheck()
err_type, _place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS:
@@ -272,10 +227,7 @@ class SiteService:
value = (req.slug or "").strip()
site = await self._get_site(place_id)
- # ★ 이미 발행된 사이트의 주소는 바꾸지 않는다.
- # 색인된 주소가 바뀌면 AI 검색이 잡아 둔 페이지가 404 가 되고, 그 자리를 다시 OTA 가 채운다 —
- # '해지는 상태 전이지 삭제가 아니다' 와 정확히 같은 이유다.
- # 한 번이라도 발행된 적 있으면(published_at) 중지·내림 상태여도 같다. 색인은 그 주소로 남아 있다.
+ # 이미 발행된 사이트의 주소는 바꾸지 않는다.
if site is not None and (site.published_at is not None or site.status == SiteStatus.PUBLISHED.value):
if (site.domain or "") != value:
res.result.SetResult(ErrorType.SITE_SLUG_LOCKED)
@@ -285,7 +237,7 @@ class SiteService:
res.site = SiteData.model_validate(site)
return res
- # ★ 클라이언트 검증을 믿지 않는다. check 와 같은 규칙으로 서버가 다시 본다.
+ # 클라이언트 검증을 믿지 않는다.
reason, suggestion = await self._judge_slug(place_id, value)
if reason is not None:
res.result.SetResult(_ERROR_BY_SLUG_REASON.get(reason, ErrorType.INVALID_REQUEST_DATA))
@@ -320,9 +272,7 @@ class SiteService:
# ---- 템플릿(디자인) ---------------------------------------------------
async def _mark_content_updated(self, place_id: str, ts):
- """★ 발행본과 달라졌다 — 이 사업장만 다시 빌드하면 된다는 표시.
- fact/faq 가 노출값이 바뀔 때 찍는 것과 같은 자리를 같은 이유로 쓴다(services/faq_service).
- 부가 효과라 실패해도 본 흐름을 막지 않는다(다음 변경 때 다시 찍힌다)."""
+ """발행본과 달라졌다 — 이 사업장만 다시 빌드하면 된다는 표시."""
err = await DB_SESSION_MNG.execute_lambda_run([places.DBType()], [lambda s: self._touch(s, place_id, ts)])
if err != ErrorType.SUCCESS:
LOG.e_no_callstack(f"[site] content_updated_at 갱신 실패 place={place_id}")
@@ -334,12 +284,7 @@ class SiteService:
return await DB_SESSION_MNG.add(s, query)
async def set_template(self, user_info: UserInfo, place_id: str, req: Req_SiteTemplate) -> Res_Site:
- """사장님이 고른 템플릿을 sites.template_id 에 저장한다. 사이트 행이 없으면 만든다.
-
- ★ 왜 서버에 저장하나: 위저드가 고른 값이 브라우저에만 남으면 발행 잡이 읽을 곳이 없어
- 업종 기본 템플릿으로 굽는다 — 고른 디자인과 실제 발행본이 갈린다.
- ★ 슬러그와 달리 발행 뒤에도 바꿀 수 있다. 주소는 AI 검색이 색인한 영구 식별자라 잠그지만,
- 디자인은 바뀌어도 URL 이 그대로다(색인이 깨지지 않는다)."""
+ """사장님이 고른 템플릿을 sites.template_id에 저장한다."""
# 사이트 생성은 빌드와 같은 경로를 쓴다(사업장당 1개 보장) — 지역 import 로 빌드 스택을 웹에 얹지 않는다.
from services.build_service import BuildAborted, ensure_site
@@ -350,8 +295,7 @@ class SiteService:
return res
value = (req.template_id or "").strip()
- # 길이만 본다. 모르는 키가 들어와도 발행 잡이 업종 기본으로 떨어뜨리므로 화면이 깨지지 않는다.
- if len(value) > _TEMPLATE_ID_MAX:
+ if value and not is_allowed(int(_place.category), value):
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
return res
@@ -366,8 +310,7 @@ class SiteService:
return res
before = site.template_id or None
- # ★ 빈 문자열은 '고르지 않음'이다 — NULL 로 되돌려 업종 기본 템플릿으로 떨어뜨린다.
- # 빈 문자열을 그대로 두면 payload 가 '있는데 이름이 없는 템플릿'을 만나 기본값으로도 못 간다.
+ # 빈 문자열은 '고르지 않음'이다 — NULL 로 되돌려 업종 기본 템플릿으로 떨어뜨린다.
after = value or None
u_err, _rowcount = await DB_SESSION_MNG.execute_lambda_claim(
sites.DBType(), lambda s: self.crud.update_site(s, site.site_id, {"template_id": after})
@@ -376,8 +319,7 @@ class SiteService:
res.result.SetResult(u_err)
return res
- # 한 번이라도 구워진 사이트에서 디자인이 바뀌었다면 지금 나가 있는 페이지와 달라졌다 —
- # 재빌드 대상으로 표시한다. 값이 그대로면 찍지 않는다(재전송이 재빌드를 만들지 않게).
+ # 한 번이라도 구워진 사이트에서 디자인이 바뀌었다면 지금 나가 있는 페이지와 달라졌다 — 재빌드 대상으로 표시한다.
if before != after and (site.published_at is not None or site.current_version_id is not None):
await self._mark_content_updated(place_id, GTime.UTC())
@@ -386,17 +328,7 @@ class SiteService:
# ---- 테마(색·서체·섹션) -----------------------------------------------
async def set_theme(self, user_info: UserInfo, place_id: str, req: Req_SiteTheme) -> Res_Site:
- """에디터가 정한 디자인을 sites.theme 에 저장한다. 사이트 행이 없으면 만든다.
-
- ★ 왜 서버에 저장하나: template_id 와 정확히 같은 이유다. 섹션 on/off·순서·배리에이션·색·서체가
- 브라우저 메모리에만 있으면 새로고침에 사라지고, 발행 잡은 읽을 곳이 없어 업종 기본 모양을 굽는다 —
- 사장님이 섹션을 끄고 순서를 바꿔도 발행본은 언제나 업종 기본으로 나갔다.
-
- ★ 값은 해석하지 않는다. 섹션 목록·배리에이션 키·색 토큰 이름은 프론트가 소유하므로
- 검증하면 프론트에 항목이 하나 늘 때마다 백엔드를 같이 고쳐야 한다(protocol.Req_SiteTheme).
- 막는 것은 크기 하나뿐이다.
-
- ★ 템플릿과 마찬가지로 발행 뒤에도 바꿀 수 있다 — 디자인이 바뀌어도 URL 은 그대로라 색인이 안 깨진다."""
+ """에디터가 정한 디자인을 sites.theme에 저장한다."""
# 사이트 생성은 빌드와 같은 경로를 쓴다(사업장당 1개 보장) — 지역 import 로 빌드 스택을 웹에 얹지 않는다.
from services.build_service import BuildAborted, ensure_site
@@ -429,8 +361,7 @@ class SiteService:
res.result.SetResult(u_err)
return res
- # 한 번이라도 구워진 사이트에서 디자인이 바뀌었다면 지금 나가 있는 페이지와 달라졌다 —
- # 재빌드 대상으로 표시한다. 값이 그대로면 찍지 않는다(재전송이 재빌드를 만들지 않게).
+ # 한 번이라도 구워진 사이트에서 디자인이 바뀌었다면 지금 나가 있는 페이지와 달라졌다 — 재빌드 대상으로 표시한다.
if before != after and (site.published_at is not None or site.current_version_id is not None):
await self._mark_content_updated(place_id, GTime.UTC())
@@ -438,14 +369,7 @@ class SiteService:
@staticmethod
def _clean_theme(theme):
- """저장할 theme 값을 만든다. 거부해야 하면 _THEME_REJECTED 를 돌려준다.
-
- ★ 하는 일이 셋뿐인 게 의도다 — 서버는 이 값을 해석하지 않는다.
- 1) 빈 값이면 NULL 로 되돌린다. 빈 dict 를 그대로 두면 payload 가 '있는데 아무것도 없는 테마'를
- 만나 업종 기본으로도 못 간다(template_id 의 빈 문자열과 같은 함정이다).
- 2) templateId 는 뺀다 — sites.template_id 컬럼이 소유하는 값이다(중복 보관 금지).
- 3) 직렬화 크기 상한을 넘으면 거부한다.
- 그 외의 키(colorPaletteId 처럼 에디터 복원 전용인 것 포함)는 전부 그대로 보관한다."""
+ """저장할 theme 값을 만든다."""
if not isinstance(theme, dict) or not theme:
return None
@@ -456,17 +380,13 @@ class SiteService:
try:
size = len(json.dumps(cleaned, ensure_ascii=False).encode("utf-8"))
except (TypeError, ValueError):
- # jsonb 컬럼에 넣을 수 없는 값이 섞였다는 뜻이다. DB 가 트랜잭션째로 튕기기 전에 여기서 막는다.
return _THEME_REJECTED
if size > _THEME_MAX_BYTES:
return _THEME_REJECTED
return cleaned
async def list_my_sites(self, user_info: UserInfo, pg: PageParams) -> Res_MySites:
- """로그인한 사장님이 가진 사이트 전부.
-
- 사업장 목록(/v1/place/list)과 따로 두는 이유: 화면이 알아야 하는 건 '사업장이 있다'가 아니라
- '발행돼 있나 · 주소가 뭔가 · 다시 구워야 하나'다."""
+ """로그인한 사장님이 가진 사이트 전부."""
res = Res_MySites(page=pg.page, size=pg.size)
uid = uuid.UUID(user_info.user_id)
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
@@ -486,13 +406,9 @@ class SiteService:
@staticmethod
def _my_site_row(place, site, built_at, primary_photo_url) -> MySiteData:
- # ★ 재빌드 판별은 단건(get_site)과 같은 규칙이어야 한다 — 다르면 목록과 에디터가 다른 답을 한다.
+ # 재빌드 판별은 단건(get_site)과 같은 규칙이어야 한다 — 다르면 목록과 에디터가 다른 답을 한다.
changed = place.content_updated_at
- # ★ sites.thumbnail_url 은 발행할 때 Azure 에 대표 사진을 재호스팅해야 채워진다
- # (services/site_thumbnail.store) — 저장소가 안 꺼져 있으면(로컬 개발 등) 늘 비어 있다.
- # 그래도 "한 번이라도 발행한 줄은 그림"이라는 화면 규칙은 지켜야 하므로, 빌더가 이미
- # 쓰고 있는 대표 사진(place_photos, primary_photo_url)으로 대신 채운다 — 발행 전 줄에는
- # 쓰지 않는다(그 규칙은 published_at 유무로 가른다: crud.site_crud.list_owner_sites).
+ # sites.thumbnail_url 은 발행할 때 Azure 에 대표 사진을 재호스팅해야 채워진다 (services/site_thumbnail.store) — 저장소가 안 꺼져 있으면(로컬 개발 등) 늘 비어 있다.
ever_published = site is not None and getattr(site, "published_at", None) is not None
thumbnail_url = getattr(site, "thumbnail_url", None) or (primary_photo_url if ever_published else None)
return MySiteData(
@@ -535,7 +451,7 @@ class SiteService:
)
if v_err == ErrorType.SUCCESS and version is not None:
res.current_version = SiteVersionData.model_validate(version)
- # ★ 개별 재빌드 판별: 노출값이 바뀐 시각이 마지막 빌드보다 나중이면 다시 빌드해야 한다.
+ # 개별 재빌드 판별: 노출값이 바뀐 시각이 마지막 빌드보다 나중이면 다시 빌드해야 한다.
built = version.built_at
changed = place.content_updated_at
res.needs_rebuild = bool(changed and (built is None or changed > built))
@@ -547,12 +463,8 @@ class SiteService:
@staticmethod
def _render_status(place, site, version) -> RenderStatusData:
- """정적 페이지가 실제로 구워졌는지 + 두 렌더러가 어긋나지 않았는지.
-
- ★ 빌드(DB)와 렌더(정적 파일)는 다른 단계다. 빌드가 BUILT 라고 페이지가 있는 게 아니다.
- 프리렌더가 남긴 보고서를 읽어 그 간극을 드러낸다.
- ★ 버전이 맞는 보고서만 OK 로 본다 — 낡은 렌더를 현재 것처럼 답하면 안 된다."""
- # ★ payload 를 쓸 때와 같은 규칙으로 슬러그를 구한다(site_payload 가 소유).
+ """정적 페이지가 실제로 구워졌는지 + 두 렌더러가 어긋나지 않았는지."""
+ # payload 를 쓸 때와 같은 규칙으로 슬러그를 구한다(site_payload 가 소유).
slug = site_payload.publish_slug(place, site)
if not slug:
return RenderStatusData()
@@ -577,25 +489,14 @@ class SiteService:
res.result.SetResult(ErrorType.PLACE_NOT_VERIFIED)
return res
- # ★ publish=False 는 **이미 나가 있는 사이트를 다시 굽는** 경로다. 한 번도 발행한 적
- # 없는 사업장에는 쓸 수 없다.
- # 왜 막나 — 빌드는 게이트를 판정하려고 payload 를 디스크에 쓰고, 프리렌더는 거기
- # 생긴 것을 곧바로 공개 페이지로 굽는다(sitemap · llms.txt · /s 목록 포함).
- # 그래서 publish=False 인데도 페이지가 공개됐고, DB 는 DRAFT·published_at=NULL 이라
- # 빌더 화면은 "발행 전" 으로 보였다. 2차 게이트(고유 콘텐츠 · JSON-LD)는 구운 결과를
- # 봐야 판정하므로 **거부돼도 페이지는 이미 나가 있었다**(실측 2026-09-15).
- # 발행 전에 보고 싶은 것은 `GET /site/preview` 가 준다 — 그쪽은 디스크를 건드리지 않는다.
+ # publish=False 는 **이미 나가 있는 사이트를 다시 굽는** 경로다.
site = await self._get_site(place_id)
if not req.publish:
if site is None or site.published_at is None:
res.result.SetResult(ErrorType.SITE_VERSION_NOT_FOUND)
return res
- # ★ 주소를 안 골랐어도 막지 않는다. 그때 쓰는 임시 슬러그(`slugify(상호명)-place_id[:8]`)에
- # 한글이 남는 것은 실수가 아니라 결정이다 — 음차하면 같은 가게가 두 주소를 갖는다
- # (`site_payload.slugify` 주석). 사람이 고르는 주소만 영문으로 제한한다.
- # 발행 모달은 주소를 먼저 받게 되어 있고(PublishModal), API 로 건너뛰면 임시 주소로
- # 나간 뒤 SITE_SLUG_LOCKED 로 잠긴다는 점은 그대로다.
+ # 주소를 안 골랐어도 막지 않는다.
job_id, created = await enqueue_job(
self.queue, JobType.BUILD,
@@ -609,21 +510,14 @@ class SiteService:
res.result.SetResult(ErrorType.COLLECT_ALREADY_RUNNING)
return res
- # ★ 노래는 여기서 따로 걸지 않는다. BUILD 잡이 스냅샷을 뜨기 **전에** 직접 만든다
- # (`build_service.run_build` → `song_service.ensure_song`) — 발행이 노래를 기다린다.
- # 따로 걸면 먼저 구워지고 노래가 몇 분 뒤 붙는데, 그러면 사장님이 [사이트 열기] 로
- # 보는 첫 화면에 그 기능이 빠져 있다(2026-09-11 결정).
+ # 노래는 여기서 따로 걸지 않는다.
res.job_id = uuid.UUID(job_id)
res.status = JobStatus.PENDING
res.created = created
return res
async def start_rollback(self, user_info: UserInfo, place_id: str, req: Req_Rollback) -> Res_StartBuild:
- """예전 버전으로 공개 주소를 되돌리는 잡을 큐에 넣는다.
-
- ★ dedupe_key 를 빌드와 **같은 것**(`build:{place_id}`)을 쓴다 — 롤백 도중에 새 발행이
- 끼어들거나 그 반대가 되면 어느 쪽이 이겼는지 알 수 없는 상태가 된다. 이 사이트의
- 공개 주소를 바꾸는 작업은 항상 하나만 활성화된다."""
+ """dedupe_key 를 빌드와 **같은 것**(`build:{place_id}`)을 쓴다 — 롤백 도중에 새 발행이 끼어들거나 그 반대가 되면 어느 쪽이 이겼는지 알 수 없는 상태가 된다."""
res = Res_StartBuild()
err_type, _place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS:
@@ -699,10 +593,7 @@ class SiteService:
return res
async def change_status(self, user_info: UserInfo, place_id: str, req: Req_SiteStatus) -> Res_Site:
- """발행 상태를 전이한다(중지·재개·내림).
-
- ★ 해지는 물리 삭제가 아니다. 색인된 페이지를 갑자기 404 로 만들면
- AI 검색이 그 자리를 다시 OTA 로 채운다 — 이 서비스가 하려던 것의 정반대가 된다."""
+ """발행 상태를 전이한다(중지·재개·내림)."""
res = Res_Site()
err_type, _place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS:
diff --git a/solution/backend/tests/test_my_sites.py b/solution/backend/tests/test_my_sites.py
index 3abae3d..62c6af4 100644
--- a/solution/backend/tests/test_my_sites.py
+++ b/solution/backend/tests/test_my_sites.py
@@ -43,12 +43,12 @@ async def test_site_row_is_joined_into_the_line(auth_headers, client):
기대결과: 템플릿·주소가 목록에 그대로 보인다."""
h = await auth_headers("my2")
pid = await _place(client, h, "합쳐진펜션")
- await client.post(f"/v1/place/{pid}/site/template", headers=h, json={"template_id": "stay-quiet-margin"})
+ await client.post(f"/v1/place/{pid}/site/template", headers=h, json={"template_id": "paper"})
await client.post(f"/v1/place/{pid}/site/slug", headers=h, json={"slug": "joined-stay"})
row = (await _list(client, h))["sites"][0]
assert row["site_id"]
- assert row["template_id"] == "stay-quiet-margin"
+ assert row["template_id"] == "paper"
assert row["domain"] == "joined-stay"
assert row["status"] == SiteStatus.DRAFT.value
@@ -107,7 +107,7 @@ async def test_needs_rebuild_matches_the_single_site_answer(auth_headers, client
h = await auth_headers("my4")
pid = await _place(client, h, "고친펜션")
# 템플릿 저장이 사이트 행을 만든다. 그 뒤 노출값이 바뀐 것으로 표시한다.
- await client.post(f"/v1/place/{pid}/site/template", headers=h, json={"template_id": "t"})
+ await client.post(f"/v1/place/{pid}/site/template", headers=h, json={"template_id": "paper"})
async with db_engine.begin() as conn:
await conn.execute(
text("UPDATE places SET content_updated_at = now() WHERE place_id = :pid"),
diff --git a/solution/backend/tests/test_site_template.py b/solution/backend/tests/test_site_template.py
index fa643e8..d7c7849 100644
--- a/solution/backend/tests/test_site_template.py
+++ b/solution/backend/tests/test_site_template.py
@@ -1,22 +1,21 @@
-"""템플릿(디자인) 선택 저장.
+"""템플릿 선택 저장.
이 경로가 절대 하면 안 되는 것:
- - 고른 템플릿을 브라우저에만 두는 것 — 발행 잡이 읽을 곳이 없어 업종 기본으로 굽는다.
- 사장님이 고른 디자인과 실제 발행본이 갈리는 것이 이 API 가 생긴 이유다.
- - ★ 발행됐다고 템플릿을 잠그는 것 — 주소(slug)와 달리 디자인은 바뀌어도 URL 이 그대로다.
- 잠글 이유가 없는 것을 잠그면 사장님이 발행 후에 디자인을 못 바꾼다.
- - 바꿔놓고 재빌드 표시를 안 하는 것 — 관리 화면은 새 디자인, 나가 있는 페이지는 옛 디자인이 된다.
+ - 업종 허용 목록(templates.json)에 없는 템플릿을 저장하는 것.
+ - 발행됐다고 템플릿을 잠그는 것 — 디자인은 바뀌어도 URL이 그대로다.
+ - 바꿔 놓고 재빌드 표시를 안 하는 것.
"""
import uuid
from sqlalchemy import text
from common.enums import ErrorType, PlaceCategory, SiteStatus
-from services.site_payload import _DEFAULT_THEME, to_site_payload
+from common.template_catalog import INDUSTRIES
+from services.site_payload import to_site_payload
-async def _place(client, headers, name="템플릿펜션"):
- r = await client.post("/v1/place", headers=headers, json={"name": name, "category": 1})
+async def _place(client, headers, name="템플릿펜션", category=1):
+ r = await client.post("/v1/place", headers=headers, json={"name": name, "category": category})
return r.json()["place"]["place_id"]
@@ -30,97 +29,82 @@ async def _get_site(client, headers, pid):
async def test_set_template_creates_site_row(auth_headers, client):
- """검증: 사이트 행이 없어도 템플릿을 먼저 고를 수 있다(고르는 건 발행보다 앞선 결정이다).
- 기대결과: SUCCESS + 저장된 값이 조회에도 그대로 나온다."""
+ """검증: 사이트 행이 없어도 템플릿을 먼저 고를 수 있고, 조회에도 그대로 나온다."""
h = await auth_headers("tpl1")
pid = await _place(client, h)
- saved = await _set_template(client, h, pid, "stay-quiet-margin")
+ saved = await _set_template(client, h, pid, "paper")
assert saved["result"]["code"] == ErrorType.SUCCESS.value
- assert saved["site"]["template_id"] == "stay-quiet-margin"
-
- # 화면이 "지금 어느 템플릿으로 나가는지"를 조회로 다시 읽을 수 있어야 한다.
- assert (await _get_site(client, h, pid))["site"]["template_id"] == "stay-quiet-margin"
+ assert saved["site"]["template_id"] == "paper"
+ assert (await _get_site(client, h, pid))["site"]["template_id"] == "paper"
-async def test_unknown_template_key_is_accepted(auth_headers, client):
- """검증: ★ 서버는 값을 검증하지 않는다 — 템플릿 목록은 프론트가 소유한다.
- 기대결과: 모르는 키도 저장된다(화이트리스트를 두면 템플릿 하나 늘릴 때마다 백엔드를 고쳐야 한다)."""
+async def test_unknown_template_is_refused(auth_headers, client):
+ """검증: 등록되지 않은 id는 거절한다."""
h = await auth_headers("tpl2")
pid = await _place(client, h)
- saved = await _set_template(client, h, pid, "brand-new-template-nobody-knows")
- assert saved["result"]["code"] == ErrorType.SUCCESS.value
- assert saved["site"]["template_id"] == "brand-new-template-nobody-knows"
-
-
-async def test_too_long_template_is_refused(auth_headers, client):
- """검증: 길이(varchar(100))만은 막는다 — 안 막으면 DB 가 트랜잭션째로 튕긴다.
- 기대결과: INVALID_REQUEST_DATA."""
- h = await auth_headers("tpl3")
- pid = await _place(client, h)
-
- refused = await _set_template(client, h, pid, "t" * 101)
+ refused = await _set_template(client, h, pid, "stay-retro")
assert refused["result"]["code"] == ErrorType.INVALID_REQUEST_DATA.value
assert "site" not in refused
+async def test_template_outside_industry_list_is_refused(auth_headers, client):
+ """검증: 등록된 템플릿이라도 그 업종 허용 목록에 없으면 거절한다(병원은 레트로를 못 쓴다)."""
+ h = await auth_headers("tpl3")
+ pid = await _place(client, h, name="템플릿의원", category=PlaceCategory.CLINIC.value)
+
+ refused = await _set_template(client, h, pid, "retro")
+ assert refused["result"]["code"] == ErrorType.INVALID_REQUEST_DATA.value
+
+
async def test_empty_value_clears_to_default(auth_headers, client):
- """검증: 빈 값은 '고르지 않음'이다 — NULL 로 되돌아가 업종 기본으로 떨어진다.
- 기대결과: 저장 후 빈 문자열을 보내면 template_id 가 응답에서 사라진다(None)."""
+ """검증: 빈 값은 '고르지 않음'이다 — NULL로 돌아가 업종 기본 템플릿으로 나간다."""
h = await auth_headers("tpl4")
pid = await _place(client, h)
- await _set_template(client, h, pid, "stay-quiet-margin")
+ await _set_template(client, h, pid, "paper")
cleared = await _set_template(client, h, pid, "")
assert cleared["result"]["code"] == ErrorType.SUCCESS.value
- # RemoveNoneResponse 가 None 필드를 지운다 — 키가 없으면 NULL 이다.
assert "template_id" not in cleared["site"]
async def test_published_site_template_is_not_locked(auth_headers, client, db_engine):
- """검증: ★ 발행된 사이트도 템플릿은 바꿀 수 있다(주소와 다르다 — URL 이 그대로라 색인이 안 깨진다).
- 기대결과: SUCCESS + 재빌드 필요 표시(needs_rebuild)."""
+ """검증: 발행된 사이트도 템플릿을 바꿀 수 있고, 재빌드 표시가 선다."""
h = await auth_headers("tpl5")
pid = await _place(client, h)
- await _set_template(client, h, pid, "stay-o2o-editorial")
+ await _set_template(client, h, pid, "simple")
- # 발행 상태를 만든다(빌드 잡을 돌리는 대신 상태만).
async with db_engine.begin() as conn:
await conn.execute(
text("UPDATE sites SET status = :st, published_at = now() WHERE place_id = :p"),
{"st": SiteStatus.PUBLISHED.value, "p": uuid.UUID(pid)},
)
- changed = await _set_template(client, h, pid, "stay-quiet-margin")
+ changed = await _set_template(client, h, pid, "paper")
assert changed["result"]["code"] == ErrorType.SUCCESS.value
- assert changed["site"]["template_id"] == "stay-quiet-margin"
- # 나가 있는 페이지와 달라졌으므로 이 사업장만 다시 빌드하면 된다는 표시가 서야 한다.
+ assert changed["site"]["template_id"] == "paper"
assert changed["needs_rebuild"] is True
async def test_other_owners_place_is_blocked(auth_headers, client):
- """검증: 남의 사업장의 템플릿은 바꿀 수 없다.
- 기대결과: PLACE_NOT_FOUND(존재 여부조차 알려주지 않는다)."""
+ """검증: 남의 사업장의 템플릿은 바꿀 수 없다(PLACE_NOT_FOUND)."""
h = await auth_headers("tpl6")
intruder = await auth_headers("tpl7")
pid = await _place(client, h)
- blocked = await _set_template(client, intruder, pid, "stay-quiet-margin")
+ blocked = await _set_template(client, intruder, pid, "paper")
assert blocked["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value
def test_payload_uses_saved_template_and_falls_back():
- """검증: 발행 payload 의 theme.templateId 는 저장된 값을 쓰고, 없으면 업종 기본으로 떨어진다.
- 기대결과: 저장값 → 그대로 / NULL → 업종 기본. ★ 고르지 않은 값을 고른 것처럼 굽지 않는다."""
+ """검증: payload의 templateId는 저장값, 없으면 업종 기본 템플릿이다."""
place = {"place_id": uuid.uuid4(), "category": PlaceCategory.LODGING.value, "name": "스테이,머뭄"}
snapshot = {"place": {"name": "스테이,머뭄", "category": PlaceCategory.LODGING.value}}
version = {"version": 1}
- chosen = to_site_payload(place, snapshot, {"template_id": "stay-quiet-margin"}, version, [])
- assert chosen["theme"]["templateId"] == "stay-quiet-margin"
+ chosen = to_site_payload(place, snapshot, {"template_id": "paper"}, version, [])
+ assert chosen["theme"]["templateId"] == "paper"
- default_id = _DEFAULT_THEME[PlaceCategory.LODGING.value]["templateId"]
- assert to_site_payload(place, snapshot, {"template_id": None}, version, [])["theme"]["templateId"] == default_id
- # 색·서체는 여전히 업종 기본이다(그 값들은 아직 저장되는 자리가 없다).
- assert chosen["theme"]["fontStyle"] == _DEFAULT_THEME[PlaceCategory.LODGING.value]["fontStyle"]
+ fallback = to_site_payload(place, snapshot, {"template_id": None}, version, [])
+ assert fallback["theme"]["templateId"] == INDUSTRIES["stay"]["defaultTemplate"]
diff --git a/solution/backend/tests/test_site_theme.py b/solution/backend/tests/test_site_theme.py
index cda7bd9..c7c1bd9 100644
--- a/solution/backend/tests/test_site_theme.py
+++ b/solution/backend/tests/test_site_theme.py
@@ -5,29 +5,25 @@
사장님이 섹션을 끄고 순서를 바꿔도 발행본이 안 바뀌던 것이 이 API 가 생긴 이유다.
- ★ 잠긴 섹션(SEO·필수 마크업)을 끈 채로 내보내는 것 — 끄기로도, 목록에서 빼기로도 막아야 한다.
한쪽만 막으면 "끄는 대신 빼면 그만"이라 잠금이 무의미해진다.
- - 저장값을 서버가 해석하는 것 — 섹션 목록·배리에이션 키는 프론트가 소유한다.
- 화이트리스트를 두면 프론트에 항목 하나 늘 때마다 백엔드를 같이 고쳐야 한다.
+ - 템플릿 정의(templates.json)에 없는 모양·템플릿으로 굽는 것.
- templateId 를 theme 안에 같이 보관하는 것 — sites.template_id 와 갈린다.
"""
import uuid
import pytest
-import pathlib
-import re
-
from sqlalchemy import text
from common.enums import ErrorType, PlaceCategory, SiteStatus
-from services.site_payload import _DEFAULT_THEME, to_site_payload
+from common.template_catalog import INDUSTRIES, TEMPLATES, UnknownTemplate
+from services.site_payload import to_site_payload
# 계약 그대로의 최소 테마. 배열 순서가 곧 섹션 순서다(별도 order 필드가 없다).
_THEME = {
"colors": {"accent": "#c2410c"},
- "fontStyle": "Warm Serif",
"colorPaletteId": "warm-sand",
"sections": [
- {"id": "faq", "name": "자주 묻는 질문", "enabled": True, "locked": False, "variantId": "faq.two-column"},
+ {"id": "faq", "name": "자주 묻는 질문", "enabled": True, "locked": False},
{"id": "hero", "name": "우리 히어로", "enabled": True, "locked": True},
{"id": "rooms", "name": "객실 안내", "enabled": False, "locked": False},
],
@@ -61,17 +57,15 @@ async def test_set_theme_creates_site_row_and_round_trips(auth_headers, client):
assert (await _get_site(client, h, pid))["site"]["theme"] == _THEME
-async def test_unknown_sections_and_variants_are_accepted(auth_headers, client):
- """검증: ★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키는 프론트가 소유한다.
- 기대결과: 백엔드가 처음 보는 섹션 id 와 배리에이션 키도 그대로 저장된다."""
+async def test_unknown_sections_are_accepted(auth_headers, client):
+ """검증: 빌더가 새로 추가한 섹션 id도 그대로 저장된다."""
h = await auth_headers("thm2")
pid = await _place(client, h)
- theme = {"sections": [{"id": "brand-new-section", "name": "신규", "enabled": True,
- "locked": False, "variantId": "nobody.knows"}]}
+ theme = {"sections": [{"id": "brand-new-section", "name": "신규", "enabled": True, "locked": False}]}
saved = await _set_theme(client, h, pid, theme)
assert saved["result"]["code"] == ErrorType.SUCCESS.value
- assert saved["site"]["theme"]["sections"][0]["variantId"] == "nobody.knows"
+ assert saved["site"]["theme"]["sections"][0]["id"] == "brand-new-section"
async def test_oversized_theme_is_refused(auth_headers, client):
@@ -80,7 +74,7 @@ async def test_oversized_theme_is_refused(auth_headers, client):
h = await auth_headers("thm3")
pid = await _place(client, h)
- refused = await _set_theme(client, h, pid, {"fontStyle": "x" * 70_000})
+ refused = await _set_theme(client, h, pid, {"colors": {"accent": "x" * 70_000}})
assert refused["result"]["code"] == ErrorType.INVALID_REQUEST_DATA.value
assert "site" not in refused
@@ -91,10 +85,10 @@ async def test_template_id_is_not_stored_inside_theme(auth_headers, client):
h = await auth_headers("thm4")
pid = await _place(client, h)
- saved = await _set_theme(client, h, pid, {"templateId": "sneaky", "fontStyle": "폰트"})
+ saved = await _set_theme(client, h, pid, {"templateId": "sneaky", "colors": {"accent": "#000000"}})
assert saved["result"]["code"] == ErrorType.SUCCESS.value
assert "templateId" not in saved["site"]["theme"]
- assert saved["site"]["theme"]["fontStyle"] == "폰트"
+ assert saved["site"]["theme"]["colors"] == {"accent": "#000000"}
async def test_empty_theme_clears_to_default(auth_headers, client):
@@ -123,7 +117,7 @@ async def test_published_site_theme_is_not_locked(auth_headers, client, db_engin
{"st": SiteStatus.PUBLISHED.value, "p": uuid.UUID(pid)},
)
- changed = await _set_theme(client, h, pid, {**_THEME, "fontStyle": "다른서체"})
+ changed = await _set_theme(client, h, pid, {**_THEME, "colors": {"accent": "#111111"}})
assert changed["result"]["code"] == ErrorType.SUCCESS.value
# 나가 있는 페이지와 달라졌으므로 이 사업장만 다시 빌드하면 된다는 표시가 서야 한다.
assert changed["needs_rebuild"] is True
@@ -149,17 +143,16 @@ def _payload_theme(theme):
return to_site_payload(_PLACE, _SNAPSHOT, {"template_id": None, "theme": theme}, _VERSION, [])["theme"]
-def test_payload_without_saved_theme_falls_back_to_category_default():
- """검증: 저장된 디자인이 없을 때.
- 기대결과: 업종 기본 색·서체·섹션 그대로 — 고르지 않은 값을 고른 것처럼 굽지 않는다."""
- spec = _DEFAULT_THEME[PlaceCategory.LODGING.value]
+def test_payload_without_saved_theme_uses_industry_default():
+ """검증: 저장된 디자인이 없으면 업종 기본 템플릿(숙박=retro)과 업종 섹션 목록 그대로 나간다."""
theme = _payload_theme(None)
+ stay = INDUSTRIES["stay"]
- assert theme["fontStyle"] == spec["fontStyle"]
- assert theme["colors"] == spec["colors"]
- assert [s["id"] for s in theme["sections"]] == [sid for sid, _, _ in spec["sections"]]
- assert all(s["enabled"] for s in theme["sections"] if s["id"] != "social")
- assert next(s for s in theme["sections"] if s["id"] == "social")["enabled"] is False
+ assert theme["templateId"] == "retro"
+ assert theme["colors"] == TEMPLATES["retro"]["colors"]
+ assert theme["look"] == TEMPLATES["retro"]["look"]
+ assert [s["id"] for s in theme["sections"]] == [s["id"] for s in stay["sections"]]
+ assert [s["enabled"] for s in theme["sections"]] == [s["enabled"] for s in stay["sections"]]
def test_payload_follows_saved_order_and_toggles():
@@ -171,13 +164,17 @@ def test_payload_follows_saved_order_and_toggles():
assert next(s for s in theme["sections"] if s["id"] == "rooms")["enabled"] is False
-def test_payload_carries_variant_id_only_when_chosen():
- """검증: 배리에이션은 그대로 싣되, 고르지 않았으면 키 자체를 붙이지 않는다.
- 기대결과: 고른 섹션엔 variantId, 안 고른 섹션엔 키 없음
- (null 을 실으면 렌더러 타입과 안 맞고, 비면 렌더러가 기본 레이아웃으로 떨어진다)."""
- theme = _payload_theme(_THEME)
- assert next(s for s in theme["sections"] if s["id"] == "faq")["variantId"] == "faq.two-column"
- assert "variantId" not in next(s for s in theme["sections"] if s["id"] == "hero")
+def test_payload_drops_variant_id():
+ """검증: 배치 고르기를 뺐으므로 저장값에 variantId가 남아 있어도 payload에 싣지 않는다."""
+ theme = _payload_theme({"sections": [{"id": "faq", "name": "FAQ", "enabled": True, "locked": False,
+ "variantId": "faq.two-column"}]})
+ assert "variantId" not in next(s for s in theme["sections"] if s["id"] == "faq")
+
+
+def test_payload_ignores_saved_look():
+ """검증: 모양(look)은 템플릿 정의가 정한다. 저장값의 look은 무시한다."""
+ theme = _payload_theme({"look": {"fontHeading": "Comic Sans"}})
+ assert theme["look"] == TEMPLATES["retro"]["look"]
def test_locked_section_cannot_be_published_disabled():
@@ -237,13 +234,11 @@ def test_owner_written_section_body_reaches_publish_payload():
def test_partial_colors_are_filled_from_category_default():
"""검증: 저장된 색이 일부 키만 담고 있을 때.
- 기대결과: 빠진 자리는 업종 기본이 메운다 — 렌더러 타입이 6개를 모두 요구하므로
- 일부만 실으면 나머지 색이 undefined 로 나가 화면이 깨진다."""
- spec = _DEFAULT_THEME[PlaceCategory.LODGING.value]
+ 기대결과: 빠진 자리는 템플릿 색이 메운다(렌더러는 6개를 모두 요구한다)."""
colors = _payload_theme({"colors": {"accent": "#c2410c"}})["colors"]
assert colors["accent"] == "#c2410c"
- assert colors["bg"] == spec["colors"]["bg"]
- assert set(colors) == set(spec["colors"])
+ assert colors["bg"] == TEMPLATES["retro"]["colors"]["bg"]
+ assert set(colors) == set(TEMPLATES["retro"]["colors"])
def test_color_palette_id_never_reaches_the_payload():
@@ -254,57 +249,17 @@ def test_color_palette_id_never_reaches_the_payload():
def test_saved_theme_does_not_override_chosen_template():
- """검증: templateId 의 출처.
- 기대결과: 언제나 sites.template_id 다 — theme 는 색·서체·섹션만 담당한다."""
- payload = to_site_payload(
- _PLACE, _SNAPSHOT, {"template_id": "stay-quiet-margin", "theme": _THEME}, _VERSION, []
- )
- assert payload["theme"]["templateId"] == "stay-quiet-margin"
+ """검증: templateId와 모양은 언제나 sites.template_id가 가리키는 템플릿에서 온다."""
+ payload = to_site_payload(_PLACE, _SNAPSHOT, {"template_id": "paper", "theme": _THEME}, _VERSION, [])
+ assert payload["theme"]["templateId"] == "paper"
+ assert payload["theme"]["look"] == TEMPLATES["paper"]["look"]
-# ── 에디터 기본 섹션표와의 1:1 대조 ────────────────────────────────────────
-# ★ 이 두 표가 어긋나면 발행본에서 섹션이 통째로 사라진다. 저장된 테마가 없는 사업장
-# (위저드만 돌고 [디자인] 탭을 건드리지 않은 대부분)은 _DEFAULT_THEME 이 곧 발행본이라,
-# 여기에 없는 섹션은 사장님이 에디터에서 아무리 봐도 사이트에 나오지 않는다.
-# 실측으로 날씨·실시간 예약·대관 문의·관람 안내가 그렇게 빠져 있었다.
-_ADMIN_SECTIONS_TS = (
- pathlib.Path(__file__).resolve().parents[2] / "frontend/src/data/industryData.ts" # parents[2] = solution/
-)
-
-# 에디터 업종 키 → PlaceCategory. 이름이 다른 건 두 층의 어휘가 달라서다(clinic vs CLINIC).
-_INDUSTRY_TO_CATEGORY = {
- "stay": PlaceCategory.LODGING.value,
- "cafe": PlaceCategory.CAFE.value,
- "restaurant": PlaceCategory.RESTAURANT.value,
- "clinic": PlaceCategory.CLINIC.value,
-}
-
-
-def _editor_sections() -> dict[str, list[tuple[str, str, bool]]]:
- """industryData.ts 의 업종별 sections 를 (id, name, locked) 목록으로 읽는다.
-
- ★ TS 를 정규식으로 읽는 건 곱지 않지만, 이 표를 백엔드로 복사해 오면 복사본이 또 어긋난다.
- 원본을 그대로 읽어 비교하는 것이 이 테스트의 요점이다."""
- src = _ADMIN_SECTIONS_TS.read_text(encoding="utf-8")
- out: dict[str, list[tuple[str, str, bool]]] = {}
- for block in re.finditer(r"^ (\w+): \{$(.*?)^ \},$", src, re.S | re.M):
- industry = block.group(1)
- body = block.group(2)
- arr = re.search(r"^ sections: \[$(.*?)^ \],$", body, re.S | re.M)
- if not arr:
- continue
- items = re.findall(
- r"id: '([\w]+)', type: '[\w]+', name: '([^']*)', isLocked: (true|false)", arr.group(1)
- )
- out[industry] = [(sid, name, locked == "true") for sid, name, locked in items]
- return out
-
-
-@pytest.mark.parametrize("industry", sorted(_INDUSTRY_TO_CATEGORY))
-def test_default_sections_match_the_editor(industry):
- """검증: 업종 기본 섹션표가 에디터(industryData.ts)와 id·순서·이름·잠금까지 같은가.
- 기대결과: 완전히 같다 — 여기가 어긋나면 에디터에는 보이는 섹션이 발행본에 없다."""
- editor = _editor_sections()
- assert industry in editor, f"industryData.ts 에서 {industry} 의 sections 를 읽지 못했다"
- server = _DEFAULT_THEME[_INDUSTRY_TO_CATEGORY[industry]]["sections"]
- assert [tuple(s) for s in server] == editor[industry]
+def test_unknown_template_stops_the_payload():
+ """검증: 등록되지 않은 id, 업종 허용 목록에 없는 id는 payload를 만들지 않는다."""
+ with pytest.raises(UnknownTemplate):
+ to_site_payload(_PLACE, _SNAPSHOT, {"template_id": "stay-retro"}, _VERSION, [])
+ clinic = {**_PLACE, "category": PlaceCategory.CLINIC.value}
+ clinic_snapshot = {"place": {"name": "테마의원", "category": PlaceCategory.CLINIC.value}}
+ with pytest.raises(UnknownTemplate):
+ to_site_payload(clinic, clinic_snapshot, {"template_id": "retro"}, _VERSION, [])
diff --git a/solution/frontend/src/data/industryData.ts b/solution/frontend/src/data/industryData.ts
index 571c00b..83d3027 100644
--- a/solution/frontend/src/data/industryData.ts
+++ b/solution/frontend/src/data/industryData.ts
@@ -1,334 +1,25 @@
-import type {IndustryData, IndustryType, TemplateItem} from '@o2o/shared';
-/**
- * 템플릿 세 벌.
- *
- * ★ 예전엔 업종마다 다섯 벌이었는데 넷이 "흰 바탕 + 고딕 + 둥근 모서리"에 색조만 달랐다 —
- * 골라도 뭐가 달라지는지 알 수 없었다. 지금은 서체·모서리·테두리·그림자·여백까지 갈린다.
- * 같은 얼굴을 색만 바꿔 늘리지 않는다.
- * ★ 업종이 바꾸는 건 accent 하나다. 생김새는 업종이 아니라 취향의 문제다.
- */
-const LOOK = {
- simple: {
- fontHeading: "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif",
- fontBody: "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif",
- radius: '0.75rem',
- borderWidth: '1px',
- shadow: '0 1px 2px rgb(0 0 0 / 0.06)',
- headingTracking: '-0.02em',
- headingWeight: '700',
- sectionSpace: '3.5rem',
- },
- magazine: {
- fontHeading: "'Noto Serif KR', 'Batang', 'Times New Roman', serif",
- fontBody: "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif",
- radius: '0px',
- borderWidth: '1px',
- shadow: 'none',
- headingTracking: '-0.03em',
- headingWeight: '700',
- sectionSpace: '5rem',
- },
- retro: {
- fontHeading: "'Gugi', 'Noto Sans KR', sans-serif",
- fontBody: "'Gowun Batang', 'Noto Serif KR', serif",
- radius: '0px',
- borderWidth: '2px',
- shadow: '4px 4px 0 rgb(27 26 21 / 0.16)',
- // 간판체는 자간을 벌리면 글자가 흩어진다. 굵기도 한 벌뿐이라 700 을 주면 가짜 볼드가 씌워진다.
- headingTracking: '0em',
- headingWeight: '400',
- sectionSpace: '4rem',
- /**
- * 갱지 결 — 가로 3px · 세로 4px 간격의 아주 옅은 줄 두 겹.
- *
- * ★ 이 칸이 비어 있어서 '옛 항구'를 골라도 면이 매끈했다. 타입(`TemplateLook.texture`)에도
- * 있고 발행본이 심는 코드(`seo/head.ts`)도 있는데 **주는 쪽만 없었다** — 색과 서체는
- * 레트로인데 종이가 아니라, 인쇄물이 아니라 '갈색 웹페이지'로 보였다.
- */
- texture:
- 'repeating-linear-gradient(0deg,rgba(27,26,21,.028) 0 1px,transparent 1px 3px),repeating-linear-gradient(90deg,rgba(27,26,21,.02) 0 1px,transparent 1px 4px)',
- },
- paper: {
- fontHeading: "'Noto Serif KR', 'AppleMyungjo', 'Nanum Myeongjo', serif",
- fontBody: "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif",
- radius: '0px',
- borderWidth: '1px',
- shadow: 'none',
- headingTracking: '0.03em',
- headingWeight: '400',
- sectionSpace: '4.5rem',
- },
-} as const;
+import {INDUSTRIES, TEMPLATES, type IndustryData, type IndustryType, type TemplateId} from '@o2o/shared';
-/** 업종 하나의 템플릿 세 벌. accent 만 업종이 정한다. */
-function templatesFor(
- industryId: IndustryType,
- accent: string,
- /**
- * 레트로 한 벌은 업종마다 이름·설명이 다르고, **끄는 섹션과 배리에이션도 다르다.**
- * ★ 공통으로 박으면 숙박에서 내린 결정(예약 안내를 안 쓴다)이 음식점의 '노포'까지 따라간다 —
- * 거긴 예약·포장 안내가 그 집의 핵심이다.
- */
- retro: {
- name: string;
- description: string;
- disabledSectionTypes?: string[];
- defaultVariants?: Record;
- /**
- * 이 업종의 **기본 템플릿**으로 세운다 — 목록 맨 앞에 온다.
- *
- * ★ 순서가 곧 기본값이다. 에디터는 저장된 templateId 가 없으면 업종의 **첫 템플릿**으로
- * 그린다(`stores/builder.ts`). 그래서 "기본을 옛 항구로" 는 배열 순서를 바꾸는 일이다.
- * ★ 백엔드 `site_payload._DEFAULT_THEME` 의 templateId 와 **같아야 한다.**
- * 어긋나면 에디터는 옛 항구로 그리는데 발행본은 다른 템플릿으로 나간다.
- */
- isDefault?: boolean;
- },
-): TemplateItem[] {
- const items: TemplateItem[] = [
- {
- id: `${industryId}-simple`,
- industryId,
- name: '심플',
- tone: 'info',
- toneLabel: '깔끔한 기본',
- description: '흰 바탕에 고딕. 읽기 쉽고 어디에도 어울립니다. 무엇을 고를지 모르겠으면 이것.',
- colors: {
- primary: '#18181b',
- secondary: '#52525b',
- bg: '#ffffff',
- card: '#fafafa',
- text: '#09090b',
- accent,
- },
- fontStyle: '고딕',
- look: LOOK.simple,
- },
- {
- id: `${industryId}-magazine`,
- industryId,
- name: '매거진',
- tone: 'photo',
- toneLabel: '잡지 편집',
- description: '명조 제목에 각진 모서리, 그림자 없이 선으로만. 사진과 글이 많을 때 품이 납니다.',
- colors: {
- primary: '#111111',
- secondary: '#57534e',
- bg: '#ffffff',
- card: '#f7f6f4',
- text: '#111111',
- accent,
- },
- fontStyle: '명조 제목',
- look: LOOK.magazine,
- },
- {
- id: `${industryId}-retro`,
- industryId,
- name: retro.name,
- tone: 'photo',
- toneLabel: '레트로 감성',
- description: retro.description,
- colors: {
- // 시안(/s/stay)의 :root 값 그대로 — paper / ink-soft / paper-2.
- primary: '#1b1a15',
- secondary: '#4c4739',
- bg: '#e4dac0',
- card: '#efe7d3',
- text: '#1b1a15',
- // 레트로의 정체성이 이 주(朱) 잉크다 — 업종 accent 로 갈아끼우지 않는다.
- accent: '#bf2f1b',
- },
- fontStyle: '옛 간판체',
- look: LOOK.retro,
- // 이 템플릿이 팔려는 게 바로 이 아이템들이다.
- /**
- * 이 템플릿이 데려오는 아이템.
- *
- * ★ 예전 값('course' · 'schedule')은 **렌더러에 없는 타입**이었다. 시안이 그 둘을
- * itinerary · event 로 갈아치웠는데 여기만 남아서, '옛 항구'를 골라도 아이템이
- * 하나도 안 붙었다(실측 2026-09-09).
- * ★ 가요·인물·연표·엽서·퀴즈는 여기 넣지 않는다 — '지역 이야기'(story) 탭이 데이터가
- * 있는 것만 골라 그린다. 따로 붙이면 탭 밖에 한 번 더 선다.
- */
- defaultSectionTypes: ['event', 'video', 'festival', 'itinerary', 'story'],
- ...(retro.disabledSectionTypes ? {disabledSectionTypes: retro.disabledSectionTypes} : {}),
- ...(retro.defaultVariants ? {defaultVariants: retro.defaultVariants} : {}),
- },
- ];
-
- // 기본 템플릿을 맨 앞으로. 나머지 순서는 그대로 둔다.
- return retro.isDefault ? [items[2], items[0], items[1]] : items;
-}
-
-function paperTemplate(industryId: IndustryType, description: string): TemplateItem {
+function configOf(id: IndustryType): IndustryData {
+ const def = INDUSTRIES[id];
return {
- id: `${industryId}-paper`,
- industryId,
- name: '고택',
- tone: 'book',
- toneLabel: '고택 지면',
- description,
- colors: {
- primary: '#1f1d19',
- secondary: '#726c61',
- bg: '#fdfcfa',
- card: '#f2efe8',
- text: '#1f1d19',
- accent: '#1f1d19',
- },
- fontStyle: '정갈한 명조',
- look: LOOK.paper,
+ id,
+ name: def.name,
+ subName: def.subName,
+ description: def.description,
+ channels: def.channels,
+ sections: def.sections.map(({locked, enabled, ...section}) => ({...section, isLocked: locked, isEnabled: enabled})),
+ defaultTemplate: def.defaultTemplate,
+ templates: (def.templates as TemplateId[]).map((templateId) => ({id: templateId, ...TEMPLATES[templateId]})),
};
}
-
export const INDUSTRY_CONFIGS: Record = {
- stay: {
- id: 'stay',
- name: '숙박',
- subName: '펜션 · 스테이',
- description: '감성 스테이, 풀빌라, 독채 펜션, 게스트하우스',
- channels: [
- { id: 'stay_platforms', name: '야놀자 · 여기어때', checked: true },
- { id: 'naver_place', name: '네이버 플레이스', checked: true },
- { id: 'instagram', name: '인스타그램', checked: true },
- ],
- sections: [
- // ★ 백엔드 기본 표(`site_payload._DEFAULT_THEME`)와 id·순서·이름·잠금이 1:1 이어야 한다.
- // 어긋나면 에디터에서 본 섹션이 발행본에서 통째로 사라진다.
- { id: 'hero', type: 'hero', name: '히어로', isLocked: true, isEnabled: true, description: '상단 메인 비주얼과 대표 문구' },
- { id: 'intro', type: 'intro', name: '소개', isLocked: false, isEnabled: true, description: '스테이의 철학과 공간 스토리' },
- { id: 'rooms', type: 'rooms', name: '객실 안내', isLocked: false, isEnabled: true, description: '객실 타입, 구조, 비치 물품' },
- { id: 'event', type: 'event', name: '소식', isLocked: false, isEnabled: true, description: '지금 하는 행사 · 공지' },
- { id: 'info', type: 'info', name: '기본 정보', isLocked: true, isEnabled: true, description: '체크인, 주차, 시설 핵심 정보' },
- { id: 'booking', type: 'booking', name: '예약 안내', isLocked: false, isEnabled: true, description: '요금 · 예약 창구 안내' },
- { id: 'video', type: 'video', name: '영상', isLocked: false, isEnabled: true, description: '유튜브 주소 하나면 됩니다' },
- { id: 'photos', type: 'photos', name: '사진 갤러리', isLocked: false, isEnabled: true, description: '감성 인테리어와 외부 풍경' },
- { id: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '위치 안내 및 대중교통 경로' },
- { id: 'festival', type: 'festival', name: '계절별 축제', isLocked: false, isEnabled: true, description: '주변에서 열리는 축제 — 계절로 묶어 보여줍니다' },
- { id: 'local', type: 'local', name: '지역 정보', isLocked: false, isEnabled: true, description: '주변 관광지 및 맛집 추천' },
- { id: 'itinerary', type: 'itinerary', name: '추천 일정', isLocked: false, isEnabled: true, description: '숙소에서 출발하는 하루 코스' },
- { id: 'story', type: 'story', name: '지역 이야기', isLocked: false, isEnabled: true, description: '가요·인물·연표·엽서·퀴즈를 탭으로' },
- { id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '고객들이 자주 묻는 질문과 답변' },
- { id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 사업장 주변 날씨' },
- { id: 'social', type: 'social', name: 'SNS 게시글', isLocked: false, isEnabled: false, description: '승인해 함께 발행한 소식 · 홈페이지 맨 아래' },
- ],
- templates: [
- ...templatesFor('stay', '#2563eb', {
- name: '옛 항구',
- description: '갱지 바탕에 간판체. 도넛판·일력·승차권이 함께 들어옵니다. 오래된 항구 도시의 인상으로 묵는 곳을 소개합니다.',
- // 숙박의 기본 템플릿 (2026-09-10, 사장님 지시). 백엔드 `_DEFAULT_THEME` 도 stay-retro 다.
- isDefault: true,
- /** 시안의 사진 갤러리는 캐러셀이다. 색·서체만 맞고 모양이 기본이면 시안이 안 된다. */
- defaultVariants: {photos: 'photos.carousel'},
- }),
- paperTemplate('stay', '크림빛 종이에 가는 명조. 그림자도 장식도 없이, 백 년 된 집의 정갈함을 그대로 보여줍니다.'),
- ],
- },
-
- cafe: {
- id: 'cafe',
- name: '카페',
- subName: '대형카페 · 로스터리',
- description: '스페셜티 로스터리, 베이커리 카페, 오션·마운틴 뷰 대형 카페',
- channels: [
- { id: 'naver_place', name: '네이버 플레이스', checked: true },
- { id: 'instagram', name: '인스타그램', checked: true },
- { id: 'kakao_map', name: '카카오맵', checked: true },
- ],
- sections: [
- { id: 'hero', type: 'hero', name: '히어로', isLocked: true, isEnabled: true, description: '시그니처 비주얼과 카페 슬로건' },
- { id: 'intro', type: 'intro', name: '소개', isLocked: false, isEnabled: true, description: '로스팅 철학과 공간 스토리' },
- { id: 'menu', type: 'menu', name: '시그니처 메뉴', isLocked: false, isEnabled: true, description: '대표 원두, 음료, 시그니처 디저트' },
- { id: 'info', type: 'info', name: '기본 정보', isLocked: true, isEnabled: true, description: '영업시간, 좌석수, 편의시설 정보' },
- { id: 'space', type: 'space', name: '공간 · 좌석 안내', isLocked: false, isEnabled: true, description: '1/2층 공간 구성 및 야외 테라스석' },
- { id: 'photos', type: 'photos', name: '사진 갤러리', isLocked: false, isEnabled: true, description: '인테리어, 커피, 베이커리 비주얼' },
- { id: 'inquiry', type: 'inquiry', name: '대관 및 단체 문의', isLocked: false, isEnabled: true, description: '촬영 대관 및 단체 예약 접수' },
- { id: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '드라이브 코스 및 주차 진입로 안내' },
- { id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 매장 주변 날씨' },
- { id: 'local', type: 'local', name: '주변 나들이', isLocked: false, isEnabled: true, description: '양평 드라이브 코스 및 명소' },
- { id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '반려견 동반, 주차, 케어키즈존 안내' },
- { id: 'social', type: 'social', name: 'SNS 게시글', isLocked: false, isEnabled: false, description: '승인해 함께 발행한 소식 · 홈페이지 맨 아래' },
- ],
- templates: [
- ...templatesFor('cafe', '#b45309', {
- name: '옛 다방',
- description: '갱지 바탕에 간판체. LP 와 손글씨로, 다방 시절의 인상으로 지금의 커피를 이야기합니다.',
- }),
- paperTemplate('cafe', '크림빛 종이에 가는 명조. 그림자도 장식도 없이, 조용한 카페의 정갈함을 그대로 보여줍니다.'),
- ],
- },
-
- restaurant: {
- id: 'restaurant',
- name: '음식점',
- subName: '식당 · 주점',
- description: '한식 다이닝, 일식 오마카세, 이탈리안 비스트로, 고기집',
- channels: [
- { id: 'naver_place', name: '네이버 플레이스', checked: true },
- { id: 'catchtable', name: '캐치테이블', checked: true },
- { id: 'instagram', name: '인스타그램', checked: true },
- ],
- sections: [
- { id: 'hero', type: 'hero', name: '히어로', isLocked: true, isEnabled: true, description: '대표 요리 비주얼 및 다이닝 소개' },
- { id: 'intro', type: 'intro', name: '소개', isLocked: false, isEnabled: true, description: '셰프의 조리 철학과 식재료 원산지 이야기' },
- { id: 'menu', type: 'menu', name: '코스 및 메뉴', isLocked: false, isEnabled: true, description: '점심/저녁 코스, 단품 요리, 주류 페어링' },
- { id: 'info', type: 'info', name: '기본 정보', isLocked: true, isEnabled: true, description: '영업시간, 휴무일, 주차, 예약 안내' },
- { id: 'booking', type: 'booking', name: '예약 · 포장 안내', isLocked: false, isEnabled: true, description: '캐치테이블 실시간 룸 예약 및 포장' },
- { id: 'photos', type: 'photos', name: '사진 갤러리', isLocked: false, isEnabled: true, description: '플레이팅, 룸 인테리어, 정갈한 상차림' },
- { id: 'inquiry', type: 'inquiry', name: '단체 행사 문의', isLocked: false, isEnabled: true, description: '상견례, 돌잔치, 기업 대관 문의' },
- { id: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '지하철역 출구 및 발렛부스 위치' },
- { id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 매장 주변 날씨' },
- { id: 'local', type: 'local', name: '주변 안내', isLocked: false, isEnabled: true, description: '청담 명품거리 및 갤러리 안내' },
- { id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '콜키지 정책, 알러지 케어, 주차 안내' },
- { id: 'social', type: 'social', name: 'SNS 게시글', isLocked: false, isEnabled: false, description: '승인해 함께 발행한 소식 · 홈페이지 맨 아래' },
- ],
- templates: [
- ...templatesFor('restaurant', '#16a34a', {
- name: '노포',
- description: '갱지 바탕에 간판체. 오래 해온 집이라는 사실 자체가 메뉴판이 됩니다.',
- }),
- paperTemplate('restaurant', '크림빛 종이에 가는 명조. 고택에서 차리는 한 상처럼, 담백하고 단정한 인상을 남깁니다.'),
- ],
- },
-
- clinic: {
- id: 'clinic',
- name: '피부과 · 성형외과',
- subName: '의원 · 클리닉',
- description: '피부과, 성형외과, 미용 클리닉',
- channels: [
- { id: 'naver_place', name: '네이버 플레이스', checked: true },
- { id: 'kakao_channel', name: '카카오톡 채널', checked: true },
- { id: 'instagram', name: '인스타그램', checked: true },
- ],
- sections: [
- { id: 'hero', type: 'hero', name: '히어로', isLocked: true, isEnabled: true, description: '병원 대표 이미지와 진료 분야' },
- { id: 'intro', type: 'intro', name: '병원 소개', isLocked: false, isEnabled: true, description: '진료 철학과 의료진 소개' },
- { id: 'programs', type: 'programs', name: '시술 안내', isLocked: false, isEnabled: true, description: '시술명, 소요 시간, 비용' },
- { id: 'info', type: 'info', name: '기본 정보', isLocked: true, isEnabled: true, description: '진료시간, 휴진일, 예약, 주차' },
- { id: 'exhibition', type: 'exhibition', name: '진료 안내', isLocked: false, isEnabled: true, description: '상담 절차와 보험 적용 안내' },
- { id: 'photos', type: 'photos', name: '사진 갤러리', isLocked: false, isEnabled: true, description: '진료실, 상담실, 대기 공간' },
- { id: 'inquiry', type: 'inquiry', name: '상담 문의', isLocked: false, isEnabled: true, description: '방문·전화 상담 접수' },
- { id: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '역에서 오는 길과 주차장' },
- { id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: false, description: '현재 기온과 주변 날씨' },
- { id: 'local', type: 'local', name: '주변 정보', isLocked: false, isEnabled: false, description: '주변 편의시설' },
- { id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '예약 변경, 회복 기간, 주의사항' },
- { id: 'social', type: 'social', name: 'SNS 게시글', isLocked: false, isEnabled: false, description: '승인해 함께 발행한 소식 · 홈페이지 맨 아래' },
- ],
- templates: templatesFor('clinic', '#4A9DC4', {
- name: '클린',
- description: '여백과 낮은 채도. 과장 없이 정보를 먼저 보여줍니다.',
- }),
- },
+ stay: configOf('stay'),
+ cafe: configOf('cafe'),
+ restaurant: configOf('restaurant'),
+ clinic: configOf('clinic'),
};
-/**
- * 업종을 아직 모를 때 떨어질 자리.
- *
- * ★ 스토어의 초기 업종이자, 서버 category 를 못 알아봤을 때의 기본값이다(placeAdapter).
- * 두 곳이 다른 값을 쓰면 "빌더가 처음 보여준 화면"과 "사업장을 열었을 때 화면"의
- * 섹션·문구가 달라진다 — 사장님 눈에는 값이 바뀐 것으로 보인다.
- */
+// 업종 미확인 시 기본값(스토어 초기값 · placeAdapter 폴백, 두 곳이 같아야 함)
export const FALLBACK_INDUSTRY: IndustryType = 'stay';
diff --git a/solution/frontend/src/features/builder/CanvasView.tsx b/solution/frontend/src/features/builder/CanvasView.tsx
index 5335279..0c97772 100644
--- a/solution/frontend/src/features/builder/CanvasView.tsx
+++ b/solution/frontend/src/features/builder/CanvasView.tsx
@@ -73,8 +73,6 @@ export function CanvasView() {
{!isPreviewMode && (
- {/* ★ min-w-0 — 이 줄이 안 줄어들면 캔버스 칸 전체의 최소 너비가 올라가고,
- 그 여파로 오른쪽 패널이 화면 밖으로 밀린다(EditorLayout 주석 참조). */}
미리보기 해상도:
@@ -106,9 +104,6 @@ export function CanvasView() {
)}
- {/* ★ 이 껍데기는 토큰도 폭도 정하지 않는다(2026-09-09).
- 안쪽 iframe 이 **자기 뷰포트**를 만들고 색·서체는 payload 가 준다.
- 바깥이 한 번 더 얹으면 두 겹이 돼 바깥 값이 안쪽을 덧칠한다 — 실제로 그랬다. */}
{viewport !== 'pc' && (
@@ -123,13 +118,6 @@ export function CanvasView() {
{viewport.toUpperCase()}
)}
-
- {/* ★ 편집 모드도 **발행본 렌더러**가 그린다(2026-09-09).
- 캔버스 전용 컴포넌트를 따로 두는 동안 두 화면이 아예 다른 트리였다 — 실측:
- 발행본 15섹션 · 에디터 12섹션, 겹치는 건 4개뿐이고 이름도 달랐다
- (gallery↔photos · location↔map · guide↔local). 사장님이 편집한 화면과
- 발행된 화면이 서로 다른 물건이었다.
- 고르는 일은 iframe 안 섹션을 눌러서 한다 — SitePreview 가 배선한다. */}
-
- {/* ★ 푸터도 모바일 하단 탭바도 여기서 그리지 않는다
- (2026-09-15 대표: "에디터에서 이 부분 필요없음 / 이 부분 자체가 필요없음")
- 발행본이 iframe 안에서 **둘 다 이미 그린다** — 푸터는 `site/sections/SiteFooter.tsx`,
- 탭바는 `site/sections/MobileTabBar.tsx`(App 과 레이아웃 Shell 다섯이 모두 세운다).
- 바깥에 한 벌 더 그리면 상호·주소·저작권·탭이 두 번 서고, 두 벌의 값이 어긋날 수도
- 있다 — 이 캔버스가 "발행되는 그 화면"이어야 한다는 원칙(SitePreview 주석)에 어긋난다. */}
diff --git a/solution/frontend/src/features/builder/ItemFormEditor.tsx b/solution/frontend/src/features/builder/ItemFormEditor.tsx
index 1c49fea..c4625fe 100644
--- a/solution/frontend/src/features/builder/ItemFormEditor.tsx
+++ b/solution/frontend/src/features/builder/ItemFormEditor.tsx
@@ -1,26 +1,10 @@
-/**
- * 붙여넣기 아이템의 **직접 입력** 폼.
- *
- * ★ 왜 만들었나
- * 입구가 JSON 붙여넣기 하나뿐이었다. 한 글자만 고치려 해도 사장님이 중괄호와 쉼표를
- * 헤집어야 했고, 쉼표 하나 잘못 지우면 섹션이 통째로 사라졌다. ChatGPT 를 안 쓰는
- * 사장님은 아예 채울 수가 없었다.
- *
- * ★ 폼과 JSON 은 한 값의 두 얼굴이다
- * 진실은 `section.data` **문자열 하나**뿐이고 이 폼은 그걸 비춘다. 그래서
- * JSON 을 붙여넣으면 폼이 따라 바뀌고, 폼을 고치면 JSON 이 다시 쓰인다 —
- * 어느 쪽이 최신인지 물을 일이 없다. 폼 상태를 따로 들고 있으면 그 순간
- * "화면은 새 값, 저장은 옛 값"이 생긴다.
- *
- * ★ 깨진 JSON 은 폼으로 못 편다. 그때는 폼을 감추고 오류만 남긴다 —
- * 반쯤 읽힌 값으로 폼을 그리면 사장님이 쓴 걸 덮어쓴다.
- */
+/** 붙여넣기 아이템의 **직접 입력** 폼. */
import {Plus, Trash2} from 'lucide-react';
import {parseSectionData} from '@o2o/shared';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {cn} from '@/lib/utils';
-import type {ItemField, SectionDataSpec} from './canvas/dataSpec';
+import type {ItemField, SectionDataSpec} from './sections/dataSpec';
type Row = Record;
diff --git a/solution/frontend/src/features/builder/RightTabsPanel.tsx b/solution/frontend/src/features/builder/RightTabsPanel.tsx
index a5a0ac0..98fd2af 100644
--- a/solution/frontend/src/features/builder/RightTabsPanel.tsx
+++ b/solution/frontend/src/features/builder/RightTabsPanel.tsx
@@ -27,12 +27,11 @@ import {INDUSTRY_CONFIGS} from '@/data/industryData';
import {FaqPanel} from './FaqPanel';
import {ItemFormEditor} from './ItemFormEditor';
import {SectionDesignPanel} from './SectionDesignPanel';
-import {resolveVariant} from './canvas/registry';
import {
buildPrompt,
dataSpecFor,
SECTION_DATA_MAX_CHARS,
-} from './canvas/dataSpec';
+} from './sections/dataSpec';
const TABS: {id: RightTab; label: string; icon: typeof Info}[] = [
{id: 'content', label: '콘텐츠', icon: FileText},
@@ -86,7 +85,6 @@ export function RightTabsPanel() {
}
function ContentTab() {
- const industry = useBuilderStore((s) => s.industry);
const sections = useBuilderStore((s) => s.sections);
const selectedSectionId = useBuilderStore((s) => s.selectedSectionId);
const updateSectionContent = useBuilderStore((s) => s.updateSectionContent);
@@ -110,17 +108,9 @@ function ContentTab() {
description: patch.description ?? section.description,
body: patch.body ?? section.body,
});
- const variantId = resolveVariant(section, industry)?.id ?? '';
const supportsTitle = section.type !== 'local';
- const supportsDescription = !(
- variantId.startsWith('rooms.') || variantId.startsWith('rules.') ||
- variantId.startsWith('local.') || variantId === 'menu.price-table' ||
- variantId === 'space.list' || variantId === 'programs.table' ||
- variantId === 'exhibition.notice'
- );
- const supportsBody =
- variantId.startsWith('intro.') || variantId.startsWith('booking.') ||
- variantId === 'inquiry.cta' || variantId === 'exhibition.notice';
+ const supportsDescription = !['rooms', 'rules', 'local'].includes(section.type);
+ const supportsBody = ['intro', 'booking', 'inquiry'].includes(section.type);
return (
@@ -171,12 +161,7 @@ function ContentTab() {
);
}
-/**
- * 붙여넣기 아이템의 JSON 입력.
- *
- * ★ 원문을 그대로 저장한다. 깨진 JSON 도 담아 두고, 왜 깨졌는지만 아래에 말한다 —
- * 저장을 막으면 사장님은 고칠 기회 없이 쓰던 걸 잃는다.
- */
+/** 붙여넣기 아이템의 JSON 입력. */
function SectionDataPanel({sectionId, sectionType}: {sectionId: string; sectionType: string}) {
const spec = dataSpecFor(sectionType);
const sections = useBuilderStore((s) => s.sections);
@@ -187,12 +172,7 @@ function SectionDataPanel({sectionId, sectionType}: {sectionId: string; sectionT
const [copied, setCopied] = useState(false);
const [showPrompt, setShowPrompt] = useState(false);
- /**
- * 직접 입력 ↔ JSON.
- *
- * ★ 둘은 같은 값(`section.data`)의 두 얼굴이라 어느 쪽으로 고쳐도 다른 쪽이 따라온다.
- * 기본은 **직접 입력**이다 — JSON 을 먼저 보여 주면 대부분의 사장님이 거기서 멈춘다.
- */
+ /** 직접 입력 ↔ JSON. */
const [mode, setMode] = useState<'form' | 'json'>('form');
const section = sections.find((item) => item.id === sectionId);
if (!spec || !section) return null;
@@ -201,7 +181,7 @@ function SectionDataPanel({sectionId, sectionType}: {sectionId: string; sectionT
const parsed = parseSectionData(sectionType, raw);
const tooLong = raw.length > SECTION_DATA_MAX_CHARS;
- // 상호·주소가 이미 박혀 있는 프롬프트. 사장님이 빈칸을 채울 일이 없어야 한다.
+ // 상호·주소가 이미 박혀 있는 프롬프트.
const promptText = buildPrompt(spec, {
storeName,
location,
@@ -435,8 +415,7 @@ function InfoTab() {
const [newLabel, setNewLabel] = useState('');
const [newValue, setNewValue] = useState('');
const placeId = useBuilderStore((s) => s.placeId);
- // ★ 셀렉터 안에서 새 배열을 만들지 않는다(스토어 상단 주의사항) — state 의 배열을 그대로 받아
- // 여기서 includes 로 좁힌다. 셀렉터가 매번 새 배열을 돌려주면 무한 렌더로 죽는다.
+ // 셀렉터 안에서 새 배열을 만들지 않는다(스토어 상단 주의사항) — state 의 배열을 그대로 받아 여기서 includes 로 좁힌다.
const savingFieldIds = useBuilderStore((s) => s.savingFieldIds);
return (
@@ -521,9 +500,7 @@ function InfoTab() {
기록되어, 이후 자동 수집이 덮어쓰지 않습니다.
- {/* ★ 재수집을 [정보] 탭 아래에 둔다. 에디터에서 "이 값이 옛날 값인데"를 깨닫는 자리가
- 바로 여기라, 다시 가져오는 버튼도 같은 자리에 있어야 찾는다.
- 데모(placeId 없음)에서는 패널이 스스로 사라진다. */}
+ {/* 재수집을 [정보] 탭 아래에 둔다. */}
diff --git a/solution/frontend/src/features/builder/SectionDesignPanel.tsx b/solution/frontend/src/features/builder/SectionDesignPanel.tsx
index 67da08d..68982e7 100644
--- a/solution/frontend/src/features/builder/SectionDesignPanel.tsx
+++ b/solution/frontend/src/features/builder/SectionDesignPanel.tsx
@@ -1,50 +1,25 @@
-/**
- * [디자인] 탭 — 고른 섹션의 레이아웃 배리에이션을 바꾸는 자리.
- *
- * 사장님은 코드 이름이 아니라 모양으로 고른다. 그래서 카드마다 와이어프레임을 붙이고,
- * "언제 이걸 고르면 좋은지"를 한 줄로 적는다.
- */
-import {
- Check,
- ChevronDown,
- LayoutTemplate,
- MousePointerClick,
- Palette,
- RotateCcw,
- Shapes,
-} from 'lucide-react';
+import {Check, ChevronDown, LayoutTemplate, Palette} from 'lucide-react';
import {INDUSTRY_CONFIGS} from '@/data/industryData';
import {queueSiteTemplateSave} from '@/features/publish/siteTemplate';
import {cn} from '@/lib/utils';
import {useBuilderStore} from '@/stores/builder';
import {COLOR_PALETTE_PRESETS} from './colorPalettes';
import {TemplatePreview} from './TemplatePreview';
-import {resolveVariant, variantsFor} from './canvas/registry';
-import {VariantThumb} from './canvas/thumbs';
-/**
- * 접히는 묶음.
- *
- * ★ [디자인] 탭은 290px 한 칸이다. 여기에 템플릿 미리보기 셋 + 팔레트 열두 칸 +
- * 이 섹션의 배리에이션이 세로로 쌓이면 스크롤이 세 화면을 넘고, 정작 방금 고른 섹션의
- * 배리에이션이 맨 아래로 밀린다. 큰 것(템플릿·색)은 접어 두고 필요할 때 편다.
- * ★ `` 다 — 상태를 리액트로 들면 탭을 오갈 때마다 접힘이 초기화된다.
- */
+// 라 탭을 오가도 접힘 상태가 남는다.
function Group({
title,
icon: Icon,
count,
- open,
children,
}: {
title: string;
icon: typeof Palette;
count?: string;
- open?: boolean;
children: React.ReactNode;
}) {
return (
-
+ {title}
@@ -56,41 +31,27 @@ function Group({
);
}
-/**
- * 템플릿 고르기.
- *
- * ★ 이 자리가 없었다. 템플릿은 온보딩 4단계에서 한 번 고르면 끝이었고, 에디터의 [디자인] 탭에는
- * 팔레트와 섹션 배리에이션만 있었다 — 사장님은 **디자인을 바꾸러 들어와서 디자인을 못 바꿨다.**
- * 스토어의 `selectTemplate` 과 서버 저장(`queueSiteTemplateSave`)은 처음부터 있었고 UI 만 없었다.
- * ★ 미리보기는 위저드와 **같은 컴포넌트**다(TemplatePreview). 두 벌로 그리면 고를 때 본 것과
- * 에디터에서 본 것이 갈린다.
- */
-function TemplatePicker({open}: {open?: boolean}) {
+function TemplatePicker() {
const industry = useBuilderStore((s) => s.industry);
const templateId = useBuilderStore((s) => s.templateId);
const placeId = useBuilderStore((s) => s.placeId);
const selectTemplate = useBuilderStore((s) => s.selectTemplate);
const templates = INDUSTRY_CONFIGS[industry].templates;
- /**
- * ★ 저장된 templateId 가 지금 목록에 **없을 수 있다.** 실제로 있었다 —
- * `stay-warm-wood` 처럼 예전 이름이 sites.template_id 에 남아 있으면
- * `resolveTemplate` 은 말없이 첫 템플릿으로 떨어지는데, 이 목록에서는 아무것도
- * 선택돼 보이지 않아 "고를 수 없는 화면"이 된다. 떨어지는 자리를 여기서도 같게 본다.
- */
- const activeId = templates.some((t) => t.id === templateId) ? templateId : templates[0].id;
return (
-
+
+
+ 템플릿을 바꾸면 이전 템플릿이 켜 둔 섹션이 꺼질 수 있습니다. 내용은 지워지지 않고, 섹션 목록에서 다시 켤 수 있습니다.
+
-
- 도시의 성격이 바뀐 해 {turningCount}개 · 전체 {items.length}개
-
-
-
- {items.map((item, index) => (
-
- ))}
-
-
- )}
-
-
- );
-}
diff --git a/solution/frontend/src/features/builder/canvas/variants/common.ts b/solution/frontend/src/features/builder/canvas/variants/common.ts
deleted file mode 100644
index 8e50472..0000000
--- a/solution/frontend/src/features/builder/canvas/variants/common.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-/**
- * 배리에이션들이 공유하는 데이터 갈무리.
- *
- * "어떤 사진을 대표로 쓰나 / 예약 링크는 어디로 가나" 같은 판단은 레이아웃과 무관하다.
- * 배리에이션마다 따로 정하면 같은 섹션인데 배리에이션을 바꾼 순간 링크가 달라진다.
- *
- * ★ 시연용 폴백은 없다.
- * 한때 이 파일은 달빛스테이(숙박 시연 세트)와 업종 시드 문구를 폴백으로 들고 있었고,
- * `demoOnly()` 게이트가 "실사업장에서만" 그걸 껐다. 그 구조 자체를 걷어냈다 —
- * 게이트가 한 곳이라도 빠지면 남의 가게 사진·전화번호·메뉴가 사장님 화면에 뜨고,
- * 사장님은 그게 자기 가게 내용인 줄 알고 그대로 발행한다.
- * 값이 없으면 **아무것도 그리지 않는다.** 발행 사이트(solution/site)와 같은 규칙이다.
- */
-import type {IndustryType, InfoField, PhotoItem} from '@o2o/shared';
-import {INDUSTRY_CONFIGS} from '@/data/industryData';
-import type {TilePhoto} from '../primitives';
-
-/** 화면에 찍히는 "언제 기준" 표기. 발행 사이트에서는 payload 의 updatedAt 이 이 자리를 대신한다. */
-export const TODAY_KO = new Date().toLocaleDateString('ko-KR', {
- year: 'numeric',
- month: 'long',
- day: 'numeric',
-});
-
-/**
- * 업종 설정(섹션 목록 · 템플릿 · 채널).
- *
- * ★ 여기에는 **구조만** 남아 있다. 문구·메뉴·FAQ 같은 콘텐츠 시드는 전부 지웠다 —
- * 가공의 가게 이름이 박힌 문장("포레스트 힐은 산자락 아래…")이 실사업장 화면에
- * 자기 소개문처럼 떴기 때문이다. 콘텐츠는 서버(fact·unit·faq)만 소유한다.
- */
-export function industryConfig(industryId: IndustryType) {
- return INDUSTRY_CONFIGS[industryId];
-}
-
-/**
- * 소개 문구.
- *
- * 수집·생성된 `intro` fact 만 쓴다. 없으면 빈 값을 돌려주고, 부르는 쪽이
- * "아직 소개문이 없습니다"를 그린다 — 남의 문장으로 자리를 메우지 않는다.
- */
-export function introParagraph(_industryId: IndustryType, infoFields: InfoField[]): string {
- const collected = infoFields.find((f) => f.id === 'intro' || f.id === 'room_intro');
- return collected?.value?.trim() ?? '';
-}
-
-/**
- * 소개 문구 축약본. `intro`/`room_intro` fact 가 길 때만 백엔드가 채워 보낸다(요청·응답에만
- * 실리고 DB 에는 남지 않는다). 없으면 빈 값 — 부르는 쪽이 원문(introParagraph)으로 떨어진다.
- */
-export function introSummary(_industryId: IndustryType, infoFields: InfoField[]): string {
- const collected = infoFields.find((f) => f.id === 'intro' || f.id === 'room_intro');
- return collected?.summary?.trim() ?? '';
-}
-
-/**
- * 이용 규정으로 읽히는 fact 들. 순서가 곧 화면 순서다.
- *
- * ★ 스키마에 있는 key 만 적는다. 여기 없는 규정은 만들지 않는다 —
- * 예전에는 '체크인 16:00 / 체크아웃 11:00' 같은 가공의 규정이 상수로 박혀 있어
- * 모든 사업장 화면에 똑같이 떴다. 사장님은 그게 자기 규정인 줄 알고 발행했다.
- */
-const RULE_FIELD_IDS = [
- 'check_in_time',
- 'check_out_time',
- 'cancel_policy',
- 'cooking_allowed',
- 'pet_allowed',
- 'smoking',
- 'extra_person_fee',
-];
-
-/** 확인된 fact 에서만 이용 규정 줄을 만든다. 없으면 빈 목록 — 부르는 쪽이 빈 상태를 그린다. */
-export function ruleItems(infoFields: InfoField[]): string[] {
- return RULE_FIELD_IDS.map((id) => infoFields.find((f) => f.id === id))
- .filter((f): f is InfoField => Boolean(f?.value?.trim()))
- .map((f) => `${f.label}: ${f.value.trim()}`);
-}
-
-/** 대표 사진. 지정이 없으면 첫 장으로 떨어진다 — 히어로가 빈 채로 나가지 않게. */
-export function primaryPhoto(photos: PhotoItem[]) {
- return photos.find((p) => p.isPrimary) ?? photos[0];
-}
-
-/** 수집된 사진(props)을 갤러리 타일 모양으로. 캡션은 사진 제목을 그대로 쓴다. */
-function toTiles(photos: PhotoItem[]): TilePhoto[] {
- return photos.map((p) => ({
- id: p.id,
- url: p.url,
- alt: p.title,
- caption: p.title,
- category: p.category,
- }));
-}
-
-/** 갤러리에 깔 사진 — 수집된 것만. 없으면 빈 목록이고, 부르는 쪽이 빈 상태를 그린다. */
-export function galleryPhotos(_industryId: IndustryType, photos: PhotoItem[]): TilePhoto[] {
- return toTiles(photos.filter((photo) => photo.isVisible !== false));
-}
-
-/**
- * 예약 버튼이 향할 곳.
- *
- * ★ 실사업장의 예약 채널(place_links)은 SectionRenderProps 로 흘러오지 않는다.
- * 그래서 상호 검색으로 떨어뜨린다 — 시연용 예약 링크를 붙이는 것보다 안전하다.
- * (확정된 링크를 쓰려면 계약에 채널 URL 을 얹어야 한다.)
- */
-export function bookingHref(_industryId: IndustryType, storeName: string) {
- return `https://search.naver.com/search.naver?query=${encodeURIComponent(storeName)}`;
-}
diff --git a/solution/frontend/src/features/builder/canvas/variants/daily/DailyCalendar.tsx b/solution/frontend/src/features/builder/canvas/variants/daily/DailyCalendar.tsx
deleted file mode 100644
index 75291b5..0000000
--- a/solution/frontend/src/features/builder/canvas/variants/daily/DailyCalendar.tsx
+++ /dev/null
@@ -1,164 +0,0 @@
-/**
- * 오늘의 한 장 — 뜯어 넘기는 일력.
- *
- * 이 팩에서 유일하게 캐러셀이 아니다. '오늘'은 하나여야 하니까. 다만 어제·내일이 양옆에
- * 반쯤 걸쳐 있어 넘길 수 있다는 걸 눈으로 알려 준다.
- */
-import {useMemo, useState} from 'react';
-import {SectionBody, SectionFrame} from '../../primitives';
-import type {SectionRenderProps} from '../../types';
-import {parseSectionData, type DailyItem} from '@o2o/shared';
-import {
- CarouselNav,
- ITEM_ACCENT,
- ITEM_BODY,
- ITEM_BORDER,
- ITEM_CARD,
- ITEM_HEADING,
- ITEM_SURFACE,
- ParseError,
- PasteHint,
- SourceLine,
-} from '../items/common';
-import '../items/items.css';
-
-const DOW = ['일', '월', '화', '수', '목', '금', '토'];
-
-function todayMonthDay(): string {
- const now = new Date();
- return `${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
-}
-
-/** MM-DD 를 올해 날짜로 읽어 요일을 낸다. 형식이 아니면 undefined — 지어내지 않는다. */
-function dowOf(monthDay: string): string | undefined {
- const m = /^(\d{2})-(\d{2})$/.exec(monthDay.trim());
- if (!m) return undefined;
- const date = new Date(new Date().getFullYear(), Number(m[1]) - 1, Number(m[2]));
- if (Number.isNaN(date.getTime())) return undefined;
- return DOW[date.getDay()];
-}
-
-function CalendarPage({item, muted}: {item: DailyItem; muted?: boolean}) {
- const [month, day] = item.monthDay.split('-');
- const dow = dowOf(item.monthDay);
-
- return (
-
- 오늘 날짜에 맞는 장이 자동으로 펼쳐집니다 · 총 {items.length}장
- {parsed.unverified > 0 && ` · 확인 필요 ${parsed.unverified}장`}
-
-
- )}
-
-
- );
-}
diff --git a/solution/frontend/src/features/builder/canvas/variants/exhibition/ExhibitionGallery.tsx b/solution/frontend/src/features/builder/canvas/variants/exhibition/ExhibitionGallery.tsx
deleted file mode 100644
index a5e19cb..0000000
--- a/solution/frontend/src/features/builder/canvas/variants/exhibition/ExhibitionGallery.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-/**
- * 관람 안내 · 갤러리 — 전시 사진을 크게 깔고 관람 정보를 옆에 붙인다.
- */
-import {Clock, Ticket} from 'lucide-react';
-import {
- FeatureCard,
- PhotoTile,
- SectionBody,
- SectionFrame,
- SectionHeading,
-} from '../../primitives';
-import type {SectionRenderProps} from '../../types';
-import {galleryPhotos} from '../common';
-
-export function ExhibitionGallery(props: SectionRenderProps) {
- const {section, isSelected, onSelect, industryId, template, photos, infoFields} = props;
- const items = galleryPhotos(industryId, photos).slice(0, 4);
- const hours = infoFields.find((f) => f.id === 'operating_hours');
- const fee = infoFields.find((f) => f.id === 'admission' || f.id === 'price_range');
-
- return (
-
-
-
-
-
- {items.map((photo) => (
-
- ))}
-
-
-
- {hours && (
-
- )}
- {fee && (
-
- )}
-
-
-
- );
-}
diff --git a/solution/frontend/src/features/builder/canvas/variants/exhibition/ExhibitionNotice.tsx b/solution/frontend/src/features/builder/canvas/variants/exhibition/ExhibitionNotice.tsx
deleted file mode 100644
index cc1f896..0000000
--- a/solution/frontend/src/features/builder/canvas/variants/exhibition/ExhibitionNotice.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-/**
- * 관람 안내 · 공지 — 사진 없이 안내 문구만. 전시 일정이 자주 바뀌는 곳에 부담이 적다.
- */
-import {Info} from 'lucide-react';
-import {SectionBody, SectionFrame, SectionHeading} from '../../primitives';
-import type {SectionRenderProps} from '../../types';
-
-export function ExhibitionNotice(props: SectionRenderProps) {
- const {section, isSelected, onSelect, storeName, template, infoFields} = props;
- const notices = infoFields
- .filter((f) => !f.requiresVerification || f.isVerified)
- .slice(0, 4);
-
- return (
-
-
- }
- colors={template.colors}
- />
-
-
-
- {section.body || `${storeName}의 상설 전시와 아트숍은 별도 예약 없이 관람하실 수 있습니다. 기획 전시 일정은 변경될 수 있으니 방문 전 확인해 주세요.`}
-
-
-
- {notices.map((field) => (
-
-
{field.label}
-
- {field.value}
-
-
- ))}
-
-
-
-
- );
-}
diff --git a/solution/frontend/src/features/builder/canvas/variants/faq/FaqAccordion.tsx b/solution/frontend/src/features/builder/canvas/variants/faq/FaqAccordion.tsx
deleted file mode 100644
index 5128e48..0000000
--- a/solution/frontend/src/features/builder/canvas/variants/faq/FaqAccordion.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- * FAQ · 아코디언 — 질문만 보이고 누르면 답이 열린다. 항목이 많아도 화면이 짧다.
- */
-import {useState} from 'react';
-import {ChevronDown, ChevronUp} from 'lucide-react';
-import {ListCard, SectionBody, SectionFrame, SectionHeading} from '../../primitives';
-import type {SectionRenderProps} from '../../types';
-import {faqEntries} from './useFaqList';
-
-export function FaqAccordion(props: SectionRenderProps) {
- const {section, isSelected, onSelect, industryId, template} = props;
- const faqs = faqEntries(industryId);
- const [openIdx, setOpenIdx] = useState(0);
-
- return (
-
-
-
-
-
- {faqs.map((faq, idx) => {
- const isOpen = openIdx === idx;
-
- return (
-
-
-
- {isOpen && (
-
- {faq.answer}
-
- )}
-
- );
- })}
-
-
-
- );
-}
diff --git a/solution/frontend/src/features/builder/canvas/variants/faq/FaqOpenList.tsx b/solution/frontend/src/features/builder/canvas/variants/faq/FaqOpenList.tsx
deleted file mode 100644
index 3241225..0000000
--- a/solution/frontend/src/features/builder/canvas/variants/faq/FaqOpenList.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-/**
- * FAQ · 펼친 목록 — 질문과 답을 모두 열어 둔다.
- * 손님이 클릭 없이 훑고 지나가게 하고 싶을 때(검색 노출에도 유리하다).
- */
-import {SectionBody, SectionFrame, SectionHeading} from '../../primitives';
-import type {SectionRenderProps} from '../../types';
-import {faqEntries} from './useFaqList';
-
-export function FaqOpenList(props: SectionRenderProps) {
- const {section, isSelected, onSelect, industryId, template} = props;
- const faqs = faqEntries(industryId);
-
- return (
-
-
-
-
-
- {faqs.map((faq) => (
-
-
-
- Q.
-
- {faq.question}
-
-
- A.
- {faq.answer}
-
-
- ))}
-
-
-
- );
-}
diff --git a/solution/frontend/src/features/builder/canvas/variants/faq/FaqTwoColumn.tsx b/solution/frontend/src/features/builder/canvas/variants/faq/FaqTwoColumn.tsx
deleted file mode 100644
index f3214b1..0000000
--- a/solution/frontend/src/features/builder/canvas/variants/faq/FaqTwoColumn.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-/**
- * FAQ · 2단 — 넓은 화면에서 두 줄씩 나눠 세로 길이를 반으로 줄인다.
- */
-import {SectionBody, SectionFrame, SectionHeading} from '../../primitives';
-import type {SectionRenderProps} from '../../types';
-import {faqEntries} from './useFaqList';
-
-export function FaqTwoColumn(props: SectionRenderProps) {
- const {section, isSelected, onSelect, industryId, template} = props;
- const faqs = faqEntries(industryId);
-
- return (
-
-
-
-
-
- {faqs.map((faq) => (
-
-
- {faq.question}
-
-
{faq.answer}
-
- ))}
-
-
-
- );
-}
diff --git a/solution/frontend/src/features/builder/canvas/variants/faq/useFaqList.ts b/solution/frontend/src/features/builder/canvas/variants/faq/useFaqList.ts
deleted file mode 100644
index 0d5039c..0000000
--- a/solution/frontend/src/features/builder/canvas/variants/faq/useFaqList.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import type {IndustryType} from '@o2o/shared';
-
-export interface FaqEntry {
- question: string;
- answer: string;
-}
-
-/**
- * FAQ 원본.
- *
- * ★ 지금은 항상 빈 목록이다. 캔버스 계약(SectionRenderProps)에 FAQ 가 없기 때문이다 —
- * 에디터는 구조적으로 실제 FAQ 를 받을 창구가 없다.
- *
- * ★ 업종 시드 FAQ 를 폴백으로 쓰던 코드를 걷어냈다. 그 안에는 가공의 값이 가격까지 붙어 있었고
- * ("숯과 그릴 세트(25,000원)", "기준 2인 초과 시 1인당 30,000원"), 사장님이 팔지도 않는
- * 조건을 자기 사이트로 읽었다. 손님이 그 금액으로 오면 클레임이다.
- *
- * 진짜 FAQ 는 COPY 잡이 **확인된 fact 만 근거로** 만들고 사장님 승인을 거쳐
- * 서버(fact.faqs)에 들어간다 — 그게 유일한 출처다. 캔버스로 흘려보내려면
- * SectionRenderProps 에 faqs 를 얹어야 한다.
- */
-export function faqEntries(_industryId: IndustryType): FaqEntry[] {
- return [];
-}
diff --git a/solution/frontend/src/features/builder/canvas/variants/hero/HeroCover.tsx b/solution/frontend/src/features/builder/canvas/variants/hero/HeroCover.tsx
deleted file mode 100644
index 3414102..0000000
--- a/solution/frontend/src/features/builder/canvas/variants/hero/HeroCover.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-/**
- * 히어로 · 표지 — 사진 한 장 위, 글은 좌하단.
- *
- * ★ 왜 가운데가 아닌가
- * 사진이 주인공인 화면에서 글자를 한가운데 얹으면 피사체를 정확히 가린다. 숙소 사진은
- * 가운데에 방이나 사람이 오는데, 상호가 그 위에 앉으면 둘 다 못 읽는다. 아래로 내리고
- * 그쪽만 어둡게 덮으면 사진은 사진대로 남고 글자는 글자대로 읽힌다.
- *
- * ★ 버튼을 세우지 않는다
- * 첫 화면에서 물어볼 것은 "방을 보겠는가" 하나다. 예약 버튼은 아래 예약 섹션이 맡고,
- * 여기서는 다음 섹션으로 눈을 내려보내기만 한다.
- *
- * ★ 색을 직접 쓰지 않는다
- * `bg-stone-900` 같은 고정색을 두면 '옛 항구'(갱지)를 골라도 첫 화면만 검게 남는다.
- * 어두운 면은 `--tpl-inverse`, 그 위 글자는 `--tpl-bg` 다 — 팔레트가 바뀌면 같이 바뀐다.
- */
-import {ChevronDown, MapPin} from 'lucide-react';
-import {SectionFrame} from '../../primitives';
-import type {SectionRenderProps} from '../../types';
-import {primaryPhoto} from '../common';
-
-/** 사진 위 글자가 놓이는 아래쪽만 짙게. 위쪽은 사진을 그대로 보여준다. */
-const SCRIM =
- 'linear-gradient(to top,' +
- ' color-mix(in srgb, var(--tpl-inverse, #1c1917) 88%, transparent) 0%,' +
- ' color-mix(in srgb, var(--tpl-inverse, #1c1917) 45%, transparent) 34%,' +
- ' transparent 66%)';
-
-export function HeroCover(props: SectionRenderProps) {
- const {section, isSelected, onSelect, storeName, location, photos} = props;
-
- // 사진이 없으면 없이 어두운 판만 남긴다 — 시연용 사진으로 자리를 메우지 않는다.
- const cover = primaryPhoto(photos);
-
- return (
-
-
- {cover?.url && (
-
-
-
-
- )}
-
-
- {location && (
-
-
- {location}
-
- )}
-
- {/* ★ tpl-title — 굵기를 템플릿이 정한다. 간판체(Gugi)는 굵기가 한 벌뿐이라
- 700 을 주면 브라우저가 가짜 볼드를 씌워 획이 뭉갠다. */}
-
- {storeName}
-
-
- {/*
- * ★ 여기에 `section.description` 을 쓰지 않는다.
- * 그 칸은 에디터의 섹션 설명("상단 메인 비주얼과 대표 문구")이라, 캔버스에 그리면
- * 사장님 화면에 우리 UI 안내문이 자기 소개문처럼 박힌다. 실제로 그렇게 나갔다.
- * 대표 문구는 수집·생성된 값이 생기기 전까지 **아무것도 그리지 않는다.**
- */}
-
-
- 객실 보기
-
-
-
-
-
- );
-}
diff --git a/solution/frontend/src/features/builder/canvas/variants/hero/HeroSplit.tsx b/solution/frontend/src/features/builder/canvas/variants/hero/HeroSplit.tsx
deleted file mode 100644
index f4d4e64..0000000
--- a/solution/frontend/src/features/builder/canvas/variants/hero/HeroSplit.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-/**
- * 히어로 · 좌우 분할 — 왼쪽 글, 오른쪽 사진.
- * 첫 화면에서 "무엇을 파는 곳인지"를 글로 먼저 읽혀야 하는 업종에 맞는다.
- */
-import {SectionFrame} from '../../primitives';
-import type {SectionRenderProps} from '../../types';
-import {industryConfig, primaryPhoto} from '../common';
-
-export function HeroSplit(props: SectionRenderProps) {
- const {section, isSelected, onSelect, industryId, storeName, location, template, photos} = props;
- const config = industryConfig(industryId);
- const cover = primaryPhoto(photos);
-
- return (
-
-
- );
-}
-
-/**
- * 앞뒤 버튼 한 쌍.
- *
- * ★ 가로 슬라이더에는 쓰지 않는다 — 그건 `Rail`(embla)이 자기 화살표를 갖는다.
- * 여기 남은 쓰임은 **스크롤이 아닌 이동**이다(일력: 장을 넘겨 오늘 자리를 바꾼다).
- */
-export function CarouselNav({
- onPrev,
- onNext,
- tone = 'light',
- label,
-}: {
- onPrev: () => void;
- onNext: () => void;
- tone?: 'light' | 'dark';
- label: string;
-}) {
- const dark = tone === 'dark';
- const style = {
- borderColor: dark ? 'color-mix(in oklab, currentColor 35%, transparent)' : ITEM_BORDER,
- backgroundColor: dark ? 'transparent' : ITEM_CARD,
- color: 'inherit',
- };
- const stop = (fn: () => void) => (event: React.MouseEvent) => {
- event.stopPropagation();
- fn();
- };
-
- return (
-
-
-
-
- );
-}
-
-/**
- * 붙여넣을 JSON 이 아직 없을 때 — 어디로 가야 하는지 말해 준다.
- *
- * ★ 그냥 "준비 중"이라고 두면 사장님은 이 섹션이 자동으로 채워지는 줄 알고 기다린다.
- * ★ 이건 발행되지 않는 **에디터 안내**다. 그래서 템플릿 색이 아니라 관리자 색을 그대로 쓴다.
- */
-export function PasteHint({label}: {label: string}) {
- return (
-
-
-
{label} 내용이 아직 없습니다
-
- 오른쪽 [콘텐츠] 탭에서 프롬프트를 복사해 ChatGPT 에 넣고, 받은 JSON 을 붙여넣으면 바로 여기에 그려집니다.
-
-
- );
-}
-
-/** 파싱이 깨졌을 때. 캔버스를 비우지 않고 왜 안 그려지는지 그 자리에 말한다(에디터 안내라 관리자 색). */
-export function ParseError({message}: {message: string}) {
- return (
-
-
붙여넣은 JSON 을 읽지 못했습니다
-
{message}
-
- );
-}
diff --git a/solution/frontend/src/features/builder/canvas/variants/items/items.css b/solution/frontend/src/features/builder/canvas/variants/items/items.css
deleted file mode 100644
index ce6e257..0000000
--- a/solution/frontend/src/features/builder/canvas/variants/items/items.css
+++ /dev/null
@@ -1,126 +0,0 @@
-/**
- * 붙여넣기 아이템의 질감 — Tailwind 로는 못 그리는 것만 여기 둔다.
- * 도넛판 홈, 종이 결, 톱니, 필름 구멍, 시험지 뒤집기.
- *
- * ★ 색은 전부 템플릿 토큰(--tpl-*)에서 받는다. 한때 갱지색·먹색을 hex 로 박아 뒀는데,
- * 템플릿을 바꿔도 이 질감들만 레트로 색으로 남아 화면이 두 벌로 보였다.
- */
-
-/* 종이 결 — 두 방향이 겹쳐야 종이로 읽힌다. 글자색을 옅게 깔아 어떤 팔레트에서도 결이 보인다. */
-.w4-paper {
- background-image:
- repeating-linear-gradient(0deg, color-mix(in oklab, currentcolor 4%, transparent) 0 1px, transparent 1px 3px),
- repeating-linear-gradient(90deg, color-mix(in oklab, currentcolor 3%, transparent) 0 1px, transparent 1px 4px);
-}
-
-/* 도넛판. --lbl 이 라벨 색이다. */
-.w4-disc {
- /* 판의 검정은 템플릿의 어두운 면(--tpl-inverse)이다. 홈은 그 위에 밝기 차로만 판다. */
- background:
- radial-gradient(circle at 50% 50%, transparent 0 15.5%, var(--lbl) 15.5% 33%, transparent 33%),
- repeating-radial-gradient(
- circle at 50% 50%,
- color-mix(in oklab, var(--w4-vinyl) 88%, white) 0 1.4px,
- var(--w4-vinyl) 1.4px 3px
- ),
- var(--w4-vinyl);
- box-shadow: inset 0 0 40px rgb(0 0 0 / 55%);
-}
-.w4-disc-sheen {
- background: conic-gradient(
- from 210deg,
- rgb(255 255 255 / 12%),
- transparent 22%,
- transparent 70%,
- rgb(255 255 255 / 7%)
- );
-}
-.w4-spin {
- animation: w4-rev 2.2s linear infinite;
-}
-@keyframes w4-rev {
- to {
- transform: rotate(360deg);
- }
-}
-
-/* 미니 판 — 캐러셀에 늘어서는 작은 것. */
-.w4-disc-mini {
- background:
- radial-gradient(circle at 50% 50%, transparent 0 14%, var(--lbl) 14% 34%, transparent 34%),
- repeating-radial-gradient(
- circle at 50% 50%,
- color-mix(in oklab, var(--w4-vinyl) 88%, white) 0 1.2px,
- var(--w4-vinyl) 1.2px 2.6px
- ),
- var(--w4-vinyl);
- box-shadow: 0 3px 10px rgb(0 0 0 / 35%);
-}
-
-/* 일력 톱니 — 뜯어낸 자국. --tear 는 섹션 바탕색이 들어온다(뜯긴 자리로 바탕이 비쳐야 한다). */
-.w4-perf {
- background: repeating-linear-gradient(90deg, transparent 0 8px, var(--tear) 8px 9px);
-}
-
-/* 승차권 절취선 */
-.w4-dash {
- border-top: 1px dashed currentcolor;
-}
-
-/* 필름 퍼포레이션 — 위아래 구멍이 있어야 한 장면이 아니라 '롤'로 읽힌다.
- 구멍은 currentColor(어두운 면 위의 글자색)로 뚫어 팔레트를 따른다. */
-.w4-film-perf {
- background: repeating-linear-gradient(90deg, currentcolor 0 9px, transparent 9px 21px);
-}
-
-/* 시험지 뒤집기. 카드가 얇아 보이지 않게 두 면을 같은 자리에 겹쳐 둔다. */
-.w4-flip {
- perspective: 900px;
-}
-.w4-flip-inner {
- transform-style: preserve-3d;
- transition: transform 0.5s;
-}
-.w4-flip-on .w4-flip-inner {
- transform: rotateY(180deg);
-}
-.w4-flip-face {
- backface-visibility: hidden;
-}
-.w4-flip-back {
- transform: rotateY(180deg);
-}
-
-/*
- * 가로 슬라이더의 창(viewport).
- *
- * 스크립트가 붙기 전에는 그냥 가로 스크롤 상자다. embla 가 붙으면 `data-slider="on"` 이
- * 걸리고 그때부터 드래그가 스크롤을 대신한다 — 둘을 같이 켜면 관성이 겹쳐 튄다.
- */
-.w4-scroll {
- overflow-x: auto;
- scrollbar-width: none;
- scroll-snap-type: x mandatory;
- -webkit-overflow-scrolling: touch;
-}
-.w4-scroll::-webkit-scrollbar {
- display: none;
-}
-.w4-scroll[data-slider='on'] {
- overflow-x: hidden;
- scroll-snap-type: none;
-}
-/* 문장을 잡으면 카드가 안 끌린다 — 끄는 동안만 선택을 끈다. */
-.w4-scroll[data-dragging='true'] {
- cursor: grabbing;
- user-select: none;
-}
-
-@media (prefers-reduced-motion: reduce) {
- .w4-spin {
- animation: none;
- }
- .w4-flip-inner {
- transition: none;
- }
-}
diff --git a/solution/frontend/src/features/builder/canvas/variants/itinerary/ItineraryTickets.tsx b/solution/frontend/src/features/builder/canvas/variants/itinerary/ItineraryTickets.tsx
deleted file mode 100644
index 94b03c5..0000000
--- a/solution/frontend/src/features/builder/canvas/variants/itinerary/ItineraryTickets.tsx
+++ /dev/null
@@ -1,194 +0,0 @@
-/**
- * 추천 일정 — 캔버스. 승차권 한 장이 정거장 하나다.
- *
- * ★ 셋을 합친 자리다(반나절 산책 · 여행 스케줄 · 계절별 추천 하루). 축이 계절이 아니라 **기간**이다.
- * ★ 순위를 매기지 않는다 — 어느 일정이 1위인지는 우리가 정할 일이 아니다.
- * ★ 시각은 사장님이 적는 게 아니라 **계산한다**. 출발 시각 + 이동 + 머무는 시간.
- * 출발을 당기면 하루가 통째로 밀린다.
- * ★ 사장님이 붙여넣은 것이 없으면 **서버가 만든 일정**을 쓴다(useLocalGuide). 발행본
- * ItinerarySection 과 같은 폴백이다 — 둘이 갈리면 "미리보기와 다르다"가 된다.
- */
-import {
- currentSeasons,
- inSeason,
- itineraryDays,
- itineraryDurations,
- parseSectionData,
- type ItineraryItem,
- type PlannedStop,
-} from '@o2o/shared';
-import {useLocalGuide} from '@/hooks/useLocalGuide';
-import {useBuilderStore} from '@/stores/builder';
-import {SectionBody, SectionFrame, Rail} from '../../primitives';
-import type {SectionRenderProps} from '../../types';
-import {
- ITEM_ACCENT,
- ITEM_BODY,
- ITEM_BORDER,
- ITEM_CARD,
- ITEM_HEADING,
- ParseError,
- PasteHint,
- SourceLine,
-} from '../items/common';
-import '../items/items.css';
-
-export function ItineraryTickets(props: SectionRenderProps) {
- const {section, isSelected, onSelect} = props;
- const parsed = parseSectionData(section.type, section.data);
- const guide = useLocalGuide();
- const placeName = useBuilderStore((s) => s.storeName);
- // 사장님이 적은 것이 언제나 이긴다 — 서버가 만든 건 비었을 때의 기본값이다.
- const items = parsed.items.length > 0 ? parsed.items : guide.itineraries;
- const live = currentSeasons();
- const durations = itineraryDurations(items);
- const groups: (string | undefined)[] =
- durations.length > 0
- ? [...durations, ...(items.some((i) => !i.duration?.trim()) ? [undefined] : [])]
- : [undefined];
-
- return (
-
-
-
- );
-}
diff --git a/solution/frontend/src/features/builder/canvas/variants/local/types.ts b/solution/frontend/src/features/builder/canvas/variants/local/types.ts
deleted file mode 100644
index 032f1ee..0000000
--- a/solution/frontend/src/features/builder/canvas/variants/local/types.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * 지역 가이드 카드 한 장의 모양(맛집·명소·축제 공통).
- *
- * ★ 값은 서버(local.place_contents)가 소유한다. hooks/useLocalGuide 가 GET /v1/local/guide
- * 응답(발행 payload 와 같은 모양)을 이 타입으로 매핑한다.
- * ★ 도보 시간 배지·필터는 distanceMeters 로 계산한다(walking.ts::walkMinutes, 분속 80m).
- * distanceMeters 가 없으면(지역 캐시에서 온 수기 항목 등) 배지·필터 대상에서 빠진다 —
- * 업장 기준 거리를 모르는 값으로 "도보 N분"을 지어내지 않는다.
- */
-export interface GuideCard {
- name: string;
- description: string;
- /** 상세 페이지 대신 검색으로 보낸다 — 없는 주소를 지어내지 않기 위해서다. */
- searchQuery: string;
- imageUrl?: string;
- distanceMeters?: number;
- /** 화면 배지에 그대로 쓰는 거리 문자열("850m"/"1.2km"). */
- distanceText?: string;
-}
-
-/** 축제는 기간·월 배지가 더 붙는다. */
-export interface FestivalCard extends GuideCard {
- month: string;
- period: string;
- officialUrl?: string;
-}
diff --git a/solution/frontend/src/features/builder/canvas/variants/local/walking.ts b/solution/frontend/src/features/builder/canvas/variants/local/walking.ts
deleted file mode 100644
index 5d9a508..0000000
--- a/solution/frontend/src/features/builder/canvas/variants/local/walking.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-/**
- * 도보 시간 환산 — 직선거리(m) → 분.
- *
- * ★ 분속 80m 로 나눈다(보통 걸음 4.8km/h). 스크린샷 문구와 같은 기준이며 화면 하단에도 그대로 적는다:
- * "도보 시간은 숙소에서 잰 직선거리를 분속 80m 로 환산한 값입니다. 실제로 걷는 길은 이보다 길 수 있습니다."
- * ★ 실측 검증(2026-09-07): 135m→2분 · 297m→4분 · 305m→4분 · 566m→7분 · 13m→1분 — 반올림 + 최소 1분.
- */
-export const WALK_METERS_PER_MINUTE = 80;
-
-export const WALK_DISCLAIMER =
- `도보 시간은 숙소에서 잰 직선거리를 분속 ${WALK_METERS_PER_MINUTE}m 로 환산한 값입니다. 실제로 걷는 길은 이보다 길 수 있습니다.`;
-
-export function walkMinutes(meters: number): number {
- return Math.max(1, Math.round(meters / WALK_METERS_PER_MINUTE));
-}
-
-/**
- * 도보 시간 필터. ★ 구간은 배타적이 아니라 **누적**이다 — "10분 이내"는 "5분 이내"를 포함한다.
- * (스크린샷 수치 8·19·5 → 19+5=24=전체 로 확인. "5분 이내"는 "10분 이내"의 부분집합.)
- */
-export type WalkFilterKey = 'all' | 'within5' | 'within10' | 'over10';
-
-export const WALK_FILTERS: {key: WalkFilterKey; label: string; test: (minutes: number) => boolean}[] = [
- {key: 'all', label: '전체', test: () => true},
- {key: 'within5', label: '걸어서 5분 이내', test: (m) => m <= 5},
- {key: 'within10', label: '걸어서 10분 이내', test: (m) => m <= 10},
- {key: 'over10', label: '걸어서 10분 이상', test: (m) => m > 10},
-];
-
-/** 거리를 모르는 항목은 '전체'에만 들어간다 — 모르는 값으로 구간을 정하지 않는다. */
-export function matchesWalkFilter(key: WalkFilterKey, meters: number | undefined): boolean {
- if (key === 'all') return true;
- if (meters === undefined) return false;
- const filter = WALK_FILTERS.find((f) => f.key === key);
- return filter ? filter.test(walkMinutes(meters)) : true;
-}
diff --git a/solution/frontend/src/features/builder/canvas/variants/map/MapCompact.tsx b/solution/frontend/src/features/builder/canvas/variants/map/MapCompact.tsx
deleted file mode 100644
index 3c65533..0000000
--- a/solution/frontend/src/features/builder/canvas/variants/map/MapCompact.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-/**
- * 오시는 길 · 간단 — 주소 한 줄과 길찾기 버튼만.
- * 도심처럼 "주소만 알면 찾아오는" 곳이면 표까지 깔 이유가 없다.
- */
-import {MapPin} from 'lucide-react';
-import {
- AddressCard,
- EmptyStateNotice,
- SectionBody,
- SectionFrame,
- SectionHeading,
-} from '../../primitives';
-import type {SectionRenderProps} from '../../types';
-
-export function MapCompact(props: SectionRenderProps) {
- const {section, isSelected, onSelect, storeName, location, template, infoFields} = props;
- // ★ props 로 온 주소만 쓴다. 없으면 카드 대신 준비 중 안내를 둔다 —
- // 시연용 주소로 떨어지면 손님을 남의 집으로 보낸다.
- const address = infoFields.find((f) => f.id === 'address')?.value ?? '';
-
- return (
-
-
-
-
- {location}
-
- }
- colors={template.colors}
- />
-
- {address ? (
-
- ) : (
- 주소는 아직 준비 중입니다.
- )}
-
-
- );
-}
diff --git a/solution/frontend/src/features/builder/canvas/variants/map/MapDetailed.tsx b/solution/frontend/src/features/builder/canvas/variants/map/MapDetailed.tsx
deleted file mode 100644
index d660420..0000000
--- a/solution/frontend/src/features/builder/canvas/variants/map/MapDetailed.tsx
+++ /dev/null
@@ -1,111 +0,0 @@
-/**
- * 오시는 길 · 상세 — 주소 카드 + 거점별 소요시간 표 + 주차/충전 안내.
- * 차로 찾아오는 손님이 많은 곳(외곽 스테이, 교외 카페)에 맞는다.
- */
-import {Car, CarFront, Zap} from 'lucide-react';
-import {
- AddressCard,
- EmptyStateNotice,
- FeatureCard,
- SectionBody,
- SectionFrame,
- SectionHeading,
-} from '../../primitives';
-import type {SectionRenderProps} from '../../types';
-
-export function MapDetailed(props: SectionRenderProps) {
- const {section, isSelected, onSelect, industryId, storeName, template, infoFields} = props;
- // ★ 주소도 소요시간표도 시연용(제주 애월)이다 — 실사업장에는 한 줄도 내보내지 않는다.
- const address =
- infoFields.find((f) => f.id === 'address')?.value ?? '';
- /**
- * 소요시간 표.
- *
- * ★ 서버(local.routes)의 산출물이고 캔버스 계약(SectionRenderProps)에 없다 —
- * 흘러올 창구가 생기기 전까지 빈다. 시연용 표(제주 애월 기준)를 깔지 않는다:
- * 남의 동네 소요시간을 보고 손님이 출발 시각을 정한다.
- */
- const travelTimes: {
- destination: string;
- byCar: string;
- byWalkOrTransit: string;
- distance: string;
- }[] = [];
- const isStay = industryId === 'stay';
-
- return (
-
-
-
-
- {address ? (
-
- ) : (
- 주소는 아직 준비 중입니다.
- )}
-
- {isStay && travelTimes.length === 0 && (
- 주요 거점 소요시간은 아직 준비 중입니다.
- )}
-
- {isStay && travelTimes.length > 0 && (
-
-
-
- 주요 거점 이동 소요시간
-
-
-
-
-
-
목적지
-
차량
-
도보 / 대중교통
-
거리
-
-
-
- {travelTimes.map((row) => (
-
-
{row.destination}
-
- {row.byCar}
-
-
{row.byWalkOrTransit}
-
- {row.distance}
-
-
- ))}
-
-
-
-
- )}
-
-
-
-
-
-
-
- );
-}
diff --git a/solution/frontend/src/features/builder/canvas/variants/menu/MenuGrid.tsx b/solution/frontend/src/features/builder/canvas/variants/menu/MenuGrid.tsx
deleted file mode 100644
index 2b1e95c..0000000
--- a/solution/frontend/src/features/builder/canvas/variants/menu/MenuGrid.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-/**
- * 메뉴 · 카드 격자 — 설명을 넉넉히 보여준다. 시그니처가 몇 개뿐일 때 강하다.
- */
-import {PriceRow, SectionBody, SectionFrame, SectionHeading} from '../../primitives';
-import type {SectionRenderProps} from '../../types';
-import {menuItems} from './useMenuItems';
-
-export function MenuGrid(props: SectionRenderProps) {
- const {section, isSelected, onSelect, industryId, template} = props;
- const items = menuItems(industryId);
-
- return (
-
-
-
-
-
- {items.map((item) => (
-
- ))}
-
-
-
- );
-}
diff --git a/solution/frontend/src/features/builder/canvas/variants/menu/MenuList.tsx b/solution/frontend/src/features/builder/canvas/variants/menu/MenuList.tsx
deleted file mode 100644
index 79be819..0000000
--- a/solution/frontend/src/features/builder/canvas/variants/menu/MenuList.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * 메뉴 · 목록 — 이름과 가격을 한 줄씩. 종이 메뉴판처럼 읽힌다.
- */
-import {ListCard, PriceRow, SectionBody, SectionFrame, SectionHeading} from '../../primitives';
-import type {SectionRenderProps} from '../../types';
-import {menuItems} from './useMenuItems';
-
-export function MenuList(props: SectionRenderProps) {
- const {section, isSelected, onSelect, industryId, template} = props;
- const items = menuItems(industryId);
-
- return (
-
-
-
-
-
- {items.map((item) => (
-
- ))}
-
-
-
- );
-}
diff --git a/solution/frontend/src/features/builder/canvas/variants/menu/MenuPriceTable.tsx b/solution/frontend/src/features/builder/canvas/variants/menu/MenuPriceTable.tsx
deleted file mode 100644
index f2e8432..0000000
--- a/solution/frontend/src/features/builder/canvas/variants/menu/MenuPriceTable.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-/**
- * 메뉴 · 가격표 — 이름과 가격만 점선으로 이어 붙인 가장 압축된 형태.
- * 품목이 많은 곳(코스 여러 개, 음료 다수)에 맞는다.
- */
-import {Pill, SectionBody, SectionFrame, SectionHeading} from '../../primitives';
-import type {SectionRenderProps} from '../../types';
-import {menuItems} from './useMenuItems';
-
-export function MenuPriceTable(props: SectionRenderProps) {
- const {section, isSelected, onSelect, industryId, template} = props;
- const items = menuItems(industryId);
-
- return (
-
-
-
-
-
- {items.map((item) => (
-
-
-
- {item.name}
- {item.tag && {item.tag}}
-
- {/* 점선 리더 — 이름과 가격 사이를 눈으로 잇는다. 선 자체는 중립 회색으로 둔다. */}
-
-
- {item.price}
-
-
- {item.desc &&
{item.desc}
}
-
- ))}
-
-
-
- );
-}
diff --git a/solution/frontend/src/features/builder/canvas/variants/menu/useMenuItems.ts b/solution/frontend/src/features/builder/canvas/variants/menu/useMenuItems.ts
deleted file mode 100644
index 29d0338..0000000
--- a/solution/frontend/src/features/builder/canvas/variants/menu/useMenuItems.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import type {IndustryType} from '@o2o/shared';
-import type {PricedItem} from '../../primitives';
-
-/**
- * 메뉴.
- *
- * ★ 지금은 항상 빈 목록이다. 메뉴는 unit(하위 단위)이고, 캔버스 계약에 units 가 없다.
- * 업종 시드의 menuList/courseList 를 폴백으로 쓰던 코드를 걷어냈다 —
- * "포레스트 크림라떼 7,500원" 이 실제로 모든 카페 화면에 떴다.
- */
-export function menuItems(_industryId: IndustryType): PricedItem[] {
- return [];
-}
diff --git a/solution/frontend/src/features/builder/canvas/variants/people/PeopleFilmstrip.tsx b/solution/frontend/src/features/builder/canvas/variants/people/PeopleFilmstrip.tsx
deleted file mode 100644
index 9237c16..0000000
--- a/solution/frontend/src/features/builder/canvas/variants/people/PeopleFilmstrip.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-/**
- * 인물 열전 — 필름 스트립.
- *
- * 프레임 하나가 인물 하나다. 사진 자리에는 **활판 이니셜**이 들어간다 —
- * 이 데이터에는 쓸 수 있는 인물 사진이 없고, 빈 회색 상자를 두면 "사진을 못 넣은 화면"으로 읽힌다.
- * 이니셜을 넣으면 없는 것이 형식이 된다.
- * ★ 이미지 URL 을 받지 않는다(dataSpec: imageQuery). 초상권 확인은 사장님 몫이라 검색어까지만 싣는다.
- */
-import {useState} from 'react';
-import {cn} from '@/lib/utils';
-import {Rail, SectionBody, SectionFrame} from '../../primitives';
-import type {SectionRenderProps} from '../../types';
-import {parseSectionData, type PeopleItem} from '@o2o/shared';
-import {
- ITEM_ACCENT,
- ITEM_BODY,
- ITEM_HEADING,
- ITEM_INVERSE,
- ParseError,
- PasteHint,
- SourceLine,
-} from '../items/common';
-import '../items/items.css';
-
-/** 이니셜 한 글자. 성이 두 글자인 이름도 첫 자만 — 프레임이 좁아 두 글자는 안 읽힌다. */
-function initialOf(name: string): string {
- return name.trim().charAt(0) || '?';
-}
-
-export function PeopleFilmstrip(props: SectionRenderProps) {
- const {section, isSelected, onSelect} = props;
- const parsed = parseSectionData(section.type, section.data);
- const [picked, setPicked] = useState(0);
-
- // 항목이 줄어 인덱스가 범위를 벗어나도 첫 사람으로 떨어진다 — 빈 화면을 만들지 않는다.
- const current = parsed.items[picked] ?? parsed.items[0];
-
- return (
-
- {/* ★ 어두운 면 위의 글자색을 여기서 한 번만. 필름 구멍(w4-film-perf)도 이 색을 따라간다. */}
-
-
- )}
-
-
- );
-}
diff --git a/solution/frontend/src/features/builder/canvas/variants/rooms/RoomCard.tsx b/solution/frontend/src/features/builder/canvas/variants/rooms/RoomCard.tsx
deleted file mode 100644
index 88e50cb..0000000
--- a/solution/frontend/src/features/builder/canvas/variants/rooms/RoomCard.tsx
+++ /dev/null
@@ -1,171 +0,0 @@
-/**
- * 객실 한 칸.
- *
- * 슬라이드든 격자든 목록이든 "객실 한 개"를 보여주는 방식은 같아야 한다 —
- * 카드 안쪽이 배리에이션마다 다르면 사장님이 레이아웃을 바꿀 때 정보가 사라진 것처럼 보인다.
- */
-import {ExternalLink, Sparkles} from 'lucide-react';
-import type {TemplateItem} from '@o2o/shared';
-import {cn} from '@/lib/utils';
-import {CtaLink, Pill} from '../../primitives';
-
-/**
- * 객실 한 건.
- *
- * ★ 이 타입은 원래 시연용 데이터 파일(data/stayData.ts)이 소유했다. 그 파일을 지우면서
- * 실제로 쓰는 쪽인 여기로 옮겼다 — 타입이 데이터 시드에 매여 있을 이유가 없다.
- * 서버 unit 이 캔버스로 흘러오게 되면 이 모양에 맞춰 매핑하면 된다.
- */
-export interface Room {
- slug: string;
- name: string;
- subName: string;
- type: string;
- capacity: string;
- bedInfo: string;
- size: string;
- price: string;
- weekdayPrice: string;
- weekendPrice: string;
- /** 다른 객실과의 차이 한 줄 */
- difference: string;
- description: string;
- features: string[];
- amenities: string[];
- images: {url: string; alt: string}[];
-}
-
-export function RoomCard({
- room,
- index,
- total,
- bookingUrl,
- layout = 'card',
- colors,
- className,
-}: {
- room: Room;
- index: number;
- total: number;
- bookingUrl: string;
- /** card = 사진 위 / 정보 아래, row = 사진 왼쪽 / 정보 오른쪽 */
- layout?: 'card' | 'row';
- /**
- * 템플릿 색 토큰. 객실명·요금·예약 버튼이 브랜드 색을 따른다.
- *
- * ★ "차별점" 상자의 호박색은 그대로 둔다 — 카드 안에서 유일하게 눈을 끄는 자리라,
- * 토큰을 먹이면 제목·요금과 같은 색이 되어 강조가 사라진다.
- */
- colors?: TemplateItem['colors'];
- className?: string;
-}) {
- const specs = (
-
- );
-}
-
-function CarouselArrow({
- side,
- disabled,
- onClick,
-}: {
- side: 'left' | 'right';
- disabled: boolean;
- onClick: () => void;
-}) {
- const Icon = side === 'left' ? ChevronLeft : ChevronRight;
- return (
-
- );
-}
diff --git a/solution/frontend/src/features/dev/mockProps.ts b/solution/frontend/src/features/dev/mockProps.ts
deleted file mode 100644
index 09b5c2a..0000000
--- a/solution/frontend/src/features/dev/mockProps.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-/**
- * 쇼케이스용 목업 props — 배리에이션 하나를 스토어 없이 그리기 위한 최소 입력.
- *
- * 캔버스(CanvasView)는 빌더 스토어에서 props 를 모으지만, 쇼케이스는 스토어를 건드리면 안 된다 —
- * 개발자가 색을 구경하다가 사장님이 편집 중이던 사이트 상태를 바꿔 버리면 곤란하다.
- * 그래서 여기서 업종 기본 데이터로 같은 모양의 props 를 직접 만든다.
- */
-import type {IndustryType, SectionItem, TemplateItem} from '@o2o/shared';
-import {INDUSTRY_CONFIGS} from '@/data/industryData';
-import {dataSpecFor} from '@/features/builder/canvas/dataSpec';
-import type {SectionRenderProps} from '@/features/builder/canvas/types';
-import type {SectionVariant} from '@/features/builder/canvas/types';
-import {asTemplate, type TokenGroup} from './tokenGroups';
-
-/** 업종의 첫 템플릿 — 토큰 그룹이 색만 갈아끼울 바탕이 된다. */
-export function baseTemplate(industry: IndustryType): TemplateItem {
- return INDUSTRY_CONFIGS[industry].templates[0];
-}
-
-/**
- * 섹션 메타를 만든다.
- *
- * 업종 기본 섹션 목록에 같은 타입이 있으면 그걸 쓴다(이름·잠금 상태가 실제와 같아진다).
- * 없으면 최소 형태로 지어낸다 — 그 업종 메뉴엔 없지만 배리에이션은 존재하는 경우다.
- */
-function sectionFor(sectionType: string, industry: IndustryType, variantId: string): SectionItem {
- // 붙여넣기 아이템은 JSON 이 없으면 "붙여넣으세요" 안내만 뜬다 — 디자인을 보러 온 화면에선 예시를 얹는다.
- const sample = dataSpecFor(sectionType)?.sample;
- const found = INDUSTRY_CONFIGS[industry].sections.find((s) => s.type === sectionType);
- if (found) return {...found, isEnabled: true, variantId, ...(sample ? {data: sample} : {})};
- return {
- id: sectionType,
- type: sectionType,
- name: sectionType,
- isLocked: false,
- isEnabled: true,
- variantId,
- ...(sample ? {data: sample} : {}),
- };
-}
-
-/**
- * 쇼케이스 전용 자리표시 값.
- *
- * ★ 실제 업소처럼 보이는 값을 쓰지 않는다. 예전에는 업종 시드('달빛스테이 제주',
- * '포레스트 힐 로스터스')를 그대로 썼는데, 그 값들이 캔버스 배리에이션에도
- * 폴백으로 새어 사장님 화면에 남의 가게가 떴다. 그래서 시드에서 콘텐츠를 걷어냈고,
- * 쇼케이스는 한눈에 가짜인 값을 **여기서만** 들고 간다 — 이 파일은 DEV 라우트
- * (`/dev/showcase`, app/router.tsx)에서만 로드된다.
- */
-const SHOWCASE_VALUES = {
- storeName: '샘플 상호',
- location: '샘플 지역',
- infoFields: [],
- photos: [],
-};
-
-/** 배리에이션 하나를 그릴 props 한 벌. */
-export function mockPropsFor(
- sectionType: string,
- variant: SectionVariant,
- industry: IndustryType,
- group: TokenGroup,
-): SectionRenderProps {
- return {
- section: sectionFor(sectionType, industry, variant.id),
- industryId: industry,
- ...SHOWCASE_VALUES,
- template: asTemplate(group, baseTemplate(industry)),
- // 쇼케이스는 "고르는" 화면이 아니다 — 선택 링·배지가 뜨면 디자인을 가린다.
- isSelected: false,
- onSelect: () => {},
- };
-}
diff --git a/solution/frontend/src/features/dev/tokenGroups.ts b/solution/frontend/src/features/dev/tokenGroups.ts
deleted file mode 100644
index 712fbff..0000000
--- a/solution/frontend/src/features/dev/tokenGroups.ts
+++ /dev/null
@@ -1,340 +0,0 @@
-/**
- * 디자인 토큰 그룹 — "색·모서리 한 세트"를 이름 붙여 저장하고 통째로 갈아끼운다.
- *
- * 배리에이션 레지스트리가 "레이아웃의 단일 출처"라면, 여기는 "색의 단일 출처"다.
- * 쇼케이스에서 그룹을 바꾸면 가운데 미리보기 전체가 그 그룹으로 다시 그려진다.
- *
- * ★ 내장 그룹은 업종 템플릿(INDUSTRY_CONFIGS[*].templates)에서 그대로 끌어온다 —
- * 쇼케이스용으로 색을 따로 적어두면 실제 템플릿과 갈라져서, 여기서 예뻐도 캔버스에선 다르다.
- */
-import type {IndustryType, TemplateItem} from '@o2o/shared';
-import {INDUSTRY_CONFIGS} from '@/data/industryData';
-import {derivePalette} from './palette';
-
-export type TokenColors = TemplateItem['colors'];
-
-/**
- * 면(surface) 토큰 — **화면 면적의 대부분**을 차지하는 색이다.
- *
- * ★ 색 6개(TemplateItem.colors)만으로는 팔레트를 바꿔도 인상이 안 바뀐다.
- * 섹션 바탕과 카드 면이 하드코딩돼 있었기 때문이다. 그걸 여기로 끌어냈다.
- * `white` 톤은 일부러 진짜 흰색으로 남긴다 — surface/surfaceAlt 와 함께 세 단계가 있어야
- * 섹션이 서로 구분되는 리듬이 생긴다.
- */
-export interface TokenSurfaces {
- /** 섹션 기본 바탕(paper 톤). */
- surface: string;
- /** 섹션 대체 바탕(tint 톤) — 위아래 섹션과 다르게 보이게 하는 색. */
- surfaceAlt: string;
- /** 어두운 섹션(dark 톤). 흰 글씨를 얹는다. */
- inverse: string;
- /** 카드·표 테두리. */
- border: string;
-}
-
-export interface TokenFonts {
- heading: string;
- body: string;
-}
-
-/**
- * 고를 수 있는 서체.
- *
- * ★ 새 폰트를 내려받지 않는다 — 전부 이미 있는 것(Pretendard self-host)이나
- * OS 기본 폰트로만 스택을 짠다. 미리보기 하나 때문에 네트워크를 타면 안 된다.
- */
-export const FONT_PRESETS: {id: string; label: string; stack: string}[] = [
- {id: 'sans', label: '고딕', stack: "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif"},
- {id: 'serif', label: '명조', stack: "'Noto Serif KR', 'Batang', 'Times New Roman', serif"},
- {id: 'system', label: '시스템', stack: "system-ui, -apple-system, 'Apple SD Gothic Neo', sans-serif"},
- {id: 'mono', label: '모노', stack: "'JetBrains Mono', Consolas, ui-monospace, monospace"},
-];
-
-const FONT_SANS = FONT_PRESETS[0].stack;
-const FONT_SERIF = FONT_PRESETS[1].stack;
-
-/** 스택 문자열 → 프리셋 id(고르는 UI 가 현재 값을 표시할 때 쓴다). */
-export function fontPresetId(stack: string): string {
- return FONT_PRESETS.find((f) => f.stack === stack)?.id ?? 'sans';
-}
-
-export interface TokenGroup {
- id: string;
- name: string;
- description: string;
- colors: TokenColors;
- /** 모서리 반경(px). --tpl-radius 로 내려간다. */
- radius: number;
- /** 제목·본문 서체. CSS font-family 스택을 그대로 담는다(--tpl-font-* 로 내려간다). */
- fonts: TokenFonts;
- /** 섹션 바탕·카드 면·테두리. 팔레트 인상을 좌우하는 쪽은 사실 이쪽이다. */
- surfaces: TokenSurfaces;
- /** 내장 그룹은 수정·삭제 대신 "복제해서 편집" 만 된다. */
- builtin?: boolean;
-}
-
-export const TOKEN_COLOR_KEYS: {key: keyof TokenColors; label: string; hint: string}[] = [
- {key: 'primary', label: 'Primary', hint: '예약·문의 버튼처럼 누르게 만드는 색'},
- {key: 'secondary', label: 'Secondary', hint: '본문 보조 텍스트'},
- {key: 'bg', label: 'Background', hint: '페이지 바탕'},
- {key: 'card', label: 'Card', hint: '카드·패널 바탕'},
- {key: 'text', label: 'Text', hint: '제목·본문 기본색'},
- {key: 'accent', label: 'Accent', hint: '배지·강조 포인트'},
-];
-
-const STORAGE_KEY = 'o2o.dev.tokenGroups';
-
-/**
- * colorhunt 인기 팔레트에서 고른 것들.
- *
- * ★ 아무 팔레트나 쓰면 안 된다 — 웹사이트로 쓰려면 최소 조건이 있다:
- * ① 아주 밝은 색(바탕용) ② 흰 글씨를 얹을 수 있는 어두운 색(버튼·제목용)
- * ③ 채도 있는 포인트 색. 파스텔만 있는 팔레트는 글자가 안 읽힌다.
- * 여기 있는 건 그 조건으로 걸러낸 것들이다.
- */
-const COLORHUNT_PALETTES: {name: string; hexes: string[]}[] = [
- {name: '네이비 & 샌드', hexes: ['#222831', '#393e46', '#948979', '#dfd0b8']},
- {name: '블루 그라데이션', hexes: ['#e3f2fd', '#90caf9', '#2196f3', '#0d47a1']},
- {name: '바다와 모래', hexes: ['#3368a0', '#66a3bf', '#c8dfdb', '#f2efe7']},
- {name: '포레스트 크림', hexes: ['#1d4533', '#f7eae0', '#f9d2ba', '#5e3122']},
- {name: '크림 & 체리', hexes: ['#fff7eb', '#f9f0e0', '#a2ab73', '#cc3a63']},
- {name: '모카 & 오렌지', hexes: ['#524646', '#a8a492', '#fcf2e5', '#ec5b38']},
- {name: '선셋 테라코타', hexes: ['#fef2a0', '#f3cd97', '#e98b50', '#bc4f4f']},
- {name: '틸 & 앰버', hexes: ['#007979', '#24b1b1', '#ffe2af', '#e37434']},
- {name: '차콜 플럼', hexes: ['#2c2c2c', '#853953', '#612d53', '#f3f4f4']},
- {name: '네이비 골드', hexes: ['#133458', '#838921', '#d99b21', '#faf7bb']},
- {name: '올리브 & 오렌지', hexes: ['#2e2910', '#2c5745', '#ebe3a7', '#eb7d00']},
-];
-
-/** colorhunt 팔레트 → 내장 토큰 그룹. 유도 규칙은 붙여넣기와 완전히 같다(palette.ts). */
-function paletteGroups(): TokenGroup[] {
- return COLORHUNT_PALETTES.flatMap(({name, hexes}) => {
- const d = derivePalette(hexes);
- if (!d) return [];
- return [
- {
- id: `builtin:palette:${name}`,
- name: `팔레트 · ${name}`,
- description: hexes.join(' '),
- colors: {
- primary: d.primary,
- secondary: d.secondary,
- bg: d.bg,
- card: d.card,
- text: d.text,
- accent: d.accent,
- },
- radius: 8,
- fonts: {heading: FONT_SANS, body: FONT_SANS},
- surfaces: {
- surface: d.surface,
- surfaceAlt: d.surfaceAlt,
- inverse: d.inverse,
- border: d.border,
- },
- builtin: true,
- },
- ];
- });
-}
-
-/** 업종 템플릿에서 뽑아낸 내장 그룹. 실제 서비스가 쓰는 색 그대로다. */
-export function builtinGroups(): TokenGroup[] {
- const out: TokenGroup[] = [];
- (Object.keys(INDUSTRY_CONFIGS) as IndustryType[]).forEach((industry) => {
- INDUSTRY_CONFIGS[industry].templates.forEach((t) => {
- out.push({
- id: `builtin:${industry}:${t.id}`,
- name: `${INDUSTRY_CONFIGS[industry].name} · ${t.name}`,
- description: t.description,
- colors: {...t.colors},
- radius: 8,
- // 사진 중심 템플릿은 제목을 명조로 — 기존 캔버스의 serif-title 과 같은 인상이다.
- fonts: {heading: t.tone === 'photo' ? FONT_SERIF : FONT_SANS, body: FONT_SANS},
- // 템플릿이 이미 갖고 있던 bg·card 를 섹션 바탕으로 쓴다 —
- // 그래야 템플릿을 바꿀 때 버튼 색만이 아니라 화면 전체가 바뀐다.
- surfaces: {
- surface: t.colors.bg,
- surfaceAlt: t.colors.card,
- inverse: '#1c1917',
- border: t.colors.secondary,
- },
- builtin: true,
- });
- });
- });
- // ★ 팔레트를 앞에 둔다 — 색을 고르러 온 사람이 제일 먼저 보는 게 팔레트여야 한다.
- return [...paletteGroups(), ...out];
-}
-
-/** 저장해 둔 커스텀 그룹. 깨진 값이 들어 있으면 조용히 버린다 — 개발 도구가 흰 화면이 되면 안 된다. */
-export function loadCustomGroups(): TokenGroup[] {
- try {
- const raw = localStorage.getItem(STORAGE_KEY);
- if (!raw) return [];
- const parsed: unknown = JSON.parse(raw);
- if (!Array.isArray(parsed)) return [];
- // ★ 폰트가 없던 시절에 저장된 그룹이 있다 — 없으면 기본값으로 채운다(그 항목만 버리면 사용자 작업이 날아간다).
- return parsed.filter(isTokenGroup).map((g) => ({
- ...g,
- radius: typeof g.radius === 'number' ? g.radius : 8,
- fonts: {
- heading: g.fonts?.heading || FONT_SANS,
- body: g.fonts?.body || FONT_SANS,
- },
- surfaces: {
- surface: g.surfaces?.surface || g.colors.bg,
- surfaceAlt: g.surfaces?.surfaceAlt || g.colors.card,
- inverse: g.surfaces?.inverse || '#1c1917',
- border: g.surfaces?.border || g.colors.secondary,
- },
- builtin: false,
- }));
- } catch {
- return [];
- }
-}
-
-function isTokenGroup(value: unknown): value is TokenGroup {
- if (!value || typeof value !== 'object') return false;
- const g = value as Partial;
- if (typeof g.id !== 'string' || typeof g.name !== 'string') return false;
- if (!g.colors || typeof g.colors !== 'object') return false;
- return TOKEN_COLOR_KEYS.every(({key}) => typeof g.colors?.[key] === 'string');
-}
-
-function persist(groups: TokenGroup[]) {
- try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(groups));
- } catch {
- // 저장 실패(시크릿 모드·용량 초과)해도 화면은 계속 돈다.
- }
-}
-
-/** 저장(같은 id 면 덮어쓴다). 저장 후 전체 커스텀 목록을 돌려준다. */
-export function saveCustomGroup(group: TokenGroup): TokenGroup[] {
- const next = loadCustomGroups().filter((g) => g.id !== group.id);
- next.push({...group, builtin: false});
- persist(next);
- return next;
-}
-
-export function deleteCustomGroup(id: string): TokenGroup[] {
- const next = loadCustomGroups().filter((g) => g.id !== id);
- persist(next);
- return next;
-}
-
-/** 편집 시작점. 내장 그룹을 고른 채로 색을 만지면 이 함수로 복제본을 만든다. */
-export function duplicateGroup(source: TokenGroup, name: string): TokenGroup {
- return {
- id: `custom:${Date.now().toString(36)}`,
- name,
- description: `${source.name} 에서 복제`,
- colors: {...source.colors},
- radius: source.radius,
- fonts: {...source.fonts},
- surfaces: {...source.surfaces},
- builtin: false,
- };
-}
-
-/**
- * 내장 그룹을 만지는 순간 자동으로 생기는 편집본.
- *
- * ★ id 를 원본에서 결정론적으로 만든다 — 색상 피커는 드래그하는 동안 onChange 가
- * 연달아 터지는데, 매번 새 id 를 뽑으면 복사본이 수십 개 쌓인다.
- * 같은 내장 그룹을 다시 편집하면 같은 편집본을 이어서 고치게 된다.
- */
-export function autoForkOf(source: TokenGroup): TokenGroup {
- return {
- id: `custom:edit:${source.id}`,
- name: `${source.name} 편집본`,
- description: `${source.name} 를 편집한 사본`,
- colors: {...source.colors},
- radius: source.radius,
- fonts: {...source.fonts},
- surfaces: {...source.surfaces},
- builtin: false,
- };
-}
-
-/**
- * 토큰 그룹을 캔버스가 읽는 모양(TemplateItem)으로 바꾼다.
- *
- * 배리에이션들은 `template.colors` 로 색을 읽으므로, 그룹을 통째로 갈아끼우려면
- * 새 TemplateItem 을 만들어 props 로 내려보내는 게 유일한 경로다.
- */
-export function asTemplate(group: TokenGroup, base: TemplateItem): TemplateItem {
- return {...base, colors: {...group.colors}};
-}
-
-/** 미리보기 프레임에 씌울 CSS 변수. 캔버스(CanvasView)와 같은 --tpl-* 이름을 쓴다. */
-export function tokenCssVars(group: TokenGroup): Record {
- return {
- '--tpl-primary': group.colors.primary,
- '--tpl-secondary': group.colors.secondary,
- '--tpl-bg': group.colors.bg,
- '--tpl-card': group.colors.card,
- '--tpl-text': group.colors.text,
- '--tpl-accent': group.colors.accent,
- '--tpl-radius': `${group.radius}px`,
- '--tpl-font-heading': group.fonts.heading,
- '--tpl-font-body': group.fonts.body,
- '--tpl-surface': group.surfaces.surface,
- '--tpl-surface-alt': group.surfaces.surfaceAlt,
- '--tpl-inverse': group.surfaces.inverse,
- '--tpl-border': group.surfaces.border,
- };
-}
-
-/**
- * `template.colors` 를 실제로 읽는 배리에이션 목록.
- *
- * ★ 나머지는 stone-* 나 #hex 로 색을 박아 두어서 **토큰 그룹을 바꿔도 안 변한다.**
- * 쇼케이스가 이걸 배지로 표시해서, 토큰을 바꿨는데 화면이 그대로인 걸
- * "버그" 로 오해하지 않게 한다.
- *
- * 목록 갱신:
- * grep -rln "template.colors" src/features/builder/canvas/variants
- */
-export const TOKEN_AWARE_VARIANT_IDS = new Set([
- 'hero.editorial',
- 'hero.split',
- 'hero.full-bleed',
- 'hero.type-only',
- 'intro.story',
- 'intro.centered',
- 'intro.side-by-side',
- 'info.table',
- 'info.cards',
- 'info.inline',
- 'photos.grid',
- 'photos.masonry',
- 'photos.carousel',
- 'photos.with-videos',
- 'map.detailed',
- 'map.compact',
- 'local.guide',
- 'faq.accordion',
- 'faq.open-list',
- 'faq.two-column',
- 'rooms.carousel',
- 'rooms.grid',
- 'rooms.list',
- 'rules.list',
- 'rules.cards',
- 'booking.card',
- 'booking.banner',
- 'menu.list',
- 'menu.grid',
- 'menu.price-table',
- 'space.zones',
- 'space.list',
- 'inquiry.cta',
- 'inquiry.form',
- 'programs.cards',
- 'programs.table',
- 'exhibition.gallery',
- 'exhibition.notice',
-]);
diff --git a/solution/frontend/src/features/onboarding/Step4Template.tsx b/solution/frontend/src/features/onboarding/Step4Template.tsx
index 40030a3..7e90930 100644
--- a/solution/frontend/src/features/onboarding/Step4Template.tsx
+++ b/solution/frontend/src/features/onboarding/Step4Template.tsx
@@ -12,12 +12,7 @@ import {WizardFooter} from './WizardFooter';
import {WizardSteps} from './WizardSteps';
-/**
- * 템플릿 미리보기 — 그 템플릿의 서체·모서리·테두리·그림자로 **실제로** 그린다.
- *
- * ★ 예전에는 회색 막대 세 줄과 색 동그라미였다. 다섯 템플릿이 전부 같은 그림이라
- * 무엇을 고르는지 알 수 없었고, 그래서 아무거나 골랐다.
- */
+/** 템플릿 미리보기 — 그 템플릿의 서체·모서리·테두리·그림자로 **실제로** 그린다. */
function TemplatePreview({template}: {template: TemplateItem}) {
const {look, colors} = template;
@@ -51,7 +46,7 @@ function TemplatePreview({template}: {template: TemplateItem}) {
className="mt-1.5 text-[10px] leading-relaxed"
style={{fontFamily: look.fontBody, color: colors.secondary}}
>
- 제목은 {template.fontStyle}, 본문은 이 서체로 나갑니다.
+ 제목과 본문이 이 서체로 나갑니다.
{
selectTemplate(id);
queueSiteTemplateSave(placeId, id);
@@ -141,6 +131,9 @@ export function Step4Template() {
{config.name}에 맞춘 템플릿입니다. 색상과 서체는 에디터에서 언제든 바꿀 수 있고,
어떤 템플릿을 골라도 구조화 데이터와 필수 섹션은 동일하게 들어갑니다.
+
+ 템플릿을 바꾸면 이전 템플릿이 켜 둔 섹션이 꺼질 수 있습니다. 내용은 지워지지 않습니다.
+
@@ -164,7 +157,7 @@ export function Step4Template() {
- );
-}
-
-// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
-export default DevShowcasePage;
diff --git a/solution/frontend/src/routes.ts b/solution/frontend/src/routes.ts
index 972973f..a3a7e45 100644
--- a/solution/frontend/src/routes.ts
+++ b/solution/frontend/src/routes.ts
@@ -1,48 +1,32 @@
import {index, layout, route, type RouteConfig} from '@react-router/dev/routes';
-/**
- * 라우트 표. 예전 `app/router.tsx` 의 배열이 여기로 왔다.
- *
- * ★ 순서·경로는 그대로다. 바뀐 건 선언 방식뿐이고, 페이지 컴포넌트는 손대지 않았다.
- * ★ 프리렌더 대상은 여기가 아니라 `react-router.config.ts` 가 정한다.
- * 목록에 없는 경로는 지금까지처럼 SPA 폴백으로 나간다.
- */
+/** 라우트 표. */
export default [
- // 비로그인 = 랜딩. ★ 예전엔 로그인 여부로 갈랐는데 지금은 둘 다 랜딩이다
- // (2026-09-04, 사장님 지적: 로그인하면 랜딩·요금에 갈 길이 없었다).
+ // 비로그인 = 랜딩.
index('pages/LandingPage.tsx'),
route('approve/:postId', 'pages/SocialApprovalPage.tsx'),
route('login', 'pages/LoginPage.tsx'),
- // 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다.
+ // 로그인 화면의 [회원가입] 이 여기로 온다.
route('signup', 'pages/SignupPage.tsx'),
- // 로그인 전 화면. ★ 랜딩과 같은 껍데기(MarketingShell)를 쓴다 — 사이드바 없는 문서형이다.
+ // 로그인 전 화면.
route('pricing', 'pages/PricingPage.tsx'),
route('showcase', 'pages/ShowcasePage.tsx'),
- // 로그인한 사장님의 홈. 예전엔 페이지마다 로 감쌌는데,
- // 가드는 한 자리에 있어야 빠뜨리지 않아서 레이아웃 라우트로 모았다.
+ // 로그인한 사장님의 홈.
layout('components/layout/RequireAuthLayout.tsx', [
route('sites', 'pages/SitesPage.tsx'),
route('account', 'pages/AccountPage.tsx'),
- // "내 사이트" 카드의 관리 메뉴에서 온다(?placeId= 로 어느 사이트인지 받는다) —
- // 사장님 여럿이 사이트 여럿을 가질 수 있어 전역 메뉴 하나로는 못 고른다.
+ // "내 사이트" 카드의 관리 메뉴에서 온다(?placeId= 로 어느 사이트인지 받는다) — 사장님 여럿이 사이트 여럿을 가질 수 있어 전역 메뉴 하나로는 못 고른다.
route('blog', 'pages/BlogPostsPage.tsx'),
+ // 개발자(DEVELOPER) 전용 — admin/frontend 를 새 도메인으로 키우는 대신 여기 경량으로 얹었다.
route('ops/sites', 'pages/OpsSitesPage.tsx'),
route('ops/users', 'pages/OpsUsersPage.tsx'),
]),
- /**
- * 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면
- * 만들어 보기도 전에 막힌다. 세션은 useAutoLogin() 이 조용히 확보한다.
- */
+ /** 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면 만들어 보기도 전에 막힌다. */
route('builder', 'pages/BuilderPage.tsx'),
- // 개발 빌드에서만 열린다. 운영 번들에는 라우트 자체가 없다.
- ...(process.env.NODE_ENV !== 'production'
- ? [route('dev/showcase', 'pages/DevShowcasePage.tsx')]
- : []),
-
route('*', 'pages/NotFoundPage.tsx'),
] satisfies RouteConfig;
diff --git a/solution/frontend/src/stores/builder.ts b/solution/frontend/src/stores/builder.ts
index f7609a5..3ab0c56 100644
--- a/solution/frontend/src/stores/builder.ts
+++ b/solution/frontend/src/stores/builder.ts
@@ -1,15 +1,18 @@
import {useMemo} from 'react';
import {create} from 'zustand';
-import type {
- IndustryType,
- InfoField,
- PhotoItem,
- SectionItem,
- TemplateItem,
- ViewportMode,
+import {
+ INDUSTRIES,
+ resolveTemplateId,
+ templateOf,
+ type IndustryType,
+ type InfoField,
+ type PhotoItem,
+ type SectionItem,
+ type TemplateItem,
+ type ViewportMode,
} from '@o2o/shared';
import {FALLBACK_INDUSTRY, INDUSTRY_CONFIGS} from '@/data/industryData';
-import {addableSection, newSectionOf} from '@/features/builder/canvas/addable';
+import {addableSection, newSectionOf} from '@/features/builder/sections/addable';
import {COLOR_PALETTE_PRESETS} from '@/features/builder/colorPalettes';
import {createFactSaver, type FactSaver} from '@/features/builder/factSave';
import {
@@ -28,11 +31,7 @@ import type {
WeatherLocation,
} from '@/stores/builderTypes';
-/**
- * ★ 화면 계약 타입은 stores/builderTypes 가 소유한다. 여기서 다시 내보내는 이유는
- * 호출부가 "빌더 스토어에서 가져온다"는 감각을 유지하게 하기 위해서다 —
- * 타입만 필요한 모듈(어댑터·저장 파이프라인)은 builderTypes 를 직접 본다.
- */
+/** 화면 계약 타입은 stores/builderTypes 가 소유한다. */
export type {
ApplyPlaceOptions,
ConfirmedIdentity,
@@ -45,23 +44,14 @@ export type {
interface BuilderState {
// ── 위저드 ────────────────────────────────────────────
- // ★ 단계(step)는 여기 없다 — 주소창이 소유한다(features/onboarding/wizardUrl).
industry: IndustryType;
- /**
- * 지금 편집 중인 실제 사업장. null 이면 아직 가게가 정해지지 않은 상태다.
- *
- * ★ 값이 null 이어도 화면을 시연용 데이터로 채우지 않는다. 캔버스는 언제나
- * 서버에서 온 것만 그리고, 없으면 빈 상태를 그린다.
- */
+ /** 지금 편집 중인 실제 사업장. */
placeId: string | null;
storeName: string;
location: string;
weatherLocation?: WeatherLocation;
selectedChannels: string[];
- /**
- * 2단계에서 확인된 신원. null 이면 아직 "이 가게가 맞다"를 아무도 말하지 않았다 —
- * 화면의 상호·주소는 업종 예시일 뿐이라는 뜻이고, 3단계는 그 사실을 그대로 표시한다.
- */
+ /** 2단계에서 확인된 신원. */
confirmedIdentity: ConfirmedIdentity | null;
/** 업종을 못 정해 업종 화면으로 넘긴 후보. 돌아와서 확정을 이어갈 때만 쓴다. */
pendingPick: PendingPick | null;
@@ -79,10 +69,7 @@ interface BuilderState {
sections: SectionItem[];
infoFields: InfoField[];
photos: PhotoItem[];
- /**
- * infoFields 의 id → 서버 fact. 데모에서는 항상 비어 있다(전이시킬 fact 자체가 없다).
- * 신원 줄(상호·주소·전화)도 여기 없다 — place 의 값이지 fact 가 아니다.
- */
+ /** infoFields 의 id → 서버 fact. */
factRefs: Record;
/** 지금 서버로 올라가는 중인 줄. 사장님에게 "저장 중"을 보여주는 근거. */
savingFieldIds: string[];
@@ -133,8 +120,6 @@ interface BuilderState {
updateSectionContent: (sectionId: string, patch: Pick) => void;
/** 붙여넣기 아이템의 원문 JSON. 깨져 있어도 그대로 담는다 — 판단은 렌더러가 한다. */
updateSectionData: (sectionId: string, data: string) => void;
- setSectionVariant: (sectionId: string, variantId: string) => void;
- resetSectionVariants: () => void;
/** 서버에 저장돼 있던 디자인을 화면에 얹는다(새로고침·다른 기기 복원). */
applyTheme: (theme: SiteThemePayload | null | undefined, savedTemplateId?: string | null) => void;
@@ -157,38 +142,14 @@ interface BuilderState {
reset: () => void;
}
-/**
- * 위저드 입력이 소유하는 줄의 id. 업종 시드·서버 매핑 모두 같은 id 를 쓴다.
- * 이 두 줄만은 "수집된 값"이 아니라 **사장님이 직접 친 값**이 근거다.
- * (아래쪽 IDENTITY_FIELD_IDS 는 전화까지 포함한 'fact 가 아닌 줄' 목록으로, 쓰임이 다르다.)
- */
+/** 위저드 입력이 소유하는 줄의 id. */
const OWNER_INPUT_FIELD_IDS = {name: 'name', address: 'address'} as const;
/** 사장님이 직접 입력한 값의 출처 표기. 백엔드의 SourceType.OWNER 에 대응한다. */
const OWNER_SOURCE = '직접 입력';
-/**
- * 위저드에서 사장님이 친 상호·위치를 정보 카드에 그대로 얹는다.
- *
- * ★ 아직 사업장이 확정되지 않은 동안(placeId === null) 화면에 보일 값은 사장님이
- * 직접 친 상호·위치뿐이다. 업종 시드에는 콘텐츠가 없다 — 예전에는 여기에 예시 가게
- * ('달빛스테이 제주')가 들어 있어서, 사장님이 '스테이머뭄 / 군산'을 쳐도 화면은
- * 계속 남의 가게를 자기 가게라고 말했다.
- *
- * ★ 실사업장의 상호·주소는 place 레코드가 소유하고 서버 응답이 유일한 근거다 —
- * 여기서 덮으면 화면과 DB 가 갈린다.
- *
- * 상호는 사장님 본인이 댄 값이라 확인 절차가 없다(OWNER 는 그 자체로 검증된 출처다).
- * 위치는 '시/군/구/동'만 받으므로 주소 한 줄로는 부족하다 — 확인 대상으로 남겨
- * 발행 전에 전체 주소를 받게 한다.
- */
-/**
- * 확정된 신원을 정보 카드에 얹는다.
- *
- * 상호·주소·전화는 사장님이 후보 카드를 보고 "내 가게가 맞다"고 고른 값이다 —
- * 그 클릭 자체가 확인이므로 [확인 필요]를 달지 않는다. 직접 입력한 주소도 마찬가지로
- * 본인이 댄 값이라 확인 대상이 아니다(틀리면 본인이 고친다).
- */
+/** 위저드에서 사장님이 친 상호·위치를 정보 카드에 그대로 얹는다. */
+/** 확정된 신원을 정보 카드에 얹는다. */
function withConfirmedIdentity(fields: InfoField[], identity: ConfirmedIdentity): InfoField[] {
const patch: Record = {
[OWNER_INPUT_FIELD_IDS.name]: identity.name.trim() || undefined,
@@ -222,14 +183,7 @@ function withOwnerIdentity(
});
}
-/**
- * 업종을 고른 직후의 빈 상태.
- *
- * ★ 콘텐츠 시드가 없다. 예전에는 여기서 상호('달빛스테이 제주')·주소·정보표·사진까지
- * 업종 시드로 채웠다. 그래서 아직 아무 가게도 고르지 않았는데 화면이 남의 가게로
- * 가득 차 있었고, 사장님은 그걸 자기 가게 내용으로 읽었다.
- * 구조(섹션 목록·템플릿·채널)만 시드가 소유하고, 값은 전부 서버에서 온다.
- */
+/** 업종을 고른 직후의 빈 상태. */
function seedFor(industry: IndustryType) {
const config = INDUSTRY_CONFIGS[industry];
return {
@@ -238,10 +192,9 @@ function seedFor(industry: IndustryType) {
location: '',
weatherLocation: undefined,
selectedChannels: config.channels.filter((c) => c.checked).map((c) => c.id),
- templateId: config.templates[0].id,
+ templateId: config.defaultTemplate,
colorPaletteId: null,
- // 배열은 반드시 사본으로 넣는다. 그대로 넣으면 에디터의 순서 변경이
- // INDUSTRY_CONFIGS 원본을 건드려, 업종을 되돌아왔을 때 초기값이 오염된다.
+ // 배열은 반드시 사본으로 넣는다.
sections: config.sections.map((s) => ({...s})),
infoFields: [],
photos: [],
@@ -250,41 +203,18 @@ function seedFor(industry: IndustryType) {
}
-/**
- * ★ 위저드 상태를 브라우저에 저장하지 않는다.
- *
- * 한때 localStorage(뒤에 sessionStorage)에 저장했다. 수집이 몇 분 걸리는 잡이라
- * 그 사이 새로고침이 나면 "어느 가게로 확정했는지"가 날아가 1단계로 튕겼기 때문이다.
- * 그런데 그 저장이 더 나쁜 문제를 만들었다 — **새 가게를 만들러 2단계에 들어가면
- * 지난번 가게가 "이 가게로 확인되었습니다"로 떠 있다.** 사장님 눈에는 자기가 고르지도
- * 않은 가게가 확정된 것으로 보인다.
- *
- * 진짜 복원 수단은 따로 있다: 신원이 확정되면 주소창에 `?placeId=...` 가 붙고
- * (Step2PlaceSearch), 새로고침하면 usePlaceSync 가 **서버에서** 다시 읽는다.
- * 서버가 진실이므로 브라우저 사본이 필요 없고, 주소를 새로 열면 자연히 깨끗하다.
- *
- * 옛 저장분은 여기서 지운다 — 이미 사용자 브라우저에 들어가 있기 때문이다.
- */
-// ★ 프로젝트 이름이 web4ai 로 바뀌어도 이 키는 그대로 둔다 — 이미 사용자 브라우저에
-// 들어가 있는 **옛 저장분의 이름**이라, 바꾸면 지워야 할 것을 못 지운다.
+/** 위저드 상태를 브라우저에 저장하지 않는다. */
+// 프로젝트 이름이 web4ai 로 바뀌어도 이 키는 그대로 둔다 — 이미 사용자 브라우저에 들어가 있는 **옛 저장분의 이름**이라, 바꾸면 지워야 할 것을 못 지운다.
for (const key of ['o2osite.builder.wizard.v1', 'o2osite.builder.wizard.v2']) {
try {
localStorage.removeItem(key);
sessionStorage.removeItem(key);
} catch {
- // 프라이빗 모드 등에서 접근이 막힌다. 저장한 적이 없다는 뜻이라 지울 것도 없다.
+ // 프라이빗 모드 등에서 접근이 막힌다.
}
}
-/**
- * 편집 → 서버 저장 파이프라인.
- *
- * ★ 스토어와 서로를 부르는 관계다(스토어 액션이 저장을 걸고, 저장은 스토어를 읽고 쓴다).
- * factSave 가 `useBuilderStore` 를 직접 import 하면 순환이 되므로, 스토어를 **인자로 넘겨**
- * 묶는다 — 의존이 한 방향으로만 흐른다(스토어 → factSave).
- * ★ 선언이 스토어보다 앞이고 대입은 뒤인 이유: 만들려면 스토어가 있어야 하는데,
- * 액션은 스토어가 다 만들어진 뒤에야 실행되므로 그때는 이미 채워져 있다.
- */
+/** 편집 → 서버 저장 파이프라인. */
let factSaver: FactSaver;
export const useBuilderStore = create((set, get) => ({
@@ -305,8 +235,7 @@ export const useBuilderStore = create((set, get) => ({
isPublishModalOpen: false,
publishedUrl: null,
- // 업종을 바꾸면 그 업종의 시드로 통째로 갈아탄다 — 앞 업종의 섹션·필드가 남으면
- // 카페 사이트에 '객실 안내'가 붙는 식으로 섞인다.
+ // 업종을 바꾸면 그 업종의 시드로 통째로 갈아탄다 — 앞 업종의 섹션·필드가 남으면 카페 사이트에 '객실 안내'가 붙는 식으로 섞인다.
selectIndustry: (industry) => {
// 화면 값이 시드로 갈아엎히므로, 아직 못 올린 편집은 갈 곳이 없다 — 여기서 버린다.
factSaver.clear();
@@ -314,7 +243,6 @@ export const useBuilderStore = create((set, get) => ({
set((state) => {
const seed = seedFor(industry);
// 업종이 바뀌어도 "이 가게가 맞다"는 확인은 그대로다(같은 가게, 다른 분류일 뿐).
- // 다만 시드가 통째로 갈리면서 상호·주소가 예시로 되돌아가므로 여기서 다시 얹는다.
const identity = state.confirmedIdentity;
return {
...seed,
@@ -325,17 +253,12 @@ export const useBuilderStore = create((set, get) => ({
infoFields: withConfirmedIdentity(seed.infoFields, identity),
}
: {
- /**
- * ★ 사장님이 친 상호·위치는 시드가 아니다 — 업종을 바꿨다고 지우면 안 된다.
- * 업종이 첫 화면이던 시절엔 여기가 늘 빈 값이라 티가 안 났는데, 지금은 상호를
- * 먼저 받는다: 지우면 검색어를 친 뒤 업종만 골라도 그 이름이 사라진다.
- */
+ /** 사장님이 친 상호·위치는 시드가 아니다 — 업종을 바꿨다고 지우면 안 된다. */
storeName: state.storeName,
location: state.location,
infoFields: withOwnerIdentity(seed.infoFields, state.storeName, state.location),
}),
- // 업종을 손으로 고르면 화면의 값은 다시 시드다 — 실사업장 배선을 남겨두면
- // "placeId 가 있다 = 화면 값이 서버에서 왔다"는 약속이 깨진다.
+ // 업종을 손으로 고르면 화면의 값은 다시 시드다 — 실사업장 배선을 남겨두면 "placeId 가 있다 = 화면 값이 서버에서 왔다"는 약속이 깨진다.
placeId: null,
gatherCompleted: false,
gatherStage: 1,
@@ -344,23 +267,12 @@ export const useBuilderStore = create((set, get) => ({
});
},
- /**
- * 서버에서 읽은 사업장을 캔버스에 얹는다.
- *
- * 덮어쓰는 것은 신원·정보·사진뿐이다. 섹션 구성·순서·배리에이션·템플릿은
- * 백엔드에 대응물이 없어 업종 시드가 계속 소유한다 — 그래서 리페치가 와도
- * 사장님이 방금 옮긴 섹션 순서가 되돌아가지 않는다.
- *
- * ★ infoFields 는 서버가 소유한다. 다만 아직 서버 응답을 못 받은 편집(낙관적 반영)만은
- * 리페치 위에 다시 얹는다 — 안 그러면 [맞아요] 를 누른 줄이 리페치 한 번에
- * '확인 필요'로 튀어, 사장님 눈에는 클릭이 씹힌 것으로 보인다(withPendingEdits).
- */
+ /** 서버에서 읽은 사업장을 캔버스에 얹는다. */
applyPlace: (input, options) => {
const isOnboarding = options?.isOnboarding ?? false;
- // 처음 여는 사업장인가. 리페치(같은 placeId)면 섹션·단계를 건드리지 않는다.
+ // 처음 여는 사업장인가.
const isNewPlace = get().placeId !== input.placeId;
// 사업장을 갈아타면 앞 사업장의 저장 대기분은 버린다 — 들고 가면 남의 값이 이 화면에 얹힌다.
- // 디자인 저장 대기분도 같다: 아직 안 나간 요청이 새 사업장 id 로 나가면 남의 사이트를 덮는다.
if (isNewPlace) {
factSaver.clear();
clearThemeSaves();
@@ -374,11 +286,7 @@ export const useBuilderStore = create((set, get) => ({
...(needsReseed ? seedFor(input.industry) : null),
...(isNewPlace
? {
- /**
- * ★ 위저드 도중에 새로고침하면 스토어의 확정 신원이 비어 있다(브라우저에 저장하지
- * 않으므로). 그대로 두면 3단계가 "아직 가게를 안 골랐다"고 판단해 수집을 열지
- * 않는다 — 서버에서 읽어온 사업장으로 여기서 다시 세운다.
- */
+ /** 위저드 도중에 새로고침하면 스토어의 확정 신원이 비어 있다(브라우저에 저장하지 않으므로). */
...(isOnboarding
? {
confirmedIdentity: {
@@ -394,13 +302,7 @@ export const useBuilderStore = create((set, get) => ({
isGathering: false,
}
: null),
- /**
- * ★ "수집 완료"는 fact 가 있을 때만이다.
- *
- * 신원(상호·주소)은 place 의 값이지 수집물이 아니다. 이걸 구분하지 않으면
- * 검색만 끝낸 화면이 2줄을 놓고 '수집 완료'라고 말하고, 사장님은 수집이
- * 실패했다고 읽는다. 리페치마다 서버 기준으로 다시 정한다.
- */
+ /** "수집 완료"는 fact 가 있을 때만이다. */
gatherCompleted: input.hasCollected,
gatherStage: input.hasCollected ? 3 : 1,
placeId: input.placeId,
@@ -415,8 +317,7 @@ export const useBuilderStore = create((set, get) => ({
});
},
- // 주소창에서 placeId 가 빠지면 데모로 되돌린다. 이미 데모면 아무것도 하지 않는다
- // (같은 state 를 돌려주면 구독자가 다시 그리지 않는다).
+ // 주소창에서 placeId 가 빠지면 데모로 되돌린다.
clearPlace: () => {
if (get().placeId === null) return;
// 화면이 시드로 돌아가므로 서버로 가던 편집도 여기서 끊는다.
@@ -425,8 +326,7 @@ export const useBuilderStore = create((set, get) => ({
set((state) => ({...seedFor(state.industry), placeId: null}));
},
- // 상호·위치는 치는 즉시 정보 카드에 반영한다 — 데모에서만. 실사업장이 배선돼 있으면
- // 서버가 신원의 주인이라 화면 입력이 카드를 덮지 않는다(리페치 한 번에 되돌아간다).
+ // 상호·위치는 치는 즉시 정보 카드에 반영한다 — 데모에서만.
setStoreName: (storeName) =>
set((state) =>
state.placeId !== null
@@ -446,7 +346,7 @@ export const useBuilderStore = create((set, get) => ({
confirmedIdentity: identity,
storeName: identity.name,
location: identity.address,
- // 확정된 신원은 화면의 예시 상호·주소를 즉시 밀어낸다. 출처는 사장님이 고른 그 출처다.
+ // 확정된 신원은 화면의 예시 상호·주소를 즉시 밀어낸다.
infoFields: withConfirmedIdentity(state.infoFields, identity),
})),
@@ -465,42 +365,28 @@ export const useBuilderStore = create((set, get) => ({
setGatherStage: (gatherStage) => set({gatherStage}),
finishGather: () => set({isGathering: false, gatherCompleted: true, gatherStage: 3}),
- /**
- * 템플릿을 고른다. 그 템플릿이 데리고 오는 섹션이 있으면 함께 들어온다.
- *
- * ★ 넣기만 하고 빼지 않는다. 템플릿을 눌러 보다가 넣어 둔 섹션이 사라지면
- * 사장님은 그게 템플릿 때문인 줄 모르고 자기가 지웠다고 생각한다.
- * ★ `disabledSectionTypes` 는 이 규칙을 어기지 않는다 — **지우지 않고 끈다.**
- * 목록에 그대로 남아 한 번 눌러 되살린다. 시안(`/s/stay`)의 옛 항구에 예약 안내가
- * 없는 것처럼, 템플릿이 "이 섹션은 안 쓴다"고 말할 자리가 없어서 켜기만 가능했다.
- * ★ 잠긴 섹션은 끄지 않는다. 히어로·기본 정보·오시는 길에는 SEO 필수 마크업이 달려 있어
- * 사장님도 못 끄는 자리다 — 템플릿이 우회로가 되면 안 된다.
- * ★ 배리에이션은 **비어 있을 때만** 넣는다. 사장님이 이미 고른 모양을 템플릿이 덮으면
- * 위와 같은 일이 생긴다(고른 적 없는 모양으로 돌아가 있다).
- */
+ // 새 템플릿의 추가 섹션은 켜고, 이전 템플릿만 켰던 섹션은 끈다(지우지 않는다).
selectTemplate: (templateId) => {
set((state) => {
- const template = INDUSTRY_CONFIGS[state.industry].templates.find((t) => t.id === templateId);
- const wanted = template?.defaultSectionTypes ?? [];
- const turnOff = template?.disabledSectionTypes ?? [];
- const variants = template?.defaultVariants ?? {};
- const added = wanted
+ const base = INDUSTRIES[state.industry].sections.map((sec) => sec.type);
+ const next = templateOf(templateId).addSections;
+ const previous = templateOf(state.templateId).addSections.filter(
+ (type) => !next.includes(type) && !base.includes(type),
+ );
+ const added = next
.filter((type) => !state.sections.some((sec) => sec.type === type))
.map(newSectionOf)
.filter((sec): sec is SectionItem => sec !== undefined);
const apply = (sec: SectionItem): SectionItem => {
- const variantId = sec.variantId ?? variants[sec.type];
- const isEnabled = wanted.includes(sec.type)
- ? true
- : turnOff.includes(sec.type) && !sec.isLocked
- ? false
- : sec.isEnabled;
- return {...sec, isEnabled, ...(variantId ? {variantId} : {})};
+ if (sec.isLocked) return sec;
+ if (next.includes(sec.type)) return {...sec, isEnabled: true};
+ if (previous.includes(sec.type)) return {...sec, isEnabled: false};
+ return sec;
};
return {
templateId,
colorPaletteId: null,
- sections: [...state.sections.map(apply), ...added.map(apply)],
+ sections: [...state.sections.map(apply), ...added],
};
});
persistTheme();
@@ -588,51 +474,16 @@ export const useBuilderStore = create((set, get) => ({
persistTheme();
},
- // 레이아웃만 갈아끼운다 — 섹션의 내용·순서·노출 여부는 건드리지 않는다.
- // 그래야 사장님이 "이 모양 저 모양" 눌러보다가 편집한 걸 잃지 않는다.
- setSectionVariant: (sectionId, variantId) => {
- set((state) => ({
- sections: state.sections.map((sec) =>
- sec.id === sectionId ? {...sec, variantId} : sec,
- ),
- }));
- persistTheme();
- },
-
- // 전부 업종 기본 레이아웃으로. variantId 를 지우면 렌더러가 기본값으로 떨어진다.
- resetSectionVariants: () => {
- set((state) => ({
- sections: state.sections.map(({variantId: _dropped, ...sec}) => sec),
- }));
- persistTheme();
- },
-
- /**
- * 서버에 저장된 디자인을 화면에 얹는다.
- *
- * ★ persistTheme 을 부르지 않는다. 여기는 **읽어서 얹는** 자리이므로 다시 저장하면
- * 메아리가 되고, 리페치가 잦은 화면에서는 그 메아리가 계속 돈다.
- *
- * ★ 섹션은 저장값으로 통째로 갈아치우지 않고 **업종 시드 위에 얹는다.**
- * 저장된 항목에는 type·description 이 없다(발행 계약에 없는 값이라 안 싣는다).
- * 그대로 넣으면 캔버스가 섹션 타입을 몰라 아무 배리에이션도 못 고른다.
- * 그래서 시드에서 type·description 을 가져오고, 저장값은 순서·노출·이름·배리에이션만 준다.
- *
- * ★ 시드에 없는 저장 섹션은 버린다 — 업종이 바뀌었거나 섹션이 없어진 경우다.
- * 반대로 저장값에 없는 시드 섹션은 **뒤에 붙인다**: 새로 생긴 섹션이 조용히 사라지면
- * 사장님은 그 기능이 있는 줄도 모른다.
- */
+ /** 서버에 저장된 디자인을 화면에 얹는다. */
applyTheme: (theme, savedTemplateId) => {
if (!theme && !savedTemplateId) return;
set((state) => {
const seedById = new Map(state.sections.map((sec) => [sec.id, sec]));
const ordered = (theme?.sections ?? [])
- // ★ 반환 타입을 못 박는다. 추론에 맡기면 description 이 **필수** 키로 좁혀져
- // (SectionItem 에서는 선택 키다) 아래 filter 의 타입 술어가 컴파일되지 않는다.
+ // 반환 타입을 못 박는다.
.map((saved): SectionItem | null => {
const seed = seedById.get(saved.id);
- // ★ 시드에 없어도 [+ 섹션 추가] 로 넣을 수 있는 것이면 되살린다.
- // 여기서 버리면 사장님이 추가하고 JSON 까지 채운 섹션이 새로고침 한 번에 사라진다.
+ // 시드에 없어도 [+ 섹션 추가] 로 넣을 수 있는 것이면 되살린다.
const revived =
seed ??
(saved.type && addableSection(saved.type) ? newSectionOf(saved.type) : undefined);
@@ -644,29 +495,14 @@ export const useBuilderStore = create((set, get) => ({
description: saved.description ?? revived.description,
body: saved.body ?? revived.body,
data: saved.data ?? revived.data,
- // 잠긴 섹션은 시드의 판단이 이긴다 — 서버에 꺼진 채로 저장돼 있더라도
- // SEO·필수 마크업이 달린 섹션을 화면에서 꺼진 것처럼 보이게 하지 않는다.
+ // 잠긴 섹션은 시드의 판단이 이긴다 — 서버에 꺼진 채로 저장돼 있더라도 SEO·필수 마크업이 달린 섹션을 화면에서 꺼진 것처럼 보이게 하지 않는다.
isEnabled: revived.isLocked ? true : saved.enabled,
- ...(saved.variantId ? {variantId: saved.variantId} : {}),
};
})
.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}
- : {}),
+ ...(savedTemplateId ? {templateId: resolveTemplateId(state.industry, savedTemplateId)} : {}),
sections: [...ordered, ...seedById.values()],
colorPaletteId: theme?.colorPaletteId ?? null,
infoFields: [
@@ -715,10 +551,6 @@ export const useBuilderStore = create((set, get) => ({
},
// 정보 탭에서 직접 고친 값 = 사장님이 쓴 값이므로 그 자리에서 승인된다(백엔드 CORRECTED 대응).
- //
- // ★ 로컬 반영은 지금까지와 똑같이 그 자리에서 끝낸다 — 데모 경로는 여기까지가 전부고,
- // 실사업장이라도 서버 왕복을 기다리며 입력이 멈추면 안 된다(낙관적 업데이트).
- // 서버로 올리는 일은 queueFactSave 가 맡는다: 타이핑이라 마지막 값 하나만 보낸다.
updateField: (id, value) => {
const before = get().infoFields.find((field) => field.id === id);
set((state) => ({
@@ -731,9 +563,6 @@ export const useBuilderStore = create((set, get) => ({
},
// 확인 탭의 [맞아요](correctedValue 없음) / [아니에요 → 수정](correctedValue 있음) 양쪽을 받는다.
- //
- // ★ 여기가 발행 게이트의 근거가 만들어지는 자리다. 로컬만 바꾸고 끝내면 게이트는
- // 저장되지도 않은 검증을 보고 "발행 가능"이라고 말한다 — 그래서 반드시 서버까지 올린다.
verifyField: (id, correctedValue) => {
const before = get().infoFields.find((field) => field.id === id);
set((state) => ({
@@ -787,10 +616,7 @@ export const useBuilderStore = create((set, get) => ({
},
}));
-/**
- * 지금 상태의 템플릿(+색 팔레트 덮어쓰기)을 계산한다. 훅이 아니라 순수 함수라
- * 렌더 트리 밖(스토어 액션)에서도 쓸 수 있다 — useCurrentTemplate 이 이걸 감싼다.
- */
+/** 지금 상태의 템플릿(+색 팔레트 덮어쓰기)을 계산한다. */
factSaver = createFactSaver(useBuilderStore);
function resolveTemplate(pick: {
@@ -798,23 +624,13 @@ function resolveTemplate(pick: {
templateId: string;
colorPaletteId: string | null;
}): TemplateItem {
- const templates = INDUSTRY_CONFIGS[pick.industry].templates;
- const template = templates.find((t) => t.id === pick.templateId) ?? templates[0];
+ const template = INDUSTRY_CONFIGS[pick.industry].templates.find((t) => t.id === pick.templateId);
+ if (!template) throw new Error(`${pick.industry} 업종에서 쓸 수 없는 템플릿: ${pick.templateId}`);
const palette = COLOR_PALETTE_PRESETS.find((item) => item.id === pick.colorPaletteId);
return palette ? {...template, colors: palette.colors} : template;
}
-/**
- * 디자인 변경을 서버에 올린다.
- *
- * ★ 왜 각 액션 끝에서 부르나(에디터 컴포넌트의 effect 가 아니라):
- * effect 로 하면 서버에서 값을 **읽어와 얹는 순간에도** 저장이 한 번 돌아 메아리가 된다.
- * 액션은 사장님이 실제로 조작했을 때만 실행되므로 그 문제가 없다.
- * 대신 새 액션을 만들 때 여기 호출을 빠뜨리면 그 조작만 조용히 저장되지 않는다 —
- * 디자인을 바꾸는 액션은 반드시 이 줄로 끝낸다.
- *
- * ★ placeId 가 없으면(아직 가게 미확정) 저장할 사이트가 없다. queueSiteThemeSave 가 조용히 통과시킨다.
- */
+/** 디자인 변경을 서버에 올린다. */
function persistTheme() {
const state = useBuilderStore.getState();
queueSiteThemeSave(
@@ -834,21 +650,14 @@ export function useCurrentTemplate(): TemplateItem {
const industry = useBuilderStore((s) => s.industry);
const templateId = useBuilderStore((s) => s.templateId);
const colorPaletteId = useBuilderStore((s) => s.colorPaletteId);
- // 구독은 훅에서 하고 계산은 resolveTemplate 하나로 모은다 — 같은 식을 두 벌 두면
- // 화면이 보는 색과 서버에 올라가는 색이 갈린다.
+ // 구독은 훅에서 하고 계산은 resolveTemplate 하나로 모은다 — 같은 식을 두 벌 두면 화면이 보는 색과 서버에 올라가는 색이 갈린다.
return useMemo(
() => resolveTemplate({industry, templateId, colorPaletteId}),
[industry, templateId, colorPaletteId],
);
}
-/**
- * 확인이 필요한데 아직 승인 안 된 항목. 발행 게이트와 배지 숫자가 같은 값을 본다.
- *
- * ★ filter 를 셀렉터 안에서 돌리면 안 된다 — zustand v5 는 useSyncExternalStore 를 쓰고,
- * 셀렉터가 매번 새 배열을 돌려주면 스냅샷이 계속 달라져 무한 렌더로 죽는다
- * ("The result of getSnapshot should be cached"). 원본 배열을 구독하고 여기서 좁힌다.
- */
+/** 확인이 필요한데 아직 승인 안 된 항목. */
export function useUnverifiedFields(): InfoField[] {
const infoFields = useBuilderStore((s) => s.infoFields);
return useMemo(
diff --git a/solution/shared/src/data/templates.json b/solution/shared/src/data/templates.json
new file mode 100644
index 0000000..6953f9a
--- /dev/null
+++ b/solution/shared/src/data/templates.json
@@ -0,0 +1,651 @@
+{
+ "templates": {
+ "simple": {
+ "name": "심플",
+ "tag": "깔끔한 기본",
+ "description": "흰 바탕에 고딕. 읽기 쉽고 어디에도 어울립니다. 무엇을 고를지 모르겠으면 이것.",
+ "layout": "basic",
+ "colors": {
+ "primary": "#18181b",
+ "secondary": "#52525b",
+ "bg": "#ffffff",
+ "card": "#fafafa",
+ "text": "#09090b",
+ "accent": "#2563eb"
+ },
+ "look": {
+ "fontHeading": "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif",
+ "fontBody": "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif",
+ "radius": "0.75rem",
+ "borderWidth": "1px",
+ "shadow": "0 1px 2px rgb(0 0 0 / 0.06)",
+ "headingTracking": "-0.02em",
+ "headingWeight": "700",
+ "sectionSpace": "3.5rem"
+ },
+ "addSections": []
+ },
+ "magazine": {
+ "name": "매거진",
+ "tag": "잡지 편집",
+ "description": "명조 제목에 각진 모서리, 그림자 없이 선으로만. 사진과 글이 많을 때 품이 납니다.",
+ "layout": "basic",
+ "colors": {
+ "primary": "#111111",
+ "secondary": "#57534e",
+ "bg": "#ffffff",
+ "card": "#f7f6f4",
+ "text": "#111111",
+ "accent": "#2563eb"
+ },
+ "look": {
+ "fontHeading": "'Noto Serif KR', 'Batang', 'Times New Roman', serif",
+ "fontBody": "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif",
+ "radius": "0px",
+ "borderWidth": "1px",
+ "shadow": "none",
+ "headingTracking": "-0.03em",
+ "headingWeight": "700",
+ "sectionSpace": "5rem"
+ },
+ "addSections": []
+ },
+ "retro": {
+ "name": "레트로",
+ "tag": "레트로 감성",
+ "description": "갱지 바탕에 간판체. 도넛판·일력·승차권이 함께 들어옵니다.",
+ "layout": "basic",
+ "colors": {
+ "primary": "#1b1a15",
+ "secondary": "#4c4739",
+ "bg": "#e4dac0",
+ "card": "#efe7d3",
+ "text": "#1b1a15",
+ "accent": "#bf2f1b"
+ },
+ "look": {
+ "fontHeading": "'Gugi', 'Noto Sans KR', sans-serif",
+ "fontBody": "'Gowun Batang', 'Noto Serif KR', serif",
+ "radius": "0px",
+ "borderWidth": "2px",
+ "shadow": "4px 4px 0 rgb(27 26 21 / 0.16)",
+ "headingTracking": "0em",
+ "headingWeight": "400",
+ "sectionSpace": "4rem",
+ "texture": "repeating-linear-gradient(0deg,rgba(27,26,21,.028) 0 1px,transparent 1px 3px),repeating-linear-gradient(90deg,rgba(27,26,21,.02) 0 1px,transparent 1px 4px)"
+ },
+ "addSections": [
+ "event",
+ "video",
+ "festival",
+ "itinerary",
+ "story"
+ ]
+ },
+ "paper": {
+ "name": "고택",
+ "tag": "고택 지면",
+ "description": "크림빛 종이에 가는 명조. 그림자도 장식도 없이 정갈한 인상을 줍니다.",
+ "layout": "paper",
+ "colors": {
+ "primary": "#1f1d19",
+ "secondary": "#726c61",
+ "bg": "#fdfcfa",
+ "card": "#f2efe8",
+ "text": "#1f1d19",
+ "accent": "#1f1d19"
+ },
+ "look": {
+ "fontHeading": "'Noto Serif KR', 'AppleMyungjo', 'Nanum Myeongjo', serif",
+ "fontBody": "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif",
+ "radius": "0px",
+ "borderWidth": "1px",
+ "shadow": "none",
+ "headingTracking": "0.03em",
+ "headingWeight": "400",
+ "sectionSpace": "4.5rem"
+ },
+ "addSections": []
+ }
+ },
+ "industries": {
+ "stay": {
+ "name": "숙박",
+ "subName": "펜션 · 스테이",
+ "description": "감성 스테이, 풀빌라, 독채 펜션, 게스트하우스",
+ "channels": [
+ {
+ "id": "stay_platforms",
+ "name": "야놀자 · 여기어때",
+ "checked": true
+ },
+ {
+ "id": "naver_place",
+ "name": "네이버 플레이스",
+ "checked": true
+ },
+ {
+ "id": "instagram",
+ "name": "인스타그램",
+ "checked": true
+ }
+ ],
+ "defaultTemplate": "retro",
+ "templates": [
+ "retro",
+ "simple",
+ "magazine",
+ "paper"
+ ],
+ "sections": [
+ {
+ "id": "hero",
+ "type": "hero",
+ "name": "히어로",
+ "locked": true,
+ "enabled": true,
+ "description": "상단 메인 비주얼과 대표 문구"
+ },
+ {
+ "id": "intro",
+ "type": "intro",
+ "name": "소개",
+ "locked": false,
+ "enabled": true,
+ "description": "스테이의 철학과 공간 스토리"
+ },
+ {
+ "id": "rooms",
+ "type": "rooms",
+ "name": "객실 안내",
+ "locked": false,
+ "enabled": true,
+ "description": "객실 타입, 구조, 비치 물품"
+ },
+ {
+ "id": "event",
+ "type": "event",
+ "name": "소식",
+ "locked": false,
+ "enabled": true,
+ "description": "지금 하는 행사 · 공지"
+ },
+ {
+ "id": "info",
+ "type": "info",
+ "name": "기본 정보",
+ "locked": true,
+ "enabled": true,
+ "description": "체크인, 주차, 시설 핵심 정보"
+ },
+ {
+ "id": "booking",
+ "type": "booking",
+ "name": "예약 안내",
+ "locked": false,
+ "enabled": true,
+ "description": "요금 · 예약 창구 안내"
+ },
+ {
+ "id": "video",
+ "type": "video",
+ "name": "영상",
+ "locked": false,
+ "enabled": true,
+ "description": "유튜브 주소 하나면 됩니다"
+ },
+ {
+ "id": "photos",
+ "type": "photos",
+ "name": "사진 갤러리",
+ "locked": false,
+ "enabled": true,
+ "description": "감성 인테리어와 외부 풍경"
+ },
+ {
+ "id": "map",
+ "type": "map",
+ "name": "오시는 길",
+ "locked": true,
+ "enabled": true,
+ "description": "위치 안내 및 대중교통 경로"
+ },
+ {
+ "id": "festival",
+ "type": "festival",
+ "name": "계절별 축제",
+ "locked": false,
+ "enabled": true,
+ "description": "주변에서 열리는 축제 — 계절로 묶어 보여줍니다"
+ },
+ {
+ "id": "local",
+ "type": "local",
+ "name": "지역 정보",
+ "locked": false,
+ "enabled": true,
+ "description": "주변 관광지 및 맛집 추천"
+ },
+ {
+ "id": "itinerary",
+ "type": "itinerary",
+ "name": "추천 일정",
+ "locked": false,
+ "enabled": true,
+ "description": "숙소에서 출발하는 하루 코스"
+ },
+ {
+ "id": "story",
+ "type": "story",
+ "name": "지역 이야기",
+ "locked": false,
+ "enabled": true,
+ "description": "가요·인물·연표·엽서·퀴즈를 탭으로"
+ },
+ {
+ "id": "faq",
+ "type": "faq",
+ "name": "자주 묻는 질문",
+ "locked": false,
+ "enabled": true,
+ "description": "고객들이 자주 묻는 질문과 답변"
+ },
+ {
+ "id": "weather",
+ "type": "weather",
+ "name": "날씨",
+ "locked": false,
+ "enabled": true,
+ "description": "현재 기온과 사업장 주변 날씨"
+ },
+ {
+ "id": "social",
+ "type": "social",
+ "name": "SNS 게시글",
+ "locked": false,
+ "enabled": false,
+ "description": "승인해 함께 발행한 소식 · 홈페이지 맨 아래"
+ }
+ ]
+ },
+ "cafe": {
+ "name": "카페",
+ "subName": "대형카페 · 로스터리",
+ "description": "스페셜티 로스터리, 베이커리 카페, 오션·마운틴 뷰 대형 카페",
+ "channels": [
+ {
+ "id": "naver_place",
+ "name": "네이버 플레이스",
+ "checked": true
+ },
+ {
+ "id": "instagram",
+ "name": "인스타그램",
+ "checked": true
+ },
+ {
+ "id": "kakao_map",
+ "name": "카카오맵",
+ "checked": true
+ }
+ ],
+ "defaultTemplate": "simple",
+ "templates": [
+ "simple",
+ "magazine",
+ "retro",
+ "paper"
+ ],
+ "sections": [
+ {
+ "id": "hero",
+ "type": "hero",
+ "name": "히어로",
+ "locked": true,
+ "enabled": true,
+ "description": "시그니처 비주얼과 카페 슬로건"
+ },
+ {
+ "id": "intro",
+ "type": "intro",
+ "name": "소개",
+ "locked": false,
+ "enabled": true,
+ "description": "로스팅 철학과 공간 스토리"
+ },
+ {
+ "id": "menu",
+ "type": "menu",
+ "name": "시그니처 메뉴",
+ "locked": false,
+ "enabled": true,
+ "description": "대표 원두, 음료, 시그니처 디저트"
+ },
+ {
+ "id": "info",
+ "type": "info",
+ "name": "기본 정보",
+ "locked": true,
+ "enabled": true,
+ "description": "영업시간, 좌석수, 편의시설 정보"
+ },
+ {
+ "id": "space",
+ "type": "space",
+ "name": "공간 · 좌석 안내",
+ "locked": false,
+ "enabled": true,
+ "description": "1/2층 공간 구성 및 야외 테라스석"
+ },
+ {
+ "id": "photos",
+ "type": "photos",
+ "name": "사진 갤러리",
+ "locked": false,
+ "enabled": true,
+ "description": "인테리어, 커피, 베이커리 비주얼"
+ },
+ {
+ "id": "inquiry",
+ "type": "inquiry",
+ "name": "대관 및 단체 문의",
+ "locked": false,
+ "enabled": true,
+ "description": "촬영 대관 및 단체 예약 접수"
+ },
+ {
+ "id": "map",
+ "type": "map",
+ "name": "오시는 길",
+ "locked": true,
+ "enabled": true,
+ "description": "드라이브 코스 및 주차 진입로 안내"
+ },
+ {
+ "id": "weather",
+ "type": "weather",
+ "name": "날씨",
+ "locked": false,
+ "enabled": true,
+ "description": "현재 기온과 매장 주변 날씨"
+ },
+ {
+ "id": "local",
+ "type": "local",
+ "name": "주변 나들이",
+ "locked": false,
+ "enabled": true,
+ "description": "양평 드라이브 코스 및 명소"
+ },
+ {
+ "id": "faq",
+ "type": "faq",
+ "name": "자주 묻는 질문",
+ "locked": false,
+ "enabled": true,
+ "description": "반려견 동반, 주차, 케어키즈존 안내"
+ },
+ {
+ "id": "social",
+ "type": "social",
+ "name": "SNS 게시글",
+ "locked": false,
+ "enabled": false,
+ "description": "승인해 함께 발행한 소식 · 홈페이지 맨 아래"
+ }
+ ]
+ },
+ "restaurant": {
+ "name": "음식점",
+ "subName": "식당 · 주점",
+ "description": "한식 다이닝, 일식 오마카세, 이탈리안 비스트로, 고기집",
+ "channels": [
+ {
+ "id": "naver_place",
+ "name": "네이버 플레이스",
+ "checked": true
+ },
+ {
+ "id": "catchtable",
+ "name": "캐치테이블",
+ "checked": true
+ },
+ {
+ "id": "instagram",
+ "name": "인스타그램",
+ "checked": true
+ }
+ ],
+ "defaultTemplate": "simple",
+ "templates": [
+ "simple",
+ "magazine",
+ "retro",
+ "paper"
+ ],
+ "sections": [
+ {
+ "id": "hero",
+ "type": "hero",
+ "name": "히어로",
+ "locked": true,
+ "enabled": true,
+ "description": "대표 요리 비주얼 및 다이닝 소개"
+ },
+ {
+ "id": "intro",
+ "type": "intro",
+ "name": "소개",
+ "locked": false,
+ "enabled": true,
+ "description": "셰프의 조리 철학과 식재료 원산지 이야기"
+ },
+ {
+ "id": "menu",
+ "type": "menu",
+ "name": "코스 및 메뉴",
+ "locked": false,
+ "enabled": true,
+ "description": "점심/저녁 코스, 단품 요리, 주류 페어링"
+ },
+ {
+ "id": "info",
+ "type": "info",
+ "name": "기본 정보",
+ "locked": true,
+ "enabled": true,
+ "description": "영업시간, 휴무일, 주차, 예약 안내"
+ },
+ {
+ "id": "booking",
+ "type": "booking",
+ "name": "예약 · 포장 안내",
+ "locked": false,
+ "enabled": true,
+ "description": "캐치테이블 실시간 룸 예약 및 포장"
+ },
+ {
+ "id": "photos",
+ "type": "photos",
+ "name": "사진 갤러리",
+ "locked": false,
+ "enabled": true,
+ "description": "플레이팅, 룸 인테리어, 정갈한 상차림"
+ },
+ {
+ "id": "inquiry",
+ "type": "inquiry",
+ "name": "단체 행사 문의",
+ "locked": false,
+ "enabled": true,
+ "description": "상견례, 돌잔치, 기업 대관 문의"
+ },
+ {
+ "id": "map",
+ "type": "map",
+ "name": "오시는 길",
+ "locked": true,
+ "enabled": true,
+ "description": "지하철역 출구 및 발렛부스 위치"
+ },
+ {
+ "id": "weather",
+ "type": "weather",
+ "name": "날씨",
+ "locked": false,
+ "enabled": true,
+ "description": "현재 기온과 매장 주변 날씨"
+ },
+ {
+ "id": "local",
+ "type": "local",
+ "name": "주변 안내",
+ "locked": false,
+ "enabled": true,
+ "description": "청담 명품거리 및 갤러리 안내"
+ },
+ {
+ "id": "faq",
+ "type": "faq",
+ "name": "자주 묻는 질문",
+ "locked": false,
+ "enabled": true,
+ "description": "콜키지 정책, 알러지 케어, 주차 안내"
+ },
+ {
+ "id": "social",
+ "type": "social",
+ "name": "SNS 게시글",
+ "locked": false,
+ "enabled": false,
+ "description": "승인해 함께 발행한 소식 · 홈페이지 맨 아래"
+ }
+ ]
+ },
+ "clinic": {
+ "name": "피부과 · 성형외과",
+ "subName": "의원 · 클리닉",
+ "description": "피부과, 성형외과, 미용 클리닉",
+ "channels": [
+ {
+ "id": "naver_place",
+ "name": "네이버 플레이스",
+ "checked": true
+ },
+ {
+ "id": "kakao_channel",
+ "name": "카카오톡 채널",
+ "checked": true
+ },
+ {
+ "id": "instagram",
+ "name": "인스타그램",
+ "checked": true
+ }
+ ],
+ "defaultTemplate": "simple",
+ "templates": [
+ "simple",
+ "magazine"
+ ],
+ "sections": [
+ {
+ "id": "hero",
+ "type": "hero",
+ "name": "히어로",
+ "locked": true,
+ "enabled": true,
+ "description": "병원 대표 이미지와 진료 분야"
+ },
+ {
+ "id": "intro",
+ "type": "intro",
+ "name": "병원 소개",
+ "locked": false,
+ "enabled": true,
+ "description": "진료 철학과 의료진 소개"
+ },
+ {
+ "id": "programs",
+ "type": "programs",
+ "name": "시술 안내",
+ "locked": false,
+ "enabled": true,
+ "description": "시술명, 소요 시간, 비용"
+ },
+ {
+ "id": "info",
+ "type": "info",
+ "name": "기본 정보",
+ "locked": true,
+ "enabled": true,
+ "description": "진료시간, 휴진일, 예약, 주차"
+ },
+ {
+ "id": "exhibition",
+ "type": "exhibition",
+ "name": "진료 안내",
+ "locked": false,
+ "enabled": true,
+ "description": "상담 절차와 보험 적용 안내"
+ },
+ {
+ "id": "photos",
+ "type": "photos",
+ "name": "사진 갤러리",
+ "locked": false,
+ "enabled": true,
+ "description": "진료실, 상담실, 대기 공간"
+ },
+ {
+ "id": "inquiry",
+ "type": "inquiry",
+ "name": "상담 문의",
+ "locked": false,
+ "enabled": true,
+ "description": "방문·전화 상담 접수"
+ },
+ {
+ "id": "map",
+ "type": "map",
+ "name": "오시는 길",
+ "locked": true,
+ "enabled": true,
+ "description": "역에서 오는 길과 주차장"
+ },
+ {
+ "id": "weather",
+ "type": "weather",
+ "name": "날씨",
+ "locked": false,
+ "enabled": false,
+ "description": "현재 기온과 주변 날씨"
+ },
+ {
+ "id": "local",
+ "type": "local",
+ "name": "주변 정보",
+ "locked": false,
+ "enabled": false,
+ "description": "주변 편의시설"
+ },
+ {
+ "id": "faq",
+ "type": "faq",
+ "name": "자주 묻는 질문",
+ "locked": false,
+ "enabled": true,
+ "description": "예약 변경, 회복 기간, 주의사항"
+ },
+ {
+ "id": "social",
+ "type": "social",
+ "name": "SNS 게시글",
+ "locked": false,
+ "enabled": false,
+ "description": "승인해 함께 발행한 소식 · 홈페이지 맨 아래"
+ }
+ ]
+ }
+ }
+}
diff --git a/solution/shared/src/index.ts b/solution/shared/src/index.ts
index a4d79db..cf2434f 100644
--- a/solution/shared/src/index.ts
+++ b/solution/shared/src/index.ts
@@ -5,3 +5,4 @@ export * from './lib/facts';
export * from './lib/section-data';
export * from './lib/section-prompts';
export * from './lib/color';
+export * from './lib/catalog';
diff --git a/solution/shared/src/lib/catalog.ts b/solution/shared/src/lib/catalog.ts
new file mode 100644
index 0000000..3e729f0
--- /dev/null
+++ b/solution/shared/src/lib/catalog.ts
@@ -0,0 +1,46 @@
+import catalog from '../data/templates.json';
+import type {IndustryDef, IndustryType, LayoutId, TemplateDef} from '../types';
+
+export type TemplateId = keyof typeof catalog.templates;
+
+export const LAYOUT_IDS: readonly LayoutId[] = ['basic', 'paper'];
+
+export const TEMPLATES = catalog.templates as Record;
+
+export const INDUSTRIES = catalog.industries as Record;
+
+export function isTemplateId(id: string): id is TemplateId {
+ return Object.hasOwn(TEMPLATES, id);
+}
+
+export function templateOf(id: string): TemplateDef {
+ if (!isTemplateId(id)) throw new Error(`등록되지 않은 템플릿: ${id}`);
+ return TEMPLATES[id];
+}
+
+export function industryTemplates(industry: IndustryType): TemplateId[] {
+ return INDUSTRIES[industry].templates as TemplateId[];
+}
+
+export function resolveTemplateId(industry: IndustryType, stored: string | null | undefined): TemplateId {
+ const id = stored?.trim() || INDUSTRIES[industry].defaultTemplate;
+ if (!INDUSTRIES[industry].templates.includes(id)) {
+ throw new Error(`${industry} 업종에서 쓸 수 없는 템플릿: ${id}`);
+ }
+ return id as TemplateId;
+}
+
+for (const [id, template] of Object.entries(TEMPLATES)) {
+ if (!LAYOUT_IDS.includes(template.layout)) {
+ throw new Error(`템플릿 ${id}의 레이아웃이 등록되지 않았다: ${template.layout}`);
+ }
+}
+
+for (const [industry, def] of Object.entries(INDUSTRIES)) {
+ for (const id of [def.defaultTemplate, ...def.templates]) {
+ if (!isTemplateId(id)) throw new Error(`${industry} 업종이 없는 템플릿을 가리킨다: ${id}`);
+ }
+ if (!def.templates.includes(def.defaultTemplate)) {
+ throw new Error(`${industry} 업종의 기본 템플릿이 허용 목록에 없다: ${def.defaultTemplate}`);
+ }
+}
diff --git a/solution/shared/src/types/builder.ts b/solution/shared/src/types/builder.ts
index a0741f9..5511be8 100644
--- a/solution/shared/src/types/builder.ts
+++ b/solution/shared/src/types/builder.ts
@@ -1,22 +1,14 @@
-/**
- * 관리자 빌더(위저드 + 에디터)가 쓰는 화면 타입.
- *
- * SitePayload 와 다른 층이다 — 이쪽은 "사장님이 편집 중인 상태",
- * SitePayload 는 "발행 잡이 구운 결과". 편집 상태는 저장 전까지 서버에 없다.
- */
+/** 관리자 빌더(위저드 + 에디터)가 쓰는 화면 타입. */
export type IndustryType = 'stay' | 'cafe' | 'restaurant' | 'clinic';
-export type TemplateTone = 'photo' | 'info' | 'book';
-
export type ViewportMode = 'pc' | 'tablet' | 'mobile';
export interface InfoField {
id: string;
label: string;
value: string;
- /** 캔버스 미리보기용 축약문. intro/room_intro 원문이 길 때만 백엔드가 채워 보낸다(응답 전용,
- * place_facts 에는 저장되지 않는다). 없으면 값이 짧거나 요약에 실패한 것 — 원문(value)으로 폴백한다. */
+ /** 캔버스 미리보기용 축약문. */
summary?: string;
/** 수집됐지만 사장님 확인이 필요한 값(백엔드 FactStatus.UNVERIFIED / PENDING_OWNER 대응). */
requiresVerification: boolean;
@@ -26,7 +18,7 @@ export interface InfoField {
source?: string;
placeholder?: string;
category?: string;
- /** ★ 틀리면 예약 클레임이 나는 항목. 미검증이면 캔버스에서도 가린다. */
+ /** 틀리면 예약 클레임이 나는 항목. 미검증이면 캔버스에서도 가린다. */
critical?: boolean;
}
@@ -40,106 +32,68 @@ export interface SectionItem {
description?: string;
/** 사장님이 직접 편집하는 섹션 본문. 수집된 값과 분리해 자동 수집이 덮어쓰지 않는다. */
body?: string;
- /**
- * 붙여넣기 아이템(가요·일력·코스)의 원문 JSON.
- *
- * ★ 파싱해서 넣지 않고 **문자열 그대로** 둔다. 편집 중인 JSON 은 늘 깨져 있고,
- * 파싱본만 들고 있으면 깨진 순간 사장님이 쓴 걸 잃는다.
- */
+ /** 붙여넣기 아이템(가요·일력·코스)의 원문 JSON. */
data?: string;
- /**
- * 이 섹션을 어떤 레이아웃으로 그릴지 — 사장님이 [디자인] 탭에서 고른 값.
- * 비어 있으면 렌더러가 해당 타입의 기본 배리에이션으로 떨어진다(빈 화면을 만들지 않는다).
- * 담기는 값은 배리에이션 레지스트리의 id (예: 'hero.editorial', 'faq.two-column').
- */
- variantId?: string;
}
-export interface TemplateItem {
+export interface TemplateItem extends TemplateDef {
id: string;
- industryId: IndustryType;
- name: string;
- tone: TemplateTone;
- toneLabel: string;
- description: string;
- colors: {
- primary: string;
- secondary: string;
- bg: string;
- card: string;
- text: string;
- accent: string;
- };
- /** 사람에게 보이는 서체 이름('고딕' · '명조 제목'). 실제 서체는 look 이 정한다. */
- fontStyle: string;
- /**
- * 실제로 화면을 가르는 값.
- *
- * ★ 예전엔 색 여섯 개뿐이라 템플릿을 바꿔도 색조만 달라졌다 — 다섯 개가 다 같아 보였다.
- * 서체·모서리·테두리·그림자·여백이 여기 있고, 캔버스가 CSS 변수로 그대로 내려보낸다.
- */
- look: TemplateLook;
- /**
- * 이 템플릿을 고르면 함께 들어오는 섹션(canvas/addable 의 타입).
- *
- * ★ 넣기만 하고 빼지 않는다. 템플릿을 이리저리 눌러보다 사장님이 넣어 둔 섹션이 사라지면
- * 그게 템플릿 때문인 줄 모른다.
- */
- defaultSectionTypes?: string[];
- /**
- * 이 템플릿에서 **꺼고 시작하는** 섹션(타입).
- *
- * ★ "넣기만 하고 빼지 않는다"(defaultSectionTypes)와 어긋나 보이지만 다른 일이다 —
- * 여기는 **지우지 않는다.** 목록에 그대로 남고 사장님이 한 번 눌러 되살린다.
- * 지워서 없어지는 것과, 꺼진 채로 보이는 것은 사장님이 겪는 일이 다르다.
- * ★ 왜 필요한가: 시안(`/s/stay`)의 옛 항구는 예약 안내가 없다. 오래된 항구 도시의
- * 인상으로 묵는 곳을 보여주는 템플릿이라 실시간 예약 창구를 첫 화면에 세우지 않는다.
- * `defaultSectionTypes` 로는 켤 수만 있어서 이 선택을 표현할 자리가 없었다.
- */
- disabledSectionTypes?: string[];
- /**
- * 섹션 타입 → 배리에이션 id. 템플릿이 고르는 "그 섹션의 모양".
- *
- * ★ 시안의 사진 갤러리는 캐러셀(`photos.carousel`)이다. 색·서체만 템플릿을 따르고
- * 섹션 모양은 늘 기본이면, 템플릿을 골라도 시안과 같은 화면이 안 된다.
- * ★ 사장님이 이미 고른 배리에이션은 덮지 않는다 — 템플릿을 눌러 보다가 자기 선택이
- * 사라지면 그게 템플릿 때문인 줄 모른다(`defaultSectionTypes` 와 같은 이유).
- * ★ 발행본은 아직 이 값을 해석하지 않는다(`site/pages/HomePage.tsx`). 지금 달라지는 것은
- * 에디터 캔버스뿐이고, 발행본까지 가려면 배리에이션을 렌더러로 옮기는 별도 작업이 필요하다.
- */
- defaultVariants?: Record;
}
-/**
- * 템플릿의 생김새. 값은 전부 CSS 에 그대로 들어가는 문자열이다 —
- * 숫자로 들고 있다가 쓰는 쪽에서 단위를 붙이면, 빠뜨린 곳이 조용히 0 이 된다.
- */
+export interface TemplateColors {
+ primary: string;
+ secondary: string;
+ bg: string;
+ card: string;
+ text: string;
+ accent: string;
+}
+
+// 값은 CSS에 그대로 들어가는 문자열이다(단위 포함).
export interface TemplateLook {
- /** 제목 서체 스택. `--tpl-font-heading` */
fontHeading: string;
- /** 본문 서체 스택. `--tpl-font-body` */
fontBody: string;
- /** 모서리. Tailwind 의 `--radius-*` 를 캔버스 안에서만 덮어 rounded-* 전부가 따라온다. */
radius: string;
- /** 카드·패널 테두리 두께. */
borderWidth: string;
- /** 카드 그림자. Tailwind 의 `--shadow-*` 를 덮는다. 'none' 이면 평평해진다. */
shadow: string;
- /** 제목 자간. */
headingTracking: string;
- /** 제목 굵기. 간판체처럼 한 굵기뿐인 서체는 400 이어야 한다. */
+ // 간판체처럼 굵기가 한 벌뿐인 서체는 400이어야 가짜 볼드가 안 생긴다.
headingWeight: string;
- /** 섹션 세로 여백. 이 값 하나로 페이지의 호흡이 바뀐다. */
sectionSpace: string;
- /**
- * 바탕 질감. CSS `background-image` 값 그대로다(갱지 결·격자).
- *
- * ★ 색·서체만으로는 '인쇄물'이 안 된다. 레트로의 정체성 절반이 이 갱지 결이라
- * 토큰으로 내려보낸다 — 없는 템플릿은 이 칸을 비우면 그만이다.
- */
texture?: string;
}
+export type LayoutId = 'basic' | 'paper';
+
+export interface TemplateDef {
+ name: string;
+ tag: string;
+ description: string;
+ layout: LayoutId;
+ colors: TemplateColors;
+ look: TemplateLook;
+ addSections: string[];
+}
+
+export interface IndustrySection {
+ id: string;
+ type: string;
+ name: string;
+ locked: boolean;
+ enabled: boolean;
+ description: string;
+}
+
+export interface IndustryDef {
+ name: string;
+ subName: string;
+ description: string;
+ channels: {id: string; name: string; checked: boolean}[];
+ defaultTemplate: string;
+ templates: string[];
+ sections: IndustrySection[];
+}
+
export interface PhotoItem {
id: string;
url: string;
@@ -155,13 +109,7 @@ export interface FaqItem {
answer: string;
}
-/**
- * Slate 문서 노드.
- *
- * 업종 시드의 `editor_script`(예약 문의 대화 예시)가 이 모양이다.
- * 렌더러(SlateScriptRenderer)와 데이터가 같은 타입을 봐야
- * "children 에 뭐가 들어올지 모르는" 상태가 안 생긴다.
- */
+/** Slate 문서 노드. */
export interface SlateTextNode {
text: string;
bold?: boolean;
@@ -180,12 +128,7 @@ export interface SlateElementNode {
export type SlateNode = SlateElementNode | SlateTextNode;
-/**
- * 업종 고유 목록의 한 줄(객실 · 메뉴 · 코스 · 프로그램).
- *
- * 업종마다 채우는 칸이 달라 전부 선택값이다 — 없는 칸은 화면에서 그냥 빠진다.
- * (숙박은 size/spec, 카페는 tag, 피부과·성형외과는 duration/target 을 쓴다.)
- */
+/** 업종 고유 목록의 한 줄(객실 · 메뉴 · 코스 · 프로그램). */
export interface IndustryListItem {
name: string;
desc?: string;
@@ -197,12 +140,7 @@ export interface IndustryListItem {
target?: string;
}
-/**
- * 업종별 부가 데이터.
- *
- * `Record` 로 두면 쓰는 쪽마다 캐스팅이 필요하고,
- * 캐스팅은 틀려도 컴파일이 통과한다. 실제로 들어오는 키만 적어 둔다.
- */
+/** 업종별 부가 데이터. */
export interface IndustryCustomData {
/** 숙박 — 객실 목록 */
rooms?: IndustryListItem[];
@@ -218,14 +156,7 @@ export interface IndustryCustomData {
editor_script?: SlateNode[];
}
-/**
- * 업종 설정.
- *
- * ★ **구조만** 담는다. 상호·주소·소개문·정보표·사진·FAQ·메뉴 같은 콘텐츠 시드는 전부 지웠다.
- * 그 값들이 있던 시절에는 아직 아무 가게도 고르지 않은 화면이 가공의 업소로 가득 찼고
- * ('달빛스테이 제주', '포레스트 힐 로스터스'), 사장님은 그걸 자기 가게 내용으로 읽고
- * 그대로 발행했다. 콘텐츠의 출처는 서버(fact · unit · faq · media) 하나뿐이다.
- */
+/** 업종 설정. */
export interface IndustryData {
id: IndustryType;
name: string;
@@ -233,5 +164,6 @@ export interface IndustryData {
description: string;
channels: { id: string; name: string; checked: boolean }[];
sections: SectionItem[];
+ defaultTemplate: string;
templates: TemplateItem[];
}
diff --git a/solution/shared/src/types/site-payload.ts b/solution/shared/src/types/site-payload.ts
index a04902b..dfae302 100644
--- a/solution/shared/src/types/site-payload.ts
+++ b/solution/shared/src/types/site-payload.ts
@@ -1,4 +1,4 @@
-import type {TemplateLook} from './builder';
+import type {TemplateColors, TemplateLook} from './builder';
import type {
FactStatus,
LinkChannel,
@@ -7,17 +7,7 @@ import type {
SourceType,
} from './domain';
-/**
- * SitePayload — 발행 사이트 렌더러(`site/`)의 유일한 입력.
- *
- * 백엔드의 BUILD 잡이 이 JSON 을 만들어 넘기고, `site/scripts/prerender.ts` 가
- * 그걸로 정적 HTML 을 굽는다. 렌더러는 이 타입 밖의 것을 절대 모른다 —
- * DB 도, API 도, 로그인도 모른다. 그래서 같은 payload 면 항상 같은 HTML 이 나온다.
- *
- * ★ 이 payload 는 "이미 게이트를 통과한 것"이 아니다. fact 마다 status 가 그대로 실려 온다.
- * 노출 필터링은 렌더러가 `selectPublishable()` 로 한 번 더 한다 —
- * 백엔드 게이트가 뚫려도 미검증 값이 화면·JSON-LD 로 새지 않게 하는 2중 방어다.
- */
+/** SitePayload — 발행 사이트 렌더러(`site/`)의 유일한 입력. */
export interface SitePayload {
/** 스키마 버전. 렌더러가 모르는 버전이면 빌드를 실패시킨다(조용히 반쪽 렌더하지 않는다). */
schemaVersion: 1;
@@ -41,17 +31,11 @@ export interface SitePayload {
/** 주요 거점까지의 이동 시간. */
routes: RouteEntry[];
- /**
- * 이 숙소의 노래. 발행할 때마다 한 곡 만든다(가사 Gemini → 작곡 Suno).
- *
- * ★ 발행이 노래를 기다리므로 발행본에는 보통 한 곡이 실려 있다. 그래도 빌 수 있다 —
- * 키가 없거나(SUNO_API_KEY) 작곡이 실패하면 **노래 없이** 발행한다(발행을 막지는 않는다).
- * 렌더러는 비면 플레이어를 아예 그리지 않는다.
- */
+ /** 이 숙소의 노래. */
songs: SongTrack[];
/** 미니 블로그 글. 전부 HTML 에 들어가고 화면이 나눠 보여준다(docs/MINI_BLOG.md). */
posts?: PostEntry[];
- /** 검수를 통과한 이용 후기. 별점은 없다(2026-09-16 회의). */
+ /** 검수를 통과한 이용 후기. 별점은 없다. */
reviews?: ReviewEntry[];
/** 같은 원고의 발행된 사본. 고유 콘텐츠·SEO 메타의 근거로 세지 않는다. */
socialPosts?: SocialPostItem[];
@@ -62,12 +46,7 @@ export interface SitePayload {
/** 템플릿 — 색/서체/섹션 순서. 관리자 에디터가 정한 값이 그대로 온다. */
theme: SiteTheme;
- /**
- * 검색 키워드 — SiteOntology 가 고르고 백엔드가 **이 가게 자료로 거른** 것(services/seo_keywords).
- *
- * ★ 선택 필드다. 옛 payload·목업·SiteOntology 가 꺼진 빌드에는 없고, 그때 제목·메타는 예전 그대로 나간다.
- * 필드를 더했을 뿐 기존 모양은 그대로라 schemaVersion 은 올리지 않는다.
- */
+ /** 검색 키워드 — SiteOntology 가 고르고 백엔드가 **이 가게 자료로 거른** 것(services/seo_keywords). */
seo?: SiteSeo;
}
@@ -87,7 +66,6 @@ export interface ReviewEntry {
export interface SiteSeo {
/** `` 로 나간다. SiteOntology 융합 순위 그대로, 최대 10개. */
keywords: string[];
- /** 제목의 업종어 자리(`<상호> · <이 값>`). 시·군 이름을 품은 유형 키워드. 없으면 예전 제목. */
titleKeyword?: string;
}
@@ -105,13 +83,7 @@ export interface SiteMeta {
updatedAt: string;
/** 사이트 버전. 빌드 산출물 캐시 무효화 키이자 `out/versions///` 산출 경로. */
version: number;
- /**
- * 이 렌더가 **공개 주소(`/s/`)를 이 버전으로 넘겨도 되는가**.
- *
- * ★ false(미리보기·재빌드)면 렌더러가 `out/versions///` 에만 굽고
- * 공개 심볼릭 링크는 건드리지 않는다 — 방문자는 계속 직전 버전을 본다.
- * BUILD 잡의 `publish=false` 가 그대로 여기로 온다(services/site_payload._publish_target).
- */
+ /** 이 렌더가 **공개 주소(`/s/`)를 이 버전으로 넘겨도 되는가**. */
publish: boolean;
}
@@ -151,9 +123,7 @@ export interface LegalInfo {
licenseLabel?: string;
}
-/**
- * fact 한 건. 업종 스키마(`common/category_schema`)의 필드 정의가 label/unit/critical 을 준다.
- */
+/** fact 한 건. */
export interface FactEntry {
key: string;
label: string;
@@ -166,7 +136,7 @@ export interface FactEntry {
status: FactStatus;
sourceType: SourceType;
sourceUrl?: string | null;
- /** ★ 틀리면 헛걸음·예약 클레임이 나는 항목. 미검증이면 절대 노출하지 않는다. */
+ /** 틀리면 헛걸음·예약 클레임이 나는 항목. 미검증이면 절대 노출하지 않는다. */
critical: boolean;
required: boolean;
/** scope=unit 인 fact 가 어느 단위에 속하는지. */
@@ -194,7 +164,7 @@ export interface MediaItem {
width?: number;
height?: number;
isPrimary?: boolean;
- /** ★ 이미지 재게시 권리(DECISIONS 1-2)가 결론 날 때까지 출처를 보존한다. */
+ /** 이미지 재게시 권리(DECISIONS 1-2)가 결론 날 때까지 출처를 보존한다. */
sourceType: SourceType;
originUrl?: string | null;
unitId?: string | null;
@@ -240,24 +210,9 @@ export interface LocalContents {
attractions: LocalPlace[];
restaurants: LocalPlace[];
festivals: FestivalEntry[];
- /**
- * 업장 좌표로 **서버가 조립한** 추천 일정(1박2일·2박3일).
- *
- * ★ 사장님이 붙여넣는 아이템(`theme.sections[].data`)과 자리를 나눈다 — 이건 우리가
- * 만든 값이라 payload 의 서버 영역에 둔다. 화면은 사장님 값을 먼저 보고, 없을 때
- * 이걸 쓴다(`ItinerarySection`).
- * ★ 저장하지 않는다. 재료(주변 정보)가 갱신되면 다음 빌드에서 저절로 최신이 된다.
- */
+ /** 업장 좌표로 **서버가 조립한** 추천 일정(1박2일·2박3일). */
itineraries?: ItineraryItem[];
- /**
- * 지역 이야기 — 서버가 지역 단위로 생성한 가요·일력·인물·연표·읽기·엽서·퀴즈.
- *
- * ★ 사장님이 붙여넣는 같은 종류의 JSON(`theme.sections[].data`)과 **모양이 같다.**
- * 화면은 둘을 한 배열로 이어 그린다(`StorySection`) — 사장님 값이 앞이다.
- * 그래서 여기 항목을 다른 모양으로 바꾸면 안 된다(`site_payload._local` 주석).
- * ★ 왜 업장이 아니라 여기인가: 군산 이야기는 군산 숙소가 같이 쓴다. 업장별로 복제하면
- * 같은 곡 목록이 사이트 수만큼 생긴다(`area_contents` 가 region_code 를 키로 두는 이유).
- */
+ /** 지역 이야기 — 서버가 지역 단위로 생성한 가요·일력·인물·연표·읽기·엽서·퀴즈. */
story?: LocalStories;
/** 지역 정보를 마지막으로 갱신한 시각. 화면에 그대로 노출한다(오래된 정보를 숨기지 않는다). */
syncedAt?: string;
@@ -274,18 +229,7 @@ export interface LocalStories {
quiz?: QuizItem[];
}
-/**
- * 기온을 다섯으로 가른다 — **밖에서 뭘 할 수 있느냐**가 갈리는 단위다.
- *
- * ★ 하늘(mood)과 따로 두는 이유: 같은 '맑음' 이라도 34도의 맑음과 3도의 맑음은
- * 손님이 할 수 있는 일이 정반대다. 여름 맑음에 "골목을 걸어 보세요"라고 쓰면
- * 폭염에 손님을 내보내는 문장이 된다.
- * ★ 경계는 관광 기준이다(기상특보 기준이 아니다).
- * 30 — 낮 골목 산책이 무리가 되는 선. 33도(폭염주의보)는 이미 늦다.
- * 25 — 바다·물놀이가 답이 되는 선.
- * 20 — 하루 종일 걸어도 되는 구간의 아래끝.
- * 10 — 실외에서 오래 서 있기 어려워지는 선.
- */
+/** 기온을 다섯으로 가른다 — **밖에서 뭘 할 수 있느냐**가 갈리는 단위다. */
export type WeatherBand = '혹서' | '더움' | '선선' | '쌀쌀' | '추움';
/** 날씨를 넷으로만 가른다 — 문장과 그림이 갈리는 최소 단위다. */
@@ -326,28 +270,11 @@ export interface WeatherSnapshot {
condition: string;
/** 날씨에 따른 안내 문구. LLM 문장이므로 사실 주장(가격·시간)을 담지 않는다. */
note?: string;
- /**
- * 날씨별 한 줄.
- *
- * ★ `note` 한 칸으로는 안 된다 (2026-09-04, 사장님: "저렇게만 말고 콘텐츠가 있어야")
- * 발행본은 정적이라 굽는 순간의 문장이 박히는데, 날씨는 브라우저에서 갱신된다.
- * 비 오는 날 "마당에 앉기 좋습니다"가 뜨는 사고가 난다. 그래서 **조건별로 미리 적어 두고**
- * 화면이 지금 날씨에 맞는 것을 고른다.
- * ★ 사장님이 자기 집을 두고 쓰는 문장이다 — 날씨 예보가 아니다.
- */
+ /** 날씨별 한 줄. */
notes?: Partial>;
noteSets?: Partial>;
tempNoteSets?: Partial>;
- /**
- * 기온대별 한 줄 — **오늘 이 동네에서 뭘 하면 되나.**
- *
- * ★ `notes` 와 갈라 둔다 (2026-09-09, 사장님: "기온에 따라서 문구 다르게, 펜션지역의 관광에 맞춰")
- * `notes` 는 하늘을 보고 **집 안에서** 뭘 할지 적은 문장이고(마당·CAFÉ·기와),
- * 이쪽은 기온을 보고 **밖에서** 어디를 갈지 적은 문장이다. 축이 둘이라 한 칸에
- * 담으면 4×5 스무 문장을 사장님이 적어야 한다 — 두 줄로 나란히 내면 넷+다섯이면 된다.
- * ★ 여기 적는 곳은 이 사이트에 **이미 있는 자리**여야 한다(주변 안내·축제).
- * 화면에 없는 곳을 날씨 칸에서만 권하면 손님이 그걸 찾을 데가 없다.
- */
+ /** 기온대별 한 줄. */
tempNotes?: Partial>;
observedAt: string;
}
@@ -359,22 +286,11 @@ export interface LocalPlace {
location?: string;
/** 업장 좌표 기준 거리("850m"/"1.2km"). 지역 캐시에서 온 항목엔 없을 수 있다. */
distanceText?: string;
- /**
- * 업장 좌표 기준 거리(m).
- *
- * ★ 사람이 읽는 `distanceText` 와 따로 싣는다 — 화면이 도보 시간 필터(분속 80m 환산)를
- * 계산하려면 숫자가 필요하고, "1.2km" 를 화면이 다시 파싱하면 백엔드와 규칙이 갈린다.
- */
+ /** 업장 좌표 기준 거리(m). */
distanceMeters?: number;
durationText?: string;
description?: string;
- /**
- * 대표 사진.
- *
- * ★ 이 칸이 없어서 주변 안내가 이름만 적힌 상자였다 — 아무도 보지 않는다.
- * ★ 우리가 찾아 붙이는 사진이 아니다. 출처(공공데이터)가 그 장소의 사진으로 준 것만 싣고,
- * **저작권 유형이 상업적 이용을 막는 건은 수집 단계에서 뺀다**(services/external/tour_places).
- */
+ /** 대표 사진. */
imageUrl?: string;
/** 외부 검색으로 보내는 질의어. 우리가 지어낸 URL 을 링크하지 않는다. */
searchQuery: string;
@@ -388,11 +304,7 @@ export interface FestivalEntry {
description?: string;
officialUrl?: string;
searchQuery: string;
- /**
- * 봄·여름·가을·겨울. 축제를 계절로 묶어 보여주려면(FestivalSection) 화면이 날짜를
- * 다시 계절로 환산해야 하는데, 그 규칙이 렌더러에 생기면 수집 쪽과 갈라진다.
- * 시작일에서 한 번만 정해 payload 에 싣는다. 비어 있으면 계절을 타지 않는 행사로 본다.
- */
+ /** 봄·여름·가을·겨울. */
season?: string;
/** 시작·종료일(YYYY-MM-DD). `period` 는 사람이 읽는 문구라 기계가 못 읽는다 — 정렬·계절 산출의 근거. */
startDate?: string;
@@ -433,12 +345,7 @@ export interface SongTrack {
/** 장르·분위기(Suno 에 넘긴 값). */
style?: string | null;
durationSec?: number | null;
- /**
- * 재생 주소. **우리 쪽 경로**다(`/s//<파일>`).
- *
- * ★ Suno 가 준 주소를 그대로 쓰지 않는다 — 만료되는 주소라, 발행 직후에는 재생되고
- * 몇 주 뒤 조용히 죽는다. 백엔드가 파일을 받아 두고 프리렌더가 사이트로 복사한다.
- */
+ /** 재생 주소. */
audioUrl: string;
/** 프리렌더가 원본을 찾을 때 쓰는 파일명. 렌더러는 쓰지 않는다. */
fileName?: string;
@@ -446,61 +353,19 @@ export interface SongTrack {
export interface SiteTheme {
templateId: string;
- colors: {
- primary: string;
- secondary: string;
- bg: string;
- card: string;
- text: string;
- accent: string;
- };
- fontStyle: string;
- /**
- * 템플릿의 생김새 — 서체 · 모서리 · 테두리 두께 · 그림자 · 섹션 여백.
- *
- * ★ 이 필드가 없던 동안 발행본은 **색만** 템플릿을 따랐다. 사장님이 레트로(간판체)를 골라도
- * 발행 페이지는 늘 같은 고딕·명조로 나갔다 — 캔버스와 발행본이 다르게 보이는 가장 큰 이유였다.
- * ★ 값은 전부 CSS 에 그대로 들어가는 문자열이다(`TemplateLook`). 렌더러는 이걸 `--tpl-*` 로
- * 내려보내기만 한다. 비어 있으면 렌더러 기본 서체로 떨어진다 — 화면이 깨지지 않는다.
- */
- look?: TemplateLook;
- /** 섹션 순서와 노출 여부. 관리자 에디터의 좌측 패널이 만든 결과 그대로. */
+ colors: TemplateColors;
+ look: TemplateLook;
sections: SectionSetting[];
}
export interface SectionSetting {
id: string;
- /** 사장님이 붙인 섹션 제목. 발행본의 소제목이 이 값을 따른다. */
name: string;
enabled: boolean;
- /** SEO·필수 마크업 때문에 끌 수 없는 섹션. */
locked: boolean;
- /**
- * 사장님이 [디자인] 탭에서 고른 레이아웃(배리에이션 레지스트리 키, 예: 'hero.editorial').
- *
- * ★ 이 필드가 없던 동안, 에디터에서 고른 배리에이션은 payload 경계에서 통째로 버려졌다 —
- * 40개를 고를 수 있는데 발행본은 언제나 같은 한 벌로 나갔다.
- * ★ 비어 있으면 렌더러가 그 섹션의 기본 레이아웃으로 떨어진다(화면이 깨지지 않는다).
- * 렌더러가 모르는 키를 만나도 마찬가지다 — 서버는 값을 해석하지 않고 그대로 싣는다.
- */
- variantId?: string;
- /**
- * 사장님이 에디터에서 직접 쓴 섹션 본문.
- *
- * ★ variantId 와 같은 사연이다 — 이 필드가 없던 동안 캔버스에 쓴 소개문은 payload 경계에서
- * 버려졌다. 저장(sites.theme)은 되는데 발행본에 안 나왔고, 고유 콘텐츠로도 세지 않아
- * "소개를 썼는데 고유 콘텐츠 0건으로 발행이 막힌다" 가 됐다.
- * ★ 줄바꿈이 문단 구분이다. 렌더러가 빈 줄을 기준으로
를 나눈다.
- */
+ // 사장님이 쓴 본문.
body?: string;
- /**
- * 붙여넣기 아이템(가요·일력·승차권·인물…)의 원문 JSON.
- *
- * ★ body·variantId 와 같은 사연이다 — 이 필드가 없으면 사장님이 붙여넣은 곡 목록이
- * payload 경계에서 버려져 빌더에서는 보이는데 발행본에는 없다.
- * ★ **문자열 그대로** 싣는다. 서버는 파싱하지 않는다 — 렌더러가 `parseSectionData()`
- * (shared/lib/section-data.ts)로 읽고, 깨진 JSON 이면 그 섹션만 조용히 비운다.
- */
+ // 붙여넣기 아이템 원문 JSON.
data?: string;
}
diff --git a/solution/site/src/App.tsx b/solution/site/src/App.tsx
index fa998cb..98859ab 100644
--- a/solution/site/src/App.tsx
+++ b/solution/site/src/App.tsx
@@ -1,68 +1,20 @@
-import type {ReactNode} from 'react';
-import type {SitePayload} from '@o2o/shared';
-import {MobileTabBar, SiteFooter, SiteHeader} from '@site/sections';
+import {templateOf, type SitePayload} from '@o2o/shared';
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 {Shell as PaperShell} from '@site/layouts/paper/Shell';
-import {HomePage} from '@site/pages';
+import {LayoutProvider} from '@site/lib/layout';
+import {LAYOUTS} from '@site/layouts';
+import {SectionList} from '@site/pages';
-/**
- * 발행 사이트 — **한 장짜리다.**
- *
- * ★ 왜 라우터를 쓰지 않나 (2026-08-31 결정)
- * 소상공인 사이트는 원래 내용이 적다. 그걸 홈·객실·주변·오시는길·FAQ 로 쪼개면
- * 페이지마다 얇아지고, 검색엔진은 그런 페이지를 색인에서 버린다("Crawled – currently
- * not indexed"). 한 장에 모으면 알찬 페이지 하나가 된다.
- * 덤으로 `basename` 을 맞추던 문제(SSR 은 /faq, 하이드레이션 후엔 /s/mmg/faq)가
- * 통째로 사라진다 — 섹션 이동은 전부 앵커(#faq)다.
- *
- * 섹션 순서·표시 여부는 HomePage 가 theme.sections 로 정한다.
- */
export function App({payload}: {payload: SitePayload}) {
- /*
- * ★ 껍데기(상단·본문 폭·하단)를 템플릿이 정한다.
- * 여기 한 벌로 두면 색만 다른 사이트가 나온다 — 안이 갈리려면 뼈대가 갈려야 한다.
- */
- const layout = layoutOf(payload.theme.templateId);
- const Shell =
- layout === 'reservation'
- ? ReservationShell
- : layout === 'oasi'
- ? OasiShell
- : layout === 'studio'
- ? StudioShell
- : layout === 'pastel'
- ? PastelShell
- : layout === 'editorial'
- ? EditorialShell
- : layout === 'paper'
- ? PaperShell
- : DefaultShell;
+ const layout = LAYOUTS[templateOf(payload.theme.templateId).layout];
+ const {Frame} = layout;
return (
-
-
-
+
+
+
+
+
);
}
-
-/** 기본 껍데기 — 지금까지 발행된 사이트가 쓰는 그것. 건드리지 않는다. */
-function DefaultShell({children}: {children: ReactNode}) {
- return (
-
-
-
-
{children}
-
-
-
-
- );
-}
diff --git a/solution/site/src/app.test.tsx b/solution/site/src/app.test.tsx
new file mode 100644
index 0000000..0740f58
--- /dev/null
+++ b/solution/site/src/app.test.tsx
@@ -0,0 +1,30 @@
+import {expect, it} from 'vitest';
+import {renderToStaticMarkup} from 'react-dom/server';
+import type {SitePayload} from '@o2o/shared';
+import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay';
+import {App} from './App';
+
+function withTemplate(templateId: string): SitePayload {
+ const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
+ payload.theme.templateId = templateId;
+ return payload;
+}
+
+it('템플릿이 가리키는 레이아웃의 Frame으로 그린다', () => {
+ const basic = renderToStaticMarkup();
+ const paper = renderToStaticMarkup();
+ expect(basic).toContain('shell flex h-16');
+ expect(paper).toContain('shell flex h-14');
+});
+
+it('여러 템플릿이 같은 레이아웃을 쓴다', () => {
+ const simple = renderToStaticMarkup();
+ const retro = renderToStaticMarkup();
+ expect(retro).toContain('shell flex h-16');
+ expect(simple).toContain('shell flex h-16');
+});
+
+it('등록되지 않은 templateId면 렌더를 멈춘다', () => {
+ expect(() => renderToStaticMarkup()).toThrow('등록되지 않은 템플릿');
+ expect(() => renderToStaticMarkup()).toThrow('등록되지 않은 템플릿');
+});
diff --git a/solution/site/src/entry-client.tsx b/solution/site/src/entry-client.tsx
index d9634f0..978f964 100644
--- a/solution/site/src/entry-client.tsx
+++ b/solution/site/src/entry-client.tsx
@@ -1,13 +1,12 @@
import {StrictMode, useEffect} from 'react';
import {createRoot, hydrateRoot} from 'react-dom/client';
-import type {SitePayload} from '@o2o/shared';
+import {templateOf, type SitePayload} from '@o2o/shared';
import {App} from './App';
import {fontHref, themeVars} from '@site/seo/head';
import './index.css';
declare global {
interface Window {
- /** 프리렌더가 HTML 안에 심어 둔 payload. 이게 있으면 하이드레이션, 없으면 개발 모드. */
__SITE_PAYLOAD__?: SitePayload;
}
}
@@ -15,37 +14,13 @@ declare global {
const container = document.getElementById('root')!;
const injected = window.__SITE_PAYLOAD__;
-/**
- * 미리보기를 띄운 빌더에게 "이제 그림이 나왔다" 고 알린다.
- *
- * ★ 빌더는 iframe 의 `load` 만으로는 이 시점을 알 수 없다. `load` 는 **셸이 뜬** 순간이고,
- * 그 뒤에 payload fetch + 웹폰트 대기(최대 2.5s) + `document.fonts.ready` 가 남아 있다.
- * 그 사이 iframe 은 흰 화면인데, load 를 완료로 읽으면 로딩 표시가 바로 사라져
- * 사장님은 "다 됐다는데 아무것도 없는" 화면을 본다.
- * ★ 두 번의 rAF 를 기다린다 — 첫 프레임은 React 가 DOM 을 붙인 직후라 아직 그려지기 전이다.
- * ★ 실패해도 보낸다(ok=false). 안 보내면 빌더의 로딩 표시가 영영 안 걷힌다.
- */
+// 빌더에 그림이 다 그려졌다고 알린다. iframe load는 빈 셸이 뜬 시점이라 쓸 수 없다.
function signalPreviewPainted(ok: boolean) {
if (window.parent === window) return;
const post = () => window.parent.postMessage({type: 'o2o:preview-painted', ok}, window.location.origin);
requestAnimationFrame(() => requestAnimationFrame(post));
}
-/**
- * 빌더 미리보기(iframe)가 여는 셸에서만 쓰는 경로 — `/preview?placeId=…`.
- *
- * ★ 왜 여기서 payload 를 가져오나
- * 미리보기는 **발행 전 최신 값**을 봐야 한다. 굽는 시점에는 어느 사업장을 미리 볼지
- * 모르므로 셸에 payload 를 심을 수 없다(prerender `writePreviewShell`).
- * ★ 토큰은 부모(빌더)와 **같은 오리진의 localStorage** 에서 읽는다. 미리보기는 자기 서버가
- * 아니라 빌더가 쓰는 것과 같은 API 를 부르므로, 키도 그쪽과 같아야 한다
- * (`frontend/src/api/mutator/custom-fetch.ts`).
- * ★ 색·서체 토큰과 **웹폰트 링크**를 발행본이 `` 에 굽는 것과 같은 함수로 만든다
- * (themeVars · fontHref). 셸은 어느 템플릿인지 모른 채 구워지므로 둘 다 여기서 얹는다.
- * ★ 폰트를 빠뜨리면 조용히 틀린다 — 레이아웃·색은 그대로인데 글자만 기본 산세리프로
- * 떨어진다. 실측(2026-09-09): 간판체(Gugi)가 안 실려 픽셀 차이가 92% 였는데
- * 지오메트리(header·hero·h1 위치)는 발행본과 완전히 같았다.
- */
function PreviewReady() {
useEffect(() => signalPreviewPainted(true), []);
return null;
@@ -65,6 +40,7 @@ async function renderPreview(placeId: string) {
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const payload = (await res.json()) as SitePayload;
+ templateOf(payload.theme.templateId);
for (const [name, value] of Object.entries(themeVars(payload))) {
document.documentElement.style.setProperty(name, value);
@@ -74,7 +50,6 @@ async function renderPreview(placeId: string) {
font.rel = 'stylesheet';
font.href = fontHref(payload);
document.head.appendChild(font);
- // 글자가 기본 서체로 한 번 그려졌다 바뀌는 것을 줄인다. 못 받아도 렌더는 계속한다.
await new Promise((resolve) => {
font.addEventListener('load', () => resolve(), {once: true});
font.addEventListener('error', () => resolve(), {once: true});
@@ -98,8 +73,7 @@ async function renderPreview(placeId: string) {
const previewPlaceId = new URLSearchParams(window.location.search).get('placeId');
if (injected) {
- // 정적 HTML 위에 하이드레이션. 서버가 그린 마크업과 1:1 이어야 하므로
- // 여기서 payload 를 바꾸거나 fetch 를 걸지 않는다.
+ // 서버가 그린 마크업과 1:1이어야 하므로 payload를 바꾸지 않는다.
hydrateRoot(
container,
@@ -108,12 +82,11 @@ if (injected) {
);
} else if (previewPlaceId) {
void renderPreview(previewPlaceId).catch((ex: unknown) => {
- // 미리보기가 못 떠도 편집은 계속돼야 한다. 부모 창이 읽을 수 있게 이유를 남긴다.
container.textContent = `미리보기를 불러오지 못했습니다 — ${ex instanceof Error ? ex.message : String(ex)}`;
signalPreviewPainted(false);
});
} else {
- // 개발 서버(vite dev) — 데모 payload 로 CSR 렌더. 프로덕션 경로가 아니다.
+ // 개발 서버 전용 데모 payload.
void import('./fixtures/moonlight-stay').then(({MOONLIGHT_STAY_PAYLOAD}) => {
createRoot(container).render(
diff --git a/solution/site/src/fixtures/moonlight-stay.ts b/solution/site/src/fixtures/moonlight-stay.ts
index 38fb5f9..75676c5 100644
--- a/solution/site/src/fixtures/moonlight-stay.ts
+++ b/solution/site/src/fixtures/moonlight-stay.ts
@@ -4,6 +4,7 @@ import {
PlaceCategory,
SiteStatus,
SourceType,
+ TEMPLATES,
type FactEntry,
type SitePayload,
} from '@o2o/shared';
@@ -530,7 +531,7 @@ export const MOONLIGHT_STAY_PAYLOAD: SitePayload = {
},
theme: {
- templateId: 'stay-warm-wood',
+ templateId: 'simple',
colors: {
primary: '#43302b',
secondary: '#786055',
@@ -539,15 +540,12 @@ export const MOONLIGHT_STAY_PAYLOAD: SitePayload = {
text: '#29201d',
accent: '#c27847',
},
- fontStyle: 'Warm Natural',
+ look: TEMPLATES.simple.look,
sections: [
{id: 'hero', name: '히어로', enabled: true, locked: true},
{id: 'intro', name: '소개', enabled: true, locked: false},
{id: 'rooms', name: '객실 안내', enabled: true, locked: false},
{id: 'info', name: '기본 정보', enabled: true, locked: true},
- // ★ 서버 기본표(`site_payload._DEFAULT_THEME`)의 숙박 목록에 있는 두 섹션이
- // fixture 에는 빠져 있었다. 그래서 개발 서버로는 이용 규정·예약 안내가 보이지 않아
- // "발행하면 나오는데 여기서는 안 나온다" 를 확인할 수 없었다.
{id: 'rules', name: '이용 규정', enabled: true, locked: false},
{id: 'booking', name: '예약 안내', enabled: true, locked: false},
{id: 'photos', name: '사진 갤러리', enabled: true, locked: false},
diff --git a/solution/site/src/layouts/basic/Frame.tsx b/solution/site/src/layouts/basic/Frame.tsx
new file mode 100644
index 0000000..062ce24
--- /dev/null
+++ b/solution/site/src/layouts/basic/Frame.tsx
@@ -0,0 +1,13 @@
+import type {ReactNode} from 'react';
+import {MobileTabBar, SiteFooter, SiteHeader} from '@site/sections';
+
+export function Frame({children}: {children: ReactNode}) {
+ return (
+
+
+
{children}
+
+
+
+ );
+}
diff --git a/solution/site/src/layouts/editorial/Hero.tsx b/solution/site/src/layouts/editorial/Hero.tsx
deleted file mode 100644
index e561b26..0000000
--- a/solution/site/src/layouts/editorial/Hero.tsx
+++ /dev/null
@@ -1,163 +0,0 @@
-import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
-import {useSite} from '@site/lib/site-context';
-import {primaryImage} from '@site/seo/meta';
-import {
- bookingActionLabel,
- bookingLinks,
- contactLinks,
- channelLabel,
- heroFacts,
- lowestPrice,
- unitSpec,
- unitViews,
-} from '@site/lib/derive';
-import {GRAIN} from './SectionHead';
-
-/**
- * 편집(잡지)형 첫 화면 — 표지.
- *
- * ★ 사진은 오른쪽에 크게 걸고, 상호는 그 경계를 넘어 왼쪽 여백까지 흘러나온다.
- * 겹침은 12칸 격자 위에서 만든다 — 사진과 상호 띠를 **같은 행 같은 칸**에 놓고
- * 띠만 아래로 붙였다(absolute 를 쓰지 않아 모바일에서 자동으로 위아래로 풀린다).
- * ★ 상호를 사진에 맨몸으로 얹지 않는다. 어느 사진의 어느 쪽이 밝은지 우리는 모른다 —
- * 어두운 띠를 한 겹 깔고 그 위에 얹으면 어떤 사진에서도 대비가 남는다(88% 기준
- * 흰 사진 위 최악값이 약 3.9:1, 큰 글자 기준 3:1 을 넘긴다).
- * ★ 태그라인·요금·이용 정보는 사진 위가 아니라 **왼쪽 종이 면**에 둔다. 작은 글자는
- * 3:1 로 부족하고(4.5:1 이 기준이다), 무엇보다 인용되는 문장을 사진에 태우면 안 된다.
- */
-export function Hero() {
- const payload = useSite();
- const {place, narrative} = payload;
- const image = primaryImage(payload);
- const spec = unitSpec(payload);
- const units = unitViews(payload);
- const facts = heroFacts(payload);
- const price = lowestPrice(payload);
- const booking = bookingLinks(payload)[0];
- const contact = contactLinks(payload)[0];
- const tagline = narrative.tagline ?? narrative.heroSubline;
- // 검색창에 치는 이름을 먼저 쓴다 — "제주시" 보다 "애월" 이다.
- const locality = place.addressSubLocality ?? place.addressLocality ?? place.addressRegion;
-
- // 메타 한 줄 — 지역 · 객실 수 · 최저가. 확인된 값만 · 로 잇는다.
- const meta = [
- locality,
- units.length > 0 ? `${spec.label} ${units.length}개` : undefined,
- price,
- ].filter((part): part is string => Boolean(part));
-
- return (
-
-
- {image && (
-
-
-
- )}
-
- {/*
- 상호 띠 — 데스크톱에서는 격자 1~12칸 같은 행에 놓고 아래에 붙인다(self-end).
- 사진이 5~12칸이므로 왼쪽 4칸은 종이 위, 나머지는 사진 위를 지난다.
- ★ z-10: 격자에서 뒤에 오는 형제(왼쪽 칸)가 이 띠를 덮지 않게 한다.
- */}
-
- {/* h1 은 페이지에 하나. 가게 이름이 들어가야 "○○ 홈페이지" 질의에 잡힌다. */}
-
- {place.name}
-
-
-
- {/* 왼쪽 종이 면 — 읽는 값이 전부 여기 있다. 아래는 상호 띠가 지나가는 자리다. */}
- {/* pb-56 은 상호 띠 높이(제목 5.5rem + 여백)에서 나온 값이다 — 줄이면 글자가 겹친다. */}
-
- {tagline && (
-
- {tagline}
-
- )}
-
- {meta.length > 0 && (
-
- {meta.join(' · ')}
-
- )}
-
- {/* 버튼을 세우지 않는다 — 잡지의 규칙은 밑줄 링크다. 대신 .tap 으로 높이를 지킨다. */}
- {(booking || place.phone || contact) && (
-
- )}
-
- {/* 확인된 값만. 없는 업종에서는 칸 자체가 안 생긴다. */}
- {facts.length > 0 && (
-
- {facts.map((row) => (
-
-
{row.label}
-
{row.value}
-
- ))}
-
- )}
-
-
-
- );
-}
diff --git a/solution/site/src/layouts/editorial/Rooms.tsx b/solution/site/src/layouts/editorial/Rooms.tsx
deleted file mode 100644
index e10bc16..0000000
--- a/solution/site/src/layouts/editorial/Rooms.tsx
+++ /dev/null
@@ -1,240 +0,0 @@
-import type {ChannelLink} from '@o2o/shared';
-import {useSite} from '@site/lib/site-context';
-import {
- bookingActionLabel,
- bookingLinks,
- sectionName,
- unitSpec,
- unitViews,
- type UnitView,
-} from '@site/lib/derive';
-import {GRAIN, SectionHead} from './SectionHead';
-
-/**
- * 편집(잡지)형 객실 — 한 객실이 한 판이다.
- *
- * ★ 카드로 감싸지 않는다. 카드 격자는 '고르는 화면'이고, 잡지의 판은 '보여 주는 화면'이다.
- * 판마다 배경을 밝은 면 ↔ 어두운 면으로 교차해 넘기는 리듬을 만든다.
- * ★ `lib/ui/Section` 을 쓰지 않고 을 직접 그린다 — 판마다 배경이 갈려야 하는데
- * Section 은 섹션 하나에 바탕 하나다.
- * ★ 사진은 두 장을 위아래로 어긋나게 걸되, 세 번째부터는 아래 접지(contact sheet)로 남긴다.
- * 장식을 위해 사진을 버리면 그 alt 문장이 HTML 에서 사라진다 — 인용될 자리가 준다.
- */
-export function Rooms() {
- const payload = useSite();
- const units = unitViews(payload);
- const spec = unitSpec(payload);
- const booking = bookingLinks(payload)[0];
-
- if (units.length === 0) return null;
-
- return (
-
-
- {/* 섹션 id 는 업종마다 다르다(rooms · menu · programs) — spec.path 가 그 id 다. */}
-
-
-
- {units.map((unit, order) => (
-
- ))}
-
- );
-}
-
-function UnitSpread({
- unit,
- no,
- dark,
- booking,
- phone,
-}: {
- unit: UnitView;
- no: string;
- dark: boolean;
- booking?: ChannelLink;
- phone?: string;
-}) {
- return (
-
- {/*
- ★ 가로 홈(gap-x-10)은 xl 부터다. 390px 에서 12칸 격자에 40px 홈을 주면
- 홈만 440px 이라 컨테이너(347px)를 넘고, 페이지 전체가 77px 가로로 밀린다(실측).
- 좁은 화면에서는 어차피 전부 col-span-12 라 홈이 할 일이 없다.
- */}
-
- {/*
- 왼쪽 절반 — 번호 · 이름 · 스펙. 읽는 값은 전부 종이 위에 있다.
- ★ pr-8 은 장식이 아니다. 오른쪽 사진이 격자의 홈을 통째로 먹으므로(-ml-10)
- 이 여백이 없으면 스펙 표의 값이 사진 모서리에 붙는다(실측: 간격 0px).
- */}
-
-
-
- {no}
-
-
- {unit.name}
-
-
-
- {unit.intro && (
-
- {unit.intro}
-
- )}
-
- {/*
- ★ 요금은 한 값이 아니라 비교하는 값이다. 주중 하나만 적어 두면 손님은
- 주말이 얼마인지 모른 채 전화를 걸거나 떠난다. 안 채워진 칸은 '문의' 로 온다.
- */}
-
- {/* 요금은 객실 정보에서 내지 않는다 — 사유는 sections/UnitsSection.tsx 의 같은 자리 주석. */}
-
- {unit.rows.length > 0 && (
-
-
- {/*
- 오른쪽 절반 — 사진 두 장을 어긋나게.
- ★ -ml-10 이 격자의 홈(gap-x-10)을 그대로 먹는다. 글이 있는 칸까지 넘기지는 않는다 —
- 겹침이 읽기를 방해하면 그건 장식이 아니라 고장이다.
- ★ 음수 여백은 xl 부터다. 390px 에서는 두 장이 그냥 위아래로 선다.
- */}
-
- {/*
- ★ 폭을 64%/50% 로 잡은 건 취향이 아니라 균형이다. 82% 로 뒀더니 오른쪽 칸이
- 1120px 이 되어 왼쪽 글 아래로 600px 짜리 빈 판이 생겼다(실측) —
- 어두운 판에서는 그 빈자리가 통째로 검은 구멍으로 보인다.
- */}
- {unit.images[0] && (
-
-
-
- )}
-
- {unit.images[1] && (
-
-
-
- )}
-
-
- {/*
- 나머지 사진 — 판 아래를 가로지르는 접지(contact sheet).
- ★ 작게라도 남겨야 alt 가 HTML 에 남는다. 장식 때문에 사진을 버리면
- 그 문장이 인용될 자리도 같이 사라진다.
- */}
- {unit.images.length > 2 && (
-
- {unit.images.slice(2).map((image) => (
-
-
-
- ))}
-
- )}
-
-
- );
-}
diff --git a/solution/site/src/layouts/editorial/SectionHead.tsx b/solution/site/src/layouts/editorial/SectionHead.tsx
deleted file mode 100644
index 09b7716..0000000
--- a/solution/site/src/layouts/editorial/SectionHead.tsx
+++ /dev/null
@@ -1,191 +0,0 @@
-import type {ReactNode} from 'react';
-import {useSite} from '@site/lib/site-context';
-import {enabledSections, unitSpec} from '@site/lib/derive';
-
-/**
- * 편집(잡지)형 — 섹션 제목 · 차례.
- *
- * ★ 이 파일은 `@/lib/ui` 에서 아무것도 가져오지 않는다. `lib/ui/Section` 이 이 파일을
- * 불러다 쓰기 때문에, 반대 방향이 생기면 순환 참조로 빌드가 조용히 깨진다.
- * ★ 차례(useSectionIndex)와 결 무늬(GRAIN)도 여기 둔다. Shell·Rooms 가 둘 다 쓰는데
- * 의존 없는 이 파일이 잎사귀라 어느 쪽에서 가져가도 고리가 생기지 않는다.
- */
-
-/**
- * 종이 결.
- *
- * ★ 화면 전체를 덮는 오버레이로 두지 않는다 — 사진까지 누렇게 뜬다(레트로 안에서 밟은 함정).
- * `background-image` 는 그 요소의 배경색 위·내용 아래에 깔리므로, 종이 면에만 앉고
- * 그 위에 놓인 는 손대지 않는다.
- * ★ 색을 박지 않고 currentColor 를 3% 섞는다. 밝은 면에서는 옅은 회색, 어두운 면에서는
- * 옅은 밝은 선이 되어 어느 팔레트에서도 결이 남는다.
- */
-export const GRAIN =
- 'repeating-linear-gradient(90deg, color-mix(in oklab, currentColor 3%, transparent) 0 1px, transparent 1px 6px),' +
- 'repeating-linear-gradient(0deg, color-mix(in oklab, currentColor 2%, transparent) 0 1px, transparent 1px 6px)';
-
-export interface IndexEntry {
- /** 두 자리 번호. 차례와 섹션 제목이 같은 번호를 써야 목차가 목차 구실을 한다. */
- no: string;
- label: string;
- /** 실제 DOM 의 앵커 id. theme 의 섹션 id 와 다르다(rooms → units). */
- anchor: string;
-}
-
-/**
- * theme 섹션 id → 화면 앵커 id.
- *
- * ★ 두 이름이 다르다. 사장님 에디터의 섹션 id 는 'rooms'·'intro'·'photos' 인데,
- * 발행본이 실제로 그리는 는 'units'·'about'·'gallery' 다.
- * 여기 없는 id 는 렌더러에 컴포넌트가 없다는 뜻이라(HomePage 의 SECTION_COMPONENTS)
- * 차례에서도 조용히 빠진다 — 눌러도 아무 데도 안 가는 목차 줄이 최악이다.
- */
-const ANCHOR: Record = {
- intro: 'about',
- info: 'info',
- rooms: 'units',
- menu: 'units',
- programs: 'units',
- pricing: 'pricing',
- booking: 'booking',
- space: 'space',
- inquiry: 'inquiry',
- exhibition: 'exhibition',
- photos: 'gallery',
- local: 'guide',
- weather: 'weather',
- map: 'location',
- faq: 'faq',
- // 붙여넣기 아이템 — 섹션 id 가 곧 앵커다.
- songs: 'songs',
- daily: 'daily',
- chronicle: 'chronicle',
- reading: 'reading',
- people: 'people',
- quiz: 'quiz',
- postcard: 'postcard',
- video: 'video',
- itinerary: 'itinerary',
-};
-
-/** 사장님이 이름을 안 붙였을 때 차례에 적을 짧은 말. 제목이 아니라 목차 라벨이다. */
-const FALLBACK_LABEL: Record = {
- about: '소개',
- info: '이용 정보',
- pricing: '요금',
- booking: '예약 안내',
- space: '공간',
- inquiry: '문의',
- exhibition: '관람 안내',
- gallery: '사진',
- guide: '주변',
- weather: '날씨',
- location: '오시는 길',
- faq: '자주 묻는 질문',
- songs: '노래',
- daily: '일력',
- chronicle: '연표',
- reading: '읽기',
- people: '인물',
- quiz: '퀴즈',
- postcard: '엽서',
- video: '영상',
- itinerary: '일정',
-};
-
-function pad(n: number): string {
- return String(n).padStart(2, '0');
-}
-
-/**
- * 차례 — 켜져 있는 섹션을 순서대로.
- *
- * ★ 번호를 여기 한 곳에서만 매긴다. 왼쪽 차례와 섹션 제목의 번호가 어긋나면
- * 목차가 오히려 길을 잃게 만든다.
- * ★ 차례 라벨은 `theme.sections[].name` 을 그대로 쓴다. derive 의 sectionName() 이
- *
용으로는 이 값을 말리지만(짧은 목록 라벨이라서), 차례는 바로 그 목록이다.
- */
-export function useSectionIndex(): IndexEntry[] {
- const payload = useSite();
- const spec = unitSpec(payload);
- const seen = new Set();
- const entries: IndexEntry[] = [];
-
- for (const section of enabledSections(payload)) {
- const anchor = ANCHOR[section.id];
- if (!anchor || seen.has(anchor)) continue;
- // 객실이 없으면 그 판은 아예 안 그려진다(Rooms 가 null). 목차에만 남기지 않는다.
- if (anchor === 'units' && payload.units.length === 0) continue;
- seen.add(anchor);
- const name = section.name?.trim();
- entries.push({
- no: pad(entries.length + 1),
- label: name || FALLBACK_LABEL[anchor] || spec.label,
- anchor,
- });
- }
-
- // 오시는 길은 섹션 설정과 무관하게 항상 나간다(HomePage). 차례도 그걸 따라간다.
- if (!seen.has('location')) {
- entries.push({no: pad(entries.length + 1), label: '오시는 길', anchor: 'location'});
- }
-
- return entries;
-}
-
-/**
- * 섹션 제목 — 큰 두 자리 번호를 옅게 깔고 제목을 그 위에 겹친다.
- *
- * ★ 제목 위 굵은 실선(2px)이 판을 가른다. 색 띠 대신 선으로 가르는 건 인쇄물의 규칙이고,
- * 어떤 팔레트에서도 같은 세기로 보인다(currentColor).
- * ★ 번호는 aria-hidden 이다. 낭독기가 "영일 객실" 로 읽으면 제목이 아니라 잡음이 된다.
- */
-export function SectionHead({
- id,
- title,
- lead,
- aside,
-}: {
- id: string;
- title: string;
- lead?: string;
- aside?: ReactNode;
-}) {
- const no = useSectionIndex().find((entry) => entry.anchor === id)?.no;
-
- return (
-
-
-
-
-
- {no && (
-
- {no}
-
- )}
-
-
- {title}
-
-
- {lead && (
-
- {lead}
-
- )}
-
-
- {aside &&
{aside}
}
-
-
- );
-}
diff --git a/solution/site/src/layouts/editorial/Shell.tsx b/solution/site/src/layouts/editorial/Shell.tsx
deleted file mode 100644
index ab3ce9b..0000000
--- a/solution/site/src/layouts/editorial/Shell.tsx
+++ /dev/null
@@ -1,234 +0,0 @@
-import {useEffect, useState, type ReactNode} from 'react';
-import {useSite} from '@site/lib/site-context';
-import {bookingActionLabel, bookingLinks, channelLabel, primaryChannelLink} from '@site/lib/derive';
-import {formatKoreanDate, isoDate} from '@site/lib/format';
-// 배럴(@/sections)이 아니라 파일을 직접 가리킨다 — 배럴을 타면 섹션 전부가 딸려 온다.
-import {MobileTabBar} from '@site/sections/MobileTabBar';
-import {GRAIN, useSectionIndex, type IndexEntry} from './SectionHead';
-
-/**
- * 편집(잡지)형 껍데기.
- *
- * ★ 왼쪽 세로 차례가 이 안의 얼굴이다. 잡지의 등(spine)처럼 화면 왼쪽에 붙어 따라오고,
- * 스크롤에 따라 지금 읽는 항목만 진해진다. **데스크톱에서만** 세운다 — 좁은 화면에서
- * 목차는 자리만 먹고, 그 일은 이미 상단 제호 줄과 하단 고정 바가 한다.
- * ★ 차례를 fixed 로 띄우지 않고 flex 한 칸으로 세웠다. fixed 로 얹으면 어두운 판이
- * 밑으로 지나갈 때 목차 글자가 통째로 사라진다 — 칸으로 두면 항상 제 종이 위에 있다.
- * ★ 제호 줄에 전화·예약을 둔다. 국내 숙박 사이트에서 상단이 답해야 하는 건 그 둘뿐이다.
- */
-export function Shell({children}: {children: ReactNode}) {
- const payload = useSite();
- const {place, site} = payload;
- const index = useSectionIndex();
- const active = useActiveSection(index);
- const booking = bookingLinks(payload)[0];
- // ★ 확정 채널을 전부 늘어놓지 않는다 — 예약 우선으로 딱 하나만 낸다(seo/jsonld.ts 주석 참고).
- const link = primaryChannelLink(payload);
- const address = place.roadAddress ?? place.address;
-
- return (
-
- {/* 제호(masthead) — 얇은 한 줄, 아래는 굵은 실선. 잡지의 표제부다. */}
-
-
-
- {/* 판권장(colophon) — 잡지 맨 뒷장. 상호·주소·연락처·법정 표기가 한자리에 남는다. */}
-
-
-
-
- );
-}
-
-/**
- * 지금 읽고 있는 섹션.
- *
- * ★ 화면 한가운데 얇은 띠(위 45% · 아래 50% 를 잘라낸 나머지)를 지나는 판만
- * '읽는 자리'로 본다. 띠를 안 좁히면 긴 섹션 둘이 동시에 걸려 목차가 깜빡인다.
- * ★ 서버 렌더에서는 아무것도 진해지지 않는다(useEffect 는 브라우저에서만 돈다) —
- * 초기 상태가 양쪽 다 같아서 하이드레이션이 어긋나지 않는다.
- */
-function useActiveSection(entries: IndexEntry[]): string {
- // 배열은 렌더마다 새로 만들어진다 — 문자열로 굳혀야 effect 가 매 렌더 다시 돌지 않는다.
- const anchors = entries.map((entry) => entry.anchor).join(',');
- const [active, setActive] = useState('');
-
- useEffect(() => {
- if (typeof IntersectionObserver === 'undefined') return;
- const targets = anchors
- .split(',')
- .map((id) => document.getElementById(id))
- .filter((el): el is HTMLElement => el !== null);
- if (targets.length === 0) return;
-
- const observer = new IntersectionObserver(
- (records) => {
- const hit = records.find((record) => record.isIntersecting);
- if (hit) setActive(hit.target.id);
- },
- {rootMargin: '-45% 0px -50% 0px'},
- );
- targets.forEach((el) => observer.observe(el));
- return () => observer.disconnect();
- }, [anchors]);
-
- return active;
-}
diff --git a/solution/site/src/layouts/editorial/index.ts b/solution/site/src/layouts/editorial/index.ts
deleted file mode 100644
index 5d79f4a..0000000
--- a/solution/site/src/layouts/editorial/index.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-/**
- * 6안 — 편집(잡지)형.
- *
- * 1안(레트로)의 정체성은 '화면을 종이처럼 다루는 밀도'다. 그 밀도를 현대 잡지로 옮겼다:
- * 왼쪽에 붙는 세로 차례 · 사진 경계를 넘어 흐르는 상호 · 큰 번호를 깐 섹션 제목 ·
- * 판마다 뒤집히는 밝은 면/어두운 면. 모서리 0, 그림자 없음, 굵은 고딕 제목 + 명조 본문.
- *
- * ★ 소비처가 파일을 직접 가리키므로(App·lib/ui/Section·HeroSection·UnitsSection)
- * 이 배럴은 폴더 밖에서 한 벌로 가져다 쓸 때를 위한 것이다.
- */
-export {Shell} from './Shell';
-export {SectionHead} from './SectionHead';
-export {Hero} from './Hero';
-export {Rooms} from './Rooms';
diff --git a/solution/site/src/layouts/index.ts b/solution/site/src/layouts/index.ts
index c16131a..a20e4b9 100644
--- a/solution/site/src/layouts/index.ts
+++ b/solution/site/src/layouts/index.ts
@@ -1,17 +1,17 @@
-/**
- * 레이아웃 = 템플릿의 뼈대. 안 하나가 폴더 하나다.
- *
- * ★ 안마다 폴더를 통째로 가른 이유
- * 상단·섹션 제목·첫 화면·객실·하단을 한 벌로 두면 어떤 payload 를 줘도 같은 사이트가
- * 나온다(그래서 색만 다른 안이 나왔었다). 안은 **뼈대가 갈려야** 안이다.
- * 폴더가 갈려 있으면 한 안을 고쳐도 다른 안이 안 흔들린다.
- *
- * 각 폴더가 지켜야 하는 계약:
- * Shell({children}) 머리·꼬리까지 포함한 페이지 껍데기
- * SectionHead(...) 섹션 제목 블록
- * Hero() 첫 화면
- * Rooms() 객실 안내
- */
-export * as reservation from './reservation';
-export * as oasi from './oasi';
-export * as studio from './studio';
+import type {LayoutId} from '@o2o/shared';
+import type {Layout} from '@site/lib/layout';
+import {Frame as BasicFrame} from './basic/Frame';
+import {Frame as PaperFrame} from './paper/Frame';
+import {Hero as PaperHero} from './paper/Hero';
+import {Rooms as PaperRooms} from './paper/Rooms';
+import {SectionHead as PaperSectionHead} from './paper/SectionHead';
+
+// 등록 안 한 섹션은 공용 컴포넌트를 쓴다.
+export const LAYOUTS: Record = {
+ basic: {Frame: BasicFrame},
+ paper: {
+ Frame: PaperFrame,
+ SectionHead: PaperSectionHead,
+ sections: {hero: PaperHero, rooms: PaperRooms, menu: PaperRooms, programs: PaperRooms},
+ },
+};
diff --git a/solution/site/src/layouts/oasi/Hero.tsx b/solution/site/src/layouts/oasi/Hero.tsx
deleted file mode 100644
index 1d172a1..0000000
--- a/solution/site/src/layouts/oasi/Hero.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-import {useSite} from '@site/lib/site-context';
-import {primaryImage} from '@site/seo/meta';
-import {bookingActionLabel, bookingLinks, essentialRows, heroFacts, lowestPrice} from '@site/lib/derive';
-
-/**
- * oasi 첫 화면 — 사진 한 장과 아주 작은 글씨.
- *
- * ★ 사진 위에 글자를 얹지 않는다. 이 안은 폭을 제한한 사진을 가운데 한 장 놓고
- * **바깥 여백에 세로쓰기 캡션**을 세우는 편집형이다 — 사진을 화면 끝까지 늘리면
- * 그 여백이 사라져 안 자체가 성립하지 않는다.
- * ★ 상호는 `
` 이지만 크게 쓰지 않는다. 크기는 왼쪽 워드마크가 이미 맡았고,
- * 여기서 큰 활자를 한 번 더 쓰면 화면에 큰 글자가 둘이 된다.
- * ★ 세로쓰기 캡션은 좁은 화면에서 감춘다 — 390px 에서는 사진 밖 여백이 없어
- * 그대로 두면 가로 스크롤이 생긴다.
- * ★ 사진 틀의 **높이를 고정한다**. 자연 비율로 두면 소스가 세로 사진일 때 첫 화면을
- * 사진 하나가 통째로 먹는다 — 실측(stay3, 1000×1333): 544×725px.
- * 사장님이 어떤 사진을 올리든 첫 화면 높이가 같아야 한다.
- */
-export function Hero() {
- const payload = useSite();
- const {place, narrative} = payload;
- const image = primaryImage(payload);
- const price = lowestPrice(payload);
- const links = bookingLinks(payload);
- /* 첫 화면 우선순위 key(체크인·영업시간…)가 하나도 없는 업종이 있다 —
- 그때 띠를 비우지 않고 확인된 이용 정보 앞 넷으로 채운다. */
- const priority = heroFacts(payload);
- const facts = priority.length > 0 ? priority : essentialRows(payload).slice(0, 4);
- const caption = image?.caption ?? place.name;
-
- return (
- /* data-oasi: 껍데기의 판 지우개(Shell PLATE_CSS)를 건너뛴다 — 여기는 이미 투명하고
- 위 여백도 스스로 정한다(첫 섹션이라 다른 섹션보다 좁다). */
-
-
- {place.name}
-
-
- {narrative.heroHeadline && (
-
- {narrative.heroHeadline}
-
- )}
-
- {image && (
-
-
- {/* 캡션 높이를 액자에 묶고(max-h-full) 한 줄로 묶는다(nowrap).
- 22rem 로 박아 두면 액자보다 캡션이 길어져 사진 아래로 흘러내리고,
- 긴 캡션은 세로쓰기가 여러 단으로 갈려 액자 옆이 글자 벽이 된다. */}
-
- {caption}
-
-
- )}
-
- {(price || facts.length > 0) && (
-
- {price && (
-
-
최저가
-
{price}
-
- )}
- {facts.map((row) => (
-
-
{row.label}
-
{row.value}
-
- ))}
-
- )}
-
- {/* 확정된 예약 채널만. 버튼 상자를 만들지 않는다 — 이 안에는 면이 없다. */}
- {links.length > 0 && (
-
- )}
-
- );
-}
diff --git a/solution/site/src/layouts/oasi/Rooms.tsx b/solution/site/src/layouts/oasi/Rooms.tsx
deleted file mode 100644
index e809037..0000000
--- a/solution/site/src/layouts/oasi/Rooms.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-import {useSite} from '@site/lib/site-context';
-import {bookingActionLabel, bookingLinks, sectionName, unitSpec, unitViews} from '@site/lib/derive';
-import {SectionHead} from './SectionHead';
-
-/**
- * oasi 객실 안내 — 카드 격자가 아니라 **사진 한 장씩 내려가는 지면**이다.
- *
- * ★ `@/lib/ui/Section` 을 쓰지 않고 `` 을 직접 그린다.
- * 그쪽은 섹션마다 배경(`--tpl-surface`)과 아래 실선을 칠하는데, 이 안은 배경이
- * 한 톤이고 면이 갈리지 않는 게 정체성이다 — 그걸 쓰면 색 띠가 생긴다.
- * ★ 값은 상자에 넣지 않는다. 요금·스펙은 가는 실선 하나로만 구분한다.
- * ★ 사진은 한 장도 빼지 않는다. 대표 한 장은 세로 캡션과 함께 크게, 나머지는
- * 아래 작은 격자로 — 감추면 HTML 에서 사라져 검색·AI 가 못 본다.
- * ★ 사진 틀은 높이를 고정한다. 자연 비율로 두면 객실마다 사진 높이가 제각각이라
- * 지면이 아니라 무너진 격자가 된다 — 실측(stay3): 대표 사진 544×725 ↔ 544×306,
- * 아래 2열 격자 268×178 · 268×335 · 268×268.
- */
-export function Rooms() {
- const payload = useSite();
- const spec = unitSpec(payload);
- const units = unitViews(payload);
- const booking = bookingLinks(payload)[0];
-
- if (units.length === 0) return null;
-
- return (
- /* data-oasi + pt-[var(--oasi-gap)]: 껍데기의 판 지우개를 건너뛰되(이미 투명하다)
- 섹션 사이 여백은 공용 섹션과 같은 값을 쓴다 — 여기만 다르면 리듬이 끊긴다. */
-
-
-
-
- {units.map((unit) => (
-
- {unit.images[0] && (
-
-
- {/* 세로 캡션은 한 줄로 묶는다(nowrap). 객실 이름이 길면
- ("A동 (일본식 가정집 느낌으로 …")) 세로쓰기가 오른쪽으로 여러 단을
- 만들어 액자 옆이 글자 벽이 된다. 잘려도 바로 아래
에 전문이 있다. */}
-
- {unit.name}
-
-
- )}
-
-
- {unit.name}
-
-
- {/* 스펙은 칩 상자 대신 가운뎃점으로 잇는다 — 이 안에는 테두리 상자가 없다. */}
- {unit.chips.length > 0 && (
-
- )}
-
- {/* 요금은 한 값이 아니라 비교하는 값이다 — 주중만 적으면 손님이 주말을 모른 채 떠난다. */}
- {/* 요금은 객실 정보에서 내지 않는다 — 사유는 sections/UnitsSection.tsx 의 같은 자리 주석. */}
-
- {unit.rows.length > 0 && (
-
-
- );
-}
diff --git a/solution/site/src/layouts/oasi/SectionHead.tsx b/solution/site/src/layouts/oasi/SectionHead.tsx
deleted file mode 100644
index c6fdd15..0000000
--- a/solution/site/src/layouts/oasi/SectionHead.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import type {ReactNode} from 'react';
-
-/**
- * oasi 섹션 제목 — **큰 제목이 없다.**
- *
- * 대괄호로 감싼 작은 라벨 한 줄이 제목이고, 설명은 그 아래 더 작은 글씨로 온다.
- * ★ `.h2` 를 쓰지 않는다. 그 클래스는 템플릿 제목 굵기·자간을 그대로 받아서
- * 이 안에서만 제목이 굵고 크게 튄다 — 이 디자인의 정체성이 그 반대다.
- * ★ `@/lib/ui` 에서 아무것도 들여오지 않는다. `lib/ui/Section` 이 이 파일을 부르므로
- * 반대 방향 import 가 생기면 순환 참조다.
- * ★ `
` 은 반드시 그린다 — 바깥 section 의 aria-labelledby 가 이걸 가리킨다.
- */
-export function SectionHead({
- id,
- title,
- lead,
- aside,
-}: {
- id: string;
- title: string;
- lead?: string;
- aside?: ReactNode;
-}) {
- return (
-
-
-
- [{title}]
-
- {lead && (
-
{lead}
- )}
-
- {aside &&
{aside}
}
-
- );
-}
diff --git a/solution/site/src/layouts/oasi/Shell.tsx b/solution/site/src/layouts/oasi/Shell.tsx
deleted file mode 100644
index c88e256..0000000
--- a/solution/site/src/layouts/oasi/Shell.tsx
+++ /dev/null
@@ -1,400 +0,0 @@
-import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
-import type {CSSProperties, ReactNode} from 'react';
-import {isoDate} from '@site/lib/format';
-import {useSite} from '@site/lib/site-context';
-import {
- bookingActionLabel,
- bookingLabel,
- bookingLinks,
- contactLinks,
- heroFacts,
- isSectionEnabled,
- unitSpec,
-} from '@site/lib/derive';
-// ★ 배럴(`@/sections`)을 거치면 껍데기 하나가 사이트의 모든 섹션을 끌고 들어온다.
-// 껍데기는 하단 고정 바 하나만 필요하다 — 파일을 직접 가리킨다.
-import {MobileTabBar} from '@site/sections/MobileTabBar';
-
-/**
- * 본문 섹션의 색 띠를 걷어낸다 (2026-09-04, 사장님 지적).
- *
- * ★ 실측: 히어로는 투명(바탕 #e5ddd2)인데 소개가 rgb(219,211,200), 예약 전 확인이
- * rgb(222,213,200), 객실이 다시 투명, 요금·이용정보가 rgb(222,213,200) 이었다.
- * 밝기가 오르락내리락하는 띠가 여덟 번 반복된다. 이 안에는 **면이 없다** —
- * Hero 주석의 "버튼 상자를 만들지 않는다"와 같은 규칙이 섹션에도 걸려야 한다.
- * ★ !important 인 이유: 공용 Section 이 배경색과 세로 여백을 **인라인 style** 로 박는다
- * (lib/ui/Section.tsx:82). 인라인은 선택자 특정도로 못 이기므로 이 자리에서만 강제한다.
- * 제 폴더가 직접 그린 판(Hero·Rooms)은 `data-oasi` 로 표시해 건너뛴다 — 이미 투명이고
- * 위 여백을 스스로 정한다.
- * ★ 글자색도 같이 강제한다. tone='dark' 섹션이 인라인으로 밝은 글자를 넣는데,
- * 면이 사라지면 그 글자가 갱지 위에 하얗게 떠서 안 읽힌다.
- * ★ 아래 실선(border-b)도 지운다. 색 띠를 지우고 선만 남기면 이번엔 줄만 여덟 개 그어진다.
- * ★ `.shell` 의 좌우 여백을 0 으로 돌린다. 껍데기가 이미 본문 폭을 잡아 뒀는데
- * 섹션 안에서 한 번 더 들어가서, 실측 1440px 에서 공용 섹션 제목만 40px 안쪽에
- * 있었다(히어로·객실 569px ↔ 소개·요금 609px).
- *
- * ★ 뒤의 두 규칙은 **본문 칸이 716px 로 고정**이라 생기는 어긋남을 되돌린다.
- * 공용 섹션은 1200px 지면을 전제로 `lg:`·`xl:` 에서 칸을 나누는데, 그 중단점은
- * 화면 폭이지 이 칸의 폭이 아니다. 이 껍데기는 화면이 아무리 넓어도 칸이 안 넓어진다.
- * - #about: 사진 7 · 글 5 로 갈리면 글 칸이 275px 다 — 한 줄에 열네 자.
- * 게다가 세로 가운데 정렬이라 짧은 사진이 허공에 뜨고 위아래로 200px 씩 빈다.
- * → 위아래로 쌓아 사진도 글도 칸을 다 쓰게 한다(글은 .measure 가 줄 길이를 잡는다).
- * - #summary: 값 표가 xl 에서 두 칸으로 갈려 한 칸이 179px 이 됐다.
- * 환불 규정 한 문장이 여덟 줄로 감긴다 → 한 칸으로 되돌린다.
- *
- * ─────────────────────────────────────────────────────────────────────────────
- * 남아 있던 마지막 면 — 공용 `.panel` (2026-09-04, 사장님 "다 고쳐")
- *
- * ★ 섹션 띠를 지우고 나니 `.panel`(index.css:197, 글자색 5% 틴트 + 실선)만 남아
- * 이 안에서 유일하게 떠 있는 상자가 됐다. 실측 64개 — FAQ 32 · 주변 24 · 축제 4 ·
- * 이용정보 2 · 오시는 길 1 · 날씨 1.
- * ★ 그냥 지우면 FAQ 서른둘이 구분 없는 글 덩어리가 된다. 그래서 **면을 걷고 구분을
- * 다시 세운다** — 인쇄물이 상자 없이 목록을 가르는 두 가지, 가는 괘선과 들여쓰기다.
- * ★ !important 를 쓰지 않는다. 이