[feat] solution: 템플릿 정의를 templates.json 한 파일로 — 업종 없는 id · 레이아웃 basic/paper 정리

템플릿 정보가 빌더·렌더러·백엔드에 따로 적혀 어긋나 있었다 — 백엔드 기본값이 없는 id 를
가리켰고, 음식점 강조색 오타, 섹션 간격이 빌더와 서버에서 달랐다.

- shared/src/data/templates.json: 템플릿 4개(simple·magazine·retro·paper)와 업종별 허용·기본
- shared/lib/catalog.ts · backend/common/template_catalog.py: 같은 JSON 을 읽는다, 모르는 id 는 에러
- 저장·미리보기·발행에서 모르는 id 를 거절한다(site_service · site.py 422 · build_service 실패)
- site: 레이아웃 등록표(basic·paper) + LayoutProvider, Shell→Frame, HomePage→SectionList
- 연결 안 된 레이아웃 5개, 배치 고르기(variant), 서체 선택, 빌더 canvas/DevShowcase 삭제
- 빌더: 템플릿을 바꾸면 이전 템플릿이 켠 섹션을 끄고 안내 문구를 띄운다
- postgres-init/migrations/0023: stay-retro → retro, 병원 허용 밖은 NULL(운영 미적용)
- Dockerfile·worker: 백엔드 이미지에 shared/src/data 복사
- docs: TEMPLATES.md 신설(세 폴더 역할·템플릿 추가·렌더링 순서), DATA_MODEL·ARCHITECTURE 등 갱신

shared·site·frontend tsc 통과, site vitest 통과, 백엔드 DB 없는 테스트 41 passed(DB 테스트 미실행)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-09-28 15:57:35 +09:00
parent 46b74b310b
commit c6908629f3
183 changed files with 1824 additions and 15243 deletions

View File

@ -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) |
---

View File

@ -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 이라
발행 사이트에 그대로 쓰면 크롤러가 `<div id="root"></div>` 만 읽고 떠난다.

View File

@ -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)이다 — 헤드리스 브라우저는 영구 금지 |

View File

@ -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개였다 —

View File

@ -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)를 새 메뉴로 키우려면 새 도메인이 필요하고 아직 그럴 기능도 안

View File

@ -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에 올린 글 보기**다.
## 연동 준비 — 한 번만 하는 일

177
docs/TEMPLATES.md Normal file
View File

@ -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/<slug>.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/<slug>/<버전>/ 에 쓴다
▼
결과를 확인하고 공개 주소를 새 버전으로 바꾼다
│ out/s/<slug> 링크를 새 버전 폴더로 갈아 끼운다 (PUBLISH_VERSION.md)
│ DB에 버전과 발행 기록을 남긴다
▼
손님이 /s/<slug> 에 들어온다
│ nginx가 구워 둔 HTML을 그대로 준다. 검색엔진은 여기까지만 읽는다
▼
브라우저가 JS를 받아 화면을 이어받는다 site entry-client.tsx hydrateRoot
심어 둔 payload로 같은 화면을 다시 만들어서, 버튼·달력 같은 동작을 붙인다
```
두 흐름의 차이는 하나다. 미리보기는 브라우저가 처음부터 그리고, 발행은 서버에서 미리 그려 둔
HTML에 브라우저가 동작만 붙인다. 그리는 코드(`App.tsx`)는 같다.

View File

@ -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%).

View File

@ -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)은 발행 때 템플릿 정의가 정한다.';

View File

@ -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

View File

@ -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/)에 있어야
# 워커가 컨테이너 안에서 렌더러를 그대로 실행할 수 있다.

View File

@ -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

View File

@ -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

View File

@ -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/<slug>`).
# 발행 주소.
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

View File

@ -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),
):
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)

View File

@ -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/<slug>/<version>/` 에만 굽고 공개
# 주소(`out/s/<slug>`)는 건드리지 않는다 — 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(

View File

@ -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`)
# 이 키가 없으면 `<head>` 에 --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://<host>/s/<slug>`)이다. 서브도메인을 쓰지 않는 이유는
사이트가 하나 늘 때마다 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/<slug>.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/<slug>/<version>/` 에만 굽고 `out/s/<slug>` 심볼릭 링크는 그대로 둔다."""
"""스냅샷 + 사이트/버전 행 + 채널 링크 → 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/<slug>)을 아는 곳이 여기다.
# 이 숙소의 노래.
"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/<slug>`(또는 서브패스 마운트라면 그 앞에 접두어)다.
# 프리렌더가 복사해 놓을 자리.
"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 를 `<slug>.json` 으로 쓴다. 경로를 돌려준다.
★ 임시파일에 쓰고 rename 한다 — 렌더러가 디렉토리를 통째로 읽는 구조라
반쯤 쓰인 JSON 을 집어 빌드가 깨지는 일이 없어야 한다(rename 은 같은 파일시스템에서 원자적)."""
"""payload 를 `<slug>.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)

View File

@ -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:

View File

@ -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"),

View File

@ -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"]

View File

@ -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, [])

View File

@ -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<string, string>;
/**
* 이 업종의 **기본 템플릿**으로 세운다 — 목록 맨 앞에 온다.
*
* ★ 순서가 곧 기본값이다. 에디터는 저장된 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<IndustryType, IndustryData> = {
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';

View File

@ -73,8 +73,6 @@ export function CanvasView() {
<div className="relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-muted">
{!isPreviewMode && (
<div className="z-10 flex h-10 min-w-0 select-none items-center justify-between gap-2 border-b border-border bg-card px-4 text-xs">
{/* ★ min-w-0 — 이 줄이 안 줄어들면 캔버스 칸 전체의 최소 너비가 올라가고,
그 여파로 오른쪽 패널이 화면 밖으로 밀린다(EditorLayout 주석 참조). */}
<div className="flex min-w-0 items-center gap-1.5">
<span className="hidden shrink-0 font-semibold md:inline">미리보기 해상도:</span>
<div className="flex shrink-0 items-center gap-0.5 rounded border border-border bg-muted p-0.5">
@ -106,9 +104,6 @@ export function CanvasView() {
)}
<div className="flex flex-1 items-start justify-center overflow-y-auto bg-muted/70 p-2 sm:p-6">
{/* ★ 이 껍데기는 토큰도 폭도 정하지 않는다(2026-09-09).
안쪽 iframe 이 **자기 뷰포트**를 만들고 색·서체는 payload 가 준다.
바깥이 한 번 더 얹으면 두 겹이 돼 바깥 값이 안쪽을 덧칠한다 — 실제로 그랬다. */}
<div className="my-auto w-full min-w-0 transition-all duration-300">
{viewport !== 'pc' && (
<div className="flex items-center justify-between bg-foreground px-4 py-2 font-mono text-[11px] text-background">
@ -123,13 +118,6 @@ export function CanvasView() {
<span className="text-[10px] opacity-70">{viewport.toUpperCase()}</span>
</div>
)}
{/* ★ 편집 모드도 **발행본 렌더러**가 그린다(2026-09-09).
캔버스 전용 컴포넌트를 따로 두는 동안 두 화면이 아예 다른 트리였다 — 실측:
발행본 15섹션 · 에디터 12섹션, 겹치는 건 4개뿐이고 이름도 달랐다
(gallery↔photos · location↔map · guide↔local). 사장님이 편집한 화면과
발행된 화면이 서로 다른 물건이었다.
고르는 일은 iframe 안 섹션을 눌러서 한다 — SitePreview 가 배선한다. */}
<SitePreview
placeId={placeId}
viewport={viewport}
@ -140,13 +128,6 @@ export function CanvasView() {
setRightTab('content');
}}
/>
{/* ★ 푸터도 모바일 하단 탭바도 여기서 그리지 않는다
(2026-09-15 대표: "에디터에서 이 부분 필요없음 / 이 부분 자체가 필요없음")
발행본이 iframe 안에서 **둘 다 이미 그린다** — 푸터는 `site/sections/SiteFooter.tsx`,
탭바는 `site/sections/MobileTabBar.tsx`(App 과 레이아웃 Shell 다섯이 모두 세운다).
바깥에 한 벌 더 그리면 상호·주소·저작권·탭이 두 번 서고, 두 벌의 값이 어긋날 수도
있다 — 이 캔버스가 "발행되는 그 화면"이어야 한다는 원칙(SitePreview 주석)에 어긋난다. */}
</div>
</div>
</div>

View File

@ -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<string, unknown>;

View File

@ -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 (
<div className="flex-1 space-y-4 overflow-y-auto p-3.5">
@ -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() {
기록되어, 이후 자동 수집이 덮어쓰지 않습니다.
</p>
{/* ★ 재수집을 [정보] 탭 아래에 둔다. 에디터에서 "이 값이 옛날 값인데"를 깨닫는 자리가
바로 여기라, 다시 가져오는 버튼도 같은 자리에 있어야 찾는다.
데모(placeId 없음)에서는 패널이 스스로 사라진다. */}
{/* 재수집을 [정보] 탭 아래에 둔다. */}
<RecollectPanel placeId={placeId} />
<PasteFactsPanel placeId={placeId} />
</div>

View File

@ -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 한 칸이다. 여기에 템플릿 미리보기 셋 + 팔레트 열두 칸 +
* 이 섹션의 배리에이션이 세로로 쌓이면 스크롤이 세 화면을 넘고, 정작 방금 고른 섹션의
* 배리에이션이 맨 아래로 밀린다. 큰 것(템플릿·색)은 접어 두고 필요할 때 편다.
* ★ `<details>` 다 — 상태를 리액트로 들면 탭을 오갈 때마다 접힘이 초기화된다.
*/
// <details>라 탭을 오가도 접힘 상태가 남는다.
function Group({
title,
icon: Icon,
count,
open,
children,
}: {
title: string;
icon: typeof Palette;
count?: string;
open?: boolean;
children: React.ReactNode;
}) {
return (
<details open={open} className="group border-b border-border pb-3">
<details open className="group border-b border-border pb-3">
<summary className="flex cursor-pointer list-none items-center gap-1.5 py-1 text-xs font-bold marker:content-none [&::-webkit-details-marker]:hidden">
<Icon className="size-3.5" />
<span>{title}</span>
@ -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 (
<Group title="템플릿" icon={LayoutTemplate} count={`${templates.length}종`} open={open}>
<Group title="템플릿" icon={LayoutTemplate} count={`${templates.length}종`}>
<p className="rounded-md bg-muted/60 px-2 py-1.5 text-[10px] leading-relaxed text-muted-foreground">
템플릿을 바꾸면 이전 템플릿이 켜 둔 섹션이 꺼질 수 있습니다. 내용은 지워지지 않고, 섹션 목록에서 다시 켤 수 있습니다.
</p>
<div className="space-y-2">
{templates.map((template) => {
const isActive = activeId === template.id;
const isActive = templateId === template.id;
return (
<button
key={template.id}
type="button"
onClick={() => {
selectTemplate(template.id);
// 고른 순간 서버에도 남긴다 — 저장 안 하면 새로고침 한 번에 되돌아간다.
queueSiteTemplateSave(placeId, template.id);
}}
aria-pressed={isActive}
@ -103,12 +64,9 @@ function TemplatePicker({open}: {open?: boolean}) {
>
<span className="flex items-center gap-1.5">
<span className="text-[11px] font-bold">{template.name}</span>
<span className="truncate text-[10px] text-muted-foreground">
{template.toneLabel} · {template.fontStyle}
</span>
<span className="truncate text-[10px] text-muted-foreground">{template.tag}</span>
{isActive && <Check className="ml-auto size-3 shrink-0 text-primary" />}
</span>
{/* 미리보기는 그 템플릿의 서체·모서리·그림자로 실제로 그린다 — 색 동그라미로는 뭘 고르는지 모른다. */}
<TemplatePreview template={template} />
</button>
);
@ -118,14 +76,14 @@ function TemplatePicker({open}: {open?: boolean}) {
);
}
function ColorPalettePicker({open}: {open?: boolean}) {
function ColorPalettePicker() {
const industry = useBuilderStore((s) => s.industry);
const selectedId = useBuilderStore((s) => s.colorPaletteId);
const selectColorPalette = useBuilderStore((s) => s.selectColorPalette);
const palettes = COLOR_PALETTE_PRESETS.filter((palette) => palette.industry === industry);
return (
<Group title="컬러 시스템" icon={Palette} count={`${palettes.length}종`} open={open}>
<Group title="컬러 시스템" icon={Palette} count={`${palettes.length}종`}>
<div className="grid grid-cols-2 gap-2">
{palettes.map((palette) => {
const isActive = selectedId === palette.id;
@ -167,114 +125,10 @@ function ColorPalettePicker({open}: {open?: boolean}) {
}
export function SectionDesignPanel() {
const industry = useBuilderStore((s) => s.industry);
const sections = useBuilderStore((s) => s.sections);
const selectedSectionId = useBuilderStore((s) => s.selectedSectionId);
const setSectionVariant = useBuilderStore((s) => s.setSectionVariant);
const resetSectionVariants = useBuilderStore((s) => s.resetSectionVariants);
const section = sections.find((s) => s.id === selectedSectionId);
const variants = section ? variantsFor(section.type, industry) : [];
const current = section ? resolveVariant(section, industry) : undefined;
if (!section) {
return (
<div className="flex-1 space-y-4 overflow-y-auto p-3.5">
<TemplatePicker open />
<ColorPalettePicker open />
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center">
<span className="flex size-9 items-center justify-center rounded-full bg-muted text-muted-foreground">
<MousePointerClick className="size-4" />
</span>
<p className="text-xs font-semibold">섹션을 먼저 고르세요</p>
<p className="text-[11px] leading-relaxed text-muted-foreground">
왼쪽 목록이나 미리보기에서 섹션을 누르면
<br />그 섹션의 레이아웃을 바꿀 수 있습니다.
</p>
</div>
</div>
);
}
return (
<div className="flex-1 space-y-3 overflow-y-auto p-3.5">
{/* 큰 것부터 좁혀 간다 — 템플릿(전체) → 팔레트(색) → 이 섹션의 레이아웃. */}
<TemplatePicker />
<ColorPalettePicker />
<div className="flex items-center justify-between border-b border-border pb-2">
<span className="flex min-w-0 items-center gap-1.5 text-xs font-bold">
<Shapes className="size-3.5 shrink-0" />
<span className="truncate">{section.name}</span>
</span>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground">
{variants.length}종
</span>
</div>
{variants.length === 0 ? (
<p className="rounded-md border border-dashed border-border bg-muted/40 p-3 text-center text-[11px] leading-relaxed text-muted-foreground">
이 섹션은 아직 고를 수 있는 레이아웃이 하나뿐입니다.
</p>
) : (
<ul className="space-y-2">
{variants.map((variant) => {
const isActive = current?.id === variant.id;
return (
<li key={variant.id}>
<button
type="button"
onClick={() => setSectionVariant(section.id, variant.id)}
aria-pressed={isActive}
className={cn(
'flex w-full cursor-pointer items-start gap-2.5 rounded-lg border p-2.5 text-left transition-all',
isActive
? 'border-primary bg-primary/8 ring-1 ring-primary'
: 'border-border bg-card hover:bg-muted/60',
)}
>
<VariantThumb
thumb={variant.thumb}
className="h-[38px] w-[54px] shrink-0 rounded border border-border bg-white"
/>
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex items-center gap-1.5">
<span className="truncate text-xs font-bold">{variant.name}</span>
{variant.isDefault && (
<span className="shrink-0 rounded bg-muted px-1 py-px text-[9px] font-medium text-muted-foreground">
기본
</span>
)}
{isActive && <Check className="ml-auto size-3.5 shrink-0 text-primary" />}
</div>
<p className="text-[11px] leading-relaxed text-muted-foreground">
{variant.description}
</p>
</div>
</button>
</li>
);
})}
</ul>
)}
<div className="space-y-2 border-t border-border pt-3">
<button
type="button"
onClick={resetSectionVariants}
className="flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-md border border-border py-2 text-[11px] font-semibold text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<RotateCcw className="size-3" />
<span>모든 섹션을 기본 레이아웃으로</span>
</button>
<p className="text-[11px] leading-relaxed text-muted-foreground">
레이아웃만 바뀝니다 —{' '}
<strong className="text-foreground">입력한 내용과 사진, 섹션 순서는 그대로</strong>{' '}
유지됩니다.
</p>
</div>
</div>
);
}

View File

@ -8,13 +8,12 @@ import {
import {CSS} from '@dnd-kit/utilities';
import {useState} from 'react';
import type {SectionItem} from '@o2o/shared';
import {Check, GripVertical, Layers, Lock, Plus, Shapes, Trash2, X} from 'lucide-react';
import {Check, GripVertical, Layers, Lock, Plus, Trash2, X} from 'lucide-react';
import {Switch} from '@/components/ui/switch';
import {cn} from '@/lib/utils';
import {useBuilderStore} from '@/stores/builder';
import {ADDABLE_SECTIONS, addableSection} from './canvas/addable';
import {resolveVariant} from './canvas/registry';
import {VariantThumb} from './canvas/thumbs';
import {ADDABLE_SECTIONS, addableSection} from './sections/addable';
import {VariantThumb} from './sections/thumbs';
interface SectionListPanelProps {
onAfterSelect?: () => void;
@ -26,13 +25,11 @@ interface SortableSectionProps extends SectionListPanelProps {
}
function SortableSection({section, index, onAfterSelect}: SortableSectionProps) {
const industry = useBuilderStore((s) => s.industry);
const setRightTab = useBuilderStore((s) => s.setRightTab);
const isSelected = useBuilderStore((s) => s.selectedSectionId === section.id);
const selectSection = useBuilderStore((s) => s.selectSection);
const toggleSection = useBuilderStore((s) => s.toggleSection);
const removeSection = useBuilderStore((s) => s.removeSection);
const variant = resolveVariant(section, industry);
// 업종 기본 섹션은 스위치로 끄는 것이지 빼는 게 아니다 — 뺄 수 있는 건 나중에 넣은 것뿐이다.
const canRemove = Boolean(addableSection(section.type));
const {attributes, listeners, setNodeRef, transform, transition, isDragging} = useSortable({
@ -45,8 +42,7 @@ function SortableSection({section, index, onAfterSelect}: SortableSectionProps)
onAfterSelect?.();
if (!section.isEnabled) return;
// 좁은 화면에서는 onAfterSelect가 캔버스 탭을 먼저 연다. 다음 페인트가 끝난 뒤
// 실제 섹션으로 이동해야 display:none 상태의 위치를 잘못 읽지 않는다.
// 좁은 화면에서는 onAfterSelect가 캔버스 탭을 먼저 연다.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
document
@ -102,32 +98,6 @@ function SortableSection({section, index, onAfterSelect}: SortableSectionProps)
{section.name}
</span>
{variant && (
<span
role="button"
tabIndex={0}
title={`${variant.name} — 눌러서 레이아웃 바꾸기`}
onClick={(event) => {
event.stopPropagation();
selectSection(section.id);
setRightTab('design');
onAfterSelect?.();
}}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
event.stopPropagation();
selectSection(section.id);
setRightTab('design');
onAfterSelect?.();
}}
className="flex cursor-pointer items-center gap-0.5 truncate text-[9px] font-normal text-muted-foreground hover:text-foreground"
>
<Shapes className="size-2.5 shrink-0" />
<span className="truncate">{variant.name}</span>
</span>
)}
{section.isLocked && (
<span className="flex items-center gap-0.5 text-[9px] font-normal text-muted-foreground">
<Lock className="size-2.5" /> 필수 고정

View File

@ -1,42 +1,21 @@
/**
* 미리보기 — 발행본을 **iframe 으로** 띄운다.
* 미리보기 — 발행본을 iframe으로 띄운다. iframe이 여는 주소는 `/preview?placeId=…`
* (site/scripts/prerender.writePreviewShell이 굽는 CSR 셸, 발행본 앱이 그 안에서 그대로 돈다).
* 같은 오리진이라 토큰(localStorage)을 iframe이 그대로 읽는다.
*
* ★ 왜 iframe 인가 (2026-09-09)
* 처음엔 발행본 컴포넌트를 빌더 안에 직접 그렸다. 렌더러도 payload 도 토큰도 같게 맞췄고
* 섹션 15개의 계산 스타일(색·서체·여백)까지 동일해졌는데, **레이아웃 폭이 어긋났다.**
* 미디어 쿼리는 창 폭을 보는데 미리보기의 실제 사이트 폭은 그 안의 프레임이기 때문이다.
* 실측(1400px 창 · 1024px 프레임, Playwright):
* festival 2560px → 6027px · guide 1168 → 2168 · location 586 → 1135
* 내용(글자 수)은 완전히 같은데 그리드 컬럼 수만 달라 두 배씩 길어졌다. 전체 픽셀 차이 89%.
* "같은 렌더러를 쓴다" 만으로는 안 되고 **뷰포트가 같아야** 한다.
*
* iframe 은 자체 뷰포트를 가진다 — 폭을 390/768/1024 로 주면 발행본이 그 폭에서 보는 것과
* 같은 미디어 쿼리가 걸린다. 해상도 전환도 그제서야 진짜가 된다.
*
* ★ iframe 이 여는 것은 `/preview?placeId=…` — 프리렌더가 굽는 CSR 셸이다
* (`site/scripts/prerender.writePreviewShell`). 그 안에서 발행본 앱이 그대로 돈다.
* ★ 같은 오리진이라 토큰(localStorage)을 iframe 이 그대로 읽는다. 따로 넘기지 않는다.
* iframe으로 띄우는 이유: 빌더 안에 직접 그렸을 때 렌더러·payload·토큰을 다 맞춰도
* 레이아웃 폭이 어긋났다 — 미디어쿼리는 창 폭을 보는데 실제 사이트 폭은 프레임 폭이라서다.
* iframe은 자체 뷰포트를 가지므로 폭을 390/768/1024로 주면 발행본과 같은 미디어쿼리가 걸린다.
*/
import {useCallback, useEffect, useRef, useState} from 'react';
import type {ViewportMode} from '@o2o/shared';
import {LoaderCircle} from 'lucide-react';
import {onSiteThemeSaved} from '@/features/publish/siteTheme';
/**
* 미리보기가 그려지기를 기다리는 상한. 넘으면 로딩 표시를 걷고 iframe 을 그대로 보여준다.
*
* ★ 영영 안 걷히는 로딩바는 안 띄우느니만 못하다 — 구 번들이 올라가 있어 `o2o:preview-painted`
* 를 안 보내는 동안에도 편집은 돼야 한다(발행본 번들은 재발행 전까지 옛것일 수 있다).
*/
// 넘으면 로딩 표시를 걷고 iframe을 그대로 보여준다(구 번들이라 painted 신호가 안 올 수 있음).
const PAINT_TIMEOUT_MS = 12000;
/**
* 각 해상도에서 iframe 에 줄 **크기**. 발행본이 그 기기에서 보는 것과 같아야 한다.
*
* ★ 높이도 준다. 폭만 맞추면 `vh` 를 쓰는 자리가 어긋난다 — 히어로가
* `clamp(24rem, 62vh, 36rem)` 이라(`HeroSection`), iframe 이 낮으면 하한 384px 에 걸린다.
* 실측: 발행본 576px ↔ 미리보기 384px. 지연 로딩 이미지도 화면에 덜 들어와 적게 뜬다.
*/
// 해상도별 iframe 크기. 높이도 줘야 vh 쓰는 자리(히어로 등)가 발행본과 맞는다.
const FRAME_SIZE: Record<ViewportMode, {w: number; h: number}> = {
pc: {w: 1024, h: 800},
tablet: {w: 768, h: 1024},

View File

@ -1,11 +1,6 @@
import type {TemplateItem} from '@o2o/shared';
/**
* 템플릿 미리보기 — 그 템플릿의 서체·모서리·테두리·그림자로 **실제로** 그린다.
*
* ★ 예전에는 회색 막대 세 줄과 색 동그라미였다. 다섯 템플릿이 전부 같은 그림이라
* 무엇을 고르는지 알 수 없었고, 그래서 아무거나 골랐다.
*/
/** 템플릿 미리보기 — 그 템플릿의 서체·모서리·테두리·그림자로 **실제로** 그린다. */
export function TemplatePreview({template}: {template: TemplateItem}) {
const {look, colors} = template;
@ -39,7 +34,7 @@ export function TemplatePreview({template}: {template: TemplateItem}) {
className="mt-1.5 text-[10px] leading-relaxed"
style={{fontFamily: look.fontBody, color: colors.secondary}}
>
제목은 {template.fontStyle}, 본문은 이 서체로 나갑니다.
제목과 본문이 이 서체로 나갑니다.
</p>
<div

View File

@ -1,145 +0,0 @@
import React from 'react';
export interface SlateTextNode {
text: string;
bold?: boolean;
italic?: boolean;
underline?: boolean;
code?: boolean;
}
export interface SlateElementNode {
type?: string;
speaker?: string;
role?: 'buyer' | 'seller' | 'host' | 'guest' | 'ai' | 'customer';
time?: string;
children: (SlateElementNode | SlateTextNode)[];
}
interface SlateScriptRendererProps {
nodes?: (SlateElementNode | SlateTextNode)[];
primaryColor?: string;
className?: string;
}
export const SlateScriptRenderer: React.FC<SlateScriptRendererProps> = ({
nodes,
primaryColor = '#18181b',
className = '',
}) => {
if (!nodes || nodes.length === 0) {
return null;
}
const renderTextNode = (node: SlateTextNode, idx: number) => {
let content: React.ReactNode = node.text;
if (node.bold) content = <strong key={idx}>{content}</strong>;
if (node.italic) content = <em key={idx}>{content}</em>;
if (node.underline) content = <u key={idx}>{content}</u>;
if (node.code) {
content = (
<code key={idx} className="bg-slate-100 px-1 py-0.5 rounded text-[11px] font-mono">
{content}
</code>
);
}
return <React.Fragment key={idx}>{content}</React.Fragment>;
};
const renderElement = (node: SlateElementNode, idx: number) => {
switch (node.type) {
case 'chat-dialog':
case 'dialog':
case 'negotiation': {
const isHost = node.role === 'seller' || node.role === 'host' || node.role === 'ai';
return (
<div
key={idx}
className={`flex items-start gap-3 my-2.5 ${isHost ? 'flex-row-reverse' : 'flex-row'}`}
>
{/* Avatar */}
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 shadow-2xs ${
isHost ? 'bg-slate-900 text-white' : 'bg-slate-200 text-slate-700'
}`}
>
{node.speaker ? node.speaker.slice(0, 1) : isHost ? '호' : '손'}
</div>
{/* Bubble */}
<div className={`max-w-[75%] space-y-1 ${isHost ? 'text-right' : 'text-left'}`}>
<div className="flex items-center gap-1.5 text-[11px] text-slate-400">
<span className="font-semibold text-slate-700">{node.speaker || (isHost ? '호스트' : '게스트')}</span>
{node.time && <span>· {node.time}</span>}
</div>
<div
className={`px-3.5 py-2.5 rounded-2xl text-xs leading-relaxed inline-block text-left ${
isHost
? 'bg-slate-900 text-white rounded-tr-xs shadow-2xs'
: 'bg-slate-100 text-slate-800 rounded-tl-xs'
}`}
>
{node.children.map((child, cIdx) =>
'text' in child ? renderTextNode(child, cIdx) : renderElement(child, cIdx)
)}
</div>
</div>
</div>
);
}
case 'heading-two':
case 'h2':
return (
<h3 key={idx} className="text-base font-bold text-slate-900 mt-4 mb-2">
{node.children.map((child, cIdx) =>
'text' in child ? renderTextNode(child, cIdx) : renderElement(child, cIdx)
)}
</h3>
);
case 'quote':
case 'blockquote':
return (
<blockquote
key={idx}
className="border-l-2 pl-3 my-3 text-xs italic text-slate-600 border-slate-400"
>
{node.children.map((child, cIdx) =>
'text' in child ? renderTextNode(child, cIdx) : renderElement(child, cIdx)
)}
</blockquote>
);
case 'callout':
case 'info-box':
return (
<div
key={idx}
className="p-3.5 my-3 bg-slate-50 border border-slate-200 rounded-xl text-xs text-slate-700 leading-relaxed"
>
{node.children.map((child, cIdx) =>
'text' in child ? renderTextNode(child, cIdx) : renderElement(child, cIdx)
)}
</div>
);
default:
return (
<p key={idx} className="text-xs text-slate-600 leading-relaxed my-1.5">
{node.children.map((child, cIdx) =>
'text' in child ? renderTextNode(child, cIdx) : renderElement(child, cIdx)
)}
</p>
);
}
};
return (
<div className={`slate-editor-script space-y-1 ${className}`}>
{nodes.map((node, idx) =>
'text' in node ? renderTextNode(node, idx) : renderElement(node, idx)
)}
</div>
);
};

View File

@ -1,51 +0,0 @@
/**
* 나중에 넣을 수 있는 섹션 — [+ 섹션 추가] 가 고르는 목록.
*
* 업종 시드(industryData)는 "처음부터 있는 것"이고 여기는 "필요하면 넣는 것"이다.
* 붙여넣기 아이템은 내용이 없으면 빈 섹션이라, 시드에 박아 두면 아무도 안 쓰는 칸이 늘 붙어 있다.
*/
import type {SectionItem} from '@o2o/shared';
import {SECTION_DATA_SPEC} from './dataSpec';
import {defaultVariant} from './registry';
import type {ThumbKey} from './types';
export interface AddableSection {
type: string;
name: string;
description: string;
thumb: ThumbKey;
}
/**
* 추가할 수 있는 섹션 목록.
*
* ★ dataSpec 이 단일 출처다 — 아이템을 하나 더 만들면 여기에도 자동으로 나타난다.
* 목록을 따로 들면 아이템을 추가하고 이 표를 잊어 "만들었는데 고를 수가 없는" 상태가 된다.
*/
export const ADDABLE_SECTIONS: AddableSection[] = Object.values(SECTION_DATA_SPEC).map((spec) => {
const variant = defaultVariant(spec.kind);
return {
type: spec.kind,
name: spec.label,
description: variant?.description ?? '',
thumb: variant?.thumb ?? 'cards',
};
});
export function addableSection(type: string): AddableSection | undefined {
return ADDABLE_SECTIONS.find((item) => item.type === type);
}
/** 목록에 새로 넣을 섹션 한 줄. id 는 타입과 같다 — 같은 아이템을 두 번 넣지 않는다. */
export function newSectionOf(type: string): SectionItem | undefined {
const addable = addableSection(type);
if (!addable) return undefined;
return {
id: type,
type,
name: addable.name,
description: addable.description,
isLocked: false,
isEnabled: true,
};
}

View File

@ -1,118 +0,0 @@
import {useState} from 'react';
import {Check, Copy} from 'lucide-react';
import type {TemplateItem} from '@o2o/shared';
import {cn} from '@/lib/utils';
/**
* 주소 + 복사 + 지도 앱 링크.
*
* "오시는 길" 배리에이션들이 공통으로 쓰는 덩어리. 복사 성공 표시까지 여기 들어 있어서
* 레이아웃을 바꿔도 사장님이 확인하는 동작은 똑같이 남는다.
*/
export function AddressCard({
address,
searchName,
variant = 'card',
colors,
className,
}: {
address: string;
/** 네이버 지도에서 찾을 이름. 보통 상호. */
searchName: string;
variant?: 'card' | 'bar';
/**
* 템플릿 색 토큰. 주소 글자만 text 를 따른다.
*
* ★ 지도 버튼의 초록·노랑은 네이버/카카오의 색이라 토큰으로 바꾸지 않는다 —
* 손님이 "그 버튼"으로 알아보는 색이다.
*/
colors?: TemplateItem['colors'];
className?: string;
}) {
const [copied, setCopied] = useState(false);
const addressStyle = colors ? {color: colors.text} : undefined;
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(address);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const links = (
<div className="flex items-center gap-2">
<a
href={`https://map.naver.com/v5/search/${encodeURIComponent(searchName)}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="rounded-lg bg-emerald-600 px-3.5 py-2 text-xs font-semibold text-white shadow-2xs transition-colors hover:bg-emerald-700"
>
네이버 지도 길찾기
</a>
<a
href={`https://map.kakao.com/link/search/${encodeURIComponent(address)}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="rounded-lg bg-amber-400 px-3.5 py-2 text-xs font-semibold text-stone-950 shadow-2xs transition-colors hover:bg-amber-500"
>
카카오맵
</a>
</div>
);
if (variant === 'bar') {
return (
<div
className={cn(
'flex flex-col items-start justify-between gap-3 rounded-xl border border-stone-200/80 bg-white px-4 py-3 sm:flex-row sm:items-center',
className,
)}
>
<span className="text-xs font-bold text-stone-900 sm:text-sm" style={addressStyle}>
{address}
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleCopy}
className="inline-flex cursor-pointer items-center gap-1 rounded border border-stone-200 bg-white px-2.5 py-1 text-xs font-medium text-stone-700 shadow-2xs hover:bg-stone-100"
>
{copied ? <Check className="size-3.5 text-emerald-600" /> : <Copy className="size-3.5" />}
<span>{copied ? '복사완료' : '복사'}</span>
</button>
{links}
</div>
</div>
);
}
return (
<div
className={cn(
'flex flex-col justify-between gap-4 rounded-xl border border-stone-200/80 bg-stone-50/70 p-5 md:flex-row md:items-center',
className,
)}
>
<div className="space-y-1">
<span className="text-[11px] font-bold uppercase text-stone-400">도로명 주소</span>
<div className="flex items-center gap-2">
<strong className="text-sm font-bold text-stone-900 sm:text-base" style={addressStyle}>
{address}
</strong>
<button
type="button"
onClick={handleCopy}
className="inline-flex cursor-pointer items-center gap-1 rounded border border-stone-200 bg-white px-2.5 py-1 text-xs font-medium text-stone-700 shadow-2xs hover:bg-stone-100"
>
{copied ? <Check className="size-3.5 text-emerald-600" /> : <Copy className="size-3.5" />}
<span>{copied ? '복사완료' : '복사'}</span>
</button>
</div>
</div>
{links}
</div>
);
}

View File

@ -1,80 +0,0 @@
/**
* 캔버스 안의 행동 유도 링크.
*
* 캔버스는 클릭이 곧 "섹션 선택"이라, 링크는 반드시 stopPropagation 을 해야 한다.
* 그 한 줄을 배리에이션마다 손으로 쓰면 언젠가 빠진다 — 여기 한 곳에 가둔다.
*/
import type {ReactNode} from 'react';
import type {TemplateItem} from '@o2o/shared';
import {cn} from '@/lib/utils';
export type CtaStyle = 'solid' | 'outline' | 'pill' | 'ghost' | 'brand';
interface CtaLinkProps {
href: string;
children: ReactNode;
variant?: CtaStyle;
size?: 'sm' | 'md';
/** 템플릿 색 토큰. 넘기면 버튼이 브랜드 색을 따른다. */
colors?: TemplateItem['colors'];
block?: boolean;
className?: string;
}
const BASE =
'inline-flex items-center justify-center gap-1.5 font-semibold transition-all cursor-pointer';
const SIZE = {
sm: 'px-3 py-1.5 text-[11px]',
md: 'px-5 py-2.5 text-xs',
};
/**
* variant 마다 토큰이 닿는 자리가 다르다 — 채운 버튼은 배경, 흰 버튼은 글자색.
*
* ★ primary 는 이 서비스에서 "흰 글자를 얹는 어두운 색"이라는 계약이다.
* 그래서 배경으로 쓸 때는 primary 만 쓴다 — accent 를 배경에 깔면 흰 글자가 뜬다.
*/
function tokenStyle(variant: CtaStyle, colors?: TemplateItem['colors']) {
if (!colors) return undefined;
if (variant === 'brand' || variant === 'solid') return {backgroundColor: colors.primary};
if (variant === 'pill' || variant === 'outline') return {color: colors.primary};
return undefined;
}
export function CtaLink({
href,
children,
variant = 'solid',
size = 'md',
colors,
block,
className,
}: CtaLinkProps) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
style={tokenStyle(variant, colors)}
className={cn(
BASE,
SIZE[size],
block && 'w-full',
variant === 'solid' && 'rounded-xl bg-stone-900 text-white shadow-xs hover:bg-stone-800',
variant === 'brand' && 'rounded-xl text-white shadow-xs hover:opacity-90',
variant === 'outline' &&
'rounded-xl border border-stone-300 bg-white text-stone-800 hover:bg-stone-100',
variant === 'pill' &&
'rounded-full bg-white text-stone-900 shadow-md hover:bg-stone-100',
variant === 'ghost' && 'rounded-lg text-stone-500 hover:bg-stone-100 hover:text-stone-900',
// 배경을 인라인 스타일로 덮으면 hover 배경 클래스가 안 먹는다 — 투명도로 대신 반응시킨다.
variant === 'solid' && colors && 'hover:opacity-90',
className,
)}
>
{children}
</a>
);
}

View File

@ -1,21 +0,0 @@
import type {ReactNode} from 'react';
import {Clock3} from 'lucide-react';
/**
* "아직 준비 중" 한 줄.
*
* 실사업장은 객실·주변정보·영상 같은 목록이 아직 비어 있다(백엔드에 조회 창구가
* 없는 것도 있다). 목록만 조용히 비우면 에디터에서는 섹션이 통째로 사라진 것처럼 보여
* 사장님이 "내가 뭘 지웠나" 하고 되돌리려 든다 — 자리는 지키고 비었다는 사실만 말한다.
*
* 톤은 HiddenFieldsNotice 와 맞춘다: 사장님에게 하는 담백한 상태 보고지 손님용 카피가 아니다.
* (다만 저쪽은 "가리는 중"이라 경고색, 이쪽은 "아직 없음"이라 중립색이다.)
*/
export function EmptyStateNotice({children}: {children: ReactNode}) {
return (
<div className="flex items-center justify-center gap-2 rounded-xl border border-dashed border-stone-300/80 bg-stone-50/60 px-4 py-6 text-center text-xs text-stone-500">
<Clock3 className="size-3.5 shrink-0 text-stone-400" />
<span>{children}</span>
</div>
);
}

View File

@ -1,47 +0,0 @@
import type {LucideIcon} from 'lucide-react';
import type {TemplateItem} from '@o2o/shared';
import {cn} from '@/lib/utils';
/**
* 아이콘 + 제목 + 한 줄 설명. 주차/충전기/편의시설처럼 "있다/없다"를 알리는 칸.
*/
export function FeatureCard({
icon: Icon,
title,
description,
iconClassName,
colors,
className,
}: {
icon: LucideIcon;
title: string;
description?: string;
iconClassName?: string;
/** 템플릿 색 토큰. 제목과 아이콘이 브랜드 색을 따른다. */
colors?: TemplateItem['colors'];
className?: string;
}) {
// ★ iconClassName 은 "전기차 충전기는 초록" 처럼 뜻이 정해진 색을 넣는 자리다.
// 그 위에 토큰을 덮으면 의미가 사라지므로, 지정이 없을 때만 primary 를 쓴다.
const iconStyle = colors && !iconClassName ? {color: colors.primary} : undefined;
return (
<div
className={cn(
'flex items-start gap-3 rounded-xl border border-stone-200/80 bg-stone-50/70 p-4',
className,
)}
>
<Icon className={cn('size-5 shrink-0 text-stone-800', iconClassName)} style={iconStyle} />
<div>
<strong
className="block text-xs font-bold text-stone-900"
style={colors ? {color: colors.text} : undefined}
>
{title}
</strong>
{description && <p className="mt-0.5 text-[11px] text-stone-600">{description}</p>}
</div>
</div>
);
}

View File

@ -1,18 +0,0 @@
import {AlertCircle} from 'lucide-react';
/**
* "미확인 항목 N개 숨김 중" 배지.
*
* 미검증 값은 캔버스에서 아예 빼되, 빠졌다는 사실은 반드시 보여준다 —
* 조용히 사라지면 사장님은 "이미 나가고 있다"고 오해한다.
*/
export function HiddenFieldsNotice({count}: {count: number}) {
if (count <= 0) return null;
return (
<span className="flex items-center gap-1.5 rounded-lg border border-amber-200 bg-amber-50 px-2.5 py-1 text-[11px] text-amber-700">
<AlertCircle className="size-3.5 text-amber-600" />
<span>미확인 항목 {count}개 숨김 중</span>
</span>
);
}

View File

@ -1,77 +0,0 @@
/**
* 기본 정보 한 줄 — 라벨/값 한 쌍을 어떤 모양으로 낼지가 통째로 배리에이션이다.
* (표의 한 행 · 카드 한 장 · 문장 속 인라인)
*/
import {cn} from '@/lib/utils';
import type {InfoField, TemplateItem} from '@o2o/shared';
export type InfoRowStyle = 'row' | 'card' | 'inline';
export function InfoRow({
field,
variant = 'row',
colors,
className,
}: {
field: InfoField;
variant?: InfoRowStyle;
/** 템플릿 색 토큰. 값(굵은 글씨)만 text 를 따르고 라벨은 중립 회색으로 남는다. */
colors?: TemplateItem['colors'];
className?: string;
}) {
const valueStyle = colors ? {color: colors.text} : undefined;
if (variant === 'card') {
return (
<div
className={cn(
'space-y-1 rounded-xl border border-stone-200/80 bg-white p-4 shadow-2xs',
className,
)}
>
<span className="block text-[11px] font-semibold uppercase tracking-wide text-stone-400">
{field.label}
</span>
<span
className="block text-xs font-bold leading-relaxed text-stone-900 sm:text-sm"
style={valueStyle}
>
{field.value}
</span>
</div>
);
}
if (variant === 'inline') {
return (
<span
className={cn(
'inline-flex flex-wrap items-baseline gap-1.5 text-xs leading-relaxed',
className,
)}
>
<span className="font-semibold text-stone-400">{field.label}</span>
<span className="font-bold text-stone-900" style={valueStyle}>
{field.value}
</span>
</span>
);
}
return (
<div
className={cn(
'flex flex-col justify-between gap-1 p-4 transition-colors hover:bg-stone-100/50 sm:flex-row sm:items-center sm:gap-4',
className,
)}
>
<span className="text-xs font-semibold text-stone-500 sm:w-1/3">{field.label}</span>
<span
className="text-xs font-bold text-stone-900 sm:w-2/3 sm:text-right sm:text-sm"
style={valueStyle}
>
{field.value}
</span>
</div>
);
}

View File

@ -1,80 +0,0 @@
import {X, ChevronLeft, ChevronRight} from 'lucide-react';
import type {TilePhoto} from './PhotoTile';
/**
* 사진 확대 보기. 갤러리 배리에이션 세 개가 전부 이걸 그대로 쓴다 —
* 확대 동작까지 배리에이션마다 다르면 사장님이 레이아웃을 바꿀 때마다 조작법을 다시 배운다.
*/
export function Lightbox({
photos,
index,
onClose,
onMove,
}: {
photos: TilePhoto[];
index: number | null;
onClose: () => void;
onMove?: (next: number) => void;
}) {
if (index === null || !photos[index]) return null;
const photo = photos[index];
const step = (delta: number) => onMove?.((index + delta + photos.length) % photos.length);
return (
<div
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4 backdrop-blur-md"
>
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="absolute right-4 top-4 z-10 cursor-pointer rounded-full bg-white/10 p-2 text-white"
>
<X className="size-6" />
</button>
{onMove && photos.length > 1 && (
<>
<button
type="button"
aria-label="이전 사진"
onClick={(e) => {
e.stopPropagation();
step(-1);
}}
className="absolute left-3 z-10 cursor-pointer rounded-full bg-white/10 p-2 text-white hover:bg-white/20"
>
<ChevronLeft className="size-5" />
</button>
<button
type="button"
aria-label="다음 사진"
onClick={(e) => {
e.stopPropagation();
step(1);
}}
className="absolute right-3 z-10 cursor-pointer rounded-full bg-white/10 p-2 text-white hover:bg-white/20"
>
<ChevronRight className="size-5" />
</button>
</>
)}
<div
onClick={(e) => e.stopPropagation()}
className="flex max-h-[85vh] max-w-3xl items-center"
>
<img
src={photo.url}
alt={photo.alt ?? photo.caption ?? ''}
className="max-h-[70vh] max-w-full rounded-xl object-contain shadow-2xl"
/>
</div>
</div>
);
}

View File

@ -1,72 +0,0 @@
import {cn} from '@/lib/utils';
export interface TilePhoto {
id: string;
url: string;
alt?: string;
caption?: string;
category?: string;
}
type TileFit = 'square' | 'portrait' | 'landscape' | 'free';
const ASPECT: Record<TileFit, string> = {
square: 'aspect-square',
portrait: 'aspect-3/4',
landscape: 'aspect-4/3',
free: '',
};
/**
* 갤러리 한 칸. 격자든 벽돌쌓기든 슬라이드든 "칸 하나"의 모양은 같아야 한다 —
* 배리에이션이 바꾸는 건 칸의 배치이지 칸 자체가 아니다.
*/
export function PhotoTile({
photo,
fit = 'square',
onOpen,
showMeta = false,
className,
}: {
photo: TilePhoto;
fit?: TileFit;
onOpen?: () => void;
showMeta?: boolean;
className?: string;
}) {
return (
<div
onClick={(e) => {
e.stopPropagation();
onOpen?.();
}}
className={cn(
'group relative select-none overflow-hidden rounded-xl border border-stone-200/80 bg-stone-100',
ASPECT[fit],
onOpen && 'cursor-pointer',
className,
)}
>
<img
src={photo.url}
alt={photo.alt ?? photo.caption ?? ''}
loading="lazy"
className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
{showMeta && (photo.category || photo.caption) && (
<div className="absolute inset-0 flex flex-col justify-between bg-black/40 p-2 text-white opacity-0 transition-opacity group-hover:opacity-100">
{photo.category ? (
<span className="self-start rounded bg-black/50 px-1.5 py-0.5 text-[10px] font-medium">
{photo.category}
</span>
) : (
<span />
)}
{photo.caption && (
<span className="truncate text-[10px] font-light">{photo.caption}</span>
)}
</div>
)}
</div>
);
}

View File

@ -1,38 +0,0 @@
import type {ReactNode} from 'react';
import {cn} from '@/lib/utils';
type PillTone = 'neutral' | 'accent' | 'warn' | 'good' | 'dark';
const TONE: Record<PillTone, string> = {
neutral: 'bg-stone-100 text-stone-600 border-stone-200/70',
accent: 'bg-amber-100/80 text-amber-900 border-amber-200/70',
warn: 'bg-amber-50 text-amber-700 border-amber-200',
good: 'bg-emerald-50 text-emerald-700 border-emerald-200/60',
dark: 'bg-stone-900/85 text-white border-transparent',
};
/** 작은 라벨 하나. 거리·태그·가격대·"대표축제" 처럼 값 옆에 붙는 부가 정보 전용. */
export function Pill({
children,
tone = 'neutral',
mono,
className,
}: {
children: ReactNode;
tone?: PillTone;
mono?: boolean;
className?: string;
}) {
return (
<span
className={cn(
'inline-flex shrink-0 items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-medium',
TONE[tone],
mono && 'font-mono',
className,
)}
>
{children}
</span>
);
}

View File

@ -1,222 +0,0 @@
import {useCallback, useEffect, useRef, useState, type ReactNode} from 'react';
import {ArrowUpRight, ChevronLeft, ChevronRight} from 'lucide-react';
import type {TemplateItem} from '@o2o/shared';
import {cn} from '@/lib/utils';
import type {GuideCard} from '../variants/local/types';
import {walkMinutes} from '../variants/local/walking';
const naverSearch = (q: string) =>
`https://search.naver.com/search.naver?query=${encodeURIComponent(q)}`;
/** 네이버지도 검색 — 맛집·명소 카드가 쓴다(실제 사이트 LocalGuideSection과 동일한 규칙). */
export const naverMapSearch = (q: string) => `https://map.naver.com/p/search/${encodeURIComponent(q)}`;
/**
* 가이드 카드 한 장 — 사진(좌하단 "도보 약 N분 850m" 배지) · 이름 · 설명 2줄 · "검색으로 열기".
*
* ★ 사진이 없으면 회색 판에 이름을 크게 쓴다(스크린샷의 '군산복집' 카드). 자리를 비우거나
* 남의 사진을 채우지 않는다 — 카드 폭이 들쭉날쭉해지면 캐러셀이 흔들린다.
* ★ 거리를 모르면 배지를 생략한다. "도보 N분"은 업장 기준 직선거리에서만 계산한다.
*/
function GuideCardView({
card,
colors,
leading,
linkUrl = naverSearch,
}: {
card: GuideCard;
colors: TemplateItem['colors'];
leading?: ReactNode;
linkUrl?: (query: string) => string;
}) {
const minutes = card.distanceMeters !== undefined ? walkMinutes(card.distanceMeters) : undefined;
return (
<a
href={linkUrl(card.searchQuery)}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="group flex w-[240px] shrink-0 snap-start flex-col overflow-hidden rounded-lg border border-stone-200/80 bg-white shadow-2xs transition-shadow hover:shadow-md sm:w-[260px]"
>
<div className="relative aspect-[4/3] w-full overflow-hidden bg-stone-200/70">
{card.imageUrl ? (
<img
src={card.imageUrl}
alt={card.name}
loading="lazy"
className="size-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
/>
) : (
<div className="flex size-full items-center justify-center px-4 text-center">
<span className="serif-title text-base font-bold text-stone-700">{card.name}</span>
</div>
)}
{minutes !== undefined && (
<span className="absolute bottom-2.5 left-2.5 inline-flex items-center gap-1.5 rounded bg-stone-900/80 px-2 py-1 text-[11px] font-semibold text-white backdrop-blur-sm">
<span>도보 약 {minutes}분</span>
{card.distanceText && <span className="font-mono font-normal text-white/75">{card.distanceText}</span>}
</span>
)}
</div>
<div className="flex flex-1 flex-col gap-1.5 p-3.5">
{leading}
<h4 className="text-sm font-bold text-stone-900" style={{color: colors.text}}>
{card.name}
</h4>
{card.description && (
<p className="line-clamp-2 text-xs leading-relaxed text-stone-500">{card.description}</p>
)}
<span className="mt-auto flex items-center gap-0.5 pt-1 text-[11px] font-medium text-stone-400 group-hover:text-stone-900">
검색으로 열기
<ArrowUpRight className="size-3" />
</span>
</div>
</a>
);
}
/**
* 카드 캐러셀 — 가로 스크롤(스냅) + 좌우 화살표 + 점 페이지네이션 + "1 / N".
*
* ★ 페이지 = 한 화면에 온전히 들어가는 카드 수. 미리보기 해상도(PC/태블릿/모바일)가 바뀌면
* ResizeObserver 가 다시 센다 — 고정 4장으로 두면 모바일에서 점이 카드 수와 어긋난다.
* ★ 화살표는 컨테이너 폭만큼 넘긴다(한 페이지). 한 장씩 넘기면 24장에 화살표 23번이다.
*/
export function PlaceCarousel({
cards,
colors,
renderLeading,
linkUrl,
}: {
cards: GuideCard[];
colors: TemplateItem['colors'];
/** 카드 이름 위에 얹을 배지(축제의 "10월" 등). */
renderLeading?: (card: GuideCard) => ReactNode;
/** 카드가 열 링크. 기본은 네이버 웹 검색이고, 맛집·명소는 네이버지도 검색(naverMapSearch)을 넘긴다. */
linkUrl?: (query: string) => string;
}) {
const trackRef = useRef<HTMLDivElement>(null);
const [perPage, setPerPage] = useState(1);
const [page, setPage] = useState(0);
const pages = Math.max(1, Math.ceil(cards.length / perPage));
// 카드 한 장 폭(gap 포함)으로 한 화면에 몇 장 들어가는지 잰다.
const measure = useCallback(() => {
const track = trackRef.current;
const first = track?.firstElementChild as HTMLElement | null;
if (!track || !first) return;
const gap = parseFloat(getComputedStyle(track).columnGap || '0') || 0;
const step = first.offsetWidth + gap;
const fit = Math.max(1, Math.floor((track.clientWidth + gap) / step));
setPerPage(fit);
setPage(Math.min(Math.round(track.scrollLeft / (step * fit)), Math.max(0, Math.ceil(cards.length / fit) - 1)));
}, [cards.length]);
useEffect(() => {
measure();
const track = trackRef.current;
if (!track) return;
const ro = new ResizeObserver(measure);
ro.observe(track);
return () => ro.disconnect();
}, [measure]);
// 필터가 바뀌어 카드가 줄면 첫 페이지로.
useEffect(() => {
trackRef.current?.scrollTo({left: 0});
setPage(0);
}, [cards]);
const scrollToPage = (next: number) => {
const track = trackRef.current;
if (!track) return;
const clamped = Math.max(0, Math.min(pages - 1, next));
track.scrollTo({left: clamped * track.clientWidth, behavior: 'smooth'});
setPage(clamped);
};
const onScroll = () => {
const track = trackRef.current;
if (!track || track.clientWidth === 0) return;
setPage(Math.max(0, Math.min(pages - 1, Math.round(track.scrollLeft / track.clientWidth))));
};
const arrow =
'flex size-9 cursor-pointer items-center justify-center rounded-full border transition-colors disabled:cursor-default disabled:opacity-30';
return (
<div className="space-y-3">
<div className="flex items-center justify-end gap-2">
<button
type="button"
aria-label="이전"
disabled={page === 0}
onClick={(e) => {
e.stopPropagation();
scrollToPage(page - 1);
}}
className={cn(arrow, 'border-stone-300/70 bg-white/70 text-stone-500 hover:bg-white')}
>
<ChevronLeft className="size-4" />
</button>
<button
type="button"
aria-label="다음"
disabled={page >= pages - 1}
onClick={(e) => {
e.stopPropagation();
scrollToPage(page + 1);
}}
style={{borderColor: colors.text, color: colors.text}}
className={cn(arrow, 'bg-white hover:bg-stone-50')}
>
<ChevronRight className="size-4" />
</button>
</div>
<div
ref={trackRef}
onScroll={onScroll}
className="scrollbar-none flex snap-x snap-mandatory gap-4 overflow-x-auto pb-1"
>
{cards.map((card) => (
<GuideCardView
key={`${card.name}-${card.distanceMeters ?? ''}`}
card={card}
colors={colors}
leading={renderLeading?.(card)}
linkUrl={linkUrl}
/>
))}
</div>
{pages > 1 && (
<div className="flex items-center justify-center gap-3">
<div className="flex items-center gap-1.5">
{Array.from({length: pages}, (_, i) => (
<button
key={i}
type="button"
aria-label={`${i + 1}페이지`}
onClick={(e) => {
e.stopPropagation();
scrollToPage(i);
}}
className={cn(
'h-1.5 cursor-pointer rounded-full transition-all',
i === page ? 'w-5' : 'w-1.5 bg-stone-300 hover:bg-stone-400',
)}
style={i === page ? {backgroundColor: colors.text} : undefined}
/>
))}
</div>
<span className="font-mono text-[11px] text-stone-400">
{page + 1} / {pages}
</span>
</div>
)}
</div>
);
}

View File

@ -1,89 +0,0 @@
import {ArrowUpRight} from 'lucide-react';
import type {TemplateItem} from '@o2o/shared';
import {cn} from '@/lib/utils';
import {Pill} from './Pill';
/**
* "이름 · 거리 · 한 줄 설명 · 바로가기" 한 행.
* 맛집 / 명소 / 축제가 전부 이 모양이라 하나로 묶는다.
*/
export function PlaceRow({
name,
meta,
description,
href,
actionLabel = '길찾기',
leading,
colors,
className,
}: {
name: string;
meta?: string;
description?: string;
href?: string;
actionLabel?: string;
leading?: React.ReactNode;
/** 템플릿 색 토큰. 상호명만 text 를 따른다 — 설명·바로가기는 중립 회색이어야 이름이 튄다. */
colors?: TemplateItem['colors'];
className?: string;
}) {
const body = (
<>
<div className="space-y-1 pr-3">
<div className="flex flex-wrap items-center gap-2">
{leading}
<span
className="text-xs font-bold text-stone-900 group-hover:text-stone-700 sm:text-sm"
style={colors ? {color: colors.text} : undefined}
>
{name}
</span>
{meta && <Pill mono>{meta}</Pill>}
</div>
{description && (
<p className="text-xs leading-relaxed text-stone-500">{description}</p>
)}
</div>
{href && (
<span className="flex shrink-0 items-center gap-0.5 text-xs font-medium text-stone-400 group-hover:text-stone-900">
<span>{actionLabel}</span>
<ArrowUpRight className="size-3.5" />
</span>
)}
</>
);
const shared = cn(
'group flex items-center justify-between p-4 transition-colors hover:bg-stone-50/80',
className,
);
if (!href) return <div className={shared}>{body}</div>;
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className={shared}
>
{body}
</a>
);
}
/** 목록을 감싸는 카드. divide 로 행 사이 선을 긋는다. */
export function ListCard({children, className}: {children: React.ReactNode; className?: string}) {
return (
<div
className={cn(
'overflow-hidden rounded-xl border border-stone-200/80 bg-white shadow-2xs [&>*+*]:border-t [&>*+*]:border-stone-100',
className,
)}
>
{children}
</div>
);
}

View File

@ -1,89 +0,0 @@
import type {TemplateItem} from '@o2o/shared';
import {cn} from '@/lib/utils';
import {Pill} from './Pill';
export interface PricedItem {
name: string;
price?: string;
desc?: string;
tag?: string;
/** 소요시간 · 대상 같은 부가 스펙. 프로그램/코스에서 쓴다. */
meta?: string[];
}
/**
* "이름 · 태그 · 설명 · 가격" 한 줄.
* 메뉴 / 코스 / 체험 프로그램이 전부 같은 뼈대라 한 원자로 묶고, 배치만 배리에이션에 맡긴다.
*/
export function PriceRow({
item,
variant = 'row',
colors,
className,
}: {
item: PricedItem;
variant?: 'row' | 'card';
/** 템플릿 색 토큰. 품명은 text, 가격은 primary 를 따른다(가격이 이 원자의 주인공이다). */
colors?: TemplateItem['colors'];
className?: string;
}) {
const nameStyle = colors ? {color: colors.text} : undefined;
const priceStyle = colors ? {color: colors.primary} : undefined;
const head = (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-bold text-stone-900 sm:text-sm" style={nameStyle}>
{item.name}
</span>
{item.tag && <Pill tone="accent">{item.tag}</Pill>}
</div>
);
const specs = item.meta?.length ? (
<div className="flex flex-wrap gap-1.5 pt-0.5">
{item.meta.map((m) => (
<Pill key={m}>{m}</Pill>
))}
</div>
) : null;
if (variant === 'card') {
return (
<div
className={cn(
'flex flex-col justify-between gap-3 rounded-xl border border-stone-200/80 bg-white p-4 shadow-2xs',
className,
)}
>
<div className="space-y-1.5">
{head}
{item.desc && <p className="text-xs leading-relaxed text-stone-500">{item.desc}</p>}
{specs}
</div>
{item.price && (
<span className="font-mono text-sm font-extrabold text-stone-900" style={priceStyle}>
{item.price}
</span>
)}
</div>
);
}
return (
<div className={cn('flex items-start justify-between gap-4 p-4', className)}>
<div className="min-w-0 space-y-1">
{head}
{item.desc && <p className="text-xs leading-relaxed text-stone-500">{item.desc}</p>}
{specs}
</div>
{item.price && (
<span
className="shrink-0 font-mono text-xs font-extrabold text-stone-900 sm:text-sm"
style={priceStyle}
>
{item.price}
</span>
)}
</div>
);
}

View File

@ -1,234 +0,0 @@
/**
* 캔버스의 가로 슬라이더.
*
* ★ 왜 라이브러리인가 (2026-09-02)
* 예전에는 `overflow-x: auto` 상자 + 화살표 버튼 두 개였다. 화살표는 `scrollBy` 를 불러
* 한 화면의 80%씩 밀었는데, **카드를 잡아끄는 방법이 없었다** — 휠 마우스 사용자는
* 화살표를 찾지 못하면 옆에 더 있다는 걸 몰랐고, 트랙패드에서만 자연스러웠다.
* embla-carousel 은 의존성 0, 코어 ~5KB(gzip)이고 발행본(solution/site)이 쓰는 것과 같다 —
* 미리보기와 발행본의 조작감이 갈리면 미리보기가 아니다.
*
* ★ 캔버스 전용 사정: 카드 안에 링크·버튼이 있다. 드래그를 시작한 뒤의 클릭은 삼킨다
* (embla 의 `pointerUp` 뒤 click 은 막지 않으면 카드가 눌린 것으로 처리된다).
*/
import {useCallback, useEffect, useRef, useState, type ReactNode} from 'react';
import useEmblaCarousel from 'embla-carousel-react';
import {ChevronLeft, ChevronRight} from 'lucide-react';
import {ITEM_BORDER, ITEM_CARD} from '../variants/items/common';
export function Rail({
label,
children,
tone = 'light',
gap = 1,
className,
nav = 'above',
viewportClassName,
trackClassName = 'items-start',
}: {
/** 스크린리더가 읽을 이름. 화살표의 aria-label 도 여기서 만든다. */
label: string;
children: ReactNode;
tone?: 'light' | 'dark';
/** 슬라이드 사이 간격(rem). */
gap?: number;
className?: string;
/**
* 화살표를 어디에 둘지. 슬라이더가 액자(필름·책장) 안에 들어가는 아이템은
* 'below' 로 액자 밖에 세운다 — 액자 안에 넣으면 질감 위에 버튼이 떠서 물건처럼 안 보인다.
*/
nav?: 'above' | 'below' | 'overlay' | 'none';
/** 창(viewport)에 붙일 클래스. 액자 안쪽 여백처럼 슬라이더 자체가 가져야 하는 값. */
viewportClassName?: string;
/** 트랙에 붙일 클래스. 세로 정렬(items-end 같은)이 아이템마다 다르다. */
trackClassName?: string;
}) {
const [emblaRef, embla] = useEmblaCarousel({align: 'start', containScroll: 'trimSnaps'});
const [snaps, setSnaps] = useState<number[]>([]);
const [selected, setSelected] = useState(0);
const [canPrev, setCanPrev] = useState(false);
const [canNext, setCanNext] = useState(false);
const viewport = useRef<HTMLDivElement | null>(null);
const dragged = useRef(false);
useEffect(() => {
if (!embla) return;
const sync = () => {
setSelected(embla.selectedScrollSnap());
setCanPrev(embla.canScrollPrev());
setCanNext(embla.canScrollNext());
};
const measure = () => {
setSnaps(embla.scrollSnapList());
sync();
};
measure();
embla.on('select', sync);
embla.on('reInit', measure);
viewport.current?.setAttribute('data-slider', 'on');
return () => {
embla.off('select', sync);
embla.off('reInit', measure);
};
}, [embla]);
// 끌고 나서 손을 떼면 그 자리의 카드가 클릭된 것으로 처리된다 — 한 번만 삼킨다.
useEffect(() => {
if (!embla) return;
const down = () => {
dragged.current = false;
viewport.current?.setAttribute('data-dragging', 'true');
};
const move = () => {
dragged.current = true;
};
const up = () => viewport.current?.removeAttribute('data-dragging');
embla.on('pointerDown', down);
embla.on('scroll', move);
embla.on('pointerUp', up);
return () => {
embla.off('pointerDown', down);
embla.off('scroll', move);
embla.off('pointerUp', up);
};
}, [embla]);
const prev = useCallback(() => embla?.scrollPrev(), [embla]);
const next = useCallback(() => embla?.scrollNext(), [embla]);
// 한 화면에 다 들어가면 조작부를 그리지 않는다 — 눌러도 안 움직이는 버튼은 고장으로 읽힌다.
const movable = snaps.length > 1;
// 액자(필름·책장) 안에 든 슬라이더는 화살표를 창 위에 겹친다 — 액자 밖에 두면
// 어느 줄을 미는 버튼인지 알 수 없고, 액자 안에 한 줄 더 두면 질감이 끊긴다.
const overlay = movable && nav === 'overlay' && (
<>
<EdgeButton dir="prev" tone={tone} onClick={prev} disabled={!canPrev} label={label} />
<EdgeButton dir="next" tone={tone} onClick={next} disabled={!canNext} label={label} />
</>
);
const controls = movable && (nav === 'above' || nav === 'below') && (
<div className={`flex items-center justify-end gap-1.5 ${nav === 'above' ? 'mb-2' : 'mt-2'}`}>
<span className="mr-auto text-[11px] tabular-nums opacity-50">
{selected + 1} / {snaps.length}
</span>
<NavButton dir="prev" tone={tone} onClick={prev} disabled={!canPrev} label={label} />
<NavButton dir="next" tone={tone} onClick={next} disabled={!canNext} label={label} />
</div>
);
return (
<div className={`${nav === 'overlay' ? 'relative' : ''} ${className ?? ''}`}>
{nav === 'above' && controls}
<div
ref={(node) => {
viewport.current = node;
emblaRef(node);
}}
className={`w4-scroll ${viewportClassName ?? ''}`}
role="group"
aria-roledescription="캐러셀"
aria-label={label}
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
prev();
}
if (event.key === 'ArrowRight') {
event.preventDefault();
next();
}
}}
onClickCapture={(event) => {
if (!dragged.current) return;
event.preventDefault();
event.stopPropagation();
dragged.current = false;
}}
>
<div className={`flex ${trackClassName}`} style={{gap: `${gap}rem`}}>
{children}
</div>
</div>
{nav === 'below' && controls}
{overlay}
</div>
);
}
function EdgeButton({
dir,
onClick,
disabled,
tone,
label,
}: {
dir: 'prev' | 'next';
onClick: () => void;
disabled: boolean;
tone: 'light' | 'dark';
label: string;
}) {
const Icon = dir === 'prev' ? ChevronLeft : ChevronRight;
const dark = tone === 'dark';
return (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onClick();
}}
disabled={disabled}
aria-label={`${label} ${dir === 'prev' ? '이전' : '다음'}`}
className={`absolute top-1/2 z-10 flex size-8 -translate-y-1/2 items-center justify-center rounded-full backdrop-blur transition-opacity hover:opacity-80 disabled:opacity-0 ${
dir === 'prev' ? 'left-1.5' : 'right-1.5'
}`}
style={{
backgroundColor: dark
? 'color-mix(in oklab, currentColor 22%, transparent)'
: 'color-mix(in oklab, currentColor 12%, transparent)',
}}
>
<Icon className="size-4" />
</button>
);
}
function NavButton({
dir,
onClick,
disabled,
tone,
label,
}: {
dir: 'prev' | 'next';
onClick: () => void;
disabled: boolean;
tone: 'light' | 'dark';
label: string;
}) {
const Icon = dir === 'prev' ? ChevronLeft : ChevronRight;
const dark = tone === 'dark';
return (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onClick();
}}
disabled={disabled}
aria-label={`${label} ${dir === 'prev' ? '이전' : '다음'}`}
className="flex size-8 items-center justify-center border transition-opacity hover:opacity-70 disabled:opacity-25"
style={{
borderColor: dark ? 'color-mix(in oklab, currentColor 35%, transparent)' : ITEM_BORDER,
backgroundColor: dark ? 'transparent' : ITEM_CARD,
}}
>
<Icon className="size-4" />
</button>
);
}

View File

@ -1,111 +0,0 @@
/**
* 섹션 껍데기 — 배리에이션이 절대 다시 만들지 않는 부분.
*
* 앵커 id(모바일 탭이 스크롤하는 목표), 선택 링, "선택됨" 배지, 배경 톤이 여기 모여 있다.
* 배리에이션은 안쪽 내용만 책임진다 — 껍데기가 제각각이면 레이아웃을 바꿀 때마다
* 선택 표시가 사라지거나 앵커가 끊긴다.
*/
import type {ReactNode} from 'react';
import type {SectionItem} from '@o2o/shared';
import {cn} from '@/lib/utils';
import type {SectionTone} from '../types';
/**
* 섹션 배경 톤 → 실제 색.
*
* 화면 면적의 대부분이 이 네 색이라, 여기가 하드코딩이면 팔레트를 바꿔도 인상이 안 바뀐다.
* 그래서 템플릿 토큰(`--tpl-*`)을 읽고, 폴백으로 기존 색을 그대로 둔다 —
* ★ 토큰을 안 내려보내는 화면(발행 사이트·썸네일 등)에서는 지금과 픽셀 단위로 동일하다.
*/
const TONE_BG: Record<SectionTone, string> = {
paper: 'var(--tpl-surface, #faf7f2)',
// ★ 진짜 흰색으로 남긴다 — surface/surfaceAlt 와 세 단계가 있어야 섹션이 서로 구분된다.
white: 'var(--tpl-bg, #ffffff)',
tint: 'var(--tpl-surface-alt, #f4f1ec)',
dark: 'var(--tpl-inverse, #1c1917)',
};
interface SectionFrameProps {
section: SectionItem;
isSelected: boolean;
onSelect: () => void;
/** 배경 톤. 위아래 섹션과 다른 톤을 주면 경계선 없이도 구분된다. */
tone?: SectionTone;
/** 히어로처럼 자기 여백을 직접 잡는 배리에이션은 false. */
padded?: boolean;
className?: string;
children: ReactNode;
}
export function SectionFrame({
section,
isSelected,
onSelect,
tone = 'white',
padded = true,
className,
children,
}: SectionFrameProps) {
const isDark = tone === 'dark';
return (
<section
id={`canvas-section-${section.id}`}
onClick={onSelect}
className={cn(
'relative cursor-pointer transition-all',
padded && 'px-4 sm:px-6 md:px-8',
isSelected
? 'ring-2 ring-stone-900 ring-offset-2'
: 'hover:outline-1 hover:outline-dashed hover:outline-stone-300',
className,
)}
style={{
backgroundColor: TONE_BG[tone],
// 세로 여백은 템플릿이 정한다 — 이 값 하나로 페이지의 호흡이 바뀐다.
// 폴백은 걷어낸 py-12/py-16 과 같은 값이라, 토큰이 없는 곳에서는 지금까지와 동일하다.
...(padded ? {paddingBlock: 'var(--tpl-section-space, 3.5rem)'} : {}),
}}
>
{isSelected && (
<span
className={cn(
'z-30 rounded bg-stone-900 px-2 py-0.5 text-[10px] font-semibold text-white',
padded ? 'mb-4 inline-block' : 'absolute left-3 top-3 shadow-xs',
isDark && 'bg-white text-stone-900',
)}
>
선택됨: {section.name}
{section.isLocked && ' (필수)'}
</span>
)}
{children}
</section>
);
}
/** 섹션 본문의 표준 폭. 배리에이션마다 폭이 다르면 스크롤할 때 글줄이 출렁인다. */
export function SectionBody({
children,
width = 'default',
className,
}: {
children: ReactNode;
width?: 'default' | 'wide' | 'narrow';
className?: string;
}) {
return (
<div
className={cn(
'mx-auto space-y-6',
width === 'wide' && 'max-w-[1120px]',
width === 'default' && 'max-w-[880px]',
width === 'narrow' && 'max-w-[640px]',
className,
)}
>
{children}
</div>
);
}

View File

@ -1,126 +0,0 @@
/**
* 섹션 제목 — 그 자체로 배리에이션을 갖는 원자.
*
* 섹션 배리에이션을 바꿔도 제목 스타일은 따로 고를 수 있어야 한다
* ("갤러리는 격자로, 제목은 가운데 정렬" 같은 조합이 실제로 나온다).
*/
import type {ReactNode} from 'react';
import type {TemplateItem} from '@o2o/shared';
import {cn} from '@/lib/utils';
export type HeadingStyle = 'underline' | 'centered' | 'eyebrow' | 'minimal';
interface SectionHeadingProps {
title: string;
subtitle?: string;
/** 제목 위에 얹는 작은 라벨. eyebrow · centered 스타일에서 쓴다. */
eyebrow?: string;
variant?: HeadingStyle;
/** 제목 오른쪽에 붙는 것(경고 배지, 추가 버튼 등). */
trailing?: ReactNode;
/**
* 템플릿 색 토큰. 넘기면 제목은 text, eyebrow 는 accent 를 따른다.
*
* ★ 부제·밑줄은 일부러 중립 회색으로 남긴다 — 여기까지 토큰을 먹이면
* accent 가 밝은 템플릿에서 부제가 배경에 묻힌다.
*/
colors?: TemplateItem['colors'];
className?: string;
}
export function SectionHeading({
title,
subtitle,
eyebrow,
variant = 'underline',
trailing,
colors,
className,
}: SectionHeadingProps) {
const titleStyle = colors ? {color: colors.text} : undefined;
const eyebrowStyle = colors ? {color: colors.accent} : undefined;
if (variant === 'centered') {
return (
<div className={cn('space-y-2 text-center', className)}>
{eyebrow && (
<span
className="text-[11px] font-semibold uppercase tracking-[0.2em] text-stone-400"
style={eyebrowStyle}
>
{eyebrow}
</span>
)}
<h2
className="tpl-title serif-title text-xl font-bold tracking-tight text-stone-900 sm:text-2xl"
style={titleStyle}
>
{title}
</h2>
{subtitle && <p className="text-xs text-stone-500 sm:text-sm">{subtitle}</p>}
{trailing && <div className="flex justify-center pt-1">{trailing}</div>}
</div>
);
}
if (variant === 'eyebrow') {
return (
<div className={cn('flex items-start justify-between gap-3', className)}>
<div className="space-y-1">
{eyebrow && (
<span
className="block text-[11px] font-bold uppercase tracking-wider text-amber-700"
style={eyebrowStyle}
>
{eyebrow}
</span>
)}
<h2
className="tpl-title serif-title text-xl font-bold tracking-tight text-stone-900 sm:text-2xl"
style={titleStyle}
>
{title}
</h2>
{subtitle && <p className="text-xs text-stone-500">{subtitle}</p>}
</div>
{trailing}
</div>
);
}
if (variant === 'minimal') {
// 여기서 제목은 사실상 작은 라벨이라 text 가 아니라 secondary 를 쓴다.
return (
<div className={cn('flex items-baseline justify-between gap-3', className)}>
<h2
className="tpl-title text-sm font-bold uppercase tracking-[0.18em] text-stone-500"
style={colors ? {color: colors.secondary} : undefined}
>
{title}
</h2>
{trailing}
</div>
);
}
// underline — 기본. 아래 실선이 섹션의 시작을 확실히 끊어준다.
return (
<div
className={cn(
'flex flex-col justify-between gap-2 border-b border-stone-200/80 pb-4 sm:flex-row sm:items-baseline',
className,
)}
>
<div>
<h2
className="tpl-title serif-title text-xl font-bold tracking-tight text-stone-900 sm:text-2xl"
style={titleStyle}
>
{title}
</h2>
{subtitle && <p className="mt-0.5 text-xs text-stone-500">{subtitle}</p>}
</div>
{trailing && <div className="self-start sm:self-auto">{trailing}</div>}
</div>
);
}

View File

@ -1,58 +0,0 @@
import type {TemplateItem} from '@o2o/shared';
import {cn} from '@/lib/utils';
import {
WALK_FILTERS,
matchesWalkFilter,
type WalkFilterKey,
} from '../variants/local/walking';
/**
* 도보 시간 필터 탭 — "전체 24 · 걸어서 5분 이내 8 · 10분 이내 19 · 10분 이상 5".
*
* ★ 구간은 누적이다(walking.ts 주석). 그래서 숫자가 서로 더해져 전체가 되지 않는다 —
* "5분 이내" ⊂ "10분 이내". 배타 구간으로 바꾸면 라벨("이내")과 숫자가 어긋난다.
* ★ 상태는 부르는 쪽(카테고리 섹션)이 든다. 섹션마다 필터가 따로 움직여야 한다.
*/
export function WalkFilterTabs({
distances,
value,
onChange,
colors,
}: {
/** 항목별 거리(m). 모르는 항목은 undefined — '전체'에만 센다. */
distances: (number | undefined)[];
value: WalkFilterKey;
onChange: (key: WalkFilterKey) => void;
colors: TemplateItem['colors'];
}) {
return (
<div className="flex flex-wrap items-center gap-2" role="tablist" aria-label="도보 시간 필터">
{WALK_FILTERS.map(({key, label}) => {
const count = distances.filter((m) => matchesWalkFilter(key, m)).length;
const active = key === value;
return (
<button
key={key}
type="button"
role="tab"
aria-selected={active}
onClick={(e) => {
e.stopPropagation();
onChange(key);
}}
style={active ? {backgroundColor: colors.text, borderColor: colors.text} : undefined}
className={cn(
'flex cursor-pointer items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors',
active
? 'text-white'
: 'border-stone-300/80 bg-white/70 text-stone-700 hover:bg-white',
)}
>
<span>{label}</span>
<span className={cn('font-mono text-[11px]', active ? 'text-white/80' : 'text-stone-400')}>{count}</span>
</button>
);
})}
</div>
);
}

View File

@ -1,23 +0,0 @@
/**
* 캔버스 원자 모음.
*
* 배리에이션은 여기 있는 것만 조립한다 — 원자를 늘리면 모든 배리에이션이 같이 좋아지고,
* 배리에이션 안에서 직접 마크업을 쓰기 시작하면 레이아웃마다 디테일이 갈라진다.
*/
export {SectionFrame, SectionBody} from './SectionFrame';
export {SectionHeading, type HeadingStyle} from './SectionHeading';
export {InfoRow, type InfoRowStyle} from './InfoRow';
export {HiddenFieldsNotice} from './HiddenFieldsNotice';
export {EmptyStateNotice} from './EmptyStateNotice';
export {PhotoTile, type TilePhoto} from './PhotoTile';
export {Lightbox} from './Lightbox';
export {PlaceRow, ListCard} from './PlaceRow';
export {PriceRow, type PricedItem} from './PriceRow';
export {AddressCard} from './AddressCard';
export {FeatureCard} from './FeatureCard';
export {Pill} from './Pill';
export {CtaLink, type CtaStyle} from './CtaLink';
export {Rail} from './Rail';
/* 주변 정보(업장 좌표 기준) — 시안 이후 들어온 것이라 시안 쪽 index 에는 없다. */
export {PlaceCarousel, naverMapSearch} from './PlaceCarousel';
export {WalkFilterTabs} from './WalkFilterTabs';

View File

@ -1,519 +0,0 @@
/**
* 배리에이션 레지스트리 — "이 섹션 타입에 어떤 레이아웃들이 있나"의 단일 출처.
*
* 캔버스 렌더러와 [디자인] 탭이 같은 표를 본다. 배리에이션을 하나 추가한다는 건
* 여기 한 줄을 더한다는 뜻이고, 그 순간 캔버스와 고르는 UI가 동시에 늘어난다.
*/
import type {IndustryType, SectionItem} from '@o2o/shared';
import {SocialCanvasSection} from '@/features/social/SocialCanvasSection';
import type {SectionVariant} from './types';
import {HeroCover} from './variants/hero/HeroCover';
import {HeroSplit} from './variants/hero/HeroSplit';
import {IntroStory} from './variants/intro/IntroStory';
import {IntroCentered} from './variants/intro/IntroCentered';
import {IntroSideBySide} from './variants/intro/IntroSideBySide';
import {InfoTable} from './variants/info/InfoTable';
import {InfoCards} from './variants/info/InfoCards';
import {InfoInline} from './variants/info/InfoInline';
import {PhotosGrid} from './variants/photos/PhotosGrid';
import {PhotosMasonry} from './variants/photos/PhotosMasonry';
import {PhotosCarousel} from './variants/photos/PhotosCarousel';
import {PhotosWithVideos} from './variants/photos/PhotosWithVideos';
import {MapDetailed} from './variants/map/MapDetailed';
import {MapCompact} from './variants/map/MapCompact';
import {ItineraryTickets} from './variants/itinerary/ItineraryTickets';
import {VideoFrame} from './variants/video/VideoFrame';
import {LocalGuide} from './variants/local/LocalGuide';
import {WeatherSection} from './variants/weather/WeatherSection';
import {FaqAccordion} from './variants/faq/FaqAccordion';
import {FaqOpenList} from './variants/faq/FaqOpenList';
import {FaqTwoColumn} from './variants/faq/FaqTwoColumn';
import {RoomsCarousel} from './variants/rooms/RoomsCarousel';
import {RoomsGrid} from './variants/rooms/RoomsGrid';
import {RoomsList} from './variants/rooms/RoomsList';
import {BookingCard} from './variants/booking/BookingCard';
import {BookingBanner} from './variants/booking/BookingBanner';
import {MenuList} from './variants/menu/MenuList';
import {MenuGrid} from './variants/menu/MenuGrid';
import {MenuPriceTable} from './variants/menu/MenuPriceTable';
import {SpaceZones} from './variants/space/SpaceZones';
import {SpaceList} from './variants/space/SpaceList';
import {InquiryCta} from './variants/inquiry/InquiryCta';
import {InquiryForm} from './variants/inquiry/InquiryForm';
import {ProgramsCards} from './variants/programs/ProgramsCards';
import {ProgramsTable} from './variants/programs/ProgramsTable';
import {ExhibitionGallery} from './variants/exhibition/ExhibitionGallery';
import {ExhibitionNotice} from './variants/exhibition/ExhibitionNotice';
// 붙여넣기 아이템 — 데이터가 fact 가 아니라 사장님이 넣은 JSON 에서 온다(dataSpec.ts).
import {SongsTurntable} from './variants/songs/SongsTurntable';
import {DailyCalendar} from './variants/daily/DailyCalendar';
import {PeopleFilmstrip} from './variants/people/PeopleFilmstrip';
import {ChronicleRail} from './variants/chronicle/ChronicleRail';
import {ReadingRail} from './variants/reading/ReadingRail';
import {PostcardStack} from './variants/postcard/PostcardStack';
import {QuizFlip} from './variants/quiz/QuizFlip';
export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
/*
* 히어로.
*
* ★ 발행본이 실제로 해석하는 것과 **1:1 이어야 한다**(`site/src/sections/HeroSection.tsx`).
* 예전에는 에디토리얼·풀스크린·타이포 셋을 더 보여줬는데 발행본에 짝이 없어서,
* 무엇을 골라도 발행본은 기본(표지)으로 나왔다 — 고르는 의미가 없는 선택지였다.
* 여기 늘릴 때는 발행본 쪽 분기도 같이 늘린다.
*/
hero: [
{
id: 'hero.cover',
name: '표지',
description: '사진 한 장 위, 상호는 좌하단. 사진의 피사체를 글자가 가리지 않는다.',
thumb: 'fullbleed',
Component: HeroCover,
isDefault: true,
},
{
id: 'hero.split',
name: '좌우 분할',
description: '왼쪽 글 · 오른쪽 사진. 무엇을 파는 곳인지 글로 먼저 읽힌다.',
thumb: 'split',
Component: HeroSplit,
},
],
intro: [
{
id: 'intro.story',
name: '스토리',
description: '넓은 사진 아래로 문단이 흐르는 잡지식 구성.',
thumb: 'stack',
Component: IntroStory,
isDefault: true,
},
{
id: 'intro.centered',
name: '가운데 정렬',
description: '짧은 문단 하나. 가장 조용하고 짧다.',
thumb: 'centered',
Component: IntroCentered,
},
{
id: 'intro.side-by-side',
name: '사진 옆 글',
description: '세로 사진 옆에 문단과 특징 목록.',
thumb: 'split',
Component: IntroSideBySide,
},
],
info: [
{
id: 'info.table',
name: '표',
description: '라벨과 값을 선으로 나눈다. 항목이 많아도 안 흔들린다.',
thumb: 'table',
Component: InfoTable,
isDefault: true,
},
{
id: 'info.cards',
name: '카드',
description: '항목마다 한 장. 모바일에서 훑기 좋다.',
thumb: 'cards',
Component: InfoCards,
},
{
id: 'info.inline',
name: '한 줄 요약',
description: '값들을 이어 붙여 가장 좁게. 정보가 적을 때.',
thumb: 'compact',
Component: InfoInline,
},
],
photos: [
{
id: 'photos.grid',
name: '정사각 격자',
description: '같은 크기로 정렬. 장수가 많을수록 정돈돼 보인다.',
thumb: 'grid',
Component: PhotosGrid,
isDefault: true,
},
{
id: 'photos.masonry',
name: '벽돌쌓기',
description: '원본 비율 그대로 어긋나게. 사진을 잘라내지 않는다.',
thumb: 'masonry',
Component: PhotosMasonry,
},
{
id: 'photos.carousel',
name: '가로 슬라이드',
description: '큰 사진을 한 장씩. 장수가 적고 한 장이 좋을 때.',
thumb: 'carousel',
Component: PhotosCarousel,
},
{
id: 'photos.with-videos',
name: '사진 + 영상',
description: '격자 갤러리 아래 YouTube 영상까지. 영상이 더 잘 팔릴 때.',
thumb: 'stack',
Component: PhotosWithVideos,
},
],
map: [
{
id: 'map.detailed',
name: '상세 안내',
description: '주소 + 거점별 소요시간 표 + 주차/충전. 차로 오는 손님이 많을 때.',
thumb: 'table',
Component: MapDetailed,
isDefault: true,
},
{
id: 'map.compact',
name: '주소만',
description: '주소 한 줄과 길찾기 버튼. 도심처럼 찾기 쉬운 곳.',
thumb: 'compact',
Component: MapCompact,
},
],
// ★ 전체/탭/요약 세 개를 하나로 통일했다(2026-09-07). 저장된 옛 id(local.tabs·local.compact)는
// resolveVariant 가 기본값으로 떨어뜨리므로 기존 사이트가 깨지지 않는다.
local: [
{
id: 'local.guide',
name: '가이드',
description: '맛집 · 명소 · 축제를 도보 시간으로 걸러 카드로 넘겨 본다.',
thumb: 'stack',
Component: LocalGuide,
isDefault: true,
},
],
weather: [
{
id: 'weather.card',
name: '날씨 카드',
description: '현재 기온과 날씨 상태, 추천 문구를 한 장에 보여준다.',
thumb: 'compact',
Component: WeatherSection,
isDefault: true,
},
],
faq: [
{
id: 'faq.accordion',
name: '아코디언',
description: '질문만 보이고 누르면 답이 열린다. 화면이 짧다.',
thumb: 'accordion',
Component: FaqAccordion,
isDefault: true,
},
{
id: 'faq.open-list',
name: '펼친 목록',
description: '답까지 전부 열어 둔다. 훑고 지나가게 하고 싶을 때.',
thumb: 'list',
Component: FaqOpenList,
},
{
id: 'faq.two-column',
name: '2단',
description: '넓은 화면에서 두 줄씩. 세로 길이가 반으로 준다.',
thumb: 'two-column',
Component: FaqTwoColumn,
},
],
rooms: [
{
id: 'rooms.carousel',
name: '가로 슬라이드',
description: '모바일에서 한 칸씩 스와이프. 객실 2~4개일 때 자연스럽다.',
thumb: 'carousel',
Component: RoomsCarousel,
isDefault: true,
},
{
id: 'rooms.grid',
name: '격자',
description: '전부 한눈에. 객실이 많고 비교해서 고르게 할 때.',
thumb: 'cards',
Component: RoomsGrid,
},
{
id: 'rooms.list',
name: '가로 목록',
description: '사진 왼쪽 · 설명 오른쪽. 객실 설명이 길 때.',
thumb: 'list',
Component: RoomsList,
},
],
booking: [
{
id: 'booking.card',
name: '안내 카드',
description: '가운데 놓인 조용한 카드. 흐름을 끊지 않는다.',
thumb: 'centered',
Component: BookingCard,
isDefault: true,
},
{
id: 'booking.banner',
name: '띠배너',
description: '브랜드 색으로 화면을 가로지르는 강한 유도.',
thumb: 'banner',
Component: BookingBanner,
},
],
menu: [
{
id: 'menu.list',
name: '메뉴판',
description: '이름과 가격을 한 줄씩. 종이 메뉴판처럼.',
thumb: 'list',
Component: MenuList,
isDefault: true,
},
{
id: 'menu.grid',
name: '카드 격자',
description: '설명을 넉넉히. 시그니처가 몇 개뿐일 때.',
thumb: 'cards',
Component: MenuGrid,
},
{
id: 'menu.price-table',
name: '가격표',
description: '점선으로 이름과 가격만. 품목이 많을 때.',
thumb: 'table',
Component: MenuPriceTable,
},
],
space: [
{
id: 'space.zones',
name: '구역 카드',
description: '사진과 함께 자리별로. 좌석 구성이 궁금한 곳에.',
thumb: 'cards',
Component: SpaceZones,
isDefault: true,
},
{
id: 'space.list',
name: '목록',
description: '사진 없이 이름과 설명만. 사진이 부실할 때.',
thumb: 'list',
Component: SpaceList,
},
],
inquiry: [
{
id: 'inquiry.cta',
name: '연락 카드',
description: '전화 · 카카오로 바로. 폼을 받을 준비가 안 됐을 때.',
thumb: 'centered',
Component: InquiryCta,
isDefault: true,
},
{
id: 'inquiry.form',
name: '문의 양식',
description: '인원 · 일정 · 연락처를 받는 신청서.',
thumb: 'form',
Component: InquiryForm,
},
],
programs: [
{
id: 'programs.cards',
name: '카드',
description: '소요시간 · 대상까지 한 장씩. 처음 오는 손님이 고르기 쉽다.',
thumb: 'cards',
Component: ProgramsCards,
isDefault: true,
},
{
id: 'programs.table',
name: '비교표',
description: '시간 · 대상 · 가격을 열로. 조건을 견줘 고르는 곳에.',
thumb: 'table',
Component: ProgramsTable,
},
],
social: [{id: 'social.cards', name: '함께 발행한 소식', description: '승인해 SNS에 게재한 최신 소식. 홈페이지 맨 아래에 표시합니다.', thumb: 'cards', Component: SocialCanvasSection, isDefault: true}],
songs: [
{
id: 'songs.turntable',
name: '턴테이블',
description: '도넛판을 눌러 얹으면 판이 돈다. 곡 3~8개일 때 가장 좋다.',
thumb: 'carousel',
Component: SongsTurntable,
isDefault: true,
},
],
daily: [
{
id: 'daily.calendar',
name: '일력',
description: '오늘 날짜에 맞는 한 장이 자동으로 펼쳐진다. 매일 바뀌니 다시 올 이유가 된다.',
thumb: 'compact',
Component: DailyCalendar,
isDefault: true,
},
],
people: [
{
id: 'people.filmstrip',
name: '필름 스트립',
description: '프레임 하나가 인물 하나. 사진이 없으면 이름 활자가 대신 들어간다.',
thumb: 'carousel',
Component: PeopleFilmstrip,
isDefault: true,
},
],
chronicle: [
{
id: 'chronicle.rail',
name: '가로 연표',
description: '연도가 큰 활자로 서고 사건이 붙는다. 붉은 점이 도시를 바꾼 해다.',
thumb: 'timeline',
Component: ChronicleRail,
isDefault: true,
},
],
reading: [
{
id: 'reading.posts',
name: '읽기 카드',
description: '한 슬라이드에 한 꼭지. 발행본은 이 중 5~6개만 매번 무작위로 세운다.',
thumb: 'carousel',
Component: ReadingRail,
isDefault: true,
},
],
postcard: [
{
id: 'postcard.stack',
name: '엽서 뒷면',
description: '우표 자리와 소인이 찍힌 뒷면 한 장. [복사] 로 손님이 그대로 퍼 간다.',
thumb: 'carousel',
Component: PostcardStack,
isDefault: true,
},
],
quiz: [
{
id: 'quiz.flip',
name: '시험지 플립',
description: '앞면은 질문, 뒤집으면 힌트와 출처. 정답은 두지 않는다.',
thumb: 'cards',
Component: QuizFlip,
isDefault: true,
},
],
exhibition: [
{
id: 'exhibition.gallery',
name: '전시 갤러리',
description: '전시 사진을 크게 깔고 관람 정보를 붙인다.',
thumb: 'grid',
Component: ExhibitionGallery,
isDefault: true,
},
{
id: 'exhibition.notice',
name: '관람 공지',
description: '사진 없이 안내 문구만. 일정이 자주 바뀌는 곳에.',
thumb: 'timeline',
Component: ExhibitionNotice,
},
],
video: [
{
id: 'video.frame',
name: '영상 한 편',
description: '표지를 먼저 보여주고 누르면 재생한다. 세로 영상(쇼츠)은 세로 틀로 나간다.',
thumb: 'fullbleed',
Component: VideoFrame,
isDefault: true,
},
],
itinerary: [
{
id: 'itinerary.tickets',
name: '승차권',
description: '절취선 뚫린 표가 줄줄이. 표 한 장이 정거장 하나이고 마지막에 완주 도장이 찍힌다.',
thumb: 'carousel',
Component: ItineraryTickets,
isDefault: true,
},
],
};
/** 그 업종에서 고를 수 있는 배리에이션. 업종 제한이 걸린 건 걸러낸다. */
export function variantsFor(sectionType: string, industry: IndustryType): SectionVariant[] {
const all = SECTION_VARIANTS[sectionType] ?? [];
return all.filter((v) => !v.industries || v.industries.includes(industry));
}
/** 그 타입의 기본 배리에이션. isDefault 가 없으면 첫 번째. */
export function defaultVariant(sectionType: string): SectionVariant | undefined {
const all = SECTION_VARIANTS[sectionType] ?? [];
return all.find((v) => v.isDefault) ?? all[0];
}
/**
* 실제로 그릴 배리에이션.
*
* 저장된 variantId 가 지워졌거나(배리에이션을 뺐거나) 업종이 바뀌어 못 쓰게 됐어도
* 기본값으로 떨어진다 — 빈 화면 대신 무언가는 반드시 그린다.
*/
export function resolveVariant(
section: SectionItem,
industry: IndustryType,
): SectionVariant | undefined {
const available = variantsFor(section.type, industry);
const picked = available.find((v) => v.id === section.variantId);
return picked ?? defaultVariant(section.type);
}

View File

@ -1,76 +0,0 @@
/**
* 캔버스 배리에이션 계약.
*
* 섹션 하나 = "무엇을 보여줄지"(데이터) + "어떻게 보여줄지"(배리에이션).
* 데이터는 이 props 로 고정하고, 배리에이션만 갈아끼운다 —
* 그래야 사장님이 레이아웃을 바꿔도 내용이 날아가지 않는다.
*/
import type {ComponentType, ReactNode} from 'react';
import type {
IndustryType,
InfoField,
PhotoItem,
SectionItem,
TemplateItem,
} from '@o2o/shared';
import type {WeatherLocation} from '@/stores/builder';
/** 모든 배리에이션이 동일하게 받는 입력. 배리에이션은 이 밖의 것을 요구하지 않는다. */
export interface SectionRenderProps {
/** 섹션 메타(id · type · name · variantId). 프레임의 앵커 id 와 배지 라벨이 여기서 나온다. */
section: SectionItem;
industryId: IndustryType;
storeName: string;
location: string;
weatherLocation?: WeatherLocation;
template: TemplateItem;
infoFields: InfoField[];
photos: PhotoItem[];
/** 에디터에서 선택된 섹션인가(미리보기 모드면 항상 false). */
isSelected: boolean;
onSelect: () => void;
}
export type SectionVariantComponent = ComponentType<SectionRenderProps>;
/** 디자인 탭이 카드로 그리는 배리에이션 한 칸. */
export interface SectionVariant {
/** 전역 유일. `<sectionType>.<slug>` 규칙 — 저장된 값이 어느 섹션 것인지 눈으로 읽힌다. */
id: string;
/** 사장님에게 보이는 이름. 개발 용어 말고 "왼쪽 사진 · 오른쪽 글" 같은 말로 쓴다. */
name: string;
/** 언제 이걸 고르면 좋은지 한 줄. */
description: string;
/** 와이어프레임 썸네일 키(`thumbs.tsx`). 글보다 그림이 빨리 읽힌다. */
thumb: ThumbKey;
Component: SectionVariantComponent;
/** 특정 업종에서만 의미 있는 배리에이션이면 여기로 좁힌다. 비우면 전 업종 공통. */
industries?: IndustryType[];
/** '기본'으로 표시할 배리에이션. 타입당 하나. */
isDefault?: boolean;
}
export type ThumbKey =
| 'fullbleed'
| 'split'
| 'stack'
| 'centered'
| 'grid'
| 'masonry'
| 'carousel'
| 'list'
| 'table'
| 'cards'
| 'accordion'
| 'two-column'
| 'banner'
| 'form'
| 'timeline'
| 'compact';
/** 섹션 프레임의 배경 톤. 배리에이션마다 다른 톤을 골라 리듬을 만든다. */
export type SectionTone = 'paper' | 'white' | 'tint' | 'dark';
export interface SectionFrameSlots {
children: ReactNode;
}

View File

@ -1,40 +0,0 @@
/**
* 예약 · 띠배너 — 브랜드 색으로 화면을 가로지르는 강한 전환 유도.
* 예약이 유일한 목표인 사이트에 맞는다(대신 자주 쓰면 광고처럼 보인다).
*
* ★ 문구에서 "실시간 캘린더" 를 걷어냈다(2026-09-09). 우리는 실시간 재고를 갖지 않는다
* (PRODUCT.md 6절). 발행본이 하는 일 그대로 — 날짜·시간을 고르고 예약 창구로 잇는다 —
* 을 말한다. 에디터가 보여주는 것이 곧 발행될 것이어야 한다.
*/
import {Calendar} from 'lucide-react';
import {CtaLink, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {bookingHref} from '../common';
export function BookingBanner(props: SectionRenderProps) {
const {section, isSelected, onSelect, industryId, storeName, template} = props;
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} padded={false}>
<div className="px-4 py-10 sm:px-6 sm:py-14" style={{backgroundColor: template.colors.primary}}>
<div className="mx-auto flex max-w-[880px] flex-col items-center justify-between gap-5 text-center sm:flex-row sm:text-left">
<div className="space-y-1.5 text-white">
<h3 className="serif-title text-lg font-bold sm:text-2xl">
{section.name}
</h3>
<p className="text-xs opacity-90 sm:text-sm">
{section.body || section.description || `${storeName} · 날짜와 시간을 고르고 예약 창구로 이어집니다`}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<CtaLink href={bookingHref(industryId, storeName)} variant="pill">
<Calendar className="size-3.5" />
<span>날짜 · 시간 고르기</span>
</CtaLink>
</div>
</div>
</div>
</SectionFrame>
);
}

View File

@ -1,148 +0,0 @@
/**
* 예약 · 카드 — **발행본의 예약 안내를 그대로 미리 보여준다.**
*
* ★ 왜 갈아엎었나 (2026-09-09)
* 이 카드는 "네이버 실시간 온라인 예약 / 캘린더에서 바로 확정 예약" 을 그리고 있었다.
* 우리는 실시간 예약을 하지 않는다(PRODUCT.md 6절 — 재고도 결제도 갖지 않는다).
* 게다가 발행본은 날짜·시간을 고르는 화면인데 캔버스만 다른 문구를 보여줘서,
* 사장님이 편집 화면에서 본 것과 발행된 사이트가 서로 다른 물건이었다.
* **에디터가 보여주는 것이 곧 발행될 것**이어야 한다.
*
* ★ 여기서는 **누르지 않는다.** 캔버스의 클릭은 "이 섹션을 고른다" 는 뜻이라
* 상태를 가진 위젯을 넣으면 선택과 싸운다. 첫 칸이 골라진 모습으로 고정해 두고,
* 실제 동작은 발행본(`solution/site` StayBookingDemo)이 한다.
*
* ★ 시간 칸은 **체크인 fact 가 있을 때만** 그린다. 발행본과 같은 규칙이다 —
* 체크인이 16:00 인데 미리보기가 14:00 을 보여주면 사장님은 그 선택지가 생긴 줄 안다.
*/
import {CalendarDays, Clock, Minus, Phone, Plus} from 'lucide-react';
import type {InfoField} from '@o2o/shared';
import {SectionBody, SectionFrame, SectionHeading} from '../../primitives';
import type {SectionRenderProps} from '../../types';
const WEEKDAY_LABEL = ['일', '월', '화', '수', '목', '금', '토'] as const;
/** 오늘부터 7칸. 캔버스는 브라우저에서만 도므로 날짜를 그대로 그려도 된다
* (발행본은 정적 HTML 이라 날짜를 굽지 않는다 — StayBookingDemo 머리주석). */
function nextDays(count: number) {
const today = new Date();
return Array.from({length: count}, (_, index) => {
const date = new Date(today.getFullYear(), today.getMonth(), today.getDate() + index);
return {day: date.getDate(), weekday: WEEKDAY_LABEL[date.getDay()]};
});
}
/** 확인된 값만 읽는다 — 캔버스도 발행본과 같은 규칙이다. */
function verified(fields: InfoField[], id: string): string | undefined {
const field = fields.find((f) => f.id === id);
if (!field || field.requiresVerification || !field.value?.trim()) return undefined;
return field.value.trim();
}
function arrivalSlots(checkIn?: string): string[] {
const match = /(\d{1,2})\s*:\s*(\d{2})/.exec(checkIn ?? '');
if (!match) return [];
const start = Number(match[1]);
if (!Number.isFinite(start)) return [];
return Array.from({length: 4}, (_, i) => start + i)
.filter((hour) => hour <= 23)
.map((hour) => `${String(hour).padStart(2, '0')}:${match[2]}`);
}
export function BookingCard(props: SectionRenderProps) {
const {section, isSelected, onSelect, template, infoFields} = props;
const days = nextDays(7);
const checkIn = verified(infoFields, 'check_in_time');
const slots = arrivalSlots(checkIn);
const phone = verified(infoFields, 'phone');
const accent = template.colors.primary;
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="white">
<SectionBody>
<SectionHeading title={section.name} subtitle={section.description} colors={template.colors} />
<div className="mx-auto max-w-lg overflow-hidden rounded-xl border border-stone-200/80 bg-white shadow-2xs">
<p className="flex items-center gap-2 border-b border-stone-200/80 px-4 py-3 text-xs font-bold text-stone-900">
<CalendarDays className="size-3.5 text-stone-400" />
<span>날짜 · 시간 선택</span>
</p>
<div className="space-y-4 p-4">
<div>
<p className="mb-1.5 text-[11px] font-semibold text-stone-400">날짜</p>
<ul className="flex gap-1.5 overflow-hidden">
{days.map((day, index) => (
<li
key={day.day}
className="flex w-11 shrink-0 flex-col items-center gap-0.5 rounded-lg border px-2 py-1.5 text-xs"
style={
index === 0
? {borderColor: accent, backgroundColor: accent, color: '#fff'}
: {borderColor: 'rgb(231 229 228)', backgroundColor: 'rgb(250 250 249)'}
}
>
<span className="text-[10px] opacity-70">{day.weekday}</span>
<span className="font-bold">{day.day}</span>
</li>
))}
</ul>
</div>
{slots.length > 0 && (
<div>
<p className="mb-1.5 flex items-center gap-1 text-[11px] font-semibold text-stone-400">
<Clock className="size-3" />
<span>도착 예정 시간 (체크인 {checkIn} 이후)</span>
</p>
<ul className="flex flex-wrap gap-1.5">
{slots.map((time, index) => (
<li
key={time}
className="rounded-md border px-2.5 py-1 text-xs"
style={
index === 0
? {borderColor: accent, backgroundColor: accent, color: '#fff'}
: {borderColor: 'rgb(231 229 228)', backgroundColor: 'rgb(250 250 249)'}
}
>
{time}
</li>
))}
</ul>
</div>
)}
<div className="flex items-center justify-between">
<p className="text-[11px] font-semibold text-stone-400">인원</p>
<div className="flex items-center gap-2 text-stone-500">
<span className="flex size-6 items-center justify-center rounded-md border border-stone-200">
<Minus className="size-3" />
</span>
<span className="w-9 text-center text-sm font-bold text-stone-900">2명</span>
<span className="flex size-6 items-center justify-center rounded-md border border-stone-200">
<Plus className="size-3" />
</span>
</div>
</div>
<div
className="rounded-lg px-4 py-2.5 text-center text-xs font-bold text-white"
style={{backgroundColor: accent}}
>
예약 요청 확인하기
</div>
{/* ★ 실제 예약이 되는 창구. 발행본과 같은 순서다 — 데모 다음에 진짜 창구가 온다. */}
{phone && (
<p className="flex items-center justify-center gap-1.5 text-[11px] text-stone-500">
<Phone className="size-3" />
<span>전화 예약 {phone}</span>
</p>
)}
</div>
</div>
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,131 +0,0 @@
/**
* 시간의 골목 — 가로 연표.
*
* 연도가 큰 활자로 서고 사건이 그 아래 붙는다. 레일 위의 **붉은 점은 도시의 성격이 바뀐 해**다 —
* 점의 색이 장식이 아니라 정보라서, 범례를 한 줄 달아 그 약속을 밝힌다.
* ★ 연도가 없는 항목은 지어내 끼우지 않고 레일 끝으로 민다. 연표에서 틀린 순서는 바로 들킨다.
*/
import {useMemo} from 'react';
import {Rail, SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type ChronicleItem} from '@o2o/shared';
import {
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ITEM_INK,
ParseError,
PasteHint,
SourceLine,
} from '../items/common';
import '../items/items.css';
function Milestone({item, isLast}: {item: ChronicleItem; isLast: boolean}) {
const turning = item.turning === true;
return (
<div className="w-[228px] shrink-0 snap-center">
<p
className="leading-none"
style={{fontFamily: ITEM_HEADING, fontSize: 34, color: turning ? ITEM_ACCENT : ITEM_INK}}
>
{item.year ?? '연도 미상'}
</p>
{/* 레일 — 점이 선 위에 놓여야 '흐르는 시간 위의 한 해'로 읽힌다 */}
<div className="relative my-3 h-3">
<i
aria-hidden
className="absolute top-1/2 left-0 border-t"
// 마지막 해에서 선이 끊긴다 — 레일이 화면 밖으로 이어지는 것처럼 보이면 뒤가 더 있는 줄 안다.
style={{borderColor: ITEM_BORDER, right: isLast ? 'calc(100% - 12px)' : 0}}
/>
<i
aria-hidden
className="absolute top-1/2 left-0 size-3 -translate-y-1/2 rounded-full border-2"
style={{
backgroundColor: turning ? ITEM_ACCENT : ITEM_CARD,
borderColor: turning ? ITEM_ACCENT : ITEM_BORDER,
}}
/>
</div>
<div className="space-y-1.5 pr-5">
<h4 className="text-base font-bold" style={{fontFamily: ITEM_BODY}}>
{item.title}
</h4>
{item.summary && (
<p className="text-[13px] leading-relaxed opacity-75" style={{fontFamily: ITEM_BODY}}>
{item.summary}
</p>
)}
{item.place && <p className="text-[11px] opacity-60">지금 이 자리 · {item.place}</p>}
<SourceLine source={item.source} verified={item.verified} />
</div>
</div>
);
}
export function ChronicleRail(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<ChronicleItem>(section.type, section.data);
// 연도가 있는 것부터 오름차순, 없는 것은 뒤로. 붙여넣은 순서를 믿지 않는다.
const items = useMemo(
() =>
[...parsed.items].sort((a, b) => {
if (a.year == null) return b.year == null ? 0 : 1;
if (b.year == null) return -1;
return a.year - b.year;
}),
[parsed.items],
);
const turningCount = items.filter((item) => item.turning === true).length;
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : items.length === 0 ? (
<PasteHint label="시간의 골목" />
) : (
<div className="space-y-3">
<p className="flex items-center gap-1.5 text-[11px] opacity-60">
<i
aria-hidden
className="size-2.5 rounded-full"
style={{backgroundColor: ITEM_ACCENT}}
/>
도시의 성격이 바뀐 해 {turningCount}개 · 전체 {items.length}개
</p>
<Rail label="연표" gap={0}>
{items.map((item, index) => (
<Milestone
key={`${item.year ?? 'x'}-${item.title}-${index}`}
item={item}
isLast={index === items.length - 1}
/>
))}
</Rail>
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -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)}`;
}

View File

@ -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 (
<div
className={`w4-paper relative w-[254px] shrink-0 border shadow-[5px_6px_0_rgba(27,26,21,.13)] transition-all ${
muted ? 'scale-[.93] opacity-45' : ''
}`}
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
>
{/* 뜯어낸 자국 */}
<div
className="w4-perf h-3.5 border-b border-dashed"
style={{['--tear' as string]: ITEM_SURFACE, borderColor: ITEM_BORDER}}
/>
<div className="absolute inset-x-0 top-0 flex justify-around px-10">
{[0, 1, 2].map((i) => (
<i
key={i}
className="-mt-1 size-2 rounded-full border"
style={{backgroundColor: ITEM_SURFACE, borderColor: ITEM_BORDER}}
/>
))}
</div>
<div className="border-b px-5 pb-3 pt-6 text-center" style={{borderColor: ITEM_BORDER}}>
<p className="text-[11px] tracking-[0.2em] opacity-60">{month}月</p>
<p className="mt-1 leading-none" style={{fontFamily: ITEM_HEADING, fontSize: 72, color: ITEM_ACCENT}}>
{String(Number(day) || day)}
</p>
{dow && <p className="mt-1.5 text-[11px] tracking-[0.3em] opacity-75">{dow}曜</p>}
</div>
<div className="space-y-2 px-5 pb-5 pt-4">
{item.category && (
<p className="text-[10px] tracking-[0.16em]" style={{color: ITEM_ACCENT}}>
{item.category}
</p>
)}
<h3 className="text-base font-bold leading-snug" style={{fontFamily: ITEM_BODY}}>
{item.title}
</h3>
{item.body && (
<p className="text-[13px] leading-relaxed opacity-75" style={{fontFamily: ITEM_BODY}}>
{item.body}
</p>
)}
{item.tags && item.tags.length > 0 && (
<p className="text-[10px] opacity-60">{item.tags.join(' ')}</p>
)}
<div className="border-t border-dashed pt-2.5" style={{borderColor: ITEM_BORDER}}>
<SourceLine source={item.source} verified={item.verified} />
</div>
</div>
</div>
);
}
export function DailyCalendar(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<DailyItem>(section.type, section.data);
const items = useMemo(
() => [...parsed.items].sort((a, b) => a.monthDay.localeCompare(b.monthDay)),
[parsed.items],
);
// 오늘 날짜가 목록에 없으면 오늘 **이후 첫 장**을 편다 — 지난 날짜를 오늘로 내걸지 않는다.
const startIndex = useMemo(() => {
if (items.length === 0) return 0;
const today = todayMonthDay();
const exact = items.findIndex((item) => item.monthDay === today);
if (exact >= 0) return exact;
const upcoming = items.findIndex((item) => item.monthDay >= today);
return upcoming >= 0 ? upcoming : 0;
}, [items]);
const [offset, setOffset] = useState(0);
const index = items.length > 0 ? (startIndex + offset + items.length * 100) % items.length : 0;
const at = (delta: number) => items[(index + delta + items.length) % items.length];
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : items.length === 0 ? (
<PasteHint label="오늘의 한 장" />
) : (
<div className="space-y-3">
<div className="w4-scroll flex items-start justify-center gap-5 overflow-x-auto pb-3">
{items.length > 1 && <CalendarPage item={at(-1)} muted />}
<CalendarPage item={items[index]} />
{items.length > 1 && <CalendarPage item={at(1)} muted />}
</div>
{items.length > 1 && (
<CarouselNav
onPrev={() => setOffset((v) => v - 1)}
onNext={() => setOffset((v) => v + 1)}
label="날짜"
/>
)}
<p className="text-center text-[11px] opacity-60">
오늘 날짜에 맞는 장이 자동으로 펼쳐집니다 · 총 {items.length}장
{parsed.unverified > 0 && ` · 확인 필요 ${parsed.unverified}장`}
</p>
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -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 (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="white">
<SectionBody>
<SectionHeading
title={section.name}
subtitle={section.description}
colors={template.colors}
/>
<div className="grid grid-cols-2 gap-2.5 sm:gap-3">
{items.map((photo) => (
<PhotoTile key={photo.id} photo={photo} fit="landscape" />
))}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{hours && (
<FeatureCard
icon={Clock}
title="관람 시간"
description={hours.value}
colors={template.colors}
/>
)}
{fee && (
<FeatureCard
icon={Ticket}
title="관람료"
description={fee.value}
colors={template.colors}
/>
)}
</div>
</SectionBody>
</SectionFrame>
);
}

View File

@ -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 (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="narrow">
<SectionHeading
variant="eyebrow"
eyebrow="NOTICE"
title={section.name}
trailing={<Info className="size-5 text-stone-400" />}
colors={template.colors}
/>
<div className="space-y-3 rounded-2xl border border-stone-200/80 bg-white p-5 shadow-2xs">
<p className="text-xs leading-relaxed text-stone-600">
{section.body || `${storeName}의 상설 전시와 아트숍은 별도 예약 없이 관람하실 수 있습니다. 기획 전시 일정은 변경될 수 있으니 방문 전 확인해 주세요.`}
</p>
<dl className="space-y-2 border-t border-stone-100 pt-3">
{notices.map((field) => (
<div key={field.id} className="flex gap-3 text-xs">
<dt className="w-20 shrink-0 font-semibold text-stone-400">{field.label}</dt>
<dd className="font-medium text-stone-800" style={{color: template.colors.text}}>
{field.value}
</dd>
</div>
))}
</dl>
</div>
</SectionBody>
</SectionFrame>
);
}

View File

@ -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<number | null>(0);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody>
<SectionHeading
title={section.name}
subtitle={section.description}
colors={template.colors}
/>
<ListCard>
{faqs.map((faq, idx) => {
const isOpen = openIdx === idx;
return (
<div key={faq.question}>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setOpenIdx(isOpen ? null : idx);
}}
aria-expanded={isOpen}
style={{color: template.colors.text}}
className="flex w-full cursor-pointer items-center justify-between gap-3 p-4 text-left text-xs font-bold text-stone-900 hover:bg-stone-50 sm:text-sm"
>
<span className="flex items-center gap-2.5">
<span
className="serif-title font-normal text-stone-400"
style={{color: template.colors.accent}}
>
Q.
</span>
<span>{faq.question}</span>
</span>
{isOpen ? (
<ChevronUp className="size-4 shrink-0 text-stone-400" />
) : (
<ChevronDown className="size-4 shrink-0 text-stone-400" />
)}
</button>
{isOpen && (
<div className="border-t border-stone-50 bg-stone-50/40 px-4 pb-4 pt-1 text-xs leading-relaxed text-stone-600 sm:text-sm">
{faq.answer}
</div>
)}
</div>
);
})}
</ListCard>
</SectionBody>
</SectionFrame>
);
}

View File

@ -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 (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody>
<SectionHeading
title={section.name}
subtitle={section.description}
colors={template.colors}
/>
<dl className="space-y-4">
{faqs.map((faq) => (
<div
key={faq.question}
className="space-y-1.5 rounded-xl border border-stone-200/80 bg-white p-4 shadow-2xs"
>
<dt
className="flex items-start gap-2 text-xs font-bold text-stone-900 sm:text-sm"
style={{color: template.colors.text}}
>
<span
className="serif-title font-normal text-stone-400"
style={{color: template.colors.accent}}
>
Q.
</span>
<span>{faq.question}</span>
</dt>
<dd className="flex items-start gap-2 text-xs leading-relaxed text-stone-600 sm:text-sm">
<span className="serif-title font-normal text-stone-300">A.</span>
<span>{faq.answer}</span>
</dd>
</div>
))}
</dl>
</SectionBody>
</SectionFrame>
);
}

View File

@ -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 (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="white">
<SectionBody width="wide">
<SectionHeading
variant="centered"
eyebrow="FAQ"
title={section.name}
subtitle={section.description}
colors={template.colors}
/>
<dl className="grid grid-cols-1 gap-x-8 gap-y-5 md:grid-cols-2">
{faqs.map((faq) => (
<div key={faq.question} className="space-y-1.5 border-t border-stone-200/80 pt-4">
<dt
className="text-xs font-bold text-stone-900 sm:text-sm"
style={{color: template.colors.text}}
>
{faq.question}
</dt>
<dd className="text-xs leading-relaxed text-stone-600">{faq.answer}</dd>
</div>
))}
</dl>
</SectionBody>
</SectionFrame>
);
}

View File

@ -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 [];
}

View File

@ -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;
// 사진이 없으면 <img> 없이 어두운 판만 남긴다 — 시연용 사진으로 자리를 메우지 않는다.
const cover = primaryPhoto(photos);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} padded={false}>
<div
className="relative flex w-full select-none items-end overflow-hidden"
style={{
backgroundColor: 'var(--tpl-inverse, #1c1917)',
color: 'var(--tpl-bg, #ffffff)',
minHeight: 'clamp(24rem, 62vh, 36rem)',
}}
>
{cover?.url && (
<div className="absolute inset-0 z-0">
<img src={cover.url} alt={storeName} className="size-full object-cover object-center" />
<div className="absolute inset-0" style={{background: SCRIM}} />
</div>
)}
<div className="shell relative z-10 pb-10 pt-24 sm:pb-12">
{location && (
<p className="mb-3 inline-flex items-center gap-1 text-[length:var(--fs-xs)] opacity-80">
<MapPin className="size-3.5" aria-hidden />
{location}
</p>
)}
{/* ★ tpl-title — 굵기를 템플릿이 정한다. 간판체(Gugi)는 굵기가 한 벌뿐이라
700 을 주면 브라우저가 가짜 볼드를 씌워 획이 뭉갠다. */}
<h1
className="tpl-title leading-[1.15]"
style={{fontSize: 'var(--fs-display)'}}
>
{storeName}
</h1>
{/*
* ★ 여기에 `section.description` 을 쓰지 않는다.
* 그 칸은 에디터의 섹션 설명("상단 메인 비주얼과 대표 문구")이라, 캔버스에 그리면
* 사장님 화면에 우리 UI 안내문이 자기 소개문처럼 박힌다. 실제로 그렇게 나갔다.
* 대표 문구는 수집·생성된 값이 생기기 전까지 **아무것도 그리지 않는다.**
*/}
<p className="mt-6 inline-flex items-center gap-1 text-[length:var(--fs-sm)] opacity-80">
객실 보기
<ChevronDown className="size-4" aria-hidden />
</p>
</div>
</div>
</SectionFrame>
);
}

View File

@ -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 (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} padded={false}>
<div className="p-6 sm:p-10" style={{backgroundColor: template.colors.bg}}>
<div className="mx-auto grid max-w-4xl grid-cols-1 items-center gap-6 md:grid-cols-12">
<div className="space-y-4 md:col-span-7">
<span
className="inline-flex items-center gap-2 rounded px-2.5 py-1 text-xs font-semibold"
style={{
backgroundColor: `${template.colors.primary}12`,
color: template.colors.primary,
}}
>
<span
className="size-1.5 rounded-full"
style={{backgroundColor: template.colors.primary}}
/>
{section.description || `${config.name} · ${location}`}
</span>
<h1
className="text-2xl font-extrabold leading-tight tracking-tight sm:text-4xl"
style={{color: template.colors.text}}
>
{storeName}
</h1>
</div>
<div className="md:col-span-5">
<div className="relative aspect-4/3 overflow-hidden rounded-lg border border-slate-200/60 shadow-xs">
<img src={cover?.url} alt={storeName} className="size-full object-cover" />
</div>
</div>
</div>
</div>
</SectionFrame>
);
}

View File

@ -1,41 +0,0 @@
/**
* 기본 정보 · 카드 — 항목마다 한 장씩. 모바일에서 손가락으로 훑기 좋다.
*/
import {
HiddenFieldsNotice,
InfoRow,
SectionBody,
SectionFrame,
SectionHeading,
} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {TODAY_KO} from '../common';
export function InfoCards(props: SectionRenderProps) {
const {section, isSelected, onSelect, template, infoFields} = props;
const visible = infoFields.filter((f) => !f.requiresVerification || f.isVerified);
const hidden = infoFields.filter((f) => f.requiresVerification && !f.isVerified).length;
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint">
<SectionBody>
<SectionHeading
title={section.name}
subtitle={section.description}
trailing={<HiddenFieldsNotice count={hidden} />}
colors={template.colors}
/>
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
{visible.map((field) => (
<InfoRow key={field.id} field={field} variant="card" colors={template.colors} />
))}
</div>
<div className="text-right">
<span className="font-mono text-[11px] text-stone-400">* {TODAY_KO} 기준 정보입니다.</span>
</div>
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,41 +0,0 @@
/**
* 기본 정보 · 한 줄 요약 — 값들을 문장처럼 이어 붙인 가장 좁은 형태.
* 정보가 적거나, 아래 섹션으로 빨리 넘기고 싶을 때.
*/
import {
HiddenFieldsNotice,
InfoRow,
SectionBody,
SectionFrame,
SectionHeading,
} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {TODAY_KO} from '../common';
export function InfoInline(props: SectionRenderProps) {
const {section, isSelected, onSelect, template, infoFields} = props;
const visible = infoFields.filter((f) => !f.requiresVerification || f.isVerified);
const hidden = infoFields.filter((f) => f.requiresVerification && !f.isVerified).length;
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="white">
<SectionBody width="narrow">
<SectionHeading
variant="minimal"
title={section.name}
subtitle={section.description}
trailing={<HiddenFieldsNotice count={hidden} />}
colors={template.colors}
/>
<div className="flex flex-wrap gap-x-5 gap-y-2.5 rounded-xl border border-stone-200/70 bg-stone-50/60 p-4">
{visible.map((field) => (
<InfoRow key={field.id} field={field} variant="inline" colors={template.colors} />
))}
</div>
<span className="block font-mono text-[11px] text-stone-400">* {TODAY_KO} 기준</span>
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,42 +0,0 @@
/**
* 기본 정보 · 표 — 라벨과 값을 선으로 나눈 가장 읽기 쉬운 형태.
* 항목이 많아도 눈이 안 흔들린다.
*/
import {
HiddenFieldsNotice,
InfoRow,
SectionBody,
SectionFrame,
SectionHeading,
} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {TODAY_KO} from '../common';
export function InfoTable(props: SectionRenderProps) {
const {section, isSelected, onSelect, template, infoFields} = props;
const visible = infoFields.filter((f) => !f.requiresVerification || f.isVerified);
const hidden = infoFields.filter((f) => f.requiresVerification && !f.isVerified).length;
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="white">
<SectionBody>
<SectionHeading
title={section.name}
subtitle={section.description}
trailing={<HiddenFieldsNotice count={hidden} />}
colors={template.colors}
/>
<div className="overflow-hidden rounded-xl border border-stone-200/80 bg-stone-50/60 [&>*+*]:border-t [&>*+*]:border-stone-200/70">
{visible.map((field) => (
<InfoRow key={field.id} field={field} variant="row" colors={template.colors} />
))}
</div>
<div className="text-right">
<span className="font-mono text-[11px] text-stone-400">* {TODAY_KO} 기준 정보입니다.</span>
</div>
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,49 +0,0 @@
/**
* 문의 · 연락 카드 — 전화 / 카카오 / 인스타로 바로 보낸다.
* 폼을 받을 준비가 안 된 사장님에게는 이쪽이 사고가 없다.
*/
import {Phone} from 'lucide-react';
import {
EmptyStateNotice,
SectionBody,
SectionFrame,
SectionHeading,
} from '../../primitives';
import type {SectionRenderProps} from '../../types';
export function InquiryCta(props: SectionRenderProps) {
const {section, isSelected, onSelect, storeName, template} = props;
// ★ 연락처(place_links)는 SectionRenderProps 로 흘러오지 않는다. 값이 생기기 전까지
// 버튼째 숨긴다 — 시연용 번호를 붙이면 남의 가게로 전화가 간다.
const phone = props.infoFields.find((f) => f.id === 'phone')?.value?.trim();
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="white">
<SectionBody width="narrow">
<SectionHeading variant="centered" title={section.name} subtitle={section.description} />
<div className="flex flex-col items-center gap-3 rounded-2xl border border-stone-200/80 bg-stone-50/70 p-6 text-center">
<p className="text-xs leading-relaxed text-stone-600">
{section.body || `${storeName} 단체·대관 문의는 아래로 연락 주시면 담당자가 안내해 드립니다.`}
</p>
{phone ? (
<div className="flex flex-wrap items-center justify-center gap-2">
<a
href={`tel:${phone}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1.5 rounded-xl px-5 py-2.5 text-xs font-semibold text-white shadow-xs transition-opacity hover:opacity-90"
style={{backgroundColor: template.colors.primary}}
>
<Phone className="size-3.5" />
<span>{phone}</span>
</a>
</div>
) : (
<EmptyStateNotice>연락처는 아직 준비 중입니다.</EmptyStateNotice>
)}
</div>
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,71 +0,0 @@
/**
* 문의 · 신청서 — 인원/일정/연락처를 받는 양식.
*
* ★ 캔버스는 미리보기다. 여기서 제출은 실제로 나가지 않는다 —
* 그 사실을 화면에 적어 둬야 사장님이 "접수됐겠지" 하고 넘어가지 않는다.
*/
import {Send} from 'lucide-react';
import {SectionBody, SectionFrame, SectionHeading} from '../../primitives';
import type {SectionRenderProps} from '../../types';
const FIELDS = [
{id: 'name', label: '성함 / 단체명', placeholder: '홍길동 / ○○기업 총무팀'},
{id: 'contact', label: '연락처', placeholder: '010-0000-0000'},
{id: 'date', label: '희망 일자', placeholder: '2026-09-14'},
{id: 'headcount', label: '예상 인원', placeholder: '20명'},
];
export function InquiryForm(props: SectionRenderProps) {
const {section, isSelected, onSelect, template} = props;
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint">
<SectionBody width="narrow">
<SectionHeading title={section.name} subtitle={section.description} />
<div
onClick={(e) => e.stopPropagation()}
role="presentation"
className="space-y-3 rounded-2xl border border-stone-200/80 bg-white p-5 shadow-2xs"
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{FIELDS.map((field) => (
<label key={field.id} className="space-y-1">
<span className="block text-[11px] font-semibold text-stone-500">
{field.label}
</span>
<input
type="text"
placeholder={field.placeholder}
className="w-full rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-xs focus:bg-white focus:outline-none"
/>
</label>
))}
</div>
<label className="block space-y-1">
<span className="block text-[11px] font-semibold text-stone-500">문의 내용</span>
<textarea
rows={3}
placeholder="행사 성격, 필요한 준비물, 예산 등을 적어 주세요."
className="w-full resize-none rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-xs focus:bg-white focus:outline-none"
/>
</label>
<button
type="button"
className="inline-flex w-full cursor-not-allowed items-center justify-center gap-1.5 rounded-xl px-5 py-2.5 text-xs font-semibold text-white opacity-90"
style={{backgroundColor: template.colors.primary}}
>
<Send className="size-3.5" />
<span>문의 보내기</span>
</button>
<p className="text-center text-[10px] text-stone-400">
미리보기 화면입니다 — 실제 접수는 발행 후 동작합니다.
</p>
</div>
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,35 +0,0 @@
/**
* 소개 · 가운데 정렬 — 짧은 문단 하나로 끝내는 가장 조용한 구성.
*/
import {SectionBody, SectionFrame, SectionHeading} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {EmptyStateNotice} from '../../primitives';
import {introParagraph} from '../common';
export function IntroCentered(props: SectionRenderProps) {
const intro = props.section.body?.trim() || introParagraph(props.industryId, props.infoFields);
const {section, isSelected, onSelect, storeName, template} = props;
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="narrow">
<SectionHeading
variant="centered"
eyebrow="ABOUT US"
title={section.name}
subtitle={section.description}
colors={template.colors}
/>
{intro ? (
<p className="text-center text-xs leading-relaxed text-stone-600 sm:text-sm">
{intro}
</p>
) : (
<EmptyStateNotice>
아직 소개문이 없습니다 — 확인된 정보만 근거로 씁니다.
</EmptyStateNotice>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,51 +0,0 @@
/**
* 소개 · 사진 옆 글 — 왼쪽 세로 사진, 오른쪽 문단 + 특징 목록.
* 사진과 설명을 같은 눈높이에서 함께 읽히고 싶을 때.
*/
import {EmptyStateNotice, SectionBody, SectionFrame, SectionHeading} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {primaryPhoto, introParagraph, introSummary} from '../common';
export function IntroSideBySide(props: SectionRenderProps) {
const intro =
props.section.body?.trim() ||
introSummary(props.industryId, props.infoFields) ||
introParagraph(props.industryId, props.infoFields);
const {section, isSelected, onSelect, storeName, template, photos} = props;
const shot = photos[1] ?? primaryPhoto(photos);
// 사진이 없으면 회색 판만 남기고 <img> 는 렌더하지 않는다.
const shotUrl = shot?.url;
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody>
<SectionHeading
variant="eyebrow"
eyebrow="ABOUT"
title={section.name}
subtitle={section.description}
/>
<div className="grid grid-cols-1 items-center gap-6 md:grid-cols-12">
<div className="md:col-span-5">
<div className="relative aspect-3/4 overflow-hidden rounded-2xl border border-stone-200/80 bg-stone-100">
{shotUrl && (
<img src={shotUrl} alt={`${storeName} 공간`} className="size-full object-cover" />
)}
</div>
</div>
<div className="space-y-4 md:col-span-7">
{intro ? (
<p className="text-xs leading-relaxed text-stone-700 sm:text-sm">{intro}</p>
) : (
<EmptyStateNotice>
아직 소개문이 없습니다 — 확인된 정보만 근거로 씁니다.
</EmptyStateNotice>
)}
</div>
</div>
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,45 +0,0 @@
/**
* 소개 · 스토리 — 넓은 사진 한 장 아래로 문단이 흐르는 잡지식 구성.
*/
import {EmptyStateNotice, SectionBody, SectionFrame, SectionHeading} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {introParagraph, introSummary} from '../common';
export function IntroStory(props: SectionRenderProps) {
const intro =
props.section.body?.trim() ||
introSummary(props.industryId, props.infoFields) ||
introParagraph(props.industryId, props.infoFields);
const {section, isSelected, onSelect, storeName, template, photos} = props;
const shotUrl = photos[1]?.url;
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody>
<SectionHeading
title={section.name}
subtitle={section.description}
colors={template.colors}
/>
<div className="relative aspect-16/9 overflow-hidden rounded-xl border border-stone-200/80 bg-stone-100 sm:aspect-21/9">
{shotUrl && (
<img src={shotUrl} alt={`${storeName} 공간`} className="size-full object-cover" />
)}
</div>
<div className="space-y-4">
{intro ? (
<p className="font-sans text-xs leading-relaxed text-stone-700 sm:text-base">
{intro}
</p>
) : (
<EmptyStateNotice>
아직 소개문이 없습니다 — 확인된 정보만 근거로 씁니다.
</EmptyStateNotice>
)}
</div>
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,182 +0,0 @@
/**
* 붙여넣기 아이템이 공유하는 조각 — 색·서체 토큰, 출처 각주, 캐러셀 화살표.
*
* ★ 색과 서체를 여기에 박지 않는다. 전부 템플릿 토큰(`--tpl-*`)을 읽는다 —
* 한때 이 파일은 갱지색·주(朱)잉크·간판체를 hex 와 폰트명으로 들고 있었고,
* 그 바람에 사장님이 템플릿을 매거진으로 바꿔도 아이템만 레트로로 남았다.
* 아이템은 '레트로 전용 부품'이 아니라 **어느 템플릿에나 들어가는 섹션**이다.
* ★ 질감(도넛판 홈·톱니·펀치 구멍)은 남긴다 — 그건 색이 아니라 물건의 생김새다.
* 질감의 색도 토큰을 따라가게 `items.css` 에서 var() 로 받는다.
*/
import {ChevronLeft, ChevronRight, MousePointerClick} from 'lucide-react';
import {cn} from '@/lib/utils';
import type {DataSource, DataVerified} from '@o2o/shared';
/** 제목 서체. 템플릿이 정한다(심플=고딕 · 매거진=명조 · 레트로=간판체). */
export const ITEM_HEADING = 'var(--tpl-font-heading, inherit)';
/** 본문 서체. */
export const ITEM_BODY = 'var(--tpl-font-body, inherit)';
/** 섹션 바탕. */
export const ITEM_SURFACE = 'var(--tpl-surface, #faf7f2)';
/** 카드·종이 면. 섹션 바탕보다 한 단계 앞이다. */
export const ITEM_CARD = 'var(--tpl-card, #ffffff)';
/** 글자색. */
export const ITEM_INK = 'var(--tpl-text, #1c1917)';
/** 선·테두리. */
export const ITEM_BORDER = 'var(--tpl-border, #d6d3d1)';
/**
* 강조 — 연표의 전환점, 일력의 날짜, 소인 도장.
*
* ★ 템플릿 강조색을 **그대로 쓰지 않고 둘레 글자색을 섞는다.** 팔레트에 따라 accent 가
* 바탕과 거의 같은 밝기일 수 있는데(연한 베이지·파스텔), 그러면 큰 날짜 숫자와 배지가
* 화면에서 사라진다. 섞으면 색조는 남고 대비만 확보된다 —
* currentColor 라서 어두운 면에서는 밝은 쪽으로, 밝은 면에서는 어두운 쪽으로 붙는다.
*/
export const ITEM_ACCENT = 'color-mix(in oklab, var(--tpl-accent, #bf2f1b) 70%, currentColor)';
/** 어두운 면(필름·플립보드·턴테이블). */
export const ITEM_INVERSE = 'var(--tpl-inverse, #1c1917)';
/**
* 어두운 면 위의 글자색.
*
* ★ 흰색을 박지 않는다. 템플릿 배경색을 글자색으로 쓰면 어떤 팔레트에서도 대비가 선다 —
* 밝은 템플릿이면 밝은 글자, 어두운 템플릿이면 그 템플릿이 정한 밝은 면 색이다.
*/
export const ITEM_INVERSE_INK = 'var(--tpl-bg, #ffffff)';
/**
* 출처 한 줄 + 확신 배지.
*
* ★ 확인되지 않은 값을 숨기지 않고 **드러낸다.** 숨기면 사장님이 그게 미검증인 줄 모르고 발행한다.
* ★ 배지 색만은 토큰이 아니다 — '확인/확인필요'는 디자인이 아니라 신호다. 템플릿 색을 따라가면
* 팔레트에 따라 경고가 안 보이는 색이 될 수 있다.
*/
export function SourceLine({
source,
verified,
tone = 'light',
}: {
source?: DataSource;
verified?: DataVerified;
tone?: 'light' | 'dark';
}) {
if (!source && !verified) return null;
return (
<p
className={cn('flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px]', tone === 'dark' ? 'opacity-70' : 'opacity-65')}
>
{verified && (
<span
className="border px-1.5 py-px"
// ★ 신호색(초록/호박)은 유지하되 둘레 글자색을 섞는다 — 어두운 면에서는 밝아지고
// 밝은 면에서는 진해져, 어떤 팔레트에서도 배지가 읽힌다.
style={{
color: `color-mix(in oklab, ${verified === '확인' ? '#2a6053' : '#b07d10'} 62%, currentColor)`,
borderColor: 'currentColor',
}}
>
{verified}
</span>
)}
{source?.name && (
<span>
출처 ·{' '}
{source.url ? (
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
className="underline underline-offset-2"
>
{source.name}
</a>
) : (
source.name
)}
</span>
)}
</p>
);
}
/**
* 앞뒤 버튼 한 쌍.
*
* ★ 가로 슬라이더에는 쓰지 않는다 — 그건 `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 (
<div className="flex justify-end gap-1.5">
<button
type="button"
className="flex size-8 items-center justify-center border transition-opacity hover:opacity-70"
style={style}
onClick={stop(onPrev)}
aria-label={`${label} 이전`}
>
<ChevronLeft className="size-4" />
</button>
<button
type="button"
className="flex size-8 items-center justify-center border transition-opacity hover:opacity-70"
style={style}
onClick={stop(onNext)}
aria-label={`${label} 다음`}
>
<ChevronRight className="size-4" />
</button>
</div>
);
}
/**
* 붙여넣을 JSON 이 아직 없을 때 — 어디로 가야 하는지 말해 준다.
*
* ★ 그냥 "준비 중"이라고 두면 사장님은 이 섹션이 자동으로 채워지는 줄 알고 기다린다.
* ★ 이건 발행되지 않는 **에디터 안내**다. 그래서 템플릿 색이 아니라 관리자 색을 그대로 쓴다.
*/
export function PasteHint({label}: {label: string}) {
return (
<div className="flex flex-col items-center gap-2 border border-dashed border-stone-400/70 bg-white/50 px-4 py-8 text-center">
<MousePointerClick className="size-4 text-stone-400" />
<p className="text-xs font-semibold text-stone-600">{label} 내용이 아직 없습니다</p>
<p className="max-w-[34ch] text-[11px] leading-relaxed text-stone-500">
오른쪽 [콘텐츠] 탭에서 프롬프트를 복사해 ChatGPT 에 넣고, 받은 JSON 을 붙여넣으면 바로 여기에 그려집니다.
</p>
</div>
);
}
/** 파싱이 깨졌을 때. 캔버스를 비우지 않고 왜 안 그려지는지 그 자리에 말한다(에디터 안내라 관리자 색). */
export function ParseError({message}: {message: string}) {
return (
<div className="border border-dashed border-red-400/70 bg-red-50/70 px-4 py-5 text-center">
<p className="text-xs font-semibold text-red-700">붙여넣은 JSON 을 읽지 못했습니다</p>
<p className="mt-1.5 text-[11px] leading-relaxed text-red-600">{message}</p>
</div>
);
}

View File

@ -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;
}
}

View File

@ -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<ItineraryItem>(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 (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : items.length === 0 ? (
<PasteHint label="추천 일정" />
) : (
<div className="space-y-7">
{groups.map((duration) => {
const dayItems = items.filter((item) =>
duration === undefined
? !item.duration?.trim()
: item.duration?.trim() === duration,
);
if (dayItems.length === 0) return null;
return (
<div key={duration ?? 'etc'} className="space-y-4">
{duration && (
<h3 className="text-lg" style={{fontFamily: ITEM_HEADING}}>
{duration}
</h3>
)}
{dayItems.map((item, index) => (
<div key={`${item.name}-${index}`} className="space-y-2.5">
<div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
<h4 className="text-base font-bold" style={{fontFamily: ITEM_HEADING}}>
{item.name}
</h4>
<span className="text-[11px] opacity-60">
{[item.audience, item.season].filter(Boolean).join(' · ')}
</span>
{/* 계절을 적으면 손님 화면에서는 그 계절에만 나간다 — 사장님이 알아야 한다. */}
{item.season && (
<span className="text-[11px] opacity-60">
{inSeason(item.season, live)
? '· 지금 나갑니다'
: `· 손님 화면에는 ${item.season} 에만 나갑니다`}
</span>
)}
</div>
{item.why && (
<p className="max-w-[52ch] text-[13px] leading-relaxed opacity-80">
{item.why}
</p>
)}
{itineraryDays(item).map((entry, dayIndex) => (
<div key={`${entry.label}-${dayIndex}`} className="space-y-1.5">
{/* ★ 출발지 — 첫날에만. 일정 데이터에는 없다(프롬프트가 정거장으로
못 넣게 막는다) — 빌더가 이미 아는 상호명을 그 자리에서 얹는다. */}
{dayIndex === 0 && placeName && (
<p className="text-[11px] opacity-50">
{placeName}에서 출발 · {entry.day.from}
</p>
)}
{entry.label && (
<p className="text-[11px] font-bold" style={{color: ITEM_ACCENT}}>
{entry.label}
<span className="ml-2 font-normal opacity-60">
{entry.day.from}–{entry.day.to}
</span>
</p>
)}
<Rail label={`${item.name} ${entry.label || '정거장'}`}>
{entry.day.stops.map((planned, stopIndex) => (
<Ticket
key={`${planned.stop.name}-${stopIndex}`}
planned={planned}
no={stopIndex + 1}
isLast={stopIndex === entry.day.stops.length - 1}
/>
))}
</Rail>
{entry.day.dropped > 0 && (
<p className="text-[11px] opacity-60">
밤 9시를 넘기는 {entry.day.dropped}곳은 화면에서 뺐습니다.
</p>
)}
</div>
))}
<SourceLine source={item.source} verified={item.verified} />
</div>
))}
</div>
);
})}
</div>
)}
</SectionBody>
</SectionFrame>
);
}
/** 승차권 한 장. 마지막 장에는 완주 도장(아티팩트의 스탬프투어 형식). */
function Ticket({planned, no, isLast}: {planned: PlannedStop; no: number; isLast: boolean}) {
const {stop, time, until, move} = planned;
return (
<article
className="w4-perf relative w-[214px] shrink-0 border"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
>
<div
className="flex items-baseline justify-between gap-2 border-b border-dashed px-3.5 py-2"
style={{borderColor: ITEM_BORDER}}
>
<span className="text-[10px] tracking-[0.2em] opacity-60">
NO.{String(no).padStart(2, '0')}
</span>
<span className="text-[13px] font-bold tabular-nums" style={{fontFamily: ITEM_HEADING}}>
{time}
<span className="opacity-55">–{until}</span>
</span>
</div>
<div className="space-y-1 px-3.5 pb-3.5 pt-2.5">
<h5 className="text-sm font-bold" style={{fontFamily: ITEM_HEADING}}>
{stop.name}
</h5>
{stop.note && <p className="text-[12px] leading-relaxed opacity-80">{stop.note}</p>}
{move > 0 && <p className="text-[10px] opacity-55">↓ 앞 칸에서 {move}분 이동</p>}
</div>
{isLast && (
<span
className="absolute bottom-2.5 right-2.5 grid size-10 rotate-[-12deg] place-items-center rounded-full border-2 text-[10px] font-bold"
style={{borderColor: ITEM_ACCENT, color: ITEM_ACCENT}}
aria-hidden
>
완주
</span>
)}
</article>
);
}

View File

@ -1,63 +0,0 @@
/**
* 지역 가이드 · 카테고리 한 묶음 — 제목(아이콘) → 도보 시간 필터 → 카드 캐러셀.
*
* 맛집·명소·축제가 전부 이 모양이다. 필터 상태는 묶음마다 따로 든다 —
* 맛집을 "5분 이내"로 걸렀는데 명소까지 줄어들면 사장님이 명소가 사라진 줄 안다.
*/
import {useState, type ReactNode} from 'react';
import type {LucideIcon} from 'lucide-react';
import type {TemplateItem} from '@o2o/shared';
import {EmptyStateNotice, PlaceCarousel, WalkFilterTabs} from '../../primitives';
import type {GuideCard} from './types';
import {matchesWalkFilter, type WalkFilterKey} from './walking';
export function LocalCategorySection({
icon: Icon,
title,
cards,
emptyText,
colors,
renderLeading,
linkUrl,
}: {
icon: LucideIcon;
title: string;
cards: GuideCard[];
emptyText: string;
colors: TemplateItem['colors'];
renderLeading?: (card: GuideCard) => ReactNode;
linkUrl?: (query: string) => string;
}) {
const [filter, setFilter] = useState<WalkFilterKey>('all');
const visible = cards.filter((c) => matchesWalkFilter(filter, c.distanceMeters));
return (
<section className="space-y-4">
<h3
className="flex items-center gap-2 text-sm font-bold text-stone-900"
style={{color: colors.text}}
>
<Icon className="size-4" style={{color: colors.primary}} />
<span>{title}</span>
</h3>
{cards.length === 0 ? (
<EmptyStateNotice>{emptyText}</EmptyStateNotice>
) : (
<>
<WalkFilterTabs
distances={cards.map((c) => c.distanceMeters)}
value={filter}
onChange={setFilter}
colors={colors}
/>
{visible.length === 0 ? (
<EmptyStateNotice>이 거리 안에는 아직 없습니다. 다른 구간을 눌러 보세요.</EmptyStateNotice>
) : (
<PlaceCarousel cards={visible} colors={colors} renderLeading={renderLeading} linkUrl={linkUrl} />
)}
</>
)}
</section>
);
}

View File

@ -1,93 +0,0 @@
/**
* 지역 가이드 — 맛집 · 명소 · 축제를 카테고리별 "도보 시간 필터 + 카드 캐러셀"로 세로 나열.
*
* ★ 2026-09-07 결정으로 전체/탭/요약 세 배리에이션을 이 하나로 통일했다.
* 데이터가 이미 카테고리별로 나뉘어 있어 "탭으로 갈아끼우기"라는 구분이 캐러셀 구조에서는
* 의미가 없어졌고, 요약(6곳)은 필터가 그 역할을 대신한다.
* ★ 값은 서버(local.place_contents)가 소유한다 — 업장 좌표 반경으로 받은 것만 그린다.
* 시연용 목록으로 자리를 메우지 않는다(남의 동네 맛집이 사장님 사이트에 붙는다).
*/
import {Calendar, MapPin, Utensils} from 'lucide-react';
import {useLocalGuide} from '@/hooks/useLocalGuide';
import {Pill, SectionBody, SectionFrame, SectionHeading, naverMapSearch} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import type {FestivalCard, GuideCard} from './types';
import {LocalCategorySection} from './LocalCategorySection';
import {WALK_DISCLAIMER} from './walking';
/** "2026년 9월 3일 갱신" — 서버 수집 시각(ISO)에서. 없으면 비운다(지어내지 않는다). */
function syncedLabel(iso?: string): string | undefined {
if (!iso) return undefined;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return undefined;
return `${d.getFullYear()}년 ${d.getMonth() + 1}월 ${d.getDate()}일 갱신`;
}
/** "전북특별자치도 군산시 …" → "군산시". 시군구 토큰이 없으면 첫 토큰. */
function regionLabel(location: string): string {
const tokens = location.split(' ').filter(Boolean);
return tokens[1] ?? tokens[0] ?? '주변';
}
export function LocalGuide(props: SectionRenderProps) {
const {section, isSelected, onSelect, industryId, location, template} = props;
const {foods, spots, festivals, syncedAt} = useLocalGuide();
const colors = template.colors;
const isStay = industryId === 'stay';
const synced = syncedLabel(syncedAt);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="wide" className="space-y-10">
<SectionHeading
title={section.name || '주변 안내'}
subtitle={`${regionLabel(location)} 지역의 맛집 · 명소 안내입니다.`}
trailing={synced && <span className="text-xs text-stone-400">{synced}</span>}
colors={colors}
/>
{/* ★ 맛집은 사진이 없으면 목록에서 뺀다 (2026-09-15, 사장님 지시) — 명소는 이름 활자로 대신하는
규칙을 유지하지만, 맛집은 실제 사이트(LocalGuideSection)와 맞춰 사진 없는 곳을 숨긴다. */}
{/* ★ 맛집·명소 카드는 네이버지도 검색으로 연다 (2026-09-15, 사장님 지시) — 실제 사이트와 동일. */}
<LocalCategorySection
icon={Utensils}
title="주변 맛집"
cards={foods.filter((card) => card.imageUrl)}
emptyText="주변 맛집·카페 추천은 아직 준비 중입니다."
colors={colors}
linkUrl={naverMapSearch}
/>
<LocalCategorySection
icon={MapPin}
title="주변 명소"
cards={spots}
emptyText="주변 명소 추천은 아직 준비 중입니다."
colors={colors}
linkUrl={naverMapSearch}
/>
{isStay && (
<LocalCategorySection
icon={Calendar}
title="주변 축제 & 문화 행사"
cards={festivals}
emptyText="주변 축제 안내는 아직 준비 중입니다."
colors={colors}
renderLeading={(card: GuideCard) => {
const fest = card as FestivalCard;
return (
<div className="flex flex-wrap items-center gap-1.5">
{fest.month && <Pill tone="accent">{fest.month}</Pill>}
{fest.period && <Pill mono>{fest.period}</Pill>}
</div>
);
}}
/>
)}
<p className="text-[11px] leading-relaxed text-stone-400">{WALK_DISCLAIMER}</p>
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,126 +0,0 @@
/**
* 실시간 날씨 + AI 추천 한 덩어리.
*
* 지역 가이드 배리에이션 셋이 전부 이걸 쓴다 — 날씨 조회를 배리에이션마다 하면
* 레이아웃을 바꿀 때마다 API 를 다시 때린다.
*/
import {ArrowUpRight, Cloud, CloudRain, Snowflake, Sparkles, Sun} from 'lucide-react';
import {useWeather} from '@/hooks/useWeather';
import {cn} from '@/lib/utils';
import type {WeatherLocation} from '@/stores/builder';
import {EmptyStateNotice} from '../../primitives';
function WeatherIcon({condition, className}: {condition: string; className?: string}) {
switch (condition) {
case '맑음':
return <Sun className={cn('text-amber-500', className)} />;
case '구름많음':
return <Cloud className={cn('text-stone-500', className)} />;
case '비':
return <CloudRain className={cn('text-blue-500', className)} />;
case '눈':
return <Snowflake className={cn('text-cyan-400', className)} />;
default:
return <Cloud className={cn('text-stone-400', className)} />;
}
}
export function WeatherPanel({
location,
weatherLocation,
dense,
}: {
location: string;
weatherLocation?: WeatherLocation;
dense?: boolean;
}) {
const observed = useWeather(
weatherLocation?.regionCode,
weatherLocation?.latitude,
weatherLocation?.longitude,
);
const weather = weatherLocation ? observed : null;
if (dense) {
return (
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-stone-200/80 bg-white px-4 py-3 shadow-2xs">
{weather ? (
<>
<WeatherIcon condition={weather.condition} className="size-5" />
<span className="text-lg font-extrabold tracking-tight text-stone-900">
{weather.temperature || '21.4'}°C
</span>
<span className="text-xs font-semibold text-stone-600">
{weather.condition || '구름많음'}
</span>
{weather.stale && <span className="text-[10px] text-amber-700">최근 관측값</span>}
<span className="min-w-0 flex-1 truncate text-[11px] text-stone-500">
{weather.recommendation}
</span>
</>
) : (
<span className="text-[11px] text-stone-500">
실시간 날씨는 아직 준비 중입니다.
</span>
)}
</div>
);
}
return (
<div className="space-y-4 rounded-2xl border border-stone-200/80 bg-white p-5 shadow-2xs sm:p-6">
<div className="flex flex-col justify-between gap-3 border-b border-stone-100 pb-4 sm:flex-row sm:items-center">
<div className="flex items-center gap-3.5">
<div className="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-stone-100/80">
<WeatherIcon condition={weather?.condition ?? ''} className="size-6" />
</div>
<div>
<span className="block text-[11px] font-semibold text-stone-400">
{location || '제주 애월읍'} 실시간 기상
</span>
{weather ? (
<div className="mt-0.5 flex items-baseline gap-2">
<span className="font-sans text-2xl font-extrabold tracking-tight text-stone-900 sm:text-3xl">
{weather.temperature || '21.4'}°C
</span>
<span className="text-xs font-semibold text-stone-700 sm:text-sm">
{weather.condition || '구름많음'}
</span>
{weather.stale && (
<span className="text-[10px] font-medium text-amber-700">최근 관측값</span>
)}
</div>
) : (
<span className="mt-0.5 block text-xs text-stone-500">
실시간 날씨는 아직 준비 중입니다.
</span>
)}
</div>
</div>
<a
href={`https://search.naver.com/search.naver?query=${encodeURIComponent(`${location} 날씨`)}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-1 self-start rounded-lg border border-stone-200/60 bg-stone-50 px-3 py-1.5 text-xs text-stone-500 transition-colors hover:bg-stone-100 hover:text-stone-900 sm:self-auto"
>
<span>주간 날씨 예보</span>
<ArrowUpRight className="size-3.5" />
</a>
</div>
{weather ? (
<div className="flex items-start gap-2.5 rounded-xl border border-stone-200/60 bg-stone-50/70 p-3.5 text-xs leading-relaxed text-stone-700 sm:text-sm">
<Sparkles className="mt-0.5 size-4 shrink-0 text-amber-600" />
<div>
<strong className="font-semibold text-stone-900">AI 맞춤 추천: </strong>
<span>{weather.recommendation}</span>
</div>
</div>
) : (
<EmptyStateNotice>날씨 기반 AI 추천은 아직 준비 중입니다.</EmptyStateNotice>
)}
</div>
);
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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 (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint">
<SectionBody width="narrow">
<SectionHeading
variant="minimal"
title={section.name}
subtitle={section.description}
trailing={
<span className="flex items-center gap-1 text-[11px] text-stone-500">
<MapPin className="size-3.5" />
{location}
</span>
}
colors={template.colors}
/>
{address ? (
<AddressCard
address={address}
searchName={storeName}
variant="bar"
colors={template.colors}
/>
) : (
<EmptyStateNotice>주소는 아직 준비 중입니다.</EmptyStateNotice>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -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 (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="white">
<SectionBody>
<SectionHeading title={section.name} subtitle={section.description} colors={template.colors} />
{address ? (
<AddressCard
address={address}
searchName={storeName}
colors={template.colors}
/>
) : (
<EmptyStateNotice>주소는 아직 준비 중입니다.</EmptyStateNotice>
)}
{isStay && travelTimes.length === 0 && (
<EmptyStateNotice>주요 거점 소요시간은 아직 준비 중입니다.</EmptyStateNotice>
)}
{isStay && travelTimes.length > 0 && (
<div className="overflow-hidden rounded-xl border border-stone-200/80 bg-white shadow-2xs">
<div className="flex items-center gap-2 border-b border-stone-200 bg-stone-50 p-3.5 text-xs font-bold text-stone-800">
<Car className="size-4 text-stone-700" />
<span>주요 거점 이동 소요시간</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="border-b border-stone-100 bg-stone-50/50 font-medium text-stone-500">
<tr>
<th className="p-3">목적지</th>
<th className="p-3">차량</th>
<th className="p-3">도보 / 대중교통</th>
<th className="hidden p-3 sm:table-cell">거리</th>
</tr>
</thead>
<tbody className="text-stone-700 [&>tr+tr]:border-t [&>tr+tr]:border-stone-100">
{travelTimes.map((row) => (
<tr key={row.destination} className="hover:bg-stone-50/50">
<td className="p-3 font-bold text-stone-900">{row.destination}</td>
<td
className="p-3 font-semibold"
style={{color: template.colors.primary}}
>
{row.byCar}
</td>
<td className="p-3">{row.byWalkOrTransit}</td>
<td className="hidden p-3 font-mono text-stone-400 sm:table-cell">
{row.distance}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<FeatureCard
icon={CarFront}
title="전용 주차장 완비"
description="객실동 입구 전용 주차 공간 4대 무료 이용 가능"
colors={template.colors}
/>
<FeatureCard
icon={Zap}
title="전기차 완속 충전기"
description="스테이 전용 7kW 완속 충전기 1기 구비"
iconClassName="text-emerald-600"
colors={template.colors}
/>
</div>
</SectionBody>
</SectionFrame>
);
}

View File

@ -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 (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint">
<SectionBody>
<SectionHeading
variant="centered"
eyebrow="MENU"
title={section.name}
subtitle={section.description}
colors={template.colors}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{items.map((item) => (
<PriceRow key={item.name} item={item} variant="card" colors={template.colors} />
))}
</div>
</SectionBody>
</SectionFrame>
);
}

View File

@ -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 (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="white">
<SectionBody>
<SectionHeading
title={section.name}
subtitle={section.description}
colors={template.colors}
/>
<ListCard>
{items.map((item) => (
<PriceRow key={item.name} item={item} colors={template.colors} />
))}
</ListCard>
</SectionBody>
</SectionFrame>
);
}

View File

@ -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 (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="narrow">
<SectionHeading variant="minimal" title={section.name} colors={template.colors} />
<ul className="space-y-3.5">
{items.map((item) => (
<li key={item.name} className="space-y-0.5">
<div className="flex items-baseline gap-2">
<span
className="flex items-center gap-2 text-xs font-bold text-stone-900 sm:text-sm"
style={{color: template.colors.text}}
>
{item.name}
{item.tag && <Pill tone="accent">{item.tag}</Pill>}
</span>
{/* 점선 리더 — 이름과 가격 사이를 눈으로 잇는다. 선 자체는 중립 회색으로 둔다. */}
<span className="min-w-4 flex-1 translate-y-[-3px] border-b border-dotted border-stone-300" />
<span
className="font-mono text-xs font-extrabold text-stone-900 sm:text-sm"
style={{color: template.colors.primary}}
>
{item.price}
</span>
</div>
{item.desc && <p className="text-[11px] leading-relaxed text-stone-500">{item.desc}</p>}
</li>
))}
</ul>
</SectionBody>
</SectionFrame>
);
}

View File

@ -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 [];
}

View File

@ -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<PeopleItem>(section.type, section.data);
const [picked, setPicked] = useState(0);
// 항목이 줄어 인덱스가 범위를 벗어나도 첫 사람으로 떨어진다 — 빈 화면을 만들지 않는다.
const current = parsed.items[picked] ?? parsed.items[0];
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="dark">
{/* ★ 어두운 면 위의 글자색을 여기서 한 번만. 필름 구멍(w4-film-perf)도 이 색을 따라간다. */}
<SectionBody width="wide" className="text-[color:var(--tpl-bg,#fff)]">
<div className="space-y-1.5">
<p className="text-[11px] tracking-[0.28em]" style={{color: ITEM_ACCENT}}>
PORTRAIT ROLL
</p>
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-55" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : !current ? (
<PasteHint label="인물 열전" />
) : (
<div className="space-y-4">
<div
className="border"
style={{
backgroundColor: ITEM_INVERSE,
borderColor: 'color-mix(in oklab, currentColor 22%, transparent)',
}}
>
<div className="w4-film-perf h-2.5 opacity-70" />
<Rail label="인물" tone="dark" nav="overlay" gap={0.75} viewportClassName="px-3 py-3">
{parsed.items.map((person, index) => (
<button
key={`${person.name}-${index}`}
type="button"
onClick={(event) => {
event.stopPropagation();
setPicked(index);
}}
aria-current={index === picked}
className={cn(
'w-[126px] shrink-0 snap-center text-left transition-all',
// 고른 프레임만 색이 돌아온다 — 필름을 손으로 짚은 자리.
index === picked ? 'opacity-100' : 'opacity-45 grayscale',
)}
>
<span
className="grid aspect-3/4 w-full place-items-center border"
style={{
// 프레임 안쪽은 필름보다 한 단 밝게 — 색을 박지 않고 글자색을 섞어 만든다.
backgroundColor: 'color-mix(in oklab, currentColor 14%, transparent)',
borderColor:
index === picked
? ITEM_ACCENT
: 'color-mix(in oklab, currentColor 28%, transparent)',
}}
>
<span className="leading-none" style={{fontFamily: ITEM_HEADING, fontSize: 46}}>
{initialOf(person.name)}
</span>
</span>
<span className="mt-2 block truncate text-xs opacity-95">{person.name}</span>
<span className="block truncate text-[10px] opacity-60">
{[person.role, person.years].filter(Boolean).join(' · ') || '역할 미상'}
</span>
</button>
))}
</Rail>
<div className="w4-film-perf h-2.5 opacity-70" />
</div>
{/* 고른 프레임의 자막 — 프레임 안에 넣으면 이름조차 안 읽힌다 */}
<div className="space-y-2 border-l-2 pl-4" style={{borderColor: ITEM_ACCENT}}>
<h3 className="text-xl" style={{fontFamily: ITEM_HEADING}}>
{current.name}
{current.aka && <span className="ml-2 text-sm opacity-55">호 {current.aka}</span>}
</h3>
<p className="text-xs opacity-55">
{[current.role, current.years].filter(Boolean).join(' · ')}
</p>
{current.oneLine && (
<p
className="max-w-[52ch] text-sm leading-relaxed opacity-80"
style={{fontFamily: ITEM_BODY}}
>
{current.oneLine}
</p>
)}
{current.imageQuery && (
<p className="text-[10px] opacity-60">사진 검색어 · {current.imageQuery}</p>
)}
<SourceLine source={current.source} verified={current.verified} tone="dark" />
</div>
<p className="text-[11px] opacity-60">
사진이 없는 인물은 이름 활자로 대신합니다 · 총 {parsed.items.length}명
{parsed.unverified > 0 && ` · 확인 필요 ${parsed.unverified}명`}
</p>
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,45 +0,0 @@
/**
* 사진 · 가로 슬라이드 — 큰 사진을 한 장씩 넘겨 본다.
* 장수가 적고 한 장 한 장이 좋을 때 가장 세게 보인다.
*/
import {useState} from 'react';
import {
Lightbox,
PhotoTile,
Rail,
SectionBody,
SectionFrame,
SectionHeading,
} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {galleryPhotos} from '../common';
export function PhotosCarousel(props: SectionRenderProps) {
const {section, isSelected, onSelect, industryId, template, photos} = props;
const items = galleryPhotos(industryId, photos);
const [openIndex, setOpenIndex] = useState<number | null>(null);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="white">
<SectionBody width="wide">
<SectionHeading title={section.name} subtitle={section.description} colors={template.colors} />
{/* ★ 예전엔 점이 그려져 있기만 하고 아무것도 가리키지 않았다(전부 같은 색·클릭 불가).
지금 몇 번째인지는 Rail 이 숫자로 말하고, 화살표와 드래그로 실제로 넘어간다. */}
<Rail label="사진" gap={0.75}>
{items.map((photo, idx) => (
<PhotoTile
key={photo.id}
photo={photo}
fit="landscape"
onOpen={() => setOpenIndex(idx)}
className="w-[78vw] max-w-[420px] shrink-0"
/>
))}
</Rail>
</SectionBody>
<Lightbox photos={items} index={openIndex} onClose={() => setOpenIndex(null)} onMove={setOpenIndex} />
</SectionFrame>
);
}

View File

@ -1,39 +0,0 @@
/**
* 사진 · 정사각 격자 — 모든 사진을 같은 크기로. 장수가 많을수록 정돈돼 보인다.
*/
import {useState} from 'react';
import {
Lightbox,
PhotoTile,
SectionBody,
SectionFrame,
SectionHeading,
} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {galleryPhotos} from '../common';
export function PhotosGrid(props: SectionRenderProps) {
const {section, isSelected, onSelect, industryId, template, photos} = props;
const items = galleryPhotos(industryId, photos);
const [openIndex, setOpenIndex] = useState<number | null>(null);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="white">
<SectionBody>
<SectionHeading
title={section.name}
subtitle={section.description || `사진 ${items.length}장 · 클릭하면 크게 보입니다`}
colors={template.colors}
/>
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 sm:gap-3 md:grid-cols-4">
{items.map((photo, idx) => (
<PhotoTile key={photo.id} photo={photo} fit="square" onOpen={() => setOpenIndex(idx)} />
))}
</div>
</SectionBody>
<Lightbox photos={items} index={openIndex} onClose={() => setOpenIndex(null)} onMove={setOpenIndex} />
</SectionFrame>
);
}

View File

@ -1,49 +0,0 @@
/**
* 사진 · 벽돌쌓기 — 세로/가로 비율을 살려 어긋나게 쌓는다.
* 사진마다 구도가 다를 때 잘라내지 않고 보여줄 수 있다.
*/
import {useState} from 'react';
import {
Lightbox,
PhotoTile,
SectionBody,
SectionFrame,
SectionHeading,
} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {galleryPhotos} from '../common';
export function PhotosMasonry(props: SectionRenderProps) {
const {section, isSelected, onSelect, industryId, template, photos} = props;
const items = galleryPhotos(industryId, photos);
const [openIndex, setOpenIndex] = useState<number | null>(null);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody>
<SectionHeading
variant="eyebrow"
eyebrow="GALLERY"
title={section.name}
subtitle={section.description}
colors={template.colors}
/>
{/* columns 로 쌓으면 사진 높이를 자르지 않고도 빈틈 없이 채워진다. */}
<div className="columns-2 gap-2.5 sm:columns-3 sm:gap-3 [&>*]:mb-2.5 sm:[&>*]:mb-3">
{items.map((photo, idx) => (
<PhotoTile
key={photo.id}
photo={photo}
fit="free"
onOpen={() => setOpenIndex(idx)}
className="break-inside-avoid"
/>
))}
</div>
</SectionBody>
<Lightbox photos={items} index={openIndex} onClose={() => setOpenIndex(null)} onMove={setOpenIndex} />
</SectionFrame>
);
}

View File

@ -1,169 +0,0 @@
/**
* 사진 · 사진 + 영상 — 격자 갤러리 아래에 YouTube 영상을 붙인다.
* 릴스·유튜브로 이미 알려진 곳이면 사진보다 영상이 더 잘 판다.
*/
import {useState} from 'react';
import {Plus} from 'lucide-react';
import {convertYouTubeToEmbed} from '@/lib/youtube';
import {
EmptyStateNotice,
Lightbox,
PhotoTile,
SectionBody,
SectionFrame,
SectionHeading,
} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {galleryPhotos} from '../common';
import {VideoCard, type VideoItem} from './VideoCard';
export function PhotosWithVideos(props: SectionRenderProps) {
const {section, isSelected, onSelect, industryId, template, photos} = props;
const items = galleryPhotos(industryId, photos);
const [openIndex, setOpenIndex] = useState<number | null>(null);
// 영상은 사장님이 이 화면에서 직접 추가한 것만 있다 — 시연용 목록을 깔지 않는다.
const [added, setAdded] = useState<VideoItem[]>([]);
const [removedIds, setRemovedIds] = useState<string[]>([]);
const videos = added.filter((v) => !removedIds.includes(v.id));
const [playingId, setPlayingId] = useState<string | null>(null);
const [isFormOpen, setIsFormOpen] = useState(false);
const [inputTitle, setInputTitle] = useState('');
const [inputUrl, setInputUrl] = useState('');
const [errorMsg, setErrorMsg] = useState('');
const handleAddVideo = (e: React.FormEvent) => {
e.preventDefault();
setErrorMsg('');
if (!inputTitle.trim() || !inputUrl.trim()) {
setErrorMsg('제목과 YouTube URL을 모두 입력해주세요.');
return;
}
const {embedUrl, isValid} = convertYouTubeToEmbed(inputUrl);
if (!isValid || !embedUrl) {
setErrorMsg('올바른 YouTube URL 형식이 아닙니다.');
return;
}
setAdded((prev) => [
{id: `canvas-v-${Date.now()}`, title: inputTitle.trim(), videoUrl: inputUrl.trim(), embedUrl},
...prev,
]);
setInputTitle('');
setInputUrl('');
setIsFormOpen(false);
};
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody className="space-y-8">
<div className="space-y-6">
<SectionHeading title={section.name} subtitle={section.description} colors={template.colors} />
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 sm:gap-3 md:grid-cols-4">
{items.map((photo, idx) => (
<PhotoTile
key={photo.id}
photo={photo}
fit="square"
onOpen={() => setOpenIndex(idx)}
/>
))}
</div>
</div>
<div className="space-y-6">
<SectionHeading
title="영상 갤러리"
subtitle="공간의 분위기를 영상으로 미리 확인해 보세요"
trailing={
<button
type="button"
title="영상 추가"
onClick={(e) => {
e.stopPropagation();
setIsFormOpen((v) => !v);
}}
className="flex size-8 cursor-pointer items-center justify-center rounded-full border border-stone-300 bg-white text-stone-700 shadow-2xs transition-colors hover:bg-stone-100 hover:text-stone-900"
>
<Plus className="size-4" />
</button>
}
colors={template.colors}
/>
{isFormOpen && (
<div
onClick={(e) => e.stopPropagation()}
role="presentation"
className="rounded-xl border border-stone-200 bg-white p-4 shadow-xs"
>
<form onSubmit={handleAddVideo} className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<input
type="text"
value={inputTitle}
onChange={(e) => setInputTitle(e.target.value)}
placeholder="영상 제목 (예: 스테이의 아침)"
className="rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-xs focus:bg-white focus:outline-none"
/>
<input
type="text"
value={inputUrl}
onChange={(e) => setInputUrl(e.target.value)}
placeholder="YouTube URL (https://www.youtube.com/watch?v=...)"
className="rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-xs focus:bg-white focus:outline-none"
/>
</div>
{errorMsg && <p className="text-[11px] text-rose-600">{errorMsg}</p>}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setIsFormOpen(false)}
className="cursor-pointer rounded-lg px-3 py-1.5 text-xs text-stone-500 hover:bg-stone-100"
>
취소
</button>
<button
type="submit"
className="cursor-pointer rounded-lg px-4 py-1.5 text-xs font-semibold text-white"
style={{backgroundColor: template.colors.primary}}
>
등록
</button>
</div>
</form>
</div>
)}
{videos.length === 0 ? (
<EmptyStateNotice>
등록된 영상이 아직 없습니다. + 버튼으로 추가할 수 있습니다.
</EmptyStateNotice>
) : (
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
{videos.map((video) => (
<VideoCard
key={video.id}
video={video}
isPlaying={playingId === video.id}
onPlay={() => setPlayingId(video.id)}
onRemove={() => setRemovedIds((prev) => [...prev, video.id])}
/>
))}
</div>
)}
</div>
</SectionBody>
<Lightbox
photos={items}
index={openIndex}
onClose={() => setOpenIndex(null)}
onMove={setOpenIndex}
/>
</SectionFrame>
);
}

View File

@ -1,108 +0,0 @@
import {ExternalLink, Trash2} from 'lucide-react';
/**
* 영상 한 건. 원래 시연용 데이터 파일이 소유하던 타입을 실제로 쓰는 여기로 옮겼다.
*/
export interface VideoItem {
id: string;
title: string;
videoUrl: string;
embedUrl: string;
duration?: string;
}
/** URL 에서 YouTube 영상 id 를 뽑는다. 못 뽑으면 썸네일을 대체 이미지로 떨어뜨린다. */
export function youTubeId(url: string): string | null {
const match = url.match(/^.*(youtu\.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/);
return match && match[2].length === 11 ? match[2] : null;
}
const FALLBACK_THUMB =
'https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?auto=format&fit=crop&w=800&q=80';
/** 영상 한 칸 — 썸네일을 누르면 그 자리에서 iframe 으로 바뀐다. */
export function VideoCard({
video,
isPlaying,
onPlay,
onRemove,
}: {
video: VideoItem;
isPlaying: boolean;
onPlay: () => void;
onRemove: () => void;
}) {
const ytId = youTubeId(video.videoUrl || video.embedUrl || '');
const thumb = ytId ? `https://img.youtube.com/vi/${ytId}/hqdefault.jpg` : FALLBACK_THUMB;
return (
<div className="flex flex-col overflow-hidden rounded-xl border border-stone-200/90 bg-white shadow-2xs">
<div className="group relative aspect-16/9 overflow-hidden bg-stone-900">
{isPlaying ? (
<iframe
src={`${video.embedUrl}?autoplay=1&rel=0`}
title={video.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="absolute inset-0 size-full border-0"
/>
) : (
<div
onClick={(e) => {
e.stopPropagation();
onPlay();
}}
className="absolute inset-0 cursor-pointer"
>
<img
src={thumb}
alt={video.title}
className="size-full object-cover opacity-90 transition-transform duration-300 group-hover:scale-105"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/40 transition-colors group-hover:bg-black/30">
<div className="flex size-12 items-center justify-center rounded-full bg-white/90 pl-1 text-stone-900 shadow-lg transition-transform group-hover:scale-110 group-active:scale-95">
<svg className="size-5 fill-current" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
<div className="absolute left-3 top-3 flex items-center gap-1.5 rounded-md bg-black/60 px-2 py-1 text-[10px] font-medium text-white backdrop-blur-xs">
<span className="size-2 rounded-full bg-rose-500" />
<span>YouTube</span>
</div>
</div>
)}
</div>
<div className="flex items-center justify-between gap-2 border-t border-stone-100 bg-white p-3.5">
<span className="truncate text-xs font-semibold text-stone-900 sm:text-sm">
{video.title}
</span>
<div className="flex shrink-0 items-center gap-1">
<a
href={video.videoUrl || `https://www.youtube.com/watch?v=${ytId}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
title="YouTube에서 열기"
className="rounded p-1 text-stone-400 hover:text-stone-700"
>
<ExternalLink className="size-3.5" />
</a>
<button
type="button"
title="삭제"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="cursor-pointer p-1 text-stone-400 hover:text-rose-600"
>
<Trash2 className="size-3.5" />
</button>
</div>
</div>
</div>
);
}

View File

@ -1,133 +0,0 @@
/**
* 오늘의 엽서 — 엽서 뒷면 더미.
*
* 앞면(사진)은 없다. 쓸 수 있는 사진이 없기도 하고, 이 아이템의 목적이 **손님이 자기 계정에
* 붙여넣는 것**이라 글이 놓이는 뒷면이 전부다. 우표 자리·소인·가운데 괘선이 그 형식을 만든다.
*/
import {useState} from 'react';
import {Check, Copy} from 'lucide-react';
import {Rail, SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type PostcardItem} from '@o2o/shared';
import {
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ITEM_INK,
ParseError,
PasteHint,
SourceLine,
} from '../items/common';
import '../items/items.css';
function Postcard({item}: {item: PostcardItem}) {
const [copied, setCopied] = useState(false);
const text = [item.line, (item.hashtags ?? []).join(' ')].filter(Boolean).join(' ');
const copy = (event: React.MouseEvent) => {
event.stopPropagation();
navigator.clipboard.writeText(text).then(
() => {
setCopied(true);
setTimeout(() => setCopied(false), 1600);
},
() => {
// 클립보드가 막힌 브라우저 — 직접 긁어 갈 수 있게 띄운다.
window.prompt('아래 문장을 복사하세요', text);
},
);
};
return (
<article
className="w4-paper w-[304px] shrink-0 snap-center border p-4 shadow-[4px_4px_0_rgba(27,26,21,.13)]"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_INK}}
>
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3">
{/* 가운데 괘선 — 엽서 뒷면을 반으로 가르는 그 선 */}
<div className="min-w-0 border-r pr-3" style={{borderColor: ITEM_BORDER}}>
<p className="text-[19px] leading-snug" style={{fontFamily: ITEM_BODY}}>
“{item.line}”
</p>
{item.hashtags && item.hashtags.length > 0 && (
<p className="mt-2 text-[11px] opacity-60">{item.hashtags.join(' ')}</p>
)}
</div>
<div className="flex w-[62px] flex-col items-center gap-3">
<span
className="grid h-[56px] w-[44px] place-items-center border border-dashed text-center text-[9px] leading-tight opacity-60"
style={{borderColor: ITEM_BORDER}}
>
郵票
<br />
10원
</span>
<span
className="grid size-[54px] -rotate-6 place-items-center rounded-full border-2 px-1 text-center text-[9px] leading-tight"
style={{fontFamily: ITEM_HEADING, borderColor: ITEM_ACCENT, color: ITEM_ACCENT}}
>
{item.postmark || item.place || '소인'}
</span>
</div>
</div>
<div
className="mt-3 flex items-center justify-between gap-2 border-t border-dashed pt-2.5"
style={{borderColor: ITEM_BORDER}}
>
<SourceLine source={item.source} verified={item.verified} />
<button
type="button"
onClick={copy}
className="flex shrink-0 items-center gap-1 border px-2 py-1 text-[10px] opacity-75 transition-opacity hover:opacity-100"
style={{borderColor: ITEM_BORDER}}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
{copied ? '복사됨' : '복사'}
</button>
</div>
</article>
);
}
export function PostcardStack(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<PostcardItem>(section.type, section.data);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : parsed.items.length === 0 ? (
<PasteHint label="오늘의 엽서" />
) : (
<div className="space-y-3">
<Rail label="엽서">
{parsed.items.map((item, index) => (
<Postcard key={`${item.line}-${index}`} item={item} />
))}
</Rail>
<p className="text-[11px] opacity-60">
[복사] 를 누르면 문장과 해시태그가 함께 복사됩니다 · 총 {parsed.items.length}장
</p>
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,29 +0,0 @@
/**
* 체험 프로그램 · 카드 — 소요시간·대상까지 붙여 한 장씩. 처음 오는 손님이 고르기 쉽다.
*/
import {PriceRow, SectionBody, SectionFrame, SectionHeading} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {programItems} from './useProgramItems';
export function ProgramsCards(props: SectionRenderProps) {
const {section, isSelected, onSelect, industryId, template} = props;
const items = programItems(industryId);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="white">
<SectionBody>
<SectionHeading
title={section.name}
subtitle={section.description}
colors={template.colors}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item) => (
<PriceRow key={item.name} item={item} variant="card" colors={template.colors} />
))}
</div>
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,60 +0,0 @@
/**
* 체험 프로그램 · 표 — 프로그램/시간/대상/가격을 열로 세워 비교시킨다.
* 정규 클래스처럼 조건을 견줘 고르는 곳에 맞는다.
*/
import {SectionBody, SectionFrame, SectionHeading} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {programItems} from './useProgramItems';
export function ProgramsTable(props: SectionRenderProps) {
const {section, isSelected, onSelect, industryId, template} = props;
const items = programItems(industryId);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint">
<SectionBody>
<SectionHeading variant="minimal" title={section.name} colors={template.colors} />
<div className="overflow-hidden rounded-xl border border-stone-200/80 bg-white shadow-2xs">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="border-b border-stone-100 bg-stone-50/60 font-medium text-stone-500">
<tr>
<th className="p-3">프로그램</th>
<th className="hidden p-3 sm:table-cell">소요 · 대상</th>
<th className="p-3 text-right">참가비</th>
</tr>
</thead>
<tbody className="text-stone-700 [&>tr+tr]:border-t [&>tr+tr]:border-stone-100">
{items.map((item) => (
<tr key={item.name} className="hover:bg-stone-50/50">
<td className="p-3">
<span
className="block font-bold text-stone-900"
style={{color: template.colors.text}}
>
{item.name}
</span>
{item.desc && (
<span className="mt-0.5 block text-[11px] text-stone-500">{item.desc}</span>
)}
</td>
<td className="hidden p-3 text-[11px] text-stone-500 sm:table-cell">
{item.meta?.join(' · ')}
</td>
<td
className="p-3 text-right font-mono font-extrabold text-stone-900"
style={{color: template.colors.primary}}
>
{item.price}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,13 +0,0 @@
import type {IndustryType} from '@o2o/shared';
import type {PricedItem} from '../../primitives';
/**
* 체험 프로그램.
*
* ★ 지금은 항상 빈 목록이다. 프로그램은 unit(하위 단위)이고, 캔버스 계약에 units 가 없다.
* 업종 시드의 programList 를 폴백으로 쓰던 코드를 걷어냈다 — 가공의 상품이 가격까지
* 붙어 모든 사업장 화면에 똑같이 떴다.
*/
export function programItems(_industryId: IndustryType): PricedItem[] {
return [];
}

View File

@ -1,120 +0,0 @@
/**
* 뒤집어 보는 질문 — 갱지 시험지 플립.
*
* 앞면은 질문 하나, 뒤집으면 힌트와 출처가 나온다.
* ★ 정답을 단정하지 않는다(dataSpec: answer 필드 없음). 이 데이터는 검증되지 않은 줄이 더 많아서
* 정답을 걸면 틀린 걸 확신하는 화면이 된다. 힌트까지가 우리가 책임질 수 있는 선이다.
*/
import {useState} from 'react';
import {cn} from '@/lib/utils';
import {Rail, SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type QuizItem} from '@o2o/shared';
import {
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ITEM_INK,
ParseError,
PasteHint,
SourceLine,
} from '../items/common';
import '../items/items.css';
function QuizCard({item, no}: {item: QuizItem; no: number}) {
const [flipped, setFlipped] = useState(false);
const face = 'w4-flip-face absolute inset-0 flex flex-col justify-between border p-4';
const faceStyle = {backgroundColor: ITEM_CARD, borderColor: ITEM_INK};
return (
<div
role="button"
tabIndex={0}
aria-pressed={flipped}
onClick={(event) => {
event.stopPropagation();
setFlipped((v) => !v);
}}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
event.stopPropagation();
setFlipped((v) => !v);
}}
className={cn('w4-flip h-[216px] w-[262px] shrink-0 cursor-pointer snap-center', flipped && 'w4-flip-on')}
>
<div className="w4-flip-inner relative size-full">
<div className={cn(face, 'w4-paper')} style={faceStyle}>
<p className="text-[10px] tracking-[0.16em] opacity-60">
문제 {no}
{item.level ? ` · ${item.level}` : ''}
</p>
<p className="text-[17px] leading-snug" style={{fontFamily: ITEM_BODY}}>
{item.question}
</p>
<p className="text-[10px]" style={{color: ITEM_ACCENT}}>
뒤집기 →
</p>
</div>
<div className={cn(face, 'w4-flip-back w4-paper')} style={faceStyle}>
<p className="text-[10px] tracking-[0.16em]" style={{color: ITEM_ACCENT}}>
힌트
</p>
{item.hint ? (
<p className="text-[13px] leading-relaxed opacity-85" style={{fontFamily: ITEM_BODY}}>
{item.hint}
</p>
) : (
<p className="text-[13px] opacity-60">힌트가 아직 없습니다.</p>
)}
<div className="space-y-1 border-t border-dashed pt-2" style={{borderColor: ITEM_BORDER}}>
{item.topic && <p className="text-[10px] opacity-60">{item.topic}</p>}
<SourceLine source={item.source} verified={item.verified} />
</div>
</div>
</div>
</div>
);
}
export function QuizFlip(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<QuizItem>(section.type, section.data);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : parsed.items.length === 0 ? (
<PasteHint label="뒤집어 보는 질문" />
) : (
<div className="space-y-3">
<Rail label="질문">
{parsed.items.map((item, index) => (
<QuizCard key={`${item.question}-${index}`} item={item} no={index + 1} />
))}
</Rail>
<p className="text-[11px] opacity-60">
정답은 두지 않습니다 — 힌트와 출처까지만 · 총 {parsed.items.length}문항
</p>
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,93 +0,0 @@
/**
* 지역 읽기 — 한 슬라이드에 한 꼭지.
*
* ★ 에디터는 **전부 보여준다.** 발행본은 이 중 5~6개만 매번 무작위로 세우지만
* (`site/sections/items/ReadingSection.tsx`), 고칠 사람은 안 뽑힌 꼭지도 봐야 한다 —
* 에디터에서 무작위로 가리면 방금 고친 글이 다음 렌더에 사라진다.
* ★ 출처 줄이 없다. 이 종류의 출처는 제목으로 만든 검색 링크라 서버가 붙인다
* (`backend/services/grounding/story.py` `_SEARCH_LINK_KINDS`) — 붙여넣기 단계에는 없다.
*/
import {Rail, SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type ReadingItem} from '@o2o/shared';
import {
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ParseError,
PasteHint,
} from '../items/common';
import '../items/items.css';
function Post({item}: {item: ReadingItem}) {
return (
<article
className="w-[300px] shrink-0 snap-center border p-4"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
>
{item.group && (
<p
className="text-[11px] font-bold tracking-[0.18em]"
style={{color: ITEM_ACCENT, fontFamily: ITEM_BODY}}
>
{item.group}
{item.year != null && <span className="font-normal opacity-70"> · {item.year}</span>}
</p>
)}
<h3
className="mt-2 border-b-2 pb-2 text-lg leading-snug"
style={{fontFamily: ITEM_HEADING, borderColor: 'currentColor'}}
>
{item.title}
</h3>
<p
className="mt-2.5 text-[13px] leading-[1.8] break-keep opacity-80"
style={{fontFamily: ITEM_BODY}}
>
{item.body}
</p>
</article>
);
}
export function ReadingRail(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<ReadingItem>(section.type, section.data);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : parsed.items.length === 0 ? (
<PasteHint label="지역 읽기" />
) : (
<div className="space-y-3">
{/* 발행본과 에디터가 다르게 보이는 자리라, 몇 개가 나가는지 여기서 밝힌다. */}
<p className="text-[11px] opacity-60">
전체 {parsed.items.length}꼭지 · 발행본은 이 중 5~6개를 매번 무작위로 세웁니다
</p>
<Rail label="지역 읽기" gap={0}>
{parsed.items.map((item, index) => (
<Post key={`${item.title}-${index}`} item={item} />
))}
</Rail>
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -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 = (
<div className="space-y-1 border-t border-stone-200/60 pt-1 text-xs text-stone-600">
<p className="flex items-center gap-1.5">
<span className="text-stone-400">•</span>
<span>수용 인원: {room.capacity}</span>
</p>
<p className="flex items-center gap-1.5">
<span className="text-stone-400">•</span>
<span>침대 구성: {room.bedInfo}</span>
</p>
<p className="flex items-center gap-1.5">
<span className="text-stone-400">•</span>
<span>면적: {room.size}</span>
</p>
</div>
);
const highlight = (
<div className="flex items-start gap-2 rounded-xl border border-amber-200/70 bg-amber-50/80 p-3 text-xs text-amber-950">
<Sparkles className="mt-0.5 size-4 shrink-0 text-amber-600" />
<div className="break-keep leading-relaxed">
<strong className="font-bold">차별점: </strong>
<span>{room.difference}</span>
</div>
</div>
);
const cover = (
<div
className={cn(
'relative overflow-hidden bg-stone-100',
layout === 'row' ? 'aspect-4/3 sm:aspect-auto sm:h-full' : 'aspect-16/10',
)}
>
<img src={room.images[0].url} alt={room.images[0].alt} className="size-full object-cover" />
<Pill tone="dark" className="absolute left-3 top-3 rounded-full px-2.5 py-1 text-[11px] backdrop-blur-md">
{room.type}
</Pill>
{layout === 'card' && (
<span className="absolute bottom-2.5 right-2.5 rounded-lg bg-white/95 px-2.5 py-1 text-xs font-bold text-stone-900 shadow-2xs backdrop-blur-md">
{index + 1} / {total}
</span>
)}
</div>
);
const body = (
<div className="space-y-3 p-4 sm:p-5">
<div className="space-y-1">
<h3
className="serif-title text-base font-bold leading-snug text-stone-900 sm:text-lg"
style={colors ? {color: colors.text} : undefined}
>
{room.name}
</h3>
<div
className="font-mono text-sm font-extrabold text-stone-900 sm:text-base"
style={colors ? {color: colors.primary} : undefined}
>
{room.price}
</div>
</div>
{highlight}
{specs}
</div>
);
const action = (
<div className="p-4 pt-0 sm:p-5 sm:pt-0">
<CtaLink href={bookingUrl} variant="solid" size="sm" colors={colors} block>
{/* ★ "실시간 예약 신청" 이었다(2026-09-09 수정). 실시간 재고를 갖지 않으므로
누르면 예약이 확정되는 것처럼 읽히면 안 된다 — 발행본과 같은 말로 맞춘다. */}
<span>예약 안내 보기</span>
<ExternalLink className="size-3.5" />
</CtaLink>
</div>
);
if (layout === 'row') {
return (
<div
className={cn(
'grid grid-cols-1 overflow-hidden rounded-2xl border border-stone-200/90 bg-stone-50/70 shadow-2xs sm:grid-cols-12',
className,
)}
>
<div className="sm:col-span-5">{cover}</div>
<div className="flex flex-col justify-between sm:col-span-7">
{body}
{action}
</div>
</div>
);
}
return (
<div
className={cn(
'flex flex-col justify-between overflow-hidden rounded-2xl border border-stone-200/90 bg-stone-50/70 shadow-2xs',
className,
)}
>
<div>
{cover}
{body}
</div>
{action}
</div>
);
}

View File

@ -1,47 +0,0 @@
/**
* 객실 · 가로 슬라이드 — 모바일에서 한 칸씩 스와이프, PC 에서는 2열.
* 객실이 2~4개일 때 가장 자연스럽다.
*/
import {EmptyStateNotice, SectionBody, SectionFrame, SectionHeading} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {bookingHref} from '../common';
import {RoomCard, type Room} from './RoomCard';
export function RoomsCarousel(props: SectionRenderProps) {
const {section, isSelected, onSelect, industryId, storeName, template} = props;
const url = bookingHref(industryId, storeName);
// ★ 객실(unit)은 캔버스 계약에 없다 — 시연용 세트로 자리를 메우지 않는다.
const rooms: Room[] = [];
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="white">
<SectionBody>
<SectionHeading title={section.name} subtitle="자연을 오롯이 품은 객실 타입" colors={template.colors} />
{rooms.length === 0 ? (
<EmptyStateNotice>객실 정보는 아직 준비 중입니다.</EmptyStateNotice>
) : (
<div className="space-y-4">
<div className="scrollbar-none flex snap-x snap-mandatory gap-4 overflow-x-auto px-1 pb-4 sm:grid sm:grid-cols-2 sm:gap-6 sm:overflow-visible sm:pb-0">
{rooms.map((room, idx) => (
<RoomCard
key={room.slug}
room={room}
index={idx}
total={rooms.length}
bookingUrl={url}
className="w-[85vw] max-w-[340px] shrink-0 snap-center sm:w-full"
colors={template.colors}
/>
))}
</div>
<div className="flex items-center justify-center gap-2 pt-1 text-[11px] text-stone-400 sm:hidden">
<span>← 좌우로 스와이프하여 다른 객실을 확인하세요 →</span>
</div>
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,45 +0,0 @@
/**
* 객실 · 격자 — 전부 한눈에 늘어놓는다. 객실이 많고 비교해서 고르게 하고 싶을 때.
*/
import {EmptyStateNotice, SectionBody, SectionFrame, SectionHeading} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {bookingHref} from '../common';
import {RoomCard, type Room} from './RoomCard';
export function RoomsGrid(props: SectionRenderProps) {
const {section, isSelected, onSelect, industryId, storeName, template} = props;
const url = bookingHref(industryId, storeName);
// ★ 객실(unit)은 캔버스 계약에 없다 — 시연용 세트로 자리를 메우지 않는다.
const rooms: Room[] = [];
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="white">
<SectionBody width="wide">
<SectionHeading
variant="centered"
eyebrow="ROOMS"
title={section.name}
subtitle="원하는 객실을 비교해 보세요"
colors={template.colors}
/>
{rooms.length === 0 ? (
<EmptyStateNotice>객실 정보는 아직 준비 중입니다.</EmptyStateNotice>
) : (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{rooms.map((room, idx) => (
<RoomCard
key={room.slug}
room={room}
index={idx}
total={rooms.length}
bookingUrl={url}
colors={template.colors}
/>
))}
</div>
)}
</SectionBody>
</SectionFrame>
);
}

Some files were not shown because too many files have changed in this diff Show More