From 5ef3e5a7debcb3413229b677fd8b40a1672a580d Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 2 Sep 2026 15:42:34 +0900 Subject: [PATCH] =?UTF-8?q?=EC=97=85=EC=A2=85=204=EB=B2=88=EC=A7=B8?= =?UTF-8?q?=EB=A5=BC=20=EA=B4=80=EA=B4=91=EC=B2=B4=ED=97=98=20=E2=86=92=20?= =?UTF-8?q?=ED=94=BC=EB=B6=80=EA=B3=BC=C2=B7=EC=84=B1=ED=98=95=EC=99=B8?= =?UTF-8?q?=EA=B3=BC=20=EB=A1=9C=20=EB=B0=94=EA=BE=B8=EA=B3=A0,=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B4=80=EB=AC=B8=EC=9D=84=20?= =?UTF-8?q?=EC=97=90=EB=94=94=ED=84=B0=20=EC=A7=84=EC=9E=85=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=90=98=EB=8F=8C=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 업종 교체 (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) Claude-Session: https://claude.ai/code/session_01Xa8ME5FQJy4VA8pPokTo1a --- .dockerignore | 31 +++--- AGENTS.md | 2 - admin/frontend/src/pages/PlaceListPage.tsx | 2 +- docs/ARCHITECTURE.md | 22 ++-- docs/DEVLOG.md | 56 ---------- postgres-init/init-data/init.sql | 2 +- solution/backend/README.md | 2 +- .../category_schema/resources/clinic.json | 31 ++++++ .../resources/tour_activity.json | 32 ------ .../backend/common/database/model/models.py | 2 +- solution/backend/common/enums.py | 2 +- .../services/collector/mock_adapter.py | 58 +++++----- solution/backend/services/prompts/copy.py | 2 +- solution/backend/services/prompts/vision.py | 2 +- solution/backend/services/site_payload.py | 14 +-- .../backend/tests/test_category_schema.py | 2 +- solution/backend/tests/test_site_theme.py | 6 +- .../src/api/generated/model/placeCategory.ts | 2 +- solution/frontend/src/app/provider.tsx | 43 +++----- solution/frontend/src/app/router.tsx | 26 +---- solution/frontend/src/data/industryData.ts | 40 +++---- .../src/features/auth/EditorSignInGate.tsx | 37 +++++++ .../frontend/src/features/auth/SignInForm.tsx | 104 ++++++++++++++++++ .../src/features/builder/colorPalettes.ts | 10 +- .../src/features/builder/placeAdapter.ts | 2 +- .../src/features/onboarding/Step1Industry.tsx | 2 +- .../features/onboarding/Step2PlaceSearch.tsx | 16 ++- .../src/features/onboarding/industryIcons.tsx | 4 +- .../src/features/onboarding/usePlaceSearch.ts | 24 ++-- .../src/features/publish/AeoReadiness.tsx | 2 +- solution/frontend/src/hooks/useAutoLogin.ts | 14 +++ solution/frontend/src/pages/BuilderPage.tsx | 20 +++- solution/shared/src/types/builder.ts | 6 +- solution/shared/src/types/domain.ts | 2 +- solution/site/src/lib/derive.ts | 4 +- solution/site/src/seo/jsonld.ts | 4 +- 36 files changed, 368 insertions(+), 262 deletions(-) create mode 100644 solution/backend/common/category_schema/resources/clinic.json delete mode 100644 solution/backend/common/category_schema/resources/tour_activity.json create mode 100644 solution/frontend/src/features/auth/EditorSignInGate.tsx create mode 100644 solution/frontend/src/features/auth/SignInForm.tsx create mode 100644 solution/frontend/src/hooks/useAutoLogin.ts diff --git a/.dockerignore b/.dockerignore index c496829..eab8dbf 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 83530f1..87b3819 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 을 앞에 세워야 한다. 아니면 비워라. diff --git a/admin/frontend/src/pages/PlaceListPage.tsx b/admin/frontend/src/pages/PlaceListPage.tsx index 9c5608a..dd870e5 100644 --- a/admin/frontend/src/pages/PlaceListPage.tsx +++ b/admin/frontend/src/pages/PlaceListPage.tsx @@ -24,7 +24,7 @@ const CATEGORY_LABEL: Record = { [PlaceCategory.LODGING]: '숙박', [PlaceCategory.CAFE]: '카페', [PlaceCategory.RESTAURANT]: '음식점', - [PlaceCategory.TOUR_ACTIVITY]: '관광체험', + [PlaceCategory.CLINIC]: '피부과·성형외과', }; const STATUS_LABEL: Record = { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 35a93df..9e5a7ab 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 로 diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 3dc0bd0..1222ff6 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -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 — 직접 쓴 소개문이 발행에서 사라지던 구멍 diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index 6545250..ac3ccf3 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -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 — ★ 카카오는 주고 네이버는 안 준다 diff --git a/solution/backend/README.md b/solution/backend/README.md index d096525..33288af 100644 --- a/solution/backend/README.md +++ b/solution/backend/README.md @@ -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줄.** 로직은 건드리지 않는다. diff --git a/solution/backend/common/category_schema/resources/clinic.json b/solution/backend/common/category_schema/resources/clinic.json new file mode 100644 index 0000000..6460987 --- /dev/null +++ b/solution/backend/common/category_schema/resources/clinic.json @@ -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 } + ] +} diff --git a/solution/backend/common/category_schema/resources/tour_activity.json b/solution/backend/common/category_schema/resources/tour_activity.json deleted file mode 100644 index 74b7623..0000000 --- a/solution/backend/common/category_schema/resources/tour_activity.json +++ /dev/null @@ -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 } - ] -} diff --git a/solution/backend/common/database/model/models.py b/solution/backend/common/database/model/models.py index 7663ef7..c343cb4 100644 --- a/solution/backend/common/database/model/models.py +++ b/solution/backend/common/database/model/models.py @@ -169,7 +169,7 @@ class place_links(MainTableMixin, MAIN_BASE): class units(MainTableMixin, MAIN_BASE): - """업종별 하위 단위 — 숙박=객실, 카페·음식점=메뉴, 관광체험=프로그램. + """업종별 하위 단위 — 숙박=객실, 카페·음식점=메뉴, 피부과·성형외과=프로그램. 가변 필드는 facts(scope=unit)로 들어가고, 여기에는 목록 렌더에 필요한 뼈대만 둔다.""" __tablename__ = "units" diff --git a/solution/backend/common/enums.py b/solution/backend/common/enums.py index 08ae772..0c96a2d 100644 --- a/solution/backend/common/enums.py +++ b/solution/backend/common/enums.py @@ -181,7 +181,7 @@ class PlaceCategory(CodeEnum): LODGING = 1 # 숙박 CAFE = 2 # 카페 RESTAURANT = 3 # 음식점 - TOUR_ACTIVITY = 4 # 관광체험 + CLINIC = 4 # 피부과·성형외과 class ExternalPlaceSource(CodeEnum): diff --git a/solution/backend/services/collector/mock_adapter.py b/solution/backend/services/collector/mock_adapter.py index c48a592..1bca451 100644 --- a/solution/backend/services/collector/mock_adapter.py +++ b/solution/backend/services/collector/mock_adapter.py @@ -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), + ("레이저 장비", "레이저 토닝"), + ("시술실", "보톡스"), ], } diff --git a/solution/backend/services/prompts/copy.py b/solution/backend/services/prompts/copy.py index 9cd4cdd..35f3c21 100644 --- a/solution/backend/services/prompts/copy.py +++ b/solution/backend/services/prompts/copy.py @@ -38,7 +38,7 @@ _CATEGORY_LABEL = { PlaceCategory.LODGING: "숙박업소", PlaceCategory.CAFE: "카페", PlaceCategory.RESTAURANT: "음식점", - PlaceCategory.TOUR_ACTIVITY: "관광·체험 시설", + PlaceCategory.CLINIC: "관광·체험 시설", } diff --git a/solution/backend/services/prompts/vision.py b/solution/backend/services/prompts/vision.py index f9bd1b2..880af9f 100644 --- a/solution/backend/services/prompts/vision.py +++ b/solution/backend/services/prompts/vision.py @@ -8,7 +8,7 @@ _LABELS = { PlaceCategory.LODGING: ["외관", "침실", "거실", "욕실", "주방", "수영장", "바비큐장", "주차장", "전망", "부대시설"], PlaceCategory.CAFE: ["외관", "내부", "좌석", "메뉴", "디저트", "음료", "테라스", "주차장"], PlaceCategory.RESTAURANT: ["외관", "내부", "좌석", "메뉴", "음식", "룸", "주차장"], - PlaceCategory.TOUR_ACTIVITY: ["외관", "시설", "장비", "활동", "안전장비", "주차장", "매표소"], + PlaceCategory.CLINIC: ["외관", "시설", "장비", "활동", "안전장비", "주차장", "매표소"], } _DEFAULT_LABELS = ["외관", "내부", "시설", "기타"] diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py index e81fed6..44ae29a 100644 --- a/solution/backend/services/site_payload.py +++ b/solution/backend/services/site_payload.py @@ -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), ], }, } diff --git a/solution/backend/tests/test_category_schema.py b/solution/backend/tests/test_category_schema.py index 3fd3c20..c4f3b1b 100644 --- a/solution/backend/tests/test_category_schema.py +++ b/solution/backend/tests/test_category_schema.py @@ -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(): diff --git a/solution/backend/tests/test_site_theme.py b/solution/backend/tests/test_site_theme.py index 1ec6f96..3b6c811 100644 --- a/solution/backend/tests/test_site_theme.py +++ b/solution/backend/tests/test_site_theme.py @@ -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, } diff --git a/solution/frontend/src/api/generated/model/placeCategory.ts b/solution/frontend/src/api/generated/model/placeCategory.ts index 7573e1f..f890cb2 100644 --- a/solution/frontend/src/api/generated/model/placeCategory.ts +++ b/solution/frontend/src/api/generated/model/placeCategory.ts @@ -17,5 +17,5 @@ export const PlaceCategory = { LODGING: 1, CAFE: 2, RESTAURANT: 3, - TOUR_ACTIVITY: 4, + CLINIC: 4, } as const; diff --git a/solution/frontend/src/app/provider.tsx b/solution/frontend/src/app/provider.tsx index 354342d..4ff0817 100644 --- a/solution/frontend/src/app/provider.tsx +++ b/solution/frontend/src/app/provider.tsx @@ -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; }; diff --git a/solution/frontend/src/app/router.tsx b/solution/frontend/src/app/router.tsx index 89f3f7a..b2206fb 100644 --- a/solution/frontend/src/app/router.tsx +++ b/solution/frontend/src/app/router.tsx @@ -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: }, - {path: '/signup', element: }, // ★ 첫 화면은 업종 선택(위저드 1단계)이다. // `?new=1` 을 붙이는 이유: 위저드 상태는 새로고침을 넘기려고 저장돼 있어서(stores/builder persist), @@ -17,26 +14,15 @@ export const router = createBrowserRouter([ {path: '/', element: }, /** - * ★ 빌더부터는 로그인한 사람만 들어온다. + * 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면 + * 만들어 보기도 전에 막힌다. * - * 예전엔 가드 없이 열어 뒀다(만들어 보기 전에 막지 않으려고). 그런데 위저드 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: ( - - - - ), - }, + {path: '/builder', element: }, /** * ★ 내부 운영 화면(/places, /local-content, /seo)은 여기 없다 — 최상단 `admin/` 앱으로 나갔다. diff --git a/solution/frontend/src/data/industryData.ts b/solution/frontend/src/data/industryData.ts index 2134e32..9f5fddd 100644 --- a/solution/frontend/src/data/industryData.ts +++ b/solution/frontend/src/data/industryData.ts @@ -197,32 +197,32 @@ export const INDUSTRY_CONFIGS: Record = { }), }, - 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: '여백과 낮은 채도. 과장 없이 정보를 먼저 보여줍니다.', }), }, }; diff --git a/solution/frontend/src/features/auth/EditorSignInGate.tsx b/solution/frontend/src/features/auth/EditorSignInGate.tsx new file mode 100644 index 0000000..c432851 --- /dev/null +++ b/solution/frontend/src/features/auth/EditorSignInGate.tsx @@ -0,0 +1,37 @@ +/** + * 에디터 앞의 로그인 관문. + * + * ★ 위저드(1~5단계)는 로그인을 요구하지 않는다 — 만들어 보기도 전에 막으면 아무도 안 만든다. + * 에디터부터는 편집한 것을 저장하고 발행해야 하는데 그게 전부 토큰을 쓴다. 토큰 없이 들여보내면 + * 저장이 조용히 실패하고 사장님은 발행하고 나서야 안다. + * ★ /login 으로 튕기지 않는다. 위저드에서 쌓은 상태를 들고 돌아올 방법을 사장님이 알 수 없다. + */ +import {SignInForm} from './SignInForm'; + +export function EditorSignInGate({onBack}: {onBack: () => void}) { + return ( +
+ + Web4Ai +

편집을 시작하려면 로그인해 주세요

+

+ 여기까지 만든 내용은 그대로 있습니다. 편집한 것을 저장하고 발행하는 데 계정이 필요합니다. +

+
+ } + footer={ + + } + /> + + ); +} diff --git a/solution/frontend/src/features/auth/SignInForm.tsx b/solution/frontend/src/features/auth/SignInForm.tsx new file mode 100644 index 0000000..b9d04de --- /dev/null +++ b/solution/frontend/src/features/auth/SignInForm.tsx @@ -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 ( +
+ {header} + +
+
+ + setId(e.target.value)} + autoComplete="username" + required + /> +
+
+ + setPassword(e.target.value)} + autoComplete="current-password" + required + /> +
+
+ + + + {footer} +
+ ); +} diff --git a/solution/frontend/src/features/builder/colorPalettes.ts b/solution/frontend/src/features/builder/colorPalettes.ts index 6c1b8af..fd7b02d 100644 --- a/solution/frontend/src/features/builder/colorPalettes.ts +++ b/solution/frontend/src/features/builder/colorPalettes.ts @@ -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) => { diff --git a/solution/frontend/src/features/builder/placeAdapter.ts b/solution/frontend/src/features/builder/placeAdapter.ts index 3674b52..f313a46 100644 --- a/solution/frontend/src/features/builder/placeAdapter.ts +++ b/solution/frontend/src/features/builder/placeAdapter.ts @@ -18,7 +18,7 @@ export const CATEGORY_TO_INDUSTRY: Record = { [PlaceCategory.LODGING]: 'stay', [PlaceCategory.CAFE]: 'cafe', [PlaceCategory.RESTAURANT]: 'restaurant', - [PlaceCategory.TOUR_ACTIVITY]: 'tour', + [PlaceCategory.CLINIC]: 'clinic', }; /** facts.source_type — 사장님이 [맞아요] 를 누를 판단 근거. 출처 없는 값은 보여주지 않는다. */ diff --git a/solution/frontend/src/features/onboarding/Step1Industry.tsx b/solution/frontend/src/features/onboarding/Step1Industry.tsx index 2bccee5..ee866d9 100644 --- a/solution/frontend/src/features/onboarding/Step1Industry.tsx +++ b/solution/frontend/src/features/onboarding/Step1Industry.tsx @@ -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); diff --git a/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx b/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx index e75cb7b..00f42d4 100644 --- a/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx +++ b/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx @@ -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); diff --git a/solution/frontend/src/features/onboarding/industryIcons.tsx b/solution/frontend/src/features/onboarding/industryIcons.tsx index af502f7..9cc56c2 100644 --- a/solution/frontend/src/features/onboarding/industryIcons.tsx +++ b/solution/frontend/src/features/onboarding/industryIcons.tsx @@ -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 = { stay: Building2, cafe: Coffee, restaurant: UtensilsCrossed, - tour: Compass, + clinic: Stethoscope, }; diff --git a/solution/frontend/src/features/onboarding/usePlaceSearch.ts b/solution/frontend/src/features/onboarding/usePlaceSearch.ts index 3262e62..9716eab 100644 --- a/solution/frontend/src/features/onboarding/usePlaceSearch.ts +++ b/solution/frontend/src/features/onboarding/usePlaceSearch.ts @@ -20,7 +20,7 @@ const INDUSTRY_TO_CATEGORY: Record = { 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}; } diff --git a/solution/frontend/src/features/publish/AeoReadiness.tsx b/solution/frontend/src/features/publish/AeoReadiness.tsx index b389278..662bb70 100644 --- a/solution/frontend/src/features/publish/AeoReadiness.tsx +++ b/solution/frontend/src/features/publish/AeoReadiness.tsx @@ -7,7 +7,7 @@ export const SCHEMA_TYPE_BY_INDUSTRY: Record = { stay: 'LodgingBusiness', cafe: 'CafeOrCoffeeShop', restaurant: 'Restaurant', - tour: 'TouristAttraction', + clinic: 'MedicalClinic', }; interface AeoItem { diff --git a/solution/frontend/src/hooks/useAutoLogin.ts b/solution/frontend/src/hooks/useAutoLogin.ts new file mode 100644 index 0000000..2830f5d --- /dev/null +++ b/solution/frontend/src/hooks/useAutoLogin.ts @@ -0,0 +1,14 @@ +import {useEffect} from 'react'; +import {ensureAutoSession} from '@/lib/autoSession'; + +/** + * 화면이 열리자마자 세션을 확보한다. + * + * 실제 로직은 `lib/autoSession` 에 있다 — 서버를 부르는 쪽(usePlaceSearch)도 같은 약속을 + * 기다려야 하기 때문에, 훅 바깥에 두고 공유한다. + */ +export function useAutoLogin() { + useEffect(() => { + void ensureAutoSession(); + }, []); +} diff --git a/solution/frontend/src/pages/BuilderPage.tsx b/solution/frontend/src/pages/BuilderPage.tsx index 1f1d4b0..e2d6c33 100644 --- a/solution/frontend/src/pages/BuilderPage.tsx +++ b/solution/frontend/src/pages/BuilderPage.tsx @@ -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=`. * - * ★ 라우트(`/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 goToStep(4)} />; + } + if (step === EDITOR_STEP) { return (
diff --git a/solution/shared/src/types/builder.ts b/solution/shared/src/types/builder.ts index 8899131..15db900 100644 --- a/solution/shared/src/types/builder.ts +++ b/solution/shared/src/types/builder.ts @@ -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[]; diff --git a/solution/shared/src/types/domain.ts b/solution/shared/src/types/domain.ts index 3e899e8..78b194e 100644 --- a/solution/shared/src/types/domain.ts +++ b/solution/shared/src/types/domain.ts @@ -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]; diff --git a/solution/site/src/lib/derive.ts b/solution/site/src/lib/derive.ts index 040a87e..7316e5e 100644 --- a/solution/site/src/lib/derive.ts +++ b/solution/site/src/lib/derive.ts @@ -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) 표에 이미 나간다. diff --git a/solution/site/src/seo/jsonld.ts b/solution/site/src/seo/jsonld.ts index e2edb71..7d0d463 100644 --- a/solution/site/src/seo/jsonld.ts +++ b/solution/site/src/seo/jsonld.ts @@ -28,7 +28,7 @@ export const SCHEMA_TYPE: Record = { [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;