Commit Graph

60 Commits

Author SHA1 Message Date
533de126fb [feat] solution/frontend,nginx: 랜딩·요금·사례를 프리렌더 — 크롤러가 빈 종이를 받던 것
실측(2026-09-07): `curl /` 가 3,021바이트에 본문 0자·`<a>` 0개였다. 같은 호스트의
발행본은 48,072바이트다. 구글은 JS 를 실행하지만 **렌더링 큐가 따로** 돌고 신규
도메인은 뒤로 밀린다 — 그동안 색인에는 "제목만 있고 내용 없는 페이지"로 들어가 있다.
서치콘솔이 "URL이 Google에 등록되어 있음"이라고 답하면서도 브랜드명 검색에조차 안
걸리던 이유다.

스크립트를 새로 짜지 않았다. react-router 7.17 에 프리렌더가 내장돼 있고
`ssr: false` 와 함께 쓰면 런타임 Node 서버 없이 지정한 경로만 HTML 로 굽는다 —
나머지는 지금까지처럼 SPA 폴백이다. 배포 구조가 그대로다.

- react-router.config.ts: `ssr:false` + `prerender: ['/', '/pricing', '/showcase']`.
  로그인 뒤에만 의미가 있는 화면은 굽지 않는다(구울 내용이 사용자별이다)
- src/root.tsx · src/routes.ts: 예전 index.html + app/router.tsx 가 하던 일.
  가드는 페이지마다 감싸지 않고 RequireAuthLayout 레이아웃 라우트 하나로 모았다
- 랜딩·요금·사례에 meta export: 제목을 브랜드가 아니라 **검색어**로 시작하게 바꿨다.
  예전 제목("Web4Ai · AI 웹 빌더")에는 사람이 치는 말이 한 단어도 없었다.
  랜딩에 Organization JSON-LD 추가 — 발행본에는 있는데 정작 랜딩엔 없었다
- src/lib/site.ts: 발행 호스트의 단일 출처. 모듈 최상위의 `window.location` 폴백을
  전부 걷었다 — 서버 번들은 라우트를 한 파일로 묶어서 프리렌더 대상이 아닌 화면의
  최상위 코드도 빌드 때 실행된다(실측: BuilderPage 에서 빌드가 죽었다)
- LoginPage: homePath 기본값 `/` → `/sites`. 예전엔 router.tsx 가 넘기던 값이라
  라우트 모듈로 옮기면서 그대로 두면 로그인 후 랜딩으로 갔다
- nginx: SPA 폴백을 `/index.html` → `/__spa-fallback.html`. 프리렌더 뒤로
  `/index.html` 은 **랜딩이 구워진 파일**이라, 그리로 넘기면 `/builder` 에 랜딩
  HTML 이 내려가고 클라이언트가 다른 주소로 하이드레이트한다
- nginx/Dockerfile: 산출물이 `dist` → `build/client`. 경로가 어긋나면 COPY 가
  조용히 빈 디렉토리를 만들고 컨테이너는 정상으로 뜬다
- site/seo/robots.ts: `/builder` `/login` `/signup` `/sites` `/account` Disallow.
  이 경로들은 빈 SPA 폴백을 받는다 — 긁히면 호스트 전체에 저품질 신호가 쌓인다

검증: tsc·eslint·react-router build 통과.
랜딩 3,021B → 21,799B, 본문 1,278자, 링크 6개.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fteiJNvAEbTnUKq8fSqoj
2026-09-07 11:30:27 +09:00
9aa93b282c [feat] solution/site,nginx: 발행본 목록 페이지와 루트 llms.txt — 크롤 경로를 둘로 늘린다
서치콘솔 URL 검사(2026-09-07, /s/stay): "참조 페이지: 감지된 페이지 없음".
색인은 됐는데 이 호스트의 어떤 페이지도 발행본을 가리키지 않아, 크롤러가 발행본에
닿는 길이 사이트맵 하나뿐이었다. 사이트맵은 "이런 주소가 있다"만 말하고 볼 가치가
있는지는 말하지 않는다 — 그래서 색인은 되고 순위는 0인 상태가 됐다.

랜딩의 쇼케이스는 API 를 fetch 해 그리는 클라이언트 렌더라(ShowcaseGrid.tsx)
JS 를 실행하지 않는 크롤러에게는 없는 링크다. 그래서 정적 HTML 로 따로 굽는다.

- seo/directory.ts: `/s/` 목록 페이지(CollectionPage + ItemList LD)와 루트 llms.txt.
  목록의 제목은 payload 가 아니라 **구운 index.html 의 <title>** 에서 읽는다 —
  발행은 바뀐 사이트 하나만 굽기 때문에 payload 로 만들면 나머지가 슬러그로 떨어진다
- prerender.ts: 사이트맵에 랜딩과 목록 페이지를 추가. 목록 주소는 끝 슬래시가 있어야
  한다 — nginx 의 `location ^~ /s/` 가 슬래시로만 잡고, 없으면 사장님 앱으로 떨어진다
- nginx: `location ^~ /s/` 에 `index index.html`. try_files 첫 인자가 끝 슬래시면
  nginx 가 디렉토리 검사로 읽고 거기서 멈춰 403 이 된다(=404 로도 안 떨어진다)

★ 루트 llms.txt 의 기대치: 구글은 안 쓴다고 공식 확인했고(2025-07 Illyes) 크롤러
  트래픽으로도 거의 안 잡힌다(90일 5억 방문 중 408건). 두는 이유는 에이전트 경로
  하나다 — 사용자가 AI 에게 "이 사이트 봐줘" 할 때의 fetch 는 봇 집계에 안 잡힌다.
  비용이 함수 하나라 채택되면 이미 있는 쪽을 택했다. 발행본별 llms.txt 는 그대로다.

검증: tsc(solution/site) 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fteiJNvAEbTnUKq8fSqoj
2026-09-07 09:45:00 +09:00
34e5932fbc [fix] solution/frontend,site: 랜딩 색인 해제 + 사이트맵 URL 을 canonical 과 일치 — 크롤러의 유일한 문을 연다
이 호스트에는 들어오는 링크가 없다. 크롤러가 발행 사이트를 찾는 경로는 사이트맵
하나뿐인데 그 문이 두 군데서 막혀 있었다.

- 랜딩('/')에 `noindex, nofollow` 가 박혀 있었다. 관리자 화면이라고 보고 걸어 둔
  것인데(7a6fee7 로 '/' 관문이 사라져 이제는 랜딩이다), nofollow 때문에
  랜딩→발행 사이트로 이어지는 발견 경로까지 함께 죽어 있었다.
- 사이트맵이 `/s/<slug>/` 를 담는데 페이지 canonical 은 `/s/<slug>` 다
  (shared/lib/slug.ts publishUrl). 서치콘솔은 제출 URL 을 전부 "대체 페이지"로
  분류한다 — 색인은 되는데 제출분 0건으로 보이는, 조용히 틀리는 종류다.

- frontend/index.html: robots 를 index,follow 로. description·canonical·og 추가.
  호스트는 적지 않고 Vite 가 빌드 때 `%VITE_PUBLISH_HOST%` 를 치환한다
  (compose 가 루트 SITE_PUBLIC_HOST 를 흘려보낸다) — 두 곳에 적으면 갈라진다
- site/scripts/prerender.ts: 사이트맵 loc 의 끝 슬래시 제거 + 오리진 루트를 첫 항목으로

남은 것: 랜딩 body 가 빈 SPA 셸이라 렌더링에 기댄다. 랜딩 프리렌더는 별건이다.

검증: tsc(solution/site) 통과 · vite build 로 %VITE_PUBLISH_HOST% 치환 확인

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fteiJNvAEbTnUKq8fSqoj
2026-09-07 09:07:39 +09:00
7a6fee70c1 [fix] solution/frontend: 로그인해도 랜딩·요금에 갈 길을 낸다 — '/' 관문 제거
로그인하면 '/' 가 무조건 /sites 로 튕겼다. 그래서 **로고를 눌러도 랜딩이 안 뜨고**,
/pricing 은 살아 있는데 링크가 랜딩과 MarketingShell 에만 있어 로그인한 사장님은
주소를 직접 쳐야 했다. 요금은 쓰는 도중에 확인하는 값이지 가입 전에만 보는 값이 아니다.

- app/router.tsx: Home 의 리다이렉트 제거. '/' 는 누구에게나 랜딩이다.
  "로그인 직후엔 내 사이트로" 는 로그인·가입 화면이 직접 보낸다.
- pages/LoginPage.tsx: 도착지를 `homePath` prop 으로 받는다. 기본값 '/' 라
  내부 운영 앱(자기 '/' 가 사업장 목록으로 간다)은 그대로다. 사장님 앱만 '/sites'.
- pages/SignupPage.tsx: 가입 후 '/' → '/sites'. 안 그러면 관문이 없어진 지금 랜딩에 떨어진다.
- 로그인·가입 화면 로고에 '/' 링크. 그 화면에서 빠져나갈 길이 하나도 없었다.
- layout/AppShell.tsx: 로그아웃 → '/login' 이 아니라 '/'. 나간 사람에게 로그인 폼을
  다시 들이밀지 않는다. 사이드바에 [요금] 추가.
- layout/MarketingShell.tsx: 헤더 [무료로 만들기] 제거 — 히어로 입력 카드가 이미 그 자리다.
  시작하는 문이 한 화면에 둘이면 어느 쪽이 진짜인지 고르게 만든다.

검증: tsc·eslint 통과. docker compose up -d --build solution-site 로 띄워 눌러 확인 —
로그인 상태에서 로고 → 랜딩, 헤더는 [이렇게 나옵니다 · 요금 · 내 사이트].
2026-09-04 17:29:13 +09:00
238d4c25c4 [feat] solution: 네이버 플레이스를 검색 단계에서 찾는다 — 로그인은 수집 직전 한 번
사장님이 후보를 고른 뒤에도 "네이버 플레이스를 자동으로 찾지 못했습니다" 가 떴다.
찾을 수 있는데도 그랬다 — 자동 발견이 확정 경로(로그인 뒤)에만 있었고, 공개 검색은
상호·주소만 돌려줬다. 그리고 확정이 사업장 생성을 요구해서, 로그인 없이 시작하기로 한
위저드가 검색 직후부터 막혔다(자동 로그인이 그걸 가리고 있었다).

- place_service.search_places_public: 응답에 naver_place_url 을 싣는다. 넓은 검색어
  한 페이지에서 못 찾은 후보는 그 후보만 겨냥해 다시 찾는다(상위 2건, 429 회피).
  실측: 12개 상호 전부 발견. 전에는 4개 중 2개
- naver_place_lookup._region_hint: 주소에서 시·군·구까지만 뽑아 검색을 좁힌다.
  첫 토막('경기도')만 쓰면 **다른 동네 동명 업소**가 잡히고, 그 id 로 검증하면 남의
  가게가 이 사이트의 기준 정보가 된다 — 실측으로 한 번 겪었다
- ttl_cache(신규) + 공개 검색 10분 캐시: 검색 1회가 네이버를 최대 3번 긁는데 인증이
  없어 새로고침만으로 나간다. 실측 1.38s → 0.005s. **빈 결과는 캐시하지 않는다** —
  일시적 0건을 굳히면 사장님이 10분간 막힌다
- 확정은 서버를 부르지 않는다(usePlaceSearch). 화면에만 남기고, 수집 직전 로그인 뒤
  ensureServerPlace 가 생성 → 검증을 한 번에 한다. 나눠 두면 "사업장은 생겼는데 검증이
  빠진" 상태가 생기고 수집이 PLACE_NOT_VERIFIED 로 조용히 거절된다
- Step3: 수집 버튼이 로그인 모달을 연다(/login 으로 튕기지 않는다 — 위저드 상태가
  주소창에 없어 돌아올 길이 없다). 로그인하면 이어서 돈다
- Step2: 후보 카드에 '네이버 플레이스 찾음' 배지. 붙여넣기 칸은 접는다 —
  펼쳐 두면 시도도 전에 실패한 것으로 읽힌다. 뒤로 오면 처음 화면으로
- ChannelUrlInput: [추가] → [이 주소로 가져오기]. 로그인 전에는 addLink 가 placeId 가
  없어 **조용히 return** 해서 입력칸만 비워졌다(useChannelLinks.ts:37)
- LoginPage: admin/1234 기본값 제거. 배포 번들에 그대로 나가 있었다
- 기본 발행 호스트를 localhost 로(compose 4곳 · site_payload.DEFAULT_HOST · .env.example).
  운영 도메인을 기본값으로 두면 .env 를 안 채운 로컬 빌드가 조용히 운영 주소를 번들에
  굽는다 — 실측: 로컬에서 만든 링크가 킹서버로 갔다. localhost 는 http 로 조립한다

검증: tsc·eslint·vite build 통과. 브라우저로 전 구간 확인(검색 → 확정 → 로그인 →
자동 발견 → 검증 → 수집 fact 27건·사진 10장·메뉴 23건 → 사진 분석).
백엔드 테스트는 이 워크트리에서 못 돌렸다 — config.test.toml 이 없어 DB 인증이 실패한다.
2026-09-03 16:37:12 +09:00
a67169dfa2 [chore] solution/frontend: 네이버 소유확인 파일 추가 — 세 엔진 중 마지막
네이버 서치어드바이저는 DNS TXT 를 안 받는다. HTML 파일 아니면 메타태그뿐이라
구글·빙과 같은 자리에 둔다.

- public/naver71762ee96e2e126623dc1da07ecae493.html

★ 확인은 상태코드가 아니라 **내용**으로 한다 — nginx 의 `try_files ... /index.html` 이
없는 경로에 SPA 를 200 으로 돌려준다.
2026-09-03 11:32:58 +09:00
bce0928385 [chore] deploy,solution,docs: 레포·발행 호스트 교체 — o2o-site-AEO / web4ai.o2osolution.ai
옛 주소 w4ai.o2o.kr 은 앞단에 vhost 가 없어 전 경로가 Apache 자체 404 다(인증서도
CN=actions.o2o.kr, 2024 만료). 그런데 canonical·og:url·sitemap 이 전부 그 주소를
가리키고 있었다 — **화면은 멀쩡하고 기계가 읽는 값만 틀린** 상태라, 검색엔진에
아무리 등록해도 색인이 안 되는 종류다.

- 기본 호스트를 쓰는 자리 전부: site_payload.DEFAULT_HOST · compose 의 `:-` 기본값 4곳 ·
  vite.config.ts allowedHosts · .env.example 둘 · check_search_ready.py · 데모 픽스처
- init.sql: site.sites.thumbnail_url 을 "기존 DB 보정(ALTER)" 절에 추가.
  CREATE TABLE 에만 있어서 **새 DB 는 되고 기존 DB 만 조용히 깨졌다** —
  실측(킹서버): GET /v1/showcase 가 200 인데 내용이 비었다
- docs/SERVERS.md: 배포 경로 ~/data2/o2o-site-AEO · 새 remote · 공개 주소 절 ·
  init.sql 이 DB 최초 생성 때만 돈다는 함정
- docs/DEVLOG.md: 항목 추가

테스트 픽스처의 w4ai.o2o.kr 은 그대로 뒀다 — 자기가 넣은 값을 자기가 검증해서
기본 호스트와 무관하다.

tsc·eslint 통과. vite build 는 도커에서 확인(로컬 node_modules 의 rollup 네이티브 누락).
2026-09-03 11:31:34 +09:00
d6118165c4 [chore] solution/frontend: 구글 소유확인 파일 추가 — 새 호스트 기준으로 다시 받은 것
발행 호스트를 web4ai.o2osolution.ai 로 옮기면서 w4ai.o2o.kr 로 받아 둔 소유확인이
전부 무효가 됐다. HTML 파일 방식으로 다시 받았다.

- public/google60b514c02fd6af4e.html: Bing 것과 같은 자리다. 이미지에 구워야
  컨테이너 재생성에도 살아남는다(docker cp 로 넣으면 다음 배포에 사라진다)

확인은 상태코드가 아니라 **내용**으로 한다 — nginx 가 없는 경로를 index.html 로
떨어뜨려 200 을 준다.
2026-09-03 11:25:11 +09:00
a4ec508ba5 [fix] solution/frontend: 도는 제목이 안 움직이던 것 + 히어로를 입력 카드 구조로 · 헤더 확대
**애니메이션이 안 보이던 진짜 이유** — `.o2o-rotator-track` 이 <span> 이라 display:inline 이었다.
인라인 요소에는 transform 이 적용되지 않는다. 애니메이션은 걸려 있고 화면만 정지였다.
(computed style 로는 animationName 이 보여서 더 헷갈린다.)

- 히어로를 아임웹 구조로: 한 줄 입력창이 아니라 **큰 입력 카드**, 발행 사이트 띠가 그 카드
  **뒤로 full-bleed** 로 지나간다. 카드 아래 따로 두면 첫 화면이 세로로 길어진다
- 카드 안: 브랜드 라벨 + [업종부터 고르기] · 큰 입력 · 하단 업종 안내 + 원형 제출 버튼
- 제목 sm:text-6xl lg:text-7xl, leading 1.15
- 헤더 h-14→h-16, 로고 h-6→h-8, 메뉴 14px, 로그인도 버튼(맨 텍스트면 눌리는 걸로 안 보인다)
- 헤더가 커진 만큼 히어로 높이 계산도 4rem 으로 — 안 바꾸면 스크롤바가 생긴다
2026-09-03 11:02:42 +09:00
ee8ba59036 [fix] solution/frontend: 히어로 검색창을 pill 에서 rounded-xl 로 — 페이지 카드 곡률과 어긋났다 2026-09-03 10:59:13 +09:00
0c3f8dd3b0 [feat] solution/frontend: 히어로를 첫 화면 높이로 · 검색창 pill · 쇼케이스 카드 축소
콘텐츠가 세로로 퍼져서 첫 화면에 임팩트가 없었다.

- 히어로 min-h-[calc(100dvh-3.5rem)] + 세로 가운데. 헤더(h-14)를 빼야 스크롤바가 안 생기고,
  vh 가 아니라 dvh 인 이유는 모바일에서 주소창이 접혔다 펴져 vh 가 흔들리기 때문
- 검색창·버튼을 rounded-full h-13 로. 아이콘 여백도 같이 밀었다
- 마퀴 카드 w-52 → w-40, 간격·글자 축소. 히어로 안에 들어와야 한 화면에 다 담긴다
2026-09-03 10:58:02 +09:00
d8ed0766d6 [feat] solution/frontend: 히어로 문구 여섯으로 · 보조 문구 제거 — 세로로 퍼지던 걸 줄인다
앞말이 넷이라 금방 반복됐고, 그 아래 보조 문구 세 줄이 히어로를 세로로 늘려 임팩트를 깎았다.

- 앞말 6개: SEO · AEO 최적화 / AI가 먼저 찾는 / 챗GPT가 인용하는 / 검색에 바로 걸리는 /
  손님이 먼저 만나는 / 우리 가게가 직접 말하는
- ★ 문구 개수와 index.css 의 o2o-rotate-6 키프레임은 한 몸이다. 늘리면 stop 도 고쳐야 한다 —
  안 고치면 뒤쪽이 영영 안 보이거나 빈 줄이 지나간다
- 보조 문구 3줄 삭제, 제목 sm:text-6xl 로 키우고 세로 여백 축소
2026-09-03 10:53:41 +09:00
d5e9b87e3f [feat] solution/frontend: 히어로 제목의 앞말이 돈다 — '홈페이지' 는 고정
"SEO · AEO 되는 웹사이트" 는 규격 이름이지 문구가 아니었다. 아임웹처럼
[형용사절] + [명사] 구조로 바꾸고, 명사를 고정한 채 앞말만 갈아 끼운다.

- AI가 먼저 찾는 / 챗GPT가 인용하는 / 검색에 바로 걸리는 / 손님이 먼저 만나는
- CSS 만으로 돈다(마퀴와 같은 이유 — 타이머는 백그라운드 탭에서 밀린다)
- 문구를 다섯 줄 쌓고 마지막을 첫 줄의 복제로 둔다. -80% 에서 0% 로 되감을 때 글자가 안 튄다
- 줄 높이를 1.25em 으로 못 박는다 — 창 1줄 · 트랙 5줄이라 한 칸이 정확히 20% 여야 한다
- 복제분은 aria-hidden. SEO·AEO 는 제목에서 빼 보조 문구로 내렸다

tsc·eslint·vite build 통과
2026-09-03 10:45:38 +09:00
4ef8e7d232 [feat] solution/frontend: 히어로 아래 발행 사이트 마퀴 — 입력칸만으론 뭐가 나오는지 모른다
상단에 입력칸 하나만 있으면 무엇이 만들어지는지 알 수 없다. 결과물을 바로 밑에서 흘린다.

- CSS 만으로 돈다(index.css o2o-marquee). setInterval 로 돌리면 탭이 백그라운드일 때
  프레임이 밀려 돌아왔을 때 툭 끊긴 것처럼 보인다
- 같은 목록을 두 벌 그리고 트랙을 -50% 까지만 민다 — 한 벌이면 끝에서 빈 화면이 지나간다
- 개수가 늘어도 흐르는 속도는 그대로(카드 수에 비례해 duration)
- 복제분은 aria-hidden·tabIndex=-1 — 스크린리더가 같은 목록을 두 번 읽지 않게
- hover 하면 멈추고, prefers-reduced-motion 이면 아예 안 움직인다
- CTA 문구를 [확인하기] 로(시안과 같게)

tsc·eslint·vite build 통과
2026-09-03 10:34:02 +09:00
fd716222ad [feat] solution/backend: 썸네일 백필 스크립트 — 이미 발행된 사이트는 채울 길이 없었다
썸네일은 발행 잡이 끝날 때 만들어진다. 그래서 이 기능이 들어오기 전에 발행된 사이트는
thumbnail_url 이 영영 NULL 이고 쇼케이스에서 글자 카드로만 나온다. 재발행을 시키면
채워지지만 사장님 사이트를 우리 사정으로 다시 굽는 건 다른 일이라 썸네일만 따로 만든다.

- 발행본 HTML 을 건드리지 않는다. 읽는 건 snapshot 의 사진 목록, 쓰는 건 thumbs/ 와 컬럼 한 칸
- --dry-run 은 Azure 설정 없이도 돈다(대상이 맞는지 먼저 봐야 한다)
- 한 건 실패가 나머지를 막지 않는다

로컬 dry-run: 발행 15건 전부 대표 사진 있음
2026-09-03 10:31:52 +09:00
af391b4c56 [feat] solution/frontend,docs: 랜딩 · 요금 · 쇼케이스 — 로그인 전 화면이 없었다
`/` 가 곧장 위저드로 튀어서 이 제품이 무엇을 파는 물건인지 말할 자리가 없었다.
처음 온 사람이 업종 선택 화면부터 만난다.

- router: `/` 는 비로그인 랜딩 · 로그인 /sites. /pricing · /showcase 추가
- MarketingShell: 사이드바 없는 문서형 껍데기. AppShell(작업 화면)과 나눴다
- 랜딩 상단은 상호명 한 칸. 문구는 SEO·AEO 축으로만 쓴다 —
  '쉽게·빠르게'로 말하면 홈페이지 빌더와 같은 자리에서 비교당한다(PRODUCT 1절)
- ShowcaseGrid: 발행 썸네일을 그대로 건다. 예시 데이터로 채우지 않고, 없으면 섹션을 감춘다.
  ★ 생성 클라이언트를 안 쓴다 — 토큰 길목을 지나면 비로그인에서 못 부른다
- 요금은 플랜 하나(70만원/월) + 월 산출물. 비교표를 만들지 않는다

tsc·eslint·vite build 통과
2026-09-03 10:21:16 +09:00
a61f9724ea [feat] solution/frontend: 온보딩을 상호명부터 — 단계를 주소창으로 옮기고 업종은 검색이 정한다
업종을 먼저 고르게 하면 경계에서 멈춘다("우리는 카페인가 음식점인가"). 그런데 상호명은
100% 안다. 그리고 업종은 AI 를 부를 필요가 없다 — 카카오·네이버 검색 응답에 분류가
이미 들어 있고(category_group_code), 지금까지 받아 놓고 안 썼다.

- 단계가 스토어에서 주소창으로: ?step=search|industry|collect|template|generating|editor.
  번호가 아니라 이름인 이유 — 단계가 4→3 으로 줄어 옛 북마크가 다른 화면을 연다
- 시작점이 상호명 검색(Step2PlaceSearch)이다. 업종 선택은 못 정했을 때의 갈래로 남는다
- 업종은 후보의 category 로 잡히고, 못 정하면 고르게 하고, 정해져도 [바꾸기] 로 바꾼다.
  ★ 확정 뒤 변경은 신원 확인부터 다시 받는다 — Req_UpdatePlace 에 category 가 없어
    PATCH 로 못 고치고, placeAdapter 가 리페치마다 덮어써서 조용히 되돌아간다
- 랜딩 진입: ?new=1 · ?q=<상호명> · ?industry=<업종>. 한 번 읽고 replace 로 지운다
- ★ ?new=1 이 setSearchParams({}) 로 **모든 쿼리를 날리던 것**을 고쳤다 — q 가 읽히기 전에 사라졌다
- selectIndustry 가 사장님이 친 상호·위치를 지우던 것도 고쳤다(업종이 첫 화면일 땐 늘 빈 값이라 안 보였다)
- 로고는 어디서나 / 로 간다. 에디터에서는 span 이라 아예 안 눌렸다

tsc·eslint·vite build 통과
2026-09-03 10:20:57 +09:00
41e49c693b Merge branch 'main' into 홈페이지-레이아웃-구상
# Conflicts:
#	docs/DEVLOG.md
#	solution/backend/crud/site_crud.py
#	solution/backend/router/router.py
#	solution/backend/router/v1/site/protocol.py
2026-09-03 09:44:51 +09:00
2026fde80f [feat] solution/backend: 상호명 공개 검색 + 업종 자동 판별 — 랜딩이 로그인 앞에서 부른다
랜딩 첫 화면이 상호명을 받으려면 검색이 로그인 앞에 있어야 하는데, 후보 조회는
place_id 와 토큰을 둘 다 요구했다. 로그인 관문을 에디터 진입 하나로 되돌려 놓고도
(b94daa9) API 는 그대로였다.

업종은 AI 를 한 번 더 부를 필요가 없다 — 카카오·네이버 검색 응답에 분류가 이미
들어 있고(category_group_code / category_name), 지금까지 받아 놓고 안 썼다.

- place.py: GET /v1/place/search 신설(인증 없음). ★ /{place_id} 앞에 둬야 한다 —
  뒤에 두면 "search" 가 place_id 로 잡혀 422 다
- place_category: AD5·CE7·FD6 우선, 없으면 분류 문자열. 못 정하면 None —
  억지로 고르면 틀린 스키마로 시작한다. HP8 은 피부과·성형외과일 때만
- kakao: KakaoPlace 에 category_group_code. 한글 분류는 바뀌어도 코드는 안 바뀐다
- rate_limit: 인증 없이 유료 API 를 부르는 경로라 IP 당 분당 20회(프로세스 메모리)
- 확정 경로(verify/candidates)는 인증 유지 — 남의 place_id 존재 여부를 열지 않는다

전체 562 passed
2026-09-03 09:41:34 +09:00
e8cda02a4b [feat] solution/backend,postgres-init: 발행 썸네일 저장 + 공개 쇼케이스 목록 — 랜딩이 실물을 걸 자리
랜딩의 "이렇게 나옵니다" 섹션이 걸 그림이 없었다. 발행은 되는데 그 사이트가
어떻게 생겼는지 밖에서 알 방법이 payload 안에만 있었다.

★ 스크린샷이 아니라 그 사이트의 대표 사진(og:image)이다. 헤드리스 브라우저는
  봇 탐지 우회 우려로 영구 금지돼 있고(DECISIONS 1-1), 워커(python:slim)·
  프리렌더(node:alpine) 어디에도 Chromium 이 없다.

- site_thumbnail: 대표 사진을 받아 <prefix>/thumbs/<slug>.<ext> 로 올린다.
  s/<slug>/ 안에 두지 않는 이유 — _remove_stale_site_files 가 매 발행마다
  그 경로를 프리렌더 산출물로 통째로 교체해 조용히 지운다
- site_payload: primary_media()·publish_origin()·region_label() 공개.
  isPrimary 계산을 한 곳으로 모아 og:image 와 썸네일이 갈릴 수 없게 했다
- build_service: azure publish 직후·IndexNow 전에 저장. 실패해도 발행은 그대로
  (payload 와 같은 원칙). thumbnail_url 은 발행 상태 전이 UPDATE 에 합쳐 1회
- GET /v1/showcase: 인증 없음. 발행된 사이트만, place_id·전화·상세주소는 안 나간다
- conftest: fake_renderer 가 늘 ok=True 라 NO_UNIQUE_CONTENT 되짚기 경로가
  통째로 안 돌고 있었다(기존에 깨져 있던 테스트 4건 포함 수정)

전체 562 passed
2026-09-03 09:41:10 +09:00
a52d166fae Merge branch 'main' into feature/auth 2026-09-02 22:56:27 +09:00
e2d15955b0 [fix] solution/frontend,deploy: :80 에서 로그인이 CORS 로 막히던 것 — API 를 같은 오리진으로
화면은 http://localhost(:80) 인데 번들이 http://localhost:9800 을 직접 불렀다. 백엔드 허용
오리진 기본값은 :3000~3005 뿐이라 브라우저가 막았고, 화면에는 '로그인에 실패했습니다'
(네트워크 예외 문구)만 떴다 — 아이디·비번 문제로 보인다.

nginx 가 이미 /v1 을 프록시한다(site.conf). 그쪽으로 부르면 CORS 를 아예 안 탄다.

- .env: PUBLIC_API_BASE_URL=http://localhost — 번들이 같은 오리진을 보게 한다
- LoginPage: 개발 편의로 admin/1234 기본값. ★ 운영 전에 빈 문자열로 되돌릴 것

브라우저 확인(localhost:80): 로그인 → /builder, 사이드바 '관리자 · 데모대행사'.
2026-09-02 22:56:22 +09:00
b07ade25b2 [fix] solution/frontend,docs: 온보딩 위저드에서 사이드바 제거 — 사이트가 되기 전엔 사이트 메뉴가 없다
사이드바는 계정 메뉴(내 사이트·새 사이트)다. 아직 사이트가 아닌 것 위에 그걸 얹으면,
만들던 중에 [새 사이트]를 눌러 방금 입력한 것을 지우는 길만 열어 준다.
아임웹도 사이트 개설 흐름에는 계정 사이드바를 붙이지 않는다.

- BuilderPage: 위저드를 AppShell 대신 얇은 상단 바(로고 + 나가는 길)로. 진행은 WizardSteps 가
  이미 보여준다. 비로그인은 돌아갈 목록이 없어 그 자리에 [로그인] 을 둔다
- BuilderPage: 에디터 헤더에 [← 내 사이트] — "내 사이트 관리가 생기면 그때 잇는다"고
  비워 뒀던 자리다
- DEVLOG: 계정 레벨/사이트 레벨을 가른 근거

검증 — tsc·eslint·vite build 통과. 위저드에 사이드바가 사라진 것은 브라우저에서 확인
2026-09-02 22:43:38 +09:00
282427e10b [feat] solution/frontend: 내 사이트 · 내 정보 — 로그인 후에 갈 곳이 생겼다
로그인해도 갈 곳이 없었다. 사업장 목록은 내부 운영 앱(admin)으로 나갔고 사장님 앱에는 그 경로가
없다. 아임웹도 같은 자리를 계정 레벨(내사이트 · 마이페이지)로 두고, 사이트 레벨(관리자 페이지)과
가른다 — 우리는 그 사이트 레벨이 에디터다.

- pages/SitesPage: 줄을 누르면 에디터로 간다(목록에 온 용건은 열에 아홉 "내 사이트 고치기").
  [사이트 열기] 는 PUBLISHED 일 때만 — 주소는 발행 전에 예약돼서, 주소만 보고 열면 404 다.
  ⋯ 메뉴에는 [발행 내리기] 하나. ★ 삭제는 두지 않았다 — 색인된 페이지를 404 로 만들면
  그 자리를 다시 OTA 가 가져가고 되돌릴 방법이 사장님에게 없다(sites.status 주석)
- pages/AccountPage: PATCH /v1/auth/me 가 받는 것만 그린다. 구글 계정은 비밀번호 칸을 접는다
  (서버가 ACCOUNT_PROVIDER_CONFLICT 로 막는다). 상호는 읽기 전용 — Req_UpdateMe 에 없다
- router: `/` 가 로그인 여부로 갈린다. 복구(isRestoring) 전에는 판단하지 않는다 —
  아니면 새로고침마다 위저드가 번쩍이고 목록으로 튄다
- AppShell: 메뉴에 [내 사이트], 계정 이름 자리가 [내 정보] 입구

검증 — tsc·eslint·vite build 통과(frontend·admin)
2026-09-02 22:43:10 +09:00
479edf9403 [feat] solution/backend: 내 사이트 목록 엔드포인트 — places LEFT JOIN sites 단일 질의
로그인한 사장님이 자기 사이트를 볼 화면이 없었다. 사이트는 place_id 로 한 건씩만 읽혀서
(site_crud.get_site_by_place) 사업장 목록으로 그리면 줄마다 사이트를 다시 물어 N+1 이 된다.

- site_crud.list_company_sites: places LEFT JOIN sites LEFT JOIN site_versions 한 번.
  사이트가 아직 없는 사업장(위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다
- protocol.MySiteData: 한 줄 = 사업장 + 사이트. render(정적 파일 존재)는 넣지 않았다 —
  보고서 파일을 읽는 값이라 줄 수만큼 파일 IO 가 된다. 단건(Res_Site)이 계속 소유한다
- site_service.list_my_sites: 회사 스코프. needs_rebuild 는 단건과 같은 규칙으로 판정한다
- GET /v1/site/list 는 라우터 객체를 따로 둔다 — 기존 라우터는 접두어에 place_id 가 박혀 있다

테스트 5건 추가(비어 있는 사업장·조인·회사 격리·재빌드 일치·비로그인), 539 passed
(기존 실패 4건은 이 변경 전에도 같다 — build_publish 3 · snapshot 1)
2026-09-02 22:37:53 +09:00
128596fc74 [feat] solution/frontend: 구글 버튼을 로그인 관문에도 · 문구 한글화
"구글 로그인 버튼이 없다" 는 지적이 맞았다. 버튼을 LoginPage·SignupPage 에만 붙여 뒀는데,
이 앱에서 사장님이 실제로 로그인 화면을 만나는 자리는 **에디터 진입 관문**(EditorSignInGate →
SignInForm)이다. 정작 거기엔 없었다.

- features/auth/SignInForm: 구글 버튼 추가. 관문·로그인 화면이 같은 폼을 쓰므로 한 곳만 고치면 된다
- lib/googleIdentity: 스크립트를 ?hl=ko 로 받는다. renderButton 의 locale 옵션은 안 먹었다 —
  'ko'·'ko_KR' 둘 다 'Continue with Google' 이 그대로 나왔다(실측)
- 버튼 문구는 signin_with('Google 계정으로 로그인'). 가입 화면만 signup_with 로 둔다.
  문구 자체는 고를 수 없다 — 구글 브랜드 가이드라 GIS 가 주는 번역을 그대로 쓴다

브라우저 확인(localhost:80): 로그인 화면에 'Google 계정으로 로그인' 한글 노출.
tsc·eslint·vite build 통과.
2026-09-02 22:34:28 +09:00
d376677b86 Merge branch 'main' into 스테이멍뭉아이템만ㅇ들ㅇ어 2026-09-02 21:59:02 +09:00
65705c5bed [feat] solution/shared,site,frontend: 계절별 추천 하루는 지금 계절만 — 간절기엔 두 계절
네 계절 코스를 다 늘어놓으니 손님 앞에 열두 개가 깔렸다. 그건 추천이 아니라 목록이다.
12월에 온 손님에게 봄 벚꽃 코스를 권할 이유가 없다.

- shared/currentSeasons: 3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울.
  계절 첫 달의 전반(1~15일)은 간절기로 보고 앞 계절과 함께 둘을 돌려준다 —
  9월 초에 여름만 보이면 지난 계절이고, 가을만 보이면 아직 이른 코스다
- site/PlannerSection: HTML 에는 전 계절을 굽고 화면에서만 접는다(hidden).
  ① 정적 페이지는 한 번 구우면 몇 달 산다. 굽는 시점의 계절을 박으면 12월에도 가을이 걸려서,
     계절 판정을 브라우저에서 한다(일력의 '오늘'과 같은 수법)
  ② 이 사이트의 존재 이유가 인용이다. 지우면 검색·AI 가 나머지 계절을 못 읽는다
  지금 계절에 코스가 없으면 접지 않고 전부 보여준다 — 빈 섹션보다 철 지난 코스가 낫다
- frontend/PlannerPodium: 탭은 그대로 두되 지금 계절로 열리고, '·지금' 표시와
  "손님 화면에는 지금 계절만 나갑니다" 한 줄. 안 적으면 사장님은 손님도 넷을 다 본다고 오해한다

tsc·eslint 통과(frontend·site). 경계 12일자 확인(3/5 겨울·봄 · 9/2 여름·가을 · 9/16 가을).
실물 payload(스테이,머뭄 /s/stay, 9코스 4계절)로 구워 오늘 여름·가을만 보이고 봄·겨울은
hidden, HTML 에는 네 계절 전부 있는 것을 브라우저에서 확인.
2026-09-02 21:58:50 +09:00
e1423418ae [fix] solution/frontend: 비로그인 상태의 사이드바 — 눌러도 아무 일 없는 [로그아웃] 제거
위저드는 로그인 없이 열린다(관문은 에디터 진입이다). 그런데 사이드바는 로그인 여부와
무관하게 [로그아웃]만 그렸다 — 로그인한 적 없는 사람에게는 **이름이 빈 줄로 나오고,
버튼을 눌러도 지울 세션이 없어 아무 일도 일어나지 않았다.** "유저 정보가 어디에도 안 보인다"가
이것이다.

- 비로그인: '로그인하지 않았습니다' + [로그인] 링크
- 로그인: 이름 · 상호 + [로그아웃]. 로그아웃은 스토어만 비우면 화면이 그대로라 눌러도
  아무 일이 없는 것처럼 보인다 — /login 으로 보낸다

브라우저 확인: 비로그인 위저드에서 안내와 [로그인] 노출 → 로그인 후 '김사장 · 달빛스테이' →
[로그아웃] 클릭 시 /login 이동. tsc·eslint·vite build 통과.
2026-09-02 21:38:43 +09:00
cfec47230c Merge branch 'main' into feature/auth
# Conflicts:
#	docs/DEVLOG.md
2026-09-02 21:34:12 +09:00
01c252287e Merge branch 'main' into feature/auth — 관문은 main 의 에디터 진입으로
main 이 5ef3e5a 로 문 앞 게이트(d6a6c8e·b94daa9)를 되돌렸다. 근거가 내가 못 본 것이었다 —
`/` 는 자기 화면 없이 /builder 로 넘기기만 하므로 **문 앞 가드는 곧 루트 가드**이고,
앱을 열자마자 로그인 화면이 된다. 그 결정을 따르고, 되돌리기에 휩쓸린 것만 복구한다.

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

브라우저 확인: 위저드는 로그인 없이 열림 → 가입 → 사이드바 '김사장 · 달빛스테이' →
에디터 상단 바 동일 표시 → 로그아웃. 구글 버튼 렌더까지 확인(실제 로그인은 client_id 필요).
pytest 534 passed / 4 failed(전부 기존 실패). tsc·eslint·vite build 통과.
2026-09-02 21:33:31 +09:00
71c0c1f6ab [feat] solution/shared,frontend,site,backend: 붙여넣기 아이템 여섯 추가 · 발행본까지 내보내고 템플릿 토큰을 따르게 한다
아이템 넷(가요·일력·승차권·스케줄)만 있었고, 그마저 **발행본에는 하나도 안 나갔다.**
`SectionSetting` 계약에 data 가 없어 사장님이 채운 JSON 이 payload 경계에서 통째로 버려졌다 —
소개문 body 와 같은 사연이다. 빌더에서는 보이는데 발행하면 없는 섹션이었다.
그리고 아이템 전부가 갱지색·주(朱)잉크·간판체를 hex 로 박고 있어, 템플릿을 매거진으로 바꿔도
아이템 섹션만 레트로로 남았다. 발행본은 색만 템플릿을 따랐다(계약에 생김새가 없었다).

- shared/section-data: 읽는 쪽 계약을 계약 패키지로 — 항목 타입 · parseSectionData.
  같은 JSON 을 빌더와 발행본이 읽는다. 파서가 두 벌이면 슬러그 규칙처럼 조용히 어긋난다
- frontend/dataSpec: 아이템 6종 추가 — 인물 열전 · 시간의 골목 · 문학 서가 · 오늘의 엽서 ·
  뒤집어 보는 질문 · 계절별 추천 하루. [+ 섹션 추가] 목록은 dataSpec 에서 파생돼 손댈 곳이 없다
- shared/planDay: 계절별 추천 하루는 시각을 **계산한다**. schedule 과 축이 다르다 —
  저쪽은 사장님이 시각을 적고 여기는 출발 시각·소요 분에서 시각을 만든다.
  조립 규칙을 shared 에 둔 이유는 파서와 같다(빌더와 발행본이 같은 시각을 내야 한다).
  21시를 넘기는 칸은 넣지 않고 뺐다고 화면에 밝힌다 — 숨기면 왜 없는지 사장님이 모른다
- shared/site-payload: SectionSetting.data · SiteTheme.look 추가. backend/site_payload 는
  해석 없이 싣는다 — 모양을 검사하면 프론트가 필드를 늘린 날 조용히 떨어뜨린다
- site/sections/items: 발행본 아이템 10종. **인터랙션은 옮기지 않았다** — 캔버스의 턴테이블은
  '지금 한 곡'만 펴는데 그러면 나머지 곡의 문장이 HTML 에 없다. 인용이 이 사이트의 존재 이유다
- site/prerender: 아이템 항목을 고유 콘텐츠로 계수. 안 세면 "곡을 여덟 개 채웠는데 0건으로
  발행이 막힌다"가 된다(intro.body 와 같은 구멍). 백엔드 fake 도 같은 규칙으로 맞췄다
- 아이템 색·서체를 전부 --tpl-* 토큰으로. retro/common → items/common, RETRO_* → ITEM_*.
  글자 단계는 stone-400/500/600 대신 불투명도로 만든다 — 팔레트가 바뀌어도 위계가 남는다
- site/seo/head: look 을 --tpl-* 로 심고, 웹폰트는 템플릿이 쓰는 것만 내려보낸다.
  전부 항상 실으면 쓰지도 않는 서체가 모든 발행 사이트의 첫 렌더를 늦춘다
- shared/color: deriveSurfaces 를 계약 패키지로. 캔버스·쇼케이스·발행본이 같은 식을 써야
  미리보기가 거짓말을 하지 않는다. 프론트 lib/color 는 재수출만 남겼다

밟은 함정: 강조색을 그대로 쓰면 팔레트에 따라 큰 날짜 숫자와 순위 배지가 사라진다(연한 accent +
밝은 바탕). color-mix(accent 70%, currentColor) 로 색조는 남기고 대비만 확보했다.
'확인/확인필요' 배지는 디자인이 아니라 신호라 신호색을 지키되 둘레 글자색만 섞는다.

tsc·eslint·vite build 통과(frontend·admin·site), site 테스트 17 passed.
실물 프리렌더(레트로 look + 아이템): 열 섹션과 본문 문장 전부 포함, --tpl-font-heading 'Gugi' ·
border-width 2px, family=Gugi&Gowun+Batang 링크, 계절 묶음·순위·계산된 시각(09:30 출발 →
09:45 도착 → 11:15 → 11:25) 확인. 고유 콘텐츠 12건 ok=true.
옛 payload(look 없음)로 다시 구워 예전과 동일하게 나오는 것까지 확인.
백엔드는 이 환경에 PostgreSQL 이 없어 pytest 를 못 돌렸다 — _theme·_sections 는 함수 단위로 확인.
2026-09-02 21:30:25 +09:00
4d6802b4f2 [feat] solution/frontend: 에디터에 로그인 사용자 표시 — 6단계엔 신원도 나가는 길도 없었다
위저드(1~5단계)는 AppShell 사이드바가 사용자와 [로그아웃]을 들고 있는데, 에디터(6단계)는
전체 화면이라 AppShell 을 안 쓴다. 그래서 편집 화면에 들어가는 순간 **누구로 로그인했는지도,
나가는 방법도 화면에서 사라졌다.**

- BuilderPage: 에디터 상단 바 오른쪽에 사용자 · 상호와 [로그아웃] 추가
- stores/auth.userLabel: 이름 → 이메일 → 아이디 순. 구글 계정의 로그인 아이디는
  google_<sub> 라 그대로 보이면 안 된다. AppShell 도 같은 규칙을 쓰게 바꿨다
  (기존 `name ?? id` 는 이름이 빈 문자열이면 그대로 통과시켰다)

브라우저 확인: 가입 → 로그인 → 사이드바 '김사장 · 달빛스테이', 에디터 상단 바 동일 표시,
[로그아웃] 클릭 시 RequireAuth 가 /login 으로 되돌림. tsc·eslint·vite build 통과.
2026-09-02 17:29:02 +09:00
5ef3e5a7de 업종 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
2026-09-02 15:42:34 +09:00
1bcee68432 Merge branch 'feature/variety-item' into main 2026-09-02 15:17:24 +09:00
38fbe0ee7b [feat] solution/frontend: 여행 스케줄 아이템 — 대합실 시간표
가요다방·일력·승차권에 이어 네 번째 붙여넣기 아이템이다. 승차권(course)은 '어디를 도는가'라
순번이 축인데, 손님이 실제로 묻는 건 '몇 시에 뭘 하나'다. 시각을 축으로 하는 칸이 없었다.

- dataSpec/schedule: ScheduleItem·ScheduleSlot 과 작성 규칙. time 은 "HH:MM" 만,
  장소에 url 대신 searchQuery — 지어낸 주소를 링크하지 않는 규약 그대로다
- registry/schedule.timetable: 역 대합실 플립보드(가운데 접힘선)로 시각을 먼저 읽힌다
- industryData: 레트로 템플릿 시드에 schedule 추가

[+ 섹션 추가] 목록은 dataSpec 에서 파생돼(addable.ts) 따로 손댈 곳이 없다.
tsc·vite build 통과.
2026-09-02 15:17:21 +09:00
8410380769 [fix] solution/backend,shared,site: 에디터에 쓴 소개문이 발행에서 사라지던 구멍 — 계약에 body 추가
사장님이 소개 섹션에 본문을 써도 발행이 "고유 콘텐츠 0건"으로 거부됐다.
본문은 sites.theme 에 저장은 되는데 payload 경계에서 버려졌다 — _sections() 가
저장값에서 id·name·enabled·locked·variantId 다섯 개만 꺼내 새로 만들었다.
그래서 발행본에 안 나오고, 계수에도 안 잡혔다.

거부 문구도 틀렸다. 렌더러가 '고유 콘텐츠 0건'을 JSON-LD 불일치와 같은 VerifyError 의
mismatches 에 실어 던져서, 백엔드가 JSONLD_MISMATCH 로 판정하고 화면에는
"구조화 데이터와 화면 값이 다릅니다" 가 떴다. 구조화 데이터는 멀쩡했다.

- shared/site-payload: SectionSetting.body 추가 — variantId 와 같은 사연
- backend/site_payload: 저장된 body 를 payload 까지 실어 보낸다
- site/derive,AboutSection: 직접 쓴 본문을 그린다. 없으면 intro fact 로 떨어진다
- site/prerender: 켜진 소개 섹션의 8자 이상 본문을 고유 콘텐츠로 계수
- site/prerender: NoUniqueContentError 분리 — mismatches 를 비워 라벨이 안 섞이게.
  계수를 못 잰 실패는 null 로 보고한다(0 으로 적으면 디스크 오류가 같은 사유를 받는다)
- backend/build_service,publish_gate: 렌더 실패가 0건이면 NO_UNIQUE_CONTENT 라벨을 붙인다.
  evaluate() 는 안 건드렸다 — 얇은 콘텐츠로 발행을 막지 않기로 한 결정 그대로다
- backend/router: theme API 설명에 body 반영

테스트 8 failed / 511 passed. 실패 8건은 변경 전(508 passed)과 동일한 기존 실패다
(test_default_sections_match_the_editor 의 solution/front 경로 오타 등).
tsc·site·shared 통과. 실물 검증: 본문만 있는 payload → ok=true, uniqueContentCount=1,
발행 HTML 에 문장 포함. 같은 payload 에서 본문을 빼면 0건으로 거부.
2026-09-02 12:00:16 +09:00
b94daa9dcf [refactor] solution/frontend: 로그인 관문을 문 앞 하나로 — 에디터 관문과 2단계 우회로 제거
같은 날 두 자리에서 같은 문제를 풀어 관문이 두 겹이 됐다. 둘 다 두면 문 앞(RequireAuth)이
먼저 걸려 에디터 관문은 영영 안 뜨는 죽은 코드다. 문 앞을 남긴 이유는 열어 둔 값이
공짜가 아니었기 때문이다.

에디터 관문을 쓰려면 2단계가 토큰 없이 지나가야 했고, 그래서 토큰이 없을 때 서버를 부르지 않고
입력값으로 신원을 세우는 우회로가 생겼다(confirmManual). 그건 이 레포의 단 하나의 규칙
— 검증 전에는 수집·발행 금지 — 을 화면이 비켜 가는 모양이고, 대가는 "로그인 뒤에 검증을 다시"다.
게다가 가입이 이제 그 자리에서 끝나므로(가입 응답에 토큰이 실린다) 문 앞 로그인의 마찰은
"만들어 보기도 전에 막는다" 던 시절보다 훨씬 작다.

- features/auth/EditorSignInGate·SignInForm 삭제. 관문이 하나면 폼도 하나다 —
  SignInForm 이 경고하던 'signIn → me 를 두 벌로 들고 있다' 를 lib/session 한 곳으로 모았다
- Step2PlaceSearch: 토큰 없을 때 검증을 건너뛰던 두 갈래 제거
- usePlaceSearch: confirmManual 제거. 토큰이 없으면 이제 진짜 '만료'다(문 앞을 통과했으므로)
  — 문구를 사실대로 되돌린다
- BuilderPage: 에디터 진입 분기 제거

되돌리려면 app/router 의 RequireAuth 를 벗기고 969fb67·22b7623 을 되살리면 된다.

tsc·eslint·vite build 통과(사장님 앱·admin).
2026-09-02 09:41:28 +09:00
e8e695c5a9 Merge branch 'main' into feature/auth
텍스트 충돌은 없었지만 **로그인 관문이 두 겹**이 됐다. 같은 문제를 오늘 두 자리에서 풀었다.

- main 969fb67 : 에디터 진입(6단계)에서 받는다 — 위저드 1~5단계는 열어 둔다
- 이 브랜치 : /builder 문 앞에서 받는다 — 위저드 진입부터 계정을 요구한다

지금은 문 앞 게이트가 먼저 걸리므로 EditorSignInGate 는 세션이 도중에 끊긴 경우에만 뜬다.
둘 중 하나를 고르는 건 제품 결정이라 코드로 정하지 않았다. 에디터 게이트만 남기려면
app/router.tsx 의 RequireAuth 한 겹을 벗기면 된다.

남은 중복: features/auth/SignInForm 과 lib/session 이 'signIn → me' 순서를 각자 들고 있다.
게이트 결정이 난 뒤 한 벌로 합친다.

pytest 527 passed / 8 failed(전부 기존 실패). tsc·eslint·vite build 통과.
2026-09-02 09:37:11 +09:00
d6a6c8e229 [feat] solution/frontend: 빌더를 로그인 뒤로 — 걸어 들어온 뒤에 막지 않는다
위저드 2단계부터 백엔드를 부르고, 만든 결과는 사업장·사이트로 계정에 귀속된다. 로그인 없이
걸어온 사람은 3단계쯤에서 '로그인이 만료되었습니다' 를 만나고 그때까지 넣은 걸 잃었다 —
만료가 아니라 처음부터 세션이 없었던 것이다. 문 앞에서 막는 편이 낫다.

- app/router: /builder 를 RequireAuth 뒤로
- app/provider: 자동 로그인(AUTO_LOGIN_ID·PW)을 부팅에서 붙인다. 화면 안(useAutoLogin)은
  가드가 먼저 판단하므로 영영 실행되지 않는다 — 그래서 훅을 지웠다
- 자동 로그인이 신원까지 채웠으면 me() 를 두 번 부르지 않는다

⚠️ main 의 969fb67(에디터 진입에서 한 번만 로그인)·22b7623 과 **정면으로 다른 설계**다.
   되돌리려면 router 의 RequireAuth 한 겹만 벗기면 된다.

tsc·eslint·vite build 통과.
2026-09-02 09:34:13 +09:00
48109fdc99 [feat] solution/backend,frontend: id/pw 가입 · 구글 로그인 — 계정을 만들 길이 없던 걸 연다
계정 생성 API 가 아예 없었다(그동안 users 를 손으로 INSERT 했다). 로그인 화면은 있는데
그 뒤에 설 계정을 만들 방법이 제품에 없는 상태였다.

- auth_service.signup: 가입 = **새 회사(테넌트) 1개 + 첫 계정 1개**. users.company_id 가
  NOT NULL 이고 모든 도메인이 company 로 스코프돼서, 회사 없는 계정은 아무것도 못 만든다
- services/external/google_identity: 구글 ID 토큰의 서명·iss·만료에 더해 **aud(우리 client_id)와
  email_verified 를 본다.** aud 검사가 빠지면 남의 앱에 발급된 '진짜' 구글 토큰으로 우리 계정에
  들어온다 — 서명도 발급자도 전부 맞으므로 다른 검사로는 안 걸린다
- users.provider/provider_uid 추가, password NULL 허용, id 20→64자(google_<sub> 가 20자를 넘는다).
  provider 에 server_default 를 같이 준 이유: ORM default 는 raw INSERT(테스트 시드)에 안 먹어서
  NOT NULL 컬럼이면 그 경로가 통째로 깨진다
- attempt_login: 소셜 계정을 먼저 끊는다. 안 끊으면 bcrypt 가 None 해시를 만나 500 이다
- 같은 이메일이라도 id/pw 계정과 구글 계정을 **잇지 않는다.** 이으면 계정 선점이다 —
  남의 이메일로 먼저 만들어 둔 계정에 그 사람의 구글 로그인이 들어간다 → DECISIONS 1-5
- LoginPage 는 admin 과 공유라 selfServe 로 갈랐다. admin 은 가입 링크도 구글 버튼도 안 뜬다
  (admin 라우터에 /signup 이 없어 404 가 난다)
- GOOGLE_CLIENT_ID 는 루트 .env 한 곳. compose 가 VITE_GOOGLE_CLIENT_ID 로 흘려보낸다 —
  두 곳에 적으면 백엔드 aud 대조와 화면 버튼이 조용히 갈라진다

★ 이미 도는 DB 는 init.sql 을 다시 적용해야 한다(말미 ALTER 섹션).

pytest: auth 13건 + 구글 토큰 검증 8건(진짜 RSA 서명으로 aud·iss·만료·email_verified·변조
거절 확인) 통과. 전체 527 passed / 8 failed(전부 기존 실패, 인증과 무관).
tsc·eslint·vite build 통과.
2026-09-02 09:33:59 +09:00
22b7623aeb [fix] solution/frontend: 2단계에서 로그인을 요구하지 않는다
로그인은 에디터 진입에서 한 번 받는 것이 이 앱의 흐름인데, 장소 API 가 전부 토큰을 요구해서
(place.py) 2단계가 로그인 벽이 되어 있었다. 토큰이 없으면 서버를 부르지 않고 입력한 상호·주소로
신원을 세워 3단계로 넘어간다. 검증은 로그인 뒤에 다시 할 수 있다.
2026-09-02 09:20:55 +09:00
139a672138 [fix] solution/frontend: 2단계 안내를 사실대로 — 없는 직접 입력을 하라고 말하고 있었다
직접 입력 폼을 되돌리면서 안내 문구는 그대로 뒀다. 화면에 없는 걸 하라고 말하는 상태였다.
지도 검색·URL 확인 모두 서버가 토큰을 요구하므로(place.py 전 엔드포인트 IsValidAccessToken)
이 단계는 로그인 없이 지나갈 수 없다. 문구를 그대로 적는다.
2026-09-02 09:14:16 +09:00
94a083c895 [chore] solution/frontend: LoginPage 원복 — 로그인 화면은 건드릴 이유가 없었다
에디터 관문을 만들면서 로그인 화면까지 공용 폼으로 갈아엎었다. 요청 밖이라 되돌린다.
관문은 features/auth/SignInForm 을 그대로 쓴다.
2026-09-02 09:05:39 +09:00
969fb6773c [feat] solution/frontend: 로그인을 에디터 진입에서 한 번만 받는다
위저드를 걷는 동안 '로그인이 만료되었습니다' 가 떴다. 만료가 아니라 **한 번도 로그인한 적이
없는 것**이었다(자동 로그인 계정이 없으면 ensureAutoSession 이 즉시 끝난다). 문구가 사실과 달라
고장으로 읽혔다.

- features/auth/EditorSignInGate: 에디터에 들어갈 때만 로그인을 받는다. 위저드 1~5단계는
  요구하지 않는다 — 만들어 보기도 전에 막으면 아무도 안 만든다. /login 으로 튕기지 않는 이유는
  위저드에서 쌓은 상태를 들고 돌아올 방법을 사장님이 알 수 없기 때문이다
- features/auth/SignInForm: 로그인 화면과 관문이 같은 폼을 쓴다. 두 벌로 두면 토큰을 심는
  순서(signIn → me)가 한쪽에서만 지켜지고, 그 실수는 "로그인은 됐는데 계속 401" 로 나타난다
- usePlaceSearch: 토큰이 없을 때의 문구에서 '만료' 를 걷어낸다. 검색은 서버가 토큰을 요구하므로
  (place.py 전 엔드포인트가 IsValidAccessToken) 프론트가 없앨 수 있는 제약이 아니다

tsc·eslint·vite build 통과
2026-09-02 09:03:17 +09:00
4debeaed3a [feat] solution/frontend: 템플릿이 색만 바꾸던 걸 끝낸다 — 업종당 5개에서 3개로
업종마다 템플릿이 다섯인데 넷이 "흰 바탕 + 고딕 + 둥근 모서리"에 색조만 달랐다. 고르는 화면의
미리보기도 회색 막대 세 줄 + 색 동그라미라 다섯 장이 전부 같은 그림이었다 — 뭐가 다른지 알 수
없으니 아무거나 골랐다.

- shared/TemplateItem.look: 제목·본문 서체, 모서리, 테두리 두께, 그림자, 제목 자간·굵기, 섹션 여백.
  CSS 에 그대로 들어가는 문자열로 들고 있다 — 숫자로 두면 쓰는 쪽에서 단위를 빠뜨린 곳이 조용히 0 이 된다
- CanvasView: Tailwind v4 의 --radius-* · --shadow-* 를 캔버스 안에서만 덮는다. 변이 파일 40여 개에
  흩어진 rounded-* · shadow-* 를 한 줄도 안 고치고 전부 템플릿을 따르게 된다. 배수는 Tailwind
  기본 비율 그대로라 기준값 0.75rem 이면 지금까지와 픽셀 단위로 같다
- index.css: .site-canvas 는 --tpl-font-heading/body 를 이미 읽고 있었는데 아무도 넣지 않았다.
  서체가 안 갈리던 진짜 이유가 이 빠진 고리다. 제목 굵기도 토큰으로 뺐다 — 간판체(Gugi)는 굵기가
  한 벌뿐이라 700 을 주면 브라우저가 가짜 볼드를 씌워 획이 뭉갠다
- industryData: templatesFor() 팩토리 하나로 심플·매거진·레트로 셋. 업종은 accent 하나만 바꾼다 —
  생김새는 업종이 아니라 취향의 문제다. 537줄 → 237줄
- Step4Template: 미리보기를 그 템플릿의 서체·모서리·테두리·그림자로 실제로 그린다
- index.html: Noto Serif KR 추가. 매거진 제목이 Batang 으로 떨어지는데 맥에는 그 서체가 없다
- SectionFrame: 세로 여백을 --tpl-section-space 로. 이 값 하나로 페이지의 호흡이 바뀐다

옛 템플릿 id 가 DB 에 남아 있어도 resolveTemplate 이 첫 템플릿으로 떨어뜨린다.

tsc·eslint·vite build 통과(frontend·admin·site), 템플릿 12벌 look 전량 대조 + 폴백 확인
2026-09-02 08:49:38 +09:00
e6ffdad888 [feat] solution/frontend: 섹션 아이템을 버튼으로 넣는다 — 레트로 템플릿이 기본 구성을 데려온다
시드에 세 아이템을 박아 두니 내용 없는 칸이 목록에 늘 붙어 있었다. 붙여넣기 아이템은
JSON 이 없으면 빈 섹션이라 "쓸 사람만 넣는" 쪽이 맞다.

- canvas/addable.ts: 추가 가능한 섹션 목록. dataSpec 이 단일 출처라 아이템을 만들면 여기 자동으로
  나타난다 — 목록을 따로 들면 만들어 놓고 고를 수 없는 상태가 된다
- SectionListPanel: 하단 [+ 섹션 추가] + 썸네일 목록. 나중에 넣은 섹션만 휴지통으로 뺀다
  (업종 기본 섹션은 스위치로 끄는 것이지 빼는 게 아니다). 내용이 있으면 빼기 전에 한 번 묻는다
- stores/builder: addSection 은 이미 있으면 새로 만들지 않고 켜기만 한다 — 새로 만들면 넣어 둔
  JSON 이 날아간다
- shared/TemplateItem.defaultSectionTypes + 업종마다 레트로 템플릿 하나(옛 항구·옛 다방·노포·시간여행).
  고르면 세 아이템이 함께 들어온다. 넣기만 하고 빼지 않는다 — 템플릿을 눌러 보다 넣어 둔 섹션이
  사라지면 사장님은 그게 템플릿 때문인 줄 모르고 자기가 지웠다고 생각한다
- siteTheme/applyTheme: 저장 payload 에 type 을 싣는다. 시드에 없는 섹션은 id 로 못 찾아 복원 때
  통째로 버려졌다 — 사장님이 채운 JSON 까지 같이 사라지는 자리였다
- industryData: 세 아이템을 시드에서 뺐다

tsc·eslint·vite build 통과(frontend·admin), 추가·삭제·템플릿 연동·저장복원 왕복 12건 확인
2026-09-02 08:35:25 +09:00
23162783f9 [feat] solution/frontend: 붙여넣기 아이템 셋 — 가요 다방·오늘의 한 장·반나절 산책
gunsan_365_story_db.xlsx 365행을 뜯어 보니 고유 주제는 52개이고 한 주제가 7회씩 돈다
(접미사 10개만 회전). 날짜 축으로 카드를 늘어놓으면 이레마다 같은 카드가 돌아온다 —
그래서 묶는 축을 주제로 잡고 날짜는 일력 한 장에만 썼다. 같은 파일 DB_Guide 가 가사·원문
전재를 금지해 가요 스키마에 lyrics 필드를 아예 두지 않았다. 없는 칸은 채울 수 없다.

- canvas/dataSpec.ts: 스키마·예시·프롬프트 단일 표. 레지스트리와 같은 결이라 한 줄을 더하면
  캔버스·[콘텐츠] 탭·프롬프트가 동시에 는다. 파싱은 절대 throw 하지 않는다 — 편집 중인 JSON 은
  늘 깨져 있고 그때 캔버스가 죽으면 고칠 방법이 없다
- shared/types/builder: SectionItem.data 는 파싱본이 아니라 원문 문자열이다. 파싱본만 들면
  JSON 이 깨진 순간 사장님이 쓴 걸 잃는다
- variants/{songs,daily,course}: 턴테이블·일력·승차권. 카드 격자를 쓰지 않고 전부 가로로 넘긴다
- RightTabsPanel: JSON 칸 + 프롬프트 복사/보기·예시 넣기·줄맞춤. 프롬프트에 상호와 주소에서 뽑은
  시·군·구를 박아 내보낸다 — 빈칸을 남기면 못 채우고 그대로 보내고 모델이 엉뚱한 곳을 지어낸다
- dataSpec.locate: JSON.parse 오류가 두 형식이다. position 형만 보면 조각 인용 형에서 위치를
  통째로 잃는다 — 조각을 원문에서 되찾아 센다
- SECTION_DATA_MAX_CHARS: 테마 상한 64KB(site_service._THEME_MAX_BYTES)를 세 섹션이 함께
  넘길 수 있고 거절은 발행 직전에야 드러난다. 화면에서 먼저 끊는다
- industryData: 네 업종에 꺼진 채로 넣는다. 내용 없이 켜져 있으면 발행본에 빈 섹션이 나간다
- index.html: Gugi·Gowun Batang·Nanum Pen Script. 없으면 고딕으로 떨어져 감성이 사라진다

발행 사이트(solution/site)는 아직 variantId 도 data 도 읽지 않는다 — 지금은 빌더 캔버스 전용이다.

tsc·eslint·vite build 통과(frontend·admin), 세 배리에이션 SSR 렌더 확인, 파서 경계 12건 확인
2026-09-01 17:58:58 +09:00
c250b656fd [chore] solution/frontend,docs: Bing 소유확인 파일 추가 — 이미지에 구워야 재생성에도 살아남는다
Bing Webmaster 의 XML 파일 방식은 루트에서 파일을 읽는다. nginx `location /` 이
`/srv/app`(= solution-site 이미지)에서 찾으므로 `public/` 에 두고 굽는 것 말고는 자리가 없다.
`docker cp` 로 밀어 넣으면 컨테이너 재생성 때 사라지고, 검색엔진이 인증을 재확인하는
시점에 조용히 풀린다.

- solution/frontend/public/BingSiteAuth.xml: Bing 이 준 파일 그대로(가공하면 파싱이 깨진다)
- docs/DEPLOY.md 2-2절: 세 검색엔진의 소유확인 방식과 사는 자리.
  ★ 확인은 상태코드가 아니라 내용으로 한다 — `try_files … /index.html` 이라
  파일명이 틀리면 404 가 아니라 빌더 HTML 이 200 으로 나간다

검증: 배포 후 curl 로 본문 대조
2026-09-01 13:33:48 +09:00
db58e94540 [feat] solution/frontend: 자동 로그인 복원 — 계정이 주입돼 있을 때만 붙는다
9b4fe40 이 개발 전용 자동 로그인을 지우면서 빌더 2단계가 막다른 길이 됐다.
로그인한 적도 없는 사람에게 "로그인이 만료되었습니다" 라고 쓰고, 그 화면에는
로그인으로 갈 링크가 없다. 실측(킹서버): 계정이 DB 에 0개라 아무도 통과 못 했다.

- lib/autoSession.ts: 옛 devSession 을 되살리되 `import.meta.env.DEV` 게이트를 뺐다.
  운영 번들에서도 돌아야 한다 — 대신 VITE_AUTO_LOGIN_ID·PW 가 **둘 다** 있을 때만
  움직이고 기본값은 없다. 진행 중인 로그인을 하나의 약속으로 공유하는 구조는 그대로
  가져왔다: 훅 안에만 두면 로그인 전에 누른 검색이 토큰 없이 나가 '만료'로 떨어진다.
- usePlaceSearch: 검색 전에 그 약속을 기다린다(경합을 만료로 오인하던 자리).
- Dockerfile·compose: VITE_* 는 번들에 구워지므로 build args 다. 값을 바꾸면
  `./deploy.sh solution-site` 로 다시 굽는다.

⚠️ 계정이 번들에 그대로 들어간다. 내부 테스트 호스트 전용이고, 사장님에게 열기 전에
   AUTO_LOGIN_* 을 비우고 재빌드해야 한다.

tsc --noEmit · eslint 통과
2026-09-01 12:00:07 +09:00