업종 4번째를 관광체험 → 피부과·성형외과 로 바꾸고, 로그인 관문을 에디터 진입으로 되돌린다

## 업종 교체 (tour → clinic)

PlaceCategory 코드 4번의 의미를 바꾼다. 아직 배포 전이라 데이터 마이그레이션은 없다.

- category_schema: tour_activity.json → clinic.json. 체험 스키마(안전 유의사항·우천 시
  운영·준비물)를 진료 스키마(진료과목·의료진·상담료·보험 적용·야간/주말진료)로 바꿨다.
  unit 은 프로그램 → 시술이다(마취 방식·회복 기간·권장 횟수·시술 후 주의사항).
- 소개문 계열만 allow_llm 이다. 시술 효과·비용 같은 값은 LLM 이 못 쓴다 —
  이 레포의 "검증 전에는 발행 금지" 규칙이 의료 문구에서 특히 중요하다.
- jsonld: TouristAttraction → MedicalClinic. 프론트 AeoReadiness 의 같은 표도 맞췄다.
- 색 팔레트를 병원 톤(클린 블루·세이지·누드·모노)으로, 아이콘을 Compass → Stethoscope 로.
- mock_adapter 목데이터를 시술 기준으로 교체. 스키마에 없는 key 를 쓰면 수집이 죽는다.
- site_payload 의 기본 섹션표를 에디터(industryData)와 같게 맞췄다 —
  test_site_theme 이 이 둘을 대조한다.

## 로그인 관문 되돌리기 (b94daa9·d6a6c8e revert)

두 커밋이 /builder 를 통째로 RequireAuth 뒤로 옮겨 `/` 가 곧바로 로그인 화면이 됐다.
`/` 는 자기 화면 없이 /builder 로 넘기기만 하므로, 문 앞 가드는 곧 루트 가드다.
위저드를 열어 두고 에디터 진입에서 한 번 받는 969fb67 설계로 되돌린다.
d6a6c8e 가 스스로 "969fb67 과 정면으로 다른 설계"라고 적어 두었다.

## 그 밖

- test_site_theme 의 경로가 solution/front 로 남아 있었다(frontend 개명 누락).
- .dockerignore: 이 머신에 buildx 가 없어 레거시 빌더가 돌고, 그러면
  nginx/Dockerfile.dockerignore 가 무시된다. 루트 것 하나로 두 이미지를 다 커버한다.

검증: frontend·admin·site lint·build 0. 백엔드 534 passed / 4 failed —
그 4개(test_build_publish 3 · test_snapshot 1)는 이 변경 전부터 실패하던 것이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xa8ME5FQJy4VA8pPokTo1a
This commit is contained in:
Mina Choi 2026-09-02 15:42:34 +09:00
parent 1bcee68432
commit 5ef3e5a7de
36 changed files with 368 additions and 262 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,10 @@ negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를
`UserRole.DEVELOPER` 주석의 **"고객사에 존재를 노출하지 않는다"** 를 번들이 깨고 있었다.
라우트 가드는 화면을 가리지 **번들은 못 가린다.**
★ 이 문제는 **코드 크기와 무관하다.** 내부 화면이 814줄뿐이어도 내려가는 건 같다.
2. **인증 모델이 갈라진다.** 둘 다 `RequireAuth` 뒤로 들어갔지만(2026-09-02 빌더 포함)
**계정이 생기는 방식**이 다르다 — 사장님은 스스로 가입하고 구글로도 들어오는 반면,
내부 운영 계정은 우리가 만들고 role >= DEVELOPER 여야 한다. 한 앱에서 두 정책을 유지하면
실수는 늘 **느슨한 쪽으로** 난다.
→ 지금은 `LoginPage` 의 `selfServe` 플래그가 그 차이를 한 곳에서 드러낸다
(사장님: 가입 링크 + 구글 버튼 / 내부: 둘 다 없음).
2. **인증 모델이 갈라진다.** 빌더는 일부러 로그인을 안 세운다("만들어 보기도 전에 막힌다").
내부 화면은 전부 `RequireAuth` 뒤다. 한 앱에서 두 정책을 유지하면 실수는 늘 **느슨한 쪽으로** 난다.
→ 지금은 두 `provider.tsx` 가 그 차이를 각자 명시한다(사장님: 인증 실패를 삼킨다 /
내부: 실패가 곧 차단).
3. **배포 리듬이 다르다.** 내부 화면을 고치려고 사장님 화면을 재배포하지 않는다.
### 백엔드 — 코드 한 벌, 진입점 둘
@ -179,9 +177,7 @@ OWNER role=2 → 403
```
OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다.
`auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). `signup`·`google` 도
같은 이유로 토큰 없이 열려 있다 — **여기서 만들어지는 계정은 언제나 `role=USER` 이고,
자기 회사(새 테넌트) 하나만 본다.** 권한이 올라가는 경로는 이 문 뒤에 없다.
`auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다).
⚠️ **`/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다**(`router/router.py`).
위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 `RequireOwner` 가
@ -226,12 +222,8 @@ admin 자기 파일만 `@admin` 이다.
### 아직 안 한 것
- 사장님 **"내 사이트 관리"** 화면(내 사업장 목록). 빌더는 2026-09-02 에 로그인 뒤로
들어갔고, 로그인 후 도착지는 아직 `/builder?new=1`(새로 만들기) 하나뿐이다.
- **계정 연결** — 같은 사람의 id/pw 계정과 구글 계정을 잇는 경로. 지금은 잇지 않고
거절한다 → [DECISIONS.md 1-5](DECISIONS.md)
- **비밀번호 재설정** — 이메일을 받아 두지만 소유 증명(인증 메일) 절차가 없다. 그래서
비밀번호를 잊으면 운영자가 손으로 바꿔 주는 수밖에 없다.
- 사장님 **"내 사이트 관리"** 화면. 이게 붙으면 빌더도 로그인 뒤로 들어간다 —
그때 `solution/frontend` 의 인증 정책을 다시 본다.
- 운영 배포에서 `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,62 +3,6 @@
무엇을 왜 바꿨는지 날짜순으로 남긴다. 새 항목을 **위에** 추가한다.
결론과 배경은 각 문서가 단일 출처다 — 여기에는 요약과 링크만 둔다.
## 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}` 로 둘 다 안 뜬다.
**왜 빌더를 막았나**
빌더는 "만들어 보기 전에 막지 않으려고" 열려 있었다. 그런데 위저드 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개**
로 정의했다. `users.company_id` 가 NOT NULL 이고 모든 도메인이 company 로 스코프되기 때문이다.
**밟기 쉬운 자리**
- **`GOOGLE_CLIENT_ID` 는 백엔드와 프론트가 같아야 한다.** 백엔드는 이 값으로 구글 토큰의
수신자(`aud`)를 대조한다 — 이 검사가 없으면 **다른 서비스에 발급된 진짜 구글 토큰**으로
우리 계정에 들어온다. 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다.
- **같은 이메일이라도 id/pw 계정과 구글 계정을 자동으로 잇지 않는다.** 이으면 계정 선점이
된다 → [DECISIONS.md 1-5](DECISIONS.md)
- 소셜 계정은 `password` 가 NULL 이다. id/pw 로그인 경로에서 먼저 끊지 않으면 해시 검증이
None 을 만나 500 이 난다.
- `provider` 에 `server_default` 를 같이 줬다. ORM default 는 raw INSERT(테스트 시드)에 안 먹어서
NOT NULL 컬럼이면 그 경로가 통째로 깨진다.
**이미 도는 DB 가 있으면** `postgres-init/init-data/init.sql` 을 다시 적용한다 — 말미의
"기존 DB 보정(ALTER)" 섹션이 새 컬럼을 채우고 `id` 를 넓힌다. 안 하면 로그인부터 500 이다.
**검증** — 백엔드 `pytest` auth 13건 + 구글 토큰 검증 8건(진짜 RSA 서명으로 aud·iss·만료·
`email_verified` 거절 확인), 프론트 `tsc` · `eslint` · `vite build` 통과.
---
## 2026-09-02 — 직접 쓴 소개문이 발행에서 사라지던 구멍

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

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

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

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

@ -267,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();
finishRestore();
return;
}
// 자동 로그인이 방금 신원까지 채웠으면 me() 를 두 번 부르지 않는다.
if (useAuthStore.getState().user) {
if (alive) finishRestore();
return;
}
try {
const res = await me();
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(() => {
/* 토큰이 죽었으면 비로그인 상태로 계속 간다. */
}
})
.finally(() => {
if (alive) finishRestore();
})();
});
return () => {
alive = false;
};

View File

@ -1,14 +1,11 @@
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';
import {NotFoundPage} from '@/pages/NotFoundPage';
import {SignupPage} from '@/pages/SignupPage';
export const router = createBrowserRouter([
{path: '/login', element: <LoginPage />},
{path: '/signup', element: <SignupPage />},
// ★ 첫 화면은 업종 선택(위저드 1단계)이다.
// `?new=1` 을 붙이는 이유: 위저드 상태는 새로고침을 넘기려고 저장돼 있어서(stores/builder persist),
@ -17,26 +14,15 @@ export const router = createBrowserRouter([
{path: '/', element: <Navigate to="/builder?new=1" replace />},
/**
* ★ 빌더부터는 로그인한 사람만 들어온다.
* 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면
* 만들어 보기도 전에 막힌다.
*
* 예전엔 가드 없이 열어 뒀다(만들어 보기 전에 막지 않으려고). 그런데 위저드 2단계부터
* 백엔드를 부르고, 만든 결과는 사업장·사이트로 **계정에 귀속**된다 — 로그인 없이 걸어온
* 사람은 3단계쯤에서 "로그인이 만료되었습니다"를 만나고 그때까지 넣은 걸 잃었다.
* 문 앞에서 막는 편이 걸어 들어온 뒤에 막는 것보다 낫다.
*
* 자동 로그인(VITE_AUTO_LOGIN_ID·PW)은 그대로 산다 — 다만 이제 화면 안이 아니라
* 부팅 때 붙는다(app/provider.tsx). 가드가 먼저 판단하므로 화면 안에서는 늦다.
* 대신 세션은 조용히 확보한다 — VITE_AUTO_LOGIN_ID·PW 가 주입돼 있으면 useAutoLogin() 이
* 그 계정으로 붙고, 없으면 서버가 필요한 순간(2단계 검색)에만 알린다.
*
* 에디터(6단계)는 전체 화면이 필요해 AppShell 을 스스로 끄고 켠다 — BuilderPage 참조.
*/
{
path: '/builder',
element: (
<RequireAuth>
<BuilderPage />
</RequireAuth>
),
},
{path: '/builder', element: <BuilderPage />},
/**
* ★ 내부 운영 화면(/places, /local-content, /seo)은 여기 없다 — 최상단 `admin/` 앱으로 나갔다.

View File

@ -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,104 @@
/**
* 로그인 폼 한 벌.
*
* ★ 로그인 화면과 에디터 진입 관문이 같은 폼을 쓴다. 두 벌로 두면 토큰을 심는 순서
* (signIn → me)가 한쪽에서만 지켜지고, 그 실수는 "로그인은 됐는데 계속 401" 로 나타난다.
*/
import {useState, type FormEvent, type ReactNode} from 'react';
import {LogIn} from 'lucide-react';
import {login, me, UserRole} from '@/api';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {notifyApiError} from '@/lib/notify';
import {toAuthUser, useAuthStore} from '@/stores/auth';
interface SignInFormProps {
/** 폼 위에 붙는 제목·설명. 화면마다 하는 말이 다르다. */
header: ReactNode;
/** 폼 아래 각주(빌더로 돌아가기 등). */
footer?: ReactNode;
submitLabel?: string;
onSignedIn?: () => void;
}
export function SignInForm({header, footer, submitLabel = '로그인', onSignedIn}: SignInFormProps) {
const signIn = useAuthStore((s) => s.signIn);
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;
}
// ★ RemoveNoneResponse 라 성공 응답에서도 토큰 필드가 빠져 올 수 있다.
// 빈 토큰으로 로그인 상태를 만들면 이후 모든 요청이 401 로 흐른다 — 여기서 끊는다.
if (!res.access_token || !res.refresh_token) {
notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.');
return;
}
const tokens = {accessToken: res.access_token, refreshToken: res.refresh_token};
// 토큰을 먼저 심어야 뒤이은 me() 가 Authorization 을 달고 나간다.
signIn(tokens, {userId: '', id, role: UserRole.USER});
const meRes = await me();
// 신원이 안 왔으면 방금 심은 임시 사용자를 그대로 둔다 — 토큰은 유효하므로 화면은 진행시킨다.
if (meRes.user_id && meRes.id) signIn(tokens, toAuthUser(meRes));
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

@ -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, 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,7 +13,9 @@ import {
Step5Generating,
} from '@/features/onboarding';
import {EditorLayout} from '@/features/builder';
import {useAutoLogin} from '@/hooks/useAutoLogin';
import {usePlaceSync} from '@/hooks/usePlaceSync';
import {useAuthStore} from '@/stores/auth';
import {EDITOR_STEP, useBuilderStore} from '@/stores/builder';
/** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */
@ -31,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');
@ -80,6 +86,9 @@ export function BuilderPage() {
const step = useBuilderStore((s) => s.step);
const goToStep = useBuilderStore((s) => s.goToStep);
const storeName = useBuilderStore((s) => s.storeName);
// ★ 스토어의 user 만 보면 자동 로그인이 심어 둔 토큰을 놓친다 — 둘 다 본다.
const authUser = useAuthStore((s) => s.user);
const isSignedIn = Boolean(authUser) || Boolean(getAccessToken());
// 배지는 주소창이 아니라 스토어가 기준이다 — [처음부터]로 데모로 돌아간 뒤에도
// 주소창에는 placeId 가 남아 있어서, 그걸 믿으면 데모를 실사업장이라고 표시한다.
const wiredPlaceId = useBuilderStore((s) => s.placeId);
@ -116,6 +125,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

@ -207,7 +207,7 @@ export function ruleRows(payload: SitePayload): InfoRow[] {
/**
* 예약 안내에 실을 fact.
*
* 숙박에는 없고(예약은 채널이 받는다) 음식점·관광체험 스키마에만 있는 key 다.
* 숙박에는 없고(예약은 채널이 받는다) 음식점·피부과·성형외과 스키마에만 있는 key 다.
* 값이 없는 업종에서는 그냥 빠진다.
*/
const BOOKING_FACT_KEYS = ['reservation_required', 'reservation_channel'] as const;
@ -236,7 +236,7 @@ export function spaceRows(payload: SitePayload): InfoRow[] {
}
/**
* 관람 안내에 실을 fact — 관광체험 스키마(tour_activity.json)의 관람 관련 key.
* 안내에 실을 fact — 피부과·성형외과 스키마(clinic.json)의 안내 관련 key.
*
* ★ 준비물·안전 유의사항은 뺐다. 그건 "관람 안내"가 아니라 체험 전 주의사항이고,
* 이용 정보(info) 표에 이미 나간다.

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