git 저장소가 없어 히스토리·협업 기반이 아예 없던 상태를 연다.
함께 문서를 재편했다. 그동안 문서가 있어도 "이 제품이 뭘 푸는가"와
"어떻게 도는가"를 담은 문서가 없어서, 목표 문장이 backend/frontend
README 두 곳에 복붙돼 있었다 — 상위 문서가 없어 아래로 샌 것이다.
신설
README.md 레포 진입점 + 문서 지도 + 문서 규칙 4가지
AGENTS.md 에이전트·신규 합류자용 함정 목록과 규약
(CLAUDE.md 는 여기로 걸린 심볼릭 링크)
docs/PRODUCT.md 제품 정의 — 문제·사용자·원칙·**non-goals**·성공 기준
docs/ARCHITECTURE.md payload 경계·발행 파이프라인·서빙 결정·앱 분리 설계
이동
backend/docs/DECISIONS.md → docs/DECISIONS.md
백엔드만의 결정이 아니다. 게다가 코드 주석 ~25곳이 이미
`docs/DECISIONS.md` 로 적고 있어 레포 루트 기준으로는 그게 맞다.
갱신
docs/DEPLOY.md 서빙 결정 반영 — nginx 정적 서빙이 지금 경로(3절),
Azure 는 나중에 켤 때(4절)로 분리
docs/ARCHITECTURE.md 사이트 = 한 장(2026-08-31) 구조 반영
docs/COLLECTION_SEO_AEO_FLOW.md
robots.txt·sitemap.xml 은 오리진 루트에만 굽는다는 점 명시
frontend/site/scripts/prerender.ts
헤더 주석의 렌더 보고서 경로가 실제(422줄)와 달라 수정
.gitignore
★ CLAUDE.md 를 더 이상 무시하지 않는다. 에이전트 지침은 팀과 모든
에이전트가 공유하는 규약이라 커밋해야 한다 — 무시하면 클론한 사람이
"배포 후 republish_all.py 필수" 같은 함정을 전달받지 못한다.
개인용 오버라이드는 ~/.claude/CLAUDE.md 에 둔다.
151 lines
7.5 KiB
Markdown
151 lines
7.5 KiB
Markdown
# o2o-web4ai frontend
|
|
|
|
소상공인 홈페이지 자동 생성 솔루션의 프론트엔드.
|
|
`o2o-negosium/negodata/front` 보일러플레이트를 이식했다 — 빌드 도구·구조·프로토콜 규약은 원본과 같다.
|
|
|
|
**목표는 예쁜 사이트가 아니라 AI 검색이 이 가게를 공식 홈페이지 기준으로 설명하게 만드는 것**이다
|
|
(백엔드 README 와 같은 문장). 그래서 두 앱의 요구사항이 정반대다.
|
|
|
|
| | `admin/` (빌더) | `site/` (발행 사이트) |
|
|
|---|---|---|
|
|
| 사용자 | 사장님 · 운영자 | 손님 · **검색/AI 크롤러** |
|
|
| 렌더링 | CSR SPA | **SSG (정적 HTML)** |
|
|
| 색인 | `noindex` | 색인·인용되라고 존재 |
|
|
| 런타임 | Vite dev / 정적 호스팅 | **서버 없음.** 파일만 |
|
|
| 데이터 | 편집 중 상태(미확인 값 포함) | `SitePayload` — **확인된 값만** |
|
|
|
|
한 레포에 두 앱을 두되 빌드를 나눈 이유가 이 표다. 보일러플레이트는 순수 CSR 이라
|
|
발행 사이트에 그대로 쓰면 크롤러가 `<div id="root"></div>` 만 읽고 떠난다.
|
|
|
|
```
|
|
frontend/
|
|
├── shared/ 두 앱이 공유 — 도메인 enum · SitePayload 계약 · 디자인 토큰 · fact 필터
|
|
├── admin/ 빌더 (위저드 4단계 + 실시간 에디터 + 발행 게이트)
|
|
└── site/ 발행 사이트 렌더러 + 프리렌더 스크립트
|
|
```
|
|
|
|
## 실행
|
|
|
|
### Docker Compose (기본 실행 방식)
|
|
|
|
```bash
|
|
docker compose up -d
|
|
```
|
|
|
|
- 발행 사이트: `http://localhost:3000/s/<slug>`
|
|
- API/Swagger: `http://localhost:9800/docs`
|
|
- 정적 사이트 저장 위치: `frontend/site/out/s/<slug>/`
|
|
- 프리렌더 입력 payload: `frontend/site/payloads/<slug>.json`
|
|
- 컨테이너 내부 정적 서버는 3001을 사용하지만, Docker가 호스트 3000으로만 공개한다.
|
|
|
|
예: `http://localhost:3000/s/grazz`
|
|
|
|
```bash
|
|
npm install # 워크스페이스 루트에서 한 번
|
|
|
|
npm run dev # 빌더 → http://localhost:3000
|
|
npm run dev:site # 발행 사이트 → http://localhost:3001 (데모 payload 로 CSR)
|
|
|
|
npm run build # 전체 타입체크 + 린트 + 빌드
|
|
npm run prerender # ★ 정적 사이트 굽기 → site/out/<slug>/
|
|
```
|
|
|
|
백엔드는 기본 `http://localhost:9800` 으로 본다. 바꾸려면 `admin/.env` 에 `VITE_API_BASE_URL`.
|
|
`cp admin/.env.example admin/.env` 로 시작한다.
|
|
|
|
## 발행 파이프라인
|
|
|
|
```
|
|
관리자 편집 → [발행 게이트] → SitePayload JSON → prerender → 정적 파일
|
|
↓ 막히면 발행 불가
|
|
미검증 fact / 필수 항목 누락 / 고유 콘텐츠 0건
|
|
```
|
|
|
|
```bash
|
|
# 데모 payload 로
|
|
npm run prerender
|
|
|
|
# 실제 payload 로 (백엔드 BUILD 잡이 부르는 자리)
|
|
npm run prerender -- --payload=./payloads --out=/var/www
|
|
```
|
|
|
|
사이트 하나당 나오는 것:
|
|
|
|
```
|
|
out/<slug>/
|
|
├── index.html 홈 (JSON-LD 4종 + 본문 전체가 HTML 에)
|
|
├── rooms/index.html 하위 단위 목록 ← 업종별 경로(rooms/menu/programs)
|
|
├── rooms/<unit>/index.html 단위 상세
|
|
├── guide|location|faq/index.html
|
|
├── sitemap.xml
|
|
├── robots.txt ★ AI 크롤러 명시 허용
|
|
├── llms.txt ★ LLM 이 읽을 사실 목록
|
|
└── assets/ 하이드레이션 번들
|
|
```
|
|
|
|
## SEO / AEO 가 어디에 박혀 있나
|
|
|
|
| 무엇 | 어디 | 왜 |
|
|
|---|---|---|
|
|
| 정적 HTML | `site/scripts/prerender.ts` | JS 를 실행 안 하는 AI 크롤러가 본문을 그대로 읽는다 |
|
|
| 구조화 데이터 | `site/src/seo/jsonld.ts` | 업종별 Schema.org 타입 + FAQPage + BreadcrumbList + WebPage |
|
|
| meta · OG · geo | `site/src/seo/meta.ts` `head.ts` | description 을 **확인된 fact 로 조립**한다(지어내지 않는다) |
|
|
| `llms.txt` | `site/src/seo/llms.ts` | 사실만. 형용사 금지. 모르는 건 "정보 없음"이라고 적는다 |
|
|
| `robots.txt` | `site/src/seo/robots.ts` | GPTBot · ClaudeBot · PerplexityBot 등 **명시 허용** |
|
|
| 핵심 정보 요약 | `site/src/sections/AnswerBlock.tsx` | AI 가 답으로 뽑아 가는 단정문을 상단에 고정 배치 |
|
|
| FAQ | `site/src/sections/FaqSection.tsx` | `<details>` — 접혀 있어도 크롤러가 읽는다 |
|
|
| 발행 게이트 | `admin/src/features/publish/publishGate.ts` | 백엔드 `PublishRejectReason` 과 1:1 |
|
|
|
|
### 절대규칙 1 이 지켜지는 지점
|
|
|
|
> 확인되지 않은 fact 는 사이트에 나가지 않는다.
|
|
|
|
한 곳에서만 거른다 — `shared/src/lib/facts.ts`.
|
|
|
|
- `selectPublishable()` — `VERIFIED` · `CORRECTED` 만 통과
|
|
- `sanitizePayloadForPublish()` — **프리렌더가 payload 를 여기 통과시킨 뒤 렌더와 임베드 양쪽에 쓴다**
|
|
|
|
두 번째가 중요하다. 정적 HTML 은 하이드레이션용으로 payload 를 통째로 심는데,
|
|
화면과 JSON-LD 만 걸러 두면 그 블롭에 미검증 값이 남아 원본 HTML 을 읽는 AI 가 그걸 읽는다.
|
|
(실제로 한 번 그렇게 샜고, 그래서 이 함수가 생겼다.)
|
|
|
|
## 백엔드 연결
|
|
|
|
API 클라이언트는 **전부 orval 생성물**이다(React Query 훅). 화면은 `@/api` 하나만 import 한다.
|
|
자세한 규약은 [admin/src/api/README.md](admin/src/api/README.md).
|
|
|
|
```bash
|
|
npm run orval -w admin # 백엔드가 떠 있을 때
|
|
|
|
cd ../backend && python scripts/export_openapi.py # 서버 없이 — 스펙을 먼저 뽑고
|
|
cd ../frontend && ORVAL_INPUT=../../backend/openapi.json npm run orval -w admin
|
|
```
|
|
|
|
열려 있는 도메인은 `auth` / `place` / `fact` / `job` / `site` 전부다.
|
|
|
|
| 화면 | 부르는 것 |
|
|
|---|---|
|
|
| 사업장 목록·상세 | `useListPlaces` `useGetPlace` `useListFacts` `useListLinks` `useTransitionFact` `useConfirmLink` |
|
|
| 위저드 2단계(수집) | `POST /place/{id}/collect` → 잡 폴링 — `useGatherSimulation.ts` |
|
|
| 위저드 4단계(생성) | `POST /place/{id}/copy` → 잡 폴링 — `Step4Generating.tsx` |
|
|
| 발행 | `POST /place/{id}/site/build {publish:true}` → 잡 폴링 — `features/publish/usePublishSite.ts` |
|
|
|
|
**발행 = 빌드다.** 백엔드에 발행 엔드포인트가 따로 없는 것이 맞다 — 발행 검수 게이트가
|
|
빌드 잡 안에 있어서(`services/build_service` → `publish_gate`) 게이트를 우회하는 경로가 없다.
|
|
그래서 잡이 DONE 이어도 발행됐다는 뜻이 아니다. `job.result.gate.passed` 를 봐야 한다.
|
|
|
|
★ `placeId` 가 없는 데모 경로(`/builder`)는 이 호출을 하나도 하지 않는다 —
|
|
로그인 없이 도는 화면이라 예전의 타이머 시뮬레이션으로 떨어진다. 실사업장은 `/builder?placeId=<uuid>`.
|
|
|
|
CORS 는 백엔드 `config.local.toml` 의 `client_url` 이 정한다(쉼표로 여러 오리진).
|
|
vite 가 3000 을 못 잡고 3001·3002 로 옮겨 뜨면 거기서 막히므로 개발 포트 대역을 함께 적어 둔다.
|
|
|
|
## 아직 안 한 것
|
|
|
|
- **폰트 self-host** — `*/public/fonts/PretendardVariable.woff2` 가 없다. 지금은 Noto Sans KR 로 폴백된다
|
|
- **이미지 최적화** — 원본 URL 을 그대로 쓴다. `srcset`/WebP 변환은 media 파이프라인이 붙은 뒤
|
|
- **admin 번들 분할** — 626KB(gzip 185KB). 라우트 단위 `lazy()` 로 나눌 수 있다
|
|
- **사진(media) 연동** — 백엔드에 media 조회 엔드포인트가 없어 빌더의 사진 탭은 실데이터가 비어 있다
|
|
(`stores/builder.ts` 의 `photos: []`). VISION 잡이 붙인 분류·alt 를 읽을 창구가 열리면 채운다
|
|
- **발행 주소** — `sites.domain` 을 채우는 경로가 아직 없다. 그때까지는 상호 슬러그로 주소를 만든다
|