Compare commits

...

24 Commits

Author SHA1 Message Date
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
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
5fa83933e4 [fix] postgres-init: 구글 로그인 컬럼의 인덱스를 ALTER 뒤로 — 기존 DB 에서 스크립트가 멈춘다
실측: dev DB(web4ai_db)에 재적용하니 인덱스 절에서 끊겼다.

  ERROR:  column "provider_uid" does not exist

파일 구조가 CREATE TABLE → 인덱스 → 기존 DB 보정(ALTER) 순이라, 새 DB 에서는 CREATE TABLE 이
컬럼을 만들어 주지만 **기존 DB 에서는 컬럼이 맨 끝 ALTER 로 생긴다.** 인덱스를 위에 두면
ON_ERROR_STOP 에서 나머지 보정까지 통째로 안 돈다 — 새 DB 에서만 테스트하면 안 보이는 종류다.

- uq_users_provider_uid 를 ALTER 섹션 끝으로 옮기고, 왜 거기 있어야 하는지 주석으로 못 박았다

dev DB 재적용으로 확인: ALTER 4건 + CREATE INDEX 통과, company.users 에 provider/provider_uid
생성 및 password NULL 허용 확인.
2026-09-02 17:29:02 +09:00
117 changed files with 6921 additions and 1104 deletions

View File

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

View File

@ -5,6 +5,24 @@
--- ---
## 2026-09-03 — 랜딩 · 요금 · 쇼케이스 — 로그인 전 화면이 생겼다
**왜**
`/` 가 곧장 위저드로 튀어서, 이 제품이 무엇을 파는 물건인지 말할 자리가 한 곳도 없었다.
처음 온 사람이 업종 선택 화면부터 만난다.
**한 일**
- `/` 는 비로그인이면 랜딩, 로그인이면 `/sites`. `/pricing` · `/showcase` 신설
- `MarketingShell` — 사이드바 없는 문서형 껍데기. `AppShell` 은 작업 화면이라 나눴다
(b07ade2 가 온보딩에서 사이드바를 뺀 것과 같은 판단)
- 랜딩 상단은 **상호명 한 칸**이다. 업종 칩은 "누구를 위한 서비스인가"를 말하는 용도이고
고르지 않아도 된다 — 업종은 검색 결과가 정한다
- 쇼케이스는 발행 썸네일을 그대로 건다. **예시 데이터로 채우지 않는다** — 이 섹션이 파는 건
"진짜로 나갔다"는 사실 하나라, 가짜를 걸면 그 자리에서 가치가 0 이다. 없으면 섹션을 감춘다
- 요금은 플랜 하나(70만원/월). 비교표를 만들지 않는다 — 고를 것이 가격대가 아니다
**검증** — tsc·eslint·vite build 통과.
## 2026-09-03 — 상호명 검색을 로그인 앞으로 · 업종은 LLM 없이 정한다 ## 2026-09-03 — 상호명 검색을 로그인 앞으로 · 업종은 LLM 없이 정한다
**왜** **왜**
@ -64,6 +82,181 @@
유도한다(`snapshot._local_contents`) — 유도 동작에 테스트가 없었다. 둘로 갈라 채웠다. 유도한다(`snapshot._local_contents`) — 유도 동작에 테스트가 없었다. 둘로 갈라 채웠다.
**검증** — `pytest` 전체 552 passed. **검증** — `pytest` 전체 552 passed.
## 2026-09-02 — 로그인한 사장님의 홈(내 사이트 · 내 정보) · 위저드에서 사이드바 제거
**왜**
로그인해도 갈 곳이 없었다. `/` 는 무조건 위저드였고, 사업장 목록은 내부 운영 앱(admin)으로
나가서 사장님 앱에는 그 경로가 아예 없다. 만든 사이트를 다시 여는 유일한 길이
`/builder?placeId=<uuid>` 를 기억하는 것이었다.
아임웹을 보면 계층이 둘로 갈려 있다 — **계정 레벨**(내사이트 목록 · 마이페이지)과
**사이트 레벨**(그 사이트의 관리자 페이지 · 디자인모드). 우리 에디터가 그 사이트 레벨이므로
비어 있던 것은 계정 레벨이다. 그리고 아임웹도 **사이트 개설 흐름에는 계정 사이드바를 붙이지
않는다** — 아직 사이트가 아닌 것에 사이트 메뉴를 얹을 수 없어서다.
**한 일**
- `GET /v1/site/list` — places LEFT JOIN sites LEFT JOIN site_versions 한 번. 사업장 목록으로
그리면 줄마다 사이트를 다시 물어 N+1 이다. 사이트가 아직 없는 사업장도 내려간다 —
빠지면 위저드를 걸어오다 만 가게를 다시 찾을 길이 없다.
`render`(정적 파일이 실제로 있는지)는 넣지 않았다 — 보고서 **파일**을 읽는 값이라 줄 수만큼
파일 IO 가 된다. 단건(`Res_Site`)이 계속 소유한다.
- `/sites` 내 사이트 · `/account` 내 정보. `/` 는 로그인 여부로 갈린다(비로그인은 그대로 위저드).
- ⋯ 메뉴는 **[발행 내리기] 하나**다. 삭제는 두지 않았다 — 색인된 페이지를 404 로 만들면 그 자리를
다시 OTA 가 가져가고, 되돌릴 방법이 사장님에게 없다.
- 위저드에서 `AppShell`(사이드바)을 걷어내고 얇은 상단 바로 바꿨다. 사이드바는 계정 메뉴라,
만들던 중에 [새 사이트]를 눌러 방금 입력한 것을 지우는 길만 열어 준다. 진행은 `WizardSteps` 가
이미 보여주므로 거기 필요한 건 로고와 나가는 길 하나다.
- 에디터 헤더에 [← 내 사이트]. `BuilderPage` 가 "내 사이트 관리가 생기면 그때 잇는다"고
비워 뒀던 자리다.
**검증** — 백엔드 테스트 5건 추가(사이트 없는 사업장 · 조인 · 회사 격리 · 재빌드 판정이 단건과
일치 · 비로그인 401), 539 passed. `tsc·eslint·vite build` 통과(frontend·admin).
위저드에 사이드바가 사라진 것은 브라우저에서 확인.
## 2026-09-02 — 계절별 추천 하루는 지금 계절만 · 간절기엔 두 계절
**왜**
네 계절 코스를 다 늘어놓으니 손님 앞에 열두 개가 깔렸다. 그건 추천이 아니라 목록이다.
12월에 온 손님에게 봄 벚꽃 코스를 권할 이유가 없다.
**한 일**
- `shared/currentSeasons()` — 3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울.
**계절 첫 달의 전반(1~15일)은 간절기**로 보고 앞 계절과 함께 둘을 돌려준다.
9월 초에 여름 코스만 보이면 지난 계절이고, 가을만 보이면 아직 이른 코스다.
- 발행본은 **HTML 에 전 계절을 굽고 화면에서만 접는다**(`hidden`). 두 가지 이유다 —
① 정적 페이지는 한 번 구우면 몇 달 산다. 굽는 시점의 계절을 박으면 12월에도 가을이 걸린다.
그래서 계절 판정을 **브라우저에서** 한다(일력의 '오늘'과 같은 수법).
② 이 사이트의 존재 이유가 인용이다. 지우면 검색·AI 가 나머지 계절을 못 읽는다.
- 지금 계절에 코스가 없으면(사장님이 그 계절을 안 채웠다) 접지 않고 전부 보여준다 —
빈 섹션보다 철 지난 코스가 낫다.
- 빌더는 탭을 그대로 두되 **지금 계절로 열리고**, 탭에 '·지금' 표시와
"손님 화면에는 지금 계절만 나갑니다" 한 줄을 붙였다. 안 적으면 사장님은 손님도 네 계절을
다 본다고 오해한다.
**검증** — `tsc·eslint` 통과(frontend·site). 경계 12일자 단위 확인
(3/5→겨울·봄, 3/20→봄, 6/7→봄·여름, 9/2→여름·가을, 9/16→가을, 12/10→가을·겨울).
실물 payload(스테이,머뭄 `/s/stay`, 9코스 4계절)로 구워 **오늘(9/2) 여름·가을만 보이고
봄·겨울은 `hidden`, HTML 에는 네 계절 전부** 있는 것을 브라우저에서 확인.
## 2026-09-02 — 계절별 추천 하루(시각을 계산해 주는 아이템) · 아이템에서 레트로 하드코딩 제거
**왜**
아이템 열 개가 전부 갱지색·주(朱)잉크·간판체를 hex 와 폰트명으로 박고 있었다. 사장님이 템플릿을
매거진으로 바꿔도 **아이템 섹션만 레트로로 남아** 화면이 두 벌로 보였다. 아이템은 레트로 전용
부품이 아니라 어느 템플릿에나 들어가는 섹션이다.
그리고 발행본은 **색만** 템플릿을 따랐다 — `theme` 계약에 생김새(look)가 없어서, 레트로를 골라도
발행 페이지는 늘 같은 고딕으로 나갔다. 캔버스와 발행본이 다르게 보이는 가장 큰 이유였다.
**한 일**
- 아이템 1종 추가 — **계절별 추천 하루**(`planner.podium`). 계절 탭 + 1·2·3위 카드.
기존 `schedule` 과 축이 다르다: 저쪽은 사장님이 시각을 적고, 여기는 **시각을 계산한다**.
사장님은 출발 시각과 "몇 분 걸리나"만 적고, 출발을 당기면 하루가 통째로 밀린다.
조립 규칙(`planDay`·`plannerTop`·`plannerSeasons`)은 파서와 같은 이유로 `@o2o/shared` 한 벌이다 —
빌더와 발행본이 같은 조건에서 **같은 시각**을 내야 한다.
밤 9시를 넘기는 칸은 넣지 않고 **뺐다고 화면에 밝힌다**(숨기면 사장님은 왜 없는지 모른다).
- 아이템 색·서체를 전부 템플릿 토큰(`--tpl-*`)으로. `retro/common.tsx` → `items/common.tsx`,
`RETRO_*` 상수 → `ITEM_*` 토큰. 글자 단계는 stone-400/500/600 대신 **불투명도**로 만든다 —
팔레트가 바뀌어도 위계가 유지된다. 질감(도넛판 홈·톱니·필름 구멍)도 `currentColor` 로 판다.
- **`SiteTheme.look` 계약 추가** — 서체·모서리·테두리 두께·그림자·섹션 여백이 발행본까지 간다.
프론트가 저장하고(`toThemePayload`), 서버는 해석 없이 싣고(`_theme`), `seo/head.ts` 가 `--tpl-*` 로 심는다.
발행본 `.serif`·`body` 도 이 토큰을 읽는다.
- 웹폰트는 **템플릿이 쓰는 것만** 내려보낸다(서체 스택을 훑어 아는 것만). 전부 항상 실으면
쓰지도 않는 서체가 모든 발행 사이트의 첫 렌더를 늦춘다.
- 색 유도식(`deriveSurfaces`)을 `@o2o/shared` 로. 캔버스·쇼케이스·**발행본**이 같은 식을 써야
미리보기가 거짓말을 하지 않는다. 프론트 `lib/color.ts` 는 재수출만 남겼다.
**밟은 함정**
- 강조색을 그대로 쓰면 팔레트에 따라 큰 날짜 숫자와 배지가 **사라진다**(연한 accent + 밝은 바탕).
→ `color-mix(accent 70%, currentColor)` — 색조는 남고 대비만 확보된다. 어두운 면에서는 밝은 쪽으로 붙는다.
- 순위 배지를 accent 로 채웠더니 같은 이유로 글자가 안 보였다. 1위만 **글자색**으로 채운다.
- '확인/확인필요' 배지는 디자인이 아니라 신호다. 신호색은 지키되 둘레 글자색을 섞어 대비만 맞춘다.
**검증** — `tsc·eslint·vite build` 통과(frontend·admin·site), site 테스트 17 passed.
쇼케이스에서 팔레트를 바꿔 제목 서체·날짜 색이 함께 바뀌는 것 확인.
실물 프리렌더(레트로 look + planner): `<head>` 에 `--tpl-font-heading: 'Gugi'…`·`--tpl-border-width: 2px`,
`family=Gugi&family=Gowun+Batang` 링크, 계절 묶음 여름·가을, 순위 1·2위,
계산된 시각(09:30 출발 → 09:45 도착 → 11:15 → 11:25…) 확인. 고유 콘텐츠 12건 · ok=true.
**옛 payload(look 없음)** 로 다시 구워 서체 링크가 예전 두 벌 그대로이고 look 토큰이 안 실리는 것까지 확인.
백엔드는 이 환경에 PostgreSQL 이 없어 pytest 를 못 돌렸다 — `_theme`·`_sections` 는 함수 단위로 직접 확인했다.
## 2026-09-02 — 붙여넣기 아이템 다섯을 더하고, 아홉 개를 발행 사이트까지 내보낸다
**왜**
아이템 카탈로그에서 고른 여덟 중 넷(가요·일력·승차권 + 스케줄)만 있었다. 나머지 다섯은
빌더에 칸 자체가 없었다. 더 큰 구멍은 그 아래에 있었다 — **아홉 개 전부 발행본에 안 나갔다.**
`SectionSetting` 계약에 `data` 가 없어서, 사장님이 채운 JSON 이 payload 경계에서 통째로 버려졌다
(소개문 `body` 와 같은 사연). 빌더에서는 보이는데 발행하면 없는 섹션이었다.
**한 일**
- 아이템 5종 추가 — 인물 열전(필름 스트립) · 시간의 골목(가로 연표) · 문학 서가(책등·세로쓰기) ·
오늘의 엽서(엽서 뒷면) · 뒤집어 보는 질문(갱지 시험지 플립).
`dataSpec` 에 스키마·프롬프트·예시, `registry` 에 배리에이션 한 줄씩.
[+ 섹션 추가] 목록은 `dataSpec` 에서 파생돼(addable.ts) 따로 손댈 곳이 없다.
- **읽는 쪽 계약을 `@o2o/shared` 로 옮겼다**(`lib/section-data.ts`) — 항목 타입 · `parseSectionData`.
같은 JSON 을 빌더와 발행 사이트가 함께 읽는다. 파서를 각자 두면 슬러그 규칙처럼 조용히 어긋난다.
빌더에는 **쓰는 쪽**(프롬프트·예시·라벨)만 남았다.
- `SectionSetting.data` 계약 추가 · `site_payload._sections()` 가 그대로 실어 보낸다(서버는 파싱하지 않는다).
- 발행 사이트에 아이템 섹션 아홉(`site/src/sections/items/`). **인터랙션은 옮기지 않았다** —
캔버스의 턴테이블은 '지금 한 곡'만 펴는데 그러면 나머지 곡의 문장이 HTML 에 없다.
이 사이트의 존재 이유가 AI·검색의 인용이라 발행본은 전 항목을 펴고 가로로만 민다.
- 프리렌더 고유 콘텐츠 계수에 아이템 항목을 넣었다. 안 세면 "곡을 여덟 개 채웠는데
고유 콘텐츠 0건으로 발행이 막힌다"가 된다 — `intro.body` 와 같은 구멍이다(백엔드 fake 도 같이).
- 간판체(Gugi)는 **아이템을 실제로 쓰는 사이트에만** `<head>` 로 내려보낸다. 서체 하나가
모든 발행 사이트의 첫 렌더를 늦출 이유가 없다.
**안 한 것**
레트로 템플릿 시드(`defaultSectionTypes`)는 넷 그대로 뒀다. 붙여넣기 아이템은 내용이 없으면
빈 섹션이라, 아홉을 시드에 박으면 아무도 안 쓰는 칸이 늘 붙어 있게 된다(addable.ts 의 근거).
**검증** — `tsc·eslint·vite build` 통과(frontend·admin·site), site 테스트 17 passed.
실물 프리렌더: 아이템 아홉이 든 payload → `ok=true`, 고유 콘텐츠 18건, 발행 HTML 에 아홉 섹션과
본문 문장 전부 포함, `family=Gugi` 링크 있음. 같은 payload 에서 아이템을 빼면 8건 · Gugi 링크 없음.
백엔드 pytest 는 이 환경에 PostgreSQL 이 없어 전 건 연결 오류로 못 돌렸다 —
바꾼 `_sections()` 와 conftest 계수는 함수 단위로 직접 돌려 확인했다.
---
## 2026-09-02 — 회원가입과 구글 로그인
**한 일**
- `POST /v1/auth/signup`(id/pw) · `POST /v1/auth/google` 추가. 로그인 화면에 구글 버튼과
가입 링크, `/signup` 화면. 내부 운영 화면은 `selfServe={false}` 로 둘 다 안 뜬다.
- `company.users` 에 `provider`(AuthProvider) · `provider_uid`(구글 sub). `password` 는 NULL
허용(소셜 계정), `id` 는 20 → 64자(`google_<sub>` 가 20자를 넘는다).
- 에디터(6단계) 상단 바에 로그인한 사용자와 [로그아웃]. 위저드는 AppShell 사이드바가
들고 있었는데 에디터는 전체 화면이라 **신원도 나가는 길도 화면에서 사라져 있었다.**
**왜 가입부터 만들었나**
계정 생성 API 가 아예 없었다 — 그동안 `users` 를 손으로 INSERT 했다. 로그인 화면은 있는데
그 뒤에 설 계정을 만들 방법이 제품에 없는 상태였다. 가입 = **새 회사(테넌트) 1개 + 첫 계정 1개**
로 정의했다. `users.company_id` 가 NOT NULL 이고 모든 도메인이 company 로 스코프되기 때문이다.
**로그인 관문은 에디터 진입 그대로다**
한때 `/builder` 를 통째로 `RequireAuth` 뒤로 옮겼다가 되돌렸다(5ef3e5a). `/` 가 자기 화면 없이
`/builder` 로 넘기기만 하므로 **문 앞 가드는 곧 루트 가드**이고, 앱을 열자마자 로그인 화면이 된다.
관문은 `EditorSignInGate`(969fb67) 한 자리다.
**밟기 쉬운 자리**
- **`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 컬럼이면 그 경로가 통째로 깨진다.
- **init.sql 에서 새 컬럼의 인덱스는 맨 끝 ALTER 섹션에 둔다.** 인덱스 절이 ALTER 보다 위라,
기존 DB 에서는 아직 없는 컬럼을 가리켜 스크립트가 통째로 멈춘다(실측으로 밟았다).
**이미 도는 DB 가 있으면** `postgres-init/init-data/init.sql` 을 다시 적용한다.
**아직 못 한 것** — 실제 구글 계정 로그인. `GOOGLE_CLIENT_ID` 가 있어야 버튼이 뜬다.
버튼 렌더까지는 확인했다(빌려온 client_id 로).
**검증** — 백엔드 auth 13건 + 구글 토큰 검증 8건(진짜 RSA 서명으로 aud·iss·만료·
`email_verified`·본문 변조 거절). 브라우저: 가입 → 위저드 진입 → 사이드바 표시 → 에디터
상단 바 표시 → 로그아웃. `tsc`·`eslint`·`vite build` 통과.
## 2026-09-02 — 직접 쓴 소개문이 발행에서 사라지던 구멍 ## 2026-09-02 — 직접 쓴 소개문이 발행에서 사라지던 구멍

View File

@ -359,8 +359,6 @@ CREATE INDEX IF NOT EXISTS idx_users_company_id ON company.users (company_id);
-- 소프트 삭제를 쓰므로 자연키 유니크는 부분 인덱스(deleted = FALSE)로 건다. -- 소프트 삭제를 쓰므로 자연키 유니크는 부분 인덱스(deleted = FALSE)로 건다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_id ON company.users (id) WHERE deleted = FALSE; CREATE UNIQUE INDEX IF NOT EXISTS uq_users_id ON company.users (id) WHERE deleted = FALSE;
-- 같은 구글 계정으로 두 번 가입되지 않게. provider 를 키에 넣어 수단이 늘어도 이 인덱스가 그대로 쓰인다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_provider_uid ON company.users (provider, provider_uid) WHERE deleted = FALSE AND provider_uid IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_companies_biz_number ON company.companies (business_number) WHERE deleted = FALSE AND business_number IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS uq_companies_biz_number ON company.companies (business_number) WHERE deleted = FALSE AND business_number IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_companies_code ON company.companies (code) WHERE deleted = FALSE; CREATE INDEX IF NOT EXISTS idx_companies_code ON company.companies (code) WHERE deleted = FALSE;
@ -442,3 +440,7 @@ ALTER TABLE company.users
ADD COLUMN IF NOT EXISTS provider_uid VARCHAR(255) NULL; ADD COLUMN IF NOT EXISTS provider_uid VARCHAR(255) NULL;
ALTER TABLE company.users ALTER COLUMN id TYPE VARCHAR(64); ALTER TABLE company.users ALTER COLUMN id TYPE VARCHAR(64);
ALTER TABLE company.users ALTER COLUMN password DROP NOT NULL; ALTER TABLE company.users ALTER COLUMN password DROP NOT NULL;
-- ★ 이 인덱스는 위 인덱스 절이 아니라 **여기** 있어야 한다. 기존 DB 에서는 컬럼이 ALTER 로
-- 생기므로, 인덱스를 먼저 만들면 "column provider_uid does not exist" 로 스크립트가 통째로 멈춘다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_provider_uid ON company.users (provider, provider_uid)
WHERE deleted = FALSE AND provider_uid IS NOT NULL;

View File

@ -196,6 +196,15 @@ def fake_renderer(monkeypatch, tmp_path_factory):
# 고유 콘텐츠 계수 규칙은 렌더러(prerender.ts countUniqueContent)와 같아야 한다. # 고유 콘텐츠 계수 규칙은 렌더러(prerender.ts countUniqueContent)와 같아야 한다.
MIN_UNIQUE_TEXT = 8 MIN_UNIQUE_TEXT = 8
def _has_long_text(value, long) -> bool:
if isinstance(value, str):
return long(value)
if isinstance(value, list):
return any(_has_long_text(v, long) for v in value)
if isinstance(value, dict):
return any(_has_long_text(v, long) for v in value.values())
return False
def _count(payload: dict) -> int: def _count(payload: dict) -> int:
def long(value) -> bool: def long(value) -> bool:
return len(str(value or "").strip()) >= MIN_UNIQUE_TEXT return len(str(value or "").strip()) >= MIN_UNIQUE_TEXT
@ -207,6 +216,17 @@ def fake_renderer(monkeypatch, tmp_path_factory):
) )
if intro and intro.get("enabled") and long(intro.get("body")): if intro and intro.get("enabled") and long(intro.get("body")):
count += 1 count += 1
# 붙여넣기 아이템(theme.sections[].data)의 항목도 고유 콘텐츠다 — 렌더러와 같은 규칙.
for section in (payload.get("theme") or {}).get("sections") or []:
if not section.get("enabled") or not section.get("data"):
continue
try:
envelope = json.loads(section["data"])
except (ValueError, TypeError):
continue
for item in (envelope or {}).get("items") or []:
if _has_long_text(item, long):
count += 1
facts = list(payload.get("facts") or []) facts = list(payload.get("facts") or [])
for unit in payload.get("units") or []: for unit in payload.get("units") or []:
facts.extend(unit.get("facts") or []) facts.extend(unit.get("facts") or [])

View File

@ -21,6 +21,10 @@ class ISiteCRUD(ABC):
async def get_site_by_domain(self, cdb: AsyncSession, domain: str) -> Tuple[ErrorType, sites]: async def get_site_by_domain(self, cdb: AsyncSession, domain: str) -> Tuple[ErrorType, sites]:
pass pass
@abstractmethod
async def list_company_sites(self, cdb: AsyncSession, company_id, skip, limit) -> Tuple[ErrorType, list, int]:
pass
@abstractmethod @abstractmethod
async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]: async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]:
pass pass
@ -89,6 +93,36 @@ class SiteCRUD(ISiteCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None return ErrorType.DB_RUN_FAILED, None
async def list_company_sites(self, cdb: AsyncSession, company_id, skip: int, limit: int) -> Tuple[ErrorType, list, int]:
"""회사의 사업장 + 사이트 + 마지막 빌드 시각. (ErrorType, [(place, site, built_at)], 총건수).
따로 읽으면 줄마다 사이트를 다시 물어 N+1 이다. LEFT JOIN 이라 사이트가 없는 사업장
(위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다."""
try:
where = and_(places.deleted == False, places.company_id == company_id) # noqa: E712
cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(places).where(where))
if cnt_err != ErrorType.SUCCESS:
return cnt_err, [], 0
total = int(cnt_rows[0] or 0) if cnt_rows else 0
query = (
select(places, sites, site_versions.built_at)
.outerjoin(sites, and_(sites.place_id == places.place_id, sites.deleted == False)) # noqa: E712
.outerjoin(site_versions, site_versions.site_version_id == sites.current_version_id)
.where(where)
.order_by(places.created_at.desc())
.offset(skip)
.limit(limit)
)
list_err, rows = await DB_SESSION_MNG.execute(cdb, query)
if list_err != ErrorType.SUCCESS:
return list_err, [], 0
return ErrorType.SUCCESS, list(rows), total
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0
async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]: async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]:
"""후보 주소들 중 이미 쓰이는 것만 추린다. 대안 제안이 후보마다 왕복하지 않게 한 번에 본다.""" """후보 주소들 중 이미 쓰이는 것만 추린다. 대안 제안이 후보마다 왕복하지 않게 한 번에 본다."""
try: try:

View File

@ -88,6 +88,7 @@ app.include_router(router.v1.faq.faq.router)
app.include_router(router.v1.media.media.router) app.include_router(router.v1.media.media.router)
app.include_router(router.v1.job.job.router) app.include_router(router.v1.job.job.router)
app.include_router(router.v1.site.site.router) app.include_router(router.v1.site.site.router)
app.include_router(router.v1.site.site.my_router)
# ★ 인증 없는 공개 목록. 랜딩이 부른다 — 어드민 진입점(:9801)에는 붙이지 않는다. # ★ 인증 없는 공개 목록. 랜딩이 부른다 — 어드민 진입점(:9801)에는 붙이지 않는다.
app.include_router(router.v1.site.showcase.router) app.include_router(router.v1.site.showcase.router)
app.include_router(router.v1.local.local.router) app.include_router(router.v1.local.local.router)

View File

@ -8,12 +8,13 @@ from common.enums import (
BuildStatus, BuildStatus,
JobStatus, JobStatus,
PlaceCategory, PlaceCategory,
PlaceStatus,
PublishAction, PublishAction,
PublishRejectReason, PublishRejectReason,
PublishResult, PublishResult,
SiteStatus, SiteStatus,
) )
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
class SiteProtocol(WebPacketProtocol): class SiteProtocol(WebPacketProtocol):
@ -66,6 +67,32 @@ class SiteData(WebPacketProtocol):
thumbnail_url: Optional[str] = None thumbnail_url: Optional[str] = None
class MySiteData(WebPacketProtocol):
"""내 사이트 목록의 한 줄 — 사업장(place) + 사이트(site).
★ render 는 여기 없다 — 보고서 **파일**을 읽는 값이라 줄 수만큼 파일 IO 가 된다(단건이 소유).
★ site_id 아래가 전부 None 이면 아직 사이트가 없는 사업장이다."""
place_id: uuid.UUID
name: str
category: PlaceCategory
place_status: PlaceStatus
road_address: Optional[str] = None
created_at: Optional[datetime] = None
site_id: Optional[uuid.UUID] = None
status: Optional[SiteStatus] = None
domain: Optional[str] = None
template_id: Optional[str] = None
published_at: Optional[datetime] = None
# 단건과 같은 규칙 — 노출값이 마지막 빌드보다 나중에 바뀌었으면 재발행 대상이다.
needs_rebuild: bool = False
class Res_MySites(Res_PageProtocol):
sites: list[MySiteData] = []
class PublishLogData(WebPacketProtocol): class PublishLogData(WebPacketProtocol):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@ -97,9 +124,9 @@ class Req_SiteTheme(SiteProtocol):
떨어뜨리므로 화면은 깨지지 않는다. 떨어뜨리므로 화면은 깨지지 않는다.
★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다: ★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다:
{"theme": {"colors": {...}, "fontStyle": "...", "colorPaletteId": "...", "sections": [...]}} {"theme": {"colors": {...}, "fontStyle": "...", "look": {...}, "colorPaletteId": "...", "sections": [...]}}
sections 는 {id, name, enabled, locked, variantId?, body?} 의 목록이고 **배열 순서가 곧 섹션 순서**다 sections 는 {id, name, enabled, locked, variantId?, body?, data?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
(별도 order 필드가 없다). variantId 와 직접 입력 본문 body 는 값이 있을 때만 키가 붙는다. (별도 order 필드가 없다). variantId·본문 body·붙여넣기 JSON data 는 값이 있을 때만 키가 붙는다.
pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 — pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 —
서버는 배달부지 심판이 아니다. 서버는 배달부지 심판이 아니다.

View File

@ -2,7 +2,7 @@ from uuid import UUID
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from common.models.gmodel import UserInfo from common.models.gmodel import PageParams, UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
from services.site_service import SiteService from services.site_service import SiteService
from .protocol import ( from .protocol import (
@ -11,6 +11,7 @@ from .protocol import (
Req_SiteTemplate, Req_SiteTemplate,
Req_SiteTheme, Req_SiteTheme,
Req_StartBuild, Req_StartBuild,
Res_MySites,
Res_PublishLogs, Res_PublishLogs,
Res_Site, Res_Site,
Res_SeoAudit, Res_SeoAudit,
@ -23,6 +24,25 @@ from .protocol import (
# 사이트/발행 라우터. 사업장 하위 리소스이며 회사 스코프는 service 가 사업장 조회로 강제한다. # 사이트/발행 라우터. 사업장 하위 리소스이며 회사 스코프는 service 가 사업장 조회로 강제한다.
router = APIRouter(prefix="/v1/place/{place_id}/site", tags=["Site"], responses={404: {"description": "Not found"}}) router = APIRouter(prefix="/v1/place/{place_id}/site", tags=["Site"], responses={404: {"description": "Not found"}})
# ★ 내 사이트 목록은 사업장 하위가 아니라 계정 하위다 — 위 라우터는 접두어에 place_id 가 박혀 있어
# "내 것 전부"가 들어갈 자리가 없다. 라우터 객체를 하나 더 둔다(router.py 에서 같이 등록).
my_router = APIRouter(prefix="/v1/site", tags=["Site"], responses={404: {"description": "Not found"}})
@my_router.get(
path="/list",
response_model=Res_MySites,
summary="내 사이트 목록",
description="로그인한 계정(회사)이 가진 사이트 전부. 아직 사이트가 만들어지지 않은 사업장도 "
"site_id=null 로 함께 내려간다 — 위저드를 걸어오다 만 것을 목록에서 잃지 않게 한다.",
)
async def list_my_sites(
service: SiteService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
pg: PageParams = Depends(),
):
return RemoveNoneResponse(await service.list_my_sites(user_info, pg))
@router.get( @router.get(
path="", path="",
@ -105,8 +125,8 @@ async def set_template(
summary="디자인(색·서체·섹션) 저장", summary="디자인(색·서체·섹션) 저장",
description="에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다" description="에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다"
"(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: " "(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: "
"{\"theme\":{\"colors\":{...},\"fontStyle\":\"...\",\"colorPaletteId\":\"...\"," "{\"theme\":{\"colors\":{...},\"fontStyle\":\"...\",\"look\":{...},\"colorPaletteId\":\"...\","
"\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\",\"body\"}]}}. " "\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\",\"body\",\"data\"}]}}. "
"★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). " "★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). "
"★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. " "★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. "
"직렬화 크기(64KB)만 막는다. " "직렬화 크기(64KB)만 막는다. "

View File

@ -0,0 +1,107 @@
"""썸네일이 없는 발행 사이트에 썸네일을 채운다.
python scripts/backfill_thumbnails.py (backend/ 에서 실행)
python scripts/backfill_thumbnails.py --dry-run (올리지 않고 대상만 본다)
python scripts/backfill_thumbnails.py --all (이미 있는 것도 다시 만든다)
★ 왜 필요한가 — 썸네일은 **발행 잡이 끝날 때** 만들어진다(build_service). 그래서 이 기능이
들어오기 전에 발행된 사이트는 thumbnail_url 이 영영 NULL 이고, 쇼케이스에서 글자 카드로만
나온다. 재발행을 시키면 채워지지만 사장님 사이트를 우리 사정으로 다시 굽는 건 다른 일이다
— 썸네일만 따로 만든다.
★ 발행본 HTML 을 건드리지 않는다. 읽는 건 site_versions.snapshot 의 사진 목록뿐이고,
쓰는 건 Blob 의 thumbs/ 와 sites.thumbnail_url 한 칸이다.
"""
import argparse, asyncio, os, sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("APP_ENV", "local")
from sqlalchemy import select # noqa: E402
from common.database.db_session_manager import DB_SESSION_MNG # noqa: E402
from common.database.model.models import places, site_versions, sites # noqa: E402
from common.enums import DBWRType, ErrorType, SiteStatus # noqa: E402
from crud.site_crud import SiteCRUD # noqa: E402
from services import site_payload, site_thumbnail # noqa: E402
_crud = SiteCRUD()
def _targets(only_missing: bool):
"""발행된 사이트 + 그 사이트의 현재 버전 스냅샷. 슬러그 계산에 places 가 필요하다."""
stmt = (
select(sites, places, site_versions)
.join(places, places.place_id == sites.place_id)
.join(site_versions, site_versions.site_version_id == sites.current_version_id)
.where(
sites.deleted == False, # noqa: E712
sites.status == SiteStatus.PUBLISHED.value,
)
)
if only_missing:
stmt = stmt.where(sites.thumbnail_url.is_(None))
async def run_query(session):
return await DB_SESSION_MNG.execute(session, stmt)
return run_query
async def run(dry_run: bool, only_missing: bool) -> None:
# --dry-run 은 목록만 본다 — Azure 설정 없이도 대상이 맞는지 확인할 수 있어야 한다.
if not dry_run and not site_thumbnail.is_configured():
raise SystemExit(
"AZURE_STORAGE_CONNECTION_STRING 이 없습니다 — 업로드할 곳이 없어 아무것도 하지 않습니다."
)
err, rows = await DB_SESSION_MNG.execute_lambda(
sites.DBType(), DBWRType.DB_READ.value, _targets(only_missing)
)
if err != ErrorType.SUCCESS:
raise SystemExit(f"대상 조회 실패: {err}")
print(f"[thumb] 대상 {len(rows)}건 ({'없는 것만' if only_missing else '전부'})")
made = skipped = failed = 0
for site, place, version in rows:
slug = site_payload.publish_slug(place, site)
snapshot = version.snapshot or {}
if dry_run:
has_photo = bool(site_payload.primary_media(snapshot))
print(f" - {slug:<24} {place.name} {'' if has_photo else '(대표 사진 없음 — 건너뜀)'}")
continue
try:
url = await site_thumbnail.store(slug, snapshot)
except Exception as ex: # noqa: BLE001 — 한 건 실패가 나머지를 막지 않는다
print(f" ✗ {slug}: {type(ex).__name__}: {ex}")
failed += 1
continue
if not url:
# 대표 사진이 없거나 받지 못했다. 정상적인 경우다 — 사진 없는 가게가 있다.
skipped += 1
continue
await DB_SESSION_MNG.execute_lambda_claim(
sites.DBType(), lambda s, sid=site.site_id, u=url: _crud.update_site(s, sid, {"thumbnail_url": u})
)
print(f" ✓ {slug} → {url}")
made += 1
if not dry_run:
print(f"[thumb] 완료 — 만듦 {made} · 사진 없음 {skipped} · 실패 {failed}")
await DB_SESSION_MNG.dispose_all()
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--dry-run", action="store_true", help="업로드 없이 대상만 출력")
parser.add_argument("--all", action="store_true", help="이미 썸네일이 있는 사이트도 다시 만든다")
args = parser.parse_args()
asyncio.run(run(args.dry_run, only_missing=not args.all))
if __name__ == "__main__":
main()

View File

@ -295,7 +295,7 @@ def _theme(site, theme_spec: dict) -> dict:
# ★ colorPaletteId 는 여기 싣지 않는다. 에디터 복원 전용 값이고 발행 계약(SiteTheme)에 없다 — # ★ colorPaletteId 는 여기 싣지 않는다. 에디터 복원 전용 값이고 발행 계약(SiteTheme)에 없다 —
# 계약에 없는 필드를 payload 에 흘리면 렌더러가 모르는 것이 발행본에 섞인다. # 계약에 없는 필드를 payload 에 흘리면 렌더러가 모르는 것이 발행본에 섞인다.
return { out = {
# 저장된 템플릿이 있으면 그것으로 굽는다(sites.template_id). 비어 있으면 업종 기본이다. # 저장된 템플릿이 있으면 그것으로 굽는다(sites.template_id). 비어 있으면 업종 기본이다.
# 여기서 안 읽으면 사장님이 고른 디자인과 실제 발행본이 갈린다(그게 이 컬럼이 생긴 이유다). # 여기서 안 읽으면 사장님이 고른 디자인과 실제 발행본이 갈린다(그게 이 컬럼이 생긴 이유다).
"templateId": _text(_get(site, "template_id")) or theme_spec["templateId"], "templateId": _text(_get(site, "template_id")) or theme_spec["templateId"],
@ -303,6 +303,13 @@ def _theme(site, theme_spec: dict) -> dict:
"fontStyle": font_style, "fontStyle": font_style,
"sections": _sections(saved.get("sections"), theme_spec["sections"]), "sections": _sections(saved.get("sections"), theme_spec["sections"]),
} }
# ★ 템플릿의 생김새(서체·모서리·테두리·그림자·여백). 색과 달리 업종 기본이 없다 —
# 프론트가 소유하는 값이라 서버가 지어낼 수 없고, 없으면 렌더러가 자기 기본 서체로 떨어진다.
# 이걸 안 실으면 발행본은 색만 템플릿을 따르고 서체는 늘 같은 것으로 나간다.
look = saved.get("look")
if isinstance(look, dict) and look:
out["look"] = {k: v for k, v in look.items() if isinstance(v, str) and v.strip()}
return out
def _sections(saved_sections, default_spec) -> list: def _sections(saved_sections, default_spec) -> list:
@ -372,6 +379,12 @@ def _sections(saved_sections, default_spec) -> list:
body = _text(item.get("body")) body = _text(item.get("body"))
if body: if body:
entry["body"] = body entry["body"] = body
# ★ 붙여넣기 아이템(가요·일력·승차권·인물…)의 원문 JSON. body 와 같은 이유로 그대로 싣는다.
# 서버는 파싱하지 않는다 — 모양을 검사하면 프론트가 필드를 하나 늘린 날 조용히 떨어뜨린다.
# 깨진 JSON 은 렌더러가 그 섹션만 비우고 넘어간다(shared/lib/section-data.ts).
data = _text(item.get("data"))
if data:
entry["data"] = data
out.append(entry) out.append(entry)
for sid, label, locked in default_spec: for sid, label, locked in default_spec:

View File

@ -16,12 +16,13 @@ from common.enums import (
SiteStatus, SiteStatus,
) )
from common.logger import LOG from common.logger import LOG
from common.models.gmodel import UserInfo from common.models.gmodel import PageParams, UserInfo
from common.utils.gtime import GTime from common.utils.gtime import GTime
from crud.job_crud import JobQueue from crud.job_crud import JobQueue
from crud.place_crud import PlaceCRUD from crud.place_crud import PlaceCRUD
from crud.site_crud import ISiteCRUD, SiteCRUD from crud.site_crud import ISiteCRUD, SiteCRUD
from router.v1.site.protocol import ( from router.v1.site.protocol import (
MySiteData,
PublishLogData, PublishLogData,
RenderStatusData, RenderStatusData,
Req_SiteSlug, Req_SiteSlug,
@ -29,6 +30,7 @@ from router.v1.site.protocol import (
Req_SiteTemplate, Req_SiteTemplate,
Req_SiteTheme, Req_SiteTheme,
Req_StartBuild, Req_StartBuild,
Res_MySites,
Res_PublishLogs, Res_PublishLogs,
Res_Site, Res_Site,
Res_SeoAudit, Res_SeoAudit,
@ -417,6 +419,44 @@ class SiteService:
return _THEME_REJECTED return _THEME_REJECTED
return cleaned return cleaned
async def list_my_sites(self, user_info: UserInfo, pg: PageParams) -> Res_MySites:
"""로그인한 계정(회사)이 가진 사이트 전부.
사업장 목록(/v1/place/list)과 따로 두는 이유: 화면이 알아야 하는 건 '사업장이 있다'가 아니라
'발행돼 있나 · 주소가 뭔가 · 다시 구워야 하나'다."""
res = Res_MySites(page=pg.page, size=pg.size)
cid = uuid.UUID(user_info.company_id)
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.list_company_sites(s, cid, pg.skip, pg.size),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.sites = [self._my_site_row(place, site, built_at) for place, site, built_at in rows]
res.total = total
return res
@staticmethod
def _my_site_row(place, site, built_at) -> MySiteData:
# ★ 재빌드 판별은 단건(get_site)과 같은 규칙이어야 한다 — 다르면 목록과 에디터가 다른 답을 한다.
changed = place.content_updated_at
return MySiteData(
place_id=place.place_id,
name=place.name,
category=place.category,
place_status=place.status,
road_address=place.road_address or place.address,
created_at=place.created_at,
site_id=getattr(site, "site_id", None),
status=getattr(site, "status", None),
domain=getattr(site, "domain", None),
template_id=getattr(site, "template_id", None),
published_at=getattr(site, "published_at", None),
needs_rebuild=bool(site is not None and changed and (built_at is None or changed > built_at)),
)
async def get_site(self, user_info: UserInfo, place_id: str) -> Res_Site: async def get_site(self, user_info: UserInfo, place_id: str) -> Res_Site:
res = Res_Site() res = Res_Site()
err_type, place = await self._load_place(user_info, place_id) err_type, place = await self._load_place(user_info, place_id)

View File

@ -0,0 +1,90 @@
"""내 사이트 목록 — 로그인한 사장님이 자기 사이트 전부를 보는 화면의 뒷단.
이 경로가 절대 하면 안 되는 것:
- 사이트가 아직 없는 사업장을 빼는 것 — 위저드를 걸어오다 만 가게가 목록에서 사라지면
사장님은 그걸 다시 찾을 길이 없다(에디터 주소를 아무도 기억하지 않는다).
- 회사 스코프를 놓치는 것 — 남의 가게가 내 목록에 섞이면 그건 목록이 아니라 사고다.
- 단건(GET /v1/place/{id}/site)과 다른 재빌드 판정을 내는 것 — 목록과 에디터가 서로 다른
답을 하면 사장님은 어느 쪽을 믿을지 알 수 없다.
"""
import uuid
from sqlalchemy import text
from common.enums import ErrorType, SiteStatus
async def _place(client, headers, name):
r = await client.post("/v1/place", headers=headers, json={"name": name, "category": 1})
return r.json()["place"]["place_id"]
async def _list(client, headers, **params):
return (await client.get("/v1/site/list", headers=headers, params=params)).json()
async def test_place_without_site_is_still_listed(auth_headers, client):
"""검증: 사이트 행이 없는 사업장(위저드만 걸어온 것)도 목록에 나온다.
기대결과: 줄은 있고 site_id 는 없다 — 화면이 '만드는 중'으로 그릴 근거다."""
h = await auth_headers("my1")
await _place(client, h, "아직펜션")
body = await _list(client, h)
assert body["result"]["code"] == ErrorType.SUCCESS.value
assert body["total"] == 1
row = body["sites"][0]
assert row["name"] == "아직펜션"
assert row.get("site_id") is None
assert row.get("status") is None
async def test_site_row_is_joined_into_the_line(auth_headers, client):
"""검증: 사업장과 사이트가 한 줄로 합쳐져 온다(줄마다 사이트를 다시 묻지 않는다).
기대결과: 템플릿·주소가 목록에 그대로 보인다."""
h = await auth_headers("my2")
pid = await _place(client, h, "합쳐진펜션")
await client.post(f"/v1/place/{pid}/site/template", headers=h, json={"template_id": "stay-quiet-margin"})
await client.post(f"/v1/place/{pid}/site/slug", headers=h, json={"slug": "joined-stay"})
row = (await _list(client, h))["sites"][0]
assert row["site_id"]
assert row["template_id"] == "stay-quiet-margin"
assert row["domain"] == "joined-stay"
assert row["status"] == SiteStatus.DRAFT.value
async def test_other_company_sites_are_not_listed(auth_headers, client, other_company_id):
"""검증: 회사(테넌트) 스코프. 남의 회사 사업장은 보이지 않는다.
기대결과: 각자 자기 것만 1건."""
mine = await auth_headers("my3")
theirs = await auth_headers("my3b", other_company_id)
await _place(client, mine, "내펜션")
await _place(client, theirs, "남의펜션")
assert [r["name"] for r in (await _list(client, mine))["sites"]] == ["내펜션"]
assert [r["name"] for r in (await _list(client, theirs))["sites"]] == ["남의펜션"]
async def test_needs_rebuild_matches_the_single_site_answer(auth_headers, client, db_engine):
"""검증: 재빌드 판정이 단건 조회와 같은 답을 낸다.
기대결과: 노출값이 바뀐 사업장은 목록에서도 needs_rebuild=true."""
h = await auth_headers("my4")
pid = await _place(client, h, "고친펜션")
# 템플릿 저장이 사이트 행을 만든다. 그 뒤 노출값이 바뀐 것으로 표시한다.
await client.post(f"/v1/place/{pid}/site/template", headers=h, json={"template_id": "t"})
async with db_engine.begin() as conn:
await conn.execute(
text("UPDATE places SET content_updated_at = now() WHERE place_id = :pid"),
{"pid": uuid.UUID(pid)},
)
single = (await client.get(f"/v1/place/{pid}/site", headers=h)).json()
row = (await _list(client, h))["sites"][0]
assert row["needs_rebuild"] is True
assert row["needs_rebuild"] == single["needs_rebuild"]
async def test_list_requires_login(client):
"""검증: 내 것을 보는 화면이므로 토큰 없이는 열리지 않는다.
기대결과: 401."""
assert (await client.get("/v1/site/list")).status_code == 401

View File

@ -54,7 +54,9 @@ export * from './listFactsParams';
export * from './listFaqsParams'; export * from './listFaqsParams';
export * from './listLinksParams'; export * from './listLinksParams';
export * from './listMediaParams'; export * from './listMediaParams';
export * from './listMySitesParams';
export * from './listPlacesParams'; export * from './listPlacesParams';
export * from './listShowcaseParams';
export * from './localContentData'; export * from './localContentData';
export * from './localContentDataBody'; export * from './localContentDataBody';
export * from './localContentDataCollectedAt'; export * from './localContentDataCollectedAt';
@ -77,6 +79,14 @@ export * from './mediaDataUnitId';
export * from './mediaDataVisionConfidence'; export * from './mediaDataVisionConfidence';
export * from './mediaDataWidth'; export * from './mediaDataWidth';
export * from './mediaStatus'; export * from './mediaStatus';
export * from './mySiteData';
export * from './mySiteDataCreatedAt';
export * from './mySiteDataDomain';
export * from './mySiteDataPublishedAt';
export * from './mySiteDataRoadAddress';
export * from './mySiteDataSiteId';
export * from './mySiteDataStatus';
export * from './mySiteDataTemplateId';
export * from './placeCandidate'; export * from './placeCandidate';
export * from './placeCandidateAddress'; export * from './placeCandidateAddress';
export * from './placeCandidateCategoryName'; export * from './placeCandidateCategoryName';
@ -101,6 +111,10 @@ export * from './placeDataPhone';
export * from './placeDataRegionCode'; export * from './placeDataRegionCode';
export * from './placeDataRoadAddress'; export * from './placeDataRoadAddress';
export * from './placeDataVerifiedAt'; export * from './placeDataVerifiedAt';
export * from './placeSearchItem';
export * from './placeSearchItemCategory';
export * from './placeSearchItemCategoryName';
export * from './placeSearchItemRoadAddress';
export * from './placeStatus'; export * from './placeStatus';
export * from './publishAction'; export * from './publishAction';
export * from './publishLogData'; export * from './publishLogData';
@ -215,11 +229,16 @@ export * from './resMeMsg';
export * from './resMeName'; export * from './resMeName';
export * from './resMediaList'; export * from './resMediaList';
export * from './resMediaListMsg'; export * from './resMediaListMsg';
export * from './resMySites';
export * from './resMySitesMsg';
export * from './resPlace'; export * from './resPlace';
export * from './resPlaceList'; export * from './resPlaceList';
export * from './resPlaceListMsg'; export * from './resPlaceListMsg';
export * from './resPlaceMsg'; export * from './resPlaceMsg';
export * from './resPlacePlace'; export * from './resPlacePlace';
export * from './resPlaceSearch';
export * from './resPlaceSearchMsg';
export * from './resPlaceSearchSource';
export * from './resPublishLogs'; export * from './resPublishLogs';
export * from './resPublishLogsMsg'; export * from './resPublishLogsMsg';
export * from './resRefreshToken'; export * from './resRefreshToken';
@ -228,6 +247,8 @@ export * from './resSeoAudit';
export * from './resSeoAuditMsg'; export * from './resSeoAuditMsg';
export * from './resSeoAuditSummary'; export * from './resSeoAuditSummary';
export * from './resSeoAuditVisibility'; export * from './resSeoAuditVisibility';
export * from './resShowcase';
export * from './resShowcaseMsg';
export * from './resSite'; export * from './resSite';
export * from './resSiteCurrentVersion'; export * from './resSiteCurrentVersion';
export * from './resSiteMsg'; export * from './resSiteMsg';
@ -273,6 +294,10 @@ export * from './resVerifyCandidatesSource';
export * from './resWeather'; export * from './resWeather';
export * from './resWeatherMsg'; export * from './resWeatherMsg';
export * from './resWeatherWeather'; export * from './resWeatherWeather';
export * from './searchPlacesPublicParams';
export * from './showcaseItem';
export * from './showcaseItemRegion';
export * from './showcaseItemThumbnailUrl';
export * from './siteData'; export * from './siteData';
export * from './siteDataCurrentVersionId'; export * from './siteDataCurrentVersionId';
export * from './siteDataDomain'; export * from './siteDataDomain';
@ -280,6 +305,7 @@ export * from './siteDataPublishedAt';
export * from './siteDataTemplateId'; export * from './siteDataTemplateId';
export * from './siteDataTheme'; export * from './siteDataTheme';
export * from './siteDataThemeAnyOf'; export * from './siteDataThemeAnyOf';
export * from './siteDataThumbnailUrl';
export * from './siteStatus'; export * from './siteStatus';
export * from './siteVersionData'; export * from './siteVersionData';
export * from './siteVersionDataBuildError'; export * from './siteVersionDataBuildError';

View File

@ -0,0 +1,18 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ListMySitesParams = {
/**
* @minimum 1
*/
page?: number;
/**
* @minimum 1
* @maximum 100
*/
size?: number;
};

View File

@ -0,0 +1,15 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ListShowcaseParams = {
/**
* 가져올 개수
* @minimum 1
* @maximum 48
*/
limit?: number;
};

View File

@ -0,0 +1,36 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { PlaceCategory } from './placeCategory';
import type { PlaceStatus } from './placeStatus';
import type { MySiteDataRoadAddress } from './mySiteDataRoadAddress';
import type { MySiteDataCreatedAt } from './mySiteDataCreatedAt';
import type { MySiteDataSiteId } from './mySiteDataSiteId';
import type { MySiteDataStatus } from './mySiteDataStatus';
import type { MySiteDataDomain } from './mySiteDataDomain';
import type { MySiteDataTemplateId } from './mySiteDataTemplateId';
import type { MySiteDataPublishedAt } from './mySiteDataPublishedAt';
/**
* 내 사이트 목록의 한 줄 — 사업장(place) + 사이트(site).
★ render 는 여기 없다 — 보고서 **파일**을 읽는 값이라 줄 수만큼 파일 IO 가 된다(단건이 소유).
★ site_id 아래가 전부 None 이면 아직 사이트가 없는 사업장이다.
*/
export interface MySiteData {
place_id: string;
name: string;
category: PlaceCategory;
place_status: PlaceStatus;
road_address?: MySiteDataRoadAddress;
created_at?: MySiteDataCreatedAt;
site_id?: MySiteDataSiteId;
status?: MySiteDataStatus;
domain?: MySiteDataDomain;
template_id?: MySiteDataTemplateId;
published_at?: MySiteDataPublishedAt;
needs_rebuild?: boolean;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type MySiteDataCreatedAt = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type MySiteDataDomain = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type MySiteDataPublishedAt = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type MySiteDataRoadAddress = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type MySiteDataSiteId = string | null;

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { SiteStatus } from './siteStatus';
export type MySiteDataStatus = SiteStatus | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type MySiteDataTemplateId = string | null;

View File

@ -0,0 +1,24 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { PlaceSearchItemRoadAddress } from './placeSearchItemRoadAddress';
import type { PlaceSearchItemCategoryName } from './placeSearchItemCategoryName';
import type { PlaceSearchItemCategory } from './placeSearchItemCategory';
/**
* 공개 검색 결과 1건.
★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·company_id·소유자)은
하나도 나가지 않는다 — 로그인 없이 열려 있는 응답이라 여기에 우리 것을 실으면 그대로 샌다.
★ 좌표·전화번호도 뺐다. 랜딩이 하는 일은 '어느 가게인지 고르게 하는 것'뿐이고,
확정과 수집은 로그인 뒤 기존 경로(POST /place → verify)가 그대로 한다.
*/
export interface PlaceSearchItem {
name?: string;
road_address?: PlaceSearchItemRoadAddress;
category_name?: PlaceSearchItemCategoryName;
category?: PlaceSearchItemCategory;
}

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { PlaceCategory } from './placeCategory';
export type PlaceSearchItemCategory = PlaceCategory | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type PlaceSearchItemCategoryName = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type PlaceSearchItemRoadAddress = string | null;

View File

@ -16,9 +16,9 @@ import type { ReqSiteThemeTheme } from './reqSiteThemeTheme';
떨어뜨리므로 화면은 깨지지 않는다. 떨어뜨리므로 화면은 깨지지 않는다.
★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다: ★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다:
{"theme": {"colors": {...}, "fontStyle": "...", "colorPaletteId": "...", "sections": [...]}} {"theme": {"colors": {...}, "fontStyle": "...", "look": {...}, "colorPaletteId": "...", "sections": [...]}}
sections 는 {id, name, enabled, locked, variantId?} 의 목록이고 **배열 순서가 곧 섹션 순서**다 sections 는 {id, name, enabled, locked, variantId?, body?, data?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
(별도 order 필드가 없다). variantId 는 고른 게 있을 때만 키가 붙는다. (별도 order 필드가 없다). variantId·본문 body·붙여넣기 JSON data 는 값이 있을 때만 키가 붙는다.
pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 — pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 —
서버는 배달부지 심판이 아니다. 서버는 배달부지 심판이 아니다.

View File

@ -0,0 +1,18 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { ErrorInfo } from './errorInfo';
import type { ResMySitesMsg } from './resMySitesMsg';
import type { MySiteData } from './mySiteData';
export interface ResMySites {
result?: ErrorInfo;
msg?: ResMySitesMsg;
total?: number;
page?: number;
size?: number;
sites?: MySiteData[];
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ResMySitesMsg = string | null;

View File

@ -0,0 +1,23 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { ErrorInfo } from './errorInfo';
import type { ResPlaceSearchMsg } from './resPlaceSearchMsg';
import type { ResPlaceSearchSource } from './resPlaceSearchSource';
import type { PlaceSearchItem } from './placeSearchItem';
/**
* 상호명 공개 검색 결과.
★ 인증이 없다. 랜딩 첫 화면에서 상호명을 치면 바로 부른다 —
만들어 보기도 전에 로그인을 요구하지 않기로 한 결정(로그인 관문은 에디터 진입 하나)의 연장이다.
*/
export interface ResPlaceSearch {
result?: ErrorInfo;
msg?: ResPlaceSearchMsg;
source?: ResPlaceSearchSource;
items?: PlaceSearchItem[];
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ResPlaceSearchMsg = string | null;

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { ExternalPlaceSource } from './externalPlaceSource';
export type ResPlaceSearchSource = ExternalPlaceSource | null;

View File

@ -0,0 +1,15 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { ErrorInfo } from './errorInfo';
import type { ResShowcaseMsg } from './resShowcaseMsg';
import type { ShowcaseItem } from './showcaseItem';
export interface ResShowcase {
result?: ErrorInfo;
msg?: ResShowcaseMsg;
items?: ShowcaseItem[];
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ResShowcaseMsg = string | null;

View File

@ -0,0 +1,15 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type SearchPlacesPublicParams = {
/**
* 상호명
* @minLength 2
* @maxLength 100
*/
q: string;
};

View File

@ -0,0 +1,24 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { PlaceCategory } from './placeCategory';
import type { ShowcaseItemRegion } from './showcaseItemRegion';
import type { ShowcaseItemThumbnailUrl } from './showcaseItemThumbnailUrl';
/**
* 랜딩 쇼케이스 카드 한 장. **로그인 없이 나가는 값이다.**
★ 여기 있는 것은 전부 이미 발행된 페이지에 적혀 있는 것뿐이다.
place_id·company_id·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과
발행 업소 명단을 통째로 긁는 것은 다른 일이다. 지역도 시·군·구까지만 준다.
*/
export interface ShowcaseItem {
name: string;
category: PlaceCategory;
region?: ShowcaseItemRegion;
url: string;
thumbnail_url?: ShowcaseItemThumbnailUrl;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ShowcaseItemRegion = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ShowcaseItemThumbnailUrl = string | null;

View File

@ -10,6 +10,7 @@ import type { SiteDataTemplateId } from './siteDataTemplateId';
import type { SiteDataTheme } from './siteDataTheme'; import type { SiteDataTheme } from './siteDataTheme';
import type { SiteDataCurrentVersionId } from './siteDataCurrentVersionId'; import type { SiteDataCurrentVersionId } from './siteDataCurrentVersionId';
import type { SiteDataPublishedAt } from './siteDataPublishedAt'; import type { SiteDataPublishedAt } from './siteDataPublishedAt';
import type { SiteDataThumbnailUrl } from './siteDataThumbnailUrl';
export interface SiteData { export interface SiteData {
site_id: string; site_id: string;
@ -20,4 +21,5 @@ export interface SiteData {
theme?: SiteDataTheme; theme?: SiteDataTheme;
current_version_id?: SiteDataCurrentVersionId; current_version_id?: SiteDataCurrentVersionId;
published_at?: SiteDataPublishedAt; published_at?: SiteDataPublishedAt;
thumbnail_url?: SiteDataThumbnailUrl;
} }

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type SiteDataThumbnailUrl = string | null;

View File

@ -41,12 +41,14 @@ import type {
ResLinkList, ResLinkList,
ResPlace, ResPlace,
ResPlaceList, ResPlaceList,
ResPlaceSearch,
ResStartCollect, ResStartCollect,
ResStartCopy, ResStartCopy,
ResStartVision, ResStartVision,
ResUnit, ResUnit,
ResUnitList, ResUnitList,
ResVerifyCandidates, ResVerifyCandidates,
SearchPlacesPublicParams,
VerifyCandidatesParams VerifyCandidatesParams
} from '.././model'; } from '.././model';
@ -150,6 +152,100 @@ export function useListPlaces<TData = Awaited<ReturnType<typeof listPlaces>>, TE
/**
* 상호명으로 외부 장소 DB(카카오 → 없으면 네이버)를 찾아 후보를 그대로 돌려준다. ★ 인증이 없다 — 랜딩 첫 화면이 부른다. 사업장을 만들지도, 우리 DB 를 읽지도 않는다. ★ 응답의 category 는 외부 분류에서 **추정한 기본값**이다. None 이면 못 정한 것이고, 값이 있어도 확정이 아니다 — 화면은 언제나 바꿀 수 있게 둔다.
* @summary 상호명 공개 검색
*/
export const searchPlacesPublic = (
params: SearchPlacesPublicParams,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResPlaceSearch>(
{url: `/v1/place/search`, method: 'GET',
params, signal
},
options);
}
export const getSearchPlacesPublicQueryKey = (params?: SearchPlacesPublicParams,) => {
return [
`/v1/place/search`, ...(params ? [params]: [])
] as const;
}
export const getSearchPlacesPublicQueryOptions = <TData = Awaited<ReturnType<typeof searchPlacesPublic>>, TError = void | HTTPValidationError>(params: SearchPlacesPublicParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof searchPlacesPublic>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getSearchPlacesPublicQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof searchPlacesPublic>>> = ({ signal }) => searchPlacesPublic(params, requestOptions, signal);
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof searchPlacesPublic>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type SearchPlacesPublicQueryResult = NonNullable<Awaited<ReturnType<typeof searchPlacesPublic>>>
export type SearchPlacesPublicQueryError = void | HTTPValidationError
export function useSearchPlacesPublic<TData = Awaited<ReturnType<typeof searchPlacesPublic>>, TError = void | HTTPValidationError>(
params: SearchPlacesPublicParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof searchPlacesPublic>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof searchPlacesPublic>>,
TError,
Awaited<ReturnType<typeof searchPlacesPublic>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useSearchPlacesPublic<TData = Awaited<ReturnType<typeof searchPlacesPublic>>, TError = void | HTTPValidationError>(
params: SearchPlacesPublicParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof searchPlacesPublic>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof searchPlacesPublic>>,
TError,
Awaited<ReturnType<typeof searchPlacesPublic>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useSearchPlacesPublic<TData = Awaited<ReturnType<typeof searchPlacesPublic>>, TError = void | HTTPValidationError>(
params: SearchPlacesPublicParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof searchPlacesPublic>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary 상호명 공개 검색
*/
export function useSearchPlacesPublic<TData = Awaited<ReturnType<typeof searchPlacesPublic>>, TError = void | HTTPValidationError>(
params: SearchPlacesPublicParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof searchPlacesPublic>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getSearchPlacesPublicQueryOptions(params,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}
/** /**
* 상호명 하나로 시작한다. 주소·좌표는 동일 업소 검증(verify)이 채운다. * 상호명 하나로 시작한다. 주소·좌표는 동일 업소 검증(verify)이 채운다.
* @summary 사업장 등록 * @summary 사업장 등록

View File

@ -0,0 +1,128 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import {
useQuery
} from '@tanstack/react-query';
import type {
DataTag,
DefinedInitialDataOptions,
DefinedUseQueryResult,
QueryClient,
QueryFunction,
QueryKey,
UndefinedInitialDataOptions,
UseQueryOptions,
UseQueryResult
} from '@tanstack/react-query';
import type {
HTTPValidationError,
ListShowcaseParams,
ResShowcase
} from '.././model';
import { customFetch } from '../../mutator/custom-fetch';
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* 실제로 발행된 사이트를 최신순으로 준다. 상호명·업종·지역(시·군·구까지)·발행 주소·썸네일뿐이다 — 로그인 없이 나가므로 사업장 식별자·전화번호·상세 주소는 싣지 않는다. 썸네일은 그 사이트의 대표 사진이고, 만들지 못한 사이트는 키가 없다.
* @summary 발행 사이트 쇼케이스(공개)
*/
export const listShowcase = (
params?: ListShowcaseParams,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResShowcase>(
{url: `/v1/showcase`, method: 'GET',
params, signal
},
options);
}
export const getListShowcaseQueryKey = (params?: ListShowcaseParams,) => {
return [
`/v1/showcase`, ...(params ? [params]: [])
] as const;
}
export const getListShowcaseQueryOptions = <TData = Awaited<ReturnType<typeof listShowcase>>, TError = void | HTTPValidationError>(params?: ListShowcaseParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listShowcase>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListShowcaseQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listShowcase>>> = ({ signal }) => listShowcase(params, requestOptions, signal);
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listShowcase>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type ListShowcaseQueryResult = NonNullable<Awaited<ReturnType<typeof listShowcase>>>
export type ListShowcaseQueryError = void | HTTPValidationError
export function useListShowcase<TData = Awaited<ReturnType<typeof listShowcase>>, TError = void | HTTPValidationError>(
params: undefined | ListShowcaseParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listShowcase>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof listShowcase>>,
TError,
Awaited<ReturnType<typeof listShowcase>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useListShowcase<TData = Awaited<ReturnType<typeof listShowcase>>, TError = void | HTTPValidationError>(
params?: ListShowcaseParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listShowcase>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof listShowcase>>,
TError,
Awaited<ReturnType<typeof listShowcase>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useListShowcase<TData = Awaited<ReturnType<typeof listShowcase>>, TError = void | HTTPValidationError>(
params?: ListShowcaseParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listShowcase>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary 발행 사이트 쇼케이스(공개)
*/
export function useListShowcase<TData = Awaited<ReturnType<typeof listShowcase>>, TError = void | HTTPValidationError>(
params?: ListShowcaseParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listShowcase>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getListShowcaseQueryOptions(params,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}

View File

@ -26,11 +26,13 @@ import type {
import type { import type {
CheckSlugParams, CheckSlugParams,
HTTPValidationError, HTTPValidationError,
ListMySitesParams,
ReqSiteSlug, ReqSiteSlug,
ReqSiteStatus, ReqSiteStatus,
ReqSiteTemplate, ReqSiteTemplate,
ReqSiteTheme, ReqSiteTheme,
ReqStartBuild, ReqStartBuild,
ResMySites,
ResPublishLogs, ResPublishLogs,
ResSeoAudit, ResSeoAudit,
ResSite, ResSite,
@ -467,7 +469,7 @@ export const useSetTemplate = <TError = void | HTTPValidationError,
return useMutation(mutationOptions, queryClient); return useMutation(mutationOptions, queryClient);
} }
/** /**
* 에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: {"theme":{"colors":{...},"fontStyle":"...","colorPaletteId":"...","sections":[{"id","name","enabled","locked","variantId"}]}}. ★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). ★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. 직렬화 크기(64KB)만 막는다. ★ templateId 는 여기 담지 않는다 — sites.template_id 와 POST /template 이 담당한다. ★ colorPaletteId 는 에디터 복원 전용이라 저장·반환만 하고 발행 payload 에는 싣지 않는다. ★ 빈 값({})을 보내면 NULL 로 되돌아가 업종 기본 색·서체·섹션으로 떨어진다. ★ 템플릿과 같이 발행 뒤에도 바꿀 수 있다(디자인이 바뀌어도 URL 은 그대로다). 이미 발행된 사이트면 재빌드가 필요하다는 표시로 content_updated_at 을 찍는다(needs_rebuild=true). * 에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: {"theme":{"colors":{...},"fontStyle":"...","look":{...},"colorPaletteId":"...","sections":[{"id","name","enabled","locked","variantId","body","data"}]}}. ★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). ★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. 직렬화 크기(64KB)만 막는다. ★ templateId 는 여기 담지 않는다 — sites.template_id 와 POST /template 이 담당한다. ★ colorPaletteId 는 에디터 복원 전용이라 저장·반환만 하고 발행 payload 에는 싣지 않는다. ★ 빈 값({})을 보내면 NULL 로 되돌아가 업종 기본 색·서체·섹션으로 떨어진다. ★ 템플릿과 같이 발행 뒤에도 바꿀 수 있다(디자인이 바뀌어도 URL 은 그대로다). 이미 발행된 사이트면 재빌드가 필요하다는 표시로 content_updated_at 을 찍는다(needs_rebuild=true).
* @summary 디자인(색·서체·섹션) 저장 * @summary 디자인(색·서체·섹션) 저장
*/ */
export const setTheme = ( export const setTheme = (
@ -849,4 +851,97 @@ export const useChangeStatus = <TError = void | HTTPValidationError,
return useMutation(mutationOptions, queryClient); return useMutation(mutationOptions, queryClient);
} }
/**
* 로그인한 계정(회사)이 가진 사이트 전부. 아직 사이트가 만들어지지 않은 사업장도 site_id=null 로 함께 내려간다 — 위저드를 걸어오다 만 것을 목록에서 잃지 않게 한다.
* @summary 내 사이트 목록
*/
export const listMySites = (
params?: ListMySitesParams,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResMySites>(
{url: `/v1/site/list`, method: 'GET',
params, signal
},
options);
}
export const getListMySitesQueryKey = (params?: ListMySitesParams,) => {
return [
`/v1/site/list`, ...(params ? [params]: [])
] as const;
}
export const getListMySitesQueryOptions = <TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>(params?: ListMySitesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListMySitesQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listMySites>>> = ({ signal }) => listMySites(params, requestOptions, signal);
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type ListMySitesQueryResult = NonNullable<Awaited<ReturnType<typeof listMySites>>>
export type ListMySitesQueryError = void | HTTPValidationError
export function useListMySites<TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>(
params: undefined | ListMySitesParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof listMySites>>,
TError,
Awaited<ReturnType<typeof listMySites>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useListMySites<TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>(
params?: ListMySitesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof listMySites>>,
TError,
Awaited<ReturnType<typeof listMySites>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useListMySites<TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>(
params?: ListMySitesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary 내 사이트 목록
*/
export function useListMySites<TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>(
params?: ListMySitesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getListMySitesQueryOptions(params,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}

View File

@ -1,17 +1,70 @@
import {createBrowserRouter, Navigate} from 'react-router'; import {createBrowserRouter, Navigate} from 'react-router';
import {Loader2} from 'lucide-react';
import {AccountPage} from '@/pages/AccountPage';
import {BuilderPage} from '@/pages/BuilderPage'; import {BuilderPage} from '@/pages/BuilderPage';
import {DevShowcasePage} from '@/pages/DevShowcasePage'; import {DevShowcasePage} from '@/pages/DevShowcasePage';
import {LandingPage} from '@/pages/LandingPage';
import {LoginPage} from '@/pages/LoginPage'; import {LoginPage} from '@/pages/LoginPage';
import {NotFoundPage} from '@/pages/NotFoundPage'; import {NotFoundPage} from '@/pages/NotFoundPage';
import {PricingPage} from '@/pages/PricingPage';
import {ShowcasePage} from '@/pages/ShowcasePage';
import {SignupPage} from '@/pages/SignupPage';
import {SitesPage} from '@/pages/SitesPage';
import {RequireAuth} from '@/components/layout/RequireAuth';
import {useAuthStore} from '@/stores/auth';
/**
* 첫 화면은 로그인 여부로 갈린다 — 이미 사이트를 가진 사장님의 용건은 "새로 만들기"가 아니라
* "내 것 고치기"다. 비로그인은 **랜딩**을 본다.
*
* ★ 예전엔 비로그인을 곧장 위저드로 보냈다. 그러면 이 제품이 무엇을 파는 물건인지 말할 자리가
* 한 곳도 없다 — 처음 온 사람이 업종 선택 화면부터 만난다.
* ★ 복구가 끝나기 전에 판단하면 새로고침할 때마다 화면이 한 번 번쩍이고 목록으로 튄다.
*/
function Home() {
const isRestoring = useAuthStore((s) => s.isRestoring);
const user = useAuthStore((s) => s.user);
if (isRestoring) {
return (
<div className="flex h-screen items-center justify-center text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
);
}
if (user) return <Navigate to="/sites" replace />;
return <LandingPage />;
}
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{path: '/login', element: <LoginPage />}, {path: '/login', element: <LoginPage />},
// 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다.
{path: '/signup', element: <SignupPage />},
// ★ 첫 화면은 업종 선택(위저드 1단계)이다. // 비로그인 = 랜딩, 로그인 = 내 사이트. Home 이 그걸 가른다.
// `?new=1` 을 붙이는 이유: 위저드 상태는 새로고침을 넘기려고 저장돼 있어서(stores/builder persist), {path: '/', element: <Home />},
// 그냥 /builder 로 보내면 지난번에 만들다 만 **에디터**가 복원돼 뜬다. 처음 들어오는 사람에게는
// 그게 "왜 자꾸 빌더로 튀냐"로 보인다. 그래서 진입 경로에서 한 번 비우고 시작한다. // 로그인 전 화면. ★ 랜딩과 같은 껍데기(MarketingShell)를 쓴다 — 사이드바 없는 문서형이다.
{path: '/', element: <Navigate to="/builder?new=1" replace />}, {path: '/pricing', element: <PricingPage />},
{path: '/showcase', element: <ShowcasePage />},
// 로그인한 사장님의 홈. 만든 사이트를 열고 고치는 자리다.
{
path: '/sites',
element: (
<RequireAuth>
<SitesPage />
</RequireAuth>
),
},
{
path: '/account',
element: (
<RequireAuth>
<AccountPage />
</RequireAuth>
),
},
/** /**
* 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면 * 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면

View File

@ -11,7 +11,7 @@ import {GOOGLE_CLIENT_ID, isGoogleLoginEnabled, loadGoogleIdentity} from '@/lib/
*/ */
export function GoogleSignInButton({ export function GoogleSignInButton({
onCredential, onCredential,
text = 'continue_with', text = 'signin_with',
}: { }: {
onCredential: (credential: string) => void; onCredential: (credential: string) => void;
text?: 'continue_with' | 'signin_with' | 'signup_with'; text?: 'continue_with' | 'signin_with' | 'signup_with';
@ -46,7 +46,9 @@ export function GoogleSignInButton({
size: 'large', size: 'large',
shape: 'rectangular', shape: 'rectangular',
text, text,
locale: 'ko', // ★ 'ko' 로는 영문('Continue with Google')이 그대로 나왔다. 지역까지 줘야 한국어다.
// 문구는 우리가 못 정한다 — 구글 브랜드 가이드라 GIS 가 주는 번역을 그대로 쓴다.
locale: 'ko_KR',
logo_alignment: 'center', logo_alignment: 'center',
// GIS 는 숫자 px 만 받는다(최대 400). 컨테이너 폭이 잡히기 전이면 최소값으로 그린다. // GIS 는 숫자 px 만 받는다(최대 400). 컨테이너 폭이 잡히기 전이면 최소값으로 그린다.
width: holder.current.offsetWidth || 320, width: holder.current.offsetWidth || 320,

View File

@ -1,8 +1,8 @@
import type {ComponentType, ReactNode} from 'react'; import type {ComponentType, ReactNode} from 'react';
import {Link, NavLink, useLocation} from 'react-router'; import {Link, NavLink, useLocation, useNavigate} from 'react-router';
import {LayoutGrid, LogOut, Search, Wand2} from 'lucide-react'; import {LayoutGrid, LogIn, LogOut, Search, Store, Wand2} from 'lucide-react';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import {useAuthStore} from '@/stores/auth'; import {userLabel, useAuthStore} from '@/stores/auth';
export type NavItem = { export type NavItem = {
to: string; to: string;
@ -23,6 +23,7 @@ export type NavItem = {
* 남의 화면이다. * 남의 화면이다.
*/ */
const OWNER_NAV: NavItem[] = [ const OWNER_NAV: NavItem[] = [
{to: '/sites', match: '/sites', label: '내 사이트', icon: Store},
{to: '/builder?new=1', match: '/builder', label: '새 사이트', icon: Wand2}, {to: '/builder?new=1', match: '/builder', label: '새 사이트', icon: Wand2},
]; ];
@ -30,11 +31,14 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const signOut = useAuthStore((s) => s.signOut); const signOut = useAuthStore((s) => s.signOut);
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
return ( return (
<div className="flex h-screen w-screen overflow-hidden bg-background text-foreground"> <div className="flex h-screen w-screen overflow-hidden bg-background text-foreground">
<aside className="hidden w-56 shrink-0 flex-col border-r border-sidebar-border bg-sidebar md:flex"> <aside className="hidden w-56 shrink-0 flex-col border-r border-sidebar-border bg-sidebar md:flex">
<Link to={nav[0]?.to ?? '/'} className="flex items-center gap-2 px-4 py-4"> {/* ★ 로고는 언제나 홈(/)이다. 메뉴 첫 항목으로 보내면 앱마다 목적지가 달라지고,
로고를 눌러 첫 화면으로 가려던 사람이 엉뚱한 목록에 떨어진다. */}
<Link to="/" className="flex items-center gap-2 px-4 py-4">
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-7 w-auto" /> <img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-7 w-auto" />
</Link> </Link>
@ -58,19 +62,47 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?
))} ))}
</nav> </nav>
{/* ★ 위저드는 로그인 없이도 열린다(관문은 에디터 진입이다) — 그래서 이 자리는
**비로그인 상태를 반드시 그려야 한다.** 예전엔 이름이 빈 줄로 나오고 [로그아웃]만
남아서, 로그인한 적 없는 사람이 눌러도 아무 일이 안 일어났다(지울 세션이 없다). */}
<div className="border-t border-sidebar-border p-3"> <div className="border-t border-sidebar-border p-3">
<div className="mb-2 truncate text-[11px] text-sidebar-foreground"> {/* 이름 자리가 곧 [내 정보] 입구다 — 메뉴를 한 줄 더 늘리지 않는다(아임웹의 프로필과 같은 자리). */}
{user?.name ?? user?.id} {user ? (
{user?.companyName ? ` · ${user.companyName}` : ''} <Link
</div> to="/account"
<button className="mb-2 block truncate rounded-md px-2 py-1 text-[11px] text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60"
type="button" >
onClick={signOut} {userLabel(user)}
className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60" {user.companyName ? ` · ${user.companyName}` : ''}
> </Link>
<LogOut className="size-3.5" /> ) : (
<span>로그아웃</span> <div className="mb-2 truncate px-2 py-1 text-[11px] text-sidebar-foreground">
</button> <span className="opacity-60">로그인하지 않았습니다</span>
</div>
)}
{user ? (
<button
type="button"
// 로그아웃은 **눈에 보이는 결과**가 있어야 한다. 스토어만 비우면 화면은 그대로라
// 눌러도 아무 일이 없는 것처럼 보인다 — 로그인 화면으로 보낸다.
onClick={() => {
signOut();
navigate('/login');
}}
className="flex w-full cursor-pointer items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60"
>
<LogOut className="size-3.5" />
<span>로그아웃</span>
</button>
) : (
<Link
to="/login"
className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60"
>
<LogIn className="size-3.5" />
<span>로그인</span>
</Link>
)}
</div> </div>
</aside> </aside>

View File

@ -0,0 +1,121 @@
import type {ReactNode} from 'react';
import {Link, NavLink} from 'react-router';
import {cn} from '@/lib/utils';
import {useAuthStore} from '@/stores/auth';
/**
* 로그인 전 화면(랜딩·요금·쇼케이스)의 껍데기.
*
* ★ AppShell 과 나눠 둔 이유: 저쪽은 **사이드바가 있는 작업 화면**이다. 아직 아무것도 만들지
* 않은 사람에게 사이드바를 보여 주면 비어 있는 메뉴만 남는다(b07ade2 가 온보딩에서 사이드바를
* 뺀 것과 같은 판단).
*
* ★ 메뉴는 셋을 넘기지 않는다. 소상공인 대상 화면에서 드롭다운은 "못 찾는 메뉴"가 된다.
*/
const NAV = [
{to: '/showcase', label: '이렇게 나옵니다'},
{to: '/pricing', label: '요금'},
];
export function MarketingShell({children}: {children: ReactNode}) {
const user = useAuthStore((s) => s.user);
return (
<div className="min-h-screen bg-background text-foreground">
<header className="sticky top-0 z-20 border-b border-border bg-background/85 backdrop-blur">
<div className="mx-auto flex h-16 w-full max-w-6xl items-center gap-8 px-5">
<Link to="/" className="flex items-center">
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-8 w-auto" />
</Link>
<nav className="hidden items-center gap-6 sm:flex">
{NAV.map(({to, label}) => (
<NavLink
key={to}
to={to}
className={({isActive}) =>
cn(
'text-sm font-medium transition-colors',
isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
)
}
>
{label}
</NavLink>
))}
</nav>
<div className="ml-auto flex items-center gap-3">
{/* ★ 이미 사이트를 가진 사장님에게 [로그인] 을 다시 보여주지 않는다 — 갈 곳은 내 사이트다. */}
{user ? (
<Link
to="/sites"
className="rounded-md bg-primary px-4 py-2 text-[13px] font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
내 사이트
</Link>
) : (
<>
{/* 아임웹처럼 둘 다 버튼이다 — 로그인만 맨 텍스트면 눌리는 것으로 안 보인다. */}
<Link
to="/login"
className="rounded-md border border-border px-4 py-2 text-[13px] font-medium transition-colors hover:bg-muted"
>
로그인
</Link>
<Link
to="/builder?new=1"
className="rounded-md bg-primary px-4 py-2 text-[13px] font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
무료로 만들기
</Link>
</>
)}
</div>
</div>
</header>
<main>{children}</main>
<footer className="mt-24 border-t border-border">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-3 px-5 py-10 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-6 w-auto opacity-60" />
<div className="flex gap-5">
{NAV.map(({to, label}) => (
<Link key={to} to={to} className="transition-colors hover:text-foreground">
{label}
</Link>
))}
</div>
</div>
</footer>
</div>
);
}
/** 랜딩의 한 켜. 섹션마다 여백을 손으로 적지 않게 한 곳에 모은다. */
export function Section({
children,
className,
muted = false,
}: {
children: ReactNode;
className?: string;
muted?: boolean;
}) {
return (
<section className={cn('border-b border-border', muted && 'bg-muted/40')}>
<div className={cn('mx-auto w-full max-w-6xl px-5 py-16 sm:py-20', className)}>{children}</div>
</section>
);
}
export function SectionHead({eyebrow, title, description}: {eyebrow?: string; title: string; description?: string}) {
return (
<header className="mb-10 max-w-2xl">
{eyebrow && <p className="mb-2 text-xs font-medium tracking-wide text-muted-foreground">{eyebrow}</p>}
<h2 className="text-2xl font-bold tracking-tight text-balance sm:text-3xl">{title}</h2>
{description && <p className="mt-3 text-sm leading-relaxed text-muted-foreground">{description}</p>}
</header>
);
}

View File

@ -6,11 +6,13 @@
*/ */
import {useState, type FormEvent, type ReactNode} from 'react'; import {useState, type FormEvent, type ReactNode} from 'react';
import {LogIn} from 'lucide-react'; import {LogIn} from 'lucide-react';
import {login, me, UserRole} from '@/api'; import {googleLogin, login} from '@/api';
import {GoogleSignInButton} from '@/components/auth/GoogleSignInButton';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
import {notifyApiError} from '@/lib/notify'; import {notifyApiError} from '@/lib/notify';
import {toAuthUser, useAuthStore} from '@/stores/auth'; import {isGoogleLoginEnabled} from '@/lib/googleIdentity';
import {establishSession} from '@/lib/session';
interface SignInFormProps { interface SignInFormProps {
/** 폼 위에 붙는 제목·설명. 화면마다 하는 말이 다르다. */ /** 폼 위에 붙는 제목·설명. 화면마다 하는 말이 다르다. */
@ -22,11 +24,31 @@ interface SignInFormProps {
} }
export function SignInForm({header, footer, submitLabel = '로그인', onSignedIn}: SignInFormProps) { export function SignInForm({header, footer, submitLabel = '로그인', onSignedIn}: SignInFormProps) {
const signIn = useAuthStore((s) => s.signIn);
const [id, setId] = useState(''); const [id, setId] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
// 구글은 가입과 로그인이 같은 동작이다 — 처음 온 계정이면 백엔드가 그 자리에서 만든다.
const handleGoogle = async (credential: string) => {
setIsSubmitting(true);
try {
const res = await googleLogin({credential});
if (res.result?.success === false) {
notifyApiError({data: res}, '구글 로그인에 실패했습니다.');
return;
}
if (!(await establishSession(res, ''))) {
notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.');
return;
}
onSignedIn?.();
} catch (error) {
notifyApiError(error, '구글 로그인에 실패했습니다.');
} finally {
setIsSubmitting(false);
}
};
const handleSubmit = async (event: FormEvent) => { const handleSubmit = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
setIsSubmitting(true); setIsSubmitting(true);
@ -36,20 +58,12 @@ export function SignInForm({header, footer, submitLabel = '로그인', onSignedI
notifyApiError({data: res}, '아이디 또는 비밀번호를 확인해 주세요.'); notifyApiError({data: res}, '아이디 또는 비밀번호를 확인해 주세요.');
return; return;
} }
// ★ RemoveNoneResponse 라 성공 응답에서도 토큰 필드가 빠져 올 수 있다. // 토큰 심는 순서(signIn → me)는 lib/session 한 곳에만 둔다 — 이 파일 맨 위 주석이
// 빈 토큰으로 로그인 상태를 만들면 이후 모든 요청이 401 로 흐른다 — 여기서 끊는다. // 경고하던 그 중복이다. 로그인 화면·가입 화면·자동 로그인이 전부 같은 함수를 쓴다.
if (!res.access_token || !res.refresh_token) { if (!(await establishSession(res, id))) {
notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.'); notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.');
return; 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?.(); onSignedIn?.();
} catch (error) { } catch (error) {
notifyApiError(error, '로그인에 실패했습니다.'); notifyApiError(error, '로그인에 실패했습니다.');
@ -98,6 +112,19 @@ export function SignInForm({header, footer, submitLabel = '로그인', onSignedI
<span>{submitLabel}</span> <span>{submitLabel}</span>
</Button> </Button>
{/* ★ 로그인 화면이 여기 하나만 있는 게 아니다 — 에디터 관문·2단계도 이 폼을 쓴다.
구글 버튼을 LoginPage 에만 붙여 두면 정작 사장님이 만나는 자리엔 없다. */}
{isGoogleLoginEnabled() && (
<>
<div className="flex items-center gap-2">
<span className="h-px flex-1 bg-border" />
<span className="text-[11px] text-muted-foreground">또는</span>
<span className="h-px flex-1 bg-border" />
</div>
<GoogleSignInButton onCredential={handleGoogle} text="signin_with" />
</>
)}
{footer} {footer}
</form> </form>
); );

View File

@ -13,7 +13,6 @@ export function EditorHeader() {
const isPreviewMode = useBuilderStore((s) => s.isPreviewMode); const isPreviewMode = useBuilderStore((s) => s.isPreviewMode);
const togglePreview = useBuilderStore((s) => s.togglePreview); const togglePreview = useBuilderStore((s) => s.togglePreview);
const openPublishModal = useBuilderStore((s) => s.openPublishModal); const openPublishModal = useBuilderStore((s) => s.openPublishModal);
const reset = useBuilderStore((s) => s.reset);
const placeId = useBuilderStore((s) => s.placeId); const placeId = useBuilderStore((s) => s.placeId);
const unverifiedCount = useUnverifiedFields().length; const unverifiedCount = useUnverifiedFields().length;
@ -37,20 +36,19 @@ export function EditorHeader() {
여기 링크를 남기면 사장님 앱에서 404 이고, 내부 경로 이름이 사장님 번들에도 남는다. 여기 링크를 남기면 사장님 앱에서 404 이고, 내부 경로 이름이 사장님 번들에도 남는다.
reset() 을 부르지 않는 이유는 그대로다: 편집하던 가게를 떠나 데모 위저드로 reset() 을 부르지 않는 이유는 그대로다: 편집하던 가게를 떠나 데모 위저드로
떨어지면 사장님 눈에는 자기 가게가 사라진 것으로 보인다. 떨어지면 사장님 눈에는 자기 가게가 사라진 것으로 보인다.
(사장님용 "내 사이트 관리"가 생기면 그때 그리로 잇는다 — ARCHITECTURE.md 4절) */} (사장님용 "내 사이트 관리"가 생기면 그때 그리로 잇는다 — ARCHITECTURE.md 4절)
★ 데모에서 돌아가는 길은 `?new=1` 이다 — 스토어를 직접 비우면 화면만 위저드로 바뀌고
주소는 그대로라, 뒤로가기가 편집기로 돌아오지 않는다(단계는 주소창이 소유한다). */}
{placeId ? ( {placeId ? (
<span className="group flex items-center gap-2"> // 편집 중이어도 로고는 홈으로 간다 — 눌리지 않는 로고는 고장으로 읽힌다.
// 입력값은 서버에 저장되므로 나갔다 들어와도 그대로다.
<Link to="/" title="처음으로 이동" className="group flex items-center gap-2">
<LogoMark /> <LogoMark />
</span> </Link>
) : ( ) : (
<button <Link to="/" title="처음으로 이동" className="group flex items-center gap-2">
type="button"
onClick={reset}
title="처음으로 이동"
className="group flex cursor-pointer items-center gap-2"
>
<LogoMark /> <LogoMark />
</button> </Link>
)} )}
<span className="hidden h-3.5 w-px bg-border sm:block" /> <span className="hidden h-3.5 w-px bg-border sm:block" />

View File

@ -16,7 +16,7 @@ import {
Wand2, Wand2,
X, X,
} from 'lucide-react'; } from 'lucide-react';
import type {InfoField} from '@o2o/shared'; import {parseSectionData, type InfoField} from '@o2o/shared';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
@ -30,7 +30,6 @@ import {resolveVariant} from './canvas/registry';
import { import {
buildPrompt, buildPrompt,
dataSpecFor, dataSpecFor,
parseSectionData,
SECTION_DATA_MAX_CHARS, SECTION_DATA_MAX_CHARS,
} from './canvas/dataSpec'; } from './canvas/dataSpec';

View File

@ -1,86 +1,17 @@
/** /**
* 붙여넣기 아이템 계약 — "이 섹션 타입은 어떤 JSON 을 먹나"의 단일 출처. * 붙여넣기 아이템의 **쓰는 쪽** — "이 섹션 타입의 JSON 을 어떻게 받아 오나".
*
* 프롬프트·예시·라벨이 여기 있고, **읽는 쪽 계약**(항목 타입 · 파서)은 `@o2o/shared` 에 있다 —
* 같은 JSON 을 빌더 캔버스와 발행 사이트가 함께 읽기 때문이다(`shared/lib/section-data.ts`).
* *
* 배리에이션 레지스트리와 같은 결이다: 여기 한 줄을 더하면 캔버스·[콘텐츠] 탭·프롬프트가 동시에 는다. * 배리에이션 레지스트리와 같은 결이다: 여기 한 줄을 더하면 캔버스·[콘텐츠] 탭·프롬프트가 동시에 는다.
* 데이터는 섹션 **타입**에 붙고 모양은 배리에이션이 갈아끼운다 — 같은 곡 JSON 으로 도넛판도 카세트도 된다. * 데이터는 섹션 **타입**에 붙고 모양은 배리에이션이 갈아끼운다 — 같은 곡 JSON 으로 도넛판도 카세트도 된다.
*/ */
/** 사장님이 스스로 매긴 확신. 미검증 값이 화면·JSON-LD 로 새지 않게 하는 첫 관문이다. */
export type DataVerified = '확인' | '확인필요';
export interface DataSource {
name: string;
url?: string;
}
export interface SongItem {
title: string;
artist?: string;
lyricist?: string;
composer?: string;
year?: number;
label?: string;
labelColor?: string;
story?: string;
connection?: string;
verified?: DataVerified;
source?: DataSource;
}
export interface DailyItem {
monthDay: string;
category?: string;
title: string;
body?: string;
season?: string;
tags?: string[];
verified?: DataVerified;
source?: DataSource;
}
export interface CourseStop {
order?: number;
name: string;
minutes?: number;
note?: string;
searchQuery?: string;
}
export interface CourseItem {
name: string;
duration?: string;
startsFrom?: string;
stops?: CourseStop[];
verified?: DataVerified;
source?: DataSource;
}
export interface ScheduleSlot {
/** 24시간 표기 "09:30". 정렬·플립 시각 표시가 이 값을 그대로 쓴다. */
time: string;
title: string;
place?: string;
minutes?: number;
note?: string;
searchQuery?: string;
}
export interface ScheduleItem {
name: string;
/** 누구를 위한 하루인가("혼자 온 손님" · "아이와 함께"). 고르는 기준이 된다. */
audience?: string;
season?: string;
slots?: ScheduleSlot[];
verified?: DataVerified;
source?: DataSource;
}
export interface SectionDataSpec { export interface SectionDataSpec {
/** JSON 봉투의 `kind`. 섹션 타입과 같은 값이라 다른 아이템 JSON 을 붙여넣으면 바로 잡힌다. */ /** JSON 봉투의 `kind`. 섹션 타입과 같은 값이라 다른 아이템 JSON 을 붙여넣으면 바로 잡힌다. */
kind: string; kind: string;
label: string; label: string;
/** 없으면 그 줄을 통째로 버리는 키. 빈 껍데기가 화면에 줄만 남기는 걸 막는다. */
requiredKey: string;
/** [예시 넣기] 가 그대로 넣는 값. */ /** [예시 넣기] 가 그대로 넣는 값. */
sample: string; sample: string;
/** 프롬프트의 '무엇을 시키나' 부분. 머리·공통규칙은 buildPrompt 가 붙인다. */ /** 프롬프트의 '무엇을 시키나' 부분. 머리·공통규칙은 buildPrompt 가 붙인다. */
@ -153,7 +84,6 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
songs: { songs: {
kind: 'songs', kind: 'songs',
label: '가요 다방', label: '가요 다방',
requiredKey: 'title',
sample: JSON.stringify( sample: JSON.stringify(
{ {
kind: 'songs', kind: 'songs',
@ -217,7 +147,6 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
daily: { daily: {
kind: 'daily', kind: 'daily',
label: '오늘의 한 장', label: '오늘의 한 장',
requiredKey: 'title',
sample: JSON.stringify( sample: JSON.stringify(
{ {
kind: 'daily', kind: 'daily',
@ -279,7 +208,6 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
course: { course: {
kind: 'course', kind: 'course',
label: '반나절 산책', label: '반나절 산책',
requiredKey: 'name',
sample: JSON.stringify( sample: JSON.stringify(
{ {
kind: 'course', kind: 'course',
@ -326,7 +254,6 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
schedule: { schedule: {
kind: 'schedule', kind: 'schedule',
label: '여행 스케줄', label: '여행 스케줄',
requiredKey: 'name',
sample: JSON.stringify( sample: JSON.stringify(
{ {
kind: 'schedule', kind: 'schedule',
@ -372,118 +299,396 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
· 장소에 url 을 넣지 않는다. searchQuery 만 넣는다. · 장소에 url 을 넣지 않는다. searchQuery 만 넣는다.
· 예약이 필요한 곳은 note 에 "예약 필요" 라고만 적고 연락처는 쓰지 않는다.`, · 예약이 필요한 곳은 note 에 "예약 필요" 라고만 적고 연락처는 쓰지 않는다.`,
}, },
people: {
kind: 'people',
label: '인물 열전',
sample: JSON.stringify(
{
kind: 'people',
version: 1,
title: '인물 열전',
subtitle: '이 도시가 배출한 이름들',
items: [
{
name: '채만식',
aka: '백릉',
years: '1902–1950',
role: '소설가',
oneLine: '군산을 무대로 근대의 뒷면을 풍자로 그려낸 작가입니다.',
imageQuery: '채만식문학관 채만식 초상',
verified: '확인',
source: {name: '군산시 채만식문학관', url: 'https://www.gunsan.go.kr/chae/'},
},
{
name: '고은',
years: '1933–',
role: '시인',
oneLine: '군산에서 태어난 시인으로 소개됩니다. 지역 문학을 이야기할 때 함께 불립니다.',
verified: '확인필요',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
{
name: '박화요비',
role: '가수',
oneLine: '군산 출신으로 소개되는 가수입니다.',
verified: '확인필요',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
],
},
null,
2,
),
task: `[해야 할 일]
[지역] 출신이거나 [지역]과 깊이 얽힌 인물을 10명까지 찾는다.
문학·음악·미술·역사 인물을 고루 섞고, 생존 인물은 공개된 사실만 쓴다.
[스키마]
{ "kind":"people", "version":1, "title":"인물 열전", "items":[
{ "name":"이름", "aka":"호·예명", "years":"1902–1950", "role":"소설가",
"oneLine":"한 문장 소개", "imageQuery":"사진 검색어",
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
rules: `
[이 아이템만의 규칙]
· "~ 출신으로 알려진" 처럼 근거가 전언뿐이면 verified 를 "확인필요" 로 한다.
· 생존 인물의 가족·거주지·건강 같은 사생활은 쓰지 않는다.
· 사진 URL 을 넣지 않는다. imageQuery 만 넣는다 — 초상권과 저작권은 사장님이 확인한다.`,
},
chronicle: {
kind: 'chronicle',
label: '시간의 골목',
sample: JSON.stringify(
{
kind: 'chronicle',
version: 1,
title: '시간의 골목',
subtitle: '이 도시를 만든 해들',
items: [
{
year: 1899,
title: '군산 개항',
summary: '항구가 열리며 도시의 성격이 한 번에 바뀝니다.',
place: '군산 내항',
turning: true,
verified: '확인',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
{
year: 1926,
title: '내항 부잔교 축조',
summary: '조수 차가 큰 항구에서 배를 대기 위한 뜬다리입니다. 미곡 반출의 통로이기도 했습니다.',
place: '군산 내항 부잔교',
verified: '확인필요',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
{
year: 1937,
title: '『탁류』 연재 시작',
summary: '채만식이 이 도시를 소설의 무대로 세웁니다.',
place: '채만식문학관',
verified: '확인',
source: {name: '군산시 채만식문학관', url: 'https://www.gunsan.go.kr/chae/'},
},
{
year: 2010,
title: '새만금 방조제 개통',
summary: '간척으로 해안선이 다시 그려집니다.',
place: '새만금 방조제',
turning: true,
verified: '확인필요',
source: {name: '대한민국 구석구석', url: 'https://korean.visitkorea.or.kr/'},
},
],
},
null,
2,
),
task: `[해야 할 일]
[지역]의 역사를 연도순으로 10~14개 사건으로 정리한다.
가장 오래된 것부터 가장 최근까지 고르게 펴고, 한 시대에 몰지 않는다.
[스키마]
{ "kind":"chronicle", "version":1, "title":"시간의 골목", "items":[
{ "year":1899, "title":"사건 이름", "summary":"두 문장 이내",
"place":"지금 가 볼 수 있는 자리", "turning":true,
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
rules: `
[이 아이템만의 규칙]
· turning 은 도시의 성격을 바꾼 해에만 true 다. 3~4개를 넘기지 않는다.
· 연도가 불확실하면 그 항목을 통째로 뺀다. 연표에서 틀린 연도는 바로 들킨다.
· place 는 지금도 찾아갈 수 있는 자리만 적는다. 없으면 필드를 뺀다.`,
},
literature: {
kind: 'literature',
label: '문학 서가',
sample: JSON.stringify(
{
kind: 'literature',
version: 1,
title: '문학 서가',
subtitle: '이 도시를 쓴 책들',
items: [
{
workTitle: '탁류',
author: '채만식',
year: '1937–1938 연재',
genre: '장편소설',
spineColor: '#7d3320',
background: '일제강점기 군산을 무대로 한 여자의 삶과 도시의 경제를 겹쳐 놓았습니다.',
whyHere: '소설 속 거리를 따라 걸으면 지금의 내항이 겹쳐 보입니다.',
verified: '확인',
source: {name: '군산시 채만식문학관', url: 'https://www.gunsan.go.kr/chae/'},
},
{
workTitle: '레디메이드 인생',
author: '채만식',
year: '1934',
genre: '단편소설',
spineColor: '#2a4a6b',
background: '배운 사람이 일자리를 얻지 못하는 식민지 도시의 앞뒤를 풍자로 잘라 보입니다.',
whyHere: '같은 작가의 문학관이 금강 하구 옆에 서 있습니다.',
verified: '확인필요',
source: {name: '군산시 채만식문학관', url: 'https://www.gunsan.go.kr/chae/'},
},
{
workTitle: '아리랑',
author: '조정래',
year: '대하소설',
genre: '장편소설',
spineColor: '#3f5c33',
background: '이 지역이 겪은 시간을 인물들의 이동으로 따라갑니다.',
whyHere: '스탬프투어 아리랑길이 소설과 같은 자리를 지납니다.',
verified: '확인필요',
source: {name: '군산 스탬프투어', url: 'https://www.gunsanstamp.kr/'},
},
],
},
null,
2,
),
task: `[해야 할 일]
[지역]을 배경으로 하거나 [지역]에서 쓰인 문학 작품을 8편까지 찾는다.
소설·시·수필을 고루 섞고, 작품이 그 지역과 어떻게 닿는지 한 문장으로 적는다.
[스키마]
{ "kind":"literature", "version":1, "title":"문학 서가", "items":[
{ "workTitle":"작품명", "author":"작가", "year":"1937", "genre":"장편소설",
"spineColor":"#7d3320", "background":"작품 배경 두 문장 이내",
"whyHere":"[지역]과 잇는 한 문장",
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
rules: `
[이 아이템만의 규칙]
· 작품 원문(구절·시행·문장)을 옮기지 않는다. 줄거리와 배경만 쓴다.
· 저작권이 살아 있는 작품일수록 짧게 쓴다.
· spineColor 는 책등 색이다. 작품 분위기에 맞는 어두운 색 하나를 hex 로 고른다.`,
},
postcard: {
kind: 'postcard',
label: '오늘의 엽서',
sample: JSON.stringify(
{
kind: 'postcard',
version: 1,
title: '오늘의 엽서',
subtitle: '그대로 붙여 쓰는 한 문장',
items: [
{
line: '부잔교 위에서는 물이 어디까지 올라왔었는지가 다리에 새겨져 있다.',
hashtags: ['#군산', '#내항', '#부잔교'],
place: '군산 내항',
postmark: '군산 內港',
verified: '확인',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
{
line: '짬뽕 한 그릇이 항구도시의 이력서인 줄은 몰랐다.',
hashtags: ['#군산짬뽕', '#항구도시'],
place: '군산 중앙로',
postmark: '군산 中央',
verified: '확인필요',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
{
line: '단팥빵을 사러 줄을 서는 일이 여행이 되는 도시.',
hashtags: ['#이성당', '#오래된가게'],
place: '이성당',
postmark: '군산 中央',
verified: '확인',
source: {name: '대한민국 구석구석', url: 'https://korean.visitkorea.or.kr/'},
},
],
},
null,
2,
),
task: `[해야 할 일]
[지역]에 대해 손님이 자기 SNS 에 그대로 붙여 쓸 만한 한 문장을 12개 쓴다.
사실 하나가 반드시 들어가되, 설명하지 말고 툭 던지는 문장으로 쓴다.
[스키마]
{ "kind":"postcard", "version":1, "title":"오늘의 엽서", "items":[
{ "line":"한 문장", "hashtags":["#태그"], "place":"장소",
"postmark":"소인에 찍을 짧은 지명",
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
rules: `
[이 아이템만의 규칙]
· 한 문장은 40자 안쪽이다. 두 문장으로 쓰지 않는다.
· 느낌표와 이모지를 쓰지 않는다. 광고 문구처럼 들리면 실패다.
· 해시태그는 3개까지. 지역명 하나는 반드시 넣는다.`,
},
quiz: {
kind: 'quiz',
label: '뒤집어 보는 질문',
sample: JSON.stringify(
{
kind: 'quiz',
version: 1,
title: '뒤집어 보는 질문',
subtitle: '뒤집으면 힌트가 나옵니다',
items: [
{
question: '부잔교는 왜 물 위에 뜨도록 만들었을까?',
hint: '서해는 조수 간만의 차가 큽니다. 고정된 부두라면 하루에 두 번 배가 닿지 못합니다.',
topic: '군산 내항',
level: '초등',
verified: '확인',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
{
question: '『탁류』의 “탁류”는 무엇을 가리키는 말일까?',
hint: '금강 하류의 흙탕물이자, 소설 속 인물들이 휩쓸려 가는 시대를 함께 가리킵니다.',
topic: '채만식문학관',
level: '중등',
verified: '확인',
source: {name: '군산시 채만식문학관', url: 'https://www.gunsan.go.kr/chae/'},
},
{
question: '고군산군도의 “고(古)”는 왜 붙었을까?',
hint: '지금의 군산이 자리 잡기 전, 이 이름이 먼저 있던 곳이 있습니다.',
topic: '고군산군도',
level: '어른',
verified: '확인필요',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
],
},
null,
2,
),
task: `[해야 할 일]
[지역]을 소재로, 아이와 어른이 함께 생각해 볼 질문을 12개 만든다.
질문은 검색하면 바로 나오는 단답형이 아니라 "왜" 와 "어떻게" 를 묻는 것으로 한다.
[스키마]
{ "kind":"quiz", "version":1, "title":"뒤집어 보는 질문", "items":[
{ "question":"질문 한 문장", "hint":"두 문장 이내 힌트",
"topic":"관련 장소·주제", "level":"초등|중등|어른",
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
rules: `
[이 아이템만의 규칙]
· answer 필드는 스키마에 없다. 정답을 단정하지 않는다 — 힌트까지만 준다.
· 힌트에 사실을 넣되, 확실하지 않으면 그 항목을 통째로 뺀다.
· 질문에 지역 이름을 넣어 어디 이야기인지 알 수 있게 한다.`,
},
planner: {
kind: 'planner',
label: '계절별 추천 하루',
sample: JSON.stringify(
{
kind: 'planner',
version: 1,
title: '계절별 추천 하루',
subtitle: '계절을 고르면 시각까지 짜 드립니다',
items: [
{
name: '개항장 골목 한 바퀴',
season: '가을',
rank: 1,
startTime: '09:30',
audience: '처음 온 손님',
why: '해가 낮아지는 계절이라 골목 그림자가 길어집니다. 걷는 거리가 가장 짧은 코스이기도 합니다.',
stops: [
{name: '군산근대역사박물관', minutes: 90, moveMinutes: 15, note: '항구 도시가 어떻게 만들어졌는지 먼저 봅니다.', searchQuery: '군산근대역사박물관'},
{name: '초원사진관', minutes: 30, moveMinutes: 10, note: '영화가 남기고 간 자리입니다.', searchQuery: '군산 초원사진관'},
{name: '이성당', minutes: 40, moveMinutes: 8, note: '단팥빵 하나로 오후를 엽니다.', searchQuery: '군산 이성당'},
{name: '경암동 철길마을', minutes: 60, moveMinutes: 20, note: '해 질 무렵 빛이 가장 좋습니다.', searchQuery: '군산 경암동 철길마을'},
],
verified: '확인',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
{
name: '섬으로 건너가는 날',
season: '가을',
rank: 2,
startTime: '10:00',
audience: '차를 가져온 손님',
why: '바람이 차기 전 마지막으로 바다가 열리는 계절입니다.',
stops: [
{name: '고군산군도 선유도', minutes: 150, moveMinutes: 50, note: '다리로 건너갑니다. 차로 이동.', searchQuery: '군산 선유도'},
{name: '장자도', minutes: 60, moveMinutes: 15, note: '섬과 섬 사이를 걸어 넘습니다.', searchQuery: '군산 장자도'},
],
verified: '확인필요',
source: {name: '대한민국 구석구석', url: 'https://korean.visitkorea.or.kr/'},
},
{
name: '비 오면 여기로',
season: '여름',
rank: 1,
startTime: '10:30',
audience: '혼자 온 손님',
why: '장마철에 실내로만 이어지는 코스입니다. 우산을 접었다 폈다 하지 않아도 됩니다.',
stops: [
{name: '군산근대건축관', minutes: 60, moveMinutes: 12, note: '옛 은행 건물 안이 통째로 전시입니다.', searchQuery: '군산근대건축관'},
{name: '한일옥', minutes: 60, moveMinutes: 8, note: '무국 한 그릇으로 몸을 데웁니다.', searchQuery: '군산 한일옥'},
{name: '째보선창 근처 카페', minutes: 90, moveMinutes: 10, note: '비 그칠 때까지 앉아 있기 좋습니다.', searchQuery: '군산 째보선창 카페'},
],
verified: '확인필요',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour/'},
},
],
},
null,
2,
),
task: `[해야 할 일]
[업소]에 묵는 손님을 위한 [지역] 하루 코스를 **계절마다 1~3위**로 추천한다.
계절 넷(봄·여름·가을·겨울)을 모두 채우고, 계절마다 성격이 다른 코스 세 개를 1·2·3위로 매긴다.
코스마다 정거장 3~5곳을 **도는 순서대로** 적는다.
★ 시각은 적지 않는다. 출발 시각(startTime)과 각 정거장의 머무는 시간(minutes)·이동 시간(moveMinutes)만
적으면 화면이 시각을 계산해 채운다.
[스키마]
{ "kind":"planner", "version":1, "title":"계절별 추천 하루", "items":[
{ "name":"코스 이름", "season":"봄|여름|가을|겨울", "rank":1,
"startTime":"09:30", "audience":"누구에게 맞는 하루",
"why":"왜 이 계절에 이 코스인가 (한 문장)",
"stops":[ {"name":"장소","minutes":90,"moveMinutes":15,
"note":"한 문장","searchQuery":"지도 검색어"} ],
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
rules: `
[이 아이템만의 규칙]
· rank 는 계절마다 1·2·3 만 쓴다. 4위는 만들지 않는다 — 아무도 안 고른다.
· 같은 계절의 세 코스가 서로 달라야 한다. 정거장이 겹치면 순위가 아니라 중복이다.
· moveMinutes 는 앞 정거장에서 오는 시간이다. 첫 정거장은 [업소]에서 나서는 시간이다.
· 걸어서 못 가는 곳은 note 에 "차로 이동" 이라고 적는다.
· 영업시간·휴무일·요금은 넣지 않는다. 바뀌면 짜 준 일정이 손님을 헛걸음시킨다.
· 장소에 url 을 넣지 않는다. searchQuery(지도 검색어)만 넣는다.`,
},
}; };
export function dataSpecFor(sectionType: string): SectionDataSpec | undefined { export function dataSpecFor(sectionType: string): SectionDataSpec | undefined {
return SECTION_DATA_SPEC[sectionType]; return SECTION_DATA_SPEC[sectionType];
} }
export interface ParsedSectionData<T> {
items: T[];
title?: string;
subtitle?: string;
/** 사람에게 보여줄 실패 사유. 있으면 items 는 비어 있다. */
error?: string;
/** 붙여넣은 JSON 의 kind 가 이 섹션과 다르다 — 다른 아이템 것을 넣었다는 뜻. */
kindMismatch?: string;
/** verified 가 '확인' 이 아닌 항목 수. 화면에 각주로 뜬다. */
unverified: number;
/** source 가 붙은 항목 수. */
sourced: number;
}
const EMPTY: ParsedSectionData<never> = {items: [], unverified: 0, sourced: 0};
/**
* JSON.parse 실패를 "몇 번째 줄"로 바꾼다.
*
* ★ V8 은 두 가지 모양으로 던진다 — `position N (line L column C)` 형과,
* 위치 없이 깨진 조각만 인용하는 `Unexpected token 'X', ..."조각" is not valid JSON` 형이다.
* 앞의 것만 보면 후자에서 위치를 통째로 잃는다(실제로 그랬다). 뒤의 것은 조각을 원문에서 되찾아 센다.
*/
function locate(raw: string, message: string): string {
const where = (pos: number) => {
const before = raw.slice(0, Math.max(0, pos));
const line = before.split('\n').length;
const col = pos - before.lastIndexOf('\n');
return `${line}번째 줄 ${col}번째 글자`;
};
const token = /Unexpected token '(.)'/.exec(message)?.[1];
// 쉼표를 하나 더 찍은 경우가 압도적으로 많다 — 그 말을 먼저 해 준다.
const hint =
token === '}' || token === ']'
? '닫는 괄호 바로 앞에 쉼표가 하나 더 있는지 보세요.'
: '그 앞의 쉼표·따옴표·괄호를 확인해 주세요.';
const lineCol = /line (\d+) column (\d+)/.exec(message);
if (lineCol) return `${lineCol[1]}번째 줄 ${lineCol[2]}번째 글자에서 JSON 이 끊깁니다. ${hint}`;
const at = /position (\d+)/.exec(message);
if (at) return `${where(Number(at[1]))}에서 JSON 이 끊깁니다. ${hint}`;
// 위치 없이 조각만 인용하는 형 — 그 조각을 원문에서 되찾는다.
const quoted = /\.\.\."([\s\S]*?)" is not valid JSON/.exec(message)?.[1];
const found = quoted ? raw.indexOf(quoted) : -1;
if (found >= 0) return `${where(found + quoted!.length)} 부근에서 JSON 이 끊깁니다. ${hint}`;
return 'JSON 이 아닙니다. ChatGPT 가 준 답에서 { 로 시작해 } 로 끝나는 부분만 붙여넣어 주세요.';
}
/**
* 붙여넣은 문자열 → 렌더 가능한 항목.
*
* ★ 절대 throw 하지 않는다. 편집 중인 JSON 은 늘 깨져 있고, 깨진 순간 캔버스가 죽으면 못 고친다.
*/
export function parseSectionData<T extends object>(
sectionType: string,
raw: string | undefined,
): ParsedSectionData<T> {
const spec = SECTION_DATA_SPEC[sectionType];
const text = (raw ?? '').trim();
if (!spec || !text) return EMPTY as ParsedSectionData<T>;
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (error) {
return {...EMPTY, error: locate(text, error instanceof Error ? error.message : '')};
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return {...EMPTY, error: '바깥이 { } 로 감싸인 JSON 이어야 합니다.'};
}
const envelope = parsed as Record<string, unknown>;
const kind = typeof envelope.kind === 'string' ? envelope.kind : undefined;
const rawItems = envelope.items;
if (!Array.isArray(rawItems)) {
return {...EMPTY, error: 'items 배열이 없습니다. 프롬프트로 다시 만들어 주세요.'};
}
const items = rawItems.filter(
(item): item is T =>
typeof item === 'object' &&
item !== null &&
!Array.isArray(item) &&
typeof (item as Record<string, unknown>)[spec.requiredKey] === 'string' &&
((item as Record<string, unknown>)[spec.requiredKey] as string).trim().length > 0,
);
let unverified = 0;
let sourced = 0;
for (const item of items) {
const row = item as Record<string, unknown>;
if (row.verified !== '확인') unverified += 1;
if (row.source && typeof row.source === 'object') sourced += 1;
}
return {
items,
title: typeof envelope.title === 'string' ? envelope.title : undefined,
subtitle: typeof envelope.subtitle === 'string' ? envelope.subtitle : undefined,
kindMismatch: kind && kind !== spec.kind ? kind : undefined,
unverified,
sourced,
};
}

View File

@ -68,6 +68,12 @@ import {SongsTurntable} from './variants/songs/SongsTurntable';
import {DailyCalendar} from './variants/daily/DailyCalendar'; import {DailyCalendar} from './variants/daily/DailyCalendar';
import {CourseTickets} from './variants/course/CourseTickets'; import {CourseTickets} from './variants/course/CourseTickets';
import {ScheduleTimetable} from './variants/schedule/ScheduleTimetable'; import {ScheduleTimetable} from './variants/schedule/ScheduleTimetable';
import {PeopleFilmstrip} from './variants/people/PeopleFilmstrip';
import {ChronicleRail} from './variants/chronicle/ChronicleRail';
import {LiteratureShelf} from './variants/literature/LiteratureShelf';
import {PostcardStack} from './variants/postcard/PostcardStack';
import {QuizFlip} from './variants/quiz/QuizFlip';
import {PlannerPodium} from './variants/planner/PlannerPodium';
export const SECTION_VARIANTS: Record<string, SectionVariant[]> = { export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
hero: [ hero: [
@ -447,6 +453,72 @@ export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
}, },
], ],
people: [
{
id: 'people.filmstrip',
name: '필름 스트립',
description: '프레임 하나가 인물 하나. 사진이 없으면 이름 활자가 대신 들어간다.',
thumb: 'carousel',
Component: PeopleFilmstrip,
isDefault: true,
},
],
chronicle: [
{
id: 'chronicle.rail',
name: '가로 연표',
description: '연도가 큰 활자로 서고 사건이 붙는다. 붉은 점이 도시를 바꾼 해다.',
thumb: 'timeline',
Component: ChronicleRail,
isDefault: true,
},
],
literature: [
{
id: 'literature.shelf',
name: '책등 서가',
description: '책등이 가로로 흐르고 고른 책만 세로쓰기로 펼쳐진다.',
thumb: 'carousel',
Component: LiteratureShelf,
isDefault: true,
},
],
postcard: [
{
id: 'postcard.stack',
name: '엽서 뒷면',
description: '우표 자리와 소인이 찍힌 뒷면 한 장. [복사] 로 손님이 그대로 퍼 간다.',
thumb: 'carousel',
Component: PostcardStack,
isDefault: true,
},
],
quiz: [
{
id: 'quiz.flip',
name: '시험지 플립',
description: '앞면은 질문, 뒤집으면 힌트와 출처. 정답은 두지 않는다.',
thumb: 'cards',
Component: QuizFlip,
isDefault: true,
},
],
planner: [
{
id: 'planner.podium',
name: '시즌 랭킹',
description: '계절을 고르면 1·2·3위 하루가 뜬다. 시각은 계산해서 채운다.',
thumb: 'timeline',
Component: PlannerPodium,
isDefault: true,
},
],
exhibition: [ exhibition: [
{ {
id: 'exhibition.gallery', id: 'exhibition.gallery',

View File

@ -0,0 +1,139 @@
/**
* 시간의 골목 — 가로 연표.
*
* 연도가 큰 활자로 서고 사건이 그 아래 붙는다. 레일 위의 **붉은 점은 도시의 성격이 바뀐 해**다 —
* 점의 색이 장식이 아니라 정보라서, 범례를 한 줄 달아 그 약속을 밝힌다.
* ★ 연도가 없는 항목은 지어내 끼우지 않고 레일 끝으로 민다. 연표에서 틀린 순서는 바로 들킨다.
*/
import {useMemo} from 'react';
import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type ChronicleItem} from '@o2o/shared';
import {
CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ITEM_INK,
ParseError,
PasteHint,
SourceLine,
useCarousel,
} from '../items/common';
import '../items/items.css';
function Milestone({item, isLast}: {item: ChronicleItem; isLast: boolean}) {
const turning = item.turning === true;
return (
<div className="w-[228px] shrink-0 snap-center">
<p
className="leading-none"
style={{fontFamily: ITEM_HEADING, fontSize: 34, color: turning ? ITEM_ACCENT : ITEM_INK}}
>
{item.year ?? '연도 미상'}
</p>
{/* 레일 — 점이 선 위에 놓여야 '흐르는 시간 위의 한 해'로 읽힌다 */}
<div className="relative my-3 h-3">
<i
aria-hidden
className="absolute top-1/2 left-0 border-t"
// 마지막 해에서 선이 끊긴다 — 레일이 화면 밖으로 이어지는 것처럼 보이면 뒤가 더 있는 줄 안다.
style={{borderColor: ITEM_BORDER, right: isLast ? 'calc(100% - 12px)' : 0}}
/>
<i
aria-hidden
className="absolute top-1/2 left-0 size-3 -translate-y-1/2 rounded-full border-2"
style={{
backgroundColor: turning ? ITEM_ACCENT : ITEM_CARD,
borderColor: turning ? ITEM_ACCENT : ITEM_BORDER,
}}
/>
</div>
<div className="space-y-1.5 pr-5">
<h4 className="text-base font-bold" style={{fontFamily: ITEM_BODY}}>
{item.title}
</h4>
{item.summary && (
<p className="text-[13px] leading-relaxed opacity-75" style={{fontFamily: ITEM_BODY}}>
{item.summary}
</p>
)}
{item.place && <p className="text-[11px] opacity-60">지금 이 자리 · {item.place}</p>}
<SourceLine source={item.source} verified={item.verified} />
</div>
</div>
);
}
export function ChronicleRail(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<ChronicleItem>(section.type, section.data);
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
// 연도가 있는 것부터 오름차순, 없는 것은 뒤로. 붙여넣은 순서를 믿지 않는다.
const items = useMemo(
() =>
[...parsed.items].sort((a, b) => {
if (a.year == null) return b.year == null ? 0 : 1;
if (b.year == null) return -1;
return a.year - b.year;
}),
[parsed.items],
);
const turningCount = items.filter((item) => item.turning === true).length;
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : items.length === 0 ? (
<PasteHint label="시간의 골목" />
) : (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="flex items-center gap-1.5 text-[11px] opacity-60">
<i
aria-hidden
className="size-2.5 rounded-full"
style={{backgroundColor: ITEM_ACCENT}}
/>
도시의 성격이 바뀐 해 {turningCount}개 · 전체 {items.length}개
</p>
{items.length > 3 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} label="연표" />
)}
</div>
<div ref={ref} className="w4-scroll flex snap-x snap-mandatory overflow-x-auto pb-3">
{items.map((item, index) => (
<Milestone
key={`${item.year ?? 'x'}-${item.title}-${index}`}
item={item}
isLast={index === items.length - 1}
/>
))}
</div>
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -6,21 +6,22 @@
*/ */
import {SectionBody, SectionFrame} from '../../primitives'; import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types'; import type {SectionRenderProps} from '../../types';
import {parseSectionData, type CourseItem, type CourseStop} from '../../dataSpec'; import {parseSectionData, type CourseItem, type CourseStop} from '@o2o/shared';
import { import {
CarouselNav, CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ITEM_INK,
ITEM_SURFACE,
ParseError, ParseError,
PasteHint, PasteHint,
RETRO_BODY,
RETRO_LINE,
RETRO_PAPER,
RETRO_PAPER_LIGHT,
RETRO_RED,
RETRO_SIGN,
SourceLine, SourceLine,
useCarousel, useCarousel,
} from '../retro/common'; } from '../items/common';
import '../retro/retro.css'; import '../items/items.css';
function Ticket({ function Ticket({
stop, stop,
@ -38,31 +39,31 @@ function Ticket({
return ( return (
<div <div
className="relative w-[236px] shrink-0 snap-center border shadow-[4px_4px_0_rgba(27,26,21,.13)]" className="relative w-[236px] shrink-0 snap-center border shadow-[4px_4px_0_rgba(27,26,21,.13)]"
style={{backgroundColor: RETRO_PAPER_LIGHT, borderColor: '#1b1a15'}} style={{backgroundColor: ITEM_CARD, borderColor: ITEM_INK}}
> >
<div <div
className="flex justify-between border-b border-dashed px-4 py-2.5 text-[10px] tracking-[0.12em] text-stone-600" className="flex justify-between border-b border-dashed px-4 py-2.5 text-[10px] tracking-[0.12em] opacity-75"
style={{borderColor: '#1b1a15'}} style={{borderColor: ITEM_INK}}
> >
<span className="truncate">{courseName}</span> <span className="truncate">{courseName}</span>
<span className="shrink-0">NO.{no}</span> <span className="shrink-0">NO.{no}</span>
</div> </div>
<p className="px-4 pt-4 leading-none" style={{fontFamily: RETRO_SIGN, fontSize: 32, color: RETRO_RED}}> <p className="px-4 pt-4 leading-none" style={{fontFamily: ITEM_HEADING, fontSize: 32, color: ITEM_ACCENT}}>
{no} {no}
</p> </p>
<h4 className="px-4 pt-1.5 text-base font-bold text-stone-900" style={{fontFamily: RETRO_BODY}}> <h4 className="px-4 pt-1.5 text-base font-bold" style={{fontFamily: ITEM_BODY}}>
{stop.name} {stop.name}
</h4> </h4>
{stop.note && ( {stop.note && (
<p className="px-4 pt-2 text-[13px] leading-relaxed text-stone-600" style={{fontFamily: RETRO_BODY}}> <p className="px-4 pt-2 text-[13px] leading-relaxed opacity-75" style={{fontFamily: ITEM_BODY}}>
{stop.note} {stop.note}
</p> </p>
)} )}
<div <div
className="mt-3 flex justify-between gap-2 border-t border-dashed px-4 py-2.5 text-[10px] text-stone-500" className="mt-3 flex justify-between gap-2 border-t border-dashed px-4 py-2.5 text-[10px] opacity-60"
style={{borderColor: RETRO_LINE}} style={{borderColor: ITEM_BORDER}}
> >
<span>{stop.minutes ? `도보 ${stop.minutes}분` : '이동 시간 미정'}</span> <span>{stop.minutes ? `도보 ${stop.minutes}분` : '이동 시간 미정'}</span>
{stop.searchQuery && <span className="truncate">지도 검색 · {stop.searchQuery}</span>} {stop.searchQuery && <span className="truncate">지도 검색 · {stop.searchQuery}</span>}
@ -71,17 +72,17 @@ function Ticket({
{/* 펀치 구멍 — 표를 표로 만드는 자리 */} {/* 펀치 구멍 — 표를 표로 만드는 자리 */}
<i <i
className="absolute -left-[7px] top-1/2 size-3 rounded-full border" className="absolute -left-[7px] top-1/2 size-3 rounded-full border"
style={{backgroundColor: RETRO_PAPER, borderColor: '#1b1a15'}} style={{backgroundColor: ITEM_SURFACE, borderColor: ITEM_INK}}
/> />
<i <i
className="absolute -right-[7px] top-1/2 size-3 rounded-full border" className="absolute -right-[7px] top-1/2 size-3 rounded-full border"
style={{backgroundColor: RETRO_PAPER, borderColor: '#1b1a15'}} style={{backgroundColor: ITEM_SURFACE, borderColor: ITEM_INK}}
/> />
{isLast && ( {isLast && (
<span <span
className="absolute bottom-3 right-3 grid size-14 -rotate-12 place-items-center rounded-full border-2 text-center text-[11px] leading-tight opacity-75" className="absolute bottom-3 right-3 grid size-14 -rotate-12 place-items-center rounded-full border-2 text-center text-[11px] leading-tight opacity-75"
style={{fontFamily: RETRO_SIGN, borderColor: RETRO_RED, color: RETRO_RED}} style={{fontFamily: ITEM_HEADING, borderColor: ITEM_ACCENT, color: ITEM_ACCENT}}
> >
완주 완주
<br /> <br />
@ -100,10 +101,10 @@ function CourseRow({course}: {course: CourseItem}) {
<div className="space-y-2.5"> <div className="space-y-2.5">
<div className="flex flex-wrap items-baseline justify-between gap-2"> <div className="flex flex-wrap items-baseline justify-between gap-2">
<div className="flex flex-wrap items-baseline gap-2.5"> <div className="flex flex-wrap items-baseline gap-2.5">
<h3 className="text-lg text-stone-900" style={{fontFamily: RETRO_SIGN}}> <h3 className="text-lg" style={{fontFamily: ITEM_HEADING}}>
{course.name} {course.name}
</h3> </h3>
<span className="text-[11px] text-stone-500"> <span className="text-[11px] opacity-60">
{[course.duration, course.startsFrom ? `${course.startsFrom} 출발` : undefined, `${stops.length}곳`] {[course.duration, course.startsFrom ? `${course.startsFrom} 출발` : undefined, `${stops.length}곳`]
.filter(Boolean) .filter(Boolean)
.join(' · ')} .join(' · ')}
@ -115,7 +116,7 @@ function CourseRow({course}: {course: CourseItem}) {
</div> </div>
{stops.length === 0 ? ( {stops.length === 0 ? (
<p className="border border-dashed px-4 py-5 text-center text-[11px] text-stone-500" style={{borderColor: RETRO_LINE}}> <p className="border border-dashed px-4 py-5 text-center text-[11px] opacity-60" style={{borderColor: ITEM_BORDER}}>
정거장이 아직 없습니다. JSON 의 stops 배열을 채워 주세요. 정거장이 아직 없습니다. JSON 의 stops 배열을 채워 주세요.
</p> </p>
) : ( ) : (
@ -145,11 +146,11 @@ export function CourseTickets(props: SectionRenderProps) {
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint"> <SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint">
<SectionBody width="wide"> <SectionBody width="wide">
<div className="space-y-1.5"> <div className="space-y-1.5">
<h2 className="text-2xl text-stone-900 sm:text-3xl" style={{fontFamily: RETRO_SIGN}}> <h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name} {parsed.title || section.name}
</h2> </h2>
{(parsed.subtitle || section.description) && ( {(parsed.subtitle || section.description) && (
<p className="text-sm text-stone-600" style={{fontFamily: RETRO_BODY}}> <p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description} {parsed.subtitle || section.description}
</p> </p>
)} )}

View File

@ -7,20 +7,20 @@
import {useMemo, useState} from 'react'; import {useMemo, useState} from 'react';
import {SectionBody, SectionFrame} from '../../primitives'; import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types'; import type {SectionRenderProps} from '../../types';
import {parseSectionData, type DailyItem} from '../../dataSpec'; import {parseSectionData, type DailyItem} from '@o2o/shared';
import { import {
CarouselNav, CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ITEM_SURFACE,
ParseError, ParseError,
PasteHint, PasteHint,
RETRO_BODY,
RETRO_LINE,
RETRO_PAPER,
RETRO_PAPER_LIGHT,
RETRO_RED,
RETRO_SIGN,
SourceLine, SourceLine,
} from '../retro/common'; } from '../items/common';
import '../retro/retro.css'; import '../items/items.css';
const DOW = ['일', '월', '화', '수', '목', '금', '토']; const DOW = ['일', '월', '화', '수', '목', '금', '토'];
@ -47,47 +47,49 @@ function CalendarPage({item, muted}: {item: DailyItem; muted?: boolean}) {
className={`w4-paper relative w-[254px] shrink-0 border shadow-[5px_6px_0_rgba(27,26,21,.13)] transition-all ${ className={`w4-paper relative w-[254px] shrink-0 border shadow-[5px_6px_0_rgba(27,26,21,.13)] transition-all ${
muted ? 'scale-[.93] opacity-45' : '' muted ? 'scale-[.93] opacity-45' : ''
}`} }`}
style={{backgroundColor: RETRO_PAPER_LIGHT, borderColor: RETRO_LINE}} style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
> >
{/* 뜯어낸 자국 */} {/* 뜯어낸 자국 */}
<div <div
className="w4-perf h-3.5 border-b border-dashed" className="w4-perf h-3.5 border-b border-dashed"
style={{['--tear' as string]: RETRO_PAPER, borderColor: RETRO_LINE}} style={{['--tear' as string]: ITEM_SURFACE, borderColor: ITEM_BORDER}}
/> />
<div className="absolute inset-x-0 top-0 flex justify-around px-10"> <div className="absolute inset-x-0 top-0 flex justify-around px-10">
{[0, 1, 2].map((i) => ( {[0, 1, 2].map((i) => (
<i <i
key={i} key={i}
className="-mt-1 size-2 rounded-full border" className="-mt-1 size-2 rounded-full border"
style={{backgroundColor: RETRO_PAPER, borderColor: RETRO_LINE}} style={{backgroundColor: ITEM_SURFACE, borderColor: ITEM_BORDER}}
/> />
))} ))}
</div> </div>
<div className="border-b px-5 pb-3 pt-6 text-center" style={{borderColor: RETRO_LINE}}> <div className="border-b px-5 pb-3 pt-6 text-center" style={{borderColor: ITEM_BORDER}}>
<p className="text-[11px] tracking-[0.2em] text-stone-500">{month}月</p> <p className="text-[11px] tracking-[0.2em] opacity-60">{month}月</p>
<p className="mt-1 leading-none" style={{fontFamily: RETRO_SIGN, fontSize: 72, color: RETRO_RED}}> <p className="mt-1 leading-none" style={{fontFamily: ITEM_HEADING, fontSize: 72, color: ITEM_ACCENT}}>
{String(Number(day) || day)} {String(Number(day) || day)}
</p> </p>
{dow && <p className="mt-1.5 text-[11px] tracking-[0.3em] text-stone-600">{dow}曜</p>} {dow && <p className="mt-1.5 text-[11px] tracking-[0.3em] opacity-75">{dow}曜</p>}
</div> </div>
<div className="space-y-2 px-5 pb-5 pt-4"> <div className="space-y-2 px-5 pb-5 pt-4">
{item.category && ( {item.category && (
<p className="text-[10px] tracking-[0.16em] text-emerald-800">{item.category}</p> <p className="text-[10px] tracking-[0.16em]" style={{color: ITEM_ACCENT}}>
{item.category}
</p>
)} )}
<h3 className="text-base font-bold leading-snug text-stone-900" style={{fontFamily: RETRO_BODY}}> <h3 className="text-base font-bold leading-snug" style={{fontFamily: ITEM_BODY}}>
{item.title} {item.title}
</h3> </h3>
{item.body && ( {item.body && (
<p className="text-[13px] leading-relaxed text-stone-600" style={{fontFamily: RETRO_BODY}}> <p className="text-[13px] leading-relaxed opacity-75" style={{fontFamily: ITEM_BODY}}>
{item.body} {item.body}
</p> </p>
)} )}
{item.tags && item.tags.length > 0 && ( {item.tags && item.tags.length > 0 && (
<p className="text-[10px] text-stone-500">{item.tags.join(' ')}</p> <p className="text-[10px] opacity-60">{item.tags.join(' ')}</p>
)} )}
<div className="border-t border-dashed pt-2.5" style={{borderColor: RETRO_LINE}}> <div className="border-t border-dashed pt-2.5" style={{borderColor: ITEM_BORDER}}>
<SourceLine source={item.source} verified={item.verified} /> <SourceLine source={item.source} verified={item.verified} />
</div> </div>
</div> </div>
@ -122,11 +124,11 @@ export function DailyCalendar(props: SectionRenderProps) {
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper"> <SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="wide"> <SectionBody width="wide">
<div className="space-y-1.5"> <div className="space-y-1.5">
<h2 className="text-2xl text-stone-900 sm:text-3xl" style={{fontFamily: RETRO_SIGN}}> <h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name} {parsed.title || section.name}
</h2> </h2>
{(parsed.subtitle || section.description) && ( {(parsed.subtitle || section.description) && (
<p className="text-sm text-stone-600" style={{fontFamily: RETRO_BODY}}> <p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description} {parsed.subtitle || section.description}
</p> </p>
)} )}
@ -150,7 +152,7 @@ export function DailyCalendar(props: SectionRenderProps) {
label="날짜" label="날짜"
/> />
)} )}
<p className="text-center text-[11px] text-stone-500"> <p className="text-center text-[11px] opacity-60">
오늘 날짜에 맞는 장이 자동으로 펼쳐집니다 · 총 {items.length}장 오늘 날짜에 맞는 장이 자동으로 펼쳐집니다 · 총 {items.length}장
{parsed.unverified > 0 && ` · 확인 필요 ${parsed.unverified}장`} {parsed.unverified > 0 && ` · 확인 필요 ${parsed.unverified}장`}
</p> </p>

View File

@ -0,0 +1,188 @@
/**
* 붙여넣기 아이템이 공유하는 조각 — 색·서체 토큰, 출처 각주, 캐러셀 화살표.
*
* ★ 색과 서체를 여기에 박지 않는다. 전부 템플릿 토큰(`--tpl-*`)을 읽는다 —
* 한때 이 파일은 갱지색·주(朱)잉크·간판체를 hex 와 폰트명으로 들고 있었고,
* 그 바람에 사장님이 템플릿을 매거진으로 바꿔도 아이템만 레트로로 남았다.
* 아이템은 '레트로 전용 부품'이 아니라 **어느 템플릿에나 들어가는 섹션**이다.
* ★ 질감(도넛판 홈·톱니·펀치 구멍)은 남긴다 — 그건 색이 아니라 물건의 생김새다.
* 질감의 색도 토큰을 따라가게 `items.css` 에서 var() 로 받는다.
*/
import {useCallback, useRef} from 'react';
import {ChevronLeft, ChevronRight, MousePointerClick} from 'lucide-react';
import {cn} from '@/lib/utils';
import type {DataSource, DataVerified} from '@o2o/shared';
/** 제목 서체. 템플릿이 정한다(심플=고딕 · 매거진=명조 · 레트로=간판체). */
export const ITEM_HEADING = 'var(--tpl-font-heading, inherit)';
/** 본문 서체. */
export const ITEM_BODY = 'var(--tpl-font-body, inherit)';
/** 섹션 바탕. */
export const ITEM_SURFACE = 'var(--tpl-surface, #faf7f2)';
/** 카드·종이 면. 섹션 바탕보다 한 단계 앞이다. */
export const ITEM_CARD = 'var(--tpl-card, #ffffff)';
/** 글자색. */
export const ITEM_INK = 'var(--tpl-text, #1c1917)';
/** 선·테두리. */
export const ITEM_BORDER = 'var(--tpl-border, #d6d3d1)';
/**
* 강조 — 연표의 전환점, 일력의 날짜, 소인 도장.
*
* ★ 템플릿 강조색을 **그대로 쓰지 않고 둘레 글자색을 섞는다.** 팔레트에 따라 accent 가
* 바탕과 거의 같은 밝기일 수 있는데(연한 베이지·파스텔), 그러면 큰 날짜 숫자와 배지가
* 화면에서 사라진다. 섞으면 색조는 남고 대비만 확보된다 —
* currentColor 라서 어두운 면에서는 밝은 쪽으로, 밝은 면에서는 어두운 쪽으로 붙는다.
*/
export const ITEM_ACCENT = 'color-mix(in oklab, var(--tpl-accent, #bf2f1b) 70%, currentColor)';
/** 어두운 면(필름·플립보드·턴테이블). */
export const ITEM_INVERSE = 'var(--tpl-inverse, #1c1917)';
/**
* 어두운 면 위의 글자색.
*
* ★ 흰색을 박지 않는다. 템플릿 배경색을 글자색으로 쓰면 어떤 팔레트에서도 대비가 선다 —
* 밝은 템플릿이면 밝은 글자, 어두운 템플릿이면 그 템플릿이 정한 밝은 면 색이다.
*/
export const ITEM_INVERSE_INK = 'var(--tpl-bg, #ffffff)';
/**
* 출처 한 줄 + 확신 배지.
*
* ★ 확인되지 않은 값을 숨기지 않고 **드러낸다.** 숨기면 사장님이 그게 미검증인 줄 모르고 발행한다.
* ★ 배지 색만은 토큰이 아니다 — '확인/확인필요'는 디자인이 아니라 신호다. 템플릿 색을 따라가면
* 팔레트에 따라 경고가 안 보이는 색이 될 수 있다.
*/
export function SourceLine({
source,
verified,
tone = 'light',
}: {
source?: DataSource;
verified?: DataVerified;
tone?: 'light' | 'dark';
}) {
if (!source && !verified) return null;
return (
<p
className={cn('flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px]', tone === 'dark' ? 'opacity-70' : 'opacity-65')}
>
{verified && (
<span
className="border px-1.5 py-px"
// ★ 신호색(초록/호박)은 유지하되 둘레 글자색을 섞는다 — 어두운 면에서는 밝아지고
// 밝은 면에서는 진해져, 어떤 팔레트에서도 배지가 읽힌다.
style={{
color: `color-mix(in oklab, ${verified === '확인' ? '#2a6053' : '#b07d10'} 62%, currentColor)`,
borderColor: 'currentColor',
}}
>
{verified}
</span>
)}
{source?.name && (
<span>
출처 ·{' '}
{source.url ? (
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
className="underline underline-offset-2"
>
{source.name}
</a>
) : (
source.name
)}
</span>
)}
</p>
);
}
/** 가로 스크롤 + 화살표. 스크롤 컨테이너를 ref 로 잡아 한 화면의 80%씩 민다. */
export function useCarousel<T extends HTMLElement>() {
const ref = useRef<T>(null);
const scrollBy = useCallback((dir: -1 | 1) => {
const box = ref.current;
if (!box) return;
box.scrollBy({left: box.clientWidth * 0.8 * dir, behavior: 'smooth'});
}, []);
return {ref, scrollBy};
}
export function CarouselNav({
onPrev,
onNext,
tone = 'light',
label,
}: {
onPrev: () => void;
onNext: () => void;
tone?: 'light' | 'dark';
label: string;
}) {
const dark = tone === 'dark';
const style = {
borderColor: dark ? 'color-mix(in oklab, currentColor 35%, transparent)' : ITEM_BORDER,
backgroundColor: dark ? 'transparent' : ITEM_CARD,
color: 'inherit',
};
const stop = (fn: () => void) => (event: React.MouseEvent) => {
event.stopPropagation();
fn();
};
return (
<div className="flex justify-end gap-1.5">
<button
type="button"
className="flex size-8 items-center justify-center border transition-opacity hover:opacity-70"
style={style}
onClick={stop(onPrev)}
aria-label={`${label} 이전`}
>
<ChevronLeft className="size-4" />
</button>
<button
type="button"
className="flex size-8 items-center justify-center border transition-opacity hover:opacity-70"
style={style}
onClick={stop(onNext)}
aria-label={`${label} 다음`}
>
<ChevronRight className="size-4" />
</button>
</div>
);
}
/**
* 붙여넣을 JSON 이 아직 없을 때 — 어디로 가야 하는지 말해 준다.
*
* ★ 그냥 "준비 중"이라고 두면 사장님은 이 섹션이 자동으로 채워지는 줄 알고 기다린다.
* ★ 이건 발행되지 않는 **에디터 안내**다. 그래서 템플릿 색이 아니라 관리자 색을 그대로 쓴다.
*/
export function PasteHint({label}: {label: string}) {
return (
<div className="flex flex-col items-center gap-2 border border-dashed border-stone-400/70 bg-white/50 px-4 py-8 text-center">
<MousePointerClick className="size-4 text-stone-400" />
<p className="text-xs font-semibold text-stone-600">{label} 내용이 아직 없습니다</p>
<p className="max-w-[34ch] text-[11px] leading-relaxed text-stone-500">
오른쪽 [콘텐츠] 탭에서 프롬프트를 복사해 ChatGPT 에 넣고, 받은 JSON 을 붙여넣으면 바로 여기에 그려집니다.
</p>
</div>
);
}
/** 파싱이 깨졌을 때. 캔버스를 비우지 않고 왜 안 그려지는지 그 자리에 말한다(에디터 안내라 관리자 색). */
export function ParseError({message}: {message: string}) {
return (
<div className="border border-dashed border-red-400/70 bg-red-50/70 px-4 py-5 text-center">
<p className="text-xs font-semibold text-red-700">붙여넣은 JSON 을 읽지 못했습니다</p>
<p className="mt-1.5 text-[11px] leading-relaxed text-red-600">{message}</p>
</div>
);
}

View File

@ -0,0 +1,108 @@
/**
* 붙여넣기 아이템의 질감 — Tailwind 로는 못 그리는 것만 여기 둔다.
* 도넛판 홈, 종이 결, 톱니, 필름 구멍, 시험지 뒤집기.
*
* ★ 색은 전부 템플릿 토큰(--tpl-*)에서 받는다. 한때 갱지색·먹색을 hex 로 박아 뒀는데,
* 템플릿을 바꿔도 이 질감들만 레트로 색으로 남아 화면이 두 벌로 보였다.
*/
/* 종이 결 — 두 방향이 겹쳐야 종이로 읽힌다. 글자색을 옅게 깔아 어떤 팔레트에서도 결이 보인다. */
.w4-paper {
background-image:
repeating-linear-gradient(0deg, color-mix(in oklab, currentcolor 4%, transparent) 0 1px, transparent 1px 3px),
repeating-linear-gradient(90deg, color-mix(in oklab, currentcolor 3%, transparent) 0 1px, transparent 1px 4px);
}
/* 도넛판. --lbl 이 라벨 색이다. */
.w4-disc {
/* 판의 검정은 템플릿의 어두운 면(--tpl-inverse)이다. 홈은 그 위에 밝기 차로만 판다. */
background:
radial-gradient(circle at 50% 50%, transparent 0 15.5%, var(--lbl) 15.5% 33%, transparent 33%),
repeating-radial-gradient(
circle at 50% 50%,
color-mix(in oklab, var(--w4-vinyl) 88%, white) 0 1.4px,
var(--w4-vinyl) 1.4px 3px
),
var(--w4-vinyl);
box-shadow: inset 0 0 40px rgb(0 0 0 / 55%);
}
.w4-disc-sheen {
background: conic-gradient(
from 210deg,
rgb(255 255 255 / 12%),
transparent 22%,
transparent 70%,
rgb(255 255 255 / 7%)
);
}
.w4-spin {
animation: w4-rev 2.2s linear infinite;
}
@keyframes w4-rev {
to {
transform: rotate(360deg);
}
}
/* 미니 판 — 캐러셀에 늘어서는 작은 것. */
.w4-disc-mini {
background:
radial-gradient(circle at 50% 50%, transparent 0 14%, var(--lbl) 14% 34%, transparent 34%),
repeating-radial-gradient(
circle at 50% 50%,
color-mix(in oklab, var(--w4-vinyl) 88%, white) 0 1.2px,
var(--w4-vinyl) 1.2px 2.6px
),
var(--w4-vinyl);
box-shadow: 0 3px 10px rgb(0 0 0 / 35%);
}
/* 일력 톱니 — 뜯어낸 자국. --tear 는 섹션 바탕색이 들어온다(뜯긴 자리로 바탕이 비쳐야 한다). */
.w4-perf {
background: repeating-linear-gradient(90deg, transparent 0 8px, var(--tear) 8px 9px);
}
/* 승차권 절취선 */
.w4-dash {
border-top: 1px dashed currentcolor;
}
/* 필름 퍼포레이션 — 위아래 구멍이 있어야 한 장면이 아니라 '롤'로 읽힌다.
구멍은 currentColor(어두운 면 위의 글자색)로 뚫어 팔레트를 따른다. */
.w4-film-perf {
background: repeating-linear-gradient(90deg, currentcolor 0 9px, transparent 9px 21px);
}
/* 시험지 뒤집기. 카드가 얇아 보이지 않게 두 면을 같은 자리에 겹쳐 둔다. */
.w4-flip {
perspective: 900px;
}
.w4-flip-inner {
transform-style: preserve-3d;
transition: transform 0.5s;
}
.w4-flip-on .w4-flip-inner {
transform: rotateY(180deg);
}
.w4-flip-face {
backface-visibility: hidden;
}
.w4-flip-back {
transform: rotateY(180deg);
}
.w4-scroll {
scrollbar-width: none;
}
.w4-scroll::-webkit-scrollbar {
display: none;
}
@media (prefers-reduced-motion: reduce) {
.w4-spin {
animation: none;
}
.w4-flip-inner {
transition: none;
}
}

View File

@ -0,0 +1,149 @@
/**
* 문학 서가 — 책등 캐러셀 + 세로쓰기 펼침면.
*
* 책등이 가로로 흐르고, 하나를 고르면 그 책만 앞으로 나와 펼쳐진다.
* 펼친 면의 제목은 **세로쓰기**다 — 원문을 못 싣는 대신(DB_Guide: 원문 전재 금지)
* 활자의 결로 문학을 느끼게 하는 자리다.
*/
import {useState} from 'react';
import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type LiteratureItem} from '@o2o/shared';
import {
CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ITEM_INVERSE_INK,
ParseError,
PasteHint,
SourceLine,
useCarousel,
} from '../items/common';
import '../items/items.css';
/** 책등 색을 안 줬을 때. 템플릿 강조색으로 떨어뜨린다 — 색을 박으면 팔레트를 바꿔도 서가만 남는다. */
const FALLBACK_SPINE = ITEM_ACCENT;
export function LiteratureShelf(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<LiteratureItem>(section.type, section.data);
const [opened, setOpened] = useState(0);
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
// 항목이 줄어 인덱스가 범위를 벗어나도 첫 책으로 떨어진다 — 빈 화면을 만들지 않는다.
const current = parsed.items[opened] ?? parsed.items[0];
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="white">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : !current ? (
<PasteHint label="문학 서가" />
) : (
<div className="space-y-5">
{/* 서가 — 아래 선반이 있어야 책이 '꽂혀 있다'로 읽힌다 */}
<div className="space-y-2">
<div
ref={ref}
className="w4-scroll flex items-end gap-1.5 overflow-x-auto border-b-4 px-2 pt-4"
// 선반 널. 글자색을 섞어 만들면 어떤 팔레트에서도 '받치는 판'으로 읽힌다.
style={{borderColor: 'color-mix(in oklab, currentColor 45%, transparent)'}}
>
{parsed.items.map((book, index) => (
<button
key={`${book.workTitle}-${index}`}
type="button"
onClick={(event) => {
event.stopPropagation();
setOpened(index);
}}
aria-current={index === opened}
className="h-[184px] w-[40px] shrink-0 snap-center px-1 py-3 text-center transition-all"
style={{
backgroundColor: book.spineColor || FALLBACK_SPINE,
// 책등 색은 사장님이 정하므로 그 위 글자는 밝은 면 색으로 고정한다.
color: ITEM_INVERSE_INK,
// 고른 책만 한 칸 튀어나온다 — 뽑아 든 자리
transform: index === opened ? 'translateY(-12px)' : undefined,
boxShadow: index === opened ? '0 6px 14px rgba(27,26,21,.35)' : undefined,
}}
>
<span
className="mx-auto block h-full overflow-hidden text-[13px] leading-tight"
style={{fontFamily: ITEM_BODY, writingMode: 'vertical-rl'}}
>
{book.workTitle}
{book.author ? ` · ${book.author}` : ''}
</span>
</button>
))}
</div>
{parsed.items.length > 6 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} label="책" />
)}
</div>
{/* 펼친 면 */}
<div
className="w4-paper grid gap-5 border p-5 sm:p-6 md:grid-cols-[auto_minmax(0,1fr)]"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
>
<p
className="mx-auto h-fit w-fit max-h-[240px] overflow-hidden border-r pr-4 leading-tight md:mx-0"
style={{
fontFamily: ITEM_HEADING,
fontSize: 30,
writingMode: 'vertical-rl',
borderColor: ITEM_BORDER,
}}
>
{current.workTitle}
</p>
<div className="min-w-0 space-y-3">
<p className="text-xs opacity-60">
{[current.author, current.year, current.genre].filter(Boolean).join(' · ')}
</p>
{current.background && (
<p
className="text-sm leading-relaxed opacity-85"
style={{fontFamily: ITEM_BODY}}
>
{current.background}
</p>
)}
{current.whyHere && (
<p
className="border-l-2 pl-3 text-sm leading-relaxed opacity-75"
style={{fontFamily: ITEM_BODY, borderColor: ITEM_BORDER}}
>
{current.whyHere}
</p>
)}
<span className="inline-block border border-dashed px-2 py-1 text-[10px] opacity-60" style={{borderColor: ITEM_BORDER}}>
◎ 원문 대신 배경 — 작품 문장은 싣지 않습니다
</span>
<SourceLine source={current.source} verified={current.verified} />
</div>
</div>
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -0,0 +1,153 @@
/**
* 인물 열전 — 필름 스트립.
*
* 프레임 하나가 인물 하나다. 사진 자리에는 **활판 이니셜**이 들어간다 —
* 이 데이터에는 쓸 수 있는 인물 사진이 없고, 빈 회색 상자를 두면 "사진을 못 넣은 화면"으로 읽힌다.
* 이니셜을 넣으면 없는 것이 형식이 된다.
* ★ 이미지 URL 을 받지 않는다(dataSpec: imageQuery). 초상권 확인은 사장님 몫이라 검색어까지만 싣는다.
*/
import {useState} from 'react';
import {cn} from '@/lib/utils';
import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type PeopleItem} from '@o2o/shared';
import {
CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_HEADING,
ITEM_INVERSE,
ParseError,
PasteHint,
SourceLine,
useCarousel,
} from '../items/common';
import '../items/items.css';
/** 이니셜 한 글자. 성이 두 글자인 이름도 첫 자만 — 프레임이 좁아 두 글자는 안 읽힌다. */
function initialOf(name: string): string {
return name.trim().charAt(0) || '?';
}
export function PeopleFilmstrip(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<PeopleItem>(section.type, section.data);
const [picked, setPicked] = useState(0);
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
// 항목이 줄어 인덱스가 범위를 벗어나도 첫 사람으로 떨어진다 — 빈 화면을 만들지 않는다.
const current = parsed.items[picked] ?? parsed.items[0];
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="dark">
{/* ★ 어두운 면 위의 글자색을 여기서 한 번만. 필름 구멍(w4-film-perf)도 이 색을 따라간다. */}
<SectionBody width="wide" className="text-[color:var(--tpl-bg,#fff)]">
<div className="space-y-1.5">
<p className="text-[11px] tracking-[0.28em]" style={{color: ITEM_ACCENT}}>
PORTRAIT ROLL
</p>
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-55" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : !current ? (
<PasteHint label="인물 열전" />
) : (
<div className="space-y-4">
<div
className="border"
style={{
backgroundColor: ITEM_INVERSE,
borderColor: 'color-mix(in oklab, currentColor 22%, transparent)',
}}
>
<div className="w4-film-perf h-2.5 opacity-70" />
<div
ref={ref}
className="w4-scroll flex snap-x snap-mandatory gap-3 overflow-x-auto px-3 py-3"
>
{parsed.items.map((person, index) => (
<button
key={`${person.name}-${index}`}
type="button"
onClick={(event) => {
event.stopPropagation();
setPicked(index);
}}
aria-current={index === picked}
className={cn(
'w-[126px] shrink-0 snap-center text-left transition-all',
// 고른 프레임만 색이 돌아온다 — 필름을 손으로 짚은 자리.
index === picked ? 'opacity-100' : 'opacity-45 grayscale',
)}
>
<span
className="grid aspect-3/4 w-full place-items-center border"
style={{
// 프레임 안쪽은 필름보다 한 단 밝게 — 색을 박지 않고 글자색을 섞어 만든다.
backgroundColor: 'color-mix(in oklab, currentColor 14%, transparent)',
borderColor:
index === picked
? ITEM_ACCENT
: 'color-mix(in oklab, currentColor 28%, transparent)',
}}
>
<span className="leading-none" style={{fontFamily: ITEM_HEADING, fontSize: 46}}>
{initialOf(person.name)}
</span>
</span>
<span className="mt-2 block truncate text-xs opacity-95">{person.name}</span>
<span className="block truncate text-[10px] opacity-60">
{[person.role, person.years].filter(Boolean).join(' · ') || '역할 미상'}
</span>
</button>
))}
</div>
<div className="w4-film-perf h-2.5 opacity-70" />
</div>
{parsed.items.length > 4 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} tone="dark" label="인물" />
)}
{/* 고른 프레임의 자막 — 프레임 안에 넣으면 이름조차 안 읽힌다 */}
<div className="space-y-2 border-l-2 pl-4" style={{borderColor: ITEM_ACCENT}}>
<h3 className="text-xl" style={{fontFamily: ITEM_HEADING}}>
{current.name}
{current.aka && <span className="ml-2 text-sm opacity-55">호 {current.aka}</span>}
</h3>
<p className="text-xs opacity-55">
{[current.role, current.years].filter(Boolean).join(' · ')}
</p>
{current.oneLine && (
<p
className="max-w-[52ch] text-sm leading-relaxed opacity-80"
style={{fontFamily: ITEM_BODY}}
>
{current.oneLine}
</p>
)}
{current.imageQuery && (
<p className="text-[10px] opacity-60">사진 검색어 · {current.imageQuery}</p>
)}
<SourceLine source={current.source} verified={current.verified} tone="dark" />
</div>
<p className="text-[11px] opacity-60">
사진이 없는 인물은 이름 활자로 대신합니다 · 총 {parsed.items.length}명
{parsed.unverified > 0 && ` · 확인 필요 ${parsed.unverified}명`}
</p>
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -0,0 +1,218 @@
/**
* 계절별 추천 하루 — 계절 탭 + 순위 카드.
*
* ★ 여행 스케줄(schedule)과 축이 다르다. 저쪽은 사장님이 시각을 적어 둔 시간표고,
* 여기는 **시각을 계산해 준다** — 사장님은 "몇 분 걸리나"만 적고, 출발 시각을 바꾸면 하루가 밀린다.
* 조립 규칙은 `@o2o/shared` 의 planDay 한 벌이다(빌더와 발행본이 같은 시각을 내야 한다).
* ★ 순위는 셋에서 끊는다. 넷째부터는 추천이 아니라 목록이 된다.
* ★ 손님 화면에는 **지금 계절만** 나간다(간절기에는 둘). 여기 탭은 사장님이 나머지 계절을
* 확인하려고 있는 것이라, 처음 열면 지금 계절에 맞춰 두고 그 사실을 아래에 적어 둔다 —
* 안 적으면 사장님은 손님도 네 계절을 다 본다고 오해한다.
*/
import {useMemo, useState} from 'react';
import {cn} from '@/lib/utils';
import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {
currentSeasons,
parseSectionData,
planDay,
plannerSeasons,
plannerTop,
type PlannerItem,
} from '@o2o/shared';
import {
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ITEM_INK,
ITEM_INVERSE_INK,
ParseError,
PasteHint,
SourceLine,
} from '../items/common';
import '../items/items.css';
const RANK_LABEL = ['1위', '2위', '3위'];
/** 총 소요를 "7시간 10분"으로. 분만 쓰면 430분이 얼마인지 아무도 모른다. */
function spanText(minutes: number): string {
const hour = Math.floor(minutes / 60);
const rest = minutes % 60;
return [hour > 0 ? `${hour}시간` : '', rest > 0 ? `${rest}분` : ''].filter(Boolean).join(' ') || '0분';
}
function PlanCard({item, rank}: {item: PlannerItem; rank: number}) {
const day = planDay(item);
return (
<article
className="w4-paper w-[320px] shrink-0 snap-center border shadow-[4px_4px_0_rgba(0,0,0,.08)]"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
>
<div
className="flex items-center justify-between gap-2 border-b px-4 py-2.5"
style={{borderColor: ITEM_BORDER}}
>
{/* ★ 1위만 채운다. 셋 다 채우면 순위가 안 읽히고, 채움색을 강조색으로 두면
팔레트에 따라 글자가 안 보인다(연한 accent 위의 밝은 글자) — 글자색으로 채운다. */}
<span
className="border px-2 py-0.5 text-[11px] font-bold"
style={
rank === 0
? {backgroundColor: ITEM_INK, color: ITEM_INVERSE_INK, borderColor: ITEM_INK}
: {borderColor: ITEM_BORDER}
}
>
{RANK_LABEL[rank] ?? `${rank + 1}위`}
</span>
<span className="text-[11px] opacity-60">
{day.from}–{day.to} · {spanText(day.totalMinutes)}
</span>
</div>
<div className="space-y-1.5 px-4 pt-3.5">
<h4 className="text-lg" style={{fontFamily: ITEM_HEADING}}>
{item.name}
</h4>
{item.audience && <p className="text-[11px] opacity-60">{item.audience}</p>}
{item.why && (
<p className="text-[13px] leading-relaxed opacity-80" style={{fontFamily: ITEM_BODY}}>
{item.why}
</p>
)}
</div>
{day.stops.length === 0 ? (
<p className="px-4 py-5 text-center text-[11px] opacity-60">
정거장이 아직 없습니다. JSON 의 stops 배열을 채워 주세요.
</p>
) : (
<ol className="mt-3 px-4 pb-3">
{day.stops.map((planned, index) => (
<li key={`${planned.stop.name}-${index}`} className="grid grid-cols-[46px_minmax(0,1fr)] gap-2.5">
{/* 시각 열 — 왼쪽에 붙어 정렬돼야 '시간표'로 읽힌다 */}
<span className="pt-2 text-[12px] tabular-nums opacity-75" style={{fontFamily: ITEM_HEADING}}>
{planned.time}
</span>
<div className="border-l pb-2 pl-3" style={{borderColor: ITEM_BORDER}}>
{planned.move > 0 && (
<p className="pt-1 text-[10px] opacity-50">↓ {planned.move}분 이동</p>
)}
<p className="pt-1 text-sm font-bold" style={{fontFamily: ITEM_BODY}}>
{planned.stop.name}
</p>
{planned.stop.note && (
<p className="mt-0.5 text-[12px] leading-relaxed opacity-75" style={{fontFamily: ITEM_BODY}}>
{planned.stop.note}
</p>
)}
<p className="mt-0.5 text-[10px] opacity-50">
{planned.time}–{planned.until}
{planned.stop.searchQuery && ` · 지도 검색 ${planned.stop.searchQuery}`}
</p>
</div>
</li>
))}
</ol>
)}
<div className="space-y-1 border-t border-dashed px-4 py-2.5" style={{borderColor: ITEM_BORDER}}>
{/* ★ 상한에 걸려 뺀 칸을 숨기지 않는다. 숨기면 사장님은 자기가 적은 곳이 왜 없는지 모른다. */}
{day.dropped > 0 && (
<p className="text-[10px]" style={{color: ITEM_ACCENT}}>
밤 9시를 넘겨 {day.dropped}곳을 뺐습니다 — 머무는 시간을 줄이거나 출발을 당겨 보세요.
</p>
)}
<SourceLine source={item.source} verified={item.verified} />
</div>
</article>
);
}
export function PlannerPodium(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<PlannerItem>(section.type, section.data);
const seasons = useMemo(() => plannerSeasons(parsed.items), [parsed.items]);
// 지금 계절(간절기면 둘 중 앞선 것)을 처음 탭으로. 손님이 보는 것과 같은 화면에서 시작한다.
const live = useMemo(() => currentSeasons().filter((s) => seasons.includes(s)), [seasons]);
const [picked, setPicked] = useState<number>();
const index = picked ?? Math.max(0, seasons.indexOf(live[0] ?? ''));
// 계절을 안 적었으면 탭 없이 전체에서 top3 를 뽑는다 — 빈 탭 줄을 그리지 않는다.
const season = seasons[index] ?? seasons[0];
const top = useMemo(() => plannerTop(parsed.items, season), [parsed.items, season]);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : parsed.items.length === 0 ? (
<PasteHint label="계절별 추천 하루" />
) : (
<div className="space-y-4">
{seasons.length > 1 && (
<div className="flex flex-wrap gap-1.5">
{seasons.map((name, tabIndex) => (
<button
key={name}
type="button"
onClick={(event) => {
event.stopPropagation();
setPicked(tabIndex);
}}
aria-current={tabIndex === index}
className={cn(
'border px-3 py-1 text-xs transition-opacity',
tabIndex !== index && 'opacity-55',
)}
style={
tabIndex === index
? {backgroundColor: ITEM_INK, color: ITEM_INVERSE_INK, borderColor: ITEM_INK}
: {borderColor: ITEM_BORDER}
}
>
{name}
{/* 손님 화면에 지금 나가는 계절. 사장님이 '어느 게 실제로 보이나'를 눈으로 안다. */}
{live.includes(name) && <span className="ml-1 opacity-70">·지금</span>}
</button>
))}
</div>
)}
<div className="w4-scroll flex snap-x snap-mandatory items-start gap-4 overflow-x-auto pb-3">
{top.map((item, rank) => (
<PlanCard key={`${item.name}-${rank}`} item={item} rank={rank} />
))}
</div>
<p className="text-[11px] opacity-60">
시각은 출발 시각과 머무는 시간으로 계산한 것입니다 · {season ? `${season} 추천 ` : '추천 '}
{top.length}개 / 전체 {parsed.items.length}개
</p>
{live.length > 0 && (
<p className="text-[11px] opacity-60">
손님 화면에는 지금 계절({live.join(' · ')})만 나갑니다. 간절기에는 두 계절이 함께 보입니다.
</p>
)}
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -0,0 +1,141 @@
/**
* 오늘의 엽서 — 엽서 뒷면 더미.
*
* 앞면(사진)은 없다. 쓸 수 있는 사진이 없기도 하고, 이 아이템의 목적이 **손님이 자기 계정에
* 붙여넣는 것**이라 글이 놓이는 뒷면이 전부다. 우표 자리·소인·가운데 괘선이 그 형식을 만든다.
*/
import {useState} from 'react';
import {Check, Copy} from 'lucide-react';
import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type PostcardItem} from '@o2o/shared';
import {
CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ITEM_INK,
ParseError,
PasteHint,
SourceLine,
useCarousel,
} from '../items/common';
import '../items/items.css';
function Postcard({item}: {item: PostcardItem}) {
const [copied, setCopied] = useState(false);
const text = [item.line, (item.hashtags ?? []).join(' ')].filter(Boolean).join(' ');
const copy = (event: React.MouseEvent) => {
event.stopPropagation();
navigator.clipboard.writeText(text).then(
() => {
setCopied(true);
setTimeout(() => setCopied(false), 1600);
},
() => {
// 클립보드가 막힌 브라우저 — 직접 긁어 갈 수 있게 띄운다.
window.prompt('아래 문장을 복사하세요', text);
},
);
};
return (
<article
className="w4-paper w-[304px] shrink-0 snap-center border p-4 shadow-[4px_4px_0_rgba(27,26,21,.13)]"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_INK}}
>
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3">
{/* 가운데 괘선 — 엽서 뒷면을 반으로 가르는 그 선 */}
<div className="min-w-0 border-r pr-3" style={{borderColor: ITEM_BORDER}}>
<p className="text-[19px] leading-snug" style={{fontFamily: ITEM_BODY}}>
“{item.line}”
</p>
{item.hashtags && item.hashtags.length > 0 && (
<p className="mt-2 text-[11px] opacity-60">{item.hashtags.join(' ')}</p>
)}
</div>
<div className="flex w-[62px] flex-col items-center gap-3">
<span
className="grid h-[56px] w-[44px] place-items-center border border-dashed text-center text-[9px] leading-tight opacity-60"
style={{borderColor: ITEM_BORDER}}
>
郵票
<br />
10원
</span>
<span
className="grid size-[54px] -rotate-6 place-items-center rounded-full border-2 px-1 text-center text-[9px] leading-tight"
style={{fontFamily: ITEM_HEADING, borderColor: ITEM_ACCENT, color: ITEM_ACCENT}}
>
{item.postmark || item.place || '소인'}
</span>
</div>
</div>
<div
className="mt-3 flex items-center justify-between gap-2 border-t border-dashed pt-2.5"
style={{borderColor: ITEM_BORDER}}
>
<SourceLine source={item.source} verified={item.verified} />
<button
type="button"
onClick={copy}
className="flex shrink-0 items-center gap-1 border px-2 py-1 text-[10px] opacity-75 transition-opacity hover:opacity-100"
style={{borderColor: ITEM_BORDER}}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
{copied ? '복사됨' : '복사'}
</button>
</div>
</article>
);
}
export function PostcardStack(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<PostcardItem>(section.type, section.data);
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : parsed.items.length === 0 ? (
<PasteHint label="오늘의 엽서" />
) : (
<div className="space-y-3">
<div ref={ref} className="w4-scroll flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3">
{parsed.items.map((item, index) => (
<Postcard key={`${item.line}-${index}`} item={item} />
))}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[11px] opacity-60">
[복사] 를 누르면 문장과 해시태그가 함께 복사됩니다 · 총 {parsed.items.length}장
</p>
{parsed.items.length > 2 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} label="엽서" />
)}
</div>
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -0,0 +1,128 @@
/**
* 뒤집어 보는 질문 — 갱지 시험지 플립.
*
* 앞면은 질문 하나, 뒤집으면 힌트와 출처가 나온다.
* ★ 정답을 단정하지 않는다(dataSpec: answer 필드 없음). 이 데이터는 검증되지 않은 줄이 더 많아서
* 정답을 걸면 틀린 걸 확신하는 화면이 된다. 힌트까지가 우리가 책임질 수 있는 선이다.
*/
import {useState} from 'react';
import {cn} from '@/lib/utils';
import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type QuizItem} from '@o2o/shared';
import {
CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ITEM_INK,
ParseError,
PasteHint,
SourceLine,
useCarousel,
} from '../items/common';
import '../items/items.css';
function QuizCard({item, no}: {item: QuizItem; no: number}) {
const [flipped, setFlipped] = useState(false);
const face = 'w4-flip-face absolute inset-0 flex flex-col justify-between border p-4';
const faceStyle = {backgroundColor: ITEM_CARD, borderColor: ITEM_INK};
return (
<div
role="button"
tabIndex={0}
aria-pressed={flipped}
onClick={(event) => {
event.stopPropagation();
setFlipped((v) => !v);
}}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
event.stopPropagation();
setFlipped((v) => !v);
}}
className={cn('w4-flip h-[216px] w-[262px] shrink-0 cursor-pointer snap-center', flipped && 'w4-flip-on')}
>
<div className="w4-flip-inner relative size-full">
<div className={cn(face, 'w4-paper')} style={faceStyle}>
<p className="text-[10px] tracking-[0.16em] opacity-60">
문제 {no}
{item.level ? ` · ${item.level}` : ''}
</p>
<p className="text-[17px] leading-snug" style={{fontFamily: ITEM_BODY}}>
{item.question}
</p>
<p className="text-[10px]" style={{color: ITEM_ACCENT}}>
뒤집기 →
</p>
</div>
<div className={cn(face, 'w4-flip-back w4-paper')} style={faceStyle}>
<p className="text-[10px] tracking-[0.16em]" style={{color: ITEM_ACCENT}}>
힌트
</p>
{item.hint ? (
<p className="text-[13px] leading-relaxed opacity-85" style={{fontFamily: ITEM_BODY}}>
{item.hint}
</p>
) : (
<p className="text-[13px] opacity-60">힌트가 아직 없습니다.</p>
)}
<div className="space-y-1 border-t border-dashed pt-2" style={{borderColor: ITEM_BORDER}}>
{item.topic && <p className="text-[10px] opacity-60">{item.topic}</p>}
<SourceLine source={item.source} verified={item.verified} />
</div>
</div>
</div>
</div>
);
}
export function QuizFlip(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<QuizItem>(section.type, section.data);
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : parsed.items.length === 0 ? (
<PasteHint label="뒤집어 보는 질문" />
) : (
<div className="space-y-3">
<div ref={ref} className="w4-scroll flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3">
{parsed.items.map((item, index) => (
<QuizCard key={`${item.question}-${index}`} item={item} no={index + 1} />
))}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[11px] opacity-60">
정답은 두지 않습니다 — 힌트와 출처까지만 · 총 {parsed.items.length}문항
</p>
{parsed.items.length > 2 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} label="질문" />
)}
</div>
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -1,153 +0,0 @@
/**
* 7080 아이템이 공유하는 조각 — 서체 스택, 출처 각주, 캐러셀 화살표.
*
* 세 아이템이 각자 만들면 같은 각주가 세 모양이 된다. 출처 표기는 이 레포의 규약이라 특히 갈리면 안 된다.
*/
import {useCallback, useRef} from 'react';
import {ChevronLeft, ChevronRight, MousePointerClick} from 'lucide-react';
import {cn} from '@/lib/utils';
import type {DataSource, DataVerified} from '../../dataSpec';
/** 옛 간판체. 큰 글자에만 쓴다 — 본문에 쓰면 읽히지 않는다. */
export const RETRO_SIGN = "'Gugi', 'Noto Sans KR', sans-serif";
/** 본문 명조. */
export const RETRO_BODY = "'Gowun Batang', 'Noto Serif KR', serif";
/** 손글씨. 엽서·주석 한 줄에만. */
export const RETRO_HAND = "'Nanum Pen Script', 'Gowun Batang', cursive";
export const RETRO_PAPER = '#e4dac0';
export const RETRO_PAPER_LIGHT = '#f2ebd9';
export const RETRO_INK = '#1b1a15';
export const RETRO_INK_SOFT = '#4c4739';
export const RETRO_LINE = '#c0b493';
export const RETRO_RED = '#bf2f1b';
/**
* 출처 한 줄 + 확신 배지.
*
* ★ 확인되지 않은 값을 숨기지 않고 **드러낸다.** 숨기면 사장님이 그게 미검증인 줄 모르고 발행한다.
*/
export function SourceLine({
source,
verified,
tone = 'light',
}: {
source?: DataSource;
verified?: DataVerified;
tone?: 'light' | 'dark';
}) {
const dark = tone === 'dark';
if (!source && !verified) return null;
return (
<p
className={cn(
'flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px]',
dark ? 'text-stone-400' : 'text-stone-500',
)}
>
{verified && (
<span
className="border px-1.5 py-px"
style={{
color: verified === '확인' ? '#2a6053' : '#b07d10',
borderColor: 'currentColor',
}}
>
{verified}
</span>
)}
{source?.name && (
<span>
출처 ·{' '}
{source.url ? (
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
className="underline underline-offset-2"
>
{source.name}
</a>
) : (
source.name
)}
</span>
)}
</p>
);
}
/** 가로 스크롤 + 화살표. 스크롤 컨테이너를 ref 로 잡아 한 화면의 80%씩 민다. */
export function useCarousel<T extends HTMLElement>() {
const ref = useRef<T>(null);
const scrollBy = useCallback((dir: -1 | 1) => {
const box = ref.current;
if (!box) return;
box.scrollBy({left: box.clientWidth * 0.8 * dir, behavior: 'smooth'});
}, []);
return {ref, scrollBy};
}
export function CarouselNav({
onPrev,
onNext,
tone = 'light',
label,
}: {
onPrev: () => void;
onNext: () => void;
tone?: 'light' | 'dark';
label: string;
}) {
const dark = tone === 'dark';
const base = cn(
'flex size-8 items-center justify-center border transition-colors',
dark
? 'border-stone-600 bg-stone-800/60 text-stone-300 hover:bg-stone-700'
: 'border-stone-400 bg-white/70 text-stone-700 hover:bg-stone-900 hover:text-white',
);
const stop = (fn: () => void) => (event: React.MouseEvent) => {
event.stopPropagation();
fn();
};
return (
<div className="flex justify-end gap-1.5">
<button type="button" className={base} onClick={stop(onPrev)} aria-label={`${label} 이전`}>
<ChevronLeft className="size-4" />
</button>
<button type="button" className={base} onClick={stop(onNext)} aria-label={`${label} 다음`}>
<ChevronRight className="size-4" />
</button>
</div>
);
}
/**
* 붙여넣을 JSON 이 아직 없을 때 — 어디로 가야 하는지 말해 준다.
*
* ★ 그냥 "준비 중"이라고 두면 사장님은 이 섹션이 자동으로 채워지는 줄 알고 기다린다.
*/
export function PasteHint({label}: {label: string}) {
return (
<div className="flex flex-col items-center gap-2 border border-dashed border-stone-400/70 bg-white/50 px-4 py-8 text-center">
<MousePointerClick className="size-4 text-stone-400" />
<p className="text-xs font-semibold text-stone-600">{label} 내용이 아직 없습니다</p>
<p className="max-w-[34ch] text-[11px] leading-relaxed text-stone-500">
오른쪽 [콘텐츠] 탭에서 프롬프트를 복사해 ChatGPT 에 넣고, 받은 JSON 을 붙여넣으면 바로 여기에 그려집니다.
</p>
</div>
);
}
/** 파싱이 깨졌을 때. 캔버스를 비우지 않고 왜 안 그려지는지 그 자리에 말한다. */
export function ParseError({message}: {message: string}) {
return (
<div className="border border-dashed border-red-400/70 bg-red-50/70 px-4 py-5 text-center">
<p className="text-xs font-semibold text-red-700">붙여넣은 JSON 을 읽지 못했습니다</p>
<p className="mt-1.5 text-[11px] leading-relaxed text-red-600">{message}</p>
</div>
);
}

View File

@ -1,69 +0,0 @@
/**
* 7080 아이템 전용 질감 — Tailwind 로는 못 그리는 것만 여기 둔다.
* 도넛판 홈, 갱지 결, 절취선, 펀치 구멍. 색은 전부 인라인 변수로 받아 템플릿 팔레트를 따른다.
*/
/* 갱지 — 두 방향 결이 겹쳐야 종이로 읽힌다. */
.w4-paper {
background-image:
repeating-linear-gradient(0deg, rgb(27 26 21 / 3%) 0 1px, transparent 1px 3px),
repeating-linear-gradient(90deg, rgb(27 26 21 / 2%) 0 1px, transparent 1px 4px);
}
/* 도넛판. --lbl 이 라벨 색이다. */
.w4-disc {
background:
radial-gradient(circle at 50% 50%, transparent 0 15.5%, var(--lbl, #d4551f) 15.5% 33%, transparent 33%),
repeating-radial-gradient(circle at 50% 50%, #1a1812 0 1.4px, #15140f 1.4px 3px),
#15140f;
box-shadow: 0 0 0 1px #000, inset 0 0 40px rgb(0 0 0 / 80%);
}
.w4-disc-sheen {
background: conic-gradient(
from 210deg,
rgb(255 255 255 / 12%),
transparent 22%,
transparent 70%,
rgb(255 255 255 / 7%)
);
}
.w4-spin {
animation: w4-rev 2.2s linear infinite;
}
@keyframes w4-rev {
to {
transform: rotate(360deg);
}
}
/* 미니 판 — 캐러셀에 늘어서는 작은 것. */
.w4-disc-mini {
background:
radial-gradient(circle at 50% 50%, transparent 0 14%, var(--lbl, #d4551f) 14% 34%, transparent 34%),
repeating-radial-gradient(circle at 50% 50%, #1a1812 0 1.2px, #15140f 1.2px 2.6px),
#15140f;
box-shadow: 0 3px 10px rgb(0 0 0 / 45%);
}
/* 일력 톱니 — 뜯어낸 자국. */
.w4-perf {
background: repeating-linear-gradient(90deg, transparent 0 8px, var(--tear, #e4dac0) 8px 9px);
}
/* 승차권 절취선 */
.w4-dash {
border-top: 1px dashed currentcolor;
}
.w4-scroll {
scrollbar-width: none;
}
.w4-scroll::-webkit-scrollbar {
display: none;
}
@media (prefers-reduced-motion: reduce) {
.w4-spin {
animation: none;
}
}

View File

@ -8,21 +8,23 @@
*/ */
import {SectionBody, SectionFrame} from '../../primitives'; import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types'; import type {SectionRenderProps} from '../../types';
import {parseSectionData, type ScheduleItem, type ScheduleSlot} from '../../dataSpec'; import {parseSectionData, type ScheduleItem, type ScheduleSlot} from '@o2o/shared';
import { import {
CarouselNav, CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_BORDER,
ITEM_CARD,
ITEM_HEADING,
ITEM_INK,
ITEM_INVERSE,
ITEM_INVERSE_INK,
ParseError, ParseError,
PasteHint, PasteHint,
RETRO_BODY,
RETRO_INK,
RETRO_LINE,
RETRO_PAPER_LIGHT,
RETRO_RED,
RETRO_SIGN,
SourceLine, SourceLine,
useCarousel, useCarousel,
} from '../retro/common'; } from '../items/common';
import '../retro/retro.css'; import '../items/items.css';
/** "09:30" → 9시 30분. 형식이 어긋나면 원문을 그대로 보여준다(지어내지 않는다). */ /** "09:30" → 9시 30분. 형식이 어긋나면 원문을 그대로 보여준다(지어내지 않는다). */
function splitTime(time: string): {head: string; tail?: string} { function splitTime(time: string): {head: string; tail?: string} {
@ -38,43 +40,47 @@ function Slot({slot, isLast}: {slot: ScheduleSlot; isLast: boolean}) {
<div className="flex shrink-0 snap-center items-stretch"> <div className="flex shrink-0 snap-center items-stretch">
<div <div
className="w-[228px] border shadow-[4px_4px_0_rgba(27,26,21,.13)]" className="w-[228px] border shadow-[4px_4px_0_rgba(27,26,21,.13)]"
style={{backgroundColor: RETRO_PAPER_LIGHT, borderColor: RETRO_INK}} style={{backgroundColor: ITEM_CARD, borderColor: ITEM_INK}}
> >
{/* 플립보드 — 가운데 접힘선이 이 판을 시계로 만든다 */} {/* 플립보드 — 가운데 접힘선이 이 판을 시계로 만든다 */}
<div className="relative px-4 py-3 text-center" style={{backgroundColor: RETRO_INK}}> {/* 플립보드는 템플릿의 어두운 면이다. 위의 글자는 밝은 면 색을 그대로 쓴다. */}
<div
className="relative px-4 py-3 text-center"
style={{backgroundColor: ITEM_INVERSE, color: ITEM_INVERSE_INK}}
>
<span <span
className="inline-flex items-baseline gap-1 leading-none text-[#f2ebd9]" className="inline-flex items-baseline gap-1 leading-none"
style={{fontFamily: RETRO_SIGN, fontSize: 30}} style={{fontFamily: ITEM_HEADING, fontSize: 30}}
> >
{head} {head}
{tail && <span className="text-[#c0b493]">:{tail}</span>} {tail && <span className="opacity-60">:{tail}</span>}
</span> </span>
<i <i
aria-hidden aria-hidden
className="pointer-events-none absolute inset-x-0 top-1/2 border-t" className="pointer-events-none absolute inset-x-0 top-1/2 border-t"
style={{borderColor: 'rgba(242,235,217,.22)'}} style={{borderColor: 'color-mix(in oklab, currentColor 25%, transparent)'}}
/> />
</div> </div>
<div className="space-y-2 px-4 pb-3 pt-3.5"> <div className="space-y-2 px-4 pb-3 pt-3.5">
<h4 className="text-base font-bold text-stone-900" style={{fontFamily: RETRO_BODY}}> <h4 className="text-base font-bold" style={{fontFamily: ITEM_BODY}}>
{slot.title} {slot.title}
</h4> </h4>
{slot.place && ( {slot.place && (
<p className="text-[12px] font-semibold" style={{fontFamily: RETRO_BODY, color: RETRO_RED}}> <p className="text-[12px] font-semibold" style={{fontFamily: ITEM_BODY, color: ITEM_ACCENT}}>
{slot.place} {slot.place}
</p> </p>
)} )}
{slot.note && ( {slot.note && (
<p className="text-[13px] leading-relaxed text-stone-600" style={{fontFamily: RETRO_BODY}}> <p className="text-[13px] leading-relaxed opacity-75" style={{fontFamily: ITEM_BODY}}>
{slot.note} {slot.note}
</p> </p>
)} )}
</div> </div>
<div <div
className="flex justify-between gap-2 border-t border-dashed px-4 py-2 text-[10px] text-stone-500" className="flex justify-between gap-2 border-t border-dashed px-4 py-2 text-[10px] opacity-60"
style={{borderColor: RETRO_LINE}} style={{borderColor: ITEM_BORDER}}
> >
<span>{slot.minutes ? `${slot.minutes}분 머묾` : '머무는 시간 미정'}</span> <span>{slot.minutes ? `${slot.minutes}분 머묾` : '머무는 시간 미정'}</span>
{slot.searchQuery && <span className="truncate">지도 검색 · {slot.searchQuery}</span>} {slot.searchQuery && <span className="truncate">지도 검색 · {slot.searchQuery}</span>}
@ -84,7 +90,7 @@ function Slot({slot, isLast}: {slot: ScheduleSlot; isLast: boolean}) {
{/* 칸과 칸 사이의 시간 — 점선이 이어져야 '흐른다'로 읽힌다 */} {/* 칸과 칸 사이의 시간 — 점선이 이어져야 '흐른다'로 읽힌다 */}
{!isLast && ( {!isLast && (
<div aria-hidden className="flex w-8 items-center justify-center"> <div aria-hidden className="flex w-8 items-center justify-center">
<i className="block h-px w-full border-t border-dashed" style={{borderColor: RETRO_LINE}} /> <i className="block h-px w-full border-t border-dashed" style={{borderColor: ITEM_BORDER}} />
</div> </div>
)} )}
</div> </div>
@ -101,10 +107,10 @@ function ScheduleRow({schedule}: {schedule: ScheduleItem}) {
<div className="space-y-2.5"> <div className="space-y-2.5">
<div className="flex flex-wrap items-baseline justify-between gap-2"> <div className="flex flex-wrap items-baseline justify-between gap-2">
<div className="flex flex-wrap items-baseline gap-2.5"> <div className="flex flex-wrap items-baseline gap-2.5">
<h3 className="text-lg text-stone-900" style={{fontFamily: RETRO_SIGN}}> <h3 className="text-lg" style={{fontFamily: ITEM_HEADING}}>
{schedule.name} {schedule.name}
</h3> </h3>
<span className="text-[11px] text-stone-500"> <span className="text-[11px] opacity-60">
{[schedule.audience, schedule.season, span, `${slots.length}칸`] {[schedule.audience, schedule.season, span, `${slots.length}칸`]
.filter(Boolean) .filter(Boolean)
.join(' · ')} .join(' · ')}
@ -117,8 +123,8 @@ function ScheduleRow({schedule}: {schedule: ScheduleItem}) {
{slots.length === 0 ? ( {slots.length === 0 ? (
<p <p
className="border border-dashed px-4 py-5 text-center text-[11px] text-stone-500" className="border border-dashed px-4 py-5 text-center text-[11px] opacity-60"
style={{borderColor: RETRO_LINE}} style={{borderColor: ITEM_BORDER}}
> >
시간대가 아직 없습니다. JSON 의 slots 배열을 채워 주세요. 시간대가 아직 없습니다. JSON 의 slots 배열을 채워 주세요.
</p> </p>
@ -147,11 +153,11 @@ export function ScheduleTimetable(props: SectionRenderProps) {
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint"> <SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint">
<SectionBody width="wide"> <SectionBody width="wide">
<div className="space-y-1.5"> <div className="space-y-1.5">
<h2 className="text-2xl text-stone-900 sm:text-3xl" style={{fontFamily: RETRO_SIGN}}> <h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name} {parsed.title || section.name}
</h2> </h2>
{(parsed.subtitle || section.description) && ( {(parsed.subtitle || section.description) && (
<p className="text-sm text-stone-600" style={{fontFamily: RETRO_BODY}}> <p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description} {parsed.subtitle || section.description}
</p> </p>
)} )}

View File

@ -7,19 +7,22 @@
import {useState} from 'react'; import {useState} from 'react';
import {SectionBody, SectionFrame} from '../../primitives'; import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types'; import type {SectionRenderProps} from '../../types';
import {parseSectionData, type SongItem} from '../../dataSpec'; import {parseSectionData, type SongItem} from '@o2o/shared';
import { import {
CarouselNav, CarouselNav,
ITEM_ACCENT,
ITEM_BODY,
ITEM_HEADING,
ITEM_INVERSE,
ParseError, ParseError,
PasteHint, PasteHint,
RETRO_BODY,
RETRO_SIGN,
SourceLine, SourceLine,
useCarousel, useCarousel,
} from '../retro/common'; } from '../items/common';
import '../retro/retro.css'; import '../items/items.css';
const FALLBACK_LABEL = '#d4551f'; /** 곡이 라벨 색을 안 주면 템플릿 강조색이 라벨이 된다 — hex 를 박으면 팔레트를 바꿔도 판만 남는다. */
const FALLBACK_LABEL = ITEM_ACCENT;
export function SongsTurntable(props: SectionRenderProps) { export function SongsTurntable(props: SectionRenderProps) {
const {section, isSelected, onSelect, template} = props; const {section, isSelected, onSelect, template} = props;
@ -32,14 +35,18 @@ export function SongsTurntable(props: SectionRenderProps) {
return ( return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="dark"> <SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="dark">
<SectionBody width="wide"> {/* ★ 어두운 면 위의 글자색을 여기서 한 번만 정한다. 아래는 전부 currentColor·opacity 로 단을 만든다 —
자식마다 색을 박으면 템플릿을 바꿨을 때 한두 곳이 옛 색으로 남는다. */}
<SectionBody width="wide" className="text-[color:var(--tpl-bg,#fff)]">
<div className="space-y-1.5"> <div className="space-y-1.5">
<p className="text-[11px] tracking-[0.28em] text-amber-500/90">33⅓ RPM</p> <p className="text-[11px] tracking-[0.28em]" style={{color: ITEM_ACCENT}}>
<h2 className="text-2xl text-stone-50 sm:text-3xl" style={{fontFamily: RETRO_SIGN}}> 33⅓ RPM
</p>
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{parsed.title || section.name} {parsed.title || section.name}
</h2> </h2>
{(parsed.subtitle || section.description) && ( {(parsed.subtitle || section.description) && (
<p className="text-sm text-stone-400" style={{fontFamily: RETRO_BODY}}> <p className="text-sm opacity-55" style={{fontFamily: ITEM_BODY}}>
{parsed.subtitle || section.description} {parsed.subtitle || section.description}
</p> </p>
)} )}
@ -51,34 +58,57 @@ export function SongsTurntable(props: SectionRenderProps) {
<PasteHint label="가요 다방" /> <PasteHint label="가요 다방" />
) : ( ) : (
<> <>
<div className="grid items-center gap-8 border border-stone-700/60 bg-linear-160 from-stone-800 to-stone-900 p-6 sm:p-8 md:grid-cols-[260px_minmax(0,1fr)]"> <div className="grid items-center gap-8 border p-6 sm:p-8 md:grid-cols-[260px_minmax(0,1fr)]"
style={{
backgroundColor: ITEM_INVERSE,
borderColor: 'color-mix(in oklab, currentColor 22%, transparent)',
}}>
{/* 턴테이블 */} {/* 턴테이블 */}
<div className="relative mx-auto size-[240px]"> <div className="relative mx-auto size-[240px]">
<div className="absolute inset-0 rounded-full bg-radial from-stone-700 from-32% to-stone-800 shadow-[0_10px_26px_rgba(0,0,0,.5)]" /> {/* 플래터 — 판 아래 깔리는 원반. 어두운 면 위에 글자색을 옅게 얹어 단을 만든다. */}
<div
className="absolute inset-0 rounded-full shadow-[0_10px_26px_rgba(0,0,0,.45)]"
style={{backgroundColor: 'color-mix(in oklab, currentColor 16%, transparent)'}}
/>
<div <div
className="w4-disc w4-spin absolute inset-2 rounded-full" className="w4-disc w4-spin absolute inset-2 rounded-full"
style={{['--lbl' as string]: current.labelColor || FALLBACK_LABEL}} style={{
['--lbl' as string]: current.labelColor || FALLBACK_LABEL,
['--w4-vinyl' as string]: ITEM_INVERSE,
}}
> >
<div className="w4-disc-sheen absolute inset-0 rounded-full" /> <div className="w4-disc-sheen absolute inset-0 rounded-full" />
<div className="absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-stone-700" /> <div
className="absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full"
style={{backgroundColor: 'color-mix(in oklab, currentColor 30%, transparent)'}}
/>
</div> </div>
{/* 톤암 — 곡이 얹혀 있으니 항상 내려와 있다. */} {/* 톤암 — 곡이 얹혀 있으니 항상 내려와 있다. */}
<div className="absolute -right-1 top-3 h-2 w-[120px] origin-right rotate-6"> <div className="absolute -right-1 top-3 h-2 w-[120px] origin-right rotate-6">
<div className="absolute inset-y-[3px] right-4 left-0 rounded-sm bg-linear-to-b from-stone-300 to-stone-500" /> <div
<div className="absolute left-0 -top-1 h-4 w-4 rounded-sm bg-stone-700" /> className="absolute inset-y-[3px] right-4 left-0 rounded-sm"
<div className="absolute -top-2 right-0 size-6 rounded-full bg-radial from-stone-300 to-stone-600" /> style={{backgroundColor: 'color-mix(in oklab, currentColor 65%, transparent)'}}
/>
<div
className="absolute left-0 -top-1 h-4 w-4 rounded-sm"
style={{backgroundColor: 'color-mix(in oklab, currentColor 40%, transparent)'}}
/>
<div
className="absolute -top-2 right-0 size-6 rounded-full"
style={{backgroundColor: 'color-mix(in oklab, currentColor 55%, transparent)'}}
/>
</div> </div>
</div> </div>
{/* 지금 도는 곡 */} {/* 지금 도는 곡 */}
<div className="min-w-0 space-y-3"> <div className="min-w-0 space-y-3">
<p className="text-[10px] tracking-[0.24em] text-amber-500/90"> <p className="text-[10px] tracking-[0.24em]" style={{color: ITEM_ACCENT}}>
A면 · {playing + 1} / {parsed.items.length} A면 · {playing + 1} / {parsed.items.length}
</p> </p>
<h3 className="text-2xl text-stone-50 sm:text-3xl" style={{fontFamily: RETRO_SIGN}}> <h3 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
{current.title} {current.title}
</h3> </h3>
<p className="text-xs text-stone-400"> <p className="text-xs opacity-55">
{[ {[
current.artist, current.artist,
current.year ? String(current.year) : undefined, current.year ? String(current.year) : undefined,
@ -92,8 +122,8 @@ export function SongsTurntable(props: SectionRenderProps) {
</p> </p>
{current.story && ( {current.story && (
<p <p
className="max-w-[46ch] text-sm leading-relaxed text-stone-300" className="max-w-[46ch] text-sm leading-relaxed opacity-80"
style={{fontFamily: RETRO_BODY}} style={{fontFamily: ITEM_BODY}}
> >
{current.story} {current.story}
</p> </p>
@ -101,12 +131,13 @@ export function SongsTurntable(props: SectionRenderProps) {
{current.connection && ( {current.connection && (
<p <p
className="max-w-[46ch] text-sm leading-relaxed" className="max-w-[46ch] text-sm leading-relaxed"
style={{fontFamily: RETRO_BODY, color: template.colors.accent}} style={{fontFamily: ITEM_BODY, color: template.colors.accent}}
> >
{current.connection} {current.connection}
</p> </p>
)} )}
<span className="inline-block border border-dashed border-stone-600 px-2 py-1 text-[10px] tracking-[0.1em] text-stone-400"> <span className="inline-block border border-dashed px-2 py-1 text-[10px] tracking-[0.1em] opacity-60"
style={{borderColor: 'color-mix(in oklab, currentColor 35%, transparent)'}}>
◎ 가사 대신 이야기 — 원문은 싣지 않습니다 ◎ 가사 대신 이야기 — 원문은 싣지 않습니다
</span> </span>
<SourceLine source={current.source} verified={current.verified} tone="dark" /> <SourceLine source={current.source} verified={current.verified} tone="dark" />
@ -131,13 +162,15 @@ export function SongsTurntable(props: SectionRenderProps) {
className="w4-disc-mini mx-auto block size-[84px] rounded-full transition-transform hover:scale-105" className="w4-disc-mini mx-auto block size-[84px] rounded-full transition-transform hover:scale-105"
style={{ style={{
['--lbl' as string]: song.labelColor || FALLBACK_LABEL, ['--lbl' as string]: song.labelColor || FALLBACK_LABEL,
['--w4-vinyl' as string]: ITEM_INVERSE,
// 고른 판만 강조색 링 — 금색을 박으면 팔레트를 바꿔도 이 링만 남는다.
boxShadow: boxShadow:
index === playing index === playing
? '0 0 0 2px #c9930f, 0 3px 10px rgba(0,0,0,.45)' ? `0 0 0 2px ${ITEM_ACCENT}, 0 3px 10px rgba(0,0,0,.4)`
: undefined, : undefined,
}} }}
/> />
<span className="mt-2 block truncate text-[10px] leading-tight text-stone-400"> <span className="mt-2 block truncate text-[10px] leading-tight opacity-55">
{song.title} {song.title}
</span> </span>
</button> </button>

View File

@ -0,0 +1,174 @@
import {useEffect, useState, type CSSProperties} from 'react';
import {Building2, Coffee, ImageOff, Stethoscope, UtensilsCrossed} from 'lucide-react';
import {PlaceCategory} from '@o2o/shared';
import {fetchShowcase, type ShowcaseItem} from './showcaseApi';
const CATEGORY_ICON: Record<number, typeof Building2> = {
[PlaceCategory.LODGING]: Building2,
[PlaceCategory.CAFE]: Coffee,
[PlaceCategory.RESTAURANT]: UtensilsCrossed,
[PlaceCategory.CLINIC]: Stethoscope,
};
const CATEGORY_LABEL: Record<number, string> = {
[PlaceCategory.LODGING]: '숙박',
[PlaceCategory.CAFE]: '카페',
[PlaceCategory.RESTAURANT]: '음식점',
[PlaceCategory.CLINIC]: '피부과 · 성형외과',
};
/** 발행 사이트 주소는 루트 상대경로로 온다(`/s/<slug>`). 발행 호스트는 번들에 구워진 값이다. */
const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host;
function siteHref(url: string): string {
return `${window.location.protocol}//${PUBLISH_HOST}${url}`;
}
/**
* 실제로 발행된 사이트를 그대로 건다.
*
* ★ 예시 데이터로 채우지 않는다. 이 섹션이 파는 건 "진짜로 나갔다"는 사실 하나이고,
* 가짜를 걸면 그 자리에서 가치가 0 이 된다. 발행본이 없으면 섹션을 통째로 감춘다.
*/
export function ShowcaseGrid({limit = 6}: {limit?: number}) {
const [items, setItems] = useState<ShowcaseItem[] | null>(null);
useEffect(() => {
let alive = true;
fetchShowcase(limit)
.then((rows) => alive && setItems(rows))
.catch(() => alive && setItems([]));
return () => {
alive = false;
};
}, [limit]);
if (items !== null && items.length === 0) return null;
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{(items ?? Array.from({length: Math.min(limit, 3)}, () => null)).map((item, index) =>
item ? <ShowcaseCard key={item.url} item={item} /> : <SkeletonCard key={index} />,
)}
</div>
);
}
function ShowcaseCard({item}: {item: ShowcaseItem}) {
const Icon = CATEGORY_ICON[item.category] ?? Building2;
return (
<a
href={siteHref(item.url)}
target="_blank"
rel="noreferrer"
className="group overflow-hidden rounded-xl border border-border bg-card transition-colors hover:border-foreground/25"
>
<div className="flex aspect-[16/10] items-center justify-center overflow-hidden bg-muted">
{item.thumbnailUrl ? (
<img
src={item.thumbnailUrl}
alt={`${item.name} 홈페이지`}
loading="lazy"
className="size-full object-cover transition-transform duration-300 group-hover:scale-[1.02]"
/>
) : (
// ★ 썸네일은 발행에 성공한 뒤에만 채워진다 — 없는 건 정상이다(사진 없는 가게).
<ImageOff className="size-6 text-muted-foreground/50" aria-hidden />
)}
</div>
<div className="flex items-center gap-2 px-4 py-3">
<Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0">
<p className="truncate text-sm font-semibold tracking-tight">{item.name}</p>
<p className="truncate text-xs text-muted-foreground">
{[item.region, CATEGORY_LABEL[item.category]].filter(Boolean).join(' · ')}
</p>
</div>
</div>
</a>
);
}
function SkeletonCard() {
return (
<div className="overflow-hidden rounded-xl border border-border bg-card">
<div className="aspect-[16/10] animate-pulse bg-muted" />
<div className="space-y-2 px-4 py-3">
<div className="h-3.5 w-2/3 animate-pulse rounded bg-muted" />
<div className="h-3 w-1/2 animate-pulse rounded bg-muted" />
</div>
</div>
);
}
/**
* 히어로 아래 발행 사이트 마퀴 — 옆으로 계속 흐른다.
*
* ★ 애니메이션은 **CSS 만으로** 돈다(index.css `o2o-marquee`). setInterval 로 인덱스를 돌리면
* 탭이 백그라운드일 때 프레임이 밀려 돌아왔을 때 툭 끊긴 것처럼 보인다.
* ★ 같은 목록을 두 벌 그린다 — 트랙을 -50% 까지만 밀면 이음매 없이 이어진다.
* 한 벌만 그리면 끝에서 빈 화면이 지나간다.
* ★ 여기도 실물이다. 발행된 사이트가 없으면 아무것도 그리지 않는다.
*/
export function ShowcasePeeks({limit = 10}: {limit?: number}) {
const [items, setItems] = useState<ShowcaseItem[]>([]);
useEffect(() => {
let alive = true;
fetchShowcase(limit)
.then((rows) => alive && setItems(rows))
.catch(() => undefined);
return () => {
alive = false;
};
}, [limit]);
if (items.length === 0) return null;
// 카드 수에 비례해 시간을 늘린다 — 개수가 늘어도 흐르는 **속도**는 그대로여야 한다.
const duration = `${Math.max(24, items.length * 6)}s`;
return (
<div
className="o2o-marquee relative mt-8 overflow-hidden [mask-image:linear-gradient(90deg,transparent,#000_8%,#000_92%,transparent)]"
aria-label="발행된 홈페이지"
>
<div
className="o2o-marquee-track flex w-max gap-3 py-1"
style={{'--marquee-duration': duration} as CSSProperties}
>
{[...items, ...items].map((item, index) => (
<a
key={`${item.url}-${index}`}
href={siteHref(item.url)}
target="_blank"
rel="noreferrer"
// 두 벌째는 첫 벌의 복제다 — 스크린리더가 같은 목록을 두 번 읽지 않게 한다.
aria-hidden={index >= items.length}
tabIndex={index >= items.length ? -1 : undefined}
className="w-40 shrink-0 overflow-hidden rounded-lg border border-border bg-card text-left transition-transform hover:-translate-y-0.5"
>
<div className="flex aspect-[16/10] items-center justify-center overflow-hidden bg-muted">
{item.thumbnailUrl ? (
<img
src={item.thumbnailUrl}
alt={`${item.name} 홈페이지`}
loading="lazy"
className="size-full object-cover"
/>
) : (
<ImageOff className="size-5 text-muted-foreground/40" aria-hidden />
)}
</div>
<div className="px-2.5 py-2">
<p className="truncate text-[11px] font-semibold tracking-tight">{item.name}</p>
<p className="truncate text-[10px] text-muted-foreground">
{[item.region, CATEGORY_LABEL[item.category]].filter(Boolean).join(' · ')}
</p>
</div>
</a>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,46 @@
import type {IndustryType} from '@o2o/shared';
import {PlaceCategory} from '@o2o/shared';
/**
* 발행된 사이트 목록 — 랜딩의 "이렇게 나옵니다" 가 쓴다.
*
* ★ 생성 클라이언트(@/api)를 쓰지 않는다. 그쪽은 토큰을 꽂는 길목(api/mutator)을 지나는데
* 이 엔드포인트는 **인증이 없고 비로그인 방문자가 부른다** — 토큰이 없어도, 만료됐어도
* 똑같이 나와야 한다. 여기서 fetch 를 직접 쓰는 이유가 그거다.
*/
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9800';
export type ShowcaseItem = {
name: string;
category: PlaceCategory;
/** 없을 수 있다 — 서버가 null 필드를 지워서 보낸다(RemoveNoneResponse). */
region?: string;
/** 루트 상대경로(`/s/<slug>`). */
url: string;
thumbnailUrl?: string;
};
const INDUSTRY_BY_CATEGORY: Record<number, IndustryType> = {
[PlaceCategory.LODGING]: 'stay',
[PlaceCategory.CAFE]: 'cafe',
[PlaceCategory.RESTAURANT]: 'restaurant',
[PlaceCategory.CLINIC]: 'clinic',
};
export function industryOf(category: PlaceCategory): IndustryType | null {
return INDUSTRY_BY_CATEGORY[category] ?? null;
}
export async function fetchShowcase(limit = 6): Promise<ShowcaseItem[]> {
const res = await fetch(`${BASE_URL}/v1/showcase?limit=${limit}`);
if (!res.ok) return [];
const body = await res.json();
if (body?.result?.success === false) return [];
return (body?.items ?? []).map((item: Record<string, unknown>) => ({
name: String(item.name ?? ''),
category: Number(item.category) as PlaceCategory,
region: (item.region as string) ?? undefined,
url: String(item.url ?? ''),
thumbnailUrl: (item.thumbnail_url as string) ?? undefined,
}));
}

View File

@ -1,18 +1,54 @@
import {useState} from 'react';
import {Check} from 'lucide-react'; import {Check} from 'lucide-react';
import type {IndustryType} from '@o2o/shared'; import type {IndustryType} from '@o2o/shared';
import {INDUSTRY_CONFIGS} from '@/data/industryData'; import {INDUSTRY_CONFIGS} from '@/data/industryData';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import {useBuilderStore} from '@/stores/builder'; import {useBuilderStore} from '@/stores/builder';
import {INDUSTRY_ICONS} from './industryIcons'; import {INDUSTRY_ICONS} from './industryIcons';
import {useWizardStep} from './wizardUrl';
import {WizardFooter} from './WizardFooter'; import {WizardFooter} from './WizardFooter';
import {WizardSteps} from './WizardSteps'; import {WizardSteps} from './WizardSteps';
const INDUSTRY_ORDER: IndustryType[] = ['stay', 'cafe', 'restaurant', 'clinic']; const INDUSTRY_ORDER: IndustryType[] = ['stay', 'cafe', 'restaurant', 'clinic'];
/**
* 업종 직접 고르기 — `?step=industry`.
*
* ★ 이 화면은 더 이상 위저드의 첫 화면이 아니다. 업종은 상호 검색 결과의 분류가 정하고
* (services/place_category.py), 여기는 **못 정했을 때와 바꿀 때만** 들른다.
* 진행 표시에서 1번을 유지하는 이유도 같다 — 이건 '내 가게 확인' 안의 갈래다.
*
* ★ 못 정해서 들어온 경우에는 아무 카드도 선택된 것으로 보이지 않게 한다. 스토어에는 언제나
* 기본 업종이 들어 있어서, 그걸 그대로 칠하면 사장님은 자기가 고르지도 않은 업종이
* 이미 정해진 것으로 읽는다.
*/
export function Step1Industry() { export function Step1Industry() {
const industry = useBuilderStore((s) => s.industry); const industry = useBuilderStore((s) => s.industry);
const pendingPick = useBuilderStore((s) => s.pendingPick);
const selectIndustry = useBuilderStore((s) => s.selectIndustry); const selectIndustry = useBuilderStore((s) => s.selectIndustry);
const goToStep = useBuilderStore((s) => s.goToStep); const setPendingPick = useBuilderStore((s) => s.setPendingPick);
const [, goToStep] = useWizardStep();
/** 검색이 업종을 못 정해 넘어온 경우 — 고르기 전에는 진행시키지 않는다. */
const mustChoose = Boolean(pendingPick && !pendingPick.industry);
const [touched, setTouched] = useState(false);
const showSelection = touched || !mustChoose;
const pick = (id: IndustryType) => {
setTouched(true);
selectIndustry(id);
};
/**
* 고른 업종을 들고 검색 화면으로 돌아간다.
*
* ★ 후보를 들고 왔으면 그 후보에 업종을 찍어 돌려보낸다 — 확정(사업장 생성)은 검색 화면
* 한 곳에서만 한다. 두 화면이 각자 확정하면 같은 가게가 두 번 만들어지는 길이 생긴다.
*/
const goBack = () => {
if (pendingPick) setPendingPick({...pendingPick, industry});
goToStep('search');
};
return ( return (
<div className="flex flex-1 flex-col justify-between bg-muted/30"> <div className="flex flex-1 flex-col justify-between bg-muted/30">
@ -22,10 +58,12 @@ export function Step1Industry() {
<div className="mb-10 text-center sm:mb-12"> <div className="mb-10 text-center sm:mb-12">
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl"> <h1 className="text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl">
어떤 업종의 홈페이지를 만들까요? {mustChoose ? '이 가게는 어떤 업종인가요?' : '업종을 바꿀까요?'}
</h1> </h1>
<p className="mx-auto mt-2.5 max-w-lg text-sm text-muted-foreground sm:text-base"> <p className="mx-auto mt-2.5 max-w-lg text-sm text-muted-foreground sm:text-base">
업종을 고르면 그 업종에 맞는 항목만 수집하고, 그 항목만 검증합니다. {mustChoose
? '지도 분류만으로는 업종을 정하지 못했습니다. 업종에 맞는 항목만 수집하고, 그 항목만 검증합니다.'
: '업종을 고르면 그 업종에 맞는 항목만 수집하고, 그 항목만 검증합니다.'}
</p> </p>
</div> </div>
@ -33,13 +71,13 @@ export function Step1Industry() {
{INDUSTRY_ORDER.map((id) => { {INDUSTRY_ORDER.map((id) => {
const config = INDUSTRY_CONFIGS[id]; const config = INDUSTRY_CONFIGS[id];
const Icon = INDUSTRY_ICONS[id]; const Icon = INDUSTRY_ICONS[id];
const isSelected = industry === id; const isSelected = showSelection && industry === id;
return ( return (
<button <button
key={id} key={id}
type="button" type="button"
onClick={() => selectIndustry(id)} onClick={() => pick(id)}
aria-pressed={isSelected} aria-pressed={isSelected}
className={cn( className={cn(
'flex cursor-pointer flex-col justify-between rounded-2xl border bg-card p-6 text-left transition-all', 'flex cursor-pointer flex-col justify-between rounded-2xl border bg-card p-6 text-left transition-all',
@ -90,16 +128,22 @@ export function Step1Industry() {
<WizardFooter <WizardFooter
hint={ hint={
<> showSelection ? (
선택된 업종:{' '} <>
<strong className="font-semibold text-foreground"> 선택된 업종:{' '}
{INDUSTRY_CONFIGS[industry].name} <strong className="font-semibold text-foreground">
</strong>{' '} {INDUSTRY_CONFIGS[industry].name}
({INDUSTRY_CONFIGS[industry].subName}) </strong>{' '}
</> ({INDUSTRY_CONFIGS[industry].subName})
</>
) : (
'업종을 하나 골라 주세요.'
)
} }
onNext={() => goToStep(2)} onPrev={() => goToStep('search')}
nextLabel="다음: 내 가게 찾기" onNext={goBack}
nextDisabled={!showSelection}
nextLabel={pendingPick ? '이 업종으로 계속하기' : '내 가게 찾기로 돌아가기'}
/> />
</div> </div>
); );

View File

@ -1,19 +1,23 @@
import {useState} from 'react'; import {useEffect, useRef, useState} from 'react';
import {useSearchParams} from 'react-router'; import {ArrowRight, Check, MapPin, Search, TriangleAlert} from 'lucide-react';
import {ArrowRight, Check, MapPin, Phone, Search, TriangleAlert} from 'lucide-react'; import type {IndustryType} from '@o2o/shared';
import {getAccessToken, type PlaceCandidate} from '@/api'; import type {PlaceSearchItem} from '@/api';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
import {INDUSTRY_CONFIGS} from '@/data/industryData';
import {CATEGORY_TO_INDUSTRY} from '@/features/builder/placeAdapter';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import type {PendingPick} from '@/stores/builder';
import {useBuilderStore} from '@/stores/builder'; import {useBuilderStore} from '@/stores/builder';
import {PlaceUrlBox} from './PlaceUrlBox'; import {PlaceUrlBox} from './PlaceUrlBox';
import {usePlaceSearch} from './usePlaceSearch'; import {usePlaceSearch} from './usePlaceSearch';
import {useWizardStep} from './wizardUrl';
import {WizardFooter} from './WizardFooter'; import {WizardFooter} from './WizardFooter';
import {WizardStage, WizardWaiting} from './WizardStage'; import {WizardStage, WizardWaiting} from './WizardStage';
const STAGE_TITLE: Record<string, string> = { const STAGE_TITLE: Record<string, string> = {
input: '상호명을 검색해 내 가게를 찾아주세요', input: '가게 이름을 알려주세요',
searching: '내 가게를 찾는 중입니다', searching: '내 가게를 찾는 중입니다',
picking: '이 중에 사장님 가게가 있나요?', picking: '이 중에 사장님 가게가 있나요?',
unavailable: '지도 검색을 사용할 수 없습니다', unavailable: '지도 검색을 사용할 수 없습니다',
@ -21,22 +25,20 @@ const STAGE_TITLE: Record<string, string> = {
}; };
const STAGE_DESCRIPTION: Record<string, string> = { const STAGE_DESCRIPTION: Record<string, string> = {
input: '지도·플레이스에 등록된 가게 중에서 사장님이 직접 고른 한 곳만 기준이 됩니다.', input: '업종은 안 고르셔도 됩니다 — 찾은 가게의 분류에서 자동으로 정해집니다.',
searching: '', searching: '',
picking: '고른 가게의 상호·주소·전화가 이 사이트의 기준 정보가 됩니다.', picking: '고른 가게의 상호·주소가 이 사이트의 기준 정보가 되고, 업종도 그 분류에서 정해집니다.',
unavailable: '', unavailable: '',
confirmed: '이제 이 가게의 공개 채널에서 정보를 수집합니다.', confirmed: '이제 이 가게의 공개 채널에서 정보를 수집합니다.',
}; };
/** outcome 코드 → 사장님이 읽을 한 줄. 서버 판정은 문구를 고르는 데만 쓴다. */
const OUTCOME_TEXT: Record<string, string> = {
matched: '이 가게로 보입니다. 맞는지 한 번만 확인해 주세요.',
ambiguous: '비슷한 이름의 가게가 여럿입니다. 어느 쪽이 사장님 가게인가요?',
no_candidate: '이 이름으로는 찾지 못했습니다.',
};
/** /**
* 2단계 — 내 가게 확인. * 1단계 — 내 가게 확인. **위저드의 시작점이다.**
*
* ★ 예전에는 업종 선택이 앞에 있었다. 그런데 사장님이 100% 아는 건 자기 가게 **이름**이고,
* 업종은 경계에서 멈춘다("우리는 카페인가 음식점인가"). 그래서 순서를 뒤집었다 —
* 상호명을 먼저 받고, 업종은 검색 결과의 분류가 정한다(services/place_category.py).
* 못 정했을 때만 업종 화면(`?step=industry`)으로 넘긴다.
* *
* 한 화면에 한 가지만 묻는다: 상호를 묻거나 · 기다리거나 · 후보를 고르게 하거나 · 확정을 보여준다. * 한 화면에 한 가지만 묻는다: 상호를 묻거나 · 기다리거나 · 후보를 고르게 하거나 · 확정을 보여준다.
*/ */
@ -45,117 +47,144 @@ export function Step2PlaceSearch() {
const storeName = useBuilderStore((s) => s.storeName); const storeName = useBuilderStore((s) => s.storeName);
const location = useBuilderStore((s) => s.location); const location = useBuilderStore((s) => s.location);
const confirmedIdentity = useBuilderStore((s) => s.confirmedIdentity); const confirmedIdentity = useBuilderStore((s) => s.confirmedIdentity);
const pendingPick = useBuilderStore((s) => s.pendingPick);
const setStoreName = useBuilderStore((s) => s.setStoreName); const setStoreName = useBuilderStore((s) => s.setStoreName);
const setLocation = useBuilderStore((s) => s.setLocation); const setLocation = useBuilderStore((s) => s.setLocation);
const selectIndustry = useBuilderStore((s) => s.selectIndustry);
const setPendingPick = useBuilderStore((s) => s.setPendingPick);
const confirmIdentity = useBuilderStore((s) => s.confirmIdentity); const confirmIdentity = useBuilderStore((s) => s.confirmIdentity);
const clearIdentity = useBuilderStore((s) => s.clearIdentity); const clearIdentity = useBuilderStore((s) => s.clearIdentity);
const goToStep = useBuilderStore((s) => s.goToStep);
// 새로고침을 넘어온 경우 이미 만들어진 사업장을 그대로 쓴다(같은 위저드에서 두 번 만들지 않는다). // 새로고침을 넘어온 경우 이미 만들어진 사업장을 그대로 쓴다(같은 위저드에서 두 번 만들지 않는다).
const search = usePlaceSearch(industry, confirmedIdentity?.placeId ?? null); const search = usePlaceSearch(confirmedIdentity?.placeId ?? null);
const [searchParams, setSearchParams] = useSearchParams(); const [, goToStep] = useWizardStep();
/**
* 확정된 사업장을 주소창에 남긴다 — `?placeId=...`.
*
* ★ 이게 새로고침을 넘기는 유일한 수단이다. 위저드 상태는 브라우저에 저장하지 않는다
* (stores/builder 주석): 저장하면 새 가게를 만들러 들어와도 지난 가게가 확정된 것으로
* 떠 버린다. 주소창에 두면 새로고침은 서버에서 복원되고(usePlaceSync), 주소를 새로
* 열면 깨끗하다 — 두 요구를 동시에 만족하는 자리는 여기뿐이다.
*/
const rememberPlace = (placeId: string | null) => {
const next = new URLSearchParams(searchParams);
if (placeId) {
next.set('placeId', placeId);
// 개발 서버 재시작/HMR로 BuilderPage가 다시 마운트돼도 신규 등록 흐름임을 보존한다.
next.set('flow', 'onboarding');
} else {
next.delete('placeId');
next.delete('flow');
}
setSearchParams(next, {replace: true});
};
const [pickedIndex, setPickedIndex] = useState<number | null>(null);
const [placeUrl, setPlaceUrl] = useState(''); const [placeUrl, setPlaceUrl] = useState('');
const canSearch = storeName.trim().length > 0 && search.phase !== 'searching'; const canSearch = storeName.trim().length > 0 && search.phase !== 'searching';
const runSearch = () => { /**
clearIdentity(); // 상호를 고쳐 다시 찾는 것이므로 앞서 확정한 신원은 물린다 * 확정된 사업장을 주소창에 남기며 다음 단계로.
setPickedIndex(null); *
// ★ 로그인 전에는 서버를 부르지 않는다. 로그인은 에디터 진입에서 한 번 받는 것이 이 앱의 흐름인데, * ★ `?placeId=` 가 새로고침을 넘기는 유일한 수단이다. 위저드 상태는 브라우저에 저장하지 않는다
// 장소 API 는 전부 토큰을 요구해서(place.py) 여기서 부르면 2단계가 로그인 벽이 된다. * (stores/builder 주석): 저장하면 새 가게를 만들러 들어와도 지난 가게가 확정된 것으로 떠 버린다.
// 입력한 값으로 신원을 세우고 넘어간다 — 검증은 로그인 뒤에 다시 할 수 있다. * 주소창에 두면 새로고침은 서버에서 복원되고(usePlaceSync), 주소를 새로 열면 깨끗하다.
if (!getAccessToken()) { * ★ 단계와 사업장을 **한 번에** 바꾼다. 나눠 쓰면 그 사이에 "placeId 는 붙었는데 아직 1단계"인
confirmIdentity(search.confirmManual(storeName, location)); * 주소가 히스토리에 한 칸 생기고, 뒤로가기가 그 칸에 걸린다.
goToStep(3); */
const advance = (placeId: string | null) => {
goToStep('collect', {
params: {placeId, flow: placeId ? 'onboarding' : null},
});
};
const finishPick = async (pick: PendingPick, nextIndustry: IndustryType) => {
const identity = await search.confirmPick(pick, nextIndustry);
setPendingPick(null);
if (!identity) return; // 실패 사유는 search.pickError 가 화면에 남긴다
confirmIdentity(identity);
advance(identity.placeId);
};
/**
* 업종 화면을 다녀온 후보를 이어서 확정한다.
*
* ★ 업종이 **찍혀서** 돌아온 것만 이어간다. 뒤로가기로 돌아온 경우(업종을 안 고른 경우)까지
* 확정하면 사장님이 고르지도 않은 기본 업종으로 사업장이 만들어진다.
*/
const resumed = useRef(false);
useEffect(() => {
const industryFromPicker = pendingPick?.industry;
if (!pendingPick || !industryFromPicker || resumed.current) return;
resumed.current = true;
void finishPick(pendingPick, industryFromPicker);
// eslint-disable-next-line react-hooks/exhaustive-deps -- 한 번만 이어붙이는 일이라 pendingPick 만 본다
}, [pendingPick]);
/**
* 후보 하나를 골랐다 — 여기서 업종이 정해진다.
*
* ★ `category` 는 서버가 외부 분류에서 **추정한** 값이고, 못 정하면 키 자체가 없다
* (RemoveNoneResponse 가 null 필드를 지운다). 억지로 하나를 고르지 않고 사장님에게 묻는다 —
* 업종은 수집 스키마와 JSON-LD 타입을 통째로 정하는 값이라 틀리면 되돌리는 값이 비싸다.
*/
const choose = (item: PlaceSearchItem) => {
const pick: PendingPick = {
name: item.name ?? '',
address: item.road_address ?? '',
};
const guessed = item.category != null ? (CATEGORY_TO_INDUSTRY[item.category] ?? null) : null;
if (!guessed) {
setPendingPick(pick);
goToStep('industry');
return; return;
} }
void search.search(storeName, location); selectIndustry(guessed);
void finishPick(pick, guessed);
};
const runSearch = () => {
clearIdentity(); // 상호를 고쳐 다시 찾는 것이므로 앞서 확정한 신원은 물린다
void search.searchPublic(storeName, location);
}; };
const backToInput = () => { const backToInput = () => {
clearIdentity(); clearIdentity();
// 다른 가게를 찾으러 간다 — 주소창에 남은 사업장도 같이 놓아준다. // 다른 가게를 찾으러 간다 — 주소창에 남은 사업장도 같이 놓아준다.
rememberPlace(null); goToStep('search', {params: {placeId: null, flow: null}, replace: true});
setPickedIndex(null);
search.reset(); search.reset();
}; };
const pick = async (candidate: PlaceCandidate, index: number) => {
// 지역검색 후보만 있고 네이버 플레이스를 못 찾았으면 확정하지 않는다.
// URL 확정 경로가 상호·주소·좌표와 수집 채널을 한 번에 보장한다.
if (!candidate.naver_place_url) return;
setPickedIndex(index);
const identity = await search.confirmByUrl(candidate.naver_place_url);
if (!identity) {
setPickedIndex(null);
return;
}
confirmIdentity(identity);
rememberPlace(identity.placeId);
goToStep(3);
};
/** 네이버 플레이스 URL 로 확정 — 가장 확실한 경로다(usePlaceSearch.confirmByUrl 주석 참고). */ /** 네이버 플레이스 URL 로 확정 — 가장 확실한 경로다(usePlaceSearch.confirmByUrl 주석 참고). */
const pickByUrl = async () => { const pickByUrl = async () => {
const url = placeUrl.trim(); const url = placeUrl.trim();
if (!url) return; if (!url) return;
// URL 확인도 서버가 토큰을 요구한다 — 로그인 전에는 입력값으로 넘어간다. const identity = await search.confirmByUrl(url, industry);
if (!getAccessToken()) {
confirmIdentity(search.confirmManual(storeName, location));
goToStep(3);
return;
}
const identity = await search.confirmByUrl(url);
if (!identity) return; if (!identity) return;
confirmIdentity(identity); confirmIdentity(identity);
rememberPlace(identity.placeId); advance(identity.placeId);
goToStep(3); };
/**
* 업종을 바꾸러 간다.
*
* ★ **서버에 사업장이 이미 만들어졌으면 신원 확인부터 다시 받는다.** places.category 는 만들 때
* 정해지고 PATCH 로 못 고친다(Req_UpdatePlace 에 category 가 없다) — 화면에서만 바꾸면
* 리페치 한 번에 서버 값으로 되돌아가서, 사장님 눈에는 바꾼 게 씹힌 것으로 보인다.
* 확정 전(로그인 전 포함)에는 서버에 아무것도 없으니 업종만 갈아도 된다.
*/
const identityIsOnServer = Boolean(confirmedIdentity?.placeId);
const changeIndustry = () => {
if (identityIsOnServer) {
clearIdentity();
goToStep('industry', {params: {placeId: null, flow: null}});
return;
}
goToStep('industry');
}; };
// ── 화면 고르기. 위에서부터 먼저 맞는 것 하나만 그린다 ────────────── // ── 화면 고르기. 위에서부터 먼저 맞는 것 하나만 그린다 ──────────────
const stage = confirmedIdentity const stage = confirmedIdentity
? 'confirmed' ? 'confirmed'
: search.phase === 'searching' : search.phase === 'searching'
? 'searching' ? 'searching'
: search.phase === 'unavailable' : search.phase === 'unavailable'
? 'unavailable' ? 'unavailable'
: search.phase === 'done' : search.phase === 'done'
? 'picking' ? 'picking'
: 'input'; : 'input';
const searchQuery = [storeName, location].filter(Boolean).join(' ');
return ( return (
<div className="flex flex-1 flex-col justify-between bg-muted/30"> <div className="flex flex-1 flex-col justify-between bg-muted/30">
<WizardStage <WizardStage
current={2} current={1}
wide={stage === 'picking'} wide={stage === 'picking'}
title={STAGE_TITLE[stage]} title={STAGE_TITLE[stage]}
description={STAGE_DESCRIPTION[stage]} description={STAGE_DESCRIPTION[stage]}
> >
{stage === 'input' && ( {stage === 'input' && (
<div className="space-y-4"> <div className="space-y-4 rounded-2xl border border-border bg-card p-6">
<div className="space-y-4 rounded-2xl border border-border bg-card p-6">
<div> <div>
<label htmlFor="store-name" className="mb-1.5 block text-xs font-semibold"> <label htmlFor="store-name" className="mb-1.5 block text-xs font-semibold">
상호명 <span className="text-destructive">*</span> 상호명 <span className="text-destructive">*</span>
@ -200,7 +229,6 @@ export function Step2PlaceSearch() {
<Search /> <Search />
<span>상호로 찾아보기</span> <span>상호로 찾아보기</span>
</Button> </Button>
</div>
</div> </div>
)} )}
@ -216,72 +244,51 @@ export function Step2PlaceSearch() {
<p <p
className={cn( className={cn(
'rounded-xl p-3 text-xs leading-relaxed', 'rounded-xl p-3 text-xs leading-relaxed',
search.candidates.length > 0 search.items.length > 0
? 'border border-border bg-card text-muted-foreground' ? 'border border-border bg-card text-muted-foreground'
: 'border border-warning/30 bg-warning/10 text-warning', : 'border border-warning/30 bg-warning/10 text-warning',
)} )}
> >
{OUTCOME_TEXT[search.outcome] ?? '아래 목록에서 사장님 가게를 골라 주세요.'} {search.items.length > 0
{search.sourceLabel && ( ? '아래 목록에서 사장님 가게를 골라 주세요.'
<Badge variant="outline" className="ml-2 text-[10px]"> : '이 이름으로는 찾지 못했습니다. 아래에서 네이버 지도 주소로 찾아 주세요.'}
{search.sourceLabel}
</Badge>
)}
</p> </p>
{search.candidates.length > 0 && {search.pickError && (
!search.candidates.some((candidate) => candidate.naver_place_url) && ( <p className="rounded-xl border border-warning/30 bg-warning/10 p-3 text-xs font-medium leading-relaxed text-warning">
<p className="rounded-xl border border-warning/30 bg-warning/10 p-3 text-xs font-medium leading-relaxed text-warning"> <TriangleAlert className="mr-1 inline size-3.5 align-text-bottom" />
<TriangleAlert className="mr-1 inline size-3.5 align-text-bottom" /> {search.pickError}
상호 후보는 찾았지만 네이버 플레이스는 찾지 못했습니다. 아래 버튼으로 네이버 </p>
지도에서 가게를 검색한 뒤 플레이스 URL을 붙여넣어 주세요. )}
</p>
)}
<ul className="space-y-2"> <ul className="space-y-2">
{search.candidates.map((candidate, index) => ( {search.items.map((item, index) => (
<li key={`${candidate.external_place_id ?? candidate.name}-${index}`}> <li key={`${item.name ?? ''}-${item.road_address ?? ''}-${index}`}>
<button <button
type="button" type="button"
onClick={() => void pick(candidate, index)} onClick={() => choose(item)}
disabled={search.isConfirming || !candidate.naver_place_url} disabled={search.isConfirming}
className="w-full cursor-pointer rounded-xl border border-border bg-card p-4 text-left transition-all hover:border-primary hover:bg-accent disabled:opacity-60" className="w-full cursor-pointer rounded-xl border border-border bg-card p-4 text-left transition-all hover:border-primary hover:bg-accent disabled:opacity-60"
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-bold">{candidate.name}</span> <span className="text-sm font-bold">{item.name}</span>
{candidate.category_name && ( {item.category_name && (
<Badge variant="outline" className="text-[10px]"> <Badge variant="outline" className="text-[10px]">
{candidate.category_name} {item.category_name}
</Badge> </Badge>
)} )}
<Badge {/* 고르기 전에 어떤 업종으로 시작하는지 먼저 보여준다 — 고른 뒤에 알면 늦다. */}
variant={candidate.naver_place_url ? 'success' : 'warning'} <IndustryBadge category={item.category} />
className="text-[10px]"
>
{candidate.naver_place_url
? '네이버 플레이스 찾음'
: '네이버 플레이스 못 찾음'}
</Badge>
</div> </div>
<p className="mt-1.5 flex items-start gap-1 text-xs text-muted-foreground"> <p className="mt-1.5 flex items-start gap-1 text-xs text-muted-foreground">
<MapPin className="mt-px size-3 shrink-0" /> <MapPin className="mt-px size-3 shrink-0" />
<span>{candidate.road_address ?? candidate.address ?? '주소 없음'}</span> <span>{item.road_address ?? '주소 없음'}</span>
</p> </p>
{candidate.phone && (
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<Phone className="size-3 shrink-0" />
{candidate.phone}
</p>
)}
</div> </div>
<span className="shrink-0 rounded-lg bg-primary px-3 py-1.5 text-[11px] font-bold text-primary-foreground"> <span className="shrink-0 rounded-lg bg-primary px-3 py-1.5 text-[11px] font-bold text-primary-foreground">
{!candidate.naver_place_url {search.isConfirming ? '확정 중...' : '이 가게예요'}
? 'URL 필요'
: search.isConfirming && pickedIndex === index
? '확정 중...'
: '이 가게예요'}
</span> </span>
</div> </div>
</button> </button>
@ -289,25 +296,26 @@ export function Step2PlaceSearch() {
))} ))}
</ul> </ul>
{!search.candidates.some((candidate) => candidate.naver_place_url) ? ( {/* 목록에서 고르면 업종은 그 후보가 정한다 — 이 줄은 아래 '주소로 확정' 경로용이다. */}
<PlaceUrlBox <IndustryLine
value={placeUrl} label="주소로 확정할 때 쓸 업종"
onChange={setPlaceUrl} industry={industry}
onSubmit={() => void pickByUrl()} onChange={changeIndustry}
isBusy={search.isConfirming} />
searchQuery={[storeName, location].filter(Boolean).join(' ')}
/>
) : (
<PlaceUrlBox
value={placeUrl}
onChange={setPlaceUrl}
onSubmit={() => void pickByUrl()}
isBusy={search.isConfirming}
compact
searchQuery={[storeName, location].filter(Boolean).join(' ')}
/>
)}
<PlaceUrlBox
value={placeUrl}
onChange={setPlaceUrl}
onSubmit={() => void pickByUrl()}
isBusy={search.isConfirming}
compact={search.items.length > 0 && !search.pickError}
searchQuery={searchQuery}
/>
<Button variant="ghost" size="sm" className="w-full" onClick={backToInput}>
<Search />
<span>다른 이름으로 다시 찾기</span>
</Button>
</div> </div>
)} )}
@ -317,12 +325,19 @@ export function Step2PlaceSearch() {
<TriangleAlert className="mr-1 inline size-3.5 align-text-bottom" /> <TriangleAlert className="mr-1 inline size-3.5 align-text-bottom" />
{search.unavailableReason} {search.unavailableReason}
</p> </p>
<IndustryLine
label="주소로 확정할 때 쓸 업종"
industry={industry}
onChange={changeIndustry}
/>
<PlaceUrlBox <PlaceUrlBox
value={placeUrl} value={placeUrl}
onChange={setPlaceUrl} onChange={setPlaceUrl}
onSubmit={() => void pickByUrl()} onSubmit={() => void pickByUrl()}
isBusy={search.isConfirming} isBusy={search.isConfirming}
searchQuery={[storeName, location].filter(Boolean).join(' ')} searchQuery={searchQuery}
/> />
<Button variant="outline" className="w-full" onClick={runSearch}> <Button variant="outline" className="w-full" onClick={runSearch}>
@ -357,17 +372,25 @@ export function Step2PlaceSearch() {
</> </>
) : ( ) : (
<Badge variant="warning" className="text-[10px]"> <Badge variant="warning" className="text-[10px]">
직접 입력 · 동일 업소 미검증 {confirmedIdentity.sourceLabel} · 동일 업소 미검증
</Badge> </Badge>
)} )}
</div> </div>
</div> </div>
<IndustryLine
industry={industry}
onChange={changeIndustry}
note={
identityIsOnServer ? '업종을 바꾸면 가게 확인을 다시 받습니다.' : undefined
}
/>
<Button <Button
variant="primary" variant="primary"
size="lg" size="lg"
className="w-full" className="w-full"
onClick={() => goToStep(3)} onClick={() => advance(confirmedIdentity.placeId)}
> >
<span>다음: 이 가게 정보 수집하기</span> <span>다음: 이 가게 정보 수집하기</span>
<ArrowRight /> <ArrowRight />
@ -386,11 +409,61 @@ export function Step2PlaceSearch() {
? `${confirmedIdentity.name} 기준으로 진행합니다.` ? `${confirmedIdentity.name} 기준으로 진행합니다.`
: '내 가게를 확인해야 다음 단계로 갈 수 있습니다.' : '내 가게를 확인해야 다음 단계로 갈 수 있습니다.'
} }
onPrev={() => goToStep(1)} onNext={() => advance(confirmedIdentity?.placeId ?? null)}
onNext={() => goToStep(3)}
nextLabel="다음: 데이터 수집" nextLabel="다음: 데이터 수집"
nextDisabled={!confirmedIdentity} nextDisabled={!confirmedIdentity}
/> />
</div> </div>
); );
} }
/** 후보 카드의 추정 업종. 못 정한 후보는 "고르면 묻는다"를 미리 알려 준다. */
function IndustryBadge({category}: {category: PickedCategory}) {
const industry = category != null ? (CATEGORY_TO_INDUSTRY[category] ?? null) : null;
return industry ? (
<Badge variant="success" className="text-[10px]">
{INDUSTRY_CONFIGS[industry].name}
</Badge>
) : (
<Badge variant="warning" className="text-[10px]">
업종 직접 선택
</Badge>
);
}
/** PlaceSearchItem.category — 서버가 못 정하면 키 자체가 없다(RemoveNoneResponse). */
type PickedCategory = PlaceSearchItem['category'];
/**
* 지금 어떤 업종으로 진행 중인지 + 바꾸는 길.
*
* ★ 업종을 자동으로 정하기로 한 이상, **정해진 값이 화면에 보여야 한다.** 안 보이면 사장님은
* 숙박 스키마로 카페 사이트를 만들고 있다는 걸 수집 결과를 보고서야 안다.
*/
function IndustryLine({
industry,
onChange,
label = '업종',
note,
}: {
industry: IndustryType;
onChange: () => void;
label?: string;
note?: string;
}) {
return (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-border bg-card px-3 py-2 text-xs">
<span>
{label}: <strong className="font-semibold">{INDUSTRY_CONFIGS[industry].name}</strong>
{note && <span className="ml-2 text-[11px] text-muted-foreground">{note}</span>}
</span>
<button
type="button"
onClick={onChange}
className="cursor-pointer rounded-md px-2 py-1 font-medium text-primary transition-colors hover:bg-accent"
>
바꾸기
</button>
</div>
);
}

View File

@ -10,6 +10,7 @@ import {useBuilderStore, useUnverifiedFields} from '@/stores/builder';
import {ChannelConfirmPanel} from './ChannelConfirmPanel'; import {ChannelConfirmPanel} from './ChannelConfirmPanel';
import {ChannelUrlInput} from './ChannelUrlInput'; import {ChannelUrlInput} from './ChannelUrlInput';
import {useCollectFlow} from './useCollectFlow'; import {useCollectFlow} from './useCollectFlow';
import {useWizardStep} from './wizardUrl';
import {WizardFooter} from './WizardFooter'; import {WizardFooter} from './WizardFooter';
import {WizardStage, WizardWaiting} from './WizardStage'; import {WizardStage, WizardWaiting} from './WizardStage';
@ -29,7 +30,7 @@ const STAGE_DESCRIPTION: Record<string, string> = {
review: '찾은 값은 전부 출처와 함께 보여드립니다. 사장님이 [맞아요]를 누른 값만 사이트와 AI 검색 답변에 나갑니다.', review: '찾은 값은 전부 출처와 함께 보여드립니다. 사장님이 [맞아요]를 누른 값만 사이트와 AI 검색 답변에 나갑니다.',
}; };
/** 채널 URL 확인과 수집 결과 검토 단계. */ /** 채널 URL 확인과 수집 결과 검토 단계(진행 표시 2번). */
export function Step3DataReview() { export function Step3DataReview() {
const industry = useBuilderStore((s) => s.industry); const industry = useBuilderStore((s) => s.industry);
const storeName = useBuilderStore((s) => s.storeName); const storeName = useBuilderStore((s) => s.storeName);
@ -37,7 +38,7 @@ export function Step3DataReview() {
const gatherCompleted = useBuilderStore((s) => s.gatherCompleted); const gatherCompleted = useBuilderStore((s) => s.gatherCompleted);
const infoFields = useBuilderStore((s) => s.infoFields); const infoFields = useBuilderStore((s) => s.infoFields);
const photos = useBuilderStore((s) => s.photos); const photos = useBuilderStore((s) => s.photos);
const goToStep = useBuilderStore((s) => s.goToStep); const [, goToStep] = useWizardStep();
const unverified = useUnverifiedFields(); const unverified = useUnverifiedFields();
const placeId = confirmedIdentity?.placeId ?? null; const placeId = confirmedIdentity?.placeId ?? null;
@ -91,7 +92,7 @@ export function Step3DataReview() {
return ( return (
<div className="flex flex-1 flex-col justify-between bg-muted/30"> <div className="flex flex-1 flex-col justify-between bg-muted/30">
<WizardStage <WizardStage
current={3} current={2}
wide={stage === 'review' || stage === 'channels'} wide={stage === 'review' || stage === 'channels'}
title={STAGE_TITLE[stage]} title={STAGE_TITLE[stage]}
description={STAGE_DESCRIPTION[stage]} description={STAGE_DESCRIPTION[stage]}
@ -178,14 +179,14 @@ export function Step3DataReview() {
가게만 채널을 긁을 수 있습니다 — 2단계에서 지도 검색으로 가게를 확인하시면 가게만 채널을 긁을 수 있습니다 — 2단계에서 지도 검색으로 가게를 확인하시면
주소 붙여넣기와 자동 찾기가 모두 열립니다. 주소 붙여넣기와 자동 찾기가 모두 열립니다.
</p> </p>
<Button variant="outline" className="w-full" onClick={() => goToStep(4)}> <Button variant="outline" className="w-full" onClick={() => goToStep('template')}>
<span>수집 없이 다음 단계로</span> <span>수집 없이 다음 단계로</span>
<ArrowRight /> <ArrowRight />
</Button> </Button>
</> </>
)} )}
<Button variant="ghost" size="sm" className="w-full" onClick={() => goToStep(2)}> <Button variant="ghost" size="sm" className="w-full" onClick={() => goToStep('search')}>
가게를 다시 찾을게요 가게를 다시 찾을게요
</Button> </Button>
</div> </div>
@ -302,7 +303,7 @@ export function Step3DataReview() {
</div> </div>
)} )}
<Button variant="primary" size="lg" className="w-full" onClick={() => goToStep(4)}> <Button variant="primary" size="lg" className="w-full" onClick={() => goToStep('template')}>
<span>다음: 템플릿 고르기</span> <span>다음: 템플릿 고르기</span>
<ArrowRight /> <ArrowRight />
</Button> </Button>
@ -354,8 +355,8 @@ export function Step3DataReview() {
'수집을 돌리면 찾은 값이 출처와 함께 여기에 나옵니다.' '수집을 돌리면 찾은 값이 출처와 함께 여기에 나옵니다.'
) )
} }
onPrev={() => goToStep(2)} onPrev={() => goToStep('search')}
onNext={() => goToStep(4)} onNext={() => goToStep('template')}
nextLabel="다음: 템플릿 선택" nextLabel="다음: 템플릿 선택"
/** /**
* ★ 수집 전에는 잠근다. * ★ 수집 전에는 잠근다.

View File

@ -4,6 +4,7 @@ import {INDUSTRY_CONFIGS} from '@/data/industryData';
import {queueSiteTemplateSave} from '@/features/publish/siteTemplate'; import {queueSiteTemplateSave} from '@/features/publish/siteTemplate';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import {useBuilderStore} from '@/stores/builder'; import {useBuilderStore} from '@/stores/builder';
import {useWizardStep} from './wizardUrl';
import {WizardFooter} from './WizardFooter'; import {WizardFooter} from './WizardFooter';
import {WizardSteps} from './WizardSteps'; import {WizardSteps} from './WizardSteps';
@ -85,7 +86,7 @@ export function Step4Template() {
const templateId = useBuilderStore((s) => s.templateId); const templateId = useBuilderStore((s) => s.templateId);
const placeId = useBuilderStore((s) => s.placeId); const placeId = useBuilderStore((s) => s.placeId);
const selectTemplate = useBuilderStore((s) => s.selectTemplate); const selectTemplate = useBuilderStore((s) => s.selectTemplate);
const goToStep = useBuilderStore((s) => s.goToStep); const [, goToStep] = useWizardStep();
const startGenerating = useBuilderStore((s) => s.startGenerating); const startGenerating = useBuilderStore((s) => s.startGenerating);
const config = INDUSTRY_CONFIGS[industry]; const config = INDUSTRY_CONFIGS[industry];
@ -105,7 +106,7 @@ export function Step4Template() {
<div className="flex flex-1 flex-col justify-between bg-muted/30"> <div className="flex flex-1 flex-col justify-between bg-muted/30">
<div className="flex flex-1 flex-col items-center justify-center px-4 py-10 sm:px-6 sm:py-14 lg:px-8"> <div className="flex flex-1 flex-col items-center justify-center px-4 py-10 sm:px-6 sm:py-14 lg:px-8">
<div className="mx-auto flex w-full max-w-5xl flex-col"> <div className="mx-auto flex w-full max-w-5xl flex-col">
<WizardSteps current={4} /> <WizardSteps current={3} />
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl"> <h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
@ -166,12 +167,13 @@ export function Step4Template() {
</div> </div>
<WizardFooter <WizardFooter
onPrev={() => goToStep(3)} onPrev={() => goToStep('collect')}
onNext={() => { onNext={() => {
// ★ 아무것도 안 누르고 넘어가는 경우(첫 템플릿이 이미 선택돼 있다)도 서버에 남긴다 — // ★ 아무것도 안 누르고 넘어가는 경우(첫 템플릿이 이미 선택돼 있다)도 서버에 남긴다 —
// 화면이 보여준 그 템플릿이 발행본이 되어야 한다. 같은 값이면 서버가 재빌드 표시도 찍지 않는다. // 화면이 보여준 그 템플릿이 발행본이 되어야 한다. 같은 값이면 서버가 재빌드 표시도 찍지 않는다.
queueSiteTemplateSave(placeId, templateId); queueSiteTemplateSave(placeId, templateId);
startGenerating(); startGenerating();
goToStep('generating');
}} }}
nextLabel="이 템플릿으로 사이트 생성하기" nextLabel="이 템플릿으로 사이트 생성하기"
/> />

View File

@ -1,12 +1,12 @@
import {useCallback, useEffect} from 'react'; import {useCallback, useEffect} from 'react';
import {useSearchParams} from 'react-router';
import {Check, Loader2} from 'lucide-react'; import {Check, Loader2} from 'lucide-react';
import {JobStatus} from '@o2o/shared'; import {JobStatus} from '@o2o/shared';
import {delay, getAccessToken, pollJob, startCopy} from '@/api'; import {delay, getAccessToken, pollJob, startCopy} from '@/api';
import {Progress} from '@/components/ui/progress'; import {Progress} from '@/components/ui/progress';
import {notify, notifyApiError} from '@/lib/notify'; import {notify, notifyApiError} from '@/lib/notify';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import {EDITOR_STEP, useBuilderStore} from '@/stores/builder'; import {useBuilderStore} from '@/stores/builder';
import {EDITOR_STEP, useWizardStep} from './wizardUrl';
const BUILD_STEPS = [ const BUILD_STEPS = [
'수집된 사진 분류 및 대체 텍스트 생성', '수집된 사진 분류 및 대체 텍스트 생성',
@ -30,9 +30,8 @@ export function Step5Generating() {
const storeName = useBuilderStore((s) => s.storeName); const storeName = useBuilderStore((s) => s.storeName);
const stage = useBuilderStore((s) => s.generateStage); const stage = useBuilderStore((s) => s.generateStage);
const setGenerateStage = useBuilderStore((s) => s.setGenerateStage); const setGenerateStage = useBuilderStore((s) => s.setGenerateStage);
const goToStep = useBuilderStore((s) => s.goToStep);
const placeId = useBuilderStore((s) => s.placeId); const placeId = useBuilderStore((s) => s.placeId);
const [searchParams, setSearchParams] = useSearchParams(); const [, goToStep] = useWizardStep();
/** /**
* 생성이 끝나면 곧바로 에디터다. * 생성이 끝나면 곧바로 에디터다.
@ -43,11 +42,10 @@ export function Step5Generating() {
* '목록으로 보내는 것'은 다른 얘기다. * '목록으로 보내는 것'은 다른 얘기다.
*/ */
const finishOnboarding = useCallback(() => { const finishOnboarding = useCallback(() => {
const next = new URLSearchParams(searchParams); // ★ `flow=onboarding` 도 같이 뗀다. 남겨 두면 이 주소를 새로고침했을 때 위저드 중인
next.delete('flow'); // 사업장으로 읽혀 신원이 다시 세워진다 — 편집 중인 사람에게는 아무 의미가 없는 일이다.
setSearchParams(next, {replace: true}); goToStep(EDITOR_STEP, {params: {flow: null}, replace: true});
goToStep(EDITOR_STEP); }, [goToStep]);
}, [goToStep, searchParams, setSearchParams]);
/** /**
* 소개문·FAQ 생성(JobType.COPY). `POST /v1/place/{id}/copy` 로 잡을 넣고 폴링한다. * 소개문·FAQ 생성(JobType.COPY). `POST /v1/place/{id}/copy` 로 잡을 넣고 폴링한다.

View File

@ -4,14 +4,17 @@ import {cn} from '@/lib/utils';
/** /**
* 위저드 진행 표시. * 위저드 진행 표시.
* *
* 2(신원 확인)와 3(사실 확인)이 왜 따로인지 사장님이 한눈에 알게 하는 것이 이 줄의 목적이다 — * 1(신원 확인)과 2(사실 확인)가 왜 따로인지 사장님이 한눈에 알게 하는 것이 이 줄의 목적이다 —
* "가게가 맞나" 를 먼저 끝내고 "값이 맞나" 로 넘어간다. * "가게가 맞나" 를 먼저 끝내고 "값이 맞나" 로 넘어간다.
*
* ★ 업종은 더 이상 단계가 아니다. 검색 결과의 분류가 정하고(못 정할 때만 1단계 안에서 묻는다),
* 그래서 네 칸이던 줄이 세 칸이 됐다 — 사장님이 처음 보는 화면이 "우리는 카페인가 음식점인가"
* 가 아니라 "가게 이름"이 되게 하는 것이 이 변경의 목적이다.
*/ */
const STEPS = [ const STEPS = [
{step: 1, label: '업종'}, {step: 1, label: '내 가게 확인'},
{step: 2, label: '내 가게 확인'}, {step: 2, label: '수집 정보 확인'},
{step: 3, label: '수집 정보 확인'}, {step: 3, label: '템플릿'},
{step: 4, label: '템플릿'},
] as const; ] as const;
export function WizardSteps({current}: {current: number}) { export function WizardSteps({current}: {current: number}) {

View File

@ -1,3 +1,5 @@
// 위저드 단계는 주소창이 소유한다 — 화면을 고르는 쪽(BuilderPage)이 이 계약을 읽는다.
export {defaultStep, EDITOR_STEP, useWizardStep, type WizardStep} from './wizardUrl';
export {Step1Industry} from './Step1Industry'; export {Step1Industry} from './Step1Industry';
export {Step2PlaceSearch} from './Step2PlaceSearch'; export {Step2PlaceSearch} from './Step2PlaceSearch';
export {Step3DataReview} from './Step3DataReview'; export {Step3DataReview} from './Step3DataReview';

View File

@ -1,21 +1,21 @@
import {useCallback, useRef, useState} from 'react'; import {useCallback, useRef, useState} from 'react';
import type {IndustryType} from '@o2o/shared'; import type {IndustryType} from '@o2o/shared';
import type {ExternalPlaceSource as ExternalPlaceSourceType, PlaceCandidate, PlaceCategory} from '@/api'; import type {PlaceCategory, PlaceSearchItem} from '@/api';
import { import {
createPlace, createPlace,
ExternalPlaceSource,
getAccessToken, getAccessToken,
PlaceCategory as PlaceCategoryEnum, PlaceCategory as PlaceCategoryEnum,
searchPlacesPublic,
updatePlace, updatePlace,
verifyCandidates, verifyCandidates,
verifyPlace,
verifyPlaceByUrl, verifyPlaceByUrl,
} from '@/api'; } from '@/api';
import type {ConfirmedIdentity} from '@/stores/builder'; import type {ConfirmedIdentity} from '@/stores/builder';
import {ensureAutoSession} from '@/lib/autoSession'; import {ensureAutoSession} from '@/lib/autoSession';
import {describeError} from '@/lib/errorMessages';
import {notify, notifyApiError} from '@/lib/notify'; import {notify, notifyApiError} from '@/lib/notify';
/** 빌더 업종 → places.category. 반대 방향은 stores/builder 의 CATEGORY_TO_INDUSTRY 다. */ /** 빌더 업종 → places.category. 반대 방향은 placeAdapter 의 CATEGORY_TO_INDUSTRY 다. */
const INDUSTRY_TO_CATEGORY: Record<IndustryType, PlaceCategory> = { const INDUSTRY_TO_CATEGORY: Record<IndustryType, PlaceCategory> = {
stay: PlaceCategoryEnum.LODGING, stay: PlaceCategoryEnum.LODGING,
cafe: PlaceCategoryEnum.CAFE, cafe: PlaceCategoryEnum.CAFE,
@ -23,21 +23,15 @@ const INDUSTRY_TO_CATEGORY: Record<IndustryType, PlaceCategory> = {
clinic: PlaceCategoryEnum.CLINIC, clinic: PlaceCategoryEnum.CLINIC,
}; };
/** 후보를 어느 장소 DB 에서 찾았는지 — 사장님이 판단할 근거로 카드에 그대로 붙인다. */
const SOURCE_LABEL: Record<number, string> = {
[ExternalPlaceSource.KAKAO]: '카카오맵',
[ExternalPlaceSource.NAVER]: '네이버 지도',
};
export type SearchPhase = export type SearchPhase =
/** 아직 검색 전. */ /** 아직 검색 전. */
| 'idle' | 'idle'
/** 사업장 생성 + 후보 조회 중. */ /** 공개 검색 중. */
| 'searching' | 'searching'
/** 후보를 받았다(0건일 수도 있다 — outcome 이 no_candidate). */ /** 후보를 받았다(0건일 수도 있다). */
| 'done' | 'done'
/** /**
* 장소 DB 를 부를 수 없다 — 로그인이 없거나 백엔드가 응답하지 않는다. * 장소 DB 를 부를 수 없다 — 키가 없거나, 분당 호출을 넘겼거나, 백엔드가 응답하지 않는다.
* ★ 이때 가짜 후보를 지어내지 않는다. 검색 결과인 척하는 예시 카드는 * ★ 이때 가짜 후보를 지어내지 않는다. 검색 결과인 척하는 예시 카드는
* 사장님이 남의 가게를 자기 가게로 확정하게 만드는 가장 빠른 길이다. * 사장님이 남의 가게를 자기 가게로 확정하게 만드는 가장 빠른 길이다.
*/ */
@ -45,41 +39,47 @@ export type SearchPhase =
export interface PlaceSearchState { export interface PlaceSearchState {
phase: SearchPhase; phase: SearchPhase;
/** 외부 장소 DB 후보. 서버가 자동 확정하지 않으므로 항상 사람이 고른다. */ /** 공개 검색 후보. 서버가 자동 확정하지 않으므로 항상 사람이 고른다. */
candidates: PlaceCandidate[]; items: PlaceSearchItem[];
/** matched | ambiguous | no_candidate — 서버 판정. 문구를 고르는 데만 쓴다. */ /** 검색을 못 한 이유. phase === 'unavailable' 일 때만 채워진다. */
outcome: string;
/** true 면 판정이 명확해 '이거 맞나요?' 한 번만 물어도 된다. */
autoSelectable: boolean;
/** 후보를 준 장소 DB 이름. 없으면 빈 문자열. */
sourceLabel: string;
source: ExternalPlaceSourceType | null;
/** 검색을 못 한 이유(로그인 필요 등). phase === 'unavailable' 일 때만 채워진다. */
unavailableReason: string; unavailableReason: string;
/**
* 고른 후보를 자동으로 확정하지 못한 이유.
* ★ 실패를 조용히 삼키면 사장님은 "눌렀는데 아무 일도 안 일어난다"만 겪는다 —
* 네이버 플레이스 URL 붙여넣기로 넘어가야 한다는 걸 여기서 말한다.
*/
pickError: string;
} }
const INITIAL: PlaceSearchState = { const INITIAL: PlaceSearchState = {
phase: 'idle', phase: 'idle',
candidates: [], items: [],
outcome: '',
autoSelectable: false,
sourceLabel: '',
source: null,
unavailableReason: '', unavailableReason: '',
pickError: '',
}; };
/** 상호명 비교용. 공백·괄호 표기가 후보마다 달라 그대로 비교하면 같은 가게도 안 맞는다. */
function normalizeName(name: string | undefined | null): string {
return (name ?? '').replace(/\s+/g, '').toLowerCase();
}
/** /**
* 상호 검색 → 동일 업소 후보 조회 → 사람이 고른 후보로 확정. * 상호명 공개 검색 → 사람이 고른 후보로 확정.
* *
* 백엔드 순서가 그대로 화면 순서다: * ★ 목록을 주는 건 **공개 검색**(`GET /v1/place/search`)이다. 인증이 없어 로그인 전에도 돌고,
* 1) POST /v1/place 사업장(껍데기)을 만든다 — 후보 조회가 place_id 를 요구한다 * 후보마다 추정 업종(category)이 함께 온다 — 업종을 사장님에게 먼저 묻지 않기로 한 결정이
* 2) GET /v1/place/{id}/verify/candidates?query= 외부 장소 DB 후보 * API 까지 내려온 자리다. 인증이 필요한 후보 조회(verify/candidates)는 목록을 그리는 데
* 3) POST /v1/place/{id}/verify 사람이 고른 후보로 동일 업소 확정 * 쓰지 않고, 고른 뒤 **네이버 플레이스 URL 을 찾는 데만** 쓴다.
*
* 확정은 백엔드 순서 그대로다:
* 1) POST /v1/place 사업장(껍데기) — 업종이 여기서 정해진다
* 2) GET /v1/place/{id}/verify/candidates?query= 네이버 플레이스 URL 찾기
* 3) POST /v1/place/{id}/verify/by-url 그 URL 로 동일 업소 확정
* *
* ★ 3 을 통과해야 수집이 열린다(place.py: "동일 업소 검증과 채널 URL 확정이 끝나야 시작할 수 있다"). * ★ 3 을 통과해야 수집이 열린다(place.py: "동일 업소 검증과 채널 URL 확정이 끝나야 시작할 수 있다").
* 그래서 직접 입력 경로는 수집 없이 사장님 입력만으로 사이트를 만든다 — 검증을 우회하지 않는다. * 그래서 로그인 전 경로는 수집 없이 사장님이 고른 상호·주소만으로 사이트를 만든다 — 검증을 우회하지 않는다.
*/ */
export function usePlaceSearch(industry: IndustryType, existingPlaceId: string | null = null) { export function usePlaceSearch(existingPlaceId: string | null = null) {
const [state, setState] = useState<PlaceSearchState>(INITIAL); const [state, setState] = useState<PlaceSearchState>(INITIAL);
const [isConfirming, setIsConfirming] = useState(false); const [isConfirming, setIsConfirming] = useState(false);
/** /**
@ -88,8 +88,10 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
* 되살아난 화면이 같은 가게를 한 번 더 만든다. * 되살아난 화면이 같은 가게를 한 번 더 만든다.
*/ */
const placeIdRef = useRef<string | null>(existingPlaceId); const placeIdRef = useRef<string | null>(existingPlaceId);
/** 그 사업장을 만들 때 쓴 업종. 업종이 바뀌면 재사용할 수 없다(아래 ensurePlace). */
const placeCategoryRef = useRef<PlaceCategory | null>(null);
const inflight = useRef<AbortController | null>(null); const inflight = useRef<AbortController | null>(null);
/** 마지막으로 검색한 상호. URL 확정 때 사업장 껍데기 이름으로 쓴다. */ /** 마지막으로 확정 시도한 상호. URL 확정 때 사업장 껍데기 이름으로 쓴다. */
const nameRef = useRef<string>(''); const nameRef = useRef<string>('');
const reset = useCallback(() => { const reset = useCallback(() => {
@ -98,128 +100,76 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
setState(INITIAL); setState(INITIAL);
}, []); }, []);
/** 사업장 껍데기 확보. 이미 만들었으면 상호만 맞춰 둔다(같은 위저드에서 두 번 만들지 않는다). */ /**
* 사업장 껍데기 확보.
*
* ★ 업종이 달라졌으면 **새로 만든다.** places.category 는 생성할 때만 정해지고 PATCH 로 고칠
* 수 없다(Req_UpdatePlace 에 category 가 없다). 그대로 재사용하면 카페로 만든 사업장에
* 숙박 스키마로 수집이 돌고 JSON-LD 타입도 어긋난다 — 화면도 빌드도 멀쩡한 채로 틀린다.
*/
const ensurePlace = useCallback( const ensurePlace = useCallback(
async (name: string, signal: AbortSignal): Promise<string | null> => { async (name: string, category: PlaceCategory, signal: AbortSignal): Promise<string | null> => {
if (placeIdRef.current) { const reusable =
placeIdRef.current !== null &&
(placeCategoryRef.current === null || placeCategoryRef.current === category);
if (reusable && placeIdRef.current) {
// PATCH 는 취소 신호를 받지 않는다(생성물 시그니처) — 뒤에서 signal 로 결과를 버린다. // PATCH 는 취소 신호를 받지 않는다(생성물 시그니처) — 뒤에서 signal 로 결과를 버린다.
await updatePlace(placeIdRef.current, {name}); await updatePlace(placeIdRef.current, {name});
if (signal.aborted) return null; if (signal.aborted) return null;
return placeIdRef.current; return placeIdRef.current;
} }
const created = await createPlace( const created = await createPlace({name, category}, undefined, signal);
{name, category: INDUSTRY_TO_CATEGORY[industry]},
undefined,
signal,
);
placeIdRef.current = created.place?.place_id ?? null; placeIdRef.current = created.place?.place_id ?? null;
placeCategoryRef.current = category;
return placeIdRef.current; return placeIdRef.current;
}, },
[industry], [],
); );
const search = useCallback( /**
async (name: string, location: string) => { * 상호명으로 후보를 찾는다 — **로그인 없이**.
nameRef.current = name.trim(); *
const query = [name.trim(), location.trim()].filter(Boolean).join(' '); * ★ 사업장을 만들지 않는다. 아직 "내 가게가 여기 있나"를 보는 중이고, 여기서 행을 만들면
if (!name.trim()) return; * 검색만 해보고 떠난 사람만큼 빈 사업장이 쌓인다.
*/
const searchPublic = useCallback(async (name: string, location: string) => {
const query = [name.trim(), location.trim()].filter(Boolean).join(' ');
if (query.length < 2) return;
inflight.current?.abort(); inflight.current?.abort();
const controller = new AbortController(); const controller = new AbortController();
inflight.current = controller; inflight.current = controller;
// ★ 자동 로그인이 켜져 있으면 끝날 때까지 기다린다. 이걸 안 기다리면 페이지를 열자마자 setState({...INITIAL, phase: 'searching'});
// 누른 검색이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다.
await ensureAutoSession();
// ★ 토큰이 없으면 화면(Step2)이 검색을 부르지 않고 입력값으로 넘어간다 — 2단계는 로그인 벽이 아니다. try {
// 그래도 여기 도달했다면 세션이 도중에 끊긴 것이므로, 로그인을 요구하지 말고 조용히 물러난다. const res = await searchPlacesPublic({q: query}, undefined, controller.signal);
if (!getAccessToken()) { if (controller.signal.aborted) return;
setState({...INITIAL});
return;
}
setState({...INITIAL, phase: 'searching'}); // ★ 이 엔드포인트의 거절은 HTTP 200 + result.success=false 로 온다(RemoveNoneResponse).
// 상태코드만 보면 "후보 0건"과 "분당 20회 초과"가 같은 화면이 된다.
try { if (res.result?.success === false) {
const placeId = await ensurePlace(name.trim(), controller.signal);
if (!placeId) throw new Error('사업장을 만들지 못했습니다.');
const res = await verifyCandidates(placeId, {query}, undefined, controller.signal);
if (controller.signal.aborted) return;
const source = res.source ?? null;
setState({
phase: 'done',
candidates: res.candidates ?? [],
outcome: res.outcome ?? '',
autoSelectable: res.auto_selectable ?? false,
source,
sourceLabel: source ? (SOURCE_LABEL[source] ?? '외부 장소 DB') : '외부 장소 DB',
unavailableReason: '',
});
} catch (error) {
if (controller.signal.aborted) return;
notifyApiError(error, '상호를 검색하지 못했습니다.');
setState({ setState({
...INITIAL, ...INITIAL,
phase: 'unavailable', phase: 'unavailable',
unavailableReason: unavailableReason:
'장소 DB 를 부르지 못했습니다. 백엔드가 떠 있는지 확인하시거나, 직접 입력으로 진행해 주세요.', describeError(res.result.desc) ?? '지도 검색을 사용할 수 없습니다.',
}); });
return;
} }
},
[ensurePlace],
);
/** setState({...INITIAL, phase: 'done', items: res.items ?? []});
* 사람이 고른 후보를 이 사업장의 신원으로 확정한다(POST /verify). } catch (error) {
* 성공하면 화면에 얹을 신원을 돌려준다 — 실패하면 null. if (controller.signal.aborted) return;
*/ notifyApiError(error, '상호를 검색하지 못했습니다.');
const confirm = useCallback( setState({
async (candidate: PlaceCandidate): Promise<ConfirmedIdentity | null> => { ...INITIAL,
const placeId = placeIdRef.current; phase: 'unavailable',
const address = candidate.road_address ?? candidate.address ?? ''; unavailableReason:
if (!placeId) return null; '장소 DB 를 부르지 못했습니다. 백엔드가 떠 있는지 확인하시거나, 직접 입력으로 진행해 주세요.',
});
setIsConfirming(true); }
try { }, []);
await verifyPlace(placeId, {
source: state.source ?? ExternalPlaceSource.KAKAO,
// 네이버는 고유 id 를 주지 않는다 — 그 경우 상호명 + 도로명주소가 중복 판정 키다.
external_place_id: candidate.external_place_id ?? '',
road_address: candidate.road_address ?? null,
address: candidate.address ?? null,
phone: candidate.phone ?? null,
latitude: candidate.latitude ?? null,
longitude: candidate.longitude ?? null,
place_url: candidate.place_url ?? null,
});
// ★ 상호도 후보 쪽으로 맞춘다. 사장님이 그 이름이 적힌 카드를 보고 "이 가게예요"를
// 눌렀는데 DB 에는 검색어로 친 이름이 남으면, 화면과 서버가 다른 상호를 들고 있게 된다.
const officialName = candidate.name?.trim();
if (officialName) await updatePlace(placeId, {name: officialName});
return {
placeId,
name: candidate.name ?? '',
address,
phone: candidate.phone ?? undefined,
origin: 'external',
sourceLabel: state.sourceLabel || '외부 장소 DB',
externalPlaceId: candidate.external_place_id ?? undefined,
placeUrl: candidate.place_url ?? undefined,
};
} catch (error) {
notifyApiError(error, '이 가게로 확정하지 못했습니다.');
return null;
} finally {
setIsConfirming(false);
}
},
[state.source, state.sourceLabel],
);
/** /**
* 네이버 플레이스 URL 로 확정한다. * 네이버 플레이스 URL 로 확정한다.
@ -230,10 +180,14 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
* 상호·주소는 서버가 그 URL 에서 읽어 온다(손으로 옮겨 적게 하면 오타가 남의 가게가 된다). * 상호·주소는 서버가 그 URL 에서 읽어 온다(손으로 옮겨 적게 하면 오타가 남의 가게가 된다).
*/ */
const confirmByUrl = useCallback( const confirmByUrl = useCallback(
async (url: string): Promise<ConfirmedIdentity | null> => { async (url: string, industry: IndustryType): Promise<ConfirmedIdentity | null> => {
setIsConfirming(true); setIsConfirming(true);
try { try {
const placeId = await ensurePlace(nameRef.current || '내 가게', new AbortController().signal); const placeId = await ensurePlace(
nameRef.current || '내 가게',
INDUSTRY_TO_CATEGORY[industry],
new AbortController().signal,
);
if (!placeId) return null; if (!placeId) return null;
const res = await verifyPlaceByUrl(placeId, {url}); const res = await verifyPlaceByUrl(placeId, {url});
const place = res.place; const place = res.place;
@ -261,20 +215,81 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
); );
/** /**
* 목록에 없거나 장소 DB 를 못 부를 때 — 사장님이 직접 댄 값으로 신원을 세운다. * 목록에 없거나 로그인 전이라 검증을 못 걸 때 — 사장님이 고른 값으로 신원만 세운다.
* *
* ★ 동일 업소 검증(POST /verify)을 하지 않는다. 검증 없이 수집을 열면 남의 가게 URL 을 * ★ 동일 업소 검증(POST /verify)을 하지 않는다. 검증 없이 수집을 열면 남의 가게 URL 을
* 긁을 수 있다. 그래서 이 경로는 수집 없이 직접 입력한 정보로만 사이트를 만든다. * 긁을 수 있다. 그래서 이 경로는 수집 없이 이 값들로만 사이트를 만든다.
*/ */
const confirmManual = useCallback((name: string, address: string): ConfirmedIdentity => { const confirmManual = useCallback(
return { (name: string, address: string, sourceLabel = '직접 입력'): ConfirmedIdentity => ({
placeId: placeIdRef.current, placeId: placeIdRef.current,
name: name.trim(), name: name.trim(),
address: address.trim(), address: address.trim(),
origin: 'owner', origin: 'owner',
sourceLabel: '직접 입력', sourceLabel,
}; }),
}, []); [],
);
return {...state, isConfirming, search, confirm, confirmByUrl, confirmManual, reset}; /**
* 공개 검색에서 고른 한 건을 이 위저드의 사업장으로 확정한다.
*
* ★ 로그인 전에는 서버를 부르지 않는다. 로그인 관문은 에디터 진입 하나이고(b94daa9),
* 사업장 생성·검증은 전부 토큰을 요구한다 — 여기서 부르면 첫 화면이 로그인 벽이 된다.
* 고른 후보의 **공식 상호·주소**로 신원을 세우고 넘어간다(검증은 로그인 뒤에 다시 걸 수 있다).
* ★ 로그인 상태면 공개 후보에 없는 것(네이버 플레이스 URL)을 인증 경로에서 한 번 더 찾아
* URL 확정까지 간다 — 그래야 수집이 열린다.
*/
const confirmPick = useCallback(
async (
pick: {name: string; address: string},
industry: IndustryType,
): Promise<ConfirmedIdentity | null> => {
nameRef.current = pick.name.trim();
setState((prev) => ({...prev, pickError: ''}));
// ★ 자동 로그인이 켜져 있으면 끝날 때까지 기다린다. 이걸 안 기다리면 페이지를 열자마자
// 누른 확정이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다.
await ensureAutoSession();
if (!getAccessToken()) return confirmManual(pick.name, pick.address, '지도 검색');
setIsConfirming(true);
try {
const controller = new AbortController();
const placeId = await ensurePlace(
pick.name.trim(),
INDUSTRY_TO_CATEGORY[industry],
controller.signal,
);
if (!placeId) throw new Error('사업장을 만들지 못했습니다.');
// 상호만으로는 동명 업소가 섞인다 — 사장님이 고른 그 주소까지 붙여 좁힌다.
const query = [pick.name.trim(), pick.address.trim()].filter(Boolean).join(' ');
const res = await verifyCandidates(placeId, {query}, undefined, controller.signal);
const candidates = res.candidates ?? [];
const hit =
candidates.find(
(c) => c.naver_place_url && normalizeName(c.name) === normalizeName(pick.name),
) ?? candidates.find((c) => c.naver_place_url);
if (!hit?.naver_place_url) {
setState((prev) => ({
...prev,
pickError:
'이 가게의 네이버 플레이스를 자동으로 찾지 못했습니다. 아래에서 지도 주소를 붙여넣어 주세요.',
}));
return null;
}
return await confirmByUrl(hit.naver_place_url, industry);
} catch (error) {
notifyApiError(error, '이 가게로 확정하지 못했습니다.');
return null;
} finally {
setIsConfirming(false);
}
},
[confirmByUrl, confirmManual, ensurePlace],
);
return {...state, isConfirming, searchPublic, confirmPick, confirmByUrl, confirmManual, reset};
} }

View File

@ -0,0 +1,96 @@
import {useCallback} from 'react';
import {useSearchParams} from 'react-router';
/**
* 위저드 단계는 **주소창이 소유한다** — `/builder?step=<이름>`.
*
* ★ 예전엔 스토어(zustand)의 `step` 이 화면을 골랐다. 브라우저가 보기엔 주소가 한 번도 안 바뀌니
* 뒤로가기가 이전 단계가 아니라 위저드 **밖으로** 나갔고, 북마크한 주소는 언제나 첫 화면으로 열렸다.
* ★ 번호가 아니라 이름을 쓴다. 단계는 늘고 준다(업종 선택이 첫 화면에서 빠지면서 뒤가 전부 한 칸
* 당겨졌다) — 번호를 주소에 박아 두면 그날 이후 옛 북마크·랜딩 링크가 조용히 다른 화면을 연다.
*/
export const WIZARD_STEPS = [
/** 상호명으로 내 가게 찾기 — 위저드의 시작점이다. */
'search',
/** 업종 직접 고르기. 검색 결과가 업종을 못 정했을 때, 또는 [바꾸기] 로 들어온다. */
'industry',
'collect',
'template',
'generating',
'editor',
] as const;
export type WizardStep = (typeof WIZARD_STEPS)[number];
/** 위저드가 끝나고 편집기로 넘어가는 단계. 숫자를 화면마다 외우지 않게 한 곳에 둔다. */
export const EDITOR_STEP: WizardStep = 'editor';
/**
* 진행 표시(WizardSteps)에 찍히는 번호.
*
* ★ 업종 화면은 '내 가게 확인' 안의 **갈래**라 같은 1번이다. 업종은 검색 결과가 정하고
* 못 정했을 때만 사람에게 묻는 것이라, 별도 단계로 세면 사장님은 안 겪을 수도 있는 단계를
* 진행 표시에서 계속 보게 된다.
* ★ 생성 화면은 진행 표시를 그리지 않는다(0 은 "표시할 번호가 없다"는 뜻이다).
*/
export const STEP_NUMBER: Record<WizardStep, number> = {
search: 1,
industry: 1,
collect: 2,
template: 3,
generating: 0,
editor: 0,
};
function parseStep(value: string | null): WizardStep | null {
return WIZARD_STEPS.includes(value as WizardStep) ? (value as WizardStep) : null;
}
/**
* 주소창에 `step` 이 없을 때 열리는 화면.
*
* ★ `?placeId=` 딥링크는 **편집하러 온 것**이다(내 사이트 목록의 [사이트 편집]) — 위저드를
* 다시 걷게 하지 않는다. 위저드가 방금 만든 사업장(`flow=onboarding`)만 예외다:
* 그건 아직 수집·템플릿을 지나는 중이고 그때는 step 이 항상 함께 붙어 있다.
*/
export function defaultStep(params: URLSearchParams): WizardStep {
return params.get('placeId') && params.get('flow') !== 'onboarding' ? EDITOR_STEP : 'search';
}
interface GoOptions {
/** 히스토리에 쌓지 않는다 — 화면이 바뀌지 않는 정정(주소창 정리)일 때만 쓴다. */
replace?: boolean;
/** 같은 이동에서 함께 바꿀 쿼리. null 이면 지운다. 단계와 함께 한 번에 바꿔야 중간 주소가 안 생긴다. */
params?: Record<string, string | null>;
}
/**
* 지금 단계와, 단계를 옮기는 함수.
*
* ★ 다른 쿼리(`placeId`·`flow`·`q`)는 건드리지 않고 `step` 만 갈아 끼운다 — 단계를 옮길 때마다
* 사업장 딥링크가 떨어져 나가면 새로고침 한 번에 어느 가게였는지가 사라진다.
*/
export function useWizardStep(): [WizardStep, (next: WizardStep, options?: GoOptions) => void] {
const [searchParams, setSearchParams] = useSearchParams();
const step = parseStep(searchParams.get('step')) ?? defaultStep(searchParams);
const go = useCallback(
(next: WizardStep, options?: GoOptions) => {
setSearchParams(
(prev) => {
const params = new URLSearchParams(prev);
params.set('step', next);
for (const [key, value] of Object.entries(options?.params ?? {})) {
if (value === null) params.delete(key);
else params.set(key, value);
}
return params;
},
{replace: options?.replace},
);
},
[setSearchParams],
);
return [step, go];
}

View File

@ -1,4 +1,4 @@
import type {InfoField, SectionItem, TemplateItem} from '@o2o/shared'; import type {InfoField, SectionItem, TemplateItem, TemplateLook} from '@o2o/shared';
import {ApiError} from '@/api'; import {ApiError} from '@/api';
import {notifyApiError} from '@/lib/notify'; import {notifyApiError} from '@/lib/notify';
import {callSiteApi, type ResultEnvelope} from './siteApi'; import {callSiteApi, type ResultEnvelope} from './siteApi';
@ -40,6 +40,13 @@ export interface SiteThemePayload {
* 서버는 어느 쪽도 해석하지 않는다. * 서버는 어느 쪽도 해석하지 않는다.
*/ */
colorPaletteId?: string | null; colorPaletteId?: string | null;
/**
* 템플릿의 생김새(서체·모서리·테두리·그림자·여백).
*
* ★ 이걸 안 실으면 발행본은 색만 템플릿을 따르고 서체는 늘 같은 것으로 나간다 —
* 사장님이 레트로를 골라도 발행 페이지만 고딕이었다. 서버는 해석하지 않고 보관만 한다.
*/
look?: TemplateLook;
sections: { sections: {
id: string; id: string;
/** /**
@ -81,6 +88,7 @@ export function toThemePayload(
return { return {
colors: template.colors, colors: template.colors,
fontStyle: template.fontStyle, fontStyle: template.fontStyle,
look: template.look,
colorPaletteId, colorPaletteId,
sections: sections.map((s) => ({ sections: sections.map((s) => ({
id: s.id, id: s.id,

View File

@ -17,7 +17,7 @@ import {useBuilderStore} from '@/stores/builder';
import type {ApplyPlaceOptions} from '@/stores/builderTypes'; import type {ApplyPlaceOptions} from '@/stores/builderTypes';
export function usePlaceSync(placeId: string | null, options?: ApplyPlaceOptions) { export function usePlaceSync(placeId: string | null, options?: ApplyPlaceOptions) {
const enterEditor = options?.enterEditor ?? true; const isOnboarding = options?.isOnboarding ?? false;
const enabled = Boolean(placeId); const enabled = Boolean(placeId);
const id = placeId ?? ''; const id = placeId ?? '';
@ -42,8 +42,8 @@ export function usePlaceSync(placeId: string | null, options?: ApplyPlaceOptions
/** /**
* ★ useEffect 가 아니라 useLayoutEffect 다. * ★ useEffect 가 아니라 useLayoutEffect 다.
* 일반 effect 는 페인트 뒤에 돌아서, 응답이 도착한 프레임에 위저드 1단계가 한 번 * 일반 effect 는 페인트 뒤에 돌아서, 응답이 도착한 프레임에 빈 편집기가 한 번 그려졌다가
* 그려졌다가 편집기로 바뀐다(깜빡임). 스토어에 얹는 일은 페인트 전에 끝나야 한다. * 값이 채워진다(깜빡임). 스토어에 얹는 일은 페인트 전에 끝나야 한다.
*/ */
useLayoutEffect(() => { useLayoutEffect(() => {
if (!placeId) { if (!placeId) {
@ -53,8 +53,10 @@ export function usePlaceSync(placeId: string | null, options?: ApplyPlaceOptions
if (!place) return; if (!place) return;
// fact 가 아직 안 왔어도 먼저 온 place 로 상호·주소부터 얹는다. // fact 가 아직 안 왔어도 먼저 온 place 로 상호·주소부터 얹는다.
// fact/스키마가 도착하면 이 effect 가 한 번 더 돌아 정보 표를 채운다. // fact/스키마가 도착하면 이 effect 가 한 번 더 돌아 정보 표를 채운다.
applyPlace(toLivePlaceInput(placeId, place, facts ?? [], specs ?? [], media ?? []), {enterEditor}); applyPlace(toLivePlaceInput(placeId, place, facts ?? [], specs ?? [], media ?? []), {
}, [placeId, place, facts, specs, media, applyPlace, clearPlace, enterEditor]); isOnboarding,
});
}, [placeId, place, facts, specs, media, applyPlace, clearPlace, isOnboarding]);
/** /**
* 서버에 저장된 디자인을 얹는다. * 서버에 저장된 디자인을 얹는다.

View File

@ -76,6 +76,57 @@ body {
} }
} }
/* 히어로 제목의 앞말만 갈아 끼운다 — '홈페이지' 는 고정이고 수식어가 돈다.
★ CSS 만으로 돈다(마퀴와 같은 이유). 문구를 **일곱 줄** 쌓는다 — 마지막 줄이 첫 줄의
복제라 -85.714% 에서 0% 로 되감을 때 글자가 튀지 않는다.
★ 줄 높이를 1.25em 으로 못 박는다. 창이 1줄이고 트랙이 7줄이라 한 칸이 정확히 1/7 이어야 한다.
★ 문구 개수(6)와 이 키프레임은 한 몸이다. 문구를 늘리면 여기 stop 도 같이 고쳐야 한다 —
안 고치면 마지막 몇 개가 영영 안 보이거나 빈 줄이 지나간다(LandingPage HEADLINES). */
@keyframes o2o-rotate-6 {
0%, 14% { transform: translateY(0); }
16.67%, 30.67% { transform: translateY(-14.286%); }
33.33%, 47.33% { transform: translateY(-28.571%); }
50%, 64% { transform: translateY(-42.857%); }
66.67%, 80.67% { transform: translateY(-57.143%); }
83.33%, 97.33% { transform: translateY(-71.429%); }
100% { transform: translateY(-85.714%); }
}
.o2o-rotator {
display: block;
height: 1.25em;
overflow: hidden;
}
.o2o-rotator-track {
/* ★ display:block 이 없으면 아무 일도 안 일어난다. 이 요소는 <span> 이라 기본이 inline 이고,
**인라인 요소에는 transform 이 적용되지 않는다** — 애니메이션은 걸려 있는데 화면은 정지다. */
display: block;
animation: o2o-rotate-6 16s cubic-bezier(0.65, 0, 0.35, 1) infinite;
}
.o2o-rotator-track > span {
display: block;
height: 1.25em;
line-height: 1.25;
}
@media (prefers-reduced-motion: reduce) {
.o2o-rotator-track { animation: none; }
}
/* 랜딩 히어로의 발행 사이트 마퀴. **CSS 만으로 돈다** — 타이머를 쓰면 탭이 백그라운드일 때
프레임이 밀려 끊긴 것처럼 보인다. 트랙에 같은 목록을 두 벌 넣고 -50% 까지 밀면 이음매가 없다. */
@keyframes o2o-marquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.o2o-marquee-track {
animation: o2o-marquee var(--marquee-duration, 48s) linear infinite;
}
.o2o-marquee:hover .o2o-marquee-track {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
.o2o-marquee-track { animation: none; }
}
/* 수집 진행바 shimmer — 0%(빈 트랙) 구간에서도 '진행 중'이 보이게 한다. */ /* 수집 진행바 shimmer — 0%(빈 트랙) 구간에서도 '진행 중'이 보이게 한다. */
@keyframes o2o-shimmer { @keyframes o2o-shimmer {
from { transform: translateX(-100%); } from { transform: translateX(-100%); }

View File

@ -1,37 +1,7 @@
/** /**
* 색 계산 — 디자인 토큰에서 '면 단계'를 만들 때 쓴다. * 색 계산은 계약 패키지가 소유한다(`@o2o/shared/lib/color.ts`).
* *
* ★ 쇼케이스(개발 도구)와 실제 캔버스가 **같은 식**을 써야 한다. * ★ 발행 사이트도 같은 식으로 면 토큰을 만든다. 두 벌로 두면 캔버스와 발행본의 바탕색이 갈린다.
* 미리보기에서 예쁜 조합이 발행 화면에서 달라지면 미리보기가 거짓말이 된다. * 여기는 기존 import 경로(`@/lib/color`)를 지키는 재수출만 남긴다.
*/ */
export {mix, deriveSurfaces} from '@o2o/shared';
function rgb(hex: string): [number, number, number] {
const n = parseInt(hex.replace('#', ''), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
/** 두 색을 섞는다. ratio=0 이면 a, 1 이면 b. */
export function mix(a: string, b: string, ratio: number): string {
// hex 가 아닌 값(css 변수·색 이름)이 들어오면 계산이 무의미하다 — 원본을 그대로 돌려준다.
if (!/^#[0-9a-fA-F]{6}$/.test(a) || !/^#[0-9a-fA-F]{6}$/.test(b)) return a;
const [ar, ag, ab] = rgb(a);
const [br, bg, bb] = rgb(b);
const ch = (x: number, y: number) => Math.round(x + (y - x) * ratio);
const hex = (n: number) => n.toString(16).padStart(2, '0');
return `#${hex(ch(ar, br))}${hex(ch(ag, bg))}${hex(ch(ab, bb))}`;
}
/**
* 템플릿 색 6개 → 면 토큰 4개.
*
* 캔버스와 쇼케이스가 공유하는 유도 규칙이다. 섹션 기본(bg) · paper · tint 가
* 서로 다른 밝기여야 경계선 없이도 섹션이 나뉜다.
*/
export function deriveSurfaces(colors: {bg: string; card: string; text: string; secondary: string}) {
return {
surface: mix(colors.bg, colors.text, 0.06),
surfaceAlt: colors.card,
inverse: '#1c1917',
border: colors.secondary,
};
}

View File

@ -26,6 +26,10 @@ export const ERROR_MESSAGE: Record<string, string> = {
PLACE_NOT_VERIFIED: '동일 업소 검증을 먼저 끝내야 합니다. 검증 전에는 수집·발행이 열리지 않습니다.', PLACE_NOT_VERIFIED: '동일 업소 검증을 먼저 끝내야 합니다. 검증 전에는 수집·발행이 열리지 않습니다.',
PLACE_VERIFY_NO_CANDIDATE: '외부 장소 정보에서 이 상호를 찾지 못했습니다.', PLACE_VERIFY_NO_CANDIDATE: '외부 장소 정보에서 이 상호를 찾지 못했습니다.',
PLACE_VERIFY_AMBIGUOUS: '같은 이름의 업소가 여럿입니다. 어느 곳인지 골라 주세요.', PLACE_VERIFY_AMBIGUOUS: '같은 이름의 업소가 여럿입니다. 어느 곳인지 골라 주세요.',
// 상호명 공개 검색(로그인 전 첫 화면)이 만나는 세 가지. 인증이 없는 경로라 분당 상한이 걸려 있다.
HTTP_TO_MANY_REQUEST: '검색을 너무 자주 했습니다. 잠시 뒤 다시 시도해 주세요.',
LOCAL_NOT_CONFIGURED: '지도 검색이 아직 설정되지 않았습니다. 네이버 지도 주소를 붙여넣어 진행해 주세요.',
LOCAL_FETCH_FAILED: '지도에서 가게를 찾지 못했습니다. 잠시 뒤 다시 시도하거나 지도 주소를 붙여넣어 주세요.',
LINK_NOT_CONFIRMED: '확정한 채널 URL 만 수집 대상이 됩니다.', LINK_NOT_CONFIRMED: '확정한 채널 URL 만 수집 대상이 됩니다.',
// fact // fact

View File

@ -8,7 +8,9 @@
* ★ client_id 는 비밀이 아니다(번들에 그대로 들어간다). 이 값으로 할 수 있는 건 "우리 앱 앞으로" * ★ client_id 는 비밀이 아니다(번들에 그대로 들어간다). 이 값으로 할 수 있는 건 "우리 앱 앞으로"
* 토큰을 받는 것뿐이고, 그 토큰이 우리 계정이 되려면 백엔드의 aud 대조를 통과해야 한다. * 토큰을 받는 것뿐이고, 그 토큰이 우리 계정이 되려면 백엔드의 aud 대조를 통과해야 한다.
*/ */
const SCRIPT_URL = 'https://accounts.google.com/gsi/client'; // ★ 언어는 **스크립트 URL 의 hl** 로 잡는다. renderButton 의 locale 옵션은 안 먹었다
// (locale:'ko'·'ko_KR' 둘 다 'Continue with Google' 이 그대로 나왔다 — 실측).
const SCRIPT_URL = 'https://accounts.google.com/gsi/client?hl=ko';
const SCRIPT_ID = 'google-identity-services'; const SCRIPT_ID = 'google-identity-services';
export const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID ?? ''; export const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID ?? '';

View File

@ -0,0 +1,144 @@
import {useEffect, useState, type FormEvent} from 'react';
import {KeyRound, Loader2} from 'lucide-react';
import {AuthProvider, updateMe, useMe} from '@/api';
import {AppShell, PageContainer} from '@/components/layout/AppShell';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {notify, notifyApiError} from '@/lib/notify';
import {toAuthUser, useAuthStore} from '@/stores/auth';
/**
* 내 정보 — `PATCH /v1/auth/me` 한 곳이 받는 것만 그린다.
*
* ★ 상호(company)는 읽기 전용이다. Req_UpdateMe 에 없다 — 자기 소속을 스스로 바꾸지 못하게
* 일부러 뺀 필드라, 입력칸을 두면 저장을 눌러도 아무 일이 안 일어난다.
* ★ 구글 계정에는 바꿀 비밀번호가 없다(서버가 ACCOUNT_PROVIDER_CONFLICT 로 막는다) —
* 입력칸 자체를 그리지 않는다.
*/
export function AccountPage() {
const {data, isLoading, refetch} = useMe();
const setUser = useAuthStore((s) => s.setUser);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [contact, setContact] = useState('');
const [password, setPassword] = useState('');
const [isSaving, setIsSaving] = useState(false);
// 서버 값이 도착하면 한 번 채운다. 타이핑 중에 덮어쓰지 않게 응답이 바뀔 때만 돈다.
useEffect(() => {
if (!data) return;
setName(data.name ?? '');
setEmail(data.email ?? '');
setContact(data.contact_number ?? '');
}, [data]);
const isGoogle = data?.provider === AuthProvider.GOOGLE;
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
setIsSaving(true);
try {
const res = await updateMe({
name,
email,
contact_number: contact,
...(password ? {password} : {}),
});
if (!res.result?.success) {
notifyApiError({data: res}, '저장하지 못했습니다.');
return;
}
// 사이드바가 이름을 들고 있다 — 저장하고 스토어를 안 갱신하면 새로고침 전까지 옛 이름이다.
if (res.user_id && res.id) setUser(toAuthUser(res));
setPassword('');
notify.success('저장했습니다.');
await refetch();
} catch (error) {
notifyApiError(error, '저장하지 못했습니다.');
} finally {
setIsSaving(false);
}
};
return (
<AppShell>
<PageContainer title="내 정보" description="이름·연락처와 로그인 정보를 관리합니다.">
{isLoading ? (
<div className="flex items-center justify-center py-20 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
) : (
<form onSubmit={handleSubmit} className="max-w-lg space-y-5">
<section className="space-y-3 rounded-xl border border-border bg-card p-5">
<Field label="로그인 아이디">
<p className="text-sm">{isGoogle ? (data?.email ?? '구글 계정') : (data?.id ?? '')}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{isGoogle ? '구글 계정으로 로그인합니다.' : '아이디는 바꿀 수 없습니다.'}
</p>
</Field>
<Field label="상호">
<p className="text-sm">{data?.company?.name ?? '-'}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
상호 변경은 고객센터로 문의해 주세요.
</p>
</Field>
</section>
<section className="space-y-4 rounded-xl border border-border bg-card p-5">
<Field label="이름">
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="홍길동" />
</Field>
<Field label="이메일">
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="owner@example.com"
/>
</Field>
<Field label="연락처">
<Input value={contact} onChange={(e) => setContact(e.target.value)} placeholder="010-0000-0000" />
</Field>
</section>
{!isGoogle && (
<section className="space-y-4 rounded-xl border border-border bg-card p-5">
<Field label="새 비밀번호">
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="비우면 지금 비밀번호를 그대로 씁니다"
autoComplete="new-password"
/>
</Field>
</section>
)}
{isGoogle && (
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<KeyRound className="size-3.5" />
구글 계정이라 비밀번호가 없습니다 — 비밀번호는 구글에서 관리합니다.
</p>
)}
<Button type="submit" variant="primary" isLoading={isSaving}>
저장
</Button>
</form>
)}
</PageContainer>
</AppShell>
);
}
function Field({label, children}: {label: string; children: React.ReactNode}) {
return (
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</span>
{children}
</label>
);
}

View File

@ -1,26 +1,34 @@
import {useEffect, useRef} from 'react'; import {useEffect} from 'react';
import {ArrowLeft, ExternalLink, Loader2, TriangleAlert} from 'lucide-react'; import {ArrowLeft, ExternalLink, Loader2, LogOut, TriangleAlert} from 'lucide-react';
import {Link, useSearchParams} from 'react-router'; import {Link, useSearchParams} from 'react-router';
import {SiteStatus} from '@o2o/shared'; import {SiteStatus, type IndustryType} from '@o2o/shared';
import {getAccessToken} from '@/api'; import {getAccessToken} from '@/api';
import {AppShell} from '@/components/layout/AppShell';
import {EditorSignInGate} from '@/features/auth/EditorSignInGate'; import {EditorSignInGate} from '@/features/auth/EditorSignInGate';
import { import {
EDITOR_STEP,
Step1Industry, Step1Industry,
Step2PlaceSearch, Step2PlaceSearch,
Step3DataReview, Step3DataReview,
Step4Template, Step4Template,
Step5Generating, Step5Generating,
useWizardStep,
} from '@/features/onboarding'; } from '@/features/onboarding';
import {EditorLayout} from '@/features/builder'; import {EditorLayout} from '@/features/builder';
import {useAutoLogin} from '@/hooks/useAutoLogin'; import {useAutoLogin} from '@/hooks/useAutoLogin';
import {usePlaceSync} from '@/hooks/usePlaceSync'; import {usePlaceSync} from '@/hooks/usePlaceSync';
import {useAuthStore} from '@/stores/auth'; import {userLabel, useAuthStore} from '@/stores/auth';
import {EDITOR_STEP, useBuilderStore} from '@/stores/builder'; import {useBuilderStore} from '@/stores/builder';
/** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */ /** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */
const SITE_PREVIEW_URL = import.meta.env.VITE_SITE_PREVIEW_URL ?? window.location.origin; const SITE_PREVIEW_URL = import.meta.env.VITE_SITE_PREVIEW_URL ?? window.location.origin;
/** 랜딩이 `?industry=` 로 넘길 수 있는 값. 주소창 값이라 아무 문자열이나 들어올 수 있다. */
const INDUSTRY_VALUES: IndustryType[] = ['stay', 'cafe', 'restaurant', 'clinic'];
function parseIndustry(value: string | null): IndustryType | null {
return INDUSTRY_VALUES.includes(value as IndustryType) ? (value as IndustryType) : null;
}
/** /**
* "발행본 사이트 열기" 가 향할 주소. * "발행본 사이트 열기" 가 향할 주소.
* *
@ -37,32 +45,55 @@ function siteUrl(domain: string | null | undefined): string | null {
export function BuilderPage() { export function BuilderPage() {
useAutoLogin(); useAutoLogin();
/** /**
* 어떤 사업장을 편집할지는 쿼리스트링으로 받는다 — `/builder?placeId=<uuid>`. * ★ 이 화면이 무엇을 그릴지는 **전부 주소창이 정한다.**
* *
* ★ 라우트(`/builder/:placeId`)로 받지 않는 이유: 빌더는 로그인 없이 도는 데모 경로이고 * ?step= 어느 단계인가(없으면 wizardUrl.defaultStep 이 정한다)
* (router.tsx 주석), placeId 는 있을 수도 없을 수도 있는 선택값이다. 쿼리스트링이면 * ?placeId= 어떤 사업장인가 — 라우트(`/builder/:placeId`)로 받지 않는 이유는
* 라우트를 하나도 안 건드리고 두 경우를 같은 화면이 받는다. * 빌더가 로그인 없이 도는 경로이고 placeId 는 있을 수도 없을 수도 있어서다.
* placeId 가 없으면 아래 훅은 네트워크를 한 번도 타지 않는다 — 데모는 지금 그대로다. * ?flow=onboarding 위저드가 방금 만든 사업장이다(딥링크로 편집하러 온 것과 구분한다)
* ?new=1 ?q= ?industry= 랜딩에서 넘어온 입구. 한 번 읽고 주소창에서 지운다.
*/ */
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [step, goToStep] = useWizardStep();
const urlPlaceId = searchParams.get('placeId'); const urlPlaceId = searchParams.get('placeId');
const isOnboarding = searchParams.get('flow') === 'onboarding';
/** /**
* `?new=1` 로 들어오면 위저드를 1단계(업종 선택)부터 시작한다. * 랜딩에서 넘어온 입구를 한 번만 읽는다 — `?new=1` · `?q=` · `?industry=`.
* *
* ★ 저장된 상태를 그대로 두면 지난번 에디터가 복원된다 — 새 가게를 만들러 온 사람에게는 * ★ `?new=1` 은 저장된 상태를 비운다. 안 비우면 새 가게를 만들러 온 사람에게 지난번 에디터가
* 자기가 만든 적 없는 화면이 뜨는 셈이다. 비운 뒤에는 주소창에서 플래그를 지워, * 복원돼 뜬다. 읽은 뒤 주소창에서 지우는 이유는 두 가지다 — 새로고침마다 작업하던 내용이
* 새로고침할 때마다 작업하던 내용이 날아가지 않게 한다. * 날아가지 않게, 그리고 사장님이 화면에서 고친 상호·업종을 링크 값이 도로 덮지 않게.
* ★ **다른 쿼리는 남긴다.** 예전엔 `setSearchParams({})` 로 통째로 비웠는데, 그러면
* `?new=1&q=...` 로 들어온 상호가 읽히기도 전에 사라진다.
*/ */
const reset = useBuilderStore((s) => s.reset); const reset = useBuilderStore((s) => s.reset);
const selectIndustry = useBuilderStore((s) => s.selectIndustry);
const setStoreName = useBuilderStore((s) => s.setStoreName);
const isNew = searchParams.get('new') === '1'; const isNew = searchParams.get('new') === '1';
const seedQuery = searchParams.get('q');
const seedIndustry = searchParams.get('industry');
useEffect(() => { useEffect(() => {
if (!isNew) return; if (!isNew && seedQuery === null && seedIndustry === null) return;
reset(); if (isNew) reset();
setSearchParams({}, {replace: true}); // 순서가 뒤집히면 안 된다 — selectIndustry 는 시드를 통째로 갈아 상호를 비운다.
}, [isNew, reset, setSearchParams]); const industry = parseIndustry(seedIndustry);
if (industry) selectIndustry(industry);
if (seedQuery) setStoreName(seedQuery);
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.delete('new');
next.delete('q');
next.delete('industry');
return next;
},
{replace: true},
);
}, [isNew, seedQuery, seedIndustry, reset, selectIndustry, setStoreName, setSearchParams]);
/** /**
* 위저드 2단계에서 확정한 사업장. 주소창에 placeId 가 없어도 이걸로 배선한다. * 위저드 1단계에서 확정한 사업장. 주소창에 placeId 가 없어도 이걸로 배선한다.
* *
* ★ 이게 없으면 위저드를 끝까지 걸어온 사장님이 에디터에서 **업종 예시값**을 본다 — * ★ 이게 없으면 위저드를 끝까지 걸어온 사장님이 에디터에서 **업종 예시값**을 본다 —
* 방금 27건을 확인해 놓고 '독채 3개 동' 같은 남의 가게 값이 뜬다. 실제로 그랬다. * 방금 27건을 확인해 놓고 '독채 3개 동' 같은 남의 가게 값이 뜬다. 실제로 그랬다.
@ -70,25 +101,14 @@ export function BuilderPage() {
*/ */
const wizardPlaceId = useBuilderStore((s) => s.confirmedIdentity?.placeId ?? null); const wizardPlaceId = useBuilderStore((s) => s.confirmedIdentity?.placeId ?? null);
const placeId = urlPlaceId ?? wizardPlaceId; const placeId = urlPlaceId ?? wizardPlaceId;
/** const sync = usePlaceSync(placeId, {isOnboarding});
* 에디터로 바로 들어갈지.
*
* ★ **처음 들어온 순간**의 주소창만 본다. 위저드 2단계가 확정 뒤에 `?placeId=` 를 붙이는데
* (새로고침 복원용), 그걸 실시간으로 보면 "URL 붙여넣고 확인 → 곧장 에디터" 가 된다 —
* 수집 결과를 보여주는 3·4·5단계가 통째로 건너뛰어진다. 실제로 그렇게 됐다.
* 사업장 목록에서 딥링크로 들어온 경우(이미 만든 가게를 여는 것)만 에디터로 보낸다.
*/
const enteredWithPlace = useRef(
Boolean(urlPlaceId) && searchParams.get('flow') !== 'onboarding',
).current;
const sync = usePlaceSync(placeId, {enterEditor: enteredWithPlace});
const step = useBuilderStore((s) => s.step);
const goToStep = useBuilderStore((s) => s.goToStep);
const storeName = useBuilderStore((s) => s.storeName); const storeName = useBuilderStore((s) => s.storeName);
// 에디터는 AppShell(사이드바)을 안 쓴다 — 누구로 로그인했는지·나가는 길이 여기 없으면 아예 없다.
const user = useAuthStore((s) => s.user);
const signOut = useAuthStore((s) => s.signOut);
// ★ 스토어의 user 만 보면 자동 로그인이 심어 둔 토큰을 놓친다 — 둘 다 본다. // ★ 스토어의 user 만 보면 자동 로그인이 심어 둔 토큰을 놓친다 — 둘 다 본다.
const authUser = useAuthStore((s) => s.user); const isSignedIn = Boolean(user) || Boolean(getAccessToken());
const isSignedIn = Boolean(authUser) || Boolean(getAccessToken());
// 배지는 주소창이 아니라 스토어가 기준이다 — [처음부터]로 데모로 돌아간 뒤에도 // 배지는 주소창이 아니라 스토어가 기준이다 — [처음부터]로 데모로 돌아간 뒤에도
// 주소창에는 placeId 가 남아 있어서, 그걸 믿으면 데모를 실사업장이라고 표시한다. // 주소창에는 placeId 가 남아 있어서, 그걸 믿으면 데모를 실사업장이라고 표시한다.
const wiredPlaceId = useBuilderStore((s) => s.placeId); const wiredPlaceId = useBuilderStore((s) => s.placeId);
@ -127,7 +147,7 @@ export function BuilderPage() {
// 에디터에 들어갈 때 로그인을 받는다. 위저드(1~5단계)는 요구하지 않는다. // 에디터에 들어갈 때 로그인을 받는다. 위저드(1~5단계)는 요구하지 않는다.
if (step === EDITOR_STEP && !isSignedIn) { if (step === EDITOR_STEP && !isSignedIn) {
return <EditorSignInGate onBack={() => goToStep(4)} />; return <EditorSignInGate onBack={() => goToStep('template')} />;
} }
if (step === EDITOR_STEP) { if (step === EDITOR_STEP) {
@ -144,10 +164,14 @@ export function BuilderPage() {
<span className="truncate rounded bg-success/20 px-2 py-0.5 font-semibold text-success"> <span className="truncate rounded bg-success/20 px-2 py-0.5 font-semibold text-success">
실사업장 · {storeName} 실사업장 · {storeName}
</span> </span>
{/* ★ 예전엔 여기 [사업장 목록] 링크가 있었다. 그 화면은 내부 운영 앱(admin)으로 {/* 돌아가는 길. 로그인한 사람에게만 목록이 있다(비로그인은 에디터에 못 들어온다). */}
나갔고, 사장님 앱에는 그 경로가 없다 — 남겨두면 404 다. admin 은 빌더를 <Link
새 탭으로 열므로(admin/src/lib/solutionUrl.ts) 돌아가는 길은 탭 닫기다. to="/sites"
사장님용 "내 사이트 관리"가 생기면 그때 이 자리에 잇는다. */} className="flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-background/70 transition-colors hover:bg-white/10 hover:text-background"
>
<ArrowLeft className="size-3" />
<span>내 사이트</span>
</Link>
</> </>
) : ( ) : (
<span className="truncate opacity-80"> <span className="truncate opacity-80">
@ -158,26 +182,49 @@ export function BuilderPage() {
{/* 캔버스는 미리보기다. 진짜 발행본은 별도 렌더러(site)가 굽는다 — {/* 캔버스는 미리보기다. 진짜 발행본은 별도 렌더러(site)가 굽는다 —
같은 화면을 두 번 구현하지 않고, 그쪽을 새 탭으로 연다. 같은 화면을 두 번 구현하지 않고, 그쪽을 새 탭으로 연다.
★ 발행 전에는 열지 않는다 — 굽지 않은 주소를 열면 404 다. */} ★ 발행 전에는 열지 않는다 — 굽지 않은 주소를 열면 404 다. */}
{publishedUrl ? ( <div className="flex shrink-0 items-center gap-2">
<a {publishedUrl ? (
href={publishedUrl} <a
target="_blank" href={publishedUrl}
rel="noopener noreferrer" target="_blank"
className="flex shrink-0 items-center gap-1 rounded-md bg-warning px-2.5 py-1 font-bold text-black transition-all hover:opacity-90" rel="noopener noreferrer"
> className="flex shrink-0 items-center gap-1 rounded-md bg-warning px-2.5 py-1 font-bold text-black transition-all hover:opacity-90"
<ExternalLink className="size-3" /> >
<span>발행본 사이트 열기</span> <ExternalLink className="size-3" />
</a> <span>발행본 사이트 열기</span>
) : ( </a>
<span ) : (
title="아직 발행 전입니다 — [사이트 발행] 을 마치면 열립니다." <span
aria-disabled="true" title="아직 발행 전입니다 — [사이트 발행] 을 마치면 열립니다."
className="flex shrink-0 cursor-not-allowed items-center gap-1 rounded-md bg-white/10 px-2.5 py-1 font-bold text-background/50" aria-disabled="true"
> className="flex shrink-0 cursor-not-allowed items-center gap-1 rounded-md bg-white/10 px-2.5 py-1 font-bold text-background/50"
<ExternalLink className="size-3" /> >
<span>발행본 사이트 열기</span> <ExternalLink className="size-3" />
</span> <span>발행본 사이트 열기</span>
)} </span>
)}
{user && (
<>
<span className="h-3.5 w-px bg-white/20" />
<span
className="max-w-[14rem] truncate text-background/70"
title={user.companyName ? `${userLabel(user)} · ${user.companyName}` : userLabel(user)}
>
{userLabel(user)}
{user.companyName ? ` · ${user.companyName}` : ''}
</span>
<button
type="button"
onClick={signOut}
className="flex shrink-0 cursor-pointer items-center gap-1 rounded-md px-1.5 py-1 text-background/70 transition-colors hover:bg-white/10 hover:text-background"
>
<LogOut className="size-3" />
<span>로그아웃</span>
</button>
</>
)}
</div>
</div> </div>
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1">
@ -187,18 +234,46 @@ export function BuilderPage() {
); );
} }
// 위저드는 관리자 화면의 일부다 — 사이드바(로고·사업장·로그아웃)를 그대로 쓴다. /**
// 에디터(EDITOR_STEP)만 전체 화면이라 위에서 먼저 빠져나간다. * 위저드는 **사이드바를 쓰지 않는다.**
*
* ★ 사이드바는 계정 메뉴(내 사이트·새 사이트)다. 아직 사이트가 아닌 것 위에 사이트 메뉴를
* 얹으면, 만들던 중에 [새 사이트]를 눌러 방금 입력한 것을 지우는 길만 열어 준다.
* 진행은 단계가 이미 보여주므로(WizardSteps) 여기 필요한 건 로고와 **나가는 길** 하나다.
*/
return ( return (
<AppShell> <div className="flex h-screen w-screen flex-col overflow-hidden bg-background text-foreground">
<div className="flex h-full min-h-full flex-col"> <div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-2.5">
{step === 1 && <Step1Industry />} <Link to="/" className="transition-opacity hover:opacity-60">
{step === 2 && <Step2PlaceSearch />} <img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-6 w-auto" />
{step === 3 && <Step3DataReview />} </Link>
{step === 4 && <Step4Template />} {/* 비로그인은 돌아갈 목록이 없다 — 그 자리에는 로그인을 둔다(빈 버튼을 두지 않는다). */}
{step === 5 && <Step5Generating />} {isSignedIn ? (
<Link
to="/sites"
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<ArrowLeft className="size-3.5" />
내 사이트
</Link>
) : (
<Link
to="/login"
className="rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
로그인
</Link>
)}
</div> </div>
</AppShell>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{step === 'search' && <Step2PlaceSearch />}
{step === 'industry' && <Step1Industry />}
{step === 'collect' && <Step3DataReview />}
{step === 'template' && <Step4Template />}
{step === 'generating' && <Step5Generating />}
</div>
</div>
); );
} }

View File

@ -0,0 +1,281 @@
import {useState, type FormEvent} from 'react';
import {Link, useNavigate} from 'react-router';
import {ArrowRight, ArrowUp, Check} from 'lucide-react';
import {MarketingShell, Section, SectionHead} from '@/components/layout/MarketingShell';
import {ShowcaseGrid, ShowcasePeeks} from '@/features/marketing/ShowcaseGrid';
import {Button} from '@/components/ui/button';
/**
* 히어로 제목의 앞말. '홈페이지' 는 고정이고 이 여섯이 돌아간다.
* ★ 개수를 바꾸면 index.css 의 `o2o-rotate-6` 키프레임도 같이 고쳐야 한다 —
* 안 고치면 뒤쪽 문구가 영영 안 보이거나 빈 줄이 지나간다. 조용히 틀리는 종류다.
*/
const HEADLINES = [
'SEO · AEO 최적화',
'AI가 먼저 찾는',
'챗GPT가 인용하는',
'검색에 바로 걸리는',
'손님이 먼저 만나는',
'우리 가게가 직접 말하는',
];
/**
* 랜딩 — 원페이지. 로그인 전 첫 화면이다.
*
* ★ 파는 것은 "예쁜 홈페이지"가 아니라 **AI 답변에서의 1차 출처 지위**다(PRODUCT.md 1절).
* 문구도 그 축에서만 쓴다 — '쉽게·빠르게·저렴하게'로 말하는 순간 홈페이지 빌더와
* 같은 자리에서 비교당하고, 그 축에서는 이길 수 없다.
*
* ★ 상단이 받는 건 **상호명**이지 업종이 아니다. 사장님은 자기 가게 이름은 100% 알지만
* 업종은 경계에서 멈춘다("우리는 카페인가 음식점인가"). 업종은 검색 결과의 분류로
* 자동으로 정해지고(services/place_category.py), 못 정하면 그때 고르게 한다.
*
* ★ 여기서 로그인을 받지 않는다. 관문은 에디터 진입 하나다(b94daa9).
*/
export function LandingPage() {
const navigate = useNavigate();
const [query, setQuery] = useState('');
const submit = (event: FormEvent) => {
event.preventDefault();
const q = query.trim();
// ★ `new=1` 을 함께 보낸다. 안 보내면 지난번에 만들다 만 에디터가 복원돼 뜬다
// (stores/builder persist — BuilderPage 주석).
navigate(q ? `/builder?new=1&q=${encodeURIComponent(q)}` : '/builder?new=1');
};
return (
<MarketingShell>
{/* ── 상단 ─────────────────────────────────────── */}
<section className="relative flex min-h-[calc(100dvh-4rem)] items-center overflow-hidden border-b border-border">
<div className="mx-auto w-full max-w-6xl px-5 text-center">
<h1 className="mx-auto text-4xl leading-[1.15] font-extrabold tracking-[-0.045em] sm:text-6xl lg:text-7xl">
{/* 앞말만 돈다. 마지막 항목은 첫 항목의 복제다 — 되감을 때 글자가 튀지 않게(index.css). */}
<span className="o2o-rotator">
<span className="o2o-rotator-track">
{[...HEADLINES, HEADLINES[0]].map((line, index) => (
<span key={index} aria-hidden={index > 0}>
{line}
</span>
))}
</span>
</span>
홈페이지
</h1>
<p className="mx-auto mt-6 text-base text-muted-foreground sm:text-lg">
가게 이름 한 줄로 시작해 보세요
</p>
{/* ★ 입력 카드와 발행 사이트 띠를 **같은 밴드**에 겹친다(아임웹과 같은 구조).
띠를 카드 아래 따로 두면 첫 화면이 세로로 길어지고, 카드가 뜬금없이 혼자 뜬다. */}
<div className="relative mt-14">
<div className="pointer-events-none absolute top-1/2 left-1/2 w-screen -translate-x-1/2 -translate-y-1/2">
<ShowcasePeeks />
</div>
<form
onSubmit={submit}
className="relative z-10 mx-auto max-w-2xl rounded-2xl border border-border bg-card p-4 text-left shadow-[0_18px_50px_-20px_rgb(20_22_34/0.35)]"
>
<div className="flex items-center gap-2">
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-4 w-auto" />
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
가게 이름으로 시작
</span>
{/* 업종부터 고르고 싶은 사람을 위한 갈래. 위저드의 업종 단계로 바로 보낸다. */}
<Link
to="/builder?new=1&step=industry"
className="ml-auto rounded-full border border-border px-3 py-1 text-xs text-muted-foreground transition-colors hover:border-foreground/25 hover:text-foreground"
>
업종부터 고르기
</Link>
</div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="가게 이름을 입력하세요"
aria-label="가게 이름"
className="mt-4 w-full border-0 bg-transparent px-1 text-base outline-none placeholder:text-muted-foreground sm:text-lg"
/>
<div className="mt-8 flex items-center justify-between gap-3">
<p className="truncate text-xs text-muted-foreground">
숙박 · 카페 · 음식점 · 피부과 · 성형외과
</p>
<button
type="submit"
aria-label="확인하기"
className="grid size-9 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground transition-colors hover:bg-primary/90"
>
<ArrowUp className="size-4" aria-hidden />
</button>
</div>
</form>
</div>
</div>
</section>
{/* ── 왜 필요한가 ───────────────────────────────── */}
<Section muted>
<div className="grid items-center gap-12 lg:grid-cols-2">
<div>
<SectionHead
eyebrow="지금 벌어지는 일"
title="네이버에 다 올려놨는데, AI는 왜 모를까요?"
description="네이버·카카오가 AI 크롤러를 막습니다. 거기 올린 영업시간도 가격도 AI는 읽지 못합니다."
/>
<p className="text-sm leading-relaxed text-muted-foreground">
그래서 AI는 블로그 후기에서 추측합니다. AI가 읽을 수 있는 페이지 하나를 놓아 두면,
<b className="text-foreground"> 그 페이지가 우리 가게의 정답</b>이 됩니다.
</p>
</div>
{/* AI 답변 예시 — 실제 답변을 옮긴 게 아니라 상황을 보여주는 그림이다. */}
<figure className="m-0 rounded-xl border border-border bg-card p-5">
<figcaption className="mb-3 text-[11px] font-medium tracking-wide text-muted-foreground">
AI 답변 예시
</figcaption>
<p className="rounded-lg border border-border bg-muted/50 px-3.5 py-2.5 text-[13px]">
양양에 조용한 독채 펜션 추천해줘
</p>
<p className="mt-3 text-[13px] leading-relaxed text-muted-foreground">
죽도해변 근처에 독채형 숙소가 몇 곳 있는 것으로 보입니다. 다만 객실 수나 요금, 반려동물 동반
여부는 확인할 수 있는 출처가 없어 정확히 안내드리기 어렵습니다.
</p>
<ul className="mt-4 space-y-1.5 border-t border-border pt-3 text-[11px] text-muted-foreground">
<li>· 블로그 후기 · 2023년 글</li>
<li>· 카페 게시글 · 작성자 미상</li>
<li className="font-medium text-destructive">· 공식 홈페이지 — 없음</li>
</ul>
</figure>
</div>
</Section>
{/* ── 무엇을 하나 ──────────────────────────────── */}
<Section>
<SectionHead
eyebrow="SEO + AEO"
title="검색엔진과 AI, 양쪽이 읽는 방식으로 만듭니다"
description="SEO 는 검색 결과에 걸리는 일, AEO 는 AI 답변에 인용되는 일. 요구하는 게 달라서 둘을 같이 맞춥니다."
/>
<ol className="grid gap-5 sm:grid-cols-3">
{[
{
title: '흩어진 정보를 모읍니다',
body: '가게가 이미 공개해 둔 곳에서 영업시간·가격·시설을 모읍니다. 맞으면 두고, 틀리면 고칩니다.',
},
{
title: 'AEO — AI가 읽는 형태로',
body: '자바스크립트 없이도 본문이 읽히는 정적 페이지로 굽고, 구조화 데이터와 llms.txt 를 함께 내보냅니다.',
},
{
title: 'SEO — 검색에 바로 걸리게',
body: '발행 즉시 네이버·Bing 에 통보하고, 구글에는 사이트맵으로 제출합니다.',
},
].map(({title, body}) => (
<li key={title} className="rounded-xl border border-border bg-card p-5">
<h3 className="text-base font-semibold tracking-tight">{title}</h3>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{body}</p>
</li>
))}
</ol>
</Section>
{/* ── 이렇게 나옵니다 ───────────────────────────── */}
<Section muted>
<SectionHead
eyebrow="이렇게 나옵니다"
title="먼저 시작한 가게들"
description="실제로 발행된 홈페이지입니다."
/>
<ShowcaseGrid limit={6} />
<div className="mt-8">
<Link
to="/showcase"
className="inline-flex items-center gap-1.5 text-sm font-medium transition-colors hover:text-muted-foreground"
>
더 보기
<ArrowRight className="size-4" aria-hidden />
</Link>
</div>
</Section>
{/* ── 발행 기준 ────────────────────────────────── */}
<Section>
<div className="grid gap-10 lg:grid-cols-[1fr_1.1fr]">
<SectionHead
title="틀린 정보는 발행하지 않습니다"
description="여기가 틀리면 틀린 채로 퍼집니다. 세 가지를 통과하지 못하면 발행을 멈춥니다."
/>
<ul className="space-y-3 text-sm">
{[
'확인하지 않은 값은 한 줄도 나가지 않습니다.',
'직접 쓴 소개가 하나도 없으면 발행하지 않습니다.',
'화면에 보이는 값과 검색엔진에 보내는 값이 다르면 그 자리에서 멈춥니다.',
].map((line) => (
<li key={line} className="flex gap-2.5 text-muted-foreground">
<Check className="mt-0.5 size-4 shrink-0 text-success" aria-hidden />
<span>{line}</span>
</li>
))}
</ul>
</div>
</Section>
{/* ── 만들고 끝이 아니다 ───────────────────────── */}
<Section muted>
<div className="grid gap-10 lg:grid-cols-[1fr_1.1fr]">
<SectionHead
eyebrow="그다음"
title="만들고 끝이 아닙니다"
description="AI가 무엇을 인용하는지는 매달 달라집니다."
/>
<div>
<ul className="space-y-3 text-sm text-muted-foreground">
{[
'AI 노출(AEO) 진단 리포트 — 지금 무엇이 인용되고 무엇이 빠졌는지',
'SEO·AEO 최적화 가이드 — 다음 달에 무엇을 고칠지',
'페이지 제작 1회 · 영상 콘텐츠 2종',
'리포트 리뷰 미팅 — 결과를 같이 봅니다',
].map((line) => (
<li key={line} className="flex gap-2.5">
<Check className="mt-0.5 size-4 shrink-0 text-success" aria-hidden />
<span>{line}</span>
</li>
))}
</ul>
<Link
to="/pricing"
className="mt-6 inline-flex items-center gap-1.5 text-sm font-medium transition-colors hover:text-muted-foreground"
>
요금 보기
<ArrowRight className="size-4" aria-hidden />
</Link>
</div>
</div>
</Section>
{/* ── 마지막 ───────────────────────────────────── */}
<Section className="text-center">
<h2 className="text-2xl font-bold tracking-tight text-balance sm:text-3xl">
가게 이름부터 넣어 보세요
</h2>
<p className="mt-3 text-sm text-muted-foreground">가입 없이 만들어 볼 수 있습니다.</p>
<div className="mt-7 flex flex-wrap justify-center gap-2">
<Link to="/builder?new=1">
<Button variant="primary" size="lg">
무료로 만들어 보기
</Button>
</Link>
<Link to="/pricing">
<Button variant="outline" size="lg">
요금 보기
</Button>
</Link>
</div>
</Section>
</MarketingShell>
);
}

View File

@ -22,8 +22,9 @@ export function LoginPage({selfServe = true}: {selfServe?: boolean}) {
const location = useLocation(); const location = useLocation();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const [id, setId] = useState(''); // 개발 편의로 기본값을 채워 둔다. 운영에 열기 전에 반드시 빈 문자열로 되돌린다.
const [password, setPassword] = useState(''); const [id, setId] = useState('admin');
const [password, setPassword] = useState('1234');
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
// ★ 기본 도착지를 '/' 로 둔다. 앱마다 홈이 다르고(사장님 → 빌더, 내부 → 사업장 목록) // ★ 기본 도착지를 '/' 로 둔다. 앱마다 홈이 다르고(사장님 → 빌더, 내부 → 사업장 목록)
@ -135,7 +136,7 @@ export function LoginPage({selfServe = true}: {selfServe?: boolean}) {
<span className="text-[11px] text-muted-foreground">또는</span> <span className="text-[11px] text-muted-foreground">또는</span>
<span className="h-px flex-1 bg-border" /> <span className="h-px flex-1 bg-border" />
</div> </div>
<GoogleSignInButton onCredential={handleGoogle} text="continue_with" /> <GoogleSignInButton onCredential={handleGoogle} text="signin_with" />
</> </>
)} )}

View File

@ -0,0 +1,109 @@
import {Link} from 'react-router';
import {Check} from 'lucide-react';
import {MarketingShell, Section} from '@/components/layout/MarketingShell';
import {Button} from '@/components/ui/button';
/**
* 요금 — 플랜 하나다.
*
* ★ 플랜을 여럿 늘어놓지 않는다. 이건 셀프서비스 구독이 아니라 **월 단위로 사람이 붙는
* 서비스**라 고를 것이 가격대가 아니라 "할 것이냐"뿐이다. 비교표를 만들면 없는 선택지를
* 지어내게 된다.
*/
const INCLUDED = ['AI 노출 진단 리포트', '최적화 가이드', '영상 콘텐츠 2종', '페이지 제작 1회'];
const TERMS: {label: string; value: string}[] = [
{label: '무엇을 넣나', value: '운영 중인 홈페이지'},
{label: '무엇이 나오나', value: '진단 리포트 + 최적화 가이드 + 페이지'},
{label: '월 산출물', value: '리포트 1회 · 영상 2종 · 페이지 1회'},
{label: '확인 절차', value: '리포트 리뷰 미팅'},
];
export function PricingPage() {
return (
<MarketingShell>
<Section>
<header className="mb-12 text-center">
<h1 className="text-3xl font-bold tracking-tight text-balance sm:text-4xl">요금</h1>
<p className="mx-auto mt-3 max-w-lg text-sm text-muted-foreground">
매달 진단하고, 고치고, 결과를 같이 봅니다.
</p>
</header>
<div className="mx-auto max-w-2xl rounded-2xl border border-border bg-card p-7 sm:p-9">
<div className="flex flex-wrap items-baseline justify-between gap-3">
<h2 className="text-2xl font-bold tracking-tight">Web4AI</h2>
<p className="text-2xl font-bold tracking-tight">
70만원
<span className="ml-1 text-sm font-medium text-muted-foreground">/ 월</span>
</p>
</div>
<p className="mt-7 text-base font-semibold tracking-tight">AI가 우리를 찾아내게 만듭니다</p>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
ChatGPT·제미나이·퍼플렉시티 답변에 우리 정보가 인용되도록 홈페이지를 진단하고 고칩니다.
</p>
<ul className="mt-6 flex flex-wrap gap-2">
{INCLUDED.map((item) => (
<li
key={item}
className="inline-flex items-center gap-1.5 rounded-full border border-border px-3.5 py-1.5 text-[13px]"
>
<Check className="size-3.5 text-success" aria-hidden />
{item}
</li>
))}
</ul>
<dl className="mt-8 grid gap-3 border-t border-border pt-7 text-sm sm:grid-cols-[7rem_1fr]">
{TERMS.map(({label, value}) => (
<div key={label} className="grid gap-1 sm:col-span-2 sm:grid-cols-subgrid">
<dt className="text-muted-foreground">{label}</dt>
<dd className="m-0">{value}</dd>
</div>
))}
</dl>
<Link to="/builder?new=1" className="mt-8 block">
<Button variant="primary" size="lg" className="w-full">
먼저 만들어 보기
</Button>
</Link>
<p className="mt-3 text-center text-xs text-muted-foreground">
가입하지 않아도 끝까지 만들어 볼 수 있습니다.
</p>
</div>
</Section>
<Section muted>
<h2 className="mb-8 text-xl font-bold tracking-tight">자주 묻는 질문</h2>
<dl className="grid gap-6 sm:grid-cols-2">
{[
{
q: '네이버 플레이스랑 뭐가 다른가요?',
a: '네이버는 AI 크롤러를 막습니다. 그래서 네이버에 올린 정보는 ChatGPT 같은 AI가 읽지 못합니다. 이 홈페이지는 AI가 읽을 수 있게 만듭니다.',
},
{
q: '홈페이지가 이미 있는데도 필요한가요?',
a: '있는 홈페이지가 AI에게 읽히는지가 관건입니다. 화면에는 보이는데 크롤러에게는 빈 페이지인 경우가 많습니다. 진단이 그걸 먼저 봅니다.',
},
{
q: '무엇을 준비해야 하나요?',
a: '지금 운영 중인 홈페이지 주소 하나면 시작합니다. 없으면 여기서 만드는 것부터 합니다.',
},
{
q: '결과는 어떻게 확인하나요?',
a: '매달 진단 리포트를 드리고, 리뷰 미팅에서 무엇이 달라졌는지 같이 봅니다.',
},
].map(({q, a}) => (
<div key={q}>
<dt className="text-sm font-semibold">{q}</dt>
<dd className="mt-1.5 text-sm leading-relaxed text-muted-foreground">{a}</dd>
</div>
))}
</dl>
</Section>
</MarketingShell>
);
}

View File

@ -0,0 +1,37 @@
import {Link} from 'react-router';
import {MarketingShell, Section} from '@/components/layout/MarketingShell';
import {ShowcaseGrid} from '@/features/marketing/ShowcaseGrid';
import {Button} from '@/components/ui/button';
/**
* 이렇게 나옵니다 — 실제로 발행된 홈페이지 목록.
*
* ★ 템플릿 갤러리가 아니다. 고르라고 보여주는 게 아니라 "진짜로 나갔다"는 증거를 거는 자리다.
* 그래서 예시 데이터로 채우지 않는다(ShowcaseGrid 주석) — 발행본이 없으면 빈 화면이 맞다.
*/
export function ShowcasePage() {
return (
<MarketingShell>
<Section>
<header className="mb-10 max-w-2xl">
<h1 className="text-3xl font-bold tracking-tight text-balance sm:text-4xl">이렇게 나옵니다</h1>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
실제로 발행된 홈페이지입니다. 눌러서 그대로 열어 보세요 — 사장님 가게도 같은 방식으로 만들어집니다.
</p>
</header>
<ShowcaseGrid limit={24} />
</Section>
<Section muted className="text-center">
<h2 className="text-2xl font-bold tracking-tight text-balance">우리 가게도 만들어 볼까요?</h2>
<p className="mt-3 text-sm text-muted-foreground">가게 이름만 있으면 됩니다.</p>
<Link to="/builder?new=1" className="mt-7 inline-block">
<Button variant="primary" size="lg">
무료로 만들어 보기
</Button>
</Link>
</Section>
</MarketingShell>
);
}

View File

@ -0,0 +1,213 @@
import {useState} from 'react';
import {Link, useNavigate} from 'react-router';
import {
Building2,
Coffee,
ExternalLink,
Loader2,
MoreHorizontal,
Pencil,
Plus,
Stethoscope,
UtensilsCrossed,
Wand2,
} from 'lucide-react';
import {PlaceCategory, publishUrlString, SiteStatus} from '@o2o/shared';
import {changeStatus, PublishAction, useListMySites, type MySiteData} from '@/api';
import {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {notify, notifyApiError} from '@/lib/notify';
// 발행본 주소는 PublishModal·CanvasView 와 같은 규칙이다 — 세 곳이 다른 주소를 말하면 안 된다.
const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host;
const CATEGORY_ICON: Record<number, typeof Building2> = {
[PlaceCategory.LODGING]: Building2,
[PlaceCategory.CAFE]: Coffee,
[PlaceCategory.RESTAURANT]: UtensilsCrossed,
[PlaceCategory.CLINIC]: Stethoscope,
};
/**
* 줄의 상태 배지. **사이트 상태(sites.status)만 본다** — 사업장 상태(places.status)는
* 수집 단계를 말하는 값이라 사장님이 궁금한 "지금 나가 있나"와 다르다.
*/
function statusBadge(row: MySiteData) {
if (!row.site_id) return {label: '만드는 중', variant: 'outline' as const};
switch (row.status) {
case SiteStatus.PUBLISHED:
return row.needs_rebuild
? {label: '수정됨 · 재발행 필요', variant: 'warning' as const}
: {label: '발행됨', variant: 'success' as const};
case SiteStatus.SUSPENDED:
return {label: '중지', variant: 'outline' as const};
case SiteStatus.UNPUBLISHED:
return {label: '내림', variant: 'outline' as const};
default:
return {label: '발행 전', variant: 'default' as const};
}
}
/** 발행본이 실제로 열리는 주소. ★ 주소는 발행 전에 예약되므로 PUBLISHED 일 때만 연다 — 아니면 404 다. */
function publishedUrl(row: MySiteData): string | null {
if (row.status !== SiteStatus.PUBLISHED || !row.domain) return null;
return publishUrlString(row.domain.split('.')[0], PUBLISH_HOST);
}
/**
* 내 사이트 — 로그인한 사장님의 홈이다.
*
* 흐름은 하나다: 위저드로 만든다 → 여기 생긴다 → 눌러서 에디터로 들어가 고친다 → 재발행한다.
* ★ 그래서 줄을 누르면 에디터로 간다. 목록에 온 용건은 열에 아홉 "내 사이트 고치기"다.
*/
export function SitesPage() {
const navigate = useNavigate();
const {data, isLoading, isError, error, refetch} = useListMySites({size: 50});
const [busyId, setBusyId] = useState<string | null>(null);
const [menuId, setMenuId] = useState<string | null>(null);
const rows = data?.sites ?? [];
// 발행 내리기만 둔다. ★ 삭제 경로는 만들지 않는다 — 색인된 페이지를 404 로 만들면
// 그 자리를 다시 OTA 가 가져가고, 되돌릴 방법이 사장님에게 없다(sites.status 주석).
const handleUnpublish = async (row: MySiteData) => {
if (!window.confirm(`'${row.name}' 사이트를 검색에서 내릴까요?\n주소는 그대로 두고 페이지만 내려갑니다.`)) return;
setMenuId(null);
setBusyId(row.place_id);
try {
const res = await changeStatus(row.place_id, {action: PublishAction.UNPUBLISH});
if (!res.result?.success) {
notifyApiError({data: res}, '사이트를 내리지 못했습니다.');
return;
}
notify.success('사이트를 내렸습니다.');
await refetch();
} catch (unpublishError) {
notifyApiError(unpublishError, '사이트를 내리지 못했습니다.');
} finally {
setBusyId(null);
}
};
return (
<AppShell>
<PageContainer
title="내 사이트"
description="만든 사이트를 열어 고치고, 다시 발행합니다."
actions={
<Button variant="primary" size="sm" onClick={() => navigate('/builder?new=1')}>
<Plus />새 사이트
</Button>
}
>
{isLoading && (
<div className="flex items-center justify-center py-20 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
)}
{isError && (
<EmptyState
title="목록을 불러오지 못했습니다"
description={(error as Error)?.message ?? '잠시 후 다시 시도해 주세요.'}
action={
<Button size="sm" onClick={() => refetch()}>
다시 시도
</Button>
}
/>
)}
{!isLoading && !isError && rows.length === 0 && (
<EmptyState
icon={Wand2}
title="아직 만든 사이트가 없습니다"
description="상호명 하나로 시작하면 AI 가 정보를 모아 사이트를 만듭니다."
action={
<Button variant="primary" size="sm" onClick={() => navigate('/builder?new=1')}>
<Plus />첫 사이트 만들기
</Button>
}
/>
)}
{rows.length > 0 && (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-card">
{rows.map((row) => {
const Icon = CATEGORY_ICON[row.category] ?? Building2;
const badge = statusBadge(row);
const url = publishedUrl(row);
const editHref = `/builder?placeId=${row.place_id}`;
return (
<li key={row.place_id} className="relative flex items-center gap-3 px-4 py-3.5 hover:bg-muted/40">
<Icon className="size-4 shrink-0 text-muted-foreground" />
<Link to={editHref} className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold">{row.name}</span>
<Badge variant={badge.variant}>{badge.label}</Badge>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{url ?? (row.domain ? `주소 예약됨 · ${row.domain}` : '주소를 아직 정하지 않았습니다')}
</p>
</Link>
<div className="flex shrink-0 items-center gap-1.5">
{url && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium transition-colors hover:bg-muted"
>
<ExternalLink className="size-3.5" />
사이트 열기
</a>
)}
<Button size="sm" onClick={() => navigate(editHref)}>
<Pencil />
{row.site_id ? '편집' : '이어서 만들기'}
</Button>
<Button
size="icon"
variant="ghost"
aria-label="더보기"
isLoading={busyId === row.place_id}
onClick={() => setMenuId(menuId === row.place_id ? null : row.place_id)}
>
{busyId === row.place_id ? null : <MoreHorizontal />}
</Button>
</div>
{menuId === row.place_id && (
<>
{/* 바깥을 눌러 닫는다. 메뉴 하나짜리라 팝오버 라이브러리를 들이지 않는다. */}
<button
type="button"
aria-label="닫기"
className="fixed inset-0 z-10 cursor-default"
onClick={() => setMenuId(null)}
/>
<div className="absolute right-4 top-12 z-20 w-44 rounded-md border border-border bg-card py-1 shadow-md">
<button
type="button"
disabled={row.status !== SiteStatus.PUBLISHED}
onClick={() => handleUnpublish(row)}
className="w-full cursor-pointer px-3 py-2 text-left text-xs transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-40"
>
발행 내리기
</button>
</div>
</>
)}
</li>
);
})}
</ul>
)}
</PageContainer>
</AppShell>
);
}

View File

@ -31,6 +31,14 @@ export function toAuthUser(res: ResMe): AuthUser {
}; };
} }
/**
* 화면에 띄울 이름. 구글 계정의 로그인 아이디는 `google_<sub>` 라 그대로 보이면 안 된다 —
* 이름 → 이메일 순으로 떨어뜨리고 아이디는 마지막이다.
*/
export function userLabel(user: AuthUser): string {
return user.name || user.email || user.id;
}
interface AuthState { interface AuthState {
user: AuthUser | null; user: AuthUser | null;
/** 부팅 시 저장된 토큰을 확인하기 전까지 true. 가드가 이 동안 리다이렉트를 미룬다. */ /** 부팅 시 저장된 토큰을 확인하기 전까지 true. 가드가 이 동안 리다이렉트를 미룬다. */

View File

@ -23,9 +23,9 @@ import type {
ConfirmedIdentity, ConfirmedIdentity,
FactRef, FactRef,
LivePlaceInput, LivePlaceInput,
PendingPick,
RightTab, RightTab,
WeatherLocation, WeatherLocation,
WizardStep,
} from '@/stores/builderTypes'; } from '@/stores/builderTypes';
/** /**
@ -38,16 +38,14 @@ export type {
ConfirmedIdentity, ConfirmedIdentity,
FactRef, FactRef,
LivePlaceInput, LivePlaceInput,
PendingPick,
RightTab, RightTab,
WeatherLocation, WeatherLocation,
WizardStep,
}; };
export {EDITOR_STEP} from '@/stores/builderTypes';
import {EDITOR_STEP} from '@/stores/builderTypes';
interface BuilderState { interface BuilderState {
// ── 위저드 ──────────────────────────────────────────── // ── 위저드 ────────────────────────────────────────────
step: WizardStep; // ★ 단계(step)는 여기 없다 — 주소창이 소유한다(features/onboarding/wizardUrl).
industry: IndustryType; industry: IndustryType;
/** /**
* 지금 편집 중인 실제 사업장. null 이면 아직 가게가 정해지지 않은 상태다. * 지금 편집 중인 실제 사업장. null 이면 아직 가게가 정해지지 않은 상태다.
@ -65,13 +63,15 @@ interface BuilderState {
* 화면의 상호·주소는 업종 예시일 뿐이라는 뜻이고, 3단계는 그 사실을 그대로 표시한다. * 화면의 상호·주소는 업종 예시일 뿐이라는 뜻이고, 3단계는 그 사실을 그대로 표시한다.
*/ */
confirmedIdentity: ConfirmedIdentity | null; confirmedIdentity: ConfirmedIdentity | null;
/** 업종을 못 정해 업종 화면으로 넘긴 후보. 돌아와서 확정을 이어갈 때만 쓴다. */
pendingPick: PendingPick | null;
// 수집(Step 2) // 수집
isGathering: boolean; isGathering: boolean;
gatherStage: number; gatherStage: number;
gatherCompleted: boolean; gatherCompleted: boolean;
// 템플릿(Step 3) · 생성(Step 4) // 템플릿 · 생성
templateId: string; templateId: string;
colorPaletteId: string | null; colorPaletteId: string | null;
generateStage: number; generateStage: number;
@ -97,7 +97,6 @@ interface BuilderState {
publishedUrl: string | null; publishedUrl: string | null;
// ── 액션 ────────────────────────────────────────────── // ── 액션 ──────────────────────────────────────────────
goToStep: (step: WizardStep) => void;
selectIndustry: (industry: IndustryType) => void; selectIndustry: (industry: IndustryType) => void;
/** 실사업장 데이터를 캔버스에 얹는다(같은 사업장을 다시 읽어도 편집을 지우지 않는다). */ /** 실사업장 데이터를 캔버스에 얹는다(같은 사업장을 다시 읽어도 편집을 지우지 않는다). */
@ -114,12 +113,16 @@ interface BuilderState {
/** 확정을 물린다 — 상호를 다시 검색하러 갈 때. */ /** 확정을 물린다 — 상호를 다시 검색하러 갈 때. */
clearIdentity: () => void; clearIdentity: () => void;
/** 업종 화면으로 넘길 후보를 들려 보낸다(null 이면 비운다). */
setPendingPick: (pick: PendingPick | null) => void;
startGather: () => void; startGather: () => void;
setGatherStage: (stage: number) => void; setGatherStage: (stage: number) => void;
finishGather: () => void; finishGather: () => void;
selectTemplate: (templateId: string) => void; selectTemplate: (templateId: string) => void;
selectColorPalette: (paletteId: string | null) => void; selectColorPalette: (paletteId: string | null) => void;
/** 생성 화면에 들어가기 직전, 진행 표시를 처음으로 되돌린다. 화면 이동은 주소창이 한다. */
startGenerating: () => void; startGenerating: () => void;
setGenerateStage: (stage: number) => void; setGenerateStage: (stage: number) => void;
@ -288,10 +291,10 @@ for (const key of ['o2osite.builder.wizard.v1', 'o2osite.builder.wizard.v2']) {
let factSaver: FactSaver; let factSaver: FactSaver;
export const useBuilderStore = create<BuilderState>((set, get) => ({ export const useBuilderStore = create<BuilderState>((set, get) => ({
step: 1,
...seedFor(FALLBACK_INDUSTRY), ...seedFor(FALLBACK_INDUSTRY),
placeId: null, placeId: null,
confirmedIdentity: null, confirmedIdentity: null,
pendingPick: null,
savingFieldIds: [], savingFieldIds: [],
isGathering: false, isGathering: false,
@ -306,8 +309,6 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
isPublishModalOpen: false, isPublishModalOpen: false,
publishedUrl: null, publishedUrl: null,
goToStep: (step) => set({step}),
// 업종을 바꾸면 그 업종의 시드로 통째로 갈아탄다 — 앞 업종의 섹션·필드가 남으면 // 업종을 바꾸면 그 업종의 시드로 통째로 갈아탄다 — 앞 업종의 섹션·필드가 남으면
// 카페 사이트에 '객실 안내'가 붙는 식으로 섞인다. // 카페 사이트에 '객실 안내'가 붙는 식으로 섞인다.
selectIndustry: (industry) => { selectIndustry: (industry) => {
@ -327,7 +328,16 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
location: identity.address, location: identity.address,
infoFields: withConfirmedIdentity(seed.infoFields, identity), infoFields: withConfirmedIdentity(seed.infoFields, identity),
} }
: null), : {
/**
* ★ 사장님이 친 상호·위치는 시드가 아니다 — 업종을 바꿨다고 지우면 안 된다.
* 업종이 첫 화면이던 시절엔 여기가 늘 빈 값이라 티가 안 났는데, 지금은 상호를
* 먼저 받는다: 지우면 검색어를 친 뒤 업종만 골라도 그 이름이 사라진다.
*/
storeName: state.storeName,
location: state.location,
infoFields: withOwnerIdentity(seed.infoFields, state.storeName, state.location),
}),
// 업종을 손으로 고르면 화면의 값은 다시 시드다 — 실사업장 배선을 남겨두면 // 업종을 손으로 고르면 화면의 값은 다시 시드다 — 실사업장 배선을 남겨두면
// "placeId 가 있다 = 화면 값이 서버에서 왔다"는 약속이 깨진다. // "placeId 가 있다 = 화면 값이 서버에서 왔다"는 약속이 깨진다.
placeId: null, placeId: null,
@ -350,7 +360,7 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
* '확인 필요'로 튀어, 사장님 눈에는 클릭이 씹힌 것으로 보인다(withPendingEdits). * '확인 필요'로 튀어, 사장님 눈에는 클릭이 씹힌 것으로 보인다(withPendingEdits).
*/ */
applyPlace: (input, options) => { applyPlace: (input, options) => {
const enterEditor = options?.enterEditor ?? true; const isOnboarding = options?.isOnboarding ?? false;
// 처음 여는 사업장인가. 리페치(같은 placeId)면 섹션·단계를 건드리지 않는다. // 처음 여는 사업장인가. 리페치(같은 placeId)면 섹션·단계를 건드리지 않는다.
const isNewPlace = get().placeId !== input.placeId; const isNewPlace = get().placeId !== input.placeId;
// 사업장을 갈아타면 앞 사업장의 저장 대기분은 버린다 — 들고 가면 남의 값이 이 화면에 얹힌다. // 사업장을 갈아타면 앞 사업장의 저장 대기분은 버린다 — 들고 가면 남의 값이 이 화면에 얹힌다.
@ -366,11 +376,14 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
return { return {
// 업종이 바뀔 때만 시드를 갈아끼운다(카페 사업장에 '객실 안내'가 남지 않게). // 업종이 바뀔 때만 시드를 갈아끼운다(카페 사업장에 '객실 안내'가 남지 않게).
...(needsReseed ? seedFor(input.industry) : null), ...(needsReseed ? seedFor(input.industry) : null),
// 이미 등록된 사업장을 여는 것이므로 수집 위저드를 다시 걷게 하지 않는다.
...(isNewPlace ...(isNewPlace
? { ? {
step: enterEditor ? EDITOR_STEP : 3, /**
...(!enterEditor * ★ 위저드 도중에 새로고침하면 스토어의 확정 신원이 비어 있다(브라우저에 저장하지
* 않으므로). 그대로 두면 3단계가 "아직 가게를 안 골랐다"고 판단해 수집을 열지
* 않는다 — 서버에서 읽어온 사업장으로 여기서 다시 세운다.
*/
...(isOnboarding
? { ? {
confirmedIdentity: { confirmedIdentity: {
placeId: input.placeId, placeId: input.placeId,
@ -384,18 +397,7 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
selectedSectionId: null, selectedSectionId: null,
isGathering: false, isGathering: false,
} }
: /** : null),
* 같은 사업장을 **딥링크로 다시 연** 경우다(`/builder?placeId=...`).
*
* ★ 단계를 다시 못 박지 않으면, 스토어에 남아 있던 위저드 단계가 그대로 뜬다 —
* 사업장 목록에서 [사이트 편집] 을 눌렀는데 생성 화면(5단계)이 되살아나
* 생성 잡이 한 번 더 들어가는 일이 실제로 생긴다. 편집하러 온 사람은
* 언제나 편집기를 봐야 한다. (리페치는 enterEditor 여부와 무관하게 안전하다 —
* 이미 EDITOR_STEP 이면 같은 값을 다시 쓰는 것뿐이다.)
*/
enterEditor
? {step: EDITOR_STEP}
: null),
/** /**
* ★ "수집 완료"는 fact 가 있을 때만이다. * ★ "수집 완료"는 fact 가 있을 때만이다.
* *
@ -454,6 +456,8 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
clearIdentity: () => set({confirmedIdentity: null}), clearIdentity: () => set({confirmedIdentity: null}),
setPendingPick: (pendingPick) => set({pendingPick}),
toggleChannel: (channelId) => toggleChannel: (channelId) =>
set((state) => ({ set((state) => ({
selectedChannels: state.selectedChannels.includes(channelId) selectedChannels: state.selectedChannels.includes(channelId)
@ -496,9 +500,8 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
set({colorPaletteId}); set({colorPaletteId});
persistTheme(); persistTheme();
}, },
// ★ 생성 화면(5)으로 보낸다. 단계 번호는 EDITOR_STEP 과 함께 WizardStep 주석이 기준이다 — // 화면 이동은 부르는 쪽이 주소창으로 한다(`?step=generating`) — 여기서는 진행 표시만 되감는다.
// 여기 숫자가 자기 화면(4)이면 [사이트 생성하기]가 아무 일도 안 하는 것처럼 보인다. startGenerating: () => set({generateStage: 1}),
startGenerating: () => set({step: 5, generateStage: 1}),
setGenerateStage: (generateStage) => set({generateStage}), setGenerateStage: (generateStage) => set({generateStage}),
selectSection: (selectedSectionId) => set({selectedSectionId}), selectSection: (selectedSectionId) => set({selectedSectionId}),
@ -747,11 +750,11 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
factSaver.clear(); factSaver.clear();
clearThemeSaves(); clearThemeSaves();
set({ set({
step: 1,
...seedFor(get().industry), ...seedFor(get().industry),
placeId: null, placeId: null,
// [처음부터]는 "이 가게가 맞다"까지 물린다 — 상호부터 다시 확인받는다. // [처음부터]는 "이 가게가 맞다"까지 물린다 — 상호부터 다시 확인받는다.
confirmedIdentity: null, confirmedIdentity: null,
pendingPick: null,
isGathering: false, isGathering: false,
gatherStage: 1, gatherStage: 1,
gatherCompleted: false, gatherCompleted: false,

View File

@ -8,23 +8,9 @@
import type {FactStatus, IndustryType, InfoField, PhotoItem} from '@o2o/shared'; import type {FactStatus, IndustryType, InfoField, PhotoItem} from '@o2o/shared';
/** /**
* 위저드 단계. * ★ 위저드 단계는 여기 없다 — 주소창이 소유한다(`features/onboarding/wizardUrl`).
* * 스토어에 두면 뒤로가기·북마크가 단계를 못 따라온다.
* 1 업종 선택
* 2 상호 검색 → **후보 목록에서 내 가게 확인** ← 남의 가게가 섞이면 제일 비싼 실수다
* 3 수집된 데이터 확인 ← [맞아요]/[아니에요] 가 여기서 끝난다
* 4 템플릿 선택
* 5 생성 중
* 6 에디터
*
* ★ 2 와 3 은 성격이 다른 확인이라 한 화면에 두지 않는다.
* 2 는 "이 가게가 맞나" (신원), 3 은 "이 값이 맞나" (사실). 신원이 틀린 채로
* 사실을 확인시키면 사장님이 남의 가게 정보를 자기 것이라고 승인하게 된다.
*/ */
export type WizardStep = 1 | 2 | 3 | 4 | 5 | 6;
/** 위저드가 끝나고 에디터로 넘어가는 단계. 숫자를 화면마다 외우지 않게 한 곳에 둔다. */
export const EDITOR_STEP = 6 satisfies WizardStep;
export type RightTab = 'content' | 'design' | 'photos' | 'info' | 'faq' | 'verify'; export type RightTab = 'content' | 'design' | 'photos' | 'info' | 'faq' | 'verify';
/** /**
@ -97,11 +83,26 @@ export interface ConfirmedIdentity {
export interface ApplyPlaceOptions { export interface ApplyPlaceOptions {
/** /**
* 처음 얹는 사업장일 때 곧장 에디터로 보낼지. * 위저드 안에서 열린 사업장인가(`?flow=onboarding`).
* *
* ★ 기본은 true — 사업장 목록에서 딥링크로 들어온 경우다(이미 등록된 가게를 여는 것이므로 * ★ 화면을 옮기지는 않는다 — 그건 주소창(`?step=`)의 일이다. 이 값이 하는 일은 하나뿐:
* 위저드를 다시 걷게 하지 않는다). 반대로 위저드 3단계는 **수집 결과를 위저드 안에서** * 위저드 도중에 새로고침하면 스토어의 확정 신원이 비어 있으므로, 서버에서 읽은 사업장으로
* 보여줘야 하므로 false 로 부른다. 안 그러면 수집이 끝나는 순간 화면이 에디터로 튄다. * 그걸 다시 세운다. 없으면 3단계가 "가게를 아직 안 골랐다"고 판단해 수집을 열지 않는다.
*/ */
enterEditor?: boolean; isOnboarding?: boolean;
}
/**
* 공개 검색에서 고른, **아직 업종이 안 정해진** 후보.
*
* ★ 업종 화면(`?step=industry`)을 다녀오는 동안만 산다. 사장님이 고른 업종을 들고 돌아와야
* 그 업종으로 사업장을 만들 수 있어서다(사업장의 category 는 만들 때 정해진다).
* ★ 브라우저에 저장하지 않는다(위저드 상태 규칙). 새로고침하면 사라지고 검색부터 다시 한다 —
* 확정 전이라 서버에도 아직 아무것도 없다.
*/
export interface PendingPick {
name: string;
address: string;
/** 업종 화면에서 고른 값. 이게 채워져야 확정이 이어진다. */
industry?: IndustryType;
} }

View File

@ -2,3 +2,5 @@ export * from './types';
export * from './lib/cn'; export * from './lib/cn';
export * from './lib/slug'; export * from './lib/slug';
export * from './lib/facts'; export * from './lib/facts';
export * from './lib/section-data';
export * from './lib/color';

View File

@ -0,0 +1,38 @@
/**
* 색 계산 — 디자인 토큰에서 '면 단계'를 만들 때 쓴다.
*
* ★ 빌더 캔버스 · 쇼케이스 · **발행 사이트**가 같은 식을 써야 한다.
* 미리보기에서 예쁜 조합이 발행 화면에서 달라지면 미리보기가 거짓말이 된다.
* 그래서 프론트 lib 이 아니라 계약 패키지에 둔다.
*/
function rgb(hex: string): [number, number, number] {
const n = parseInt(hex.replace('#', ''), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
/** 두 색을 섞는다. ratio=0 이면 a, 1 이면 b. */
export function mix(a: string, b: string, ratio: number): string {
// hex 가 아닌 값(css 변수·색 이름)이 들어오면 계산이 무의미하다 — 원본을 그대로 돌려준다.
if (!/^#[0-9a-fA-F]{6}$/.test(a) || !/^#[0-9a-fA-F]{6}$/.test(b)) return a;
const [ar, ag, ab] = rgb(a);
const [br, bg, bb] = rgb(b);
const ch = (x: number, y: number) => Math.round(x + (y - x) * ratio);
const hex = (n: number) => n.toString(16).padStart(2, '0');
return `#${hex(ch(ar, br))}${hex(ch(ag, bg))}${hex(ch(ab, bb))}`;
}
/**
* 템플릿 색 6개 → 면 토큰 4개.
*
* 캔버스와 쇼케이스가 공유하는 유도 규칙이다. 섹션 기본(bg) · paper · tint 가
* 서로 다른 밝기여야 경계선 없이도 섹션이 나뉜다.
*/
export function deriveSurfaces(colors: {bg: string; card: string; text: string; secondary: string}) {
return {
surface: mix(colors.bg, colors.text, 0.06),
surfaceAlt: colors.card,
inverse: '#1c1917',
border: colors.secondary,
};
}

View File

@ -0,0 +1,433 @@
/**
* 붙여넣기 아이템의 **읽는 쪽 계약** — "이 섹션 타입의 JSON 은 어떤 모양인가".
*
* ★ 왜 shared 인가
* 같은 JSON 을 두 렌더러가 읽는다 — 빌더 캔버스(solution/frontend)와 발행 사이트(solution/site).
* 파서를 각자 두면 "빌더에서는 보이는데 발행하면 없다"가 조용히 생긴다(슬러그 규칙과 같은 함정).
* 프롬프트·예시·라벨처럼 **쓰는 쪽**만 필요한 것은 빌더에 남는다(`canvas/dataSpec.ts`).
*/
/** 사장님이 스스로 매긴 확신. 미검증 값이 화면·JSON-LD 로 새지 않게 하는 첫 관문이다. */
export type DataVerified = '확인' | '확인필요';
export interface DataSource {
name: string;
url?: string;
}
export interface SongItem {
title: string;
artist?: string;
lyricist?: string;
composer?: string;
year?: number;
label?: string;
labelColor?: string;
story?: string;
connection?: string;
verified?: DataVerified;
source?: DataSource;
}
export interface DailyItem {
monthDay: string;
category?: string;
title: string;
body?: string;
season?: string;
tags?: string[];
verified?: DataVerified;
source?: DataSource;
}
export interface CourseStop {
order?: number;
name: string;
minutes?: number;
note?: string;
searchQuery?: string;
}
export interface CourseItem {
name: string;
duration?: string;
startsFrom?: string;
stops?: CourseStop[];
verified?: DataVerified;
source?: DataSource;
}
export interface ScheduleSlot {
/** 24시간 표기 "09:30". 정렬·플립 시각 표시가 이 값을 그대로 쓴다. */
time: string;
title: string;
place?: string;
minutes?: number;
note?: string;
searchQuery?: string;
}
export interface ScheduleItem {
name: string;
/** 누구를 위한 하루인가("혼자 온 손님" · "아이와 함께"). 고르는 기준이 된다. */
audience?: string;
season?: string;
slots?: ScheduleSlot[];
verified?: DataVerified;
source?: DataSource;
}
export interface PeopleItem {
name: string;
/** 호·예명. 본명 옆에 나란히 불리는 이름이 있으면 프레임 아래 각인으로 붙는다. */
aka?: string;
years?: string;
role?: string;
oneLine?: string;
/**
* 사진 검색어. ★ 이미지 URL 을 받지 않는다 — 초상권·저작권 확인은 사장님 몫이고,
* 모델이 지어낸 주소를 링크하면 깨진 사진이 인물 얼굴 자리에 남는다(LocalPlace.searchQuery 와 같은 규약).
*/
imageQuery?: string;
verified?: DataVerified;
source?: DataSource;
}
export interface ChronicleItem {
/** 연도가 없으면 레일 끝으로 밀린다 — 순서를 지어내지 않는다. */
year?: number;
title: string;
summary?: string;
place?: string;
/** 도시의 성격을 바꾼 해. 레일의 붉은 점이 이 값이다 — 점의 색이 장식이 아니라 정보다. */
turning?: boolean;
verified?: DataVerified;
source?: DataSource;
}
export interface LiteratureItem {
workTitle: string;
author?: string;
/** "1937" · "1937–1938 연재" 처럼 원문 그대로. 숫자로 좁히면 연재물이 안 들어간다. */
year?: string;
genre?: string;
spineColor?: string;
background?: string;
whyHere?: string;
verified?: DataVerified;
source?: DataSource;
}
export interface PostcardItem {
line: string;
hashtags?: string[];
place?: string;
/** 소인에 찍을 짧은 지명. 없으면 place 가 그 자리에 들어간다. */
postmark?: string;
verified?: DataVerified;
source?: DataSource;
}
export interface QuizItem {
question: string;
/** ★ answer 는 없다. 이 데이터는 검증되지 않은 줄이 더 많아서, 정답을 단정하면 틀린 걸 단정한다. */
hint?: string;
topic?: string;
level?: string;
verified?: DataVerified;
source?: DataSource;
}
export interface PlannerStop {
name: string;
/** 여기서 머무는 시간(분). 없으면 60분으로 본다 — 못 재면 시각을 계산할 수 없다. */
minutes?: number;
/** 앞 칸에서 여기까지 오는 시간(분). 첫 칸은 업소에서 나서는 시간이다. */
moveMinutes?: number;
note?: string;
searchQuery?: string;
}
export interface PlannerItem {
name: string;
/** '봄' · '여름' · '가을' · '겨울'. 화면의 계절 탭이 이 값에서 파생된다. */
season?: string;
/** 그 계절 안에서의 순위. 1·2·3 만 쓴다 — 4위부터는 아무도 안 고른다. */
rank?: number;
/** 하루가 시작하는 시각 "HH:MM". 여기서부터 이동·머무는 시간을 더해 칸마다 시각을 박는다. */
startTime?: string;
audience?: string;
/** 왜 이 계절에 이 코스인가. 순위를 납득시키는 한 문장. */
why?: string;
stops?: PlannerStop[];
verified?: DataVerified;
source?: DataSource;
}
/**
* 섹션 타입 → 없으면 그 줄을 통째로 버리는 키.
*
* ★ 이 표가 곧 "붙여넣기 아이템이 무엇무엇인가"의 목록이다. 여기 없는 타입의 data 는 파싱하지 않는다.
* 빈 껍데기(제목 없는 줄)가 화면에 줄만 남기는 걸 막는 자리이기도 하다.
*/
export const SECTION_ITEM_REQUIRED_KEY: Record<string, string> = {
songs: 'title',
daily: 'title',
course: 'name',
schedule: 'name',
people: 'name',
chronicle: 'title',
literature: 'workTitle',
postcard: 'line',
quiz: 'question',
planner: 'name',
};
export interface ParsedSectionData<T> {
items: T[];
title?: string;
subtitle?: string;
/** 사람에게 보여줄 실패 사유. 있으면 items 는 비어 있다. */
error?: string;
/** 붙여넣은 JSON 의 kind 가 이 섹션과 다르다 — 다른 아이템 것을 넣었다는 뜻. */
kindMismatch?: string;
/** verified 가 '확인' 이 아닌 항목 수. 화면에 각주로 뜬다. */
unverified: number;
/** source 가 붙은 항목 수. */
sourced: number;
}
const EMPTY: ParsedSectionData<never> = {items: [], unverified: 0, sourced: 0};
/**
* JSON.parse 실패를 "몇 번째 줄"로 바꾼다.
*
* ★ V8 은 두 가지 모양으로 던진다 — `position N (line L column C)` 형과,
* 위치 없이 깨진 조각만 인용하는 `Unexpected token 'X', ..."조각" is not valid JSON` 형이다.
* 앞의 것만 보면 후자에서 위치를 통째로 잃는다(실제로 그랬다). 뒤의 것은 조각을 원문에서 되찾아 센다.
*/
function locate(raw: string, message: string): string {
const where = (pos: number) => {
const before = raw.slice(0, Math.max(0, pos));
const line = before.split('\n').length;
const col = pos - before.lastIndexOf('\n');
return `${line}번째 줄 ${col}번째 글자`;
};
const token = /Unexpected token '(.)'/.exec(message)?.[1];
// 쉼표를 하나 더 찍은 경우가 압도적으로 많다 — 그 말을 먼저 해 준다.
const hint =
token === '}' || token === ']'
? '닫는 괄호 바로 앞에 쉼표가 하나 더 있는지 보세요.'
: '그 앞의 쉼표·따옴표·괄호를 확인해 주세요.';
const lineCol = /line (\d+) column (\d+)/.exec(message);
if (lineCol) return `${lineCol[1]}번째 줄 ${lineCol[2]}번째 글자에서 JSON 이 끊깁니다. ${hint}`;
const at = /position (\d+)/.exec(message);
if (at) return `${where(Number(at[1]))}에서 JSON 이 끊깁니다. ${hint}`;
// 위치 없이 조각만 인용하는 형 — 그 조각을 원문에서 되찾는다.
const quoted = /\.\.\."([\s\S]*?)" is not valid JSON/.exec(message)?.[1];
const found = quoted ? raw.indexOf(quoted) : -1;
if (found >= 0) return `${where(found + quoted!.length)} 부근에서 JSON 이 끊깁니다. ${hint}`;
return 'JSON 이 아닙니다. ChatGPT 가 준 답에서 { 로 시작해 } 로 끝나는 부분만 붙여넣어 주세요.';
}
/**
* 붙여넣은 문자열 → 렌더 가능한 항목.
*
* ★ 절대 throw 하지 않는다. 편집 중인 JSON 은 늘 깨져 있고, 깨진 순간 캔버스가 죽으면 못 고친다.
* 발행 사이트에서도 같다 — 프리렌더가 예외로 죽으면 사이트 전체가 안 구워진다.
*/
export function parseSectionData<T extends object>(
sectionType: string,
raw: string | undefined,
): ParsedSectionData<T> {
const requiredKey = SECTION_ITEM_REQUIRED_KEY[sectionType];
const text = (raw ?? '').trim();
if (!requiredKey || !text) return EMPTY as ParsedSectionData<T>;
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (error) {
return {...EMPTY, error: locate(text, error instanceof Error ? error.message : '')};
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return {...EMPTY, error: '바깥이 { } 로 감싸인 JSON 이어야 합니다.'};
}
const envelope = parsed as Record<string, unknown>;
const kind = typeof envelope.kind === 'string' ? envelope.kind : undefined;
const rawItems = envelope.items;
if (!Array.isArray(rawItems)) {
return {...EMPTY, error: 'items 배열이 없습니다. 프롬프트로 다시 만들어 주세요.'};
}
const items = rawItems.filter(
(item): item is T =>
typeof item === 'object' &&
item !== null &&
!Array.isArray(item) &&
typeof (item as Record<string, unknown>)[requiredKey] === 'string' &&
((item as Record<string, unknown>)[requiredKey] as string).trim().length > 0,
);
let unverified = 0;
let sourced = 0;
for (const item of items) {
const row = item as Record<string, unknown>;
if (row.verified !== '확인') unverified += 1;
if (row.source && typeof row.source === 'object') sourced += 1;
}
return {
items,
title: typeof envelope.title === 'string' ? envelope.title : undefined,
subtitle: typeof envelope.subtitle === 'string' ? envelope.subtitle : undefined,
kindMismatch: kind && kind !== sectionType ? kind : undefined,
unverified,
sourced,
};
}
/* ─────────────────────────────────────────────────────────────
* 계절별 추천 하루 — 시각을 **계산해서** 짜 준다.
*
* ★ 왜 shared 인가: 파서와 같은 이유다. 빌더 캔버스와 발행 사이트가 같은 코스에서 **같은 시각**을
* 내놓아야 한다. 조립 규칙이 두 벌이면 사장님이 본 일정과 손님이 보는 일정이 조용히 갈린다.
* ★ 여행 스케줄(schedule)과 축이 다르다 — 저쪽은 사장님이 시각을 적고, 여기는 시각을 계산한다.
* 그래서 사장님은 "몇 분 걸리나"만 알면 되고, 출발 시각을 바꾸면 하루가 통째로 밀린다.
* ───────────────────────────────────────────────────────────── */
/** 밤 9시를 넘기는 칸은 넣지 않는다 — 짜 준 일정이 손님을 밤까지 끌고 다니면 그 순간 신뢰를 잃는다. */
const PLAN_ENDS_BY = 21 * 60;
/** 머무는 시간을 안 적었을 때. 0 으로 두면 한 시각에 칸이 겹쳐 쌓인다. */
const PLAN_DEFAULT_STAY = 60;
/** 출발 시각을 안 적었을 때. 체크아웃 뒤 움직이는 시각을 기본으로 잡는다. */
const PLAN_DEFAULT_START = '10:00';
/** 계절 탭 순서. 데이터에 있는 계절만 이 순서로 세운다 — 붙여넣은 순서대로 두면 겨울이 맨 앞에 온다. */
const SEASON_ORDER = ['봄', '여름', '가을', '겨울'];
/** "HH:MM" → 자정부터의 분. 형식이 아니면 undefined — 지어내지 않는다. */
export function minutesOfTime(time: string): number | undefined {
const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
if (!match) return undefined;
const hour = Number(match[1]);
const minute = Number(match[2]);
if (hour > 23 || minute > 59) return undefined;
return hour * 60 + minute;
}
export function timeOfMinutes(total: number): string {
const wrapped = ((total % 1440) + 1440) % 1440;
return `${String(Math.floor(wrapped / 60)).padStart(2, '0')}:${String(wrapped % 60).padStart(2, '0')}`;
}
export interface PlannedStop {
stop: PlannerStop;
/** 도착 시각 "HH:MM". */
time: string;
/** 떠나는 시각 "HH:MM". */
until: string;
/** 앞 칸에서 오는 데 걸린 분. 0 이면 화면이 이동 줄을 그리지 않는다. */
move: number;
}
export interface PlannedDay {
stops: PlannedStop[];
/** 하루가 시작·끝나는 시각. 카드 머리에 "09:30–16:40" 으로 뜬다. */
from: string;
to: string;
/** 총 소요(분) — 이동 시간까지 포함한다. */
totalMinutes: number;
/** 21시 상한에 걸려 못 넣은 칸 수. 숨기지 않고 화면에 밝힌다. */
dropped: number;
}
/**
* 코스 하나 → 시각이 박힌 하루.
*
* 정거장 순서는 사장님이 적은 그대로다(적은 순서가 곧 도는 순서다). 출발 시각부터
* 이동·머무는 시간을 누적해 칸마다 도착·출발 시각을 박고, 상한을 넘기는 칸은 버린다 —
* 넘겨서라도 다 넣으면 자정에 끝나는 일정이 나온다.
*/
export function planDay(item: PlannerItem): PlannedDay {
const start = minutesOfTime(item.startTime ?? '') ?? minutesOfTime(PLAN_DEFAULT_START) ?? 600;
const stops: PlannedStop[] = [];
let clock = start;
let dropped = 0;
for (const stop of item.stops ?? []) {
const move = Math.max(0, stop.moveMinutes ?? 0);
const stay = Math.max(1, stop.minutes ?? PLAN_DEFAULT_STAY);
const arrive = clock + move;
if (arrive + stay > PLAN_ENDS_BY) {
dropped += 1;
continue;
}
stops.push({stop, time: timeOfMinutes(arrive), until: timeOfMinutes(arrive + stay), move});
clock = arrive + stay;
}
return {
stops,
from: timeOfMinutes(start),
to: timeOfMinutes(clock),
totalMinutes: clock - start,
dropped,
};
}
/**
* 지금 계절. **간절기에는 두 개**를 돌려준다.
*
* ★ 왜 둘인가 — 9월 초에 온 손님에게 여름 코스만 보이면 이미 지난 계절이고, 가을 코스만
* 보이면 아직 이른 코스다. 경계에서는 둘 다 보여야 손님이 고를 수 있다.
* ★ 경계는 계절 첫 달의 전반(1~15일)로 잡는다. 실측이 아니라 규약이라 이 한 곳에만 둔다 —
* 빌더와 발행본이 같은 날 다른 계절을 고르면 사장님이 본 것과 손님이 보는 것이 갈린다.
* ★ 절대 빌드 시각으로 계산하지 않는다. 발행본은 정적이라 한 번 구우면 몇 달을 사는데,
* 구운 날의 계절을 박으면 12월에도 가을 코스가 걸린다(일력이 '오늘'을 다루는 방식과 같다).
*/
export function currentSeasons(now: Date = new Date()): string[] {
const month = now.getMonth() + 1;
// 3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울. 3월을 0 으로 당겨 3으로 끊는다.
const index = Math.floor(((month - 3 + 12) % 12) / 3);
const season = SEASON_ORDER[index];
const isFirstMonth = month % 3 === 0;
if (isFirstMonth && now.getDate() <= 15) {
return [SEASON_ORDER[(index + 3) % 4], season];
}
return [season];
}
/** 데이터에 실제로 있는 계절만, 봄·여름·가을·겨울 순으로. 그 밖의 값(장마·연중)은 뒤에 붙인다. */
export function plannerSeasons(items: PlannerItem[]): string[] {
const seen: string[] = [];
for (const item of items) {
const season = item.season?.trim();
if (season && !seen.includes(season)) seen.push(season);
}
return seen.sort((a, b) => {
const ai = SEASON_ORDER.indexOf(a);
const bi = SEASON_ORDER.indexOf(b);
return (ai < 0 ? SEASON_ORDER.length : ai) - (bi < 0 ? SEASON_ORDER.length : bi);
});
}
/**
* 그 계절의 추천 코스 — 순위대로 최대 세 개.
*
* ★ 셋에서 끊는다. 넷째부터는 아무도 안 고르고, 화면에서는 "추천"이 아니라 목록이 된다.
* ★ rank 를 안 적었으면 붙여넣은 순서가 순위다 — 순위 없는 코스를 1위로 올리지 않는다.
*/
export function plannerTop(items: PlannerItem[], season?: string): PlannerItem[] {
const picked = season ? items.filter((item) => item.season?.trim() === season) : [...items];
picked.sort((a, b) => (a.rank ?? Number.MAX_SAFE_INTEGER) - (b.rank ?? Number.MAX_SAFE_INTEGER));
return picked.slice(0, 3);
}

View File

@ -1,3 +1,4 @@
import type {TemplateLook} from './builder';
import type { import type {
FactStatus, FactStatus,
LinkChannel, LinkChannel,
@ -229,6 +230,15 @@ export interface SiteTheme {
accent: string; accent: string;
}; };
fontStyle: string; fontStyle: string;
/**
* 템플릿의 생김새 — 서체 · 모서리 · 테두리 두께 · 그림자 · 섹션 여백.
*
* ★ 이 필드가 없던 동안 발행본은 **색만** 템플릿을 따랐다. 사장님이 레트로(간판체)를 골라도
* 발행 페이지는 늘 같은 고딕·명조로 나갔다 — 캔버스와 발행본이 다르게 보이는 가장 큰 이유였다.
* ★ 값은 전부 CSS 에 그대로 들어가는 문자열이다(`TemplateLook`). 렌더러는 이걸 `--tpl-*` 로
* 내려보내기만 한다. 비어 있으면 렌더러 기본 서체로 떨어진다 — 화면이 깨지지 않는다.
*/
look?: TemplateLook;
/** 섹션 순서와 노출 여부. 관리자 에디터의 좌측 패널이 만든 결과 그대로. */ /** 섹션 순서와 노출 여부. 관리자 에디터의 좌측 패널이 만든 결과 그대로. */
sections: SectionSetting[]; sections: SectionSetting[];
} }
@ -258,4 +268,13 @@ export interface SectionSetting {
* ★ 줄바꿈이 문단 구분이다. 렌더러가 빈 줄을 기준으로 <p> 를 나눈다. * ★ 줄바꿈이 문단 구분이다. 렌더러가 빈 줄을 기준으로 <p> 를 나눈다.
*/ */
body?: string; body?: string;
/**
* 붙여넣기 아이템(가요·일력·승차권·인물…)의 원문 JSON.
*
* ★ body·variantId 와 같은 사연이다 — 이 필드가 없으면 사장님이 붙여넣은 곡 목록이
* payload 경계에서 버려져 빌더에서는 보이는데 발행본에는 없다.
* ★ **문자열 그대로** 싣는다. 서버는 파싱하지 않는다 — 렌더러가 `parseSectionData()`
* (shared/lib/section-data.ts)로 읽고, 깨진 JSON 이면 그 섹션만 조용히 비운다.
*/
data?: string;
} }

View File

@ -12,7 +12,12 @@ import {
import {basename, dirname, join, resolve} from 'node:path'; import {basename, dirname, join, resolve} from 'node:path';
import {fileURLToPath} from 'node:url'; import {fileURLToPath} from 'node:url';
import type {SitePayload} from '@o2o/shared'; import type {SitePayload} from '@o2o/shared';
import {joinUrl, sanitizePayloadForPublish} from '@o2o/shared'; import {
joinUrl,
parseSectionData,
sanitizePayloadForPublish,
SECTION_ITEM_REQUIRED_KEY,
} from '@o2o/shared';
import {render} from '@/entry-server'; import {render} from '@/entry-server';
import { import {
collectJsonLd, collectJsonLd,
@ -397,6 +402,27 @@ function countUniqueContent(payload: SitePayload): number {
const intro = payload.theme.sections.find((section) => section.id === 'intro'); const intro = payload.theme.sections.find((section) => section.id === 'intro');
if (intro?.enabled && long(intro.body)) count += 1; if (intro?.enabled && long(intro.body)) count += 1;
/**
* 붙여넣기 아이템(가요·일력·승차권·인물…)의 항목도 이 가게에만 있는 문장이다.
*
* ★ 안 세면 "곡을 여덟 개 채웠는데 고유 콘텐츠 0건으로 발행이 막힌다"가 된다 —
* 소개 본문(intro.body)이 계약에 없던 시절과 같은 구멍이다.
* ★ 긴 문장이 한 줄이라도 있는 항목만 센다. 제목·연도만 있는 줄은 가게를 구분하지 못한다.
*/
const hasLongText = (value: unknown): boolean => {
if (typeof value === 'string') return long(value);
if (Array.isArray(value)) return value.some(hasLongText);
if (value && typeof value === 'object') return Object.values(value).some(hasLongText);
return false;
};
for (const section of payload.theme.sections) {
if (!section.enabled || !SECTION_ITEM_REQUIRED_KEY[section.id]) continue;
const parsed = parseSectionData<Record<string, unknown>>(section.id, section.data);
for (const item of parsed.items) {
if (hasLongText(item)) count += 1;
}
}
// 문장형 fact 만 센다 — bool·number·time 은 전부 템플릿 값이라 가게를 구분하지 못한다. // 문장형 fact 만 센다 — bool·number·time 은 전부 템플릿 값이라 가게를 구분하지 못한다.
for (const fact of [...payload.facts, ...payload.units.flatMap((unit) => unit.facts)]) { for (const fact of [...payload.facts, ...payload.units.flatMap((unit) => unit.facts)]) {
if (fact.type === 'text' && long(fact.value)) count += 1; if (fact.type === 'text' && long(fact.value)) count += 1;

View File

@ -38,13 +38,22 @@ html {
body { body {
background-color: var(--color-surface); background-color: var(--color-surface);
color: var(--color-ink); color: var(--color-ink);
font-family: var(--font-sans); /* ★ 본문 서체도 템플릿이 정한다(theme.look → seo/head.ts). 토큰이 없는 옛 payload 에서는
지금까지와 똑같이 Noto Sans KR 로 떨어진다. */
font-family: var(--tpl-font-body, var(--font-sans));
font-feature-settings: "tnum" 1; font-feature-settings: "tnum" 1;
} }
@layer components { @layer components {
/* 제목 서체. 이름은 'serif' 로 남아 있지만 값은 템플릿이 정한다 —
레트로면 간판체, 심플이면 고딕이다. 토큰이 없으면 예전처럼 명조로 떨어진다. */
.serif { .serif {
font-family: var(--font-serif); font-family: var(--tpl-font-heading, var(--font-serif));
letter-spacing: var(--tpl-heading-tracking, normal);
}
/* 카드·패널 테두리 두께도 템플릿이 정한다. 레트로는 2px 라야 인쇄물처럼 보인다. */
.tpl-border {
border-width: var(--tpl-border-width, 1px);
} }
/* 섹션 공통 폭 — 본문이 한 줄에 너무 길어지지 않게. */ /* 섹션 공통 폭 — 본문이 한 줄에 너무 길어지지 않게. */

Some files were not shown because too many files have changed in this diff Show More