[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:
parent
46b74b310b
commit
c6908629f3
@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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>` 만 읽고 떠난다.
|
||||
|
||||
|
||||
@ -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)이다 — 헤드리스 브라우저는 영구 금지 |
|
||||
|
||||
@ -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개였다 —
|
||||
|
||||
@ -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)를 새 메뉴로 키우려면 새 도메인이 필요하고 아직 그럴 기능도 안
|
||||
|
||||
@ -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
177
docs/TEMPLATES.md
Normal 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`)는 같다.
|
||||
@ -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%).
|
||||
|
||||
@ -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)은 발행 때 템플릿 정의가 정한다.';
|
||||
@ -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
|
||||
|
||||
@ -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/)에 있어야
|
||||
# 워커가 컨테이너 안에서 렌더러를 그대로 실행할 수 있다.
|
||||
|
||||
@ -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
|
||||
|
||||
41
solution/backend/common/template_catalog.py
Normal file
41
solution/backend/common/template_catalog.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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, [])
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>;
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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" /> 필수 고정
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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';
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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)}`;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 [];
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 [];
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 [];
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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
Loading…
Reference in New Issue
Block a user