Merge branch 'main' into feature/auth — 관문은 main 의 에디터 진입으로

main 이 5ef3e5a 로 문 앞 게이트(d6a6c8e·b94daa9)를 되돌렸다. 근거가 내가 못 본 것이었다 —
`/` 는 자기 화면 없이 /builder 로 넘기기만 하므로 **문 앞 가드는 곧 루트 가드**이고,
앱을 열자마자 로그인 화면이 된다. 그 결정을 따르고, 되돌리기에 휩쓸린 것만 복구한다.

- app/router: `/signup` 라우트 복구. 라우터를 통째로 되돌리면서 같이 날아갔고,
  그 결과 로그인 화면의 [회원가입] 링크가 404 였다
- features/auth/SignInForm: 토큰 심는 순서(signIn → me)를 lib/session 으로. 이 파일 맨 위
  주석이 경고하던 그 중복이다 — 관문이 되살아나면서 사본도 같이 돌아왔다
- pages/BuilderPage: 에디터 관문(main)과 상단 바 사용자 표시(이쪽)를 함께 둔다.
  충돌은 `authUser`/`user` 이름뿐이었다
- docs/DEVLOG: 인증 항목이 되돌리기에 휩쓸려 사라졌다. 지금 설계(에디터 진입 관문)에 맞춰
  다시 썼다 — 문 앞 가드를 시도했다 되돌린 이력도 함께 남긴다
- docs/ARCHITECTURE: 인증 모델 서술과 '아직 안 한 것' 을 지금 상태로

브라우저 확인: 위저드는 로그인 없이 열림 → 가입 → 사이드바 '김사장 · 달빛스테이' →
에디터 상단 바 동일 표시 → 로그아웃. 구글 버튼 렌더까지 확인(실제 로그인은 client_id 필요).
pytest 534 passed / 4 failed(전부 기존 실패). tsc·eslint·vite build 통과.
This commit is contained in:
Mina Choi 2026-09-02 21:33:31 +09:00
commit 01c252287e
49 changed files with 806 additions and 260 deletions

View File

@ -1,29 +1,30 @@
# ★ 빌드 컨텍스트가 레포 루트다(solution/backend/Dockerfile 주석 참조).
# 컨텍스트가 넓어진 만큼 여기서 확실히 잘라내야 이미지가 붓지 않는다.
**/__pycache__/
*.pyc
**/.pytest_cache/
# 빌드 컨텍스트는 레포 루트고, 백엔드 이미지와 solution-site 이미지가 이것을 **같이** 쓴다.
# ★ nginx/Dockerfile.dockerignore 는 BuildKit 전용이다. 이 머신엔 buildx 가 없어 레거시
# 빌더가 돌고, 그러면 이 파일만 읽힌다 — 여기서 프론트 소스를 자르면 nginx 빌드가 죽는다.
# (백엔드 이미지는 COPY 대상이 solution/backend·admin/backend 뿐이라 영향이 없다.)
.git/
.venv/
**/.venv/
node_modules/
**/node_modules/
**/dist/
**/.vite/
**/__pycache__/
*.pyc
**/.pytest_cache/
.venv/
**/.venv/
# 발행 산출물은 볼륨에서 온다. 이미지에 구우면 사이트가 늘 때마다 이미지가 붓는다.
solution/site/out/
solution/site/payloads/
# 프론트·문서는 백엔드 이미지에 들어갈 이유가 없다.
solution/frontend/
solution/site/
solution/shared/
admin/frontend/
docs/
nginx/
postgres-init/
**/*.md
solution/backend/tests/
solution/backend/loadtest/
# 시크릿 — 이미지에 굽지 않는다. Dockerfile 이 example 을 복사해 넣고 실값은 compose env 로 준다.
# 시크릿 — 이미지에 굽지 않는다. 백엔드는 example 사본 + compose env, 프론트는 build args.
**/config.local.toml
**/config.test.toml
.env
.env.*
!.env.example

View File

@ -43,8 +43,6 @@
(`VITE_GOOGLE_CLIENT_ID`, compose 가 루트 값을 흘려보낸다). 백엔드는 이 값으로 구글 토큰의
수신자(`aud`)를 대조한다 — **이 검사가 유일하게 "남의 앱에 발급된 진짜 구글 토큰"을 막는다.**
어긋나면 버튼은 뜨는데 로그인만 계속 거부된다. 비우면 구글 로그인만 꺼진다(서버는 뜬다).
- **`/builder` 는 로그인 뒤에 있다.** 자동 로그인(`AUTO_LOGIN_ID`·`PW`)은 화면 안이 아니라
부팅(`app/provider.tsx`)에서 붙는다 — 가드가 먼저 판단하므로 화면 안에서 부르면 늦다.
- **`AZURE_STORAGE_PREFIX` 와 루트 절대경로는 충돌한다.** HTML 이 `/assets/…` 를 가리키는데
블롭은 `ai-for-web/assets/…` 에 놓인다. 접두사를 쓰려면 오리진 경로를 `/ai-for-web` 로 잡는
CDN 을 앞에 세워야 한다. 아니면 비워라.

View File

@ -24,7 +24,7 @@ const CATEGORY_LABEL: Record<number, string> = {
[PlaceCategory.LODGING]: '숙박',
[PlaceCategory.CAFE]: '카페',
[PlaceCategory.RESTAURANT]: '음식점',
[PlaceCategory.TOUR_ACTIVITY]: '관광체험',
[PlaceCategory.CLINIC]: '피부과·성형외과',
};
const STATUS_LABEL: Record<number, string> = {

View File

@ -137,12 +137,13 @@ negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를
`UserRole.DEVELOPER` 주석의 **"고객사에 존재를 노출하지 않는다"** 를 번들이 깨고 있었다.
라우트 가드는 화면을 가리지 **번들은 못 가린다.**
★ 이 문제는 **코드 크기와 무관하다.** 내부 화면이 814줄뿐이어도 내려가는 건 같다.
2. **인증 모델이 갈라진다.** 둘 다 `RequireAuth` 뒤로 들어갔지만(2026-09-02 빌더 포함)
**계정이 생기는 방식**이 다르다 — 사장님은 스스로 가입하고 구글로도 들어오는 반면,
내부 운영 계정은 우리가 만들고 role >= DEVELOPER 여야 한다. 한 앱에서 두 정책을 유지하면
실수는 늘 **느슨한 쪽으로** 난다.
→ 지금은 `LoginPage``selfServe` 플래그가 그 차이를 한 곳에서 드러낸다
(사장님: 가입 링크 + 구글 버튼 / 내부: 둘 다 없음).
2. **인증 모델이 갈라진다.** 빌더는 위저드를 열어 두고 **에디터 진입에서 한 번** 받는다
("만들어 보기도 전에 막힌다"). 내부 화면은 전부 `RequireAuth` 뒤다. 계정이 생기는 방식도
다르다 — 사장님은 스스로 가입하고 구글로도 들어오지만, 내부 운영 계정은 우리가 만들고
role >= DEVELOPER 여야 한다(`LoginPage` 의 `selfServe` 플래그가 그 차이를 한 곳에서 드러낸다).
한 앱에서 두 정책을 유지하면 실수는 늘 **느슨한 쪽으로** 난다.
→ 지금은 두 `provider.tsx` 가 그 차이를 각자 명시한다(사장님: 인증 실패를 삼킨다 /
내부: 실패가 곧 차단).
3. **배포 리듬이 다르다.** 내부 화면을 고치려고 사장님 화면을 재배포하지 않는다.
### 백엔드 — 코드 한 벌, 진입점 둘
@ -180,8 +181,8 @@ OWNER role=2 → 403
OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다.
`auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). `signup`·`google` 도
같은 이유로 토큰 없이 열려 있다 — **여기서 만들어지는 계정은 언제나 `role=USER` 이고,
자기 회사(새 테넌트) 하나만 본다.** 권한이 올라가는 경로는 이 문 뒤에 없다.
같은 이유로 토큰 없이 열려 있다 — **여기서 만들어지는 계정은 언제나 `role=USER` 이고 자기
회사(새 테넌트) 하나만 본다.** 권한이 올라가는 경로는 이 문 뒤에 없다.
⚠️ **`/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다**(`router/router.py`).
위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 `RequireOwner`
@ -226,12 +227,11 @@ admin 자기 파일만 `@admin` 이다.
### 아직 안 한 것
- 사장님 **"내 사이트 관리"** 화면(내 사업장 목록). 빌더는 2026-09-02 에 로그인 뒤로
들어갔고, 로그인 후 도착지는 아직 `/builder?new=1`(새로 만들기) 하나뿐이다.
- 사장님 **"내 사이트 관리"** 화면(내 사업장 목록). 로그인 후 도착지가 아직
`/builder?new=1`(새로 만들기) 하나뿐이다.
- **계정 연결** — 같은 사람의 id/pw 계정과 구글 계정을 잇는 경로. 지금은 잇지 않고
거절한다 → [DECISIONS.md 1-5](DECISIONS.md)
- **비밀번호 재설정** — 이메일을 받아 두지만 소유 증명(인증 메일) 절차가 없다. 그래서
비밀번호를 잊으면 운영자가 손으로 바꿔 주는 수밖에 없다.
- **비밀번호 재설정** — 이메일을 받아 두지만 소유 증명(인증 메일) 절차가 없다.
- 운영 배포에서 `admin`(:3002)을 내부망에만 여는 것. compose 는 `ADMIN_BIND` 기본값을
`127.0.0.1` 로 두었다. **0.0.0.0 으로 열면 앱을 가른 의미가 없다.**
- **폰트 self-host**`solution/site/public/fonts/PretendardVariable.woff2` 가 없어 Noto Sans KR 로

View File

@ -3,45 +3,28 @@
무엇을 왜 바꿨는지 날짜순으로 남긴다. 새 항목을 **위에** 추가한다.
결론과 배경은 각 문서가 단일 출처다 — 여기에는 요약과 링크만 둔다.
## 2026-09-02 — 로그인을 붙이고, 빌더를 그 뒤로 넣었다
---
## 2026-09-02 — 회원가입과 구글 로그인
**한 일**
- id/pw **회원가입**(`POST /v1/auth/signup`) 과 **구글 로그인**(`POST /v1/auth/google`) 추가.
- `company.users``provider`(AuthProvider) · `provider_uid`(구글 sub) 추가. `password`
NULL 허용, `id` 는 20 → 64자.
- `/builder``RequireAuth` 뒤로 넣었다. 자동 로그인은 화면 안(`useAutoLogin`) 이 아니라
부팅(`app/provider.tsx`)에서 붙는다 — 가드가 먼저 판단하므로 화면 안은 이제 실행되지 않는다.
- 로그인 화면에 구글 버튼 + 가입 링크. 내부 운영 화면은 `selfServe={false}` 로 둘 다 안 뜬다.
- `POST /v1/auth/signup`(id/pw) · `POST /v1/auth/google` 추가. 로그인 화면에 구글 버튼과
가입 링크, `/signup` 화면. 내부 운영 화면은 `selfServe={false}` 로 둘 다 안 뜬다.
- `company.users``provider`(AuthProvider) · `provider_uid`(구글 sub). `password` 는 NULL
허용(소셜 계정), `id` 는 20 → 64자(`google_<sub>` 가 20자를 넘는다).
- 에디터(6단계) 상단 바에 로그인한 사용자와 [로그아웃]. 위저드는 AppShell 사이드바가
들고 있었는데 에디터는 전체 화면이라 **신원도 나가는 길도 화면에서 사라져 있었다.**
**왜 빌더를 막았나**
빌더는 "만들어 보기 전에 막지 않으려고" 열려 있었다. 그런데 위저드 2단계부터 백엔드를 부르고,
만든 결과는 사업장·사이트로 **계정에 귀속**된다. 로그인 없이 걸어온 사람은 3단계쯤에서
"로그인이 만료되었습니다"를 만나고 그때까지 넣은 걸 잃었다 — 만료가 아니라 처음부터 세션이
없었던 것이다. 문 앞에서 막는 편이 걸어 들어온 뒤에 막는 것보다 낫다.
**관문이 두 개였다 — 문 앞 하나로 합쳤다**
같은 날 두 자리에서 같은 문제를 풀었다. main 은 **에디터 진입(6단계)** 에서 받았고
(`EditorSignInGate`·`SignInForm`), 이쪽은 **`/builder` 문 앞**에서 받았다. 둘 다 두면 문 앞이
먼저 걸려 에디터 관문은 영영 안 뜨는 죽은 코드가 된다. 문 앞을 남긴 이유:
- 에디터 관문을 쓰려면 **2단계가 토큰 없이 지나가야** 했고, 그래서 토큰이 없을 때 서버를 부르지
않고 입력값으로 신원을 세우는 우회로가 생겼다(`confirmManual`). 그건 이 레포의 단 하나의
규칙(**검증 전에는 수집·발행 금지**)을 화면이 비켜 가는 모양이고, 대가는 "로그인 뒤에 검증을
다시" 다. 열어 둔 값이 공짜가 아니었다.
- 이제 가입이 그 자리에서 끝난다(가입 응답에 토큰이 실린다). 구글이면 클릭 두 번이다 —
문 앞 로그인의 마찰이 "만들어 보기도 전에 막는다" 던 시절보다 훨씬 작다.
- 관문이 하나면 로그인 폼도 하나다. `SignInForm` 이 경고하던 "`signIn → me` 순서를 두 벌로
들고 있다"는 `lib/session.establishSession` 한 곳으로 모았다.
지운 것: `features/auth/EditorSignInGate`·`SignInForm`, `usePlaceSearch.confirmManual`,
Step2 의 토큰 없을 때 우회로. 되돌리려면 `app/router.tsx``RequireAuth` 를 벗기고
그 셋을 되살리면 된다(커밋 `969fb67`·`22b7623`).
**왜 가입까지 만들었나**
빌더가 로그인 뒤로 들어간 순간, 계정을 만들 길이 없으면 제품이 닫힌다. 계정 생성 API 가
아예 없어서(그동안 `users` 를 손으로 INSERT 했다) 가입 = **새 회사(테넌트) 1개 + 첫 계정 1개**
**왜 가입부터 만들었나**
계정 생성 API 가 아예 없었다 — 그동안 `users` 를 손으로 INSERT 했다. 로그인 화면은 있는데
그 뒤에 설 계정을 만들 방법이 제품에 없는 상태였다. 가입 = **새 회사(테넌트) 1개 + 첫 계정 1개**
로 정의했다. `users.company_id` 가 NOT NULL 이고 모든 도메인이 company 로 스코프되기 때문이다.
**로그인 관문은 에디터 진입 그대로다**
한때 `/builder` 를 통째로 `RequireAuth` 뒤로 옮겼다가 되돌렸다(5ef3e5a). `/` 가 자기 화면 없이
`/builder` 로 넘기기만 하므로 **문 앞 가드는 곧 루트 가드**이고, 앱을 열자마자 로그인 화면이 된다.
관문은 `EditorSignInGate`(969fb67) 한 자리다.
**밟기 쉬운 자리**
- **`GOOGLE_CLIENT_ID` 는 백엔드와 프론트가 같아야 한다.** 백엔드는 이 값으로 구글 토큰의
수신자(`aud`)를 대조한다 — 이 검사가 없으면 **다른 서비스에 발급된 진짜 구글 토큰**으로
@ -52,15 +35,33 @@ Step2 의 토큰 없을 때 우회로. 되돌리려면 `app/router.tsx` 의 `Req
None 을 만나 500 이 난다.
- `provider``server_default` 를 같이 줬다. ORM default 는 raw INSERT(테스트 시드)에 안 먹어서
NOT NULL 컬럼이면 그 경로가 통째로 깨진다.
- **init.sql 에서 새 컬럼의 인덱스는 맨 끝 ALTER 섹션에 둔다.** 인덱스 절이 ALTER 보다 위라,
기존 DB 에서는 아직 없는 컬럼을 가리켜 스크립트가 통째로 멈춘다(실측으로 밟았다).
**이미 도는 DB 가 있으면** `postgres-init/init-data/init.sql` 을 다시 적용한다 — 말미의
"기존 DB 보정(ALTER)" 섹션이 새 컬럼을 채우고 `id` 를 넓힌다. 안 하면 로그인부터 500 이다.
**이미 도는 DB 가 있으면** `postgres-init/init-data/init.sql` 을 다시 적용한다.
**검증** — 백엔드 `pytest` auth 13건 + 구글 토큰 검증 8건(진짜 RSA 서명으로 aud·iss·만료·
`email_verified` 거절 확인), 프론트 `tsc` · `eslint` · `vite build` 통과.
**아직 못 한 것** — 실제 구글 계정 로그인. `GOOGLE_CLIENT_ID` 가 있어야 버튼이 뜬다.
버튼 렌더까지는 확인했다(빌려온 client_id 로).
**검증** — 백엔드 auth 13건 + 구글 토큰 검증 8건(진짜 RSA 서명으로 aud·iss·만료·
`email_verified`·본문 변조 거절). 브라우저: 가입 → 위저드 진입 → 사이드바 표시 → 에디터
상단 바 표시 → 로그아웃. `tsc`·`eslint`·`vite build` 통과.
---
## 2026-09-02 — 직접 쓴 소개문이 발행에서 사라지던 구멍
**왜**
에디터의 소개 섹션 본문은 `sites.theme` 에 저장됐지만 발행 payload 경계에서 버려졌고,
프리렌더도 고유 콘텐츠로 세지 않았다. 사장님이 소개를 써도 발행 화면은 0건이라며 거부했다.
**한 일**
- `SectionSetting.body` 계약을 추가하고 저장값을 payload 까지 전달
- 소개 본문을 발행 HTML에 표시하고, 켜진 소개 섹션의 8자 이상 본문만 고유 콘텐츠로 계수
- 고유 콘텐츠 0건과 JSON-LD 불일치, 계수 실패를 서로 다른 발행 사유로 분리
**검증** — 직접 입력 소개문만 있는 발행 경로 회귀 테스트 추가.
## 2026-09-02 — 템플릿이 색만 바꾸던 걸 끝냈다 (5개 → 3개)
**왜**

View File

@ -91,7 +91,7 @@ CREATE TABLE IF NOT EXISTS place.places (
company_id uuid NOT NULL, -- 테넌트(company.companies.company_id)
owner_user_id uuid NULL, -- 사장님 계정(company.users.user_id)
name VARCHAR(200) NOT NULL, -- 상호명(입력값)
category SMALLINT NOT NULL, -- 업종(PlaceCategory): 1=숙박 2=카페 3=음식점 4=관광체험
category SMALLINT NOT NULL, -- 업종(PlaceCategory): 1=숙박 2=카페 3=음식점 4=피부과·성형외과
status SMALLINT NOT NULL DEFAULT 1, -- 상태(PlaceStatus): 1=draft 2=collecting 3=review 4=published 5=suspended
external_source SMALLINT NULL, -- 검증에 쓴 외부 장소 DB(ExternalPlaceSource): 1=kakao 2=naver
external_place_id VARCHAR(64) NULL, -- 외부 고유 장소 id — ★ 카카오는 주고 네이버는 안 준다

View File

@ -39,7 +39,7 @@ common/category_schema/resources/
├── lodging.json 숙박 필드 30 (critical 14)
├── cafe.json 카페 필드 23 (critical 12)
├── restaurant.json 음식점 필드 24 (critical 15)
└── tour_activity.json 관광체험 필드 24 (critical 17)
└── clinic.json 피부과·성형외과 필드 24 (critical 17)
```
**업종 추가 = JSON 파일 1개 + `PlaceCategory` 코드 1줄.** 로직은 건드리지 않는다.

View File

@ -0,0 +1,31 @@
{
"category": "clinic",
"code": 4,
"label": "피부과·성형외과",
"fields": [
{ "key": "operating_hours", "label": "진료시간", "type": "text", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "closed_days", "label": "휴진일", "type": "text", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "specialties", "label": "진료과목", "type": "text", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "medical_staff", "label": "의료진", "type": "text", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "reservation_required", "label": "예약 필수", "type": "bool", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "reservation_channel", "label": "예약 방법", "type": "text", "scope": "place", "required": false, "critical": true, "allow_llm": false },
{ "key": "consultation_fee", "label": "상담료", "type": "text", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "insurance_covered", "label": "보험 적용 안내", "type": "text", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "cancel_policy", "label": "예약 변경·취소", "type": "text", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "night_clinic", "label": "야간진료", "type": "text", "scope": "place", "required": false, "critical": true, "allow_llm": false },
{ "key": "weekend_clinic", "label": "주말진료", "type": "text", "scope": "place", "required": false, "critical": true, "allow_llm": false },
{ "key": "guide_language", "label": "외국어 상담", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": false },
{ "key": "parking", "label": "주차 가능", "type": "bool", "scope": "place", "required": false, "critical": true, "allow_llm": false },
{ "key": "wheelchair_accessible", "label": "휠체어 접근", "type": "bool", "scope": "place", "required": false, "critical": true, "allow_llm": false },
{ "key": "intro", "label": "병원 소개", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": true },
{ "key": "program_name", "label": "시술명", "type": "text", "scope": "unit", "required": true, "critical": false, "allow_llm": false },
{ "key": "program_duration", "label": "소요 시간", "type": "number", "scope": "unit", "required": true, "critical": true, "allow_llm": false, "unit": "분" },
{ "key": "price_adult", "label": "시술 비용", "type": "number", "scope": "unit", "required": false, "critical": true, "allow_llm": false, "unit": "원" },
{ "key": "anesthesia", "label": "마취 방식", "type": "text", "scope": "unit", "required": false, "critical": true, "allow_llm": false },
{ "key": "downtime", "label": "회복 기간", "type": "text", "scope": "unit", "required": false, "critical": true, "allow_llm": false },
{ "key": "session_count", "label": "권장 횟수", "type": "text", "scope": "unit", "required": false, "critical": true, "allow_llm": false },
{ "key": "aftercare", "label": "시술 후 주의사항", "type": "text", "scope": "unit", "required": false, "critical": true, "allow_llm": false },
{ "key": "program_intro", "label": "시술 설명", "type": "text", "scope": "unit", "required": false, "critical": false, "allow_llm": true }
]
}

View File

@ -1,32 +0,0 @@
{
"category": "tour_activity",
"code": 4,
"label": "관광체험",
"fields": [
{ "key": "operating_hours", "label": "운영시간", "type": "text", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "closed_days", "label": "휴무일", "type": "text", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "reservation_required", "label": "예약 필수", "type": "bool", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "reservation_channel", "label": "예약 방법", "type": "text", "scope": "place", "required": false, "critical": true, "allow_llm": false },
{ "key": "cancel_policy", "label": "취소·환불 규정", "type": "text", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "age_limit", "label": "연령 제한", "type": "text", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "safety_notice", "label": "안전 유의사항", "type": "text", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "weather_dependent", "label": "우천 시 운영", "type": "text", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "what_to_bring", "label": "준비물", "type": "text", "scope": "place", "required": false, "critical": true, "allow_llm": false },
{ "key": "session_times", "label": "회차 시간", "type": "text", "scope": "place", "required": false, "critical": true, "allow_llm": false },
{ "key": "guide_language", "label": "안내 언어", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": false },
{ "key": "parking", "label": "주차 가능", "type": "bool", "scope": "place", "required": false, "critical": true, "allow_llm": false },
{ "key": "wheelchair_accessible", "label": "휠체어 접근", "type": "bool", "scope": "place", "required": false, "critical": true, "allow_llm": false },
{ "key": "locker_available", "label": "물품보관함", "type": "bool", "scope": "place", "required": false, "critical": false, "allow_llm": false },
{ "key": "shower_available", "label": "샤워시설", "type": "bool", "scope": "place", "required": false, "critical": false, "allow_llm": false },
{ "key": "intro", "label": "체험 소개", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": true },
{ "key": "program_name", "label": "프로그램명", "type": "text", "scope": "unit", "required": true, "critical": false, "allow_llm": false },
{ "key": "program_duration", "label": "소요 시간", "type": "number", "scope": "unit", "required": true, "critical": true, "allow_llm": false, "unit": "분" },
{ "key": "program_capacity", "label": "회차당 정원", "type": "number", "scope": "unit", "required": false, "critical": true, "allow_llm": false, "unit": "명" },
{ "key": "program_min_people", "label": "최소 출발 인원", "type": "number", "scope": "unit", "required": false, "critical": true, "allow_llm": false, "unit": "명" },
{ "key": "price_adult", "label": "성인 요금", "type": "number", "scope": "unit", "required": false, "critical": true, "allow_llm": false, "unit": "원" },
{ "key": "price_child", "label": "소인 요금", "type": "number", "scope": "unit", "required": false, "critical": true, "allow_llm": false, "unit": "원" },
{ "key": "program_level", "label": "난이도", "type": "text", "scope": "unit", "required": false, "critical": false, "allow_llm": false },
{ "key": "program_intro", "label": "프로그램 소개", "type": "text", "scope": "unit", "required": false, "critical": false, "allow_llm": true }
]
}

View File

@ -169,7 +169,7 @@ class place_links(MainTableMixin, MAIN_BASE):
class units(MainTableMixin, MAIN_BASE):
"""업종별 하위 단위 — 숙박=객실, 카페·음식점=메뉴, 관광체험=프로그램.
"""업종별 하위 단위 — 숙박=객실, 카페·음식점=메뉴, 피부과·성형외과=프로그램.
가변 필드는 facts(scope=unit) 들어가고, 여기에는 목록 렌더에 필요한 뼈대만 둔다."""
__tablename__ = "units"

View File

@ -181,7 +181,7 @@ class PlaceCategory(CodeEnum):
LODGING = 1 # 숙박
CAFE = 2 # 카페
RESTAURANT = 3 # 음식점
TOUR_ACTIVITY = 4 # 관광체험
CLINIC = 4 # 피부과·성형외과
class ExternalPlaceSource(CodeEnum):

View File

@ -201,6 +201,12 @@ def fake_renderer(monkeypatch, tmp_path_factory):
return len(str(value or "").strip()) >= MIN_UNIQUE_TEXT
count = 0
intro = next(
(s for s in (payload.get("theme") or {}).get("sections") or [] if s.get("id") == "intro"),
None,
)
if intro and intro.get("enabled") and long(intro.get("body")):
count += 1
facts = list(payload.get("facts") or [])
for unit in payload.get("units") or []:
facts.extend(unit.get("facts") or [])

View File

@ -88,8 +88,8 @@ class Req_SiteTheme(SiteProtocol):
그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다:
{"theme": {"colors": {...}, "fontStyle": "...", "colorPaletteId": "...", "sections": [...]}}
sections {id, name, enabled, locked, variantId?} 목록이고 **배열 순서가 섹션 순서**
(별도 order 필드가 없다). variantId 고른 있을 때만 키가 붙는다.
sections {id, name, enabled, locked, variantId?, body?} 목록이고 **배열 순서가 섹션 순서**
(별도 order 필드가 없다). variantId 직접 입력 본문 body 값이 있을 때만 키가 붙는다.
pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다
서버는 배달부지 심판이 아니다.

View File

@ -106,7 +106,7 @@ async def set_template(
description="에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다"
"(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: "
"{\"theme\":{\"colors\":{...},\"fontStyle\":\"...\",\"colorPaletteId\":\"...\","
"\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\"}]}}. "
"\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\",\"body\"}]}}. "
"★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). "
"★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. "
"직렬화 크기(64KB)만 막는다. "

View File

@ -200,7 +200,10 @@ async def run_build(job: dict) -> dict:
)
mismatches = list(report.get("mismatches") or [])
unique_count = report.get("uniqueContentCount") or 0
# ★ 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 []
result["unique_content_count"] = unique_count
result["mismatches"] = mismatches[:20]
@ -211,14 +214,25 @@ async def run_build(job: dict) -> dict:
# 대개 게이트 사유(고유 콘텐츠 0건·구조화 데이터 불일치)이기 때문이다.
# 여기서 사유를 정확히 골라야 publish_logs 에 '무엇을 고쳐야 하는지' 가 남는다 —
# 전부 "렌더 실패"로 뭉뚱그리면 운영자가 손댈 곳을 알 수 없다.
gate = publish_gate.evaluate(PlaceCategory(place.category), snapshot["facts"], unique_count, mismatches)
gate = publish_gate.evaluate(
PlaceCategory(place.category), snapshot["facts"], unique_count_raw, mismatches
)
result["gate"] = {"passed": gate.passed, **gate.as_log()}
if not gate.passed:
return await _fail(f"{gate.reason.name}: {gate.as_log()}", gate, stamp)
if not report.get("ok"):
# 게이트로 설명되지 않는 실패(디스크·번들·payload 파손). 재시도가 의미 있는 쪽이다.
# ★ 렌더러가 거부한 이유에 사유 코드를 붙인다. evaluate() 는 얇은 콘텐츠로 막지
# 않지만 렌더러는 스팸 판정을 피하려 페이지 쓰기를 거부한다 — 그 사유를 "렌더 실패"
# 로 뭉개면 화면이 NO_UNIQUE_CONTENT 문구를 못 고르고, 사장님은 손댈 곳을 모른다.
thin = publish_gate.check_unique_content(unique_count_raw)
if not thin.passed:
# 위에서 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 파손). 재시도가 의미 있다.
return await _fail(str(report.get("error") or "렌더 실패"), None, stamp)
# DB 발행 상태를 바꾸기 전에 정적 파일을 외부 저장소에 올린다.

View File

@ -29,7 +29,7 @@ _CATEGORY_BY_NAME = {
"lodging": PlaceCategory.LODGING,
"cafe": PlaceCategory.CAFE,
"restaurant": PlaceCategory.RESTAURANT,
"tour_activity": PlaceCategory.TOUR_ACTIVITY,
"clinic": PlaceCategory.CLINIC,
}
_CHANNEL_BY_NAME = {c.name.lower(): c for c in LinkChannel}
@ -86,16 +86,18 @@ _PLACE_FACTS = {
("signature_menu", "한우 물회"),
("price_range", "15,000~30,000원"),
],
PlaceCategory.TOUR_ACTIVITY: [
("operating_hours", "09:00~18:00"),
("closed_days", "매주 수요일"),
PlaceCategory.CLINIC: [
("operating_hours", "평일 10:00~19:00, 토 10:00~14:00"),
("closed_days", "일요일·공휴일"),
("specialties", "피부과, 성형외과"),
("medical_staff", "피부과 전문의 2인, 성형외과 전문의 1인"),
("reservation_required", "true"),
("reservation_channel", "네이버 예약"),
("cancel_policy", "체험 3일 전까지 100% 환불, 당일 취소 불가"),
("age_limit", "만 7세 이상"),
("safety_notice", "구명조끼 착용 필수, 음주 후 참여 불가"),
("weather_dependent", "우천 시 당일 오전 8시에 개별 안내"),
("what_to_bring", "여벌 옷, 수건"),
("reservation_channel", "네이버 예약, 전화"),
("consultation_fee", "초진 상담료 없음"),
("insurance_covered", "질환 치료는 보험 적용, 미용 시술은 비급여"),
("cancel_policy", "예약 1일 전까지 변경 가능, 당일 취소는 재예약 제한"),
("night_clinic", "목요일 21:00까지"),
("weekend_clinic", "토요일 14:00까지"),
("parking", "true"),
],
}
@ -130,19 +132,21 @@ _UNIT_FACTS = {
"한우 물회": [("menu_name", "한우 물회"), ("menu_price", "18000")],
"성게 비빔밥": [("menu_name", "성게 비빔밥"), ("menu_price", "22000"), ("menu_min_order", "2")],
},
PlaceCategory.TOUR_ACTIVITY: {
"서핑 입문 클래스": [
("program_name", "서핑 입문 클래스"),
("program_duration", "120"),
("program_capacity", "8"),
("price_adult", "70000"),
("price_child", "50000"),
PlaceCategory.CLINIC: {
"레이저 토닝": [
("program_name", "레이저 토닝"),
("program_duration", "30"),
("price_adult", "100000"),
("anesthesia", "마취 없음"),
("downtime", "당일 일상생활 가능"),
("session_count", "5회 권장"),
],
"패들보드 체험": [
("program_name", "패들보드 체험"),
("program_duration", "90"),
("program_capacity", "6"),
("price_adult", "55000"),
"보톡스": [
("program_name", "보톡스"),
("program_duration", "20"),
("price_adult", "150000"),
("anesthesia", "연고 마취"),
("downtime", "1~2일 미세 부기"),
],
},
}
@ -168,11 +172,11 @@ _MEDIA = {
("한우 물회", "한우 물회"),
("성게 비빔밥", "성게 비빔밥"),
],
PlaceCategory.TOUR_ACTIVITY: [
("해변 전경", None),
("장비 보관소", None),
("서핑 강습", "서핑 입문 클래스"),
("패들보드", "패들보드 체험"),
PlaceCategory.CLINIC: [
("병원 외관", None),
("상담실", None),
("레이저 장비", "레이저 토닝"),
("시술실", "보톡스"),
],
}

View File

@ -38,7 +38,7 @@ _CATEGORY_LABEL = {
PlaceCategory.LODGING: "숙박업소",
PlaceCategory.CAFE: "카페",
PlaceCategory.RESTAURANT: "음식점",
PlaceCategory.TOUR_ACTIVITY: "관광·체험 시설",
PlaceCategory.CLINIC: "관광·체험 시설",
}

View File

@ -8,7 +8,7 @@ _LABELS = {
PlaceCategory.LODGING: ["외관", "침실", "거실", "욕실", "주방", "수영장", "바비큐장", "주차장", "전망", "부대시설"],
PlaceCategory.CAFE: ["외관", "내부", "좌석", "메뉴", "디저트", "음료", "테라스", "주차장"],
PlaceCategory.RESTAURANT: ["외관", "내부", "좌석", "메뉴", "음식", "", "주차장"],
PlaceCategory.TOUR_ACTIVITY: ["외관", "시설", "장비", "활동", "안전장비", "주차장", "매표소"],
PlaceCategory.CLINIC: ["외관", "시설", "장비", "활동", "안전장비", "주차장", "매표소"],
}
_DEFAULT_LABELS = ["외관", "내부", "시설", "기타"]

View File

@ -63,11 +63,20 @@ def check_required_fields(category: PlaceCategory, facts: list) -> GateResult:
return GateResult(True)
def check_unique_content(unique_content_count: int) -> GateResult:
"""고유 콘텐츠 품질 진단용. 발행 게이트에서는 더 이상 호출하지 않는다.
def check_unique_content(unique_content_count: int | None) -> GateResult:
"""렌더러가 '고유 콘텐츠 0건' 으로 거부했을 때 사유 코드를 붙이는 자리.
evaluate() 이걸 부르지 않는다(얇은 콘텐츠로 발행을 막지 않기로 했다).
부르는 곳은 build_service 렌더 보고서가 실패로 왔을 이유를 되짚는다.
가게에만 있는 (소개문·FAQ·객실 설명·사진 alt·템플릿 아닌 fact ) 0이면
같은 템플릿 대량 생성으로 보인다. 그건 스팸 판정 대상이고, 판정되면 사이트가 통째로 무의미해진다."""
같은 템플릿 대량 생성으로 보인다. 그건 스팸 판정 대상이고, 판정되면 사이트가 통째로 무의미해진다.
None '0건' 아니라 '재지 못했다' . 렌더러가 디스크·번들 문제로 죽으면 계수가
없는 채로 보고서가 온다 그걸 0 으로 읽으면 디스크 오류에 NO_UNIQUE_CONTENT 라는
엉뚱한 사유가 붙는다. 판정하지 않고 통과시키고, 진짜 사유는 report.error 말한다."""
if unique_content_count is None:
return GateResult(True)
if unique_content_count <= 0:
return GateResult(False, PublishRejectReason.NO_UNIQUE_CONTENT, {"unique_content_count": unique_content_count})
return GateResult(True)
@ -83,11 +92,17 @@ def check_jsonld_matches(mismatches: list) -> GateResult:
return GateResult(True)
def evaluate(category: PlaceCategory, facts: list, unique_content_count: int, mismatches: list) -> GateResult:
def evaluate(
category: PlaceCategory, facts: list, unique_content_count: int | None, mismatches: list
) -> GateResult:
"""게이트 전체. **처음 걸린 것에서 멈춘다** — 운영자가 하나씩 고치게 사유를 하나만 준다.
순서는 심각도 : 미검증(클레임) JSON-LD 불일치(거짓).
unique_content_count 받되 여기서 막지 않는다 얇은 콘텐츠는 '틀린 것' 아니다
(test_evaluate_does_not_block_thin_content). 실제로 페이지 쓰기를 거부하는 쪽은
렌더러이고, 사유는 build_service check_unique_content 되짚어 라벨을 붙인다.
업종 필수 항목 누락은 **막지 않는다**(2026-08-27 결정).
막아야 것은 "틀린 정보가 나가는 것"이지 "정보가 덜 찬 것" 아니다.
영업시간이 비어 있어도 주소·전화가 확인된 페이지는 자체로 쓸모가 있고,

View File

@ -108,16 +108,16 @@ _DEFAULT_THEME = {
("local", "주변 안내", False), ("faq", "자주 묻는 질문", False),
],
},
PlaceCategory.TOUR_ACTIVITY.value: {
"templateId": "tour-visual-tour",
PlaceCategory.CLINIC.value: {
"templateId": "clinic-visual-clinic",
"fontStyle": "Visual Journey",
"colors": {"primary": "#0f172a", "secondary": "#475569", "bg": "#ffffff",
"card": "#f8fafc", "text": "#020617", "accent": "#0284c7"},
"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),
("hero", "히어로", True), ("intro", "병원 소개", False), ("programs", "시술 안내", False),
("info", "기본 정보", True), ("exhibition", "진료 안내", False), ("photos", "사진 갤러리", False),
("inquiry", "상담 문의", False), ("map", "오시는 길", True), ("weather", "날씨", False),
("local", "주변 정보", False), ("faq", "자주 묻는 질문", False),
],
},
}
@ -363,6 +363,15 @@ def _sections(saved_sections, default_spec) -> list:
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
out.append(entry)
for sid, label, locked in default_spec:

View File

@ -136,6 +136,29 @@ async def test_no_unique_content_blocks_publish(auth_headers, client):
assert r["gate"]["reason"] == PublishRejectReason.NO_UNIQUE_CONTENT.name
async def test_owner_written_intro_counts_as_unique_content(auth_headers, client):
"""검증: fact·FAQ·사진 설명은 없지만 사장님이 소개 섹션 본문을 직접 썼다.
기대결과: 실제 발행본에 표시되는 소개문 1건으로 계수되어 발행된다."""
h = await auth_headers("owner-intro")
pid = await _place(client, h, "직접소개펜션")
for k, v in {**REQUIRED, "cancel_policy": "X"}.items():
await client.post(f"/v1/place/{pid}/fact", headers=h, json={"key": k, "value": v})
await client.post(f"/v1/place/{pid}/site/theme", headers=h, json={"theme": {"sections": [{
"id": "intro", "name": "소개", "enabled": True, "locked": False,
"body": "하조대의 아침 바다를 조용히 바라볼 수 있는 작은 숙소입니다.",
}]}})
job_id = (await client.post(
f"/v1/place/{pid}/site/build", headers=h, json={"publish": True}
)).json()["job_id"]
await _run()
r = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"]["result"]
assert r["gate"]["passed"] is True
assert r["unique_content_count"] == 1
assert r["published"] is True
async def test_missing_required_field_warns_but_publishes(auth_headers, client, db_engine):
"""검증: 취소 규정 없이 빌드한다.

View File

@ -10,7 +10,7 @@ from common.enums import PlaceCategory
def test_all_categories_have_schema():
"""검증: PlaceCategory 의 모든 업종에 스키마 파일이 있는지.
기대결과: 4 업종(숙박·카페·음식점·관광체험) 모두 로드되고 code 1:1 맞는다."""
기대결과: 4 업종(숙박·카페·음식점·피부과·성형외과) 모두 로드되고 code 1:1 맞는다."""
schemas = all_schemas()
assert set(schemas) == {c.value for c in PlaceCategory}
for code, schema in schemas.items():

View File

@ -79,6 +79,12 @@ def test_zero_unique_content_blocks_publish():
assert check_unique_content(1).passed is True
def test_unmeasured_unique_content_is_not_zero():
"""검증: 렌더러가 계수를 못 재고(None) 실패했다.
기대결과: 통과 디스크·번들 오류에 NO_UNIQUE_CONTENT 라는 엉뚱한 사유가 붙으면 된다."""
assert check_unique_content(None).passed is True
def test_jsonld_mismatch_blocks_publish():
"""검증: 구조화 데이터 값이 화면 값과 다르다.
기대결과: 규칙 3 JSONLD_MISMATCH 거부. 검색엔진에만 다른 말을 하면 된다."""

View File

@ -222,6 +222,18 @@ def test_owner_disabled_section_stays_disabled():
assert next(s for s in theme["sections"] if s["id"] == "photos")["enabled"] is False
def test_owner_written_section_body_reaches_publish_payload():
"""검증: 에디터에서 직접 쓴 소개 본문.
기대결과: 발행 payload 그대로 남는다 저장만 되고 경계에서 버려지면 발행본과 계수에 쓴다."""
theme = _payload_theme({"sections": [{
"id": "intro", "name": "소개", "enabled": True, "locked": False,
"body": "바다를 보며 조용히 쉬어가는 작은 숙소입니다.",
}]})
assert next(s for s in theme["sections"] if s["id"] == "intro")["body"] == (
"바다를 보며 조용히 쉬어가는 작은 숙소입니다."
)
def test_partial_colors_are_filled_from_category_default():
"""검증: 저장된 색이 일부 키만 담고 있을 때.
기대결과: 빠진 자리는 업종 기본이 메운다 렌더러 타입이 6개를 모두 요구하므로
@ -255,15 +267,15 @@ def test_saved_theme_does_not_override_chosen_template():
# 여기에 없는 섹션은 사장님이 에디터에서 아무리 봐도 사이트에 나오지 않는다.
# 실측으로 날씨·실시간 예약·대관 문의·관람 안내가 그렇게 빠져 있었다.
_ADMIN_SECTIONS_TS = (
pathlib.Path(__file__).resolve().parents[2] / "front/src/data/industryData.ts" # parents[2] = solution/
pathlib.Path(__file__).resolve().parents[2] / "frontend/src/data/industryData.ts" # parents[2] = solution/
)
# 에디터 업종 키 → PlaceCategory. 이름이 다른 건 두 층의 어휘가 달라서다(tour vs TOUR_ACTIVITY).
# 에디터 업종 키 → PlaceCategory. 이름이 다른 건 두 층의 어휘가 달라서다(clinic vs CLINIC).
_INDUSTRY_TO_CATEGORY = {
"stay": PlaceCategory.LODGING.value,
"cafe": PlaceCategory.CAFE.value,
"restaurant": PlaceCategory.RESTAURANT.value,
"tour": PlaceCategory.TOUR_ACTIVITY.value,
"clinic": PlaceCategory.CLINIC.value,
}

View File

@ -17,5 +17,5 @@ export const PlaceCategory = {
LODGING: 1,
CAFE: 2,
RESTAURANT: 3,
TOUR_ACTIVITY: 4,
CLINIC: 4,
} as const;

View File

@ -2,17 +2,13 @@ import {QueryClientProvider} from '@tanstack/react-query';
import {useEffect, type ReactNode} from 'react';
import {Toaster} from 'sonner';
import {getAccessToken, me} from '@/api';
import {ensureAutoSession} from '@/lib/autoSession';
import {queryClient} from '@/lib/query-client';
import {toAuthUser, useAuthStore} from '@/stores/auth';
/**
* .
* · , .
*
* (VITE_AUTO_LOGIN_ID·PW) **** .
* , RequireAuth
* . .
* .
* ,
* . .
*/
function useRestoreSession() {
const setUser = useAuthStore((s) => s.setUser);
@ -20,27 +16,24 @@ function useRestoreSession() {
useEffect(() => {
let alive = true;
void (async () => {
if (!getAccessToken()) await ensureAutoSession();
if (!getAccessToken()) {
if (alive) finishRestore();
return;
}
// 자동 로그인이 방금 신원까지 채웠으면 me() 를 두 번 부르지 않는다.
if (useAuthStore.getState().user) {
if (alive) finishRestore();
return;
}
try {
const res = await me();
if (!getAccessToken()) {
finishRestore();
return;
}
void me()
.then((res) => {
if (!alive || res.result?.success === false) return;
// ★ RemoveNoneResponse 라 신원 필드가 통째로 빠져 올 수 있다.
// 반쪽짜리 사용자를 세우면 화면은 로그인된 것처럼 굴면서 요청은 401 이 난다 — 세우지 않는다.
if (alive && res.result?.success !== false && res.user_id && res.id) setUser(toAuthUser(res));
} catch {
if (!res.user_id || !res.id) return;
setUser(toAuthUser(res));
})
.catch(() => {
/* 토큰이 죽었으면 비로그인 상태로 계속 간다. */
}
if (alive) finishRestore();
})();
})
.finally(() => {
if (alive) finishRestore();
});
return () => {
alive = false;
};

View File

@ -1,5 +1,4 @@
import {createBrowserRouter, Navigate} from 'react-router';
import {RequireAuth} from '@/components/layout/RequireAuth';
import {BuilderPage} from '@/pages/BuilderPage';
import {DevShowcasePage} from '@/pages/DevShowcasePage';
import {LoginPage} from '@/pages/LoginPage';
@ -8,6 +7,7 @@ import {SignupPage} from '@/pages/SignupPage';
export const router = createBrowserRouter([
{path: '/login', element: <LoginPage />},
// 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다.
{path: '/signup', element: <SignupPage />},
// ★ 첫 화면은 업종 선택(위저드 1단계)이다.
@ -17,26 +17,15 @@ export const router = createBrowserRouter([
{path: '/', element: <Navigate to="/builder?new=1" replace />},
/**
* .
*
* .
*
* ( ). 2
* , · ** **
* 3 "로그인이 만료되었습니다" .
* .
* VITE_AUTO_LOGIN_ID·PW useAutoLogin()
* , (2 ) .
*
* (VITE_AUTO_LOGIN_ID·PW)
* (app/provider.tsx). .
*
* (6) AppShell BuilderPage .
* (6) AppShell BuilderPage .
*/
{
path: '/builder',
element: (
<RequireAuth>
<BuilderPage />
</RequireAuth>
),
},
{path: '/builder', element: <BuilderPage />},
/**
* (/places, /local-content, /seo) `admin/` .

View File

@ -103,7 +103,7 @@ function templatesFor(
fontStyle: '옛 간판체',
look: LOOK.retro,
// 이 템플릿이 팔려는 게 바로 이 아이템들이다.
defaultSectionTypes: ['songs', 'daily', 'course'],
defaultSectionTypes: ['songs', 'daily', 'course', 'schedule'],
},
];
}
@ -197,32 +197,32 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
}),
},
tour: {
id: 'tour',
name: '관광 · 체험',
subName: '체험공간 · 전시',
description: '원데이 클래스, 도자기·목공 공방, 사설 갤러리, 팜스테이',
clinic: {
id: 'clinic',
name: '피부과 · 성형외과',
subName: '의원 · 클리닉',
description: '피부과, 성형외과, 미용 클리닉',
channels: [
{ id: 'naver_place', name: '네이버 플레이스', checked: true },
{ id: 'frip', 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: true, description: '현재 기온과 체험장 주변 날씨' },
{ id: 'local', type: 'local', name: '주변 관광 코스', isLocked: false, isEnabled: true, description: '도예마을 맛집 및 주변 힐링 스팟' },
{ id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '작품 수령 기간, 복장, 환불 규정' },
{ 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: '예약 변경, 회복 기간, 주의사항' },
],
templates: templatesFor('tour', '#7c3aed', {
name: '시간여행',
description: '갱지 바탕에 간판체. 일력과 승차권으로 지역의 시간을 걸어 다니게 만듭니다.',
templates: templatesFor('clinic', '#4A9DC4', {
name: '클린',
description: '여백과 낮은 채도. 과장 없이 정보를 먼저 보여줍니다.',
}),
},
};

View File

@ -0,0 +1,37 @@
/**
* .
*
* (1~5) .
* .
* .
* /login . .
*/
import {SignInForm} from './SignInForm';
export function EditorSignInGate({onBack}: {onBack: () => void}) {
return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4">
<SignInForm
submitLabel="로그인하고 편집 시작"
header={
<div className="space-y-1 text-center">
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="mx-auto mb-3 h-9 w-auto" />
<h1 className="text-sm font-bold"> </h1>
<p className="text-xs leading-relaxed text-muted-foreground">
. .
</p>
</div>
}
footer={
<button
type="button"
onClick={onBack}
className="w-full text-center text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
</button>
}
/>
</div>
);
}

View File

@ -0,0 +1,95 @@
/**
* .
*
* .
* (signIn me) , "로그인은 됐는데 계속 401" .
*/
import {useState, type FormEvent, type ReactNode} from 'react';
import {LogIn} from 'lucide-react';
import {login} from '@/api';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {notifyApiError} from '@/lib/notify';
import {establishSession} from '@/lib/session';
interface SignInFormProps {
/** 폼 위에 붙는 제목·설명. 화면마다 하는 말이 다르다. */
header: ReactNode;
/** 폼 아래 각주(빌더로 돌아가기 등). */
footer?: ReactNode;
submitLabel?: string;
onSignedIn?: () => void;
}
export function SignInForm({header, footer, submitLabel = '로그인', onSignedIn}: SignInFormProps) {
const [id, setId] = useState('');
const [password, setPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
setIsSubmitting(true);
try {
const res = await login({id, password});
if (res.result?.success === false) {
notifyApiError({data: res}, '아이디 또는 비밀번호를 확인해 주세요.');
return;
}
// 토큰 심는 순서(signIn → me)는 lib/session 한 곳에만 둔다 — 이 파일 맨 위 주석이
// 경고하던 그 중복이다. 로그인 화면·가입 화면·자동 로그인이 전부 같은 함수를 쓴다.
if (!(await establishSession(res, id))) {
notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.');
return;
}
onSignedIn?.();
} catch (error) {
notifyApiError(error, '로그인에 실패했습니다.');
} finally {
setIsSubmitting(false);
}
};
return (
<form
onSubmit={handleSubmit}
className="w-full max-w-sm space-y-4 rounded-2xl border border-border bg-card p-7"
>
{header}
<div className="space-y-3">
<div>
<label htmlFor="login-id" className="mb-1.5 block text-xs font-semibold">
</label>
<Input
id="login-id"
value={id}
onChange={(e) => setId(e.target.value)}
autoComplete="username"
required
/>
</div>
<div>
<label htmlFor="login-pw" className="mb-1.5 block text-xs font-semibold">
</label>
<Input
id="login-pw"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
</div>
<Button type="submit" variant="primary" className="w-full" isLoading={isSubmitting}>
<LogIn />
<span>{submitLabel}</span>
</Button>
{footer}
</form>
);
}

View File

@ -55,6 +55,26 @@ export interface CourseItem {
source?: DataSource;
}
export interface ScheduleSlot {
/** 24시간 표기 "09:30". 정렬·플립 시각 표시가 이 값을 그대로 쓴다. */
time: string;
title: string;
place?: string;
minutes?: number;
note?: string;
searchQuery?: string;
}
export interface ScheduleItem {
name: string;
/** 누구를 위한 하루인가("혼자 온 손님" · "아이와 함께"). 고르는 기준이 된다. */
audience?: string;
season?: string;
slots?: ScheduleSlot[];
verified?: DataVerified;
source?: DataSource;
}
export interface SectionDataSpec {
/** JSON 봉투의 `kind`. 섹션 타입과 같은 값이라 다른 아이템 JSON 을 붙여넣으면 바로 잡힌다. */
kind: string;
@ -302,6 +322,56 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
· minutes . note "차로 이동" .
· · . .`,
},
schedule: {
kind: 'schedule',
label: '여행 스케줄',
requiredKey: 'name',
sample: JSON.stringify(
{
kind: 'schedule',
version: 1,
title: '여행 스케줄',
items: [
{
name: '비 오는 날의 하루',
audience: '혼자 온 손님',
season: '장마',
slots: [
{time: '09:00', title: '늦은 아침', place: '스테이 머뭄', minutes: 60, note: '창가 자리에서 비 소리를 먼저 듣습니다.'},
{time: '10:30', title: '실내로 피신', place: '군산근대역사박물관', minutes: 90, note: '항구 도시가 어떻게 만들어졌는지 한 바퀴.', searchQuery: '군산근대역사박물관'},
{time: '12:30', title: '점심', place: '한일옥', minutes: 60, note: '무국 한 그릇으로 몸을 데웁니다.', searchQuery: '군산 한일옥'},
{time: '14:00', title: '책과 커피', place: '마리서사', minutes: 120, note: '비 그칠 때까지 앉아 있기 좋은 곳입니다.', searchQuery: '군산 마리서사'},
{time: '17:00', title: '해 질 무렵 산책', place: '경암동 철길마을', minutes: 60, note: '비 온 뒤 철길에 물이 고여 하늘이 두 번 보입니다.', searchQuery: '군산 경암동 철길마을'},
],
verified: '확인필요',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour'},
},
],
},
null,
2,
),
task: `[해야 할 일]
[] [] 2~3 .
5~7. .
( )·( )· .
[]
{ "kind":"schedule", "version":1, "title":"여행 스케줄", "items":[
{ "name":"스케줄 이름", "audience":"누구를 위한 하루", "season":"계절·날씨",
"slots":[ {"time":"09:00","title":"무엇을 하나","place":"장소",
"minutes":60,"note":"한 문장","searchQuery":"지도 검색어"} ],
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
rules: `
[ ]
· time 24 "HH:MM" . "오전 9시" .
· · . .
· [] , .
· url . searchQuery .
· note "예약 필요" .`,
},
};
export function dataSpecFor(sectionType: string): SectionDataSpec | undefined {

View File

@ -67,6 +67,7 @@ import {ExhibitionNotice} from './variants/exhibition/ExhibitionNotice';
import {SongsTurntable} from './variants/songs/SongsTurntable';
import {DailyCalendar} from './variants/daily/DailyCalendar';
import {CourseTickets} from './variants/course/CourseTickets';
import {ScheduleTimetable} from './variants/schedule/ScheduleTimetable';
export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
hero: [
@ -435,6 +436,17 @@ export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
},
],
schedule: [
{
id: 'schedule.timetable',
name: '대합실 시간표',
description: '칸 하나가 시간대 하나. 검은 플립보드에 시각이 먼저 뜬다.',
thumb: 'carousel',
Component: ScheduleTimetable,
isDefault: true,
},
],
exhibition: [
{
id: 'exhibition.gallery',

View File

@ -0,0 +1,174 @@
/**
* .
*
* . ( )
* .
* (course) . '어디를 도는가'(), '언제 무엇을'().
* . searchQuery .
*/
import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type ScheduleItem, type ScheduleSlot} from '../../dataSpec';
import {
CarouselNav,
ParseError,
PasteHint,
RETRO_BODY,
RETRO_INK,
RETRO_LINE,
RETRO_PAPER_LIGHT,
RETRO_RED,
RETRO_SIGN,
SourceLine,
useCarousel,
} from '../retro/common';
import '../retro/retro.css';
/** "09:30" → 9시 30분. 형식이 어긋나면 원문을 그대로 보여준다(지어내지 않는다). */
function splitTime(time: string): {head: string; tail?: string} {
const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
if (!match) return {head: time.trim()};
return {head: match[1].padStart(2, '0'), tail: match[2]};
}
function Slot({slot, isLast}: {slot: ScheduleSlot; isLast: boolean}) {
const {head, tail} = splitTime(slot.time ?? '');
return (
<div className="flex shrink-0 snap-center items-stretch">
<div
className="w-[228px] border shadow-[4px_4px_0_rgba(27,26,21,.13)]"
style={{backgroundColor: RETRO_PAPER_LIGHT, borderColor: RETRO_INK}}
>
{/* 플립보드 — 가운데 접힘선이 이 판을 시계로 만든다 */}
<div className="relative px-4 py-3 text-center" style={{backgroundColor: RETRO_INK}}>
<span
className="inline-flex items-baseline gap-1 leading-none text-[#f2ebd9]"
style={{fontFamily: RETRO_SIGN, fontSize: 30}}
>
{head}
{tail && <span className="text-[#c0b493]">:{tail}</span>}
</span>
<i
aria-hidden
className="pointer-events-none absolute inset-x-0 top-1/2 border-t"
style={{borderColor: 'rgba(242,235,217,.22)'}}
/>
</div>
<div className="space-y-2 px-4 pb-3 pt-3.5">
<h4 className="text-base font-bold text-stone-900" style={{fontFamily: RETRO_BODY}}>
{slot.title}
</h4>
{slot.place && (
<p className="text-[12px] font-semibold" style={{fontFamily: RETRO_BODY, color: RETRO_RED}}>
{slot.place}
</p>
)}
{slot.note && (
<p className="text-[13px] leading-relaxed text-stone-600" style={{fontFamily: RETRO_BODY}}>
{slot.note}
</p>
)}
</div>
<div
className="flex justify-between gap-2 border-t border-dashed px-4 py-2 text-[10px] text-stone-500"
style={{borderColor: RETRO_LINE}}
>
<span>{slot.minutes ? `${slot.minutes}분 머묾` : '머무는 시간 미정'}</span>
{slot.searchQuery && <span className="truncate"> · {slot.searchQuery}</span>}
</div>
</div>
{/* 칸과 칸 사이의 시간 — 점선이 이어져야 '흐른다'로 읽힌다 */}
{!isLast && (
<div aria-hidden className="flex w-8 items-center justify-center">
<i className="block h-px w-full border-t border-dashed" style={{borderColor: RETRO_LINE}} />
</div>
)}
</div>
);
}
function ScheduleRow({schedule}: {schedule: ScheduleItem}) {
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
const slots = schedule.slots ?? [];
const span =
slots.length > 1 ? `${slots[0]?.time ?? ''}${slots[slots.length - 1]?.time ?? ''}` : undefined;
return (
<div className="space-y-2.5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div className="flex flex-wrap items-baseline gap-2.5">
<h3 className="text-lg text-stone-900" style={{fontFamily: RETRO_SIGN}}>
{schedule.name}
</h3>
<span className="text-[11px] text-stone-500">
{[schedule.audience, schedule.season, span, `${slots.length}`]
.filter(Boolean)
.join(' · ')}
</span>
</div>
{slots.length > 2 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} label={schedule.name} />
)}
</div>
{slots.length === 0 ? (
<p
className="border border-dashed px-4 py-5 text-center text-[11px] text-stone-500"
style={{borderColor: RETRO_LINE}}
>
. JSON slots .
</p>
) : (
<div ref={ref} className="w4-scroll flex snap-x snap-mandatory overflow-x-auto pb-3">
{slots.map((slot, index) => (
<Slot
key={`${slot.time}-${slot.title}-${index}`}
slot={slot}
isLast={index === slots.length - 1}
/>
))}
</div>
)}
<SourceLine source={schedule.source} verified={schedule.verified} />
</div>
);
}
export function ScheduleTimetable(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<ScheduleItem>(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 text-stone-900 sm:text-3xl" style={{fontFamily: RETRO_SIGN}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm text-stone-600" style={{fontFamily: RETRO_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : parsed.items.length === 0 ? (
<PasteHint label="여행 스케줄" />
) : (
<div className="space-y-8">
{parsed.items.map((schedule, index) => (
<ScheduleRow key={`${schedule.name}-${index}`} schedule={schedule} />
))}
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -29,11 +29,11 @@ const SOURCE = [
{industry: 'restaurant', id: 'food-sage', name: '세이지 다이닝', swatches: ['#F7E7DC', '#FFF8F3', '#758694', '#405D72']},
{industry: 'restaurant', id: 'food-night', name: '나이트 다이닝', swatches: ['#F5E8C7', '#DEBA9D', '#9E7777', '#6F4C5B']},
{industry: 'tour', id: 'tour-teal', name: '틸 앰버', swatches: ['#FFE2AF', '#E37434', '#24B1B1', '#007979']},
{industry: 'tour', id: 'tour-sky', name: '클리어 스카이', swatches: ['#F9F7F7', '#DBE2EF', '#3F72AF', '#112D4E']},
{industry: 'tour', id: 'tour-spring', name: '스프링 필드', swatches: ['#F6F193', '#C5EBAA', '#A5DD9B', '#A0D683']},
{industry: 'tour', id: 'tour-sunset', name: '선셋 어드벤처', swatches: ['#FFEEA9', '#FFBF78', '#FF7D29', '#FF4E88']},
{industry: 'tour', id: 'tour-violet', name: '바이올렛 플레이', swatches: ['#F3F8FF', '#E26EE5', '#7E30E1', '#49108B']},
{industry: 'clinic', id: 'clinic-clean', name: '클린 블루', swatches: ['#F7FAFC', '#E3F0F7', '#4A9DC4', '#1E5F7A']},
{industry: 'clinic', id: 'clinic-sky', name: '클리어 스카이', swatches: ['#F9F7F7', '#DBE2EF', '#3F72AF', '#112D4E']},
{industry: 'clinic', id: 'clinic-sage', name: '세이지 그린', swatches: ['#F6F7F2', '#DCE5D5', '#9FB89A', '#5E7A61']},
{industry: 'clinic', id: 'clinic-nude', name: '누드 베이지', swatches: ['#FBF6F0', '#EFE0D1', '#C9A88A', '#8C6A4F']},
{industry: 'clinic', id: 'clinic-mono', name: '모노 그레이', swatches: ['#FAFAFA', '#E5E5E5', '#8A8A8A', '#2B2B2B']},
] satisfies Array<{industry: IndustryType; id: string; name: string; swatches: string[]}>;
export const COLOR_PALETTE_PRESETS: ColorPalettePreset[] = SOURCE.flatMap((preset) => {

View File

@ -18,7 +18,7 @@ export const CATEGORY_TO_INDUSTRY: Record<number, IndustryType> = {
[PlaceCategory.LODGING]: 'stay',
[PlaceCategory.CAFE]: 'cafe',
[PlaceCategory.RESTAURANT]: 'restaurant',
[PlaceCategory.TOUR_ACTIVITY]: 'tour',
[PlaceCategory.CLINIC]: 'clinic',
};
/** facts.source_type — 사장님이 [맞아요] 를 누를 판단 근거. 출처 없는 값은 보여주지 않는다. */

View File

@ -7,7 +7,7 @@ import {INDUSTRY_ICONS} from './industryIcons';
import {WizardFooter} from './WizardFooter';
import {WizardSteps} from './WizardSteps';
const INDUSTRY_ORDER: IndustryType[] = ['stay', 'cafe', 'restaurant', 'tour'];
const INDUSTRY_ORDER: IndustryType[] = ['stay', 'cafe', 'restaurant', 'clinic'];
export function Step1Industry() {
const industry = useBuilderStore((s) => s.industry);

View File

@ -1,7 +1,7 @@
import {useState} from 'react';
import {useSearchParams} from 'react-router';
import {ArrowRight, Check, MapPin, Phone, Search, TriangleAlert} from 'lucide-react';
import type {PlaceCandidate} from '@/api';
import {getAccessToken, type PlaceCandidate} from '@/api';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
@ -83,6 +83,14 @@ export function Step2PlaceSearch() {
const runSearch = () => {
clearIdentity(); // 상호를 고쳐 다시 찾는 것이므로 앞서 확정한 신원은 물린다
setPickedIndex(null);
// ★ 로그인 전에는 서버를 부르지 않는다. 로그인은 에디터 진입에서 한 번 받는 것이 이 앱의 흐름인데,
// 장소 API 는 전부 토큰을 요구해서(place.py) 여기서 부르면 2단계가 로그인 벽이 된다.
// 입력한 값으로 신원을 세우고 넘어간다 — 검증은 로그인 뒤에 다시 할 수 있다.
if (!getAccessToken()) {
confirmIdentity(search.confirmManual(storeName, location));
goToStep(3);
return;
}
void search.search(storeName, location);
};
@ -113,6 +121,12 @@ export function Step2PlaceSearch() {
const pickByUrl = async () => {
const url = placeUrl.trim();
if (!url) return;
// URL 확인도 서버가 토큰을 요구한다 — 로그인 전에는 입력값으로 넘어간다.
if (!getAccessToken()) {
confirmIdentity(search.confirmManual(storeName, location));
goToStep(3);
return;
}
const identity = await search.confirmByUrl(url);
if (!identity) return;
confirmIdentity(identity);

View File

@ -1,4 +1,4 @@
import {Building2, Coffee, Compass, UtensilsCrossed} from 'lucide-react';
import {Building2, Coffee, Stethoscope, UtensilsCrossed} from 'lucide-react';
import type {IndustryType} from '@o2o/shared';
/** 업종 아이콘. lucide 하나로 통일한다 — 손으로 그린 SVG 세트를 따로 두지 않는다. */
@ -6,5 +6,5 @@ export const INDUSTRY_ICONS: Record<IndustryType, typeof Building2> = {
stay: Building2,
cafe: Coffee,
restaurant: UtensilsCrossed,
tour: Compass,
clinic: Stethoscope,
};

View File

@ -20,7 +20,7 @@ const INDUSTRY_TO_CATEGORY: Record<IndustryType, PlaceCategory> = {
stay: PlaceCategoryEnum.LODGING,
cafe: PlaceCategoryEnum.CAFE,
restaurant: PlaceCategoryEnum.RESTAURANT,
tour: PlaceCategoryEnum.TOUR_ACTIVITY,
clinic: PlaceCategoryEnum.CLINIC,
};
/** 후보를 어느 장소 DB 에서 찾았는지 — 사장님이 판단할 근거로 카드에 그대로 붙인다. */
@ -132,14 +132,10 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
// 누른 검색이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다.
await ensureAutoSession();
// ★ /builder 가 RequireAuth 뒤라 여기까지 왔으면 토큰이 있어야 한다. 없다면 위저드를
// 걷는 도중에 세션이 끊긴 것이다 — 그건 실제로 '만료' 이므로 그렇게 말한다.
// ★ 토큰이 없으면 화면(Step2)이 검색을 부르지 않고 입력값으로 넘어간다 — 2단계는 로그인 벽이 아니다.
// 그래도 여기 도달했다면 세션이 도중에 끊긴 것이므로, 로그인을 요구하지 말고 조용히 물러난다.
if (!getAccessToken()) {
setState({
...INITIAL,
phase: 'unavailable',
unavailableReason: '로그인이 만료되었습니다. 다시 로그인한 뒤 검색해 주세요.',
});
setState({...INITIAL});
return;
}
@ -270,5 +266,15 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
* (POST /verify) . URL
* . .
*/
return {...state, isConfirming, search, confirm, confirmByUrl, reset};
const confirmManual = useCallback((name: string, address: string): ConfirmedIdentity => {
return {
placeId: placeIdRef.current,
name: name.trim(),
address: address.trim(),
origin: 'owner',
sourceLabel: '직접 입력',
};
}, []);
return {...state, isConfirming, search, confirm, confirmByUrl, confirmManual, reset};
}

View File

@ -7,7 +7,7 @@ export const SCHEMA_TYPE_BY_INDUSTRY: Record<IndustryType, string> = {
stay: 'LodgingBusiness',
cafe: 'CafeOrCoffeeShop',
restaurant: 'Restaurant',
tour: 'TouristAttraction',
clinic: 'MedicalClinic',
};
interface AeoItem {

View File

@ -0,0 +1,14 @@
import {useEffect} from 'react';
import {ensureAutoSession} from '@/lib/autoSession';
/**
* .
*
* `lib/autoSession` (usePlaceSearch)
* , .
*/
export function useAutoLogin() {
useEffect(() => {
void ensureAutoSession();
}, []);
}

View File

@ -2,7 +2,9 @@ import {useEffect, useRef} from 'react';
import {ArrowLeft, ExternalLink, Loader2, LogOut, TriangleAlert} from 'lucide-react';
import {Link, useSearchParams} from 'react-router';
import {SiteStatus} from '@o2o/shared';
import {getAccessToken} from '@/api';
import {AppShell} from '@/components/layout/AppShell';
import {EditorSignInGate} from '@/features/auth/EditorSignInGate';
import {
Step1Industry,
Step2PlaceSearch,
@ -11,9 +13,10 @@ import {
Step5Generating,
} from '@/features/onboarding';
import {EditorLayout} from '@/features/builder';
import {useAutoLogin} from '@/hooks/useAutoLogin';
import {usePlaceSync} from '@/hooks/usePlaceSync';
import {EDITOR_STEP, useBuilderStore} from '@/stores/builder';
import {userLabel, useAuthStore} from '@/stores/auth';
import {EDITOR_STEP, useBuilderStore} from '@/stores/builder';
/** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */
const SITE_PREVIEW_URL = import.meta.env.VITE_SITE_PREVIEW_URL ?? window.location.origin;
@ -32,12 +35,14 @@ function siteUrl(domain: string | null | undefined): string | null {
}
export function BuilderPage() {
useAutoLogin();
/**
* `/builder?placeId=<uuid>`.
*
* (`/builder/:placeId`) 이유: placeId
* ( vs ).
* . placeId .
* (`/builder/:placeId`) 이유: 빌더는
* (router.tsx ), placeId .
* .
* placeId .
*/
const [searchParams, setSearchParams] = useSearchParams();
const urlPlaceId = searchParams.get('placeId');
@ -84,6 +89,8 @@ export function BuilderPage() {
// 에디터는 AppShell(사이드바)을 안 쓴다 — 누구로 로그인했는지·나가는 길이 여기 없으면 아예 없다.
const user = useAuthStore((s) => s.user);
const signOut = useAuthStore((s) => s.signOut);
// ★ 스토어의 user 만 보면 자동 로그인이 심어 둔 토큰을 놓친다 — 둘 다 본다.
const isSignedIn = Boolean(user) || Boolean(getAccessToken());
// 배지는 주소창이 아니라 스토어가 기준이다 — [처음부터]로 데모로 돌아간 뒤에도
// 주소창에는 placeId 가 남아 있어서, 그걸 믿으면 데모를 실사업장이라고 표시한다.
const wiredPlaceId = useBuilderStore((s) => s.placeId);
@ -120,6 +127,11 @@ export function BuilderPage() {
);
}
// 에디터에 들어갈 때 로그인을 받는다. 위저드(1~5단계)는 요구하지 않는다.
if (step === EDITOR_STEP && !isSignedIn) {
return <EditorSignInGate onBack={() => goToStep(4)} />;
}
if (step === EDITOR_STEP) {
return (
<div className="relative flex h-screen w-screen flex-col overflow-hidden">

View File

@ -5,7 +5,7 @@
* SitePayload "발행 잡이 구운 결과". .
*/
export type IndustryType = 'stay' | 'cafe' | 'restaurant' | 'tour';
export type IndustryType = 'stay' | 'cafe' | 'restaurant' | 'clinic';
export type TemplateTone = 'photo' | 'info' | 'book';
@ -152,7 +152,7 @@ export type SlateNode = SlateElementNode | SlateTextNode;
* ( · · · ).
*
* .
* ( size/spec, tag, duration/target .)
* ( size/spec, tag, · duration/target .)
*/
export interface IndustryListItem {
name: string;
@ -178,7 +178,7 @@ export interface IndustryCustomData {
menuList?: IndustryListItem[];
/** 음식점 — 코스 목록 */
courseList?: IndustryListItem[];
/** 관광체험 — 프로그램 목록 */
/** 피부과·성형외과 — 시술 목록 */
programList?: IndustryListItem[];
/** 이용 규정 문장들 */
rules?: string[];

View File

@ -12,7 +12,7 @@ export const PlaceCategory = {
LODGING: 1,
CAFE: 2,
RESTAURANT: 3,
TOUR_ACTIVITY: 4,
CLINIC: 4,
} as const;
export type PlaceCategory = (typeof PlaceCategory)[keyof typeof PlaceCategory];

View File

@ -249,4 +249,13 @@ export interface SectionSetting {
* .
*/
variantId?: string;
/**
* .
*
* variantId payload
* . (sites.theme) ,
* "소개를 썼는데 고유 콘텐츠 0건으로 발행이 막힌다" .
* . <p> .
*/
body?: string;
}

View File

@ -192,6 +192,24 @@ class VerifyError extends Error {
}
}
/**
* 0. **VerifyError .**
*
* VerifyError mismatches .
* mismatches JSONLD_MISMATCH ,
* "구조화 데이터와 화면 값이 다릅니다" ** **
* . .
*/
class NoUniqueContentError extends Error {
/** 백엔드가 JSONLD_MISMATCH 로 오인하지 않도록 늘 비어 있다. */
readonly mismatches: string[] = [];
constructor(readonly uniqueContentCount: number) {
super('고유 콘텐츠가 0건이다 — 이 가게에만 있는 내용이 없으면 발행하지 않는다');
this.name = 'NoUniqueContentError';
}
}
/**
* ** **(2026-08-31). ·····FAQ
* 30 HTML .
@ -321,10 +339,7 @@ function prerenderSite(input: SitePayload, outRoot: string, assets: ReturnType<t
* .
*/
if (uniqueContentCount <= 0) {
throw new VerifyError(
['고유 콘텐츠가 0건이다 — 이 가게에만 있는 내용이 없으면 발행하지 않는다'],
uniqueContentCount,
);
throw new NoUniqueContentError(uniqueContentCount);
}
writeFile(siteDir, 'index.html', html);
@ -377,6 +392,11 @@ function countUniqueContent(payload: SitePayload): number {
const long = (value: unknown) => String(value ?? '').trim().length >= MIN_UNIQUE_TEXT;
let count = 0;
// 소개 섹션의 직접 입력 본문은 실제 AboutSection 에 표시되는 가게 고유 문장이다.
// ★ enabled 를 함께 본다. 꺼서 페이지에 없는 문장까지 세면 빈 페이지가 게이트를 통과한다.
const intro = payload.theme.sections.find((section) => section.id === 'intro');
if (intro?.enabled && long(intro.body)) count += 1;
// 문장형 fact 만 센다 — bool·number·time 은 전부 템플릿 값이라 가게를 구분하지 못한다.
for (const fact of [...payload.facts, ...payload.units.flatMap((unit) => unit.facts)]) {
if (fact.type === 'text' && long(fact.value)) count += 1;
@ -585,11 +605,14 @@ function main() {
} catch (ex) {
// ★ 한 사이트가 깨졌다고 나머지를 못 굽게 두지 않는다. 실패는 보고서로 남긴다 —
// 조용히 넘어가면 "발행했는데 페이지가 없다"가 다시 반복된다.
// ★ 계수를 못 잰 실패(디스크·번들·payload 파손)는 null 로 남긴다. 0 으로 적으면
// 백엔드가 "고유 콘텐츠 0건" 으로 읽어 또 엉뚱한 사유를 붙인다.
const counted = ex instanceof VerifyError || ex instanceof NoUniqueContentError ? ex : null;
fail(
entry,
ex instanceof Error ? ex.message : String(ex),
ex instanceof VerifyError ? ex.mismatches : [],
ex instanceof VerifyError ? ex.uniqueContentCount : null,
counted?.mismatches ?? [],
counted?.uniqueContentCount ?? null,
);
}
}

View File

@ -121,6 +121,12 @@ export function isSectionEnabled(payload: SitePayload, id: string): boolean {
return payload.theme.sections.find((section) => section.id === id)?.enabled ?? false;
}
/** 사장님이 에디터에서 직접 쓴 섹션 본문. 빈 줄을 문단 경계로 쓴다. */
export function sectionBody(payload: SitePayload, id: string): string[] {
const body = payload.theme.sections.find((section) => section.id === id)?.body;
return body?.split(/\n\s*\n/).map((paragraph) => paragraph.trim()).filter(Boolean) ?? [];
}
export function unitSpec(payload: SitePayload) {
return UNIT_SPEC[payload.place.category];
}
@ -201,7 +207,7 @@ export function ruleRows(payload: SitePayload): InfoRow[] {
/**
* fact.
*
* ( ) · key .
* ( ) ·· key .
* .
*/
const BOOKING_FACT_KEYS = ['reservation_required', 'reservation_channel'] as const;
@ -230,7 +236,7 @@ export function spaceRows(payload: SitePayload): InfoRow[] {
}
/**
* fact (tour_activity.json) key.
* fact · (clinic.json) key.
*
* · . "관람 안내" ,
* (info) .

View File

@ -1,10 +1,14 @@
import {useSite} from '@/lib/site-context';
import {galleryImages} from '@/lib/derive';
import {galleryImages, sectionBody} from '@/lib/derive';
export function AboutSection() {
const payload = useSite();
const {narrative, place} = payload;
if (narrative.about.length === 0) return null;
// 직접 입력한 소개가 있으면 그 문장이 이긴다. 없을 때만 수집·승인된 intro fact 로 떨어진다.
// ★ 에디터 본문을 계수만 하고 화면에 안 내면 빈 페이지가 발행 게이트를 통과한다.
const paragraphs = sectionBody(payload, 'intro');
const about = paragraphs.length > 0 ? paragraphs : narrative.about;
if (about.length === 0) return null;
// 대표가 아닌 사진 중 첫 장. 히어로와 같은 사진이 두 번 나오지 않게.
const image = galleryImages(payload).find((m) => !m.isPrimary);
@ -46,7 +50,7 @@ export function AboutSection() {
</h2>
<div className="space-y-3 text-sm leading-relaxed opacity-80">
{narrative.about.map((paragraph, index) => (
{about.map((paragraph, index) => (
<p key={index}>{paragraph}</p>
))}
</div>

View File

@ -28,7 +28,7 @@ export const SCHEMA_TYPE: Record<PlaceCategory, string> = {
[PlaceCategory.LODGING]: 'LodgingBusiness',
[PlaceCategory.CAFE]: 'CafeOrCoffeeShop',
[PlaceCategory.RESTAURANT]: 'Restaurant',
[PlaceCategory.TOUR_ACTIVITY]: 'TouristAttraction',
[PlaceCategory.CLINIC]: 'MedicalClinic',
};
/** 업종 → 하위 단위의 Schema.org 타입과 URL 경로. */
@ -36,7 +36,7 @@ export const UNIT_SPEC: Record<PlaceCategory, {type: string; path: string; label
[PlaceCategory.LODGING]: {type: 'HotelRoom', path: 'rooms', label: '객실'},
[PlaceCategory.CAFE]: {type: 'MenuItem', path: 'menu', label: '메뉴'},
[PlaceCategory.RESTAURANT]: {type: 'MenuItem', path: 'menu', label: '메뉴'},
[PlaceCategory.TOUR_ACTIVITY]: {type: 'Product', path: 'programs', label: '프로그램'},
[PlaceCategory.CLINIC]: {type: 'Product', path: 'programs', label: '시술'},
};
export type Json = Record<string, unknown>;