Compare commits
24 Commits
2026fde80f
...
a4ec508ba5
| Author | SHA1 | Date | |
|---|---|---|---|
| a4ec508ba5 | |||
| ee8ba59036 | |||
| 0c3f8dd3b0 | |||
| d8ed0766d6 | |||
| d5e9b87e3f | |||
| 4ef8e7d232 | |||
| fd716222ad | |||
| af391b4c56 | |||
| a61f9724ea | |||
| 41e49c693b | |||
| a52d166fae | |||
| e2d15955b0 | |||
| b07ade25b2 | |||
| 282427e10b | |||
| 479edf9403 | |||
| 128596fc74 | |||
| d376677b86 | |||
| 65705c5bed | |||
| e1423418ae | |||
| cfec47230c | |||
| 01c252287e | |||
| 71c0c1f6ab | |||
| 4d6802b4f2 | |||
| 5fa83933e4 |
@ -144,8 +144,11 @@ negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를
|
||||
`UserRole.DEVELOPER` 주석의 **"고객사에 존재를 노출하지 않는다"** 를 번들이 깨고 있었다.
|
||||
라우트 가드는 화면을 가리지 **번들은 못 가린다.**
|
||||
★ 이 문제는 **코드 크기와 무관하다.** 내부 화면이 814줄뿐이어도 내려가는 건 같다.
|
||||
2. **인증 모델이 갈라진다.** 빌더는 일부러 로그인을 안 세운다("만들어 보기도 전에 막힌다").
|
||||
내부 화면은 전부 `RequireAuth` 뒤다. 한 앱에서 두 정책을 유지하면 실수는 늘 **느슨한 쪽으로** 난다.
|
||||
2. **인증 모델이 갈라진다.** 빌더는 위저드를 열어 두고 **에디터 진입에서 한 번** 받는다
|
||||
("만들어 보기도 전에 막힌다"). 내부 화면은 전부 `RequireAuth` 뒤다. 계정이 생기는 방식도
|
||||
다르다 — 사장님은 스스로 가입하고 구글로도 들어오지만, 내부 운영 계정은 우리가 만들고
|
||||
role >= DEVELOPER 여야 한다(`LoginPage` 의 `selfServe` 플래그가 그 차이를 한 곳에서 드러낸다).
|
||||
한 앱에서 두 정책을 유지하면 실수는 늘 **느슨한 쪽으로** 난다.
|
||||
→ 지금은 두 `provider.tsx` 가 그 차이를 각자 명시한다(사장님: 인증 실패를 삼킨다 /
|
||||
내부: 실패가 곧 차단).
|
||||
3. **배포 리듬이 다르다.** 내부 화면을 고치려고 사장님 화면을 재배포하지 않는다.
|
||||
@ -184,7 +187,9 @@ OWNER role=2 → 403
|
||||
```
|
||||
|
||||
OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다.
|
||||
`auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다).
|
||||
`auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). `signup`·`google` 도
|
||||
같은 이유로 토큰 없이 열려 있다 — **여기서 만들어지는 계정은 언제나 `role=USER` 이고 자기
|
||||
회사(새 테넌트) 하나만 본다.** 권한이 올라가는 경로는 이 문 뒤에 없다.
|
||||
|
||||
⚠️ **`/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다**(`router/router.py`).
|
||||
위 논리대로라면 이 라우터는 :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` 기본값을
|
||||
`127.0.0.1` 로 두었다. **0.0.0.0 으로 열면 앱을 가른 의미가 없다.**
|
||||
- **폰트 self-host** — `solution/site/public/fonts/PretendardVariable.woff2` 가 없어 Noto Sans KR 로
|
||||
|
||||
193
docs/DEVLOG.md
193
docs/DEVLOG.md
@ -5,6 +5,24 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-03 — 랜딩 · 요금 · 쇼케이스 — 로그인 전 화면이 생겼다
|
||||
|
||||
**왜**
|
||||
`/` 가 곧장 위저드로 튀어서, 이 제품이 무엇을 파는 물건인지 말할 자리가 한 곳도 없었다.
|
||||
처음 온 사람이 업종 선택 화면부터 만난다.
|
||||
|
||||
**한 일**
|
||||
- `/` 는 비로그인이면 랜딩, 로그인이면 `/sites`. `/pricing` · `/showcase` 신설
|
||||
- `MarketingShell` — 사이드바 없는 문서형 껍데기. `AppShell` 은 작업 화면이라 나눴다
|
||||
(b07ade2 가 온보딩에서 사이드바를 뺀 것과 같은 판단)
|
||||
- 랜딩 상단은 **상호명 한 칸**이다. 업종 칩은 "누구를 위한 서비스인가"를 말하는 용도이고
|
||||
고르지 않아도 된다 — 업종은 검색 결과가 정한다
|
||||
- 쇼케이스는 발행 썸네일을 그대로 건다. **예시 데이터로 채우지 않는다** — 이 섹션이 파는 건
|
||||
"진짜로 나갔다"는 사실 하나라, 가짜를 걸면 그 자리에서 가치가 0 이다. 없으면 섹션을 감춘다
|
||||
- 요금은 플랜 하나(70만원/월). 비교표를 만들지 않는다 — 고를 것이 가격대가 아니다
|
||||
|
||||
**검증** — tsc·eslint·vite build 통과.
|
||||
|
||||
## 2026-09-03 — 상호명 검색을 로그인 앞으로 · 업종은 LLM 없이 정한다
|
||||
|
||||
**왜**
|
||||
@ -64,6 +82,181 @@
|
||||
유도한다(`snapshot._local_contents`) — 유도 동작에 테스트가 없었다. 둘로 갈라 채웠다.
|
||||
|
||||
**검증** — `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 — 직접 쓴 소개문이 발행에서 사라지던 구멍
|
||||
|
||||
|
||||
@ -359,8 +359,6 @@ CREATE INDEX IF NOT EXISTS idx_users_company_id ON company.users (company_id);
|
||||
|
||||
-- 소프트 삭제를 쓰므로 자연키 유니크는 부분 인덱스(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 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;
|
||||
ALTER TABLE company.users ALTER COLUMN id TYPE VARCHAR(64);
|
||||
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;
|
||||
|
||||
@ -196,6 +196,15 @@ def fake_renderer(monkeypatch, tmp_path_factory):
|
||||
# 고유 콘텐츠 계수 규칙은 렌더러(prerender.ts countUniqueContent)와 같아야 한다.
|
||||
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 long(value) -> bool:
|
||||
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")):
|
||||
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 [])
|
||||
for unit in payload.get("units") or []:
|
||||
facts.extend(unit.get("facts") or [])
|
||||
|
||||
@ -21,6 +21,10 @@ class ISiteCRUD(ABC):
|
||||
async def get_site_by_domain(self, cdb: AsyncSession, domain: str) -> Tuple[ErrorType, sites]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def list_company_sites(self, cdb: AsyncSession, company_id, skip, limit) -> Tuple[ErrorType, list, int]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]:
|
||||
pass
|
||||
@ -89,6 +93,36 @@ class SiteCRUD(ISiteCRUD):
|
||||
LOG.e_no_callstack(ex)
|
||||
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]:
|
||||
"""후보 주소들 중 이미 쓰이는 것만 추린다. 대안 제안이 후보마다 왕복하지 않게 한 번에 본다."""
|
||||
try:
|
||||
|
||||
@ -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.job.job.router)
|
||||
app.include_router(router.v1.site.site.router)
|
||||
app.include_router(router.v1.site.site.my_router)
|
||||
# ★ 인증 없는 공개 목록. 랜딩이 부른다 — 어드민 진입점(:9801)에는 붙이지 않는다.
|
||||
app.include_router(router.v1.site.showcase.router)
|
||||
app.include_router(router.v1.local.local.router)
|
||||
|
||||
@ -8,12 +8,13 @@ from common.enums import (
|
||||
BuildStatus,
|
||||
JobStatus,
|
||||
PlaceCategory,
|
||||
PlaceStatus,
|
||||
PublishAction,
|
||||
PublishRejectReason,
|
||||
PublishResult,
|
||||
SiteStatus,
|
||||
)
|
||||
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
|
||||
|
||||
|
||||
class SiteProtocol(WebPacketProtocol):
|
||||
@ -66,6 +67,32 @@ class SiteData(WebPacketProtocol):
|
||||
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):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@ -97,9 +124,9 @@ class Req_SiteTheme(SiteProtocol):
|
||||
떨어뜨리므로 화면은 깨지지 않는다.
|
||||
|
||||
★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다:
|
||||
{"theme": {"colors": {...}, "fontStyle": "...", "colorPaletteId": "...", "sections": [...]}}
|
||||
sections 는 {id, name, enabled, locked, variantId?, body?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
|
||||
(별도 order 필드가 없다). variantId 와 직접 입력 본문 body 는 값이 있을 때만 키가 붙는다.
|
||||
{"theme": {"colors": {...}, "fontStyle": "...", "look": {...}, "colorPaletteId": "...", "sections": [...]}}
|
||||
sections 는 {id, name, enabled, locked, variantId?, body?, data?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
|
||||
(별도 order 필드가 없다). variantId·본문 body·붙여넣기 JSON data 는 값이 있을 때만 키가 붙는다.
|
||||
pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 —
|
||||
서버는 배달부지 심판이 아니다.
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ from uuid import UUID
|
||||
|
||||
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 services.site_service import SiteService
|
||||
from .protocol import (
|
||||
@ -11,6 +11,7 @@ from .protocol import (
|
||||
Req_SiteTemplate,
|
||||
Req_SiteTheme,
|
||||
Req_StartBuild,
|
||||
Res_MySites,
|
||||
Res_PublishLogs,
|
||||
Res_Site,
|
||||
Res_SeoAudit,
|
||||
@ -23,6 +24,25 @@ from .protocol import (
|
||||
# 사이트/발행 라우터. 사업장 하위 리소스이며 회사 스코프는 service 가 사업장 조회로 강제한다.
|
||||
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(
|
||||
path="",
|
||||
@ -105,8 +125,8 @@ async def set_template(
|
||||
summary="디자인(색·서체·섹션) 저장",
|
||||
description="에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다"
|
||||
"(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: "
|
||||
"{\"theme\":{\"colors\":{...},\"fontStyle\":\"...\",\"colorPaletteId\":\"...\","
|
||||
"\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\",\"body\"}]}}. "
|
||||
"{\"theme\":{\"colors\":{...},\"fontStyle\":\"...\",\"look\":{...},\"colorPaletteId\":\"...\","
|
||||
"\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\",\"body\",\"data\"}]}}. "
|
||||
"★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). "
|
||||
"★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. "
|
||||
"직렬화 크기(64KB)만 막는다. "
|
||||
|
||||
107
solution/backend/scripts/backfill_thumbnails.py
Normal file
107
solution/backend/scripts/backfill_thumbnails.py
Normal 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()
|
||||
@ -295,7 +295,7 @@ def _theme(site, theme_spec: dict) -> dict:
|
||||
|
||||
# ★ colorPaletteId 는 여기 싣지 않는다. 에디터 복원 전용 값이고 발행 계약(SiteTheme)에 없다 —
|
||||
# 계약에 없는 필드를 payload 에 흘리면 렌더러가 모르는 것이 발행본에 섞인다.
|
||||
return {
|
||||
out = {
|
||||
# 저장된 템플릿이 있으면 그것으로 굽는다(sites.template_id). 비어 있으면 업종 기본이다.
|
||||
# 여기서 안 읽으면 사장님이 고른 디자인과 실제 발행본이 갈린다(그게 이 컬럼이 생긴 이유다).
|
||||
"templateId": _text(_get(site, "template_id")) or theme_spec["templateId"],
|
||||
@ -303,6 +303,13 @@ def _theme(site, theme_spec: dict) -> dict:
|
||||
"fontStyle": font_style,
|
||||
"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:
|
||||
@ -372,6 +379,12 @@ def _sections(saved_sections, default_spec) -> list:
|
||||
body = _text(item.get("body"))
|
||||
if 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)
|
||||
|
||||
for sid, label, locked in default_spec:
|
||||
|
||||
@ -16,12 +16,13 @@ from common.enums import (
|
||||
SiteStatus,
|
||||
)
|
||||
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 crud.job_crud import JobQueue
|
||||
from crud.place_crud import PlaceCRUD
|
||||
from crud.site_crud import ISiteCRUD, SiteCRUD
|
||||
from router.v1.site.protocol import (
|
||||
MySiteData,
|
||||
PublishLogData,
|
||||
RenderStatusData,
|
||||
Req_SiteSlug,
|
||||
@ -29,6 +30,7 @@ from router.v1.site.protocol import (
|
||||
Req_SiteTemplate,
|
||||
Req_SiteTheme,
|
||||
Req_StartBuild,
|
||||
Res_MySites,
|
||||
Res_PublishLogs,
|
||||
Res_Site,
|
||||
Res_SeoAudit,
|
||||
@ -417,6 +419,44 @@ class SiteService:
|
||||
return _THEME_REJECTED
|
||||
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:
|
||||
res = Res_Site()
|
||||
err_type, place = await self._load_place(user_info, place_id)
|
||||
|
||||
90
solution/backend/tests/test_my_sites.py
Normal file
90
solution/backend/tests/test_my_sites.py
Normal 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
|
||||
@ -54,7 +54,9 @@ export * from './listFactsParams';
|
||||
export * from './listFaqsParams';
|
||||
export * from './listLinksParams';
|
||||
export * from './listMediaParams';
|
||||
export * from './listMySitesParams';
|
||||
export * from './listPlacesParams';
|
||||
export * from './listShowcaseParams';
|
||||
export * from './localContentData';
|
||||
export * from './localContentDataBody';
|
||||
export * from './localContentDataCollectedAt';
|
||||
@ -77,6 +79,14 @@ export * from './mediaDataUnitId';
|
||||
export * from './mediaDataVisionConfidence';
|
||||
export * from './mediaDataWidth';
|
||||
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 './placeCandidateAddress';
|
||||
export * from './placeCandidateCategoryName';
|
||||
@ -101,6 +111,10 @@ export * from './placeDataPhone';
|
||||
export * from './placeDataRegionCode';
|
||||
export * from './placeDataRoadAddress';
|
||||
export * from './placeDataVerifiedAt';
|
||||
export * from './placeSearchItem';
|
||||
export * from './placeSearchItemCategory';
|
||||
export * from './placeSearchItemCategoryName';
|
||||
export * from './placeSearchItemRoadAddress';
|
||||
export * from './placeStatus';
|
||||
export * from './publishAction';
|
||||
export * from './publishLogData';
|
||||
@ -215,11 +229,16 @@ export * from './resMeMsg';
|
||||
export * from './resMeName';
|
||||
export * from './resMediaList';
|
||||
export * from './resMediaListMsg';
|
||||
export * from './resMySites';
|
||||
export * from './resMySitesMsg';
|
||||
export * from './resPlace';
|
||||
export * from './resPlaceList';
|
||||
export * from './resPlaceListMsg';
|
||||
export * from './resPlaceMsg';
|
||||
export * from './resPlacePlace';
|
||||
export * from './resPlaceSearch';
|
||||
export * from './resPlaceSearchMsg';
|
||||
export * from './resPlaceSearchSource';
|
||||
export * from './resPublishLogs';
|
||||
export * from './resPublishLogsMsg';
|
||||
export * from './resRefreshToken';
|
||||
@ -228,6 +247,8 @@ export * from './resSeoAudit';
|
||||
export * from './resSeoAuditMsg';
|
||||
export * from './resSeoAuditSummary';
|
||||
export * from './resSeoAuditVisibility';
|
||||
export * from './resShowcase';
|
||||
export * from './resShowcaseMsg';
|
||||
export * from './resSite';
|
||||
export * from './resSiteCurrentVersion';
|
||||
export * from './resSiteMsg';
|
||||
@ -273,6 +294,10 @@ export * from './resVerifyCandidatesSource';
|
||||
export * from './resWeather';
|
||||
export * from './resWeatherMsg';
|
||||
export * from './resWeatherWeather';
|
||||
export * from './searchPlacesPublicParams';
|
||||
export * from './showcaseItem';
|
||||
export * from './showcaseItemRegion';
|
||||
export * from './showcaseItemThumbnailUrl';
|
||||
export * from './siteData';
|
||||
export * from './siteDataCurrentVersionId';
|
||||
export * from './siteDataDomain';
|
||||
@ -280,6 +305,7 @@ export * from './siteDataPublishedAt';
|
||||
export * from './siteDataTemplateId';
|
||||
export * from './siteDataTheme';
|
||||
export * from './siteDataThemeAnyOf';
|
||||
export * from './siteDataThumbnailUrl';
|
||||
export * from './siteStatus';
|
||||
export * from './siteVersionData';
|
||||
export * from './siteVersionDataBuildError';
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
36
solution/frontend/src/api/generated/model/mySiteData.ts
Normal file
36
solution/frontend/src/api/generated/model/mySiteData.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
24
solution/frontend/src/api/generated/model/placeSearchItem.ts
Normal file
24
solution/frontend/src/api/generated/model/placeSearchItem.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -16,9 +16,9 @@ import type { ReqSiteThemeTheme } from './reqSiteThemeTheme';
|
||||
떨어뜨리므로 화면은 깨지지 않는다.
|
||||
|
||||
★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다:
|
||||
{"theme": {"colors": {...}, "fontStyle": "...", "colorPaletteId": "...", "sections": [...]}}
|
||||
sections 는 {id, name, enabled, locked, variantId?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
|
||||
(별도 order 필드가 없다). variantId 는 고른 게 있을 때만 키가 붙는다.
|
||||
{"theme": {"colors": {...}, "fontStyle": "...", "look": {...}, "colorPaletteId": "...", "sections": [...]}}
|
||||
sections 는 {id, name, enabled, locked, variantId?, body?, data?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
|
||||
(별도 order 필드가 없다). variantId·본문 body·붙여넣기 JSON data 는 값이 있을 때만 키가 붙는다.
|
||||
pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 —
|
||||
서버는 배달부지 심판이 아니다.
|
||||
|
||||
|
||||
18
solution/frontend/src/api/generated/model/resMySites.ts
Normal file
18
solution/frontend/src/api/generated/model/resMySites.ts
Normal 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[];
|
||||
}
|
||||
@ -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;
|
||||
23
solution/frontend/src/api/generated/model/resPlaceSearch.ts
Normal file
23
solution/frontend/src/api/generated/model/resPlaceSearch.ts
Normal 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[];
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
15
solution/frontend/src/api/generated/model/resShowcase.ts
Normal file
15
solution/frontend/src/api/generated/model/resShowcase.ts
Normal 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[];
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
};
|
||||
24
solution/frontend/src/api/generated/model/showcaseItem.ts
Normal file
24
solution/frontend/src/api/generated/model/showcaseItem.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -10,6 +10,7 @@ import type { SiteDataTemplateId } from './siteDataTemplateId';
|
||||
import type { SiteDataTheme } from './siteDataTheme';
|
||||
import type { SiteDataCurrentVersionId } from './siteDataCurrentVersionId';
|
||||
import type { SiteDataPublishedAt } from './siteDataPublishedAt';
|
||||
import type { SiteDataThumbnailUrl } from './siteDataThumbnailUrl';
|
||||
|
||||
export interface SiteData {
|
||||
site_id: string;
|
||||
@ -20,4 +21,5 @@ export interface SiteData {
|
||||
theme?: SiteDataTheme;
|
||||
current_version_id?: SiteDataCurrentVersionId;
|
||||
published_at?: SiteDataPublishedAt;
|
||||
thumbnail_url?: SiteDataThumbnailUrl;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -41,12 +41,14 @@ import type {
|
||||
ResLinkList,
|
||||
ResPlace,
|
||||
ResPlaceList,
|
||||
ResPlaceSearch,
|
||||
ResStartCollect,
|
||||
ResStartCopy,
|
||||
ResStartVision,
|
||||
ResUnit,
|
||||
ResUnitList,
|
||||
ResVerifyCandidates,
|
||||
SearchPlacesPublicParams,
|
||||
VerifyCandidatesParams
|
||||
} 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)이 채운다.
|
||||
* @summary 사업장 등록
|
||||
|
||||
128
solution/frontend/src/api/generated/showcase/showcase.ts
Normal file
128
solution/frontend/src/api/generated/showcase/showcase.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -26,11 +26,13 @@ import type {
|
||||
import type {
|
||||
CheckSlugParams,
|
||||
HTTPValidationError,
|
||||
ListMySitesParams,
|
||||
ReqSiteSlug,
|
||||
ReqSiteStatus,
|
||||
ReqSiteTemplate,
|
||||
ReqSiteTheme,
|
||||
ReqStartBuild,
|
||||
ResMySites,
|
||||
ResPublishLogs,
|
||||
ResSeoAudit,
|
||||
ResSite,
|
||||
@ -467,7 +469,7 @@ export const useSetTemplate = <TError = void | HTTPValidationError,
|
||||
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 디자인(색·서체·섹션) 저장
|
||||
*/
|
||||
export const setTheme = (
|
||||
@ -849,4 +851,97 @@ export const useChangeStatus = <TError = void | HTTPValidationError,
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,17 +1,70 @@
|
||||
import {createBrowserRouter, Navigate} from 'react-router';
|
||||
import {Loader2} from 'lucide-react';
|
||||
import {AccountPage} from '@/pages/AccountPage';
|
||||
import {BuilderPage} from '@/pages/BuilderPage';
|
||||
import {DevShowcasePage} from '@/pages/DevShowcasePage';
|
||||
import {LandingPage} from '@/pages/LandingPage';
|
||||
import {LoginPage} from '@/pages/LoginPage';
|
||||
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([
|
||||
{path: '/login', element: <LoginPage />},
|
||||
// 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다.
|
||||
{path: '/signup', element: <SignupPage />},
|
||||
|
||||
// ★ 첫 화면은 업종 선택(위저드 1단계)이다.
|
||||
// `?new=1` 을 붙이는 이유: 위저드 상태는 새로고침을 넘기려고 저장돼 있어서(stores/builder persist),
|
||||
// 그냥 /builder 로 보내면 지난번에 만들다 만 **에디터**가 복원돼 뜬다. 처음 들어오는 사람에게는
|
||||
// 그게 "왜 자꾸 빌더로 튀냐"로 보인다. 그래서 진입 경로에서 한 번 비우고 시작한다.
|
||||
{path: '/', element: <Navigate to="/builder?new=1" replace />},
|
||||
// 비로그인 = 랜딩, 로그인 = 내 사이트. Home 이 그걸 가른다.
|
||||
{path: '/', element: <Home />},
|
||||
|
||||
// 로그인 전 화면. ★ 랜딩과 같은 껍데기(MarketingShell)를 쓴다 — 사이드바 없는 문서형이다.
|
||||
{path: '/pricing', element: <PricingPage />},
|
||||
{path: '/showcase', element: <ShowcasePage />},
|
||||
|
||||
// 로그인한 사장님의 홈. 만든 사이트를 열고 고치는 자리다.
|
||||
{
|
||||
path: '/sites',
|
||||
element: (
|
||||
<RequireAuth>
|
||||
<SitesPage />
|
||||
</RequireAuth>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/account',
|
||||
element: (
|
||||
<RequireAuth>
|
||||
<AccountPage />
|
||||
</RequireAuth>
|
||||
),
|
||||
},
|
||||
|
||||
/**
|
||||
* 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면
|
||||
|
||||
@ -11,7 +11,7 @@ import {GOOGLE_CLIENT_ID, isGoogleLoginEnabled, loadGoogleIdentity} from '@/lib/
|
||||
*/
|
||||
export function GoogleSignInButton({
|
||||
onCredential,
|
||||
text = 'continue_with',
|
||||
text = 'signin_with',
|
||||
}: {
|
||||
onCredential: (credential: string) => void;
|
||||
text?: 'continue_with' | 'signin_with' | 'signup_with';
|
||||
@ -46,7 +46,9 @@ export function GoogleSignInButton({
|
||||
size: 'large',
|
||||
shape: 'rectangular',
|
||||
text,
|
||||
locale: 'ko',
|
||||
// ★ 'ko' 로는 영문('Continue with Google')이 그대로 나왔다. 지역까지 줘야 한국어다.
|
||||
// 문구는 우리가 못 정한다 — 구글 브랜드 가이드라 GIS 가 주는 번역을 그대로 쓴다.
|
||||
locale: 'ko_KR',
|
||||
logo_alignment: 'center',
|
||||
// GIS 는 숫자 px 만 받는다(최대 400). 컨테이너 폭이 잡히기 전이면 최소값으로 그린다.
|
||||
width: holder.current.offsetWidth || 320,
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import type {ComponentType, ReactNode} from 'react';
|
||||
import {Link, NavLink, useLocation} from 'react-router';
|
||||
import {LayoutGrid, LogOut, Search, Wand2} from 'lucide-react';
|
||||
import {Link, NavLink, useLocation, useNavigate} from 'react-router';
|
||||
import {LayoutGrid, LogIn, LogOut, Search, Store, Wand2} from 'lucide-react';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {useAuthStore} from '@/stores/auth';
|
||||
import {userLabel, useAuthStore} from '@/stores/auth';
|
||||
|
||||
export type NavItem = {
|
||||
to: string;
|
||||
@ -23,6 +23,7 @@ export type NavItem = {
|
||||
* 남의 화면이다.
|
||||
*/
|
||||
const OWNER_NAV: NavItem[] = [
|
||||
{to: '/sites', match: '/sites', label: '내 사이트', icon: Store},
|
||||
{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 signOut = useAuthStore((s) => s.signOut);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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" />
|
||||
</Link>
|
||||
|
||||
@ -58,19 +62,47 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* ★ 위저드는 로그인 없이도 열린다(관문은 에디터 진입이다) — 그래서 이 자리는
|
||||
**비로그인 상태를 반드시 그려야 한다.** 예전엔 이름이 빈 줄로 나오고 [로그아웃]만
|
||||
남아서, 로그인한 적 없는 사람이 눌러도 아무 일이 안 일어났다(지울 세션이 없다). */}
|
||||
<div className="border-t border-sidebar-border p-3">
|
||||
<div className="mb-2 truncate text-[11px] text-sidebar-foreground">
|
||||
{user?.name ?? user?.id}
|
||||
{user?.companyName ? ` · ${user.companyName}` : ''}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={signOut}
|
||||
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"
|
||||
>
|
||||
<LogOut className="size-3.5" />
|
||||
<span>로그아웃</span>
|
||||
</button>
|
||||
{/* 이름 자리가 곧 [내 정보] 입구다 — 메뉴를 한 줄 더 늘리지 않는다(아임웹의 프로필과 같은 자리). */}
|
||||
{user ? (
|
||||
<Link
|
||||
to="/account"
|
||||
className="mb-2 block truncate rounded-md px-2 py-1 text-[11px] text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60"
|
||||
>
|
||||
{userLabel(user)}
|
||||
{user.companyName ? ` · ${user.companyName}` : ''}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="mb-2 truncate px-2 py-1 text-[11px] text-sidebar-foreground">
|
||||
<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>
|
||||
</aside>
|
||||
|
||||
|
||||
121
solution/frontend/src/components/layout/MarketingShell.tsx
Normal file
121
solution/frontend/src/components/layout/MarketingShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -6,11 +6,13 @@
|
||||
*/
|
||||
import {useState, type FormEvent, type ReactNode} from '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 {Input} from '@/components/ui/input';
|
||||
import {notifyApiError} from '@/lib/notify';
|
||||
import {toAuthUser, useAuthStore} from '@/stores/auth';
|
||||
import {isGoogleLoginEnabled} from '@/lib/googleIdentity';
|
||||
import {establishSession} from '@/lib/session';
|
||||
|
||||
interface SignInFormProps {
|
||||
/** 폼 위에 붙는 제목·설명. 화면마다 하는 말이 다르다. */
|
||||
@ -22,11 +24,31 @@ interface SignInFormProps {
|
||||
}
|
||||
|
||||
export function SignInForm({header, footer, submitLabel = '로그인', onSignedIn}: SignInFormProps) {
|
||||
const signIn = useAuthStore((s) => s.signIn);
|
||||
const [id, setId] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 구글은 가입과 로그인이 같은 동작이다 — 처음 온 계정이면 백엔드가 그 자리에서 만든다.
|
||||
const 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) => {
|
||||
event.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
@ -36,20 +58,12 @@ export function SignInForm({header, footer, submitLabel = '로그인', onSignedI
|
||||
notifyApiError({data: res}, '아이디 또는 비밀번호를 확인해 주세요.');
|
||||
return;
|
||||
}
|
||||
// ★ RemoveNoneResponse 라 성공 응답에서도 토큰 필드가 빠져 올 수 있다.
|
||||
// 빈 토큰으로 로그인 상태를 만들면 이후 모든 요청이 401 로 흐른다 — 여기서 끊는다.
|
||||
if (!res.access_token || !res.refresh_token) {
|
||||
// 토큰 심는 순서(signIn → me)는 lib/session 한 곳에만 둔다 — 이 파일 맨 위 주석이
|
||||
// 경고하던 그 중복이다. 로그인 화면·가입 화면·자동 로그인이 전부 같은 함수를 쓴다.
|
||||
if (!(await establishSession(res, id))) {
|
||||
notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.');
|
||||
return;
|
||||
}
|
||||
const tokens = {accessToken: res.access_token, refreshToken: res.refresh_token};
|
||||
|
||||
// 토큰을 먼저 심어야 뒤이은 me() 가 Authorization 을 달고 나간다.
|
||||
signIn(tokens, {userId: '', id, role: UserRole.USER});
|
||||
|
||||
const meRes = await me();
|
||||
// 신원이 안 왔으면 방금 심은 임시 사용자를 그대로 둔다 — 토큰은 유효하므로 화면은 진행시킨다.
|
||||
if (meRes.user_id && meRes.id) signIn(tokens, toAuthUser(meRes));
|
||||
onSignedIn?.();
|
||||
} catch (error) {
|
||||
notifyApiError(error, '로그인에 실패했습니다.');
|
||||
@ -98,6 +112,19 @@ export function SignInForm({header, footer, submitLabel = '로그인', onSignedI
|
||||
<span>{submitLabel}</span>
|
||||
</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}
|
||||
</form>
|
||||
);
|
||||
|
||||
@ -13,7 +13,6 @@ export function EditorHeader() {
|
||||
const isPreviewMode = useBuilderStore((s) => s.isPreviewMode);
|
||||
const togglePreview = useBuilderStore((s) => s.togglePreview);
|
||||
const openPublishModal = useBuilderStore((s) => s.openPublishModal);
|
||||
const reset = useBuilderStore((s) => s.reset);
|
||||
const placeId = useBuilderStore((s) => s.placeId);
|
||||
|
||||
const unverifiedCount = useUnverifiedFields().length;
|
||||
@ -37,20 +36,19 @@ export function EditorHeader() {
|
||||
여기 링크를 남기면 사장님 앱에서 404 이고, 내부 경로 이름이 사장님 번들에도 남는다.
|
||||
reset() 을 부르지 않는 이유는 그대로다: 편집하던 가게를 떠나 데모 위저드로
|
||||
떨어지면 사장님 눈에는 자기 가게가 사라진 것으로 보인다.
|
||||
(사장님용 "내 사이트 관리"가 생기면 그때 그리로 잇는다 — ARCHITECTURE.md 4절) */}
|
||||
(사장님용 "내 사이트 관리"가 생기면 그때 그리로 잇는다 — ARCHITECTURE.md 4절)
|
||||
★ 데모에서 돌아가는 길은 `?new=1` 이다 — 스토어를 직접 비우면 화면만 위저드로 바뀌고
|
||||
주소는 그대로라, 뒤로가기가 편집기로 돌아오지 않는다(단계는 주소창이 소유한다). */}
|
||||
{placeId ? (
|
||||
<span className="group flex items-center gap-2">
|
||||
// 편집 중이어도 로고는 홈으로 간다 — 눌리지 않는 로고는 고장으로 읽힌다.
|
||||
// 입력값은 서버에 저장되므로 나갔다 들어와도 그대로다.
|
||||
<Link to="/" title="처음으로 이동" className="group flex items-center gap-2">
|
||||
<LogoMark />
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
title="처음으로 이동"
|
||||
className="group flex cursor-pointer items-center gap-2"
|
||||
>
|
||||
<Link to="/" title="처음으로 이동" className="group flex items-center gap-2">
|
||||
<LogoMark />
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<span className="hidden h-3.5 w-px bg-border sm:block" />
|
||||
|
||||
@ -16,7 +16,7 @@ import {
|
||||
Wand2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import type {InfoField} from '@o2o/shared';
|
||||
import {parseSectionData, type InfoField} from '@o2o/shared';
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Input} from '@/components/ui/input';
|
||||
@ -30,7 +30,6 @@ import {resolveVariant} from './canvas/registry';
|
||||
import {
|
||||
buildPrompt,
|
||||
dataSpecFor,
|
||||
parseSectionData,
|
||||
SECTION_DATA_MAX_CHARS,
|
||||
} from './canvas/dataSpec';
|
||||
|
||||
|
||||
@ -1,86 +1,17 @@
|
||||
/**
|
||||
* 붙여넣기 아이템 계약 — "이 섹션 타입은 어떤 JSON 을 먹나"의 단일 출처.
|
||||
* 붙여넣기 아이템의 **쓰는 쪽** — "이 섹션 타입의 JSON 을 어떻게 받아 오나".
|
||||
*
|
||||
* 프롬프트·예시·라벨이 여기 있고, **읽는 쪽 계약**(항목 타입 · 파서)은 `@o2o/shared` 에 있다 —
|
||||
* 같은 JSON 을 빌더 캔버스와 발행 사이트가 함께 읽기 때문이다(`shared/lib/section-data.ts`).
|
||||
*
|
||||
* 배리에이션 레지스트리와 같은 결이다: 여기 한 줄을 더하면 캔버스·[콘텐츠] 탭·프롬프트가 동시에 는다.
|
||||
* 데이터는 섹션 **타입**에 붙고 모양은 배리에이션이 갈아끼운다 — 같은 곡 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 {
|
||||
/** JSON 봉투의 `kind`. 섹션 타입과 같은 값이라 다른 아이템 JSON 을 붙여넣으면 바로 잡힌다. */
|
||||
kind: string;
|
||||
label: string;
|
||||
/** 없으면 그 줄을 통째로 버리는 키. 빈 껍데기가 화면에 줄만 남기는 걸 막는다. */
|
||||
requiredKey: string;
|
||||
/** [예시 넣기] 가 그대로 넣는 값. */
|
||||
sample: string;
|
||||
/** 프롬프트의 '무엇을 시키나' 부분. 머리·공통규칙은 buildPrompt 가 붙인다. */
|
||||
@ -153,7 +84,6 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
|
||||
songs: {
|
||||
kind: 'songs',
|
||||
label: '가요 다방',
|
||||
requiredKey: 'title',
|
||||
sample: JSON.stringify(
|
||||
{
|
||||
kind: 'songs',
|
||||
@ -217,7 +147,6 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
|
||||
daily: {
|
||||
kind: 'daily',
|
||||
label: '오늘의 한 장',
|
||||
requiredKey: 'title',
|
||||
sample: JSON.stringify(
|
||||
{
|
||||
kind: 'daily',
|
||||
@ -279,7 +208,6 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
|
||||
course: {
|
||||
kind: 'course',
|
||||
label: '반나절 산책',
|
||||
requiredKey: 'name',
|
||||
sample: JSON.stringify(
|
||||
{
|
||||
kind: 'course',
|
||||
@ -326,7 +254,6 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
|
||||
schedule: {
|
||||
kind: 'schedule',
|
||||
label: '여행 스케줄',
|
||||
requiredKey: 'name',
|
||||
sample: JSON.stringify(
|
||||
{
|
||||
kind: 'schedule',
|
||||
@ -372,118 +299,396 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
|
||||
· 장소에 url 을 넣지 않는다. searchQuery 만 넣는다.
|
||||
· 예약이 필요한 곳은 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 {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -68,6 +68,12 @@ import {SongsTurntable} from './variants/songs/SongsTurntable';
|
||||
import {DailyCalendar} from './variants/daily/DailyCalendar';
|
||||
import {CourseTickets} from './variants/course/CourseTickets';
|
||||
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[]> = {
|
||||
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: [
|
||||
{
|
||||
id: 'exhibition.gallery',
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -6,21 +6,22 @@
|
||||
*/
|
||||
import {SectionBody, SectionFrame} from '../../primitives';
|
||||
import type {SectionRenderProps} from '../../types';
|
||||
import {parseSectionData, type CourseItem, type CourseStop} from '../../dataSpec';
|
||||
import {parseSectionData, type CourseItem, type CourseStop} from '@o2o/shared';
|
||||
import {
|
||||
CarouselNav,
|
||||
ITEM_ACCENT,
|
||||
ITEM_BODY,
|
||||
ITEM_BORDER,
|
||||
ITEM_CARD,
|
||||
ITEM_HEADING,
|
||||
ITEM_INK,
|
||||
ITEM_SURFACE,
|
||||
ParseError,
|
||||
PasteHint,
|
||||
RETRO_BODY,
|
||||
RETRO_LINE,
|
||||
RETRO_PAPER,
|
||||
RETRO_PAPER_LIGHT,
|
||||
RETRO_RED,
|
||||
RETRO_SIGN,
|
||||
SourceLine,
|
||||
useCarousel,
|
||||
} from '../retro/common';
|
||||
import '../retro/retro.css';
|
||||
} from '../items/common';
|
||||
import '../items/items.css';
|
||||
|
||||
function Ticket({
|
||||
stop,
|
||||
@ -38,31 +39,31 @@ function Ticket({
|
||||
return (
|
||||
<div
|
||||
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
|
||||
className="flex justify-between border-b border-dashed px-4 py-2.5 text-[10px] tracking-[0.12em] text-stone-600"
|
||||
style={{borderColor: '#1b1a15'}}
|
||||
className="flex justify-between border-b border-dashed px-4 py-2.5 text-[10px] tracking-[0.12em] opacity-75"
|
||||
style={{borderColor: ITEM_INK}}
|
||||
>
|
||||
<span className="truncate">{courseName}</span>
|
||||
<span className="shrink-0">NO.{no}</span>
|
||||
</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}
|
||||
</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}
|
||||
</h4>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="mt-3 flex justify-between gap-2 border-t border-dashed px-4 py-2.5 text-[10px] text-stone-500"
|
||||
style={{borderColor: RETRO_LINE}}
|
||||
className="mt-3 flex justify-between gap-2 border-t border-dashed px-4 py-2.5 text-[10px] opacity-60"
|
||||
style={{borderColor: ITEM_BORDER}}
|
||||
>
|
||||
<span>{stop.minutes ? `도보 ${stop.minutes}분` : '이동 시간 미정'}</span>
|
||||
{stop.searchQuery && <span className="truncate">지도 검색 · {stop.searchQuery}</span>}
|
||||
@ -71,17 +72,17 @@ function Ticket({
|
||||
{/* 펀치 구멍 — 표를 표로 만드는 자리 */}
|
||||
<i
|
||||
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
|
||||
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 && (
|
||||
<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"
|
||||
style={{fontFamily: RETRO_SIGN, borderColor: RETRO_RED, color: RETRO_RED}}
|
||||
style={{fontFamily: ITEM_HEADING, borderColor: ITEM_ACCENT, color: ITEM_ACCENT}}
|
||||
>
|
||||
완주
|
||||
<br />
|
||||
@ -100,10 +101,10 @@ function CourseRow({course}: {course: CourseItem}) {
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div className="flex flex-wrap items-baseline gap-2.5">
|
||||
<h3 className="text-lg text-stone-900" style={{fontFamily: RETRO_SIGN}}>
|
||||
<h3 className="text-lg" style={{fontFamily: ITEM_HEADING}}>
|
||||
{course.name}
|
||||
</h3>
|
||||
<span className="text-[11px] text-stone-500">
|
||||
<span className="text-[11px] opacity-60">
|
||||
{[course.duration, course.startsFrom ? `${course.startsFrom} 출발` : undefined, `${stops.length}곳`]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
@ -115,7 +116,7 @@ function CourseRow({course}: {course: CourseItem}) {
|
||||
</div>
|
||||
|
||||
{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 배열을 채워 주세요.
|
||||
</p>
|
||||
) : (
|
||||
@ -145,11 +146,11 @@ export function CourseTickets(props: SectionRenderProps) {
|
||||
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint">
|
||||
<SectionBody width="wide">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-2xl text-stone-900 sm:text-3xl" style={{fontFamily: RETRO_SIGN}}>
|
||||
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
|
||||
{parsed.title || section.name}
|
||||
</h2>
|
||||
{(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}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -7,20 +7,20 @@
|
||||
import {useMemo, useState} from 'react';
|
||||
import {SectionBody, SectionFrame} from '../../primitives';
|
||||
import type {SectionRenderProps} from '../../types';
|
||||
import {parseSectionData, type DailyItem} from '../../dataSpec';
|
||||
import {parseSectionData, type DailyItem} from '@o2o/shared';
|
||||
import {
|
||||
CarouselNav,
|
||||
ITEM_ACCENT,
|
||||
ITEM_BODY,
|
||||
ITEM_BORDER,
|
||||
ITEM_CARD,
|
||||
ITEM_HEADING,
|
||||
ITEM_SURFACE,
|
||||
ParseError,
|
||||
PasteHint,
|
||||
RETRO_BODY,
|
||||
RETRO_LINE,
|
||||
RETRO_PAPER,
|
||||
RETRO_PAPER_LIGHT,
|
||||
RETRO_RED,
|
||||
RETRO_SIGN,
|
||||
SourceLine,
|
||||
} from '../retro/common';
|
||||
import '../retro/retro.css';
|
||||
} from '../items/common';
|
||||
import '../items/items.css';
|
||||
|
||||
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 ${
|
||||
muted ? 'scale-[.93] opacity-45' : ''
|
||||
}`}
|
||||
style={{backgroundColor: RETRO_PAPER_LIGHT, borderColor: RETRO_LINE}}
|
||||
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
|
||||
>
|
||||
{/* 뜯어낸 자국 */}
|
||||
<div
|
||||
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">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<i
|
||||
key={i}
|
||||
className="-mt-1 size-2 rounded-full border"
|
||||
style={{backgroundColor: RETRO_PAPER, borderColor: RETRO_LINE}}
|
||||
style={{backgroundColor: ITEM_SURFACE, borderColor: ITEM_BORDER}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-b px-5 pb-3 pt-6 text-center" style={{borderColor: RETRO_LINE}}>
|
||||
<p className="text-[11px] tracking-[0.2em] text-stone-500">{month}月</p>
|
||||
<p className="mt-1 leading-none" style={{fontFamily: RETRO_SIGN, fontSize: 72, color: RETRO_RED}}>
|
||||
<div className="border-b px-5 pb-3 pt-6 text-center" style={{borderColor: ITEM_BORDER}}>
|
||||
<p className="text-[11px] tracking-[0.2em] opacity-60">{month}月</p>
|
||||
<p className="mt-1 leading-none" style={{fontFamily: ITEM_HEADING, fontSize: 72, color: ITEM_ACCENT}}>
|
||||
{String(Number(day) || day)}
|
||||
</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 className="space-y-2 px-5 pb-5 pt-4">
|
||||
{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}
|
||||
</h3>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
{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} />
|
||||
</div>
|
||||
</div>
|
||||
@ -122,11 +124,11 @@ export function DailyCalendar(props: SectionRenderProps) {
|
||||
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
|
||||
<SectionBody width="wide">
|
||||
<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}
|
||||
</h2>
|
||||
{(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}
|
||||
</p>
|
||||
)}
|
||||
@ -150,7 +152,7 @@ export function DailyCalendar(props: SectionRenderProps) {
|
||||
label="날짜"
|
||||
/>
|
||||
)}
|
||||
<p className="text-center text-[11px] text-stone-500">
|
||||
<p className="text-center text-[11px] opacity-60">
|
||||
오늘 날짜에 맞는 장이 자동으로 펼쳐집니다 · 총 {items.length}장
|
||||
{parsed.unverified > 0 && ` · 확인 필요 ${parsed.unverified}장`}
|
||||
</p>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -8,21 +8,23 @@
|
||||
*/
|
||||
import {SectionBody, SectionFrame} from '../../primitives';
|
||||
import type {SectionRenderProps} from '../../types';
|
||||
import {parseSectionData, type ScheduleItem, type ScheduleSlot} from '../../dataSpec';
|
||||
import {parseSectionData, type ScheduleItem, type ScheduleSlot} from '@o2o/shared';
|
||||
import {
|
||||
CarouselNav,
|
||||
ITEM_ACCENT,
|
||||
ITEM_BODY,
|
||||
ITEM_BORDER,
|
||||
ITEM_CARD,
|
||||
ITEM_HEADING,
|
||||
ITEM_INK,
|
||||
ITEM_INVERSE,
|
||||
ITEM_INVERSE_INK,
|
||||
ParseError,
|
||||
PasteHint,
|
||||
RETRO_BODY,
|
||||
RETRO_INK,
|
||||
RETRO_LINE,
|
||||
RETRO_PAPER_LIGHT,
|
||||
RETRO_RED,
|
||||
RETRO_SIGN,
|
||||
SourceLine,
|
||||
useCarousel,
|
||||
} from '../retro/common';
|
||||
import '../retro/retro.css';
|
||||
} from '../items/common';
|
||||
import '../items/items.css';
|
||||
|
||||
/** "09:30" → 9시 30분. 형식이 어긋나면 원문을 그대로 보여준다(지어내지 않는다). */
|
||||
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="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
|
||||
className="inline-flex items-baseline gap-1 leading-none text-[#f2ebd9]"
|
||||
style={{fontFamily: RETRO_SIGN, fontSize: 30}}
|
||||
className="inline-flex items-baseline gap-1 leading-none"
|
||||
style={{fontFamily: ITEM_HEADING, fontSize: 30}}
|
||||
>
|
||||
{head}
|
||||
{tail && <span className="text-[#c0b493]">:{tail}</span>}
|
||||
{tail && <span className="opacity-60">:{tail}</span>}
|
||||
</span>
|
||||
<i
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-1/2 border-t"
|
||||
style={{borderColor: 'rgba(242,235,217,.22)'}}
|
||||
style={{borderColor: 'color-mix(in oklab, currentColor 25%, transparent)'}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 px-4 pb-3 pt-3.5">
|
||||
<h4 className="text-base font-bold text-stone-900" style={{fontFamily: RETRO_BODY}}>
|
||||
<h4 className="text-base font-bold" style={{fontFamily: ITEM_BODY}}>
|
||||
{slot.title}
|
||||
</h4>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-between gap-2 border-t border-dashed px-4 py-2 text-[10px] text-stone-500"
|
||||
style={{borderColor: RETRO_LINE}}
|
||||
className="flex justify-between gap-2 border-t border-dashed px-4 py-2 text-[10px] opacity-60"
|
||||
style={{borderColor: ITEM_BORDER}}
|
||||
>
|
||||
<span>{slot.minutes ? `${slot.minutes}분 머묾` : '머무는 시간 미정'}</span>
|
||||
{slot.searchQuery && <span className="truncate">지도 검색 · {slot.searchQuery}</span>}
|
||||
@ -84,7 +90,7 @@ function Slot({slot, isLast}: {slot: ScheduleSlot; isLast: boolean}) {
|
||||
{/* 칸과 칸 사이의 시간 — 점선이 이어져야 '흐른다'로 읽힌다 */}
|
||||
{!isLast && (
|
||||
<div aria-hidden className="flex w-8 items-center justify-center">
|
||||
<i className="block h-px w-full border-t border-dashed" style={{borderColor: RETRO_LINE}} />
|
||||
<i className="block h-px w-full border-t border-dashed" style={{borderColor: ITEM_BORDER}} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -101,10 +107,10 @@ function ScheduleRow({schedule}: {schedule: ScheduleItem}) {
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div className="flex flex-wrap items-baseline gap-2.5">
|
||||
<h3 className="text-lg text-stone-900" style={{fontFamily: RETRO_SIGN}}>
|
||||
<h3 className="text-lg" style={{fontFamily: ITEM_HEADING}}>
|
||||
{schedule.name}
|
||||
</h3>
|
||||
<span className="text-[11px] text-stone-500">
|
||||
<span className="text-[11px] opacity-60">
|
||||
{[schedule.audience, schedule.season, span, `${slots.length}칸`]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
@ -117,8 +123,8 @@ function ScheduleRow({schedule}: {schedule: ScheduleItem}) {
|
||||
|
||||
{slots.length === 0 ? (
|
||||
<p
|
||||
className="border border-dashed px-4 py-5 text-center text-[11px] text-stone-500"
|
||||
style={{borderColor: RETRO_LINE}}
|
||||
className="border border-dashed px-4 py-5 text-center text-[11px] opacity-60"
|
||||
style={{borderColor: ITEM_BORDER}}
|
||||
>
|
||||
시간대가 아직 없습니다. JSON 의 slots 배열을 채워 주세요.
|
||||
</p>
|
||||
@ -147,11 +153,11 @@ export function ScheduleTimetable(props: SectionRenderProps) {
|
||||
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint">
|
||||
<SectionBody width="wide">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-2xl text-stone-900 sm:text-3xl" style={{fontFamily: RETRO_SIGN}}>
|
||||
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
|
||||
{parsed.title || section.name}
|
||||
</h2>
|
||||
{(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}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -7,19 +7,22 @@
|
||||
import {useState} from 'react';
|
||||
import {SectionBody, SectionFrame} from '../../primitives';
|
||||
import type {SectionRenderProps} from '../../types';
|
||||
import {parseSectionData, type SongItem} from '../../dataSpec';
|
||||
import {parseSectionData, type SongItem} from '@o2o/shared';
|
||||
import {
|
||||
CarouselNav,
|
||||
ITEM_ACCENT,
|
||||
ITEM_BODY,
|
||||
ITEM_HEADING,
|
||||
ITEM_INVERSE,
|
||||
ParseError,
|
||||
PasteHint,
|
||||
RETRO_BODY,
|
||||
RETRO_SIGN,
|
||||
SourceLine,
|
||||
useCarousel,
|
||||
} from '../retro/common';
|
||||
import '../retro/retro.css';
|
||||
} from '../items/common';
|
||||
import '../items/items.css';
|
||||
|
||||
const FALLBACK_LABEL = '#d4551f';
|
||||
/** 곡이 라벨 색을 안 주면 템플릿 강조색이 라벨이 된다 — hex 를 박으면 팔레트를 바꿔도 판만 남는다. */
|
||||
const FALLBACK_LABEL = ITEM_ACCENT;
|
||||
|
||||
export function SongsTurntable(props: SectionRenderProps) {
|
||||
const {section, isSelected, onSelect, template} = props;
|
||||
@ -32,14 +35,18 @@ export function SongsTurntable(props: SectionRenderProps) {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<p className="text-[11px] tracking-[0.28em] text-amber-500/90">33⅓ RPM</p>
|
||||
<h2 className="text-2xl text-stone-50 sm:text-3xl" style={{fontFamily: RETRO_SIGN}}>
|
||||
<p className="text-[11px] tracking-[0.28em]" style={{color: ITEM_ACCENT}}>
|
||||
33⅓ RPM
|
||||
</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 text-stone-400" style={{fontFamily: RETRO_BODY}}>
|
||||
<p className="text-sm opacity-55" style={{fontFamily: ITEM_BODY}}>
|
||||
{parsed.subtitle || section.description}
|
||||
</p>
|
||||
)}
|
||||
@ -51,34 +58,57 @@ export function SongsTurntable(props: SectionRenderProps) {
|
||||
<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="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
|
||||
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="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 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 className="absolute left-0 -top-1 h-4 w-4 rounded-sm bg-stone-700" />
|
||||
<div className="absolute -top-2 right-0 size-6 rounded-full bg-radial from-stone-300 to-stone-600" />
|
||||
<div
|
||||
className="absolute inset-y-[3px] right-4 left-0 rounded-sm"
|
||||
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 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}
|
||||
</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}
|
||||
</h3>
|
||||
<p className="text-xs text-stone-400">
|
||||
<p className="text-xs opacity-55">
|
||||
{[
|
||||
current.artist,
|
||||
current.year ? String(current.year) : undefined,
|
||||
@ -92,8 +122,8 @@ export function SongsTurntable(props: SectionRenderProps) {
|
||||
</p>
|
||||
{current.story && (
|
||||
<p
|
||||
className="max-w-[46ch] text-sm leading-relaxed text-stone-300"
|
||||
style={{fontFamily: RETRO_BODY}}
|
||||
className="max-w-[46ch] text-sm leading-relaxed opacity-80"
|
||||
style={{fontFamily: ITEM_BODY}}
|
||||
>
|
||||
{current.story}
|
||||
</p>
|
||||
@ -101,12 +131,13 @@ export function SongsTurntable(props: SectionRenderProps) {
|
||||
{current.connection && (
|
||||
<p
|
||||
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}
|
||||
</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>
|
||||
<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"
|
||||
style={{
|
||||
['--lbl' as string]: song.labelColor || FALLBACK_LABEL,
|
||||
['--w4-vinyl' as string]: ITEM_INVERSE,
|
||||
// 고른 판만 강조색 링 — 금색을 박으면 팔레트를 바꿔도 이 링만 남는다.
|
||||
boxShadow:
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
<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}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
174
solution/frontend/src/features/marketing/ShowcaseGrid.tsx
Normal file
174
solution/frontend/src/features/marketing/ShowcaseGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
solution/frontend/src/features/marketing/showcaseApi.ts
Normal file
46
solution/frontend/src/features/marketing/showcaseApi.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
@ -1,18 +1,54 @@
|
||||
import {useState} from 'react';
|
||||
import {Check} from 'lucide-react';
|
||||
import type {IndustryType} from '@o2o/shared';
|
||||
import {INDUSTRY_CONFIGS} from '@/data/industryData';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {useBuilderStore} from '@/stores/builder';
|
||||
import {INDUSTRY_ICONS} from './industryIcons';
|
||||
import {useWizardStep} from './wizardUrl';
|
||||
import {WizardFooter} from './WizardFooter';
|
||||
import {WizardSteps} from './WizardSteps';
|
||||
|
||||
const INDUSTRY_ORDER: IndustryType[] = ['stay', 'cafe', 'restaurant', 'clinic'];
|
||||
|
||||
/**
|
||||
* 업종 직접 고르기 — `?step=industry`.
|
||||
*
|
||||
* ★ 이 화면은 더 이상 위저드의 첫 화면이 아니다. 업종은 상호 검색 결과의 분류가 정하고
|
||||
* (services/place_category.py), 여기는 **못 정했을 때와 바꿀 때만** 들른다.
|
||||
* 진행 표시에서 1번을 유지하는 이유도 같다 — 이건 '내 가게 확인' 안의 갈래다.
|
||||
*
|
||||
* ★ 못 정해서 들어온 경우에는 아무 카드도 선택된 것으로 보이지 않게 한다. 스토어에는 언제나
|
||||
* 기본 업종이 들어 있어서, 그걸 그대로 칠하면 사장님은 자기가 고르지도 않은 업종이
|
||||
* 이미 정해진 것으로 읽는다.
|
||||
*/
|
||||
export function Step1Industry() {
|
||||
const industry = useBuilderStore((s) => s.industry);
|
||||
const pendingPick = useBuilderStore((s) => s.pendingPick);
|
||||
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 (
|
||||
<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">
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl">
|
||||
어떤 업종의 홈페이지를 만들까요?
|
||||
{mustChoose ? '이 가게는 어떤 업종인가요?' : '업종을 바꿀까요?'}
|
||||
</h1>
|
||||
<p className="mx-auto mt-2.5 max-w-lg text-sm text-muted-foreground sm:text-base">
|
||||
업종을 고르면 그 업종에 맞는 항목만 수집하고, 그 항목만 검증합니다.
|
||||
{mustChoose
|
||||
? '지도 분류만으로는 업종을 정하지 못했습니다. 업종에 맞는 항목만 수집하고, 그 항목만 검증합니다.'
|
||||
: '업종을 고르면 그 업종에 맞는 항목만 수집하고, 그 항목만 검증합니다.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -33,13 +71,13 @@ export function Step1Industry() {
|
||||
{INDUSTRY_ORDER.map((id) => {
|
||||
const config = INDUSTRY_CONFIGS[id];
|
||||
const Icon = INDUSTRY_ICONS[id];
|
||||
const isSelected = industry === id;
|
||||
const isSelected = showSelection && industry === id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => selectIndustry(id)}
|
||||
onClick={() => pick(id)}
|
||||
aria-pressed={isSelected}
|
||||
className={cn(
|
||||
'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
|
||||
hint={
|
||||
<>
|
||||
선택된 업종:{' '}
|
||||
<strong className="font-semibold text-foreground">
|
||||
{INDUSTRY_CONFIGS[industry].name}
|
||||
</strong>{' '}
|
||||
({INDUSTRY_CONFIGS[industry].subName})
|
||||
</>
|
||||
showSelection ? (
|
||||
<>
|
||||
선택된 업종:{' '}
|
||||
<strong className="font-semibold text-foreground">
|
||||
{INDUSTRY_CONFIGS[industry].name}
|
||||
</strong>{' '}
|
||||
({INDUSTRY_CONFIGS[industry].subName})
|
||||
</>
|
||||
) : (
|
||||
'업종을 하나 골라 주세요.'
|
||||
)
|
||||
}
|
||||
onNext={() => goToStep(2)}
|
||||
nextLabel="다음: 내 가게 찾기"
|
||||
onPrev={() => goToStep('search')}
|
||||
onNext={goBack}
|
||||
nextDisabled={!showSelection}
|
||||
nextLabel={pendingPick ? '이 업종으로 계속하기' : '내 가게 찾기로 돌아가기'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,19 +1,23 @@
|
||||
import {useState} from 'react';
|
||||
import {useSearchParams} from 'react-router';
|
||||
import {ArrowRight, Check, MapPin, Phone, Search, TriangleAlert} from 'lucide-react';
|
||||
import {getAccessToken, type PlaceCandidate} from '@/api';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {ArrowRight, Check, MapPin, Search, TriangleAlert} from 'lucide-react';
|
||||
import type {IndustryType} from '@o2o/shared';
|
||||
import type {PlaceSearchItem} from '@/api';
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
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 type {PendingPick} from '@/stores/builder';
|
||||
import {useBuilderStore} from '@/stores/builder';
|
||||
import {PlaceUrlBox} from './PlaceUrlBox';
|
||||
import {usePlaceSearch} from './usePlaceSearch';
|
||||
import {useWizardStep} from './wizardUrl';
|
||||
import {WizardFooter} from './WizardFooter';
|
||||
import {WizardStage, WizardWaiting} from './WizardStage';
|
||||
|
||||
const STAGE_TITLE: Record<string, string> = {
|
||||
input: '상호명을 검색해 내 가게를 찾아주세요',
|
||||
input: '가게 이름을 알려주세요',
|
||||
searching: '내 가게를 찾는 중입니다',
|
||||
picking: '이 중에 사장님 가게가 있나요?',
|
||||
unavailable: '지도 검색을 사용할 수 없습니다',
|
||||
@ -21,22 +25,20 @@ const STAGE_TITLE: Record<string, string> = {
|
||||
};
|
||||
|
||||
const STAGE_DESCRIPTION: Record<string, string> = {
|
||||
input: '지도·플레이스에 등록된 가게 중에서 사장님이 직접 고른 한 곳만 기준이 됩니다.',
|
||||
input: '업종은 안 고르셔도 됩니다 — 찾은 가게의 분류에서 자동으로 정해집니다.',
|
||||
searching: '',
|
||||
picking: '고른 가게의 상호·주소·전화가 이 사이트의 기준 정보가 됩니다.',
|
||||
picking: '고른 가게의 상호·주소가 이 사이트의 기준 정보가 되고, 업종도 그 분류에서 정해집니다.',
|
||||
unavailable: '',
|
||||
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 location = useBuilderStore((s) => s.location);
|
||||
const confirmedIdentity = useBuilderStore((s) => s.confirmedIdentity);
|
||||
const pendingPick = useBuilderStore((s) => s.pendingPick);
|
||||
const setStoreName = useBuilderStore((s) => s.setStoreName);
|
||||
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 clearIdentity = useBuilderStore((s) => s.clearIdentity);
|
||||
const goToStep = useBuilderStore((s) => s.goToStep);
|
||||
|
||||
// 새로고침을 넘어온 경우 이미 만들어진 사업장을 그대로 쓴다(같은 위저드에서 두 번 만들지 않는다).
|
||||
const search = usePlaceSearch(industry, confirmedIdentity?.placeId ?? null);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
/**
|
||||
* 확정된 사업장을 주소창에 남긴다 — `?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 search = usePlaceSearch(confirmedIdentity?.placeId ?? null);
|
||||
const [, goToStep] = useWizardStep();
|
||||
const [placeUrl, setPlaceUrl] = useState('');
|
||||
|
||||
const canSearch = storeName.trim().length > 0 && search.phase !== 'searching';
|
||||
|
||||
const runSearch = () => {
|
||||
clearIdentity(); // 상호를 고쳐 다시 찾는 것이므로 앞서 확정한 신원은 물린다
|
||||
setPickedIndex(null);
|
||||
// ★ 로그인 전에는 서버를 부르지 않는다. 로그인은 에디터 진입에서 한 번 받는 것이 이 앱의 흐름인데,
|
||||
// 장소 API 는 전부 토큰을 요구해서(place.py) 여기서 부르면 2단계가 로그인 벽이 된다.
|
||||
// 입력한 값으로 신원을 세우고 넘어간다 — 검증은 로그인 뒤에 다시 할 수 있다.
|
||||
if (!getAccessToken()) {
|
||||
confirmIdentity(search.confirmManual(storeName, location));
|
||||
goToStep(3);
|
||||
/**
|
||||
* 확정된 사업장을 주소창에 남기며 다음 단계로.
|
||||
*
|
||||
* ★ `?placeId=` 가 새로고침을 넘기는 유일한 수단이다. 위저드 상태는 브라우저에 저장하지 않는다
|
||||
* (stores/builder 주석): 저장하면 새 가게를 만들러 들어와도 지난 가게가 확정된 것으로 떠 버린다.
|
||||
* 주소창에 두면 새로고침은 서버에서 복원되고(usePlaceSync), 주소를 새로 열면 깨끗하다.
|
||||
* ★ 단계와 사업장을 **한 번에** 바꾼다. 나눠 쓰면 그 사이에 "placeId 는 붙었는데 아직 1단계"인
|
||||
* 주소가 히스토리에 한 칸 생기고, 뒤로가기가 그 칸에 걸린다.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
void search.search(storeName, location);
|
||||
selectIndustry(guessed);
|
||||
void finishPick(pick, guessed);
|
||||
};
|
||||
|
||||
const runSearch = () => {
|
||||
clearIdentity(); // 상호를 고쳐 다시 찾는 것이므로 앞서 확정한 신원은 물린다
|
||||
void search.searchPublic(storeName, location);
|
||||
};
|
||||
|
||||
const backToInput = () => {
|
||||
clearIdentity();
|
||||
// 다른 가게를 찾으러 간다 — 주소창에 남은 사업장도 같이 놓아준다.
|
||||
rememberPlace(null);
|
||||
setPickedIndex(null);
|
||||
goToStep('search', {params: {placeId: null, flow: null}, replace: true});
|
||||
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 주석 참고). */
|
||||
const pickByUrl = async () => {
|
||||
const url = placeUrl.trim();
|
||||
if (!url) return;
|
||||
// URL 확인도 서버가 토큰을 요구한다 — 로그인 전에는 입력값으로 넘어간다.
|
||||
if (!getAccessToken()) {
|
||||
confirmIdentity(search.confirmManual(storeName, location));
|
||||
goToStep(3);
|
||||
return;
|
||||
}
|
||||
const identity = await search.confirmByUrl(url);
|
||||
const identity = await search.confirmByUrl(url, industry);
|
||||
if (!identity) return;
|
||||
confirmIdentity(identity);
|
||||
rememberPlace(identity.placeId);
|
||||
goToStep(3);
|
||||
advance(identity.placeId);
|
||||
};
|
||||
|
||||
/**
|
||||
* 업종을 바꾸러 간다.
|
||||
*
|
||||
* ★ **서버에 사업장이 이미 만들어졌으면 신원 확인부터 다시 받는다.** 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
|
||||
? 'confirmed'
|
||||
: search.phase === 'searching'
|
||||
? 'searching'
|
||||
: search.phase === 'unavailable'
|
||||
? 'unavailable'
|
||||
: search.phase === 'done'
|
||||
? 'picking'
|
||||
: 'input';
|
||||
? 'searching'
|
||||
: search.phase === 'unavailable'
|
||||
? 'unavailable'
|
||||
: search.phase === 'done'
|
||||
? 'picking'
|
||||
: 'input';
|
||||
|
||||
const searchQuery = [storeName, location].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col justify-between bg-muted/30">
|
||||
<WizardStage
|
||||
current={2}
|
||||
current={1}
|
||||
wide={stage === 'picking'}
|
||||
title={STAGE_TITLE[stage]}
|
||||
description={STAGE_DESCRIPTION[stage]}
|
||||
>
|
||||
{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>
|
||||
<label htmlFor="store-name" className="mb-1.5 block text-xs font-semibold">
|
||||
상호명 <span className="text-destructive">*</span>
|
||||
@ -200,7 +229,6 @@ export function Step2PlaceSearch() {
|
||||
<Search />
|
||||
<span>상호로 찾아보기</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -216,72 +244,51 @@ export function Step2PlaceSearch() {
|
||||
<p
|
||||
className={cn(
|
||||
'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-warning/30 bg-warning/10 text-warning',
|
||||
)}
|
||||
>
|
||||
{OUTCOME_TEXT[search.outcome] ?? '아래 목록에서 사장님 가게를 골라 주세요.'}
|
||||
{search.sourceLabel && (
|
||||
<Badge variant="outline" className="ml-2 text-[10px]">
|
||||
{search.sourceLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{search.items.length > 0
|
||||
? '아래 목록에서 사장님 가게를 골라 주세요.'
|
||||
: '이 이름으로는 찾지 못했습니다. 아래에서 네이버 지도 주소로 찾아 주세요.'}
|
||||
</p>
|
||||
|
||||
{search.candidates.length > 0 &&
|
||||
!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">
|
||||
<TriangleAlert className="mr-1 inline size-3.5 align-text-bottom" />
|
||||
상호 후보는 찾았지만 네이버 플레이스는 찾지 못했습니다. 아래 버튼으로 네이버
|
||||
지도에서 가게를 검색한 뒤 플레이스 URL을 붙여넣어 주세요.
|
||||
</p>
|
||||
)}
|
||||
{search.pickError && (
|
||||
<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" />
|
||||
{search.pickError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className="space-y-2">
|
||||
{search.candidates.map((candidate, index) => (
|
||||
<li key={`${candidate.external_place_id ?? candidate.name}-${index}`}>
|
||||
{search.items.map((item, index) => (
|
||||
<li key={`${item.name ?? ''}-${item.road_address ?? ''}-${index}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void pick(candidate, index)}
|
||||
disabled={search.isConfirming || !candidate.naver_place_url}
|
||||
onClick={() => choose(item)}
|
||||
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"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-bold">{candidate.name}</span>
|
||||
{candidate.category_name && (
|
||||
<span className="text-sm font-bold">{item.name}</span>
|
||||
{item.category_name && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{candidate.category_name}
|
||||
{item.category_name}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant={candidate.naver_place_url ? 'success' : 'warning'}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{candidate.naver_place_url
|
||||
? '네이버 플레이스 찾음'
|
||||
: '네이버 플레이스 못 찾음'}
|
||||
</Badge>
|
||||
{/* 고르기 전에 어떤 업종으로 시작하는지 먼저 보여준다 — 고른 뒤에 알면 늦다. */}
|
||||
<IndustryBadge category={item.category} />
|
||||
</div>
|
||||
<p className="mt-1.5 flex items-start gap-1 text-xs text-muted-foreground">
|
||||
<MapPin className="mt-px size-3 shrink-0" />
|
||||
<span>{candidate.road_address ?? candidate.address ?? '주소 없음'}</span>
|
||||
<span>{item.road_address ?? '주소 없음'}</span>
|
||||
</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>
|
||||
<span className="shrink-0 rounded-lg bg-primary px-3 py-1.5 text-[11px] font-bold text-primary-foreground">
|
||||
{!candidate.naver_place_url
|
||||
? 'URL 필요'
|
||||
: search.isConfirming && pickedIndex === index
|
||||
? '확정 중...'
|
||||
: '이 가게예요'}
|
||||
{search.isConfirming ? '확정 중...' : '이 가게예요'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
@ -289,25 +296,26 @@ export function Step2PlaceSearch() {
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{!search.candidates.some((candidate) => candidate.naver_place_url) ? (
|
||||
<PlaceUrlBox
|
||||
value={placeUrl}
|
||||
onChange={setPlaceUrl}
|
||||
onSubmit={() => void pickByUrl()}
|
||||
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(' ')}
|
||||
/>
|
||||
)}
|
||||
{/* 목록에서 고르면 업종은 그 후보가 정한다 — 이 줄은 아래 '주소로 확정' 경로용이다. */}
|
||||
<IndustryLine
|
||||
label="주소로 확정할 때 쓸 업종"
|
||||
industry={industry}
|
||||
onChange={changeIndustry}
|
||||
/>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
@ -317,12 +325,19 @@ export function Step2PlaceSearch() {
|
||||
<TriangleAlert className="mr-1 inline size-3.5 align-text-bottom" />
|
||||
{search.unavailableReason}
|
||||
</p>
|
||||
|
||||
<IndustryLine
|
||||
label="주소로 확정할 때 쓸 업종"
|
||||
industry={industry}
|
||||
onChange={changeIndustry}
|
||||
/>
|
||||
|
||||
<PlaceUrlBox
|
||||
value={placeUrl}
|
||||
onChange={setPlaceUrl}
|
||||
onSubmit={() => void pickByUrl()}
|
||||
isBusy={search.isConfirming}
|
||||
searchQuery={[storeName, location].filter(Boolean).join(' ')}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
|
||||
<Button variant="outline" className="w-full" onClick={runSearch}>
|
||||
@ -357,17 +372,25 @@ export function Step2PlaceSearch() {
|
||||
</>
|
||||
) : (
|
||||
<Badge variant="warning" className="text-[10px]">
|
||||
직접 입력 · 동일 업소 미검증
|
||||
{confirmedIdentity.sourceLabel} · 동일 업소 미검증
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IndustryLine
|
||||
industry={industry}
|
||||
onChange={changeIndustry}
|
||||
note={
|
||||
identityIsOnServer ? '업종을 바꾸면 가게 확인을 다시 받습니다.' : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => goToStep(3)}
|
||||
onClick={() => advance(confirmedIdentity.placeId)}
|
||||
>
|
||||
<span>다음: 이 가게 정보 수집하기</span>
|
||||
<ArrowRight />
|
||||
@ -386,11 +409,61 @@ export function Step2PlaceSearch() {
|
||||
? `${confirmedIdentity.name} 기준으로 진행합니다.`
|
||||
: '내 가게를 확인해야 다음 단계로 갈 수 있습니다.'
|
||||
}
|
||||
onPrev={() => goToStep(1)}
|
||||
onNext={() => goToStep(3)}
|
||||
onNext={() => advance(confirmedIdentity?.placeId ?? null)}
|
||||
nextLabel="다음: 데이터 수집"
|
||||
nextDisabled={!confirmedIdentity}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import {useBuilderStore, useUnverifiedFields} from '@/stores/builder';
|
||||
import {ChannelConfirmPanel} from './ChannelConfirmPanel';
|
||||
import {ChannelUrlInput} from './ChannelUrlInput';
|
||||
import {useCollectFlow} from './useCollectFlow';
|
||||
import {useWizardStep} from './wizardUrl';
|
||||
import {WizardFooter} from './WizardFooter';
|
||||
import {WizardStage, WizardWaiting} from './WizardStage';
|
||||
|
||||
@ -29,7 +30,7 @@ const STAGE_DESCRIPTION: Record<string, string> = {
|
||||
review: '찾은 값은 전부 출처와 함께 보여드립니다. 사장님이 [맞아요]를 누른 값만 사이트와 AI 검색 답변에 나갑니다.',
|
||||
};
|
||||
|
||||
/** 채널 URL 확인과 수집 결과 검토 단계. */
|
||||
/** 채널 URL 확인과 수집 결과 검토 단계(진행 표시 2번). */
|
||||
export function Step3DataReview() {
|
||||
const industry = useBuilderStore((s) => s.industry);
|
||||
const storeName = useBuilderStore((s) => s.storeName);
|
||||
@ -37,7 +38,7 @@ export function Step3DataReview() {
|
||||
const gatherCompleted = useBuilderStore((s) => s.gatherCompleted);
|
||||
const infoFields = useBuilderStore((s) => s.infoFields);
|
||||
const photos = useBuilderStore((s) => s.photos);
|
||||
const goToStep = useBuilderStore((s) => s.goToStep);
|
||||
const [, goToStep] = useWizardStep();
|
||||
|
||||
const unverified = useUnverifiedFields();
|
||||
const placeId = confirmedIdentity?.placeId ?? null;
|
||||
@ -91,7 +92,7 @@ export function Step3DataReview() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col justify-between bg-muted/30">
|
||||
<WizardStage
|
||||
current={3}
|
||||
current={2}
|
||||
wide={stage === 'review' || stage === 'channels'}
|
||||
title={STAGE_TITLE[stage]}
|
||||
description={STAGE_DESCRIPTION[stage]}
|
||||
@ -178,14 +179,14 @@ export function Step3DataReview() {
|
||||
가게만 채널을 긁을 수 있습니다 — 2단계에서 지도 검색으로 가게를 확인하시면
|
||||
주소 붙여넣기와 자동 찾기가 모두 열립니다.
|
||||
</p>
|
||||
<Button variant="outline" className="w-full" onClick={() => goToStep(4)}>
|
||||
<Button variant="outline" className="w-full" onClick={() => goToStep('template')}>
|
||||
<span>수집 없이 다음 단계로</span>
|
||||
<ArrowRight />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button variant="ghost" size="sm" className="w-full" onClick={() => goToStep(2)}>
|
||||
<Button variant="ghost" size="sm" className="w-full" onClick={() => goToStep('search')}>
|
||||
가게를 다시 찾을게요
|
||||
</Button>
|
||||
</div>
|
||||
@ -302,7 +303,7 @@ export function Step3DataReview() {
|
||||
</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>
|
||||
<ArrowRight />
|
||||
</Button>
|
||||
@ -354,8 +355,8 @@ export function Step3DataReview() {
|
||||
'수집을 돌리면 찾은 값이 출처와 함께 여기에 나옵니다.'
|
||||
)
|
||||
}
|
||||
onPrev={() => goToStep(2)}
|
||||
onNext={() => goToStep(4)}
|
||||
onPrev={() => goToStep('search')}
|
||||
onNext={() => goToStep('template')}
|
||||
nextLabel="다음: 템플릿 선택"
|
||||
/**
|
||||
* ★ 수집 전에는 잠근다.
|
||||
|
||||
@ -4,6 +4,7 @@ import {INDUSTRY_CONFIGS} from '@/data/industryData';
|
||||
import {queueSiteTemplateSave} from '@/features/publish/siteTemplate';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {useBuilderStore} from '@/stores/builder';
|
||||
import {useWizardStep} from './wizardUrl';
|
||||
import {WizardFooter} from './WizardFooter';
|
||||
import {WizardSteps} from './WizardSteps';
|
||||
|
||||
@ -85,7 +86,7 @@ export function Step4Template() {
|
||||
const templateId = useBuilderStore((s) => s.templateId);
|
||||
const placeId = useBuilderStore((s) => s.placeId);
|
||||
const selectTemplate = useBuilderStore((s) => s.selectTemplate);
|
||||
const goToStep = useBuilderStore((s) => s.goToStep);
|
||||
const [, goToStep] = useWizardStep();
|
||||
const startGenerating = useBuilderStore((s) => s.startGenerating);
|
||||
|
||||
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 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">
|
||||
<WizardSteps current={4} />
|
||||
<WizardSteps current={3} />
|
||||
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
@ -166,12 +167,13 @@ export function Step4Template() {
|
||||
</div>
|
||||
|
||||
<WizardFooter
|
||||
onPrev={() => goToStep(3)}
|
||||
onPrev={() => goToStep('collect')}
|
||||
onNext={() => {
|
||||
// ★ 아무것도 안 누르고 넘어가는 경우(첫 템플릿이 이미 선택돼 있다)도 서버에 남긴다 —
|
||||
// 화면이 보여준 그 템플릿이 발행본이 되어야 한다. 같은 값이면 서버가 재빌드 표시도 찍지 않는다.
|
||||
queueSiteTemplateSave(placeId, templateId);
|
||||
startGenerating();
|
||||
goToStep('generating');
|
||||
}}
|
||||
nextLabel="이 템플릿으로 사이트 생성하기"
|
||||
/>
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import {useCallback, useEffect} from 'react';
|
||||
import {useSearchParams} from 'react-router';
|
||||
import {Check, Loader2} from 'lucide-react';
|
||||
import {JobStatus} from '@o2o/shared';
|
||||
import {delay, getAccessToken, pollJob, startCopy} from '@/api';
|
||||
import {Progress} from '@/components/ui/progress';
|
||||
import {notify, notifyApiError} from '@/lib/notify';
|
||||
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 = [
|
||||
'수집된 사진 분류 및 대체 텍스트 생성',
|
||||
@ -30,9 +30,8 @@ export function Step5Generating() {
|
||||
const storeName = useBuilderStore((s) => s.storeName);
|
||||
const stage = useBuilderStore((s) => s.generateStage);
|
||||
const setGenerateStage = useBuilderStore((s) => s.setGenerateStage);
|
||||
const goToStep = useBuilderStore((s) => s.goToStep);
|
||||
const placeId = useBuilderStore((s) => s.placeId);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [, goToStep] = useWizardStep();
|
||||
|
||||
/**
|
||||
* 생성이 끝나면 곧바로 에디터다.
|
||||
@ -43,11 +42,10 @@ export function Step5Generating() {
|
||||
* '목록으로 보내는 것'은 다른 얘기다.
|
||||
*/
|
||||
const finishOnboarding = useCallback(() => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete('flow');
|
||||
setSearchParams(next, {replace: true});
|
||||
goToStep(EDITOR_STEP);
|
||||
}, [goToStep, searchParams, setSearchParams]);
|
||||
// ★ `flow=onboarding` 도 같이 뗀다. 남겨 두면 이 주소를 새로고침했을 때 위저드 중인
|
||||
// 사업장으로 읽혀 신원이 다시 세워진다 — 편집 중인 사람에게는 아무 의미가 없는 일이다.
|
||||
goToStep(EDITOR_STEP, {params: {flow: null}, replace: true});
|
||||
}, [goToStep]);
|
||||
|
||||
/**
|
||||
* 소개문·FAQ 생성(JobType.COPY). `POST /v1/place/{id}/copy` 로 잡을 넣고 폴링한다.
|
||||
|
||||
@ -4,14 +4,17 @@ import {cn} from '@/lib/utils';
|
||||
/**
|
||||
* 위저드 진행 표시.
|
||||
*
|
||||
* 2(신원 확인)와 3(사실 확인)이 왜 따로인지 사장님이 한눈에 알게 하는 것이 이 줄의 목적이다 —
|
||||
* 1(신원 확인)과 2(사실 확인)가 왜 따로인지 사장님이 한눈에 알게 하는 것이 이 줄의 목적이다 —
|
||||
* "가게가 맞나" 를 먼저 끝내고 "값이 맞나" 로 넘어간다.
|
||||
*
|
||||
* ★ 업종은 더 이상 단계가 아니다. 검색 결과의 분류가 정하고(못 정할 때만 1단계 안에서 묻는다),
|
||||
* 그래서 네 칸이던 줄이 세 칸이 됐다 — 사장님이 처음 보는 화면이 "우리는 카페인가 음식점인가"
|
||||
* 가 아니라 "가게 이름"이 되게 하는 것이 이 변경의 목적이다.
|
||||
*/
|
||||
const STEPS = [
|
||||
{step: 1, label: '업종'},
|
||||
{step: 2, label: '내 가게 확인'},
|
||||
{step: 3, label: '수집 정보 확인'},
|
||||
{step: 4, label: '템플릿'},
|
||||
{step: 1, label: '내 가게 확인'},
|
||||
{step: 2, label: '수집 정보 확인'},
|
||||
{step: 3, label: '템플릿'},
|
||||
] as const;
|
||||
|
||||
export function WizardSteps({current}: {current: number}) {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// 위저드 단계는 주소창이 소유한다 — 화면을 고르는 쪽(BuilderPage)이 이 계약을 읽는다.
|
||||
export {defaultStep, EDITOR_STEP, useWizardStep, type WizardStep} from './wizardUrl';
|
||||
export {Step1Industry} from './Step1Industry';
|
||||
export {Step2PlaceSearch} from './Step2PlaceSearch';
|
||||
export {Step3DataReview} from './Step3DataReview';
|
||||
|
||||
@ -1,21 +1,21 @@
|
||||
import {useCallback, useRef, useState} from 'react';
|
||||
import type {IndustryType} from '@o2o/shared';
|
||||
import type {ExternalPlaceSource as ExternalPlaceSourceType, PlaceCandidate, PlaceCategory} from '@/api';
|
||||
import type {PlaceCategory, PlaceSearchItem} from '@/api';
|
||||
import {
|
||||
createPlace,
|
||||
ExternalPlaceSource,
|
||||
getAccessToken,
|
||||
PlaceCategory as PlaceCategoryEnum,
|
||||
searchPlacesPublic,
|
||||
updatePlace,
|
||||
verifyCandidates,
|
||||
verifyPlace,
|
||||
verifyPlaceByUrl,
|
||||
} from '@/api';
|
||||
import type {ConfirmedIdentity} from '@/stores/builder';
|
||||
import {ensureAutoSession} from '@/lib/autoSession';
|
||||
import {describeError} from '@/lib/errorMessages';
|
||||
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> = {
|
||||
stay: PlaceCategoryEnum.LODGING,
|
||||
cafe: PlaceCategoryEnum.CAFE,
|
||||
@ -23,21 +23,15 @@ const INDUSTRY_TO_CATEGORY: Record<IndustryType, PlaceCategory> = {
|
||||
clinic: PlaceCategoryEnum.CLINIC,
|
||||
};
|
||||
|
||||
/** 후보를 어느 장소 DB 에서 찾았는지 — 사장님이 판단할 근거로 카드에 그대로 붙인다. */
|
||||
const SOURCE_LABEL: Record<number, string> = {
|
||||
[ExternalPlaceSource.KAKAO]: '카카오맵',
|
||||
[ExternalPlaceSource.NAVER]: '네이버 지도',
|
||||
};
|
||||
|
||||
export type SearchPhase =
|
||||
/** 아직 검색 전. */
|
||||
| 'idle'
|
||||
/** 사업장 생성 + 후보 조회 중. */
|
||||
/** 공개 검색 중. */
|
||||
| 'searching'
|
||||
/** 후보를 받았다(0건일 수도 있다 — outcome 이 no_candidate). */
|
||||
/** 후보를 받았다(0건일 수도 있다). */
|
||||
| 'done'
|
||||
/**
|
||||
* 장소 DB 를 부를 수 없다 — 로그인이 없거나 백엔드가 응답하지 않는다.
|
||||
* 장소 DB 를 부를 수 없다 — 키가 없거나, 분당 호출을 넘겼거나, 백엔드가 응답하지 않는다.
|
||||
* ★ 이때 가짜 후보를 지어내지 않는다. 검색 결과인 척하는 예시 카드는
|
||||
* 사장님이 남의 가게를 자기 가게로 확정하게 만드는 가장 빠른 길이다.
|
||||
*/
|
||||
@ -45,41 +39,47 @@ export type SearchPhase =
|
||||
|
||||
export interface PlaceSearchState {
|
||||
phase: SearchPhase;
|
||||
/** 외부 장소 DB 후보. 서버가 자동 확정하지 않으므로 항상 사람이 고른다. */
|
||||
candidates: PlaceCandidate[];
|
||||
/** matched | ambiguous | no_candidate — 서버 판정. 문구를 고르는 데만 쓴다. */
|
||||
outcome: string;
|
||||
/** true 면 판정이 명확해 '이거 맞나요?' 한 번만 물어도 된다. */
|
||||
autoSelectable: boolean;
|
||||
/** 후보를 준 장소 DB 이름. 없으면 빈 문자열. */
|
||||
sourceLabel: string;
|
||||
source: ExternalPlaceSourceType | null;
|
||||
/** 검색을 못 한 이유(로그인 필요 등). phase === 'unavailable' 일 때만 채워진다. */
|
||||
/** 공개 검색 후보. 서버가 자동 확정하지 않으므로 항상 사람이 고른다. */
|
||||
items: PlaceSearchItem[];
|
||||
/** 검색을 못 한 이유. phase === 'unavailable' 일 때만 채워진다. */
|
||||
unavailableReason: string;
|
||||
/**
|
||||
* 고른 후보를 자동으로 확정하지 못한 이유.
|
||||
* ★ 실패를 조용히 삼키면 사장님은 "눌렀는데 아무 일도 안 일어난다"만 겪는다 —
|
||||
* 네이버 플레이스 URL 붙여넣기로 넘어가야 한다는 걸 여기서 말한다.
|
||||
*/
|
||||
pickError: string;
|
||||
}
|
||||
|
||||
const INITIAL: PlaceSearchState = {
|
||||
phase: 'idle',
|
||||
candidates: [],
|
||||
outcome: '',
|
||||
autoSelectable: false,
|
||||
sourceLabel: '',
|
||||
source: null,
|
||||
items: [],
|
||||
unavailableReason: '',
|
||||
pickError: '',
|
||||
};
|
||||
|
||||
/** 상호명 비교용. 공백·괄호 표기가 후보마다 달라 그대로 비교하면 같은 가게도 안 맞는다. */
|
||||
function normalizeName(name: string | undefined | null): string {
|
||||
return (name ?? '').replace(/\s+/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 상호 검색 → 동일 업소 후보 조회 → 사람이 고른 후보로 확정.
|
||||
* 상호명 공개 검색 → 사람이 고른 후보로 확정.
|
||||
*
|
||||
* 백엔드 순서가 그대로 화면 순서다:
|
||||
* 1) POST /v1/place 사업장(껍데기)을 만든다 — 후보 조회가 place_id 를 요구한다
|
||||
* 2) GET /v1/place/{id}/verify/candidates?query= 외부 장소 DB 후보
|
||||
* 3) POST /v1/place/{id}/verify 사람이 고른 후보로 동일 업소 확정
|
||||
* ★ 목록을 주는 건 **공개 검색**(`GET /v1/place/search`)이다. 인증이 없어 로그인 전에도 돌고,
|
||||
* 후보마다 추정 업종(category)이 함께 온다 — 업종을 사장님에게 먼저 묻지 않기로 한 결정이
|
||||
* API 까지 내려온 자리다. 인증이 필요한 후보 조회(verify/candidates)는 목록을 그리는 데
|
||||
* 쓰지 않고, 고른 뒤 **네이버 플레이스 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 확정이 끝나야 시작할 수 있다").
|
||||
* 그래서 직접 입력 경로는 수집 없이 사장님 입력만으로 사이트를 만든다 — 검증을 우회하지 않는다.
|
||||
* 그래서 로그인 전 경로는 수집 없이 사장님이 고른 상호·주소만으로 사이트를 만든다 — 검증을 우회하지 않는다.
|
||||
*/
|
||||
export function usePlaceSearch(industry: IndustryType, existingPlaceId: string | null = null) {
|
||||
export function usePlaceSearch(existingPlaceId: string | null = null) {
|
||||
const [state, setState] = useState<PlaceSearchState>(INITIAL);
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
/**
|
||||
@ -88,8 +88,10 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
|
||||
* 되살아난 화면이 같은 가게를 한 번 더 만든다.
|
||||
*/
|
||||
const placeIdRef = useRef<string | null>(existingPlaceId);
|
||||
/** 그 사업장을 만들 때 쓴 업종. 업종이 바뀌면 재사용할 수 없다(아래 ensurePlace). */
|
||||
const placeCategoryRef = useRef<PlaceCategory | null>(null);
|
||||
const inflight = useRef<AbortController | null>(null);
|
||||
/** 마지막으로 검색한 상호. URL 확정 때 사업장 껍데기 이름으로 쓴다. */
|
||||
/** 마지막으로 확정 시도한 상호. URL 확정 때 사업장 껍데기 이름으로 쓴다. */
|
||||
const nameRef = useRef<string>('');
|
||||
|
||||
const reset = useCallback(() => {
|
||||
@ -98,128 +100,76 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
|
||||
setState(INITIAL);
|
||||
}, []);
|
||||
|
||||
/** 사업장 껍데기 확보. 이미 만들었으면 상호만 맞춰 둔다(같은 위저드에서 두 번 만들지 않는다). */
|
||||
/**
|
||||
* 사업장 껍데기 확보.
|
||||
*
|
||||
* ★ 업종이 달라졌으면 **새로 만든다.** places.category 는 생성할 때만 정해지고 PATCH 로 고칠
|
||||
* 수 없다(Req_UpdatePlace 에 category 가 없다). 그대로 재사용하면 카페로 만든 사업장에
|
||||
* 숙박 스키마로 수집이 돌고 JSON-LD 타입도 어긋난다 — 화면도 빌드도 멀쩡한 채로 틀린다.
|
||||
*/
|
||||
const ensurePlace = useCallback(
|
||||
async (name: string, signal: AbortSignal): Promise<string | null> => {
|
||||
if (placeIdRef.current) {
|
||||
async (name: string, category: PlaceCategory, signal: AbortSignal): Promise<string | null> => {
|
||||
const reusable =
|
||||
placeIdRef.current !== null &&
|
||||
(placeCategoryRef.current === null || placeCategoryRef.current === category);
|
||||
if (reusable && placeIdRef.current) {
|
||||
// PATCH 는 취소 신호를 받지 않는다(생성물 시그니처) — 뒤에서 signal 로 결과를 버린다.
|
||||
await updatePlace(placeIdRef.current, {name});
|
||||
if (signal.aborted) return null;
|
||||
return placeIdRef.current;
|
||||
}
|
||||
const created = await createPlace(
|
||||
{name, category: INDUSTRY_TO_CATEGORY[industry]},
|
||||
undefined,
|
||||
signal,
|
||||
);
|
||||
const created = await createPlace({name, category}, undefined, signal);
|
||||
placeIdRef.current = created.place?.place_id ?? null;
|
||||
placeCategoryRef.current = category;
|
||||
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();
|
||||
const controller = new AbortController();
|
||||
inflight.current = controller;
|
||||
inflight.current?.abort();
|
||||
const controller = new AbortController();
|
||||
inflight.current = controller;
|
||||
|
||||
// ★ 자동 로그인이 켜져 있으면 끝날 때까지 기다린다. 이걸 안 기다리면 페이지를 열자마자
|
||||
// 누른 검색이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다.
|
||||
await ensureAutoSession();
|
||||
setState({...INITIAL, phase: 'searching'});
|
||||
|
||||
// ★ 토큰이 없으면 화면(Step2)이 검색을 부르지 않고 입력값으로 넘어간다 — 2단계는 로그인 벽이 아니다.
|
||||
// 그래도 여기 도달했다면 세션이 도중에 끊긴 것이므로, 로그인을 요구하지 말고 조용히 물러난다.
|
||||
if (!getAccessToken()) {
|
||||
setState({...INITIAL});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await searchPlacesPublic({q: query}, undefined, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
setState({...INITIAL, phase: 'searching'});
|
||||
|
||||
try {
|
||||
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, '상호를 검색하지 못했습니다.');
|
||||
// ★ 이 엔드포인트의 거절은 HTTP 200 + result.success=false 로 온다(RemoveNoneResponse).
|
||||
// 상태코드만 보면 "후보 0건"과 "분당 20회 초과"가 같은 화면이 된다.
|
||||
if (res.result?.success === false) {
|
||||
setState({
|
||||
...INITIAL,
|
||||
phase: 'unavailable',
|
||||
unavailableReason:
|
||||
'장소 DB 를 부르지 못했습니다. 백엔드가 떠 있는지 확인하시거나, 직접 입력으로 진행해 주세요.',
|
||||
describeError(res.result.desc) ?? '지도 검색을 사용할 수 없습니다.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
},
|
||||
[ensurePlace],
|
||||
);
|
||||
|
||||
/**
|
||||
* 사람이 고른 후보를 이 사업장의 신원으로 확정한다(POST /verify).
|
||||
* 성공하면 화면에 얹을 신원을 돌려준다 — 실패하면 null.
|
||||
*/
|
||||
const confirm = useCallback(
|
||||
async (candidate: PlaceCandidate): Promise<ConfirmedIdentity | null> => {
|
||||
const placeId = placeIdRef.current;
|
||||
const address = candidate.road_address ?? candidate.address ?? '';
|
||||
if (!placeId) return null;
|
||||
|
||||
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],
|
||||
);
|
||||
setState({...INITIAL, phase: 'done', items: res.items ?? []});
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
notifyApiError(error, '상호를 검색하지 못했습니다.');
|
||||
setState({
|
||||
...INITIAL,
|
||||
phase: 'unavailable',
|
||||
unavailableReason:
|
||||
'장소 DB 를 부르지 못했습니다. 백엔드가 떠 있는지 확인하시거나, 직접 입력으로 진행해 주세요.',
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 네이버 플레이스 URL 로 확정한다.
|
||||
@ -230,10 +180,14 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
|
||||
* 상호·주소는 서버가 그 URL 에서 읽어 온다(손으로 옮겨 적게 하면 오타가 남의 가게가 된다).
|
||||
*/
|
||||
const confirmByUrl = useCallback(
|
||||
async (url: string): Promise<ConfirmedIdentity | null> => {
|
||||
async (url: string, industry: IndustryType): Promise<ConfirmedIdentity | null> => {
|
||||
setIsConfirming(true);
|
||||
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;
|
||||
const res = await verifyPlaceByUrl(placeId, {url});
|
||||
const place = res.place;
|
||||
@ -261,20 +215,81 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
|
||||
);
|
||||
|
||||
/**
|
||||
* 목록에 없거나 장소 DB 를 못 부를 때 — 사장님이 직접 댄 값으로 신원을 세운다.
|
||||
* 목록에 없거나 로그인 전이라 검증을 못 걸 때 — 사장님이 고른 값으로 신원만 세운다.
|
||||
*
|
||||
* ★ 동일 업소 검증(POST /verify)을 하지 않는다. 검증 없이 수집을 열면 남의 가게 URL 을
|
||||
* 긁을 수 있다. 그래서 이 경로는 수집 없이 직접 입력한 정보로만 사이트를 만든다.
|
||||
* 긁을 수 있다. 그래서 이 경로는 수집 없이 이 값들로만 사이트를 만든다.
|
||||
*/
|
||||
const confirmManual = useCallback((name: string, address: string): ConfirmedIdentity => {
|
||||
return {
|
||||
const confirmManual = useCallback(
|
||||
(name: string, address: string, sourceLabel = '직접 입력'): ConfirmedIdentity => ({
|
||||
placeId: placeIdRef.current,
|
||||
name: name.trim(),
|
||||
address: address.trim(),
|
||||
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};
|
||||
}
|
||||
|
||||
96
solution/frontend/src/features/onboarding/wizardUrl.ts
Normal file
96
solution/frontend/src/features/onboarding/wizardUrl.ts
Normal 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];
|
||||
}
|
||||
@ -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 {notifyApiError} from '@/lib/notify';
|
||||
import {callSiteApi, type ResultEnvelope} from './siteApi';
|
||||
@ -40,6 +40,13 @@ export interface SiteThemePayload {
|
||||
* 서버는 어느 쪽도 해석하지 않는다.
|
||||
*/
|
||||
colorPaletteId?: string | null;
|
||||
/**
|
||||
* 템플릿의 생김새(서체·모서리·테두리·그림자·여백).
|
||||
*
|
||||
* ★ 이걸 안 실으면 발행본은 색만 템플릿을 따르고 서체는 늘 같은 것으로 나간다 —
|
||||
* 사장님이 레트로를 골라도 발행 페이지만 고딕이었다. 서버는 해석하지 않고 보관만 한다.
|
||||
*/
|
||||
look?: TemplateLook;
|
||||
sections: {
|
||||
id: string;
|
||||
/**
|
||||
@ -81,6 +88,7 @@ export function toThemePayload(
|
||||
return {
|
||||
colors: template.colors,
|
||||
fontStyle: template.fontStyle,
|
||||
look: template.look,
|
||||
colorPaletteId,
|
||||
sections: sections.map((s) => ({
|
||||
id: s.id,
|
||||
|
||||
@ -17,7 +17,7 @@ import {useBuilderStore} from '@/stores/builder';
|
||||
import type {ApplyPlaceOptions} from '@/stores/builderTypes';
|
||||
|
||||
export function usePlaceSync(placeId: string | null, options?: ApplyPlaceOptions) {
|
||||
const enterEditor = options?.enterEditor ?? true;
|
||||
const isOnboarding = options?.isOnboarding ?? false;
|
||||
const enabled = Boolean(placeId);
|
||||
const id = placeId ?? '';
|
||||
|
||||
@ -42,8 +42,8 @@ export function usePlaceSync(placeId: string | null, options?: ApplyPlaceOptions
|
||||
|
||||
/**
|
||||
* ★ useEffect 가 아니라 useLayoutEffect 다.
|
||||
* 일반 effect 는 페인트 뒤에 돌아서, 응답이 도착한 프레임에 위저드 1단계가 한 번
|
||||
* 그려졌다가 편집기로 바뀐다(깜빡임). 스토어에 얹는 일은 페인트 전에 끝나야 한다.
|
||||
* 일반 effect 는 페인트 뒤에 돌아서, 응답이 도착한 프레임에 빈 편집기가 한 번 그려졌다가
|
||||
* 값이 채워진다(깜빡임). 스토어에 얹는 일은 페인트 전에 끝나야 한다.
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
if (!placeId) {
|
||||
@ -53,8 +53,10 @@ export function usePlaceSync(placeId: string | null, options?: ApplyPlaceOptions
|
||||
if (!place) return;
|
||||
// fact 가 아직 안 왔어도 먼저 온 place 로 상호·주소부터 얹는다.
|
||||
// fact/스키마가 도착하면 이 effect 가 한 번 더 돌아 정보 표를 채운다.
|
||||
applyPlace(toLivePlaceInput(placeId, place, facts ?? [], specs ?? [], media ?? []), {enterEditor});
|
||||
}, [placeId, place, facts, specs, media, applyPlace, clearPlace, enterEditor]);
|
||||
applyPlace(toLivePlaceInput(placeId, place, facts ?? [], specs ?? [], media ?? []), {
|
||||
isOnboarding,
|
||||
});
|
||||
}, [placeId, place, facts, specs, media, applyPlace, clearPlace, isOnboarding]);
|
||||
|
||||
/**
|
||||
* 서버에 저장된 디자인을 얹는다.
|
||||
|
||||
@ -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%(빈 트랙) 구간에서도 '진행 중'이 보이게 한다. */
|
||||
@keyframes o2o-shimmer {
|
||||
from { transform: translateX(-100%); }
|
||||
|
||||
@ -1,37 +1,7 @@
|
||||
/**
|
||||
* 색 계산 — 디자인 토큰에서 '면 단계'를 만들 때 쓴다.
|
||||
* 색 계산은 계약 패키지가 소유한다(`@o2o/shared/lib/color.ts`).
|
||||
*
|
||||
* ★ 쇼케이스(개발 도구)와 실제 캔버스가 **같은 식**을 써야 한다.
|
||||
* 미리보기에서 예쁜 조합이 발행 화면에서 달라지면 미리보기가 거짓말이 된다.
|
||||
* ★ 발행 사이트도 같은 식으로 면 토큰을 만든다. 두 벌로 두면 캔버스와 발행본의 바탕색이 갈린다.
|
||||
* 여기는 기존 import 경로(`@/lib/color`)를 지키는 재수출만 남긴다.
|
||||
*/
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
export {mix, deriveSurfaces} from '@o2o/shared';
|
||||
|
||||
@ -26,6 +26,10 @@ export const ERROR_MESSAGE: Record<string, string> = {
|
||||
PLACE_NOT_VERIFIED: '동일 업소 검증을 먼저 끝내야 합니다. 검증 전에는 수집·발행이 열리지 않습니다.',
|
||||
PLACE_VERIFY_NO_CANDIDATE: '외부 장소 정보에서 이 상호를 찾지 못했습니다.',
|
||||
PLACE_VERIFY_AMBIGUOUS: '같은 이름의 업소가 여럿입니다. 어느 곳인지 골라 주세요.',
|
||||
// 상호명 공개 검색(로그인 전 첫 화면)이 만나는 세 가지. 인증이 없는 경로라 분당 상한이 걸려 있다.
|
||||
HTTP_TO_MANY_REQUEST: '검색을 너무 자주 했습니다. 잠시 뒤 다시 시도해 주세요.',
|
||||
LOCAL_NOT_CONFIGURED: '지도 검색이 아직 설정되지 않았습니다. 네이버 지도 주소를 붙여넣어 진행해 주세요.',
|
||||
LOCAL_FETCH_FAILED: '지도에서 가게를 찾지 못했습니다. 잠시 뒤 다시 시도하거나 지도 주소를 붙여넣어 주세요.',
|
||||
LINK_NOT_CONFIRMED: '확정한 채널 URL 만 수집 대상이 됩니다.',
|
||||
|
||||
// fact
|
||||
|
||||
@ -8,7 +8,9 @@
|
||||
* ★ client_id 는 비밀이 아니다(번들에 그대로 들어간다). 이 값으로 할 수 있는 건 "우리 앱 앞으로"
|
||||
* 토큰을 받는 것뿐이고, 그 토큰이 우리 계정이 되려면 백엔드의 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';
|
||||
|
||||
export const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID ?? '';
|
||||
|
||||
144
solution/frontend/src/pages/AccountPage.tsx
Normal file
144
solution/frontend/src/pages/AccountPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,26 +1,34 @@
|
||||
import {useEffect, useRef} from 'react';
|
||||
import {ArrowLeft, ExternalLink, Loader2, TriangleAlert} from 'lucide-react';
|
||||
import {useEffect} from 'react';
|
||||
import {ArrowLeft, ExternalLink, Loader2, LogOut, TriangleAlert} from 'lucide-react';
|
||||
import {Link, useSearchParams} from 'react-router';
|
||||
import {SiteStatus} from '@o2o/shared';
|
||||
import {SiteStatus, type IndustryType} from '@o2o/shared';
|
||||
import {getAccessToken} from '@/api';
|
||||
import {AppShell} from '@/components/layout/AppShell';
|
||||
import {EditorSignInGate} from '@/features/auth/EditorSignInGate';
|
||||
import {
|
||||
EDITOR_STEP,
|
||||
Step1Industry,
|
||||
Step2PlaceSearch,
|
||||
Step3DataReview,
|
||||
Step4Template,
|
||||
Step5Generating,
|
||||
useWizardStep,
|
||||
} from '@/features/onboarding';
|
||||
import {EditorLayout} from '@/features/builder';
|
||||
import {useAutoLogin} from '@/hooks/useAutoLogin';
|
||||
import {usePlaceSync} from '@/hooks/usePlaceSync';
|
||||
import {useAuthStore} from '@/stores/auth';
|
||||
import {EDITOR_STEP, useBuilderStore} from '@/stores/builder';
|
||||
import {userLabel, useAuthStore} from '@/stores/auth';
|
||||
import {useBuilderStore} from '@/stores/builder';
|
||||
|
||||
/** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */
|
||||
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() {
|
||||
useAutoLogin();
|
||||
/**
|
||||
* 어떤 사업장을 편집할지는 쿼리스트링으로 받는다 — `/builder?placeId=<uuid>`.
|
||||
* ★ 이 화면이 무엇을 그릴지는 **전부 주소창이 정한다.**
|
||||
*
|
||||
* ★ 라우트(`/builder/:placeId`)로 받지 않는 이유: 빌더는 로그인 없이 도는 데모 경로이고
|
||||
* (router.tsx 주석), placeId 는 있을 수도 없을 수도 있는 선택값이다. 쿼리스트링이면
|
||||
* 라우트를 하나도 안 건드리고 두 경우를 같은 화면이 받는다.
|
||||
* placeId 가 없으면 아래 훅은 네트워크를 한 번도 타지 않는다 — 데모는 지금 그대로다.
|
||||
* ?step= 어느 단계인가(없으면 wizardUrl.defaultStep 이 정한다)
|
||||
* ?placeId= 어떤 사업장인가 — 라우트(`/builder/:placeId`)로 받지 않는 이유는
|
||||
* 빌더가 로그인 없이 도는 경로이고 placeId 는 있을 수도 없을 수도 있어서다.
|
||||
* ?flow=onboarding 위저드가 방금 만든 사업장이다(딥링크로 편집하러 온 것과 구분한다)
|
||||
* ?new=1 ?q= ?industry= 랜딩에서 넘어온 입구. 한 번 읽고 주소창에서 지운다.
|
||||
*/
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [step, goToStep] = useWizardStep();
|
||||
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 selectIndustry = useBuilderStore((s) => s.selectIndustry);
|
||||
const setStoreName = useBuilderStore((s) => s.setStoreName);
|
||||
const isNew = searchParams.get('new') === '1';
|
||||
const seedQuery = searchParams.get('q');
|
||||
const seedIndustry = searchParams.get('industry');
|
||||
useEffect(() => {
|
||||
if (!isNew) return;
|
||||
reset();
|
||||
setSearchParams({}, {replace: true});
|
||||
}, [isNew, reset, setSearchParams]);
|
||||
if (!isNew && seedQuery === null && seedIndustry === null) return;
|
||||
if (isNew) reset();
|
||||
// 순서가 뒤집히면 안 된다 — selectIndustry 는 시드를 통째로 갈아 상호를 비운다.
|
||||
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개 동' 같은 남의 가게 값이 뜬다. 실제로 그랬다.
|
||||
@ -70,25 +101,14 @@ export function BuilderPage() {
|
||||
*/
|
||||
const wizardPlaceId = useBuilderStore((s) => s.confirmedIdentity?.placeId ?? null);
|
||||
const placeId = urlPlaceId ?? wizardPlaceId;
|
||||
/**
|
||||
* 에디터로 바로 들어갈지.
|
||||
*
|
||||
* ★ **처음 들어온 순간**의 주소창만 본다. 위저드 2단계가 확정 뒤에 `?placeId=` 를 붙이는데
|
||||
* (새로고침 복원용), 그걸 실시간으로 보면 "URL 붙여넣고 확인 → 곧장 에디터" 가 된다 —
|
||||
* 수집 결과를 보여주는 3·4·5단계가 통째로 건너뛰어진다. 실제로 그렇게 됐다.
|
||||
* 사업장 목록에서 딥링크로 들어온 경우(이미 만든 가게를 여는 것)만 에디터로 보낸다.
|
||||
*/
|
||||
const enteredWithPlace = useRef(
|
||||
Boolean(urlPlaceId) && searchParams.get('flow') !== 'onboarding',
|
||||
).current;
|
||||
const sync = usePlaceSync(placeId, {enterEditor: enteredWithPlace});
|
||||
const sync = usePlaceSync(placeId, {isOnboarding});
|
||||
|
||||
const step = useBuilderStore((s) => s.step);
|
||||
const goToStep = useBuilderStore((s) => s.goToStep);
|
||||
const storeName = useBuilderStore((s) => s.storeName);
|
||||
// 에디터는 AppShell(사이드바)을 안 쓴다 — 누구로 로그인했는지·나가는 길이 여기 없으면 아예 없다.
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const signOut = useAuthStore((s) => s.signOut);
|
||||
// ★ 스토어의 user 만 보면 자동 로그인이 심어 둔 토큰을 놓친다 — 둘 다 본다.
|
||||
const authUser = useAuthStore((s) => s.user);
|
||||
const isSignedIn = Boolean(authUser) || Boolean(getAccessToken());
|
||||
const isSignedIn = Boolean(user) || Boolean(getAccessToken());
|
||||
// 배지는 주소창이 아니라 스토어가 기준이다 — [처음부터]로 데모로 돌아간 뒤에도
|
||||
// 주소창에는 placeId 가 남아 있어서, 그걸 믿으면 데모를 실사업장이라고 표시한다.
|
||||
const wiredPlaceId = useBuilderStore((s) => s.placeId);
|
||||
@ -127,7 +147,7 @@ export function BuilderPage() {
|
||||
|
||||
// 에디터에 들어갈 때 로그인을 받는다. 위저드(1~5단계)는 요구하지 않는다.
|
||||
if (step === EDITOR_STEP && !isSignedIn) {
|
||||
return <EditorSignInGate onBack={() => goToStep(4)} />;
|
||||
return <EditorSignInGate onBack={() => goToStep('template')} />;
|
||||
}
|
||||
|
||||
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">
|
||||
실사업장 · {storeName}
|
||||
</span>
|
||||
{/* ★ 예전엔 여기 [사업장 목록] 링크가 있었다. 그 화면은 내부 운영 앱(admin)으로
|
||||
나갔고, 사장님 앱에는 그 경로가 없다 — 남겨두면 404 다. admin 은 빌더를
|
||||
새 탭으로 열므로(admin/src/lib/solutionUrl.ts) 돌아가는 길은 탭 닫기다.
|
||||
사장님용 "내 사이트 관리"가 생기면 그때 이 자리에 잇는다. */}
|
||||
{/* 돌아가는 길. 로그인한 사람에게만 목록이 있다(비로그인은 에디터에 못 들어온다). */}
|
||||
<Link
|
||||
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">
|
||||
@ -158,26 +182,49 @@ export function BuilderPage() {
|
||||
{/* 캔버스는 미리보기다. 진짜 발행본은 별도 렌더러(site)가 굽는다 —
|
||||
같은 화면을 두 번 구현하지 않고, 그쪽을 새 탭으로 연다.
|
||||
★ 발행 전에는 열지 않는다 — 굽지 않은 주소를 열면 404 다. */}
|
||||
{publishedUrl ? (
|
||||
<a
|
||||
href={publishedUrl}
|
||||
target="_blank"
|
||||
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>
|
||||
</a>
|
||||
) : (
|
||||
<span
|
||||
title="아직 발행 전입니다 — [사이트 발행] 을 마치면 열립니다."
|
||||
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>
|
||||
</span>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{publishedUrl ? (
|
||||
<a
|
||||
href={publishedUrl}
|
||||
target="_blank"
|
||||
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>
|
||||
</a>
|
||||
) : (
|
||||
<span
|
||||
title="아직 발행 전입니다 — [사이트 발행] 을 마치면 열립니다."
|
||||
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>
|
||||
</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 className="min-h-0 flex-1">
|
||||
@ -187,18 +234,46 @@ export function BuilderPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// 위저드는 관리자 화면의 일부다 — 사이드바(로고·사업장·로그아웃)를 그대로 쓴다.
|
||||
// 에디터(EDITOR_STEP)만 전체 화면이라 위에서 먼저 빠져나간다.
|
||||
/**
|
||||
* 위저드는 **사이드바를 쓰지 않는다.**
|
||||
*
|
||||
* ★ 사이드바는 계정 메뉴(내 사이트·새 사이트)다. 아직 사이트가 아닌 것 위에 사이트 메뉴를
|
||||
* 얹으면, 만들던 중에 [새 사이트]를 눌러 방금 입력한 것을 지우는 길만 열어 준다.
|
||||
* 진행은 단계가 이미 보여주므로(WizardSteps) 여기 필요한 건 로고와 **나가는 길** 하나다.
|
||||
*/
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex h-full min-h-full flex-col">
|
||||
{step === 1 && <Step1Industry />}
|
||||
{step === 2 && <Step2PlaceSearch />}
|
||||
{step === 3 && <Step3DataReview />}
|
||||
{step === 4 && <Step4Template />}
|
||||
{step === 5 && <Step5Generating />}
|
||||
<div className="flex h-screen w-screen flex-col overflow-hidden bg-background text-foreground">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-2.5">
|
||||
<Link to="/" className="transition-opacity hover:opacity-60">
|
||||
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-6 w-auto" />
|
||||
</Link>
|
||||
{/* 비로그인은 돌아갈 목록이 없다 — 그 자리에는 로그인을 둔다(빈 버튼을 두지 않는다). */}
|
||||
{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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
281
solution/frontend/src/pages/LandingPage.tsx
Normal file
281
solution/frontend/src/pages/LandingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -22,8 +22,9 @@ export function LoginPage({selfServe = true}: {selfServe?: boolean}) {
|
||||
const location = useLocation();
|
||||
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);
|
||||
|
||||
// ★ 기본 도착지를 '/' 로 둔다. 앱마다 홈이 다르고(사장님 → 빌더, 내부 → 사업장 목록)
|
||||
@ -135,7 +136,7 @@ export function LoginPage({selfServe = true}: {selfServe?: boolean}) {
|
||||
<span className="text-[11px] text-muted-foreground">또는</span>
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
<GoogleSignInButton onCredential={handleGoogle} text="continue_with" />
|
||||
<GoogleSignInButton onCredential={handleGoogle} text="signin_with" />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
109
solution/frontend/src/pages/PricingPage.tsx
Normal file
109
solution/frontend/src/pages/PricingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
solution/frontend/src/pages/ShowcasePage.tsx
Normal file
37
solution/frontend/src/pages/ShowcasePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
213
solution/frontend/src/pages/SitesPage.tsx
Normal file
213
solution/frontend/src/pages/SitesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 {
|
||||
user: AuthUser | null;
|
||||
/** 부팅 시 저장된 토큰을 확인하기 전까지 true. 가드가 이 동안 리다이렉트를 미룬다. */
|
||||
|
||||
@ -23,9 +23,9 @@ import type {
|
||||
ConfirmedIdentity,
|
||||
FactRef,
|
||||
LivePlaceInput,
|
||||
PendingPick,
|
||||
RightTab,
|
||||
WeatherLocation,
|
||||
WizardStep,
|
||||
} from '@/stores/builderTypes';
|
||||
|
||||
/**
|
||||
@ -38,16 +38,14 @@ export type {
|
||||
ConfirmedIdentity,
|
||||
FactRef,
|
||||
LivePlaceInput,
|
||||
PendingPick,
|
||||
RightTab,
|
||||
WeatherLocation,
|
||||
WizardStep,
|
||||
};
|
||||
export {EDITOR_STEP} from '@/stores/builderTypes';
|
||||
import {EDITOR_STEP} from '@/stores/builderTypes';
|
||||
|
||||
interface BuilderState {
|
||||
// ── 위저드 ────────────────────────────────────────────
|
||||
step: WizardStep;
|
||||
// ★ 단계(step)는 여기 없다 — 주소창이 소유한다(features/onboarding/wizardUrl).
|
||||
industry: IndustryType;
|
||||
/**
|
||||
* 지금 편집 중인 실제 사업장. null 이면 아직 가게가 정해지지 않은 상태다.
|
||||
@ -65,13 +63,15 @@ interface BuilderState {
|
||||
* 화면의 상호·주소는 업종 예시일 뿐이라는 뜻이고, 3단계는 그 사실을 그대로 표시한다.
|
||||
*/
|
||||
confirmedIdentity: ConfirmedIdentity | null;
|
||||
/** 업종을 못 정해 업종 화면으로 넘긴 후보. 돌아와서 확정을 이어갈 때만 쓴다. */
|
||||
pendingPick: PendingPick | null;
|
||||
|
||||
// 수집(Step 2)
|
||||
// 수집
|
||||
isGathering: boolean;
|
||||
gatherStage: number;
|
||||
gatherCompleted: boolean;
|
||||
|
||||
// 템플릿(Step 3) · 생성(Step 4)
|
||||
// 템플릿 · 생성
|
||||
templateId: string;
|
||||
colorPaletteId: string | null;
|
||||
generateStage: number;
|
||||
@ -97,7 +97,6 @@ interface BuilderState {
|
||||
publishedUrl: string | null;
|
||||
|
||||
// ── 액션 ──────────────────────────────────────────────
|
||||
goToStep: (step: WizardStep) => void;
|
||||
selectIndustry: (industry: IndustryType) => void;
|
||||
|
||||
/** 실사업장 데이터를 캔버스에 얹는다(같은 사업장을 다시 읽어도 편집을 지우지 않는다). */
|
||||
@ -114,12 +113,16 @@ interface BuilderState {
|
||||
/** 확정을 물린다 — 상호를 다시 검색하러 갈 때. */
|
||||
clearIdentity: () => void;
|
||||
|
||||
/** 업종 화면으로 넘길 후보를 들려 보낸다(null 이면 비운다). */
|
||||
setPendingPick: (pick: PendingPick | null) => void;
|
||||
|
||||
startGather: () => void;
|
||||
setGatherStage: (stage: number) => void;
|
||||
finishGather: () => void;
|
||||
|
||||
selectTemplate: (templateId: string) => void;
|
||||
selectColorPalette: (paletteId: string | null) => void;
|
||||
/** 생성 화면에 들어가기 직전, 진행 표시를 처음으로 되돌린다. 화면 이동은 주소창이 한다. */
|
||||
startGenerating: () => void;
|
||||
setGenerateStage: (stage: number) => void;
|
||||
|
||||
@ -288,10 +291,10 @@ for (const key of ['o2osite.builder.wizard.v1', 'o2osite.builder.wizard.v2']) {
|
||||
let factSaver: FactSaver;
|
||||
|
||||
export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
step: 1,
|
||||
...seedFor(FALLBACK_INDUSTRY),
|
||||
placeId: null,
|
||||
confirmedIdentity: null,
|
||||
pendingPick: null,
|
||||
savingFieldIds: [],
|
||||
|
||||
isGathering: false,
|
||||
@ -306,8 +309,6 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
isPublishModalOpen: false,
|
||||
publishedUrl: null,
|
||||
|
||||
goToStep: (step) => set({step}),
|
||||
|
||||
// 업종을 바꾸면 그 업종의 시드로 통째로 갈아탄다 — 앞 업종의 섹션·필드가 남으면
|
||||
// 카페 사이트에 '객실 안내'가 붙는 식으로 섞인다.
|
||||
selectIndustry: (industry) => {
|
||||
@ -327,7 +328,16 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
location: identity.address,
|
||||
infoFields: withConfirmedIdentity(seed.infoFields, identity),
|
||||
}
|
||||
: null),
|
||||
: {
|
||||
/**
|
||||
* ★ 사장님이 친 상호·위치는 시드가 아니다 — 업종을 바꿨다고 지우면 안 된다.
|
||||
* 업종이 첫 화면이던 시절엔 여기가 늘 빈 값이라 티가 안 났는데, 지금은 상호를
|
||||
* 먼저 받는다: 지우면 검색어를 친 뒤 업종만 골라도 그 이름이 사라진다.
|
||||
*/
|
||||
storeName: state.storeName,
|
||||
location: state.location,
|
||||
infoFields: withOwnerIdentity(seed.infoFields, state.storeName, state.location),
|
||||
}),
|
||||
// 업종을 손으로 고르면 화면의 값은 다시 시드다 — 실사업장 배선을 남겨두면
|
||||
// "placeId 가 있다 = 화면 값이 서버에서 왔다"는 약속이 깨진다.
|
||||
placeId: null,
|
||||
@ -350,7 +360,7 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
* '확인 필요'로 튀어, 사장님 눈에는 클릭이 씹힌 것으로 보인다(withPendingEdits).
|
||||
*/
|
||||
applyPlace: (input, options) => {
|
||||
const enterEditor = options?.enterEditor ?? true;
|
||||
const isOnboarding = options?.isOnboarding ?? false;
|
||||
// 처음 여는 사업장인가. 리페치(같은 placeId)면 섹션·단계를 건드리지 않는다.
|
||||
const isNewPlace = get().placeId !== input.placeId;
|
||||
// 사업장을 갈아타면 앞 사업장의 저장 대기분은 버린다 — 들고 가면 남의 값이 이 화면에 얹힌다.
|
||||
@ -366,11 +376,14 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
return {
|
||||
// 업종이 바뀔 때만 시드를 갈아끼운다(카페 사업장에 '객실 안내'가 남지 않게).
|
||||
...(needsReseed ? seedFor(input.industry) : null),
|
||||
// 이미 등록된 사업장을 여는 것이므로 수집 위저드를 다시 걷게 하지 않는다.
|
||||
...(isNewPlace
|
||||
? {
|
||||
step: enterEditor ? EDITOR_STEP : 3,
|
||||
...(!enterEditor
|
||||
/**
|
||||
* ★ 위저드 도중에 새로고침하면 스토어의 확정 신원이 비어 있다(브라우저에 저장하지
|
||||
* 않으므로). 그대로 두면 3단계가 "아직 가게를 안 골랐다"고 판단해 수집을 열지
|
||||
* 않는다 — 서버에서 읽어온 사업장으로 여기서 다시 세운다.
|
||||
*/
|
||||
...(isOnboarding
|
||||
? {
|
||||
confirmedIdentity: {
|
||||
placeId: input.placeId,
|
||||
@ -384,18 +397,7 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
selectedSectionId: null,
|
||||
isGathering: false,
|
||||
}
|
||||
: /**
|
||||
* 같은 사업장을 **딥링크로 다시 연** 경우다(`/builder?placeId=...`).
|
||||
*
|
||||
* ★ 단계를 다시 못 박지 않으면, 스토어에 남아 있던 위저드 단계가 그대로 뜬다 —
|
||||
* 사업장 목록에서 [사이트 편집] 을 눌렀는데 생성 화면(5단계)이 되살아나
|
||||
* 생성 잡이 한 번 더 들어가는 일이 실제로 생긴다. 편집하러 온 사람은
|
||||
* 언제나 편집기를 봐야 한다. (리페치는 enterEditor 여부와 무관하게 안전하다 —
|
||||
* 이미 EDITOR_STEP 이면 같은 값을 다시 쓰는 것뿐이다.)
|
||||
*/
|
||||
enterEditor
|
||||
? {step: EDITOR_STEP}
|
||||
: null),
|
||||
: null),
|
||||
/**
|
||||
* ★ "수집 완료"는 fact 가 있을 때만이다.
|
||||
*
|
||||
@ -454,6 +456,8 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
|
||||
clearIdentity: () => set({confirmedIdentity: null}),
|
||||
|
||||
setPendingPick: (pendingPick) => set({pendingPick}),
|
||||
|
||||
toggleChannel: (channelId) =>
|
||||
set((state) => ({
|
||||
selectedChannels: state.selectedChannels.includes(channelId)
|
||||
@ -496,9 +500,8 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
set({colorPaletteId});
|
||||
persistTheme();
|
||||
},
|
||||
// ★ 생성 화면(5)으로 보낸다. 단계 번호는 EDITOR_STEP 과 함께 WizardStep 주석이 기준이다 —
|
||||
// 여기 숫자가 자기 화면(4)이면 [사이트 생성하기]가 아무 일도 안 하는 것처럼 보인다.
|
||||
startGenerating: () => set({step: 5, generateStage: 1}),
|
||||
// 화면 이동은 부르는 쪽이 주소창으로 한다(`?step=generating`) — 여기서는 진행 표시만 되감는다.
|
||||
startGenerating: () => set({generateStage: 1}),
|
||||
setGenerateStage: (generateStage) => set({generateStage}),
|
||||
|
||||
selectSection: (selectedSectionId) => set({selectedSectionId}),
|
||||
@ -747,11 +750,11 @@ export const useBuilderStore = create<BuilderState>((set, get) => ({
|
||||
factSaver.clear();
|
||||
clearThemeSaves();
|
||||
set({
|
||||
step: 1,
|
||||
...seedFor(get().industry),
|
||||
placeId: null,
|
||||
// [처음부터]는 "이 가게가 맞다"까지 물린다 — 상호부터 다시 확인받는다.
|
||||
confirmedIdentity: null,
|
||||
pendingPick: null,
|
||||
isGathering: false,
|
||||
gatherStage: 1,
|
||||
gatherCompleted: false,
|
||||
|
||||
@ -8,23 +8,9 @@
|
||||
import type {FactStatus, IndustryType, InfoField, PhotoItem} from '@o2o/shared';
|
||||
|
||||
/**
|
||||
* 위저드 단계.
|
||||
*
|
||||
* 1 업종 선택
|
||||
* 2 상호 검색 → **후보 목록에서 내 가게 확인** ← 남의 가게가 섞이면 제일 비싼 실수다
|
||||
* 3 수집된 데이터 확인 ← [맞아요]/[아니에요] 가 여기서 끝난다
|
||||
* 4 템플릿 선택
|
||||
* 5 생성 중
|
||||
* 6 에디터
|
||||
*
|
||||
* ★ 2 와 3 은 성격이 다른 확인이라 한 화면에 두지 않는다.
|
||||
* 2 는 "이 가게가 맞나" (신원), 3 은 "이 값이 맞나" (사실). 신원이 틀린 채로
|
||||
* 사실을 확인시키면 사장님이 남의 가게 정보를 자기 것이라고 승인하게 된다.
|
||||
* ★ 위저드 단계는 여기 없다 — 주소창이 소유한다(`features/onboarding/wizardUrl`).
|
||||
* 스토어에 두면 뒤로가기·북마크가 단계를 못 따라온다.
|
||||
*/
|
||||
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';
|
||||
|
||||
/**
|
||||
@ -97,11 +83,26 @@ export interface ConfirmedIdentity {
|
||||
|
||||
export interface ApplyPlaceOptions {
|
||||
/**
|
||||
* 처음 얹는 사업장일 때 곧장 에디터로 보낼지.
|
||||
* 위저드 안에서 열린 사업장인가(`?flow=onboarding`).
|
||||
*
|
||||
* ★ 기본은 true — 사업장 목록에서 딥링크로 들어온 경우다(이미 등록된 가게를 여는 것이므로
|
||||
* 위저드를 다시 걷게 하지 않는다). 반대로 위저드 3단계는 **수집 결과를 위저드 안에서**
|
||||
* 보여줘야 하므로 false 로 부른다. 안 그러면 수집이 끝나는 순간 화면이 에디터로 튄다.
|
||||
* ★ 화면을 옮기지는 않는다 — 그건 주소창(`?step=`)의 일이다. 이 값이 하는 일은 하나뿐:
|
||||
* 위저드 도중에 새로고침하면 스토어의 확정 신원이 비어 있으므로, 서버에서 읽은 사업장으로
|
||||
* 그걸 다시 세운다. 없으면 3단계가 "가게를 아직 안 골랐다"고 판단해 수집을 열지 않는다.
|
||||
*/
|
||||
enterEditor?: boolean;
|
||||
isOnboarding?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 검색에서 고른, **아직 업종이 안 정해진** 후보.
|
||||
*
|
||||
* ★ 업종 화면(`?step=industry`)을 다녀오는 동안만 산다. 사장님이 고른 업종을 들고 돌아와야
|
||||
* 그 업종으로 사업장을 만들 수 있어서다(사업장의 category 는 만들 때 정해진다).
|
||||
* ★ 브라우저에 저장하지 않는다(위저드 상태 규칙). 새로고침하면 사라지고 검색부터 다시 한다 —
|
||||
* 확정 전이라 서버에도 아직 아무것도 없다.
|
||||
*/
|
||||
export interface PendingPick {
|
||||
name: string;
|
||||
address: string;
|
||||
/** 업종 화면에서 고른 값. 이게 채워져야 확정이 이어진다. */
|
||||
industry?: IndustryType;
|
||||
}
|
||||
|
||||
@ -2,3 +2,5 @@ export * from './types';
|
||||
export * from './lib/cn';
|
||||
export * from './lib/slug';
|
||||
export * from './lib/facts';
|
||||
export * from './lib/section-data';
|
||||
export * from './lib/color';
|
||||
|
||||
38
solution/shared/src/lib/color.ts
Normal file
38
solution/shared/src/lib/color.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
433
solution/shared/src/lib/section-data.ts
Normal file
433
solution/shared/src/lib/section-data.ts
Normal 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);
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import type {TemplateLook} from './builder';
|
||||
import type {
|
||||
FactStatus,
|
||||
LinkChannel,
|
||||
@ -229,6 +230,15 @@ export interface SiteTheme {
|
||||
accent: string;
|
||||
};
|
||||
fontStyle: string;
|
||||
/**
|
||||
* 템플릿의 생김새 — 서체 · 모서리 · 테두리 두께 · 그림자 · 섹션 여백.
|
||||
*
|
||||
* ★ 이 필드가 없던 동안 발행본은 **색만** 템플릿을 따랐다. 사장님이 레트로(간판체)를 골라도
|
||||
* 발행 페이지는 늘 같은 고딕·명조로 나갔다 — 캔버스와 발행본이 다르게 보이는 가장 큰 이유였다.
|
||||
* ★ 값은 전부 CSS 에 그대로 들어가는 문자열이다(`TemplateLook`). 렌더러는 이걸 `--tpl-*` 로
|
||||
* 내려보내기만 한다. 비어 있으면 렌더러 기본 서체로 떨어진다 — 화면이 깨지지 않는다.
|
||||
*/
|
||||
look?: TemplateLook;
|
||||
/** 섹션 순서와 노출 여부. 관리자 에디터의 좌측 패널이 만든 결과 그대로. */
|
||||
sections: SectionSetting[];
|
||||
}
|
||||
@ -258,4 +268,13 @@ export interface SectionSetting {
|
||||
* ★ 줄바꿈이 문단 구분이다. 렌더러가 빈 줄을 기준으로 <p> 를 나눈다.
|
||||
*/
|
||||
body?: string;
|
||||
/**
|
||||
* 붙여넣기 아이템(가요·일력·승차권·인물…)의 원문 JSON.
|
||||
*
|
||||
* ★ body·variantId 와 같은 사연이다 — 이 필드가 없으면 사장님이 붙여넣은 곡 목록이
|
||||
* payload 경계에서 버려져 빌더에서는 보이는데 발행본에는 없다.
|
||||
* ★ **문자열 그대로** 싣는다. 서버는 파싱하지 않는다 — 렌더러가 `parseSectionData()`
|
||||
* (shared/lib/section-data.ts)로 읽고, 깨진 JSON 이면 그 섹션만 조용히 비운다.
|
||||
*/
|
||||
data?: string;
|
||||
}
|
||||
|
||||
@ -12,7 +12,12 @@ import {
|
||||
import {basename, dirname, join, resolve} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
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 {
|
||||
collectJsonLd,
|
||||
@ -397,6 +402,27 @@ function countUniqueContent(payload: SitePayload): number {
|
||||
const intro = payload.theme.sections.find((section) => section.id === 'intro');
|
||||
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 은 전부 템플릿 값이라 가게를 구분하지 못한다.
|
||||
for (const fact of [...payload.facts, ...payload.units.flatMap((unit) => unit.facts)]) {
|
||||
if (fact.type === 'text' && long(fact.value)) count += 1;
|
||||
|
||||
@ -38,13 +38,22 @@ html {
|
||||
body {
|
||||
background-color: var(--color-surface);
|
||||
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;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* 제목 서체. 이름은 '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
Loading…
Reference in New Issue
Block a user