Compare commits

...

9 Commits

Author SHA1 Message Date
Haewon Kam
c0804dd2bd feat(supporters): 서포터즈 사이트 초안 표시를 눈에 띄게 강화
기존 상단 띠 배너("샘플 사이트입니다")는 스크롤하면 사라져 병원이
둘러보는 동안에는 초안인지 최종본인지 판단이 서지 않았다.

- sticky 헤더 안에 "초안" 배지 추가(스크롤해도 계속 보임), 영문판도 동일
- 문구를 "샘플 사이트입니다"에서 상태 중심으로 변경:
  "초안입니다. 검색에 노출되지 않으며, 병원 확인 전입니다."
- 띠 배너 글자 크기·굵기 상향(0.83rem/500 → 0.95rem/600)
- 인쇄 시 배지 숨김(기존 배너 규칙과 동일하게)
- INDEXABLE=true(공개본)에서는 배너·배지 모두 그대로 뜨지 않음

templates/supporters-astro(정본)와 supporters/(현재 작업 사본) 양쪽에
동일하게 반영. 이미 배포된 서포터즈 사이트(예: supporters-jkplastic)에는
재빌드·재배포 전까지 적용되지 않는다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 10:27:26 +09:00
Haewon Kam
890da0ecdf feat(discovery): 진단 리포트에서 개선 제안(Action Plan) 단계 분리
증거(진단)와 처방(할 일)을 한 페이지에 두면 스크롤이 너무 길어져
둘 다 읽기 힘들다는 지적에 따라 단계를 나눴다.

- /actions/:id 신규: Action Plan(P0~P2) + What We Don't Sell + 빌드 제안
  (ActionPlanSection·IntegritySection 신규 컴포넌트, Tag는 두 페이지가
  같이 써서 공용 컴포넌트로 뺌)
- /discovery/🆔 위 섹션 제거, 진단(점수·근거·Scorecard·Rubric)만 남김.
  페이지 맨 아래 중복으로 박혀 있던 확인 항목 패널(ClinicInputsPanel)도
  제거 — /supporters/:id 전용 단계가 이미 있어 두 번 보여줄 필요가 없었다
- 진단 데이터가 없는 병원이 /actions/:id 로 오면 /discovery/:id 로 되돌림
- ClinicNav: 5단계로 확장(진단 리포트→개선 제안→사이트 빌드→사진 확인→
  확인 항목), 다음 단계 플로팅 버튼 추가(기존엔 이전 단계만 있었다)
- App.tsx: 라우트 전환 시 스크롤을 맨 위로 되돌림. 스크롤을 내린 채
  다음 단계를 누르면 새 페이지가 같은 위치(짧으면 빈 하단)에서 시작하던
  문제 수정. 인앱 해시 앵커(#breakdown)가 있으면 건드리지 않음
- 사이트 빌드/확인 항목 화면의 미리보기 버튼을 "초안 사이트 열기"로
  통일(공개 후에는 "공개본 사이트 열기")

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 10:27:12 +09:00
Haewon Kam
36731d3ad1 feat(discovery): JK성형외과 영문 기준 AEO/GEO 진단 추가
- 영문 사이트(jkplastic.com/en) 기준 v1.0 45/C, AEO 40/D, GEO 27/D
- audit_aeo_geo.py 자동 채점(21항목) + 영문 페이지 11개 원본 실측 보정
  - 자동 채점의 국문 전제 정규식이 놓친 B4(NAP)·C4(수치)·F1(alt) 보정
  - 외부 실측 가능한 C1·C4·C7·C8·D1·D3 확정, 오너 계정 필요한
    A6·D2·D4·E1~E4는 미검증으로 분모 제외
- discoveryResults.ts 레지스트리에 jkplastic 등록

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 10:26:51 +09:00
Haewon Kam
11c4bb24b3 docs: 2026-09-18 핸드오버와 다음 세션 프롬프트
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 17:27:50 +09:00
Haewon Kam
f38c0f3bda feat(front): 진단 요약과 보완 계획을 보여 준 뒤 빌드하게 한다
버튼만 두면 무엇에 동의하는지 모른 채 누르게 된다. 진단을 요약하고, 이 빌드가 무엇을
보완하는지 말한 뒤 누르게 한다.

처음 만든 판은 실행 항목을 카테고리로 갈라 "이 빌드로 채워지는 항목 5건"이라고 했는데
그 5건이 깨진 JSON-LD 수정·GBP 전화번호 등록·H1 재설계처럼 전부 병원 홈페이지 몫이었다.
카테고리는 무엇에 관한 문제인지를 말할 뿐 누가 고치는지를 말하지 않는다. 지키지 못할
약속이라 프레임을 바꿨다.

빌드는 홈페이지를 한 줄도 고치지 않는다. AI가 인용할 새 표면을 하나 더 만들어 약한
카테고리(콘텐츠·엔티티·표면)를 우회 보완할 뿐이다. 화면도 그렇게 말한다.
"이 빌드가 대신할 수 없는 것"으로 실행 항목이 그대로 병원 몫임을 밝힌다.

- 다크 섹션 위 주 버튼을 .btn-on-dark 로 뺀다. 기본 CTA 그라디언트(#4F1DA1→#021341)는
  끝 색이 배경(#0A1128)과 거의 같아 버튼으로 읽히지 않았다. 밝은 보라와 글로우로 떼어 놓는다.
- 문구에서 "결과"를 뺀다. 빌드가 끝나도 사진 확인과 확인 항목이 남아 최종이 아니고,
  누른 적 없는 사람에게 "결과 보기"부터 보이는 것도 맞지 않는다.
- 이전 단계로 가는 플로팅 버튼을 왼쪽 아래에 고정한다. 오른쪽 아래는 주 행동 자리이고,
  좁은 화면에서는 상단 절차 메뉴가 가로 스크롤 안으로 밀린다. 첫 단계에서는 띄우지 않는다.
- 떠 있던 승인 바와 BuildSiteCta 는 이 섹션과 역할이 겹쳐 지운다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 17:25:50 +09:00
Haewon Kam
14c4b82155 refactor(design): 요청문과 설명문을 텍스트 역할 토큰으로 가른다
"한 장씩 봐 주세요" 같은 요청문이 읽고 넘어가는 설명문과 같은 무게로 놓여 있어
지나치기 쉬웠다. 역할별 토큰과 클래스를 두고 화면에 적용한다.

  .ui-ask   요청문. 사람이 무엇을 해야 하는지 말하는 문장 (17px·600·인디고)
  .ui-ask-block  문단으로 서는 요청문. 왼쪽 규칙선으로 설명과 구분한다
  .ui-body  설명 본문 (16px·slate-600)
  .ui-note  보조·출처 (14px·slate-400)

크기는 기존 화면을 세어 맞췄다. text-base 167회·text-sm 58회가 지배적이라
body 를 16px, note 를 14px 로 두면 치환해도 크기가 변하지 않고 역할과 색만 정리된다.

한글 줄바꿈: word-break keep-all 로 어절을 지키고, 긴 URL·영문 토큰만
overflow-wrap anywhere 로 자른다. text-wrap 으로 마지막 줄에 한 어절만 남는 것을
줄이고, 62ch 로 줄 길이를 묶는다. 제목에도 keep-all 과 balance 를 건다.

다크 섹션은 .on-dark 로 값을 뒤집고, 그 안의 흰 카드는 .on-light 로 되돌린다.
이걸 두지 않으면 흰 글씨가 흰 배경에 얹혀 보이지 않는다(디자인 시스템 기록된 실수).

사진 확인 화면의 썸네일 줄이 누를 수 있는 것으로 안 읽혀 같이 고친다. 72px 카드,
호버 반응, 순번 배지, 현재 위치 표시, 안 정한 것 아래 띠, 범례, 이전·다음 이동을 둔다.
상태가 안 보이는 것이 크기보다 큰 문제였다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:56:52 +09:00
Haewon Kam
be1ea72fcc feat(front): 진단 리포트에서 사이트 빌드로 이어지는 화면
예전 전략마케팅 툴은 분석 진행 화면에서 단계를 보여 주고 리포트로 넘어갔다.
지금 버전은 진단과 사이트 빌드가 서로 모르는 상태로 떨어져 있어, 진단을 읽은 병원이
다음에 무엇을 하는지 알 수 없었다. 진단 → 빌드 → 사진 확인 → 확인 항목을 한 줄로 잇는다.

- /build/:id 를 추가한다. 예전 /report/loading 의 단계 UX(스피너·체크·진행바)를 잇되,
  진행 상황을 프런트가 만들지 않고 supporter_builds 를 폴링해 워커가 쓴 값을 읽는다.
  프런트가 파이프라인을 돌리지 않으므로 창을 닫아도 빌드는 계속되고, 재개 로직이 필요 없다.
- 워커 17단계를 고객이 읽을 수 있는 5묶음으로 접는다(src/lib/buildPhases.ts). 진행률은
  묶음이 아니라 끝난 워커 단계 수로 세어 한 묶음 안에서도 바가 움직인다.
- 경고로 끝난 단계는 완료 화면에 "확인 대기로 발행된 부분"으로 남긴다. 자동으로 못 채운
  값을 감추지 않는다. 멈춤은 발행 게이트와 실행 실패를 구분해 문구를 나눈다.
- 진단 리포트의 Action Plan 뒤에 빌드 구간을 둔다. 무엇을 만들어 주는지와 버튼만 둔다.
  버튼은 supporter_builds 에 queued 를 넣고 폴러가 집어 간다.
- 상단 메뉴를 경로로 가른다. 병원 작업 경로에서는 마케팅 메뉴 대신 4단계 절차를 띄운다.
  Pricing·Use Cases 는 사진을 고르는 중인 병원에게 방해다.
- 진단 데이터가 없는 병원(빌드만 한 곳)은 막다른 길 대신 빌드·사진 확인으로 잇는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:56:34 +09:00
Haewon Kam
74c70d656e docs(design): 신청 접수 알림과 완료 안내 메일 설계 v0.1
랜딩 신청 모달은 남긴 연락처로 안내한다고 말하는데 보내는 코드가 없다.
운영자도 신청이 들어온 사실을 알 방법이 없다. discovery_leads 는 anon insert 만
열려 있고 폴러는 supporter_builds 만 본다.

메일을 셋으로 나눈다. 접수 확인(신청자)과 신규 신청 알림(운영자)은 insert 트리거로
자동 보내고, 완료 안내는 사람이 보낸다. 자동으로 보내면 사진 검토를 건너뛴 링크가
나간다. 발송은 Edge Function 에 두고 프런트에서 부르지 않는다(anon 키 스팸 경로).
email_log 로 중복 발송을 막고 안 갔다는 문의에 답할 근거를 남긴다.
연락처가 전화번호일 수 있어 contact_kind 를 함께 저장한다.

발송 서비스 계정·발신 도메인·"리포트"의 정의는 결정 대기로 8절에 적었다.

개인 업무 문서(주간보고)는 저장소에 올리지 않는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:22:28 +09:00
Haewon Kam
5cf28d9eef feat(front): 병원이 사이트에 실을 사진을 한 장씩 고르는 화면
싣기 어려운 사진(환자·전후 비교·시술 장면·효과 주장 그래픽)은 파일명이나
설명문으로 걸러지지 않아 사람이 눈으로 봐야 한다. 그 과정이 제품 밖에 있어
빌드마다 검토서를 따로 만들어 보냈다.

/images/:clinicId 를 추가한다. supporter_builds.report.images 를 읽어 미리보기
사이트의 사진을 한 장씩 보여 주고, 사용·제외를 supporter_inputs(key=image_review)
에 쌓는다. 워커가 apply_inputs 에서 읽어 다음 빌드에 반영한다.

- 첫 화면 대표 → 사이트에 실림 → 아직 안 쓰임 순으로 정렬한다. 중간에 그만둬도
  실제로 노출되는 사진은 다 보게 된다
- 판정 기준은 법 조항이 아니라 눈으로 확인할 수 있는 질문으로 적는다
- 중간 저장한다. 같은 key 는 최신 행이 유효값이라 다시 들어오면 이어서 본다
- 사진 파일은 preview_url 에서 불러온다. 배포 전 빌드는 안내 문구로 빠진다

대상이 병원 담당자라 제작 과정과 내부 규칙은 화면에 쓰지 않는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:04:25 +09:00
45 changed files with 2293 additions and 209 deletions

4
.gitignore vendored
View File

@ -17,3 +17,7 @@ src/data/db/
# 파이썬 캐시
__pycache__/
# 개인 업무 문서 (주간보고 등). 저장소에 올리지 않는다
docs/*주간보고*
docs/*weekly_report*

View File

@ -0,0 +1,172 @@
# 설계: 신청 접수 알림과 완료 안내 메일 v0.1
작성 2026-09-18 · 대상 AI Discovery 랜딩(`/discovery`) 신청 흐름
---
## 1. 지금 무엇이 비어 있나
랜딩 신청 모달은 이렇게 말합니다.
> 입력하신 URL을 실측 진단한 뒤, 남겨주신 연락처로 리포트를 안내드립니다.
**보내는 코드가 없습니다.** `workers/`, `scripts/`, `supabase/functions/` 전체에 메일 발송이 없습니다. 유일한 `mailto:`는 저장 실패 시 사람이 직접 메일을 쓰라고 띄우는 폴백입니다(`DiscoveryLeadModal.tsx:82`).
그래서 지금은 이렇게 됩니다.
```
신청 → discovery_leads (status: new)
→ supporter_builds 큐 → 워커가 빌드 → preview
→ 여기서 끝. 신청자에게도, 운영자에게도 아무 연락이 없음
```
2026-09-18 JK성형외과 신청이 정상 접수되고 10분 만에 빌드까지 끝났지만, 신청자가 직접 확인하기 전까지 아무도 몰랐습니다.
**문제는 두 갈래입니다.**
| | 문제 |
|---|---|
| 운영자 | 신청이 들어온 사실을 알 방법이 없습니다. `discovery_leads`는 anon insert만 열려 있고 조회는 service_role 전용이며, 폴러는 `supporter_builds`만 봅니다 |
| 신청자 | 화면이 약속한 안내를 받지 못합니다 |
---
## 2. 설계 원칙
**원칙 1. 자동 발송은 접수 알림까지만 한다.**
완료 안내를 자동으로 보내면 사람 검토를 건너뛴 결과물 링크가 나갑니다. 이번 JK 건에서 첫 화면 대표 사진이 사람 확인이 필요한 사진이었던 것을 생각하면, 자동 발송이 아니었던 것이 결과적으로 안전했습니다. 완료 안내는 **사람이 보내기를 누를 때** 나갑니다.
**원칙 2. 화면의 약속과 코드를 맞춘다.**
지금 모달 문구는 자동으로 갈 것처럼 읽힙니다. 실제 동작에 맞게 문구를 고치거나, 동작을 문구에 맞추거나 둘 중 하나를 해야 합니다. 이 설계는 후자에 가깝되 검토 단계를 명시하는 쪽입니다.
**원칙 3. "리포트"라는 말을 정확히 쓴다.**
현재 파이프라인이 만드는 것은 **서포터즈 사이트**입니다. 랜딩이 말하는 AEO/GEO 36항목 진단 리포트(`/discovery/viewclinic` 형태)는 이 빌드에서 생성되지 않습니다. 신청 한 건이 서로 다른 두 산출물로 갈라져 있어, 메일 문안에서 무엇을 보내는지 분명히 해야 합니다.
---
## 3. 보내는 메일 3종
### 3-1. 접수 확인 (신청자에게, 자동)
- **시점**: `discovery_leads` insert 직후
- **받는 사람**: 신청자가 남긴 연락처
- **내용**: 접수 사실, 어떤 URL로 접수됐는지, 언제쯤 연락이 가는지, 문의처(`o2oteam@o2o.kr`)
- **보내지 않는 것**: 결과 링크, 진행률
### 3-2. 신규 신청 알림 (운영자에게, 자동)
- **시점**: 3-1과 동시
- **받는 사람**: 운영 담당 주소 1개
- **내용**: 병원명·URL·연락처, 빌드 큐 등록 여부, 확인 화면 링크(`/images/<clinic_id>`, `/supporters/<clinic_id>`)
- **목적**: 신청을 놓치지 않는 최소 장치. 이것만 있어도 지금 상태보다 낫습니다
### 3-3. 완료 안내 (신청자에게, 사람이 보냄)
- **시점**: 운영자가 이미지 검토와 결과 확인을 마치고 **보내기를 눌렀을 때**
- **받는 사람**: 신청자
- **내용**: 무엇을 만들었는지, 미리보기 링크(noindex), 병원이 확인해야 할 것 두 가지(`/images/<id>` 사진 확인, `/supporters/<id>` 확인 항목), 색인 전환은 법무 확인 뒤라는 안내
- **전제**: 발송 전에 사진 검토가 끝나 있어야 합니다
---
## 4. 무엇으로 보내나
### 4-1. 발송 수단
**현재 메일 발송 키가 하나도 없습니다.** `.env`에 Resend·SendGrid·SES·SMTP 어느 것도 없어 계정부터 필요합니다.
| 후보 | 장점 | 단점 |
|---|---|---|
| **Resend** (추천) | Edge Function에서 fetch 한 번. 도메인 인증이 단순. 무료 한도로 이 규모는 충분 | 계정·도메인 인증 필요 |
| SendGrid | 전달률 기록이 길다 | 설정 항목이 많다 |
| AWS SES | 단가가 가장 낮다 | 샌드박스 해제 심사가 필요 |
발신 도메인은 `o2o.kr` 계열을 쓰고, 신규 도메인은 초기 전달률이 낮으므로 SPF·DKIM·DMARC를 먼저 맞춥니다.
### 4-2. 발송 위치
**Supabase Edge Function `notify-lead` 를 새로 만듭니다.**
프런트에서 직접 부르지 않습니다. 프런트가 부르면 anon 키로 메일을 쏠 수 있게 되어 스팸 경로가 됩니다. DB 변경을 신호로 삼습니다.
```
discovery_leads INSERT
→ Postgres 트리거 (pg_net)
→ Edge Function notify-lead (service_role 검증)
→ Resend 호출 2건 (신청자 접수 확인 + 운영자 알림)
→ email_log 기록
```
완료 안내(3-3)는 같은 함수의 다른 진입점으로 두고, 운영자 화면의 버튼에서 호출합니다.
---
## 5. 필요한 DB 변경
### 5-1. `email_log` 신설
무엇을 언제 누구에게 보냈는지 남깁니다. 중복 발송을 막고, 안 갔다는 문의에 답할 근거가 됩니다.
| 열 | 용도 |
|---|---|
| `id` | |
| `kind` | `lead_ack` · `lead_notify` · `build_done` |
| `lead_id` / `clinic_id` | 대상 |
| `to_addr` | 받는 주소 |
| `status` | `sent` · `failed` |
| `provider_id` | 발송 서비스가 준 id. 전달 추적용 |
| `error` | 실패 사유 |
| `created_at` | |
중복 발송 방지는 `(kind, lead_id)` 유니크로 겁니다.
### 5-2. `discovery_leads.status` 를 실제로 쓴다
지금은 모든 행이 `new`로 고정입니다. 흐름을 담도록 값을 정합니다.
```
new → notified(접수 메일 발송) → building → review(사람 검토 중)
→ delivered(완료 안내 발송) → closed
```
### 5-3. 연락처가 이메일이 아닐 수 있다
모달은 "이메일 또는 전화번호"를 받습니다. 전화번호면 메일을 보낼 수 없습니다.
- 저장 시 형식을 판별해 `contact_kind`(`email` · `phone`)를 같이 남깁니다
- `phone`이면 접수 메일을 건너뛰고 운영자 알림에 **전화로 연락 필요**를 표시합니다
---
## 6. 화면에서 고칠 것
| 위치 | 지금 | 바꿀 안 |
|---|---|---|
| 신청 모달 완료 문구 | "실측 진단한 뒤, 남겨주신 연락처로 리포트를 안내드립니다" | "접수됐습니다. 확인 메일을 보내 드렸습니다. 결과가 준비되면 남겨주신 연락처로 안내드립니다" |
| 운영자 화면 | 없음 | 완료 안내 발송 버튼. 사진 검토가 끝나지 않았으면 버튼을 잠그고 이유를 표시 |
---
## 7. 만들 순서
| 순서 | 할 일 | 비고 |
|---|---|---|
| 1 | 발송 서비스 계정과 도메인 인증 | **haewon 결정 필요.** 이것 없이는 아무것도 못 보냅니다 |
| 2 | `email_log` 테이블, `contact_kind` 열 추가 | |
| 3 | Edge Function `notify-lead` + 트리거 | 3-1·3-2 자동 발송 |
| 4 | 모달 문구 수정 | 1~3과 무관하게 지금 할 수 있습니다 |
| 5 | 완료 안내 발송 버튼 | 사진 검토 완료 조건과 묶습니다 |
**1번이 없으면 2~3번이 무의미하므로, 4번을 먼저 넣어 화면과 코드의 어긋남부터 없애는 방법도 있습니다.**
---
## 8. haewon 결정이 필요한 것
1. **발송 서비스**: Resend를 추천합니다. 계정과 발신 도메인(`o2o.kr` 하위 무엇으로 할지)이 필요합니다
2. **운영자 알림 수신 주소**: 개인 주소인지 공용 주소인지
3. **완료 안내를 사람이 보내는 방식으로 할지**: 자동 발송으로 하면 사진 검토를 건너뛴 링크가 나갈 수 있습니다. 이 설계는 사람이 보내는 쪽을 전제합니다
4. **"리포트"의 정의**: 완료 안내로 보내는 것이 서포터즈 사이트인지, AEO/GEO 진단 리포트인지, 둘 다인지. 지금 파이프라인은 앞의 것만 만듭니다

171
docs/HANDOVER_2026-09-18.md Normal file
View File

@ -0,0 +1,171 @@
# 핸드오버 · 2026-09-18 (금)
다음 작업은 2026-09-19(토) 또는 다음 주. 이 문서 하나만 읽으면 이어서 할 수 있게 적는다.
---
## 0. 오늘 한 일 한 줄
랜딩에서 들어온 JK성형외과 신청을 자동 빌드로 완주시키고, 그 과정에서 드러난 파이프라인 결함 6건을 고쳤다. 그리고 진단 리포트에서 사이트 빌드까지 이어지는 제품 화면을 새로 만들었다.
---
## 1. 지금 상태
### 저장소 두 곳, 커밋 완료
**Productize-2** (`main`) · 프런트엔드
```
f38c0f3 feat(front): 진단 요약과 보완 계획을 보여 준 뒤 빌드하게 한다
14c4b82 refactor(design): 요청문과 설명문을 텍스트 역할 토큰으로 가른다
be1ea72 feat(front): 진단 리포트에서 사이트 빌드로 이어지는 화면
74c70d6 docs(design): 신청 접수 알림과 완료 안내 메일 설계 v0.1
5cf28d9 feat(front): 병원이 사이트에 실을 사진을 한 장씩 고르는 화면
```
**Productize** (`feature/supporters-autobuild`) · 파이프라인
```
1eb166e fix(worker): 병원 입력을 큐 빌드에서도 반영하고, 이미지 확인 결과를 사이트에 적용한다
fa48d43 fix(supporters): 의료진 목록 페이지를 사람별로 나누고 지번 주소도 좌표로 바꾼다
```
**푸시하지 않았다.** 두 저장소 모두 로컬에만 있다.
`f38c0f3` 은 haewon 육안 확인 전에 커밋했다. 퇴근 시점에 작업 트리를 비워 두려고 먼저 넣은 것이라, 마음에 안 들면 `git reset --soft HEAD~1` 로 되돌리면 된다.
### 추적하지 않는 파일 2개
`scripts/out/naver_briefing_summary.json`, `scripts/out/sentiment_summary.json` 는 09-15 측정 산출물이고 오늘 세션 것이 아니라 그대로 뒀다. 주간보고가 이 파일을 근거로 인용하므로 보고서를 공유할 계획이면 같이 커밋해야 한다.
### 배포
| 대상 | 주소 | 상태 |
|---|---|---|
| JK성형외과 서포터즈 | https://supporters-jkplastic.vercel.app | 배포됨 · noindex · 의료진 9명·주변 안내 123곳 반영 |
| 프런트엔드 | 배포 안 함 | 오늘 만든 화면은 로컬에만 있다 |
---
## 2. 오늘 고친 파이프라인 결함 6건
| # | 결함 | 상태 |
|---|---|---|
| 1 | 의료진 목록 페이지를 사람별로 못 나눔. 9명이 "과전문의"라는 없는 사람 1명으로 합쳐짐 | 고침 |
| 2 | 지번 주소를 좌표로 못 바꿔 주변 안내 전체가 빔 | 고침 |
| 3 | 글이 예전 의료진 id를 들고 있으면 astro build 중단 | 고침 |
| 4 | `--deploy` 없는 부분 재실행이 `preview_url` 을 지움 | 고침 |
| 5 | 큐 워커가 `--from-supabase` 를 안 넘겨 **병원 입력이 하나도 반영되지 않던 것** | 고침 |
| 6 | 이미지 수집 개수 중복 계산 (36장을 51장으로 보고) | 고침 |
**수정 효과 (JK 재실행 실측)**
| 항목 | 전 | 후 |
|---|---|---|
| 의료진 | 1명 (가짜 이름) | 9명 (실명·직함·약력 분리) |
| 수집 뉴스 | 958건 | 1,165건 (기고·인터뷰 217 → 387) |
| 주변 안내 | 0곳 | 국문 123곳 · 영문 108곳 |
---
## 3. 오늘 만든 화면
```
/discovery/:id 진단 리포트 → [이대로 빌드하기]
/build/:id 빌드 진행 (5단계, supporter_builds 폴링)
/images/:id 사진 확인 (한 장씩 사용/빼기)
/supporters/:id 확인 항목 입력 (기존)
```
**새 파일**
```
src/pages/SiteBuildPage.tsx 빌드 진행 화면
src/pages/ImageReviewPage.tsx 사진 확인 화면
src/components/discovery/ImageReviewPanel.tsx 사진 확인 본체
src/components/discovery/BuildPlanSection.tsx 진단 요약 → 보완 계획 → 빌드
src/components/ClinicNav.tsx 4단계 절차 상단 메뉴 + 이전 단계 플로팅 버튼
src/lib/buildPhases.ts 워커 17단계 → 고객용 5묶음
src/lib/buildCoverage.ts 빌드가 보완하는 카테고리 선정
```
**설계상 지켜야 할 것 두 가지**
1. **빌드는 병원 홈페이지를 고치지 않는다.** 진단의 실행 항목은 단 하나도 빌드로 해결되지 않는다. 화면에서 "실행 항목 N건 해결"로 쓰면 지키지 못할 약속이 된다. 오늘 한 번 그렇게 만들었다가 고쳤다.
2. **"결과"라는 말을 쓰지 않는다.** 빌드가 끝나도 사진 확인과 확인 항목이 남아 최종이 아니다.
**텍스트 역할 토큰** (`src/index.css`)
```
.ui-ask 요청문 (17px·600·인디고) · .ui-ask-block 은 왼쪽 규칙선
.ui-body 설명 본문 (16px·slate-600)
.ui-note 보조·출처 (14px·slate-400)
.on-dark / .on-light 다크 섹션과 그 안의 흰 카드
.btn-on-dark 다크 섹션 위 주 버튼 (기본 CTA 그라디언트는 흰 배경 전용)
```
---
## 4. 바로 이어서 할 수 있는 것
### 4-1. haewon 육안 확인 (최우선)
`f38c0f3` 의 화면을 아직 안 보셨다. 확인할 것:
- `/discovery/viewclinic` 하단 **What We Will Fix** 섹션의 문구와 버튼 대비
- 네 화면 오가며 **이전 단계 플로팅 버튼** (왼쪽 아래)
- `/images/jkplastic` 썸네일 줄
```bash
cd ~/orca/workspaces/INFINITH/Productize-2 && npm run dev # 포트 3000 사용 중이면 자동으로 다음 포트
```
### 4-2. 분석 리포트의 남은 요소 흡수 (오늘 하던 것)
haewon이 고른 방향은 **"분석 리포트의 좋은 부분을 진단으로 흡수"** 였다. 채널 점수 링·로드맵 타임라인·KPI 세 가지 중 아직 하나도 안 옮겼다. 중간에 "OK 버튼" 이야기로 방향이 틀어져 `BuildPlanSection` 을 먼저 만들었다.
| 요소 | 기존 컴포넌트 | 데이터 |
|---|---|---|
| 채널 건강도 링 | `src/components/report/ChannelOverview.tsx` | `supporter_builds.report.channels` 에 실제 발견 채널이 있다 |
| 로드맵 타임라인 | `src/components/report/RoadmapTimeline.tsx` | `result.actions` 의 P0/P1/P2 + effort 로 시간축을 만들 수 있다 |
| KPI 대시보드 | `src/components/report/KPIDashboard.tsx` | **데이터 없음.** 목표치를 지어내면 안 된다. 재측정 기준으로 바꾸는 편이 맞다 |
로드맵이 가장 쉽고 값이 크다. 데이터가 이미 있고 진단의 Action Plan에 시간축만 없다.
### 4-3. 분석 리포트 자체의 결함 (옮기든 안 옮기든 남는 문제)
`/report/:id` 를 바노바기 리포트로 열어 확인한 것:
- 히어로가 비어 있다. 첫 화면에 그라디언트만 크게 깔리고 제목·병원명이 없다
- `데이터 없음 (NaN년)`. 개원 연도 계산이 깨져 NaN이 화면에 나온다
- 섹션 사이 여백이 화면 하나 가까이 된다
---
## 5. 결정 대기 (haewon)
### 5-1. 메일 (설계 끝, 결정 필요)
`docs/DESIGN_신청알림메일_v0.1.md` §8
1. **발송 서비스**: Resend 추천. 계정과 발신 도메인(`o2o.kr` 하위 무엇으로)
2. **운영자 알림 수신 주소**: 개인인지 공용인지
3. **완료 안내를 사람이 보내는 방식으로 확정할지**
4. **"리포트"의 정의**: 가장 큰 건. 랜딩은 AEO/GEO 진단 리포트를 약속하는데 파이프라인은 서포터즈 사이트를 만든다. 메일 문안을 쓰려면 이걸 먼저 정해야 한다
### 5-2. 진단 데이터가 3곳뿐
`DISCOVERY_RESULTS` 에 viewclinic·taeha·wonjin 만 있다. JK처럼 빌드만 한 병원은 `/discovery/:id` 에서 **No Audit Yet** 화면을 본다(오늘 만들었다). 진단은 36항목 실측이라 자동으로 안 따라온다. 자동화 범위를 어디까지 잡을지 별도 과제.
### 5-3. 기타
- `clinicDoctors` 4명 고정. JK는 9명인데 소개 페이지에 4명만 나온다
- planner가 주변 안내 좌표를 받고도 범용 기본표를 쓴다
- 뷰 의료진 id 두 벌 (`dr-cho` 수작업 10개 vs `dr-chojinwoo` 자동 27개). 뷰 재빌드 때 한쪽으로 모아야 한다
- `check.mjs --update-layout` 으로 레이아웃 기준선 생성
---
## 6. 알아 두면 시간 아끼는 것
- **빌드는 백그라운드로 돌리고 다른 작업을 같이 한다.** `nohup node workers/supporters-build/run.mjs ... &` 로 띄우고 로그를 감시한다. 오늘 내내 그렇게 했다.
- **같은 병원 빌드를 동시에 두 개 돌리면 안 된다.** 작업 폴더(`~/supporters-builds/<clinic>-<id>/`)를 공유해 서로 덮어쓴다.
- **부분 재실행은 `--from <단계>`.** 글은 `--regenerate` 없으면 보존되어 생성 비용이 0이다.
- **워커는 Productize 워크트리에서 돈다.** Productize-2 가 아니다. launchd 폴러 `com.infinith.supporters-poll`, 로그 `~/supporters-builds/poll.log`.
- **주간보고는 gitignore 처리했다** (`docs/*주간보고*`). 개인 문서라 저장소에 올리지 않는다.

View File

@ -0,0 +1,82 @@
# 다음 세션 프롬프트 · 2026-09-19 이후
아래 §0 을 그대로 복사해 새 세션 첫 메시지로 넣으면 된다.
---
## §0. 복사해서 붙일 프롬프트
```
INFINITH AI Discovery 제품 개발을 이어서 한다.
작업 폴더: ~/orca/workspaces/INFINITH/Productize-2 (main)
파이프라인: ~/orca/workspaces/INFINITH/Productize (feature/supporters-autobuild)
시작하기 전에 읽어라:
docs/HANDOVER_2026-09-18.md 어제까지의 상태와 결정 대기 목록
docs/DESIGN_신청알림메일_v0.1.md 메일 설계 (결정 대기 4건은 §8)
두 저장소 모두 커밋은 되어 있고 푸시는 안 했다. 시작할 때 git status 와
다른 세션이 같은 폴더를 열고 있지 않은지 먼저 확인해라.
오늘 할 일은 아래에서 고른다. 1번을 먼저 하고, 내가 확인해 준 뒤 2번으로 간다.
1. 어제 만든 화면 육안 확인
npm run dev 로 띄우고 /discovery/viewclinic, /build/jkplastic,
/images/jkplastic 를 열어 둬라. 내가 본다.
특히 볼 것: What We Will Fix 섹션 문구·버튼 대비, 왼쪽 아래 이전 단계 버튼,
사진 확인 썸네일 줄.
2. 분석 리포트(/report/:id)의 로드맵 타임라인을 진단 리포트로 옮긴다
진단의 Action Plan 은 P0/P1/P2 목록만 있고 시간축이 없다.
기존 RoadmapTimeline 컴포넌트와 result.actions 의 effort 값을 쓴다.
데이터를 지어내지 마라. 없는 값은 확인 대기로 둔다.
지켜야 할 것:
- 서포터즈 빌드는 병원 홈페이지를 고치지 않는다. 진단 실행 항목이
빌드로 해결된다고 화면에 쓰지 마라.
- 고객 화면에 "결과"라는 말을 쓰지 마라. 빌드가 끝나도 사진 확인과
확인 항목이 남아 최종이 아니다.
- 텍스트는 src/index.css 의 .ui-ask / .ui-body / .ui-note 역할 토큰을 쓴다.
다크 섹션은 .on-dark, 그 안의 흰 카드는 .on-light, 버튼은 .btn-on-dark.
- 프로덕션 배포(vercel --prod, 병원 사이트)는 내 확인을 받고 실행한다.
```
---
## §1. 선택지 (위 프롬프트에서 2번 대신 고를 수 있는 것)
우선순위순이 아니라 성격순이다. 무엇을 할지는 haewon이 정한다.
### A. 분석 리포트 요소 흡수 (어제 정한 방향)
| 요소 | 난이도 | 비고 |
|---|---|---|
| 로드맵 타임라인 | 낮음 | 데이터가 이미 있다. 가장 값이 크다 |
| 채널 건강도 링 | 중간 | `supporter_builds.report.channels` 를 쓴다 |
| KPI 대시보드 | 보류 | 목표치 데이터가 없다. 지어내지 말고 재측정 기준으로 바꾸는 편이 맞다 |
### B. 메일 붙이기
결정 4건(설계 §8)이 먼저다. 다만 **순서 4번(모달 문구 수정)은 계정 없이 지금 할 수 있다.** 화면이 약속하는 것과 실제 동작의 어긋남부터 없애는 조치다.
### C. 파이프라인 잔여
- `clinicDoctors` 4명 고정을 병원 규모에 맞출지
- planner가 tourism 좌표를 쓰도록
- 뷰 의료진 id 두 벌 정리 (뷰 재빌드와 묶어서)
- `check.mjs --update-layout` 레이아웃 기준선
### D. 분석 리포트 자체 수정
히어로 공백 · `NaN년` · 과한 여백. 옮기든 접든 남는 문제다.
---
## §2. 다음 측정에서 볼 값
09-15 실측에서 네이버 AI 브리핑이 120문항 중 119문항에 노출되는데 **논브랜드 83건 중 뷰성형외과 언급은 1건**이었다. 브랜드명을 아는 사람에게만 답변이 나오고, 시술이나 지역으로 찾는 사람에게는 도달하지 못한다.
서포터즈 사이트가 이 구간을 움직이는지가 제품의 핵심 증명이다. 뷰·원진·오라클이 색인 허용으로 전환된 뒤 같은 120문항을 다시 돌려 비교한다.
근거: `docs/reports/viewclinic/03_question_bank/Viewclinic_QB_Sentiment.md`, `scripts/out/naver_briefing_summary.json`

View File

@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { Outlet, useLocation } from 'react-router';
import Navbar from './components/Navbar';
import Footer from './components/Footer';
@ -8,6 +9,15 @@ export default function App() {
const location = useLocation();
const isLoadingPage = location.pathname.startsWith('/report/loading');
// 라우트가 바뀌어도 브라우저가 스크롤 위치를 그대로 들고 온다. 진단 리포트처럼 긴 페이지를
// 내려 보다가 "다음 단계"를 누르면, 새로 연 페이지가 그 위치(짧으면 하단 빈 공간)에서 시작해
// 항상 위부터 보게 되지 않는다. 라우트 전환마다 맨 위로 되돌린다.
// 해시(#breakdown 같은 인앱 앵커)가 있으면 그 앵커로 가려는 의도이므로 건드리지 않는다.
useEffect(() => {
if (location.hash) return;
window.scrollTo(0, 0);
}, [location.pathname, location.hash]);
return (
<div className="min-h-screen bg-slate-50 selection:bg-purple-200 selection:text-primary-900">
{!isLoadingPage && <Navbar />}

View File

@ -0,0 +1,152 @@
/**
* .
*
* (Product·Pricing·Use Cases) .
* ,
* . .
*
* . .
*/
import { Link, useLocation } from 'react-router';
import { motion } from 'motion/react';
import { PrismFilled } from './icons/FilledIcons';
type Step = { key: string; label: string; to: (id: string) => string; match: RegExp };
const STEPS: Step[] = [
{ key: 'report', label: '진단 리포트', to: (id) => `/discovery/${id}`, match: /^\/discovery\/[^/]+/ },
{ key: 'actions', label: '개선 제안', to: (id) => `/actions/${id}`, match: /^\/actions\/[^/]+/ },
{ key: 'build', label: '사이트 빌드', to: (id) => `/build/${id}`, match: /^\/build\/[^/]+/ },
{ key: 'images', label: '사진 확인', to: (id) => `/images/${id}`, match: /^\/images\/[^/]+/ },
{ key: 'inputs', label: '확인 항목', to: (id) => `/supporters/${id}`, match: /^\/supporters\/[^/]+/ },
];
/** 작업 공간 경로면 병원 id 를 돌려준다. 아니면 null. */
export function clinicIdFromPath(pathname: string): string | null {
for (const s of STEPS) {
if (s.match.test(pathname)) return pathname.split('/')[2] ?? null;
}
return null;
}
export default function ClinicNav({ clinicId }: { clinicId: string }) {
const { pathname } = useLocation();
const currentIndex = STEPS.findIndex((s) => s.match.test(pathname));
const prev = currentIndex > 0 ? STEPS[currentIndex - 1] : null;
const next = currentIndex > -1 && currentIndex < STEPS.length - 1 ? STEPS[currentIndex + 1] : null;
return (
<>
<nav className="fixed top-0 left-0 right-0 z-50 bg-white/95 border-b border-slate-100 backdrop-blur-md" data-no-print>
<div className="max-w-7xl mx-auto px-6 h-20 flex items-center gap-6">
{/* 로고 + 병원 */}
<Link to="/" className="flex items-center gap-3 shrink-0" aria-label="INFINITH 홈">
<PrismFilled size={22} className="text-[#6C5CE7]" />
<span className="hidden sm:block font-serif text-xl font-black tracking-[0.05em] bg-gradient-to-r from-[#4F1DA1] to-[#021341] bg-clip-text text-transparent">
INFINITH
</span>
</Link>
<span className="hidden md:block w-px h-7 bg-slate-200 shrink-0" />
<span className="hidden md:block text-sm font-semibold text-slate-500 shrink-0 max-w-[160px] truncate" title={clinicId}>
{clinicId}
</span>
{/* 단계 */}
<ol className="flex items-center gap-1 overflow-x-auto ml-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{STEPS.map((s, i) => {
const isCurrent = i === currentIndex;
const isPast = currentIndex > -1 && i < currentIndex;
return (
<li key={s.key} className="flex items-center shrink-0">
{i > 0 && <span className="w-4 h-px bg-slate-200 mx-0.5" aria-hidden="true" />}
<Link
to={s.to(clinicId)}
aria-current={isCurrent ? 'page' : undefined}
className={`group inline-flex items-center gap-2 rounded-full pl-2 pr-3.5 py-2 text-sm font-semibold transition-colors
${isCurrent
? 'bg-[#F3F0FF] text-[#4A3A7C]'
: 'text-slate-500 hover:bg-slate-100 hover:text-[#1D0024]'}`}
>
<span
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold shrink-0 transition-colors
${isCurrent
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white'
: isPast
? 'bg-[#D5CDF5] text-[#4A3A7C]'
: 'bg-slate-100 text-slate-400 group-hover:bg-slate-200'}`}
>
{i + 1}
</span>
<span className="whitespace-nowrap">{s.label}</span>
</Link>
</li>
);
})}
</ol>
</div>
</nav>
{/* . .
. .
. .
,
. . */}
{prev && (
<motion.div
initial={{ opacity: 0, x: -12 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3, duration: 0.3 }}
className="fixed left-4 bottom-4 md:left-6 md:bottom-6 z-40"
data-no-print
>
<Link
to={prev.to(clinicId)}
className="group inline-flex items-center gap-2.5 rounded-full bg-white/95 backdrop-blur-md border border-slate-200
shadow-[0_4px_20px_rgba(29,0,36,0.14)] pl-3 pr-5 py-3 transition-shadow hover:shadow-[0_6px_26px_rgba(29,0,36,0.2)]"
>
<span className="w-7 h-7 rounded-full bg-slate-100 text-[#1D0024] flex items-center justify-center
transition-colors group-hover:bg-[#F3F0FF]">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
<path d="M8.9 1.4 3.3 7l5.6 5.6a1.1 1.1 0 0 0 1.6-1.6L6.5 7l4-4A1.1 1.1 0 0 0 8.9 1.4Z" fill="currentColor" />
</svg>
</span>
<span className="text-left leading-tight">
<span className="block text-[11px] font-semibold text-slate-400"> </span>
<span className="block text-sm font-bold text-[#1D0024] whitespace-nowrap">{prev.label}</span>
</span>
</Link>
</motion.div>
)}
{next && (
<motion.div
initial={{ opacity: 0, x: 12 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3, duration: 0.3 }}
className="fixed right-4 bottom-4 md:right-6 md:bottom-6 z-40"
data-no-print
>
<Link
to={next.to(clinicId)}
className="group inline-flex items-center gap-2.5 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white
shadow-[0_4px_20px_rgba(29,0,36,0.24)] pl-5 pr-3 py-3 transition-shadow hover:shadow-[0_6px_26px_rgba(29,0,36,0.32)]"
>
<span className="text-right leading-tight">
<span className="block text-[11px] font-semibold text-white/60"> </span>
<span className="block text-sm font-bold whitespace-nowrap">{next.label}</span>
</span>
<span className="w-7 h-7 rounded-full bg-white/15 flex items-center justify-center
transition-colors group-hover:bg-white/25">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
<path d="M5.1 1.4 10.7 7l-5.6 5.6a1.1 1.1 0 0 1-1.6-1.6L7.5 7l-4-4A1.1 1.1 0 0 1 5.1 1.4Z" fill="currentColor" />
</svg>
</span>
</Link>
</motion.div>
)}
</>
);
}

View File

@ -18,11 +18,17 @@
* `/pricing` `#solution` /pricing #solution을 .
* `/#solution` React Router가 .
*/
import { Link } from 'react-router';
import { Link, useLocation } from 'react-router';
import { ArrowRight } from 'lucide-react';
import { buildContactMailto } from '../lib/contact';
import ClinicNav, { clinicIdFromPath } from './ClinicNav';
export default function Navbar() {
const { pathname } = useLocation();
// 병원 작업 공간(진단·빌드·사진 확인·확인 항목)에서는 절차 메뉴로 바꾼다.
const clinicId = clinicIdFromPath(pathname);
if (clinicId) return <ClinicNav clinicId={clinicId} />;
return (
<nav className="fixed top-0 left-0 right-0 z-50 bg-white/95 border-b border-slate-100 backdrop-blur-md">
<div className="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">

View File

@ -41,7 +41,7 @@ export default function AbmrLoop({ axes }: AbmrLoopProps) {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.1 }}
className="relative overflow-hidden bg-[#0A1128] rounded-2xl px-8 py-7 flex flex-col items-center text-left"
className="on-dark relative overflow-hidden bg-[#0A1128] rounded-2xl px-8 py-7 flex flex-col items-center text-left"
>
{/* Background glow */}
<div className="absolute top-[45%] left-1/2 -translate-x-1/2 -translate-y-1/2 w-[420px] h-[420px] bg-accent/20 rounded-full blur-[80px] pointer-events-none" />
@ -65,7 +65,7 @@ export default function AbmrLoop({ axes }: AbmrLoopProps) {
</svg>
{/* Center orb */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[2] w-[120px] h-[120px] md:w-[148px] md:h-[148px] rounded-full bg-[#0A1128]/90 border border-white/5 shadow-[0_0_60px_rgba(167,139,250,0.12)] flex flex-col items-center justify-center gap-0.5">
<div className="on-dark absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[2] w-[120px] h-[120px] md:w-[148px] md:h-[148px] rounded-full bg-[#0A1128]/90 border border-white/5 shadow-[0_0_60px_rgba(167,139,250,0.12)] flex flex-col items-center justify-center gap-0.5">
<span className="text-2xl md:text-3xl font-bold bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] bg-clip-text text-transparent">
ABMR
</span>
@ -77,7 +77,7 @@ export default function AbmrLoop({ axes }: AbmrLoopProps) {
{/* Nodes */}
{NODES.map((node) => (
<div key={node.letter} className={`absolute ${node.pos} z-[3] flex flex-col items-center gap-[7px]`}>
<div className="w-12 h-12 md:w-[58px] md:h-[58px] rounded-full border border-purple-500/30 bg-[#0A1128]/80 shadow-[0_0_20px_rgba(168,85,247,0.15)] flex items-center justify-center">
<div className="on-dark w-12 h-12 md:w-[58px] md:h-[58px] rounded-full border border-purple-500/30 bg-[#0A1128]/80 shadow-[0_0_20px_rgba(168,85,247,0.15)] flex items-center justify-center">
<span className="text-xl md:text-[25px] font-bold text-purple-300">{node.letter}</span>
</div>
<span className="text-sm font-medium text-purple-100">{node.label}</span>

View File

@ -0,0 +1,67 @@
/**
* (/actions/:id) 1부: 우선순위별 .
* "Action Plan" . () ( )
* . .
*/
import { motion } from 'motion/react';
import { SectionWrapper } from '../report/ui/SectionWrapper';
import { Tag } from './Tag';
import type { ActionPriority, DiscoveryResult } from '../../types/discovery';
const PRIORITY_STYLE: Record<ActionPriority, string> = {
P0: 'bg-[#FFF0F0] text-[#7C3A4B] border-[#F5D5DC]',
P1: 'bg-[#FFF6ED] text-[#7C5C3A] border-[#F5E0C5]',
P2: 'bg-[#F3F0FF] text-[#4A3A7C] border-[#D5CDF5]',
};
const PRIORITY_HEADING: Record<ActionPriority, string> = {
P0: '즉시: 크롤러 접근·엔티티·측정',
P1: '4주 내: 콘텐츠 구조',
P2: '지속: 신선도·시딩',
};
export function ActionPlanSection({ result }: { result: DiscoveryResult }) {
return (
<SectionWrapper
id="actions"
title="Action Plan"
subtitle="우선순위별 실행 항목. 각 항목이 어느 기준을 올리는지 연결"
>
<div className="space-y-4">
{(['P0', 'P1', 'P2'] as ActionPriority[]).map((p) => (
<div key={p}>
<div className="flex items-center gap-3 mb-3 mt-6 first:mt-0">
<span className={`inline-flex items-center rounded-full text-sm font-semibold px-3 py-1 border ${PRIORITY_STYLE[p]}`}>
{p}
</span>
<span className="text-lg font-bold text-[#0A1128]">{PRIORITY_HEADING[p]}</span>
</div>
<div className="grid md:grid-cols-2 gap-4">
{result.actions.filter((a) => a.priority === p).map((a, i) => (
<motion.div
key={a.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.08 }}
className="on-light bg-white rounded-2xl border border-slate-100 shadow-sm p-5"
>
<div className="flex items-start justify-between gap-3 mb-2">
<h4 className="font-bold text-[#0A1128]">{a.title}</h4>
<span className="ui-note shrink-0">{a.effort}</span>
</div>
<p className="ui-body mb-3">{a.detail}</p>
<div className="flex flex-wrap gap-1.5">
{a.criterionIds.map((cid) => (
<Tag key={cid}>{cid}</Tag>
))}
</div>
</motion.div>
))}
</div>
</div>
))}
</div>
</SectionWrapper>
);
}

View File

@ -16,14 +16,14 @@ function ItemRow({ it }: { it: ItemScoreV2; key?: string }) {
return (
<div className="py-4 grid md:grid-cols-[200px_1fr_120px] gap-3 items-start">
<div>
<span className="text-sm font-semibold text-slate-400">{it.item.id}</span>
<span className="ui-note font-semibold">{it.item.id}</span>
<p className="text-base font-bold text-[#0A1128]">{it.item.name}</p>
<p className="text-sm text-slate-400 mt-0.5">{it.item.weight} · {METHOD_LABEL[it.item.method]}</p>
<p className="ui-note mt-0.5">{it.item.weight} · {METHOD_LABEL[it.item.method]}</p>
</div>
<div>
<p className="text-base text-slate-700">{it.item.question}</p>
{it.evidence && <p className="text-sm text-slate-500 mt-1">{it.evidence}</p>}
<p className="text-sm text-slate-400 mt-1">
<p className="ui-body">{it.item.question}</p>
{it.evidence && <p className="ui-note mt-1">{it.evidence}</p>}
<p className="ui-note mt-1">
{BASIS_LABEL[it.basis]}
{it.signals.length > 0 && ` · ${it.signals.map((s) => `${s.criterionId}=${s.level === 'unverified' ? '미검증' : s.level}`).join(' · ')}`}
</p>
@ -51,7 +51,7 @@ function AxisCard({ ax }: { ax: AxisScoreV2 }) {
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-sm text-slate-400"> {ax.verifiedCount}/{ax.items.length} · {ax.earned} / {ax.possible}</p>
<p className="ui-note"> {ax.verifiedCount}/{ax.items.length} · {ax.earned} / {ax.possible}</p>
{ax.cappedBy.length > 0 && (
<p className="text-sm font-semibold text-[#C084CF]"> {Math.min(...ax.cappedBy.map((g) => g.gate.cap))} ( {ax.rawScore})</p>
)}
@ -60,7 +60,7 @@ function AxisCard({ ax }: { ax: AxisScoreV2 }) {
<ScoreRing score={ax.score} size={96} />
</div>
</div>
<p className="text-base text-slate-600 mb-3">{ax.axis.description}</p>
<p className="ui-body mb-3">{ax.axis.description}</p>
<div className="divide-y divide-slate-100">
{ax.items.map((it) => <ItemRow key={it.item.id} it={it} />)}
</div>
@ -81,8 +81,8 @@ export function AeoGeoV2Panel({ rubric, score }: { rubric: RubricV2; score: Over
<ScoreRing score={ax.score} size={120} label={ax.axis.nameEn} />
<div>
<p className="font-serif text-2xl font-bold text-[#1D0024]">{ax.axis.code} · {ax.axis.name}</p>
<p className="text-base text-slate-600 mt-1">{ax.axis.question}</p>
<p className="text-sm text-slate-400 mt-2">
<p className="ui-body mt-1">{ax.axis.question}</p>
<p className="ui-note mt-2">
<span className="font-bold text-[#0A1128]">{ax.grade}</span> · {ax.verifiedCount}/{ax.items.length}
{ax.cappedBy.length > 0 && ` · 게이트 상한 적용 (원점수 ${ax.rawScore})`}
</p>
@ -95,7 +95,7 @@ export function AeoGeoV2Panel({ rubric, score }: { rubric: RubricV2; score: Over
<p className="text-base font-bold text-[#0A1128] mb-2"> ( )</p>
<ul className="grid md:grid-cols-2 gap-2">
{score.gates.map((g) => (
<li key={g.gate.id} className="text-base text-slate-700">
<li key={g.gate.id} className="ui-body">
<span className={`font-bold ${g.passed ? 'text-[#6C5CE7]' : 'text-[#C084CF]'}`}>{g.gate.id} {g.gate.name} · {g.passed ? '통과' : `실패 (${g.failedBy.join(', ')}) → 상한 ${g.gate.cap}`}</span>
<span className="text-slate-500"> {g.gate.reason}</span>
</li>

View File

@ -38,7 +38,7 @@ export default function AiAnswerMock({ clinicName, criteriaCount }: AiAnswerMock
<circle cx="9" cy="9" r="5.5" stroke="#64748b" strokeWidth="1.5" />
<path d="M13.5 13.5 17 17" stroke="#64748b" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<span className="text-base font-medium text-slate-700 break-keep">
<span className="ui-body font-medium">
</span>
</div>

View File

@ -0,0 +1,179 @@
/**
* .
*
* . , .
*
* : 빌드는 .
* . .
* AI가 .
* "실행 항목 N건 해결" .
*/
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { motion } from 'motion/react';
import { supabase } from '../../lib/supabase';
import { STATE_LABEL } from '../../lib/buildPhases';
import type { BuildState } from '../../lib/buildPhases';
import { weakSpots } from '../../lib/buildCoverage';
import type { DiscoveryResult, OverallScore } from '../../types/discovery';
import { CheckFilled, WarningFilled } from '../icons/FilledIcons';
import { ArrowRight } from 'lucide-react';
type Props = {
result: DiscoveryResult;
overall: OverallScore;
summary: { rubricVersion: string; grade: string; score: number; aeo?: number; geo?: number; verified: number; total: number };
};
export function BuildPlanSection({ result, overall, summary }: Props) {
const navigate = useNavigate();
const [existing, setExisting] = useState<BuildState | null>(null);
const [checking, setChecking] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const weak = weakSpots(overall.categories);
const critical = result.keyFindings.filter((f) => f.severity === 'critical');
const p0 = result.actions.filter((a) => a.priority === 'P0');
useEffect(() => {
let alive = true;
void (async () => {
const { data } = await supabase.from('supporter_builds').select('status')
.eq('clinic_id', result.id).order('created_at', { ascending: false }).limit(1);
if (!alive) return;
setExisting((data?.[0]?.status as BuildState) ?? null);
setChecking(false);
})();
return () => { alive = false; };
}, [result.id]);
async function approve() {
setBusy(true); setError(null);
await supabase.from('supporter_inputs').insert({
clinic_id: result.id, key: 'report_approval',
value: { ...summary, weakSpots: weak.map((w) => w.id), approvedAt: new Date().toISOString() },
});
const { error: e } = await supabase.from('supporter_builds').insert({
clinic_id: result.id, clinic_name: result.clinicName, url: result.url, status: 'queued',
});
setBusy(false);
if (e) { setError(e.message); return; }
navigate(`/build/${result.id}`);
}
const inFlight = existing === 'queued' || existing === 'running';
return (
<section className="on-dark bg-[#0A1128] text-white relative overflow-hidden py-16 md:py-20 px-6" data-no-print>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,rgba(108,92,231,0.16),transparent_60%)]" />
<div className="relative max-w-5xl mx-auto">
<h2 className="font-serif text-3xl md:text-5xl font-bold tracking-[-0.02em] mb-8">
<span className="bg-gradient-to-r from-purple-300 to-blue-300 bg-clip-text text-transparent">What We Will Fix</span>
</h2>
{/* 1. 진단 요약 */}
<div className="on-light bg-white rounded-2xl p-6 md:p-7 shadow-[3px_4px_12px_rgba(0,0,0,0.2)] mb-6">
<p className="ui-note mb-3"> · {result.auditedAt} </p>
<div className="flex flex-wrap items-baseline gap-x-8 gap-y-3 mb-4">
<span className="text-[#1D0024]">
<strong className="font-serif text-4xl font-black">{summary.grade}</strong>
<span className="ui-note ml-2"> {summary.score}</span>
</span>
{typeof summary.aeo === 'number' && (
<span className="text-[#1D0024]"><strong className="text-2xl font-bold">{summary.aeo}</strong><span className="ui-note ml-1.5">AEO </span></span>
)}
{typeof summary.geo === 'number' && (
<span className="text-[#1D0024]"><strong className="text-2xl font-bold">{summary.geo}</strong><span className="ui-note ml-1.5">GEO </span></span>
)}
<span className="ui-note"> {summary.verified} / {summary.total}</span>
</div>
{critical.length > 0 && (
<ul className="flex flex-col gap-1.5 pt-4 border-t border-slate-100">
{critical.slice(0, 3).map((f) => (
<li key={f.title} className="ui-body !text-[#1D0024] flex gap-2">
<span className="mt-[9px] w-1.5 h-1.5 rounded-full bg-[#D4889A] shrink-0" />
<span>{f.title}</span>
</li>
))}
</ul>
)}
</div>
{/* 2. 이렇게 보완하겠습니다 */}
<p className="ui-ask ui-ask-block mb-5">
, AI가 .
</p>
<div className="grid md:grid-cols-3 gap-4 mb-8">
{weak.map((w, i) => (
<motion.div
key={w.id}
initial={{ opacity: 0, y: 18 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.08 }}
className="on-light bg-white rounded-2xl p-5 shadow-[3px_4px_12px_rgba(0,0,0,0.2)]"
>
<div className="flex items-baseline justify-between gap-2 mb-3">
<span className="ui-note">{w.name}</span>
<span className="font-serif text-2xl font-black text-[#1D0024]">{w.pct}<span className="ui-note">%</span></span>
</div>
<p className="ui-note mb-1"></p>
<h3 className="font-bold text-[#1D0024] mb-3">{w.now}</h3>
<p className="ui-note mb-1"></p>
<p className="ui-body">{w.fix}</p>
</motion.div>
))}
</div>
{/* 3. 빌드가 대신할 수 없는 것 */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 mb-8">
<div className="flex items-center gap-2 mb-3 text-[#F5E0C5]">
<WarningFilled size={15} />
<h3 className="text-base font-bold"> </h3>
</div>
<p className="ui-body mb-3">
, .{' '}
{result.actions.length} .
</p>
<ul className="flex flex-col gap-1.5">
{p0.map((a) => (
<li key={a.title} className="ui-note"><span className="font-semibold text-[#F5E0C5] mr-1.5">P0</span>{a.title}</li>
))}
</ul>
</div>
{/* 4.
. CTA (#4F1DA1#021341)
(#0A1128) .
"결과" . . */}
<div className="flex flex-wrap items-center gap-3">
{checking ? (
<span className="ui-note"> .</span>
) : existing ? (
<>
<button type="button" onClick={() => navigate(`/build/${result.id}`)}
className="btn-on-dark">
{inFlight ? '만드는 과정 보기' : '만들어진 사이트 보기'} <ArrowRight className="w-5 h-5" />
</button>
{!inFlight && (
<button type="button" onClick={() => void approve()} disabled={busy}
className="rounded-full bg-white/10 border border-white/20 text-white font-semibold px-6 py-4 text-base hover:bg-white/15 transition-colors disabled:opacity-50">
{busy ? '시작하는 중' : '다시 빌드하기'}
</button>
)}
<span className="ui-note">{STATE_LABEL[existing]}</span>
</>
) : (
<button type="button" onClick={() => void approve()} disabled={busy}
className="btn-on-dark">
<CheckFilled size={18} />{busy ? '시작하는 중' : '이대로 빌드하기'}
</button>
)}
</div>
{error && <p className="text-[#F5D5DC] text-base mt-4"> . {error}</p>}
<p className="ui-note mt-5"> . .</p>
</div>
</section>
);
}

View File

@ -97,8 +97,12 @@ export function ClinicInputsPanel({ clinicId }: { clinicId: string }) {
<div className="max-w-7xl mx-auto">
<div className="text-sm font-bold tracking-[0.24em] text-accent mb-4">SUPPORTERS BUILD · </div>
<h2 className="font-serif text-3xl md:text-[40px] font-bold leading-tight text-primary-900 mb-3 break-keep"> </h2>
<p className="text-[17px] leading-[1.7] text-slate-500 mb-10 break-keep">
. , "확인 대기" . .
<p className="ui-body mb-4">
.{' '}
"확인 대기" , .
</p>
<p className="ui-ask ui-ask-block mb-10">
. .
</p>
{/* 빌드 상태 */}
@ -108,12 +112,12 @@ export function ClinicInputsPanel({ clinicId }: { clinicId: string }) {
{build && (
<div className="grid md:grid-cols-[1fr_auto] gap-6 items-start">
<div>
<p className="text-sm text-slate-500 mb-1"></p>
<p className="text-xl font-bold text-primary-900">{STATUS_LABEL[build.status] ?? build.status}{build.phase ? <span className="text-sm font-medium text-slate-500 ml-2"> {build.phase}</span> : null}</p>
<p className="text-sm text-slate-400 mt-1"> {build.updated_at.slice(0, 16).replace('T', ' ')} · {posts.length}{typeof build.report?.totalCostUsd === 'number' ? ` · 생성 비용 $${build.report.totalCostUsd}` : ''}</p>
<p className="ui-note mb-1"></p>
<p className="text-xl font-bold text-primary-900">{STATUS_LABEL[build.status] ?? build.status}{build.phase ? <span className="ui-note font-medium ml-2"> {build.phase}</span> : null}</p>
<p className="ui-note mt-1"> {build.updated_at.slice(0, 16).replace('T', ' ')} · {posts.length}{typeof build.report?.totalCostUsd === 'number' ? ` · 생성 비용 $${build.report.totalCostUsd}` : ''}</p>
</div>
{build.preview_url && (
<a href={build.preview_url} target="_blank" rel="noreferrer" className="inline-flex items-center justify-center rounded-full bg-primary-900 text-white font-semibold px-6 py-3 text-base"> (noindex)</a>
<a href={build.preview_url} target="_blank" rel="noreferrer" className="inline-flex items-center justify-center rounded-full bg-primary-900 text-white font-semibold px-6 py-3 text-base">{build.status === 'published' || build.status === 'approved' ? '공개본 사이트 열기' : '초안 사이트 열기'}</a>
)}
</div>
)}
@ -135,7 +139,7 @@ export function ClinicInputsPanel({ clinicId }: { clinicId: string }) {
<h3 className="font-bold text-lg text-primary-900 break-keep">{f.title}</h3>
<span className={`shrink-0 text-xs font-bold px-2.5 py-1 rounded-full ${cur ? 'bg-[#E9F7EF] text-[#2E7D4F]' : 'bg-[#FFF4E5] text-[#B26A00]'}`}>{cur ? `입력됨 ${String(current(f.key)?.created_at ?? '').slice(0, 10)}` : '확인 대기'}</span>
</div>
<p className="text-sm text-slate-500 mb-4 break-keep">{f.hint}</p>
<p className="ui-body mb-4">{f.hint}</p>
<div className="grid sm:grid-cols-2 gap-3">
{f.inputs.map((inp) => {
const value = draft[inp.name] ?? (cur ? String(Array.isArray(cur[inp.name]) ? (cur[inp.name] as string[]).join(', ') : cur[inp.name] ?? '') : '');
@ -175,7 +179,7 @@ export function ClinicInputsPanel({ clinicId }: { clinicId: string }) {
{build?.report?.channels && (
<div className="bg-white border border-slate-200 rounded-2xl p-6 shadow-[0_8px_32px_rgba(31,38,135,0.05)]">
<h3 className="font-bold text-lg text-primary-900 mb-1"> </h3>
<p className="text-sm text-slate-500 mb-3 break-keep">·· . "확인 필요" .</p>
<p className="ui-note mb-3">·· . "확인 필요" .</p>
<ul className="text-sm flex flex-col gap-2">
{(['youtube', 'instagram', 'facebook', 'naverBlog', 'tiktok', 'kakao', 'gangnamunni', 'naverPlace', 'babitalk'] as const).map((p) => {
const ok = build.report?.channels?.confirmed[p] ?? [];
@ -195,15 +199,15 @@ export function ClinicInputsPanel({ clinicId }: { clinicId: string }) {
)}
<div className="bg-white border border-slate-200 rounded-2xl p-6 shadow-[0_8px_32px_rgba(31,38,135,0.05)]">
<h3 className="font-bold text-lg text-primary-900 mb-1"> ID</h3>
<p className="text-sm text-slate-500 mb-3 break-keep"> · ID . .</p>
<p className="ui-note mb-3"> · ID . .</p>
{physicians.length ? (
<ul className="text-sm text-slate-700 flex flex-col gap-1">{physicians.map((p) => <li key={p.id}><span className="font-mono text-accent">{p.id}</span> · {p.name} {p.title}</li>)}</ul>
) : <p className="text-sm text-slate-400"> .</p>}
) : <p className="ui-note"> .</p>}
</div>
<div className="bg-white border border-slate-200 rounded-2xl p-6 shadow-[0_8px_32px_rgba(31,38,135,0.05)]">
<h3 className="font-bold text-lg text-primary-900 mb-1">2. · </h3>
<p className="text-sm text-slate-500 mb-4 break-keep"> , . () .</p>
{posts.length === 0 && <p className="text-sm text-slate-400"> .</p>}
<p className="ui-note mb-4"> , . () .</p>
{posts.length === 0 && <p className="ui-note"> .</p>}
<div className="flex flex-col gap-4">
{posts.map((p) => {
const rv = current('post_review', p.id)?.value as { reviewer?: string; reviewedAt?: string } | undefined;

View File

@ -12,7 +12,7 @@ interface DiscoveryCtaProps {
export default function DiscoveryCta({ criteriaCount, onStart }: DiscoveryCtaProps) {
return (
<section className="bg-[#0A1128] px-6 py-24">
<section className="on-dark bg-[#0A1128] px-6 py-24">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
@ -30,7 +30,7 @@ export default function DiscoveryCta({ criteriaCount, onStart }: DiscoveryCtaPro
<button
type="button"
onClick={onStart}
className="inline-flex items-center h-[52px] px-[30px] bg-white rounded-full text-base font-bold text-primary-900 hover:bg-slate-100 transition-colors"
className="on-light inline-flex items-center h-[52px] px-[30px] bg-white rounded-full text-base font-bold text-primary-900 hover:bg-slate-100 transition-colors"
>
URL
</button>

View File

@ -93,7 +93,7 @@ export default function DiscoveryLeadModal({ open, onClose }: DiscoveryLeadModal
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center px-6 bg-[#0A1128]/60 backdrop-blur-sm"
className="on-dark fixed inset-0 z-50 flex items-center justify-center px-6 bg-[#0A1128]/60 backdrop-blur-sm"
onClick={onClose}
data-no-print
>
@ -102,7 +102,7 @@ export default function DiscoveryLeadModal({ open, onClose }: DiscoveryLeadModal
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 24, scale: 0.98 }}
transition={{ duration: 0.25 }}
className="relative w-full max-w-md bg-white rounded-2xl shadow-2xl p-8"
className="on-light relative w-full max-w-md bg-white rounded-2xl shadow-2xl p-8"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
@ -134,7 +134,7 @@ export default function DiscoveryLeadModal({ open, onClose }: DiscoveryLeadModal
</svg>
</div>
<h3 className="text-lg font-bold text-primary-900 mb-2"> </h3>
<p className="text-sm leading-relaxed text-slate-600 mb-6">
<p className="ui-body mb-6">
URL을 , .
<br />
: {DISCOVERY_CONTACT_EMAIL}
@ -150,7 +150,7 @@ export default function DiscoveryLeadModal({ open, onClose }: DiscoveryLeadModal
) : (
<form onSubmit={handleSubmit}>
<h3 className="text-xl font-bold text-primary-900 mb-1.5">URL </h3>
<p className="text-sm leading-relaxed text-slate-600 mb-6">
<p className="ui-body mb-6">
URL만 AI .
.
</p>
@ -175,7 +175,7 @@ export default function DiscoveryLeadModal({ open, onClose }: DiscoveryLeadModal
value={clinicName}
onChange={(e) => setClinicName(e.target.value)}
placeholder="뷰성형외과"
className="w-full h-12 px-4 mb-4 rounded-xl border border-slate-200 bg-white text-[15px] text-primary-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-[#6C5CE7]/40"
className="on-light w-full h-12 px-4 mb-4 rounded-xl border border-slate-200 bg-white text-[15px] text-primary-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-[#6C5CE7]/40"
/>
<label className="block text-xs font-semibold text-slate-500 mb-1.5">
@ -186,7 +186,7 @@ export default function DiscoveryLeadModal({ open, onClose }: DiscoveryLeadModal
value={contact}
onChange={(e) => setContact(e.target.value)}
placeholder="marketing@clinic.com"
className="w-full h-12 px-4 mb-4 rounded-xl border border-slate-200 bg-white text-[15px] text-primary-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-[#6C5CE7]/40"
className="on-light w-full h-12 px-4 mb-4 rounded-xl border border-slate-200 bg-white text-[15px] text-primary-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-[#6C5CE7]/40"
/>
{fieldError && (

View File

@ -0,0 +1,410 @@
/**
* .
* - 읽기: supporter_builds( 1).report.images · supporter_inputs(key='image_review')
* - 쓰기: supporter_inputs insert (anon RLS). .
* - (preview_url) . .
* - . , .
*/
import { useEffect, useMemo, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { supabase } from '../../lib/supabase';
import { CheckFilled, CrossFilled, EyeFilled, WarningFilled, LinkExternalFilled } from '../icons/FilledIcons';
type Role = 'hero' | 'used' | 'stored';
type ImageItem = {
id: string;
src: string;
alt: string;
category: 'clinic' | 'procedure' | 'equipment' | 'doctors';
width: number;
height: number;
sourceUrl: string;
pages: string[];
role: Role;
};
type Build = {
id: string;
status: string;
preview_url: string | null;
updated_at: string;
report: { images?: { capturedAt: string; total: number; used: number; items: ImageItem[] } } | null;
};
type Decision = 'use' | 'exclude';
const ROLE_LABEL: Record<Role, string> = {
hero: '첫 화면 대표 사진',
used: '사이트에 실립니다',
stored: '아직 쓰이지 않습니다',
};
const CATEGORY_LABEL: Record<ImageItem['category'], string> = {
clinic: '병원 공간',
procedure: '시술 설명',
equipment: '장비',
doctors: '의료진',
};
/** 병원이 판단할 때 떠올릴 것. 법 조항이 아니라 눈으로 확인할 수 있는 말로 둔다. */
const CHECKPOINTS = [
'환자분의 얼굴이나 몸이 알아볼 수 있게 나오나요',
'수술 전과 후를 나란히 놓고 비교한 사진인가요',
'주사·절개처럼 시술하는 장면이 담겼나요',
'효과나 성능을 숫자·화살표로 내세운 그림인가요',
'상장·수상·인증 마크가 들어 있나요',
];
export function ImageReviewPanel({ clinicId }: { clinicId: string }) {
const [build, setBuild] = useState<Build | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [decisions, setDecisions] = useState<Record<string, Decision>>({});
const [cursor, setCursor] = useState(0);
const [inputBy, setInputBy] = useState('');
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
void (async () => {
const [b, inputs] = await Promise.all([
supabase.from('supporter_builds').select('id,status,preview_url,report,updated_at').eq('clinic_id', clinicId).order('created_at', { ascending: false }).limit(1),
supabase.from('supporter_inputs').select('value,input_by,created_at').eq('clinic_id', clinicId).eq('key', 'image_review').order('created_at', { ascending: true }),
]);
if (!alive) return;
if (b.error) setLoadError(b.error.message);
setBuild((b.data?.[0] as Build) ?? null);
// 같은 key 는 최신 행이 유효값이다.
const last = inputs.data?.[inputs.data.length - 1]?.value as { decisions?: Record<string, Decision> } | undefined;
if (last?.decisions) setDecisions(last.decisions);
setLoading(false);
})();
return () => { alive = false; };
}, [clinicId]);
const items = useMemo(() => {
const list = build?.report?.images?.items ?? [];
// 첫 화면 대표 사진과 실제로 실리는 사진을 먼저 본다.
const rank: Record<Role, number> = { hero: 0, used: 1, stored: 2 };
return [...list].sort((a, b) => rank[a.role] - rank[b.role]);
}, [build]);
const decidedCount = items.filter((i) => decisions[i.id]).length;
const excluded = items.filter((i) => decisions[i.id] === 'exclude');
const done = items.length > 0 && decidedCount === items.length;
const current = items[cursor];
const previewUrl = build?.preview_url ?? '';
function decide(id: string, d: Decision) {
setDecisions((p) => ({ ...p, [id]: d }));
setSaved(null);
setCursor((c) => Math.min(c + 1, items.length - 1));
}
async function save() {
setSaving(true);
setSaveError(null);
const { error } = await supabase.from('supporter_inputs').insert({
clinic_id: clinicId,
key: 'image_review',
value: { decisions, excludeImages: excluded.map((i) => i.id), total: items.length, decidedAt: new Date().toISOString() },
input_by: inputBy.trim() || null,
});
setSaving(false);
if (error) { setSaveError(error.message); return; }
setSaved(new Date().toISOString().slice(0, 16).replace('T', ' '));
}
if (loading) {
return (
<section className="on-light bg-white py-20 px-6">
<div className="max-w-7xl mx-auto"><p className="text-slate-500"> .</p></div>
</section>
);
}
if (!items.length || !previewUrl) {
return (
<section className="on-light bg-white py-20 px-6 [word-break:keep-all]">
<div className="on-light max-w-3xl mx-auto bg-white border border-slate-200 rounded-2xl p-8 shadow-[3px_4px_12px_rgba(0,0,0,0.06)]">
<h2 className="font-bold text-xl text-primary-900 mb-2"> </h2>
<p className="ui-body">
{loadError
? `자료를 불러오지 못했습니다. ${loadError}`
: !previewUrl
? '사이트 초안이 준비되면 이 화면에서 사진을 한 장씩 확인하실 수 있습니다.'
: '홈페이지에서 모은 사진이 아직 없습니다.'}
</p>
</div>
</section>
);
}
return (
<section className="on-light bg-white py-16 md:py-20 px-6 [word-break:keep-all]">
<div className="max-w-7xl mx-auto">
{/* 진행 */}
<div className="flex flex-wrap items-end justify-between gap-4 mb-3">
<div>
<h2 className="font-serif text-3xl md:text-4xl font-bold text-[#0A1128]">Photo Check</h2>
<p className="ui-ask ui-ask-block mt-3">
{items.length}.{' '}
.
</p>
{build?.report?.images?.used ? (
<p className="ui-body mt-2"> {build.report.images.used} .</p>
) : null}
</div>
<div className="text-right shrink-0">
<p className="ui-note"></p>
<p className="text-2xl font-bold text-[#1D0024]">{decidedCount}<span className="text-slate-400 text-lg"> / {items.length}</span></p>
</div>
</div>
<div className="h-1.5 rounded-full bg-slate-100 overflow-hidden mb-10">
<motion.div
className="h-full bg-gradient-to-r from-[#4F1DA1] to-[#021341]"
animate={{ width: `${(decidedCount / items.length) * 100}%` }}
transition={{ duration: 0.35 }}
/>
</div>
<div className="grid lg:grid-cols-[minmax(0,1fr)_340px] gap-8 items-start">
{/* 한 장씩 보기 */}
<div>
<AnimatePresence mode="wait">
<motion.div
key={current.id}
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.22 }}
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden"
>
<div className="bg-slate-50 flex items-center justify-center p-4">
<img
src={`${previewUrl}${current.src}`}
alt={current.alt}
className="max-h-[420px] w-auto object-contain rounded-lg"
loading="eager"
/>
</div>
<div className="p-6 md:p-7">
<div className="flex flex-wrap items-center gap-2 mb-3">
<RoleBadge role={current.role} />
<span className="text-xs font-semibold px-2.5 py-1 rounded-full bg-slate-100 text-slate-600">{CATEGORY_LABEL[current.category]}</span>
</div>
<h3 className="ui-ask text-lg mb-1.5"> ?</h3>
<p className="ui-body mb-1">
{current.alt || '사진에 붙은 설명이 없습니다.'}
</p>
<p className="ui-note">
{current.pages[0] ?? '홈페이지'}{current.pages.length > 1 ? `${current.pages.length - 1}` : ''}
{' · '}
<a href={current.sourceUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline underline-offset-4 text-slate-500">
<LinkExternalFilled size={12} />
</a>
</p>
<div className="flex flex-wrap gap-3 mt-6">
<button
type="button"
onClick={() => decide(current.id, 'use')}
className={`inline-flex items-center gap-2 rounded-full px-6 py-3 text-base font-semibold transition-shadow ${
decisions[current.id] === 'use'
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white shadow-[4px_6px_16px_rgba(0,0,0,0.09)]'
: 'bg-white border border-slate-200 text-[#021341] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)]'
}`}
>
<CheckFilled size={16} />
</button>
<button
type="button"
onClick={() => decide(current.id, 'exclude')}
className={`inline-flex items-center gap-2 rounded-full px-6 py-3 text-base font-semibold transition-shadow ${
decisions[current.id] === 'exclude'
? 'bg-[#FFF0F0] border border-[#F5D5DC] text-[#7C3A4B] shadow-[4px_6px_16px_rgba(0,0,0,0.09)]'
: 'bg-white border border-slate-200 text-[#021341] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)]'
}`}
>
<CrossFilled size={16} />
</button>
<div className="flex items-center gap-1 ml-auto">
<button
type="button"
onClick={() => setCursor((c) => Math.max(c - 1, 0))}
disabled={cursor === 0}
className="rounded-full px-4 py-3 text-base font-semibold text-slate-600 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent"
>
</button>
<span className="text-sm text-slate-400 tabular-nums px-1">{cursor + 1} / {items.length}</span>
<button
type="button"
onClick={() => setCursor((c) => Math.min(c + 1, items.length - 1))}
disabled={cursor === items.length - 1}
className="rounded-full px-4 py-3 text-base font-semibold text-slate-600 hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent"
>
</button>
</div>
</div>
</div>
</motion.div>
</AnimatePresence>
{/* 전체 훑기 */}
<div className="mt-6">
<div className="flex items-baseline justify-between gap-3 mb-3">
<h3 className="text-base font-bold text-[#1D0024]"> </h3>
<p className="ui-note">
{items.length - decidedCount > 0
? <> <strong className="text-[#1D0024]">{items.length - decidedCount}</strong> </>
: '모두 확인하셨습니다'}
</p>
</div>
<div className="flex flex-wrap gap-2.5">
{items.map((i, idx) => {
const d = decisions[i.id];
const isCur = idx === cursor;
return (
<button
key={i.id}
type="button"
onClick={() => setCursor(idx)}
aria-label={`${idx + 1}번째 사진으로 이동${d ? (d === 'use' ? ', 사용으로 정함' : ', 빼기로 정함') : ', 아직 안 정함'}`}
aria-current={isCur ? 'true' : undefined}
title={i.alt || `${idx + 1}번째 사진`}
className={`group relative w-[72px] h-[72px] rounded-xl overflow-hidden bg-slate-100 cursor-pointer
transition-all duration-150 outline-none
hover:-translate-y-0.5 hover:shadow-[3px_5px_14px_rgba(0,0,0,0.14)]
focus-visible:ring-2 focus-visible:ring-[#6C5CE7] focus-visible:ring-offset-2
${isCur
? 'ring-2 ring-[#6C5CE7] ring-offset-2 shadow-[3px_4px_12px_rgba(108,92,231,0.28)]'
: 'ring-1 ring-slate-200 hover:ring-[#9B8AD4]'}`}
>
<img
src={`${previewUrl}${i.src}`}
alt=""
loading="lazy"
className={`w-full h-full object-cover transition-transform duration-200 group-hover:scale-[1.06]
${d === 'exclude' ? 'grayscale opacity-45' : ''}`}
/>
{/* 순번. 어디까지 봤는지 세기 쉽게 한다 */}
<span className="on-dark absolute top-1 left-1 min-w-[18px] h-[18px] px-1 rounded-md bg-[#0A1128]/65 text-white text-[11px] font-semibold leading-[18px] text-center">
{idx + 1}
</span>
{/* 정한 것 */}
{d === 'exclude' && (
<span className="absolute inset-0 flex items-center justify-center">
<span className="w-6 h-6 rounded-full bg-[#FFF0F0] border border-[#F5D5DC] text-[#7C3A4B] flex items-center justify-center">
<CrossFilled size={12} />
</span>
</span>
)}
{d === 'use' && (
<span className="absolute bottom-1 right-1 w-5 h-5 rounded-full bg-[#F3F0FF] border border-[#D5CDF5] text-[#4A3A7C] flex items-center justify-center">
<CheckFilled size={11} />
</span>
)}
{/* 아직 안 정한 것은 아래에 띠를 둬서 남은 일이 보이게 한다 */}
{!d && <span className="absolute inset-x-0 bottom-0 h-1 bg-[#D4A872]" />}
{/* 현재 보고 있는 것 */}
{isCur && (
<span className="absolute inset-x-0 bottom-0 bg-[#6C5CE7] text-white text-[10px] font-bold text-center py-0.5">
</span>
)}
</button>
);
})}
</div>
<div className="ui-note flex flex-wrap items-center gap-x-4 gap-y-1 mt-3">
<span className="inline-flex items-center gap-1.5"><span className="w-3 h-1 rounded-full bg-[#D4A872]" /> </span>
<span className="inline-flex items-center gap-1.5"><span className="w-3.5 h-3.5 rounded-full bg-[#F3F0FF] border border-[#D5CDF5]" /> </span>
<span className="inline-flex items-center gap-1.5"><span className="w-3.5 h-3.5 rounded-full bg-[#FFF0F0] border border-[#F5D5DC]" /> </span>
</div>
</div>
</div>
{/* 판단 기준 + 제출 */}
<div className="flex flex-col gap-5 lg:sticky lg:top-24">
<div className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6">
<div className="flex items-center gap-2 mb-3 text-[#7C5C3A]">
<WarningFilled size={16} />
<h3 className="text-base font-bold"> </h3>
</div>
<ul className="flex flex-col gap-2">
{CHECKPOINTS.map((c) => (
<li key={c} className="ui-body !text-[#1D0024] flex gap-2">
<span className="mt-[9px] w-1.5 h-1.5 rounded-full bg-[#D4A872] shrink-0" />
<span>{c}</span>
</li>
))}
</ul>
<p className="ui-ask text-[15px] mt-4"> .{' '}<span className="ui-note"> .</span></p>
</div>
<div className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6">
<h3 className="text-base font-bold text-[#1D0024] mb-3"> </h3>
<dl className="flex flex-col gap-1.5 text-[15px]">
<div className="flex justify-between"><dt className="text-slate-500"></dt><dd className="font-bold text-[#4A3A7C]">{decidedCount - excluded.length}</dd></div>
<div className="flex justify-between"><dt className="text-slate-500"></dt><dd className="font-bold text-[#7C3A4B]">{excluded.length}</dd></div>
<div className="flex justify-between"><dt className="text-slate-500"></dt><dd className="font-bold text-slate-400">{items.length - decidedCount}</dd></div>
</dl>
<label className="flex flex-col gap-1.5 mt-5">
<span className="text-sm text-slate-600"> (·)</span>
<input
value={inputBy}
onChange={(e) => setInputBy(e.target.value)}
placeholder="예: 김담당 실장"
className="border border-slate-200 rounded-lg px-3 py-2 text-base"
/>
</label>
<button
type="button"
onClick={() => void save()}
disabled={saving || decidedCount === 0}
className="w-full mt-4 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white font-semibold px-6 py-3 text-base disabled:opacity-40"
>
{saving ? '보내는 중' : done ? '확인 결과 보내기' : `여기까지 보내기 (${decidedCount}장)`}
</button>
{saved && (
<p className="text-sm text-[#4A3A7C] mt-3 flex items-start gap-1.5">
<span className="mt-0.5"><CheckFilled size={13} /></span>
<span>{saved} . .</span>
</p>
)}
{saveError && <p className="text-sm text-[#7C3A4B] mt-3"> . {saveError}</p>}
{!done && decidedCount > 0 && !saved && (
<p className="ui-note mt-3"> .</p>
)}
</div>
{previewUrl && (
<a
href={previewUrl}
target="_blank"
rel="noreferrer"
className="on-light inline-flex items-center justify-center gap-2 rounded-full bg-white border border-slate-200 text-[#021341] font-semibold px-6 py-3 text-base"
>
<EyeFilled size={15} />
</a>
)}
</div>
</div>
</div>
</section>
);
}
function RoleBadge({ role }: { role: Role }) {
const style: Record<Role, string> = {
hero: 'bg-[#EFF0FF] text-[#3A3F7C] border-[#C5CBF5]',
used: 'bg-[#F3F0FF] text-[#4A3A7C] border-[#D5CDF5]',
stored: 'bg-slate-100 text-slate-500 border-slate-200',
};
return <span className={`text-xs font-semibold px-2.5 py-1 rounded-full border ${style[role]}`}>{ROLE_LABEL[role]}</span>;
}

View File

@ -0,0 +1,53 @@
/**
* (/actions/:id) 2부: 하지 + .
* "이 개선안이 무엇을 안 파는지" . .
*/
import { SectionWrapper } from '../report/ui/SectionWrapper';
import { CheckFilled, CrossFilled } from '../icons/FilledIcons';
import type { DiscoveryResult } from '../../types/discovery';
export function IntegritySection({ result }: { result: DiscoveryResult }) {
return (
<SectionWrapper
id="integrity"
title="What We Don't Sell"
subtitle="근거 없는 것은 팔지 않는다. 이 리포트의 정직성 규약"
dark
>
<div className="grid lg:grid-cols-2 gap-6">
<div className="on-light bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 text-[#0A1128]">
<h3 className="text-xl font-bold mb-4 font-sans text-[#0A1128]!"> </h3>
<ul className="space-y-4">
{result.notDoing.map((n) => (
<li key={n.item} className="flex gap-3">
<span className="shrink-0 text-[#D4889A] mt-0.5"><CrossFilled size={18} /></span>
<div>
<p className="text-base font-semibold">{n.item}</p>
<p className="ui-note mt-0.5">{n.reason}</p>
</div>
</li>
))}
</ul>
</div>
<div className="on-light bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 text-[#0A1128]">
<h3 className="text-xl font-bold mb-4 font-sans text-[#0A1128]!"> </h3>
<ul className="space-y-3">
{result.conditions.map((c) => (
<li key={c} className="flex gap-3">
<span className="shrink-0 text-[#9B8AD4] mt-0.5"><CheckFilled size={18} /></span>
<p className="ui-body">{c}</p>
</li>
))}
</ul>
<div className="mt-6 rounded-xl p-4 bg-[linear-gradient(to_right,#fff3eb,#e4cfff,#f5f9ff)]">
<p className="text-sm text-slate-600 leading-relaxed">
<strong> </strong> . (·GBP·GA4·)
, . ,
·· <strong></strong> .
</p>
</div>
</div>
</div>
</SectionWrapper>
);
}

View File

@ -89,7 +89,7 @@ export default function SampleReport({ overall, axes, result }: SampleReportProp
<div className={`px-3.5 py-[5px] border rounded-full ${GRADE_BADGE_STYLE[ax.grade]}`}>
<span className="text-sm font-bold">{ax.code} {ax.grade}</span>
</div>
<span className="text-sm text-slate-500">{ax.name}</span>
<span className="ui-note">{ax.name}</span>
</div>
))}
</div>
@ -161,7 +161,7 @@ export default function SampleReport({ overall, axes, result }: SampleReportProp
<span className="text-[13px] font-bold">{finding.label}</span>
</div>
<div className="text-[15.5px] font-semibold text-primary-900 break-keep">{finding.title}</div>
<p className="text-sm leading-[1.7] text-slate-500 break-keep">{finding.detail}</p>
<p className="ui-note leading-[1.7]">{finding.detail}</p>
</motion.div>
))}
</div>

View File

@ -0,0 +1,13 @@
/**
* . (Scorecard의 // ) ( ID )
* .
*/
import type { ReactNode } from 'react';
export function Tag({ children }: { key?: string; children: ReactNode }) {
return (
<span className="inline-flex items-center rounded-full bg-slate-100 text-slate-600 text-sm font-medium px-2 py-0.5">
{children}
</span>
);
}

View File

@ -28,7 +28,7 @@ const STATS = [
export default function WhyNow() {
return (
<section id="why-now" className="bg-[#0A1128] px-6 py-22">
<section id="why-now" className="on-dark bg-[#0A1128] px-6 py-22">
<div className="max-w-7xl mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}

View File

@ -22,7 +22,7 @@ export function SectionWrapper({
id={id}
className={`
${dark
? 'bg-[#0A1128] text-white relative overflow-hidden'
? 'on-dark bg-[#0A1128] text-white relative overflow-hidden'
: 'bg-white'
}
py-16 md:py-20 px-6

View File

@ -6,9 +6,11 @@ import type { DiscoveryResult } from '../types/discovery';
import { DISCOVERY_VIEWCLINIC } from './discovery_viewclinic';
import { DISCOVERY_TAEHA } from './discovery_taeha';
import { DISCOVERY_WONJIN } from './discovery_wonjin';
import { DISCOVERY_JKPLASTIC } from './discovery_jkplastic';
export const DISCOVERY_RESULTS: Record<string, DiscoveryResult> = {
viewclinic: DISCOVERY_VIEWCLINIC,
taeha: DISCOVERY_TAEHA,
wonjin: DISCOVERY_WONJIN,
jkplastic: DISCOVERY_JKPLASTIC,
};

View File

@ -0,0 +1,164 @@
/**
* JK성형외과 (www.jkplastic.com/en/) AEO/GEO
* 채점: audit_aeo_geo.py · 2026-09-20 · JS HTML
* 보정: 2026-09-20 · 11 + 1
*
* . .
* ·NAP·alt
* B4·C4·F1 . HTML을 .
*/
import type { DiscoveryResult } from '../types/discovery';
export const DISCOVERY_JKPLASTIC: DiscoveryResult = {
id: 'jkplastic',
clinicName: 'JK성형외과',
url: 'https://www.jkplastic.com/en/',
industry: '성형외과 (강남 압구정) · 영문 사이트 기준',
auditedAt: '2026-09-20',
rubricVersion: '1.0',
conditions: [
'실측 대상: 영문 페이지 11개 (홈·의료진·오시는길·JK스토리·연혁·안전·쌍꺼풀·코·모티바·사각턱·수술후기) + 대조용 국문 의료진 1개, JS 미실행 HTML 기준',
'크롤러 접근성: robots.txt 파싱 + 12개 AI 봇 UA 실제 GET 요청',
'영문 기준 진단이다. 국문 사이트(jkplastic.com/)의 페이지·수치·표면은 채점에 넣지 않았다',
'자동 채점기의 국문 전제 정규식이 놓친 B4·C4·F1은 영문 원본 HTML을 다시 읽어 보정했다',
'semi/manual 항목 중 외부 실측이 가능한 C1·C4·C7·C8·D1·D3은 확정했고, 오너 계정이 필요한 A6·D2·D4·E1~E4는 미검증으로 두어 분모에서 뺐다',
'답변엔진 언급률은 측정하지 않았다 (단발 조회는 무효, 반복 측정은 별도 기준선 단계에서 수행)',
],
results: [
// A. 접근성
{ criterionId: 'A1', level: 3, evidence: '12개 AI 봇 전부 허용. OAI-SearchBot·PerplexityBot·GPTBot UA로 GET → 전부 200', source: 'robots.txt: User-agent: * / Disallow: /admincenter/' },
{ criterionId: 'A2', level: 3, evidence: 'Server 미표기 직접 응답, Cloudflare 헤더 없음, AI UA 챌린지 없음' },
{ criterionId: 'A3', level: 3, evidence: '서버 렌더링(ASP). JS 미실행 HTML에서 영문 홈 본문 5,110자 추출. 시술 페이지도 3,692~7,667자로 본문이 HTML에 그대로 있다' },
{ criterionId: 'A4', level: 1, evidence: '변형 간 최종 URL 불일치(중복 콘텐츠 위험): [\'https://jkplastic.com\', \'https://jkplastic.com/index.asp\', \'https://www.jkplastic.com\', \'https://www.jkplastic.com/index.asp\']' },
{ criterionId: 'A5', level: 1, evidence: 'robots.txt에 sitemap 선언, 200 응답, URL 236개. 그러나 lastmod 최신값이 2024-05-28로 28개월 전이고, 영문 55개 URL 안에 의료진(/en/about-us/our-value/doctors.asp)과 오시는길(/en/about-us/medical-info/map.asp)이 빠져 있다. 두 페이지 모두 직접 요청하면 200이다. 영문 권위·위치 페이지가 발견 경로에서 누락된 상태', source: 'sitemap.xml 파싱 + 두 URL 직접 GET 200 (2026-09-20)' },
{ criterionId: 'A6', level: 'unverified', evidence: '[manual] Bing Webmaster Tools 등록 여부는 오너 계정 접근 필요' },
{ criterionId: 'A7', level: 3, evidence: 'TTFB 0.147s / 초기 HTML 93KB. 영문 시술 페이지 67~83KB — 모두 기준 이내' },
// B. 엔티티
{ criterionId: 'B1', level: 0, evidence: 'JSON-LD 블록 없음. 영문 11개 페이지 전부 구조화 데이터 0' },
{ criterionId: 'B2', level: 0, evidence: 'MedicalClinic·LocalBusiness 등 업종 엔티티 스키마 없음' },
{ criterionId: 'B3', level: 0, evidence: 'Physician 스키마 없음. 의료진 학력·경력은 HTML 텍스트로만 존재' },
{ criterionId: 'B4', level: 2, evidence: '영문 12개 페이지 중 11개가 지번 표기 "584-2 Sinsa-dong, Gangnam-gu, Seoul", 홈 푸터만 도로명 표기 "835, Nonhyeon-ro, Gangnam-gu, Seoul". 같은 위치의 2개 변형이다. 대표전화는 "(+82) 2-777-7797" 단일 표기이며 Whatsapp 번호(+82-10-9738-4042)는 용도가 달라 변형으로 세지 않았다. 자동 채점은 홈·시술 페이지만 보고 "미표기 0점"으로 판정했으나 오시는길 페이지에 완전한 NAP이 텍스트로 있다', source: '영문 11개 페이지 주소·전화 정규식 추출 (2026-09-20)' },
{ criterionId: 'B5', level: 0, evidence: '스키마 자체가 없어 sameAs 부재. 푸터에 Facebook·YouTube·Instagram 링크가 있으나 스키마에 미연결' },
{ criterionId: 'B6', level: 0, evidence: 'rel=canonical 없음. A4의 4변형 중복과 겹쳐 정본 URL 신호가 없다' },
{ criterionId: 'B7', level: 0, evidence: '언어 버전 7종(ENG·KOR·CHN·JPN·RUS·ARAB·IND)을 헤더에서 전환하지만 hreflang 태그는 0개. 외국인 환자 질의가 주력인 사이트인데 언어판이 서로 연결되어 있지 않다' },
// C. 콘텐츠
{ criterionId: 'C1', level: 1, evidence: '본문 첫 200자가 전부 슬로건이다. 홈 "Live a life True to yourself / Mindful of your beauty", JK스토리 "Virtue We take the right path", 쌍꺼풀 "Simple Beauty is in the eye of the beholder". "JK Plastic Surgery is a plastic surgery clinic in Apgujeong, Seoul, specializing in ..." 형태의 정의문이 어느 핵심 페이지에도 없다. 상호·지역·전문분야를 한 문장에 담은 텍스트는 메타 타이틀에만 있다', source: '영문 홈·JK스토리·쌍꺼풀 본문 첫 200자 (2026-09-20)' },
{ criterionId: 'C2', level: 0, evidence: 'FAQ·Q&A 섹션이 영문 11개 페이지에 없다. FAQPage 스키마도 없다. 후기 게시판(/en/boad/bd_free/5/)은 있으나 질문·답변 구조가 아니다' },
{ criterionId: 'C3', level: 2, evidence: 'H1 1개: "JK PLASTIC". 다만 상호 자체가 H1이라 페이지 주제를 나타내지 않고, H2는 라벨형 중복이 있다' },
{ criterionId: 'C4', level: 2, evidence: '시술 4개 중 3개에 스펙이 텍스트로 있다. 쌍꺼풀 "Operation Time 30-40 minutes / Stitch Removal 4days after the surgery / Hospitalization Not necessary", 모티바 "Operation Time 1.5 Hours / Stitch Removal 10 days", 사각턱 "Operation Time 1~1.5 hours / Stitch Removal 2 weeks / Avoid hard and tough food for 4 weeks". 코 성형 페이지는 수치 0. 가격은 영문 어느 시술 페이지에도 없다. 자동 채점이 "수치 0회"로 판정한 것은 국문 단위(분·일·원)만 찾는 정규식 때문이다', source: '영문 시술 4개 페이지 수치 맥락 추출 (2026-09-20)' },
{ criterionId: 'C5', level: 1, evidence: 'HTML <table> 0개. 정의 리스트 <dl>은 모티바 6개·홈 4개가 있으나 내용이 제품 특징 설명(Shell·Silicone Gel·BluSeal®)이라 비교표가 아니다. 시술별 스펙(수술시간·실밥제거)은 표가 아닌 이미지와 div 레이아웃으로 배치되어 있다' },
{ criterionId: 'C6', level: 0, evidence: '최신 갱신 신호 2024-05-28 (sitemap lastmod) — 28개월 전. dateModified·article:modified_time 마크업 없음. 영문 사이트에 갱신 시점을 알리는 신호가 하나도 없다' },
{ criterionId: 'C7', level: 2, evidence: '영문 의료진 페이지(7,225자)에 대표원장 학력·전공·수련이 텍스트로 있다. "Joo Kwon / Plastic Surgeon / Representative Doctor / Graduated Catholic University Medical School (majored in Plastic Surgery) / Finished Intern·Resident at Seoul St. Mary\'s Hospital / Outpatient Professor of Plastic Surgery School of Catholic University". 학회·학술 언급 87회, 전문의·인증 표현 2회, "20 years of know-how" 경력 표기. 다만 Physician 스키마가 없어 구조화되지 않았고, 이 페이지가 sitemap에서 빠져 있다', source: '/en/about-us/our-value/doctors.asp 본문 실측 (2026-09-20)' },
{ criterionId: 'C8', level: 2, evidence: '영문 10개 페이지의 60자 이상 단락 144개 중 10개를 표본 추출해 채점: 7/10(70%)이 시술명 또는 상호를 포함한다. 다만 상호(JK)를 포함한 단락은 10개 중 1개뿐이고 나머지는 시술명으로만 맞았다. 답변엔진이 단락을 인용해도 "어느 병원 이야기인지"가 문장 안에 남지 않는 구조다', source: '무작위 표본 10개 (seed 고정) 수동 채점 (2026-09-20)' },
// D. 표면
{ criterionId: 'D1', level: 2, evidence: '영문 기준 버티컬은 해외 의료관광 플랫폼이다. Bookimed(리뷰 9건 표기, 요약 평점 4.5/5)·Medijump·MediGlobus·PlacidWay·Clinics on Call·seoul.clinic에 프로필이 있고 가격·의료진·후기 항목이 채워져 있다. 다만 병원이 직접 관리하는 프로필인지, 갱신이 3개월 이내인지는 확인하지 못해 상위 단계로 올리지 않았다', source: '웹 검색 실측 (2026-09-20)' },
{ criterionId: 'D2', level: 'unverified', evidence: '[manual] 2차 출처(seoul.clinic 리뷰 기사)에 Google 리뷰 약 80건·평점 4.5 언급이 있으나, 프로필 소유권·카테고리·NAP 일치는 Google Maps에서 직접 확인해야 한다. 2차 출처 수치로 판정하지 않는다' },
{ criterionId: 'D3', level: 2, evidence: '자체 YouTube 채널(JK Plastic Surgery Center) 운영 중이고, 의료 전문지 기사(Korea Biomedical Review "In a way, JK plastic surgeons are artists")와 해외 의료관광 도메인 6곳의 소개 문서가 있다. 유튜브와 언론이 모두 있어 2단계. 다만 2025~2026년 신규 언론 노출은 검색에서 확인되지 않아, 언급이 과거에 쏠려 있다', source: '웹 검색 실측, naver.com 제외 (2026-09-20)' },
{ criterionId: 'D4', level: 'unverified', evidence: '[manual] 표면 간 NAP 대조표는 D2(GBP) 실측이 선행되어야 작성할 수 있다. 사이트 내부 변형 2종은 B4 참조' },
{ criterionId: 'D5', level: 'unverified', evidence: '[semi] 네이버 플레이스는 영문 사이트에서 링크되지 않고, 검색으로도 플레이스 프로필을 확인하지 못했다. 글로벌 AI 기여도가 0인 항목이라 영문 기준 진단에서 미검증으로 둔다. 국문 트랙에서 별도 실측한다' },
{ criterionId: 'D6', level: 0, evidence: 'Wikidata에 "JK성형외과"·"JK Plastic Surgery" 항목 없음' },
// E. 측정
{ criterionId: 'E1', level: 'unverified', evidence: '[manual] 서버 로그 접근 필요 (AI 크롤러 히트 + IP 검증)' },
{ criterionId: 'E2', level: 'unverified', evidence: '[manual] GA4 접근 필요 (AI 유입 커스텀 채널그룹 여부)' },
{ criterionId: 'E3', level: 'unverified', evidence: '[manual] GSC 접근 필요 (AI Overviews 노출)' },
{ criterionId: 'E4', level: 'unverified', evidence: '[manual] 영문 질의 기준선 스팟체크 미실시 (질의 10개 × 5회 반복은 별도 단계)' },
// F. 위생
{ criterionId: 'F1', level: 1, evidence: '영문 11개 페이지 이미지 483장 중 142장(29%) alt 누락. 페이지 편차가 크다: 쌍꺼풀 36/67·홈 36/64가 절반 이상 누락인 반면 의료진 2/31·JK스토리 2/26은 양호. 자동 채점의 61%는 홈 한 페이지만 본 값이다', source: '영문 11개 페이지 img 태그 전수 파싱 (2026-09-20)' },
{ criterionId: 'F2', level: 'unverified', evidence: 'meta generator 없음 — CMS 종류·버전 판정 근거 없음. ASP 기반으로 보이나 확정하지 않는다' },
{ criterionId: 'F3', level: 3, evidence: '초기 HTML 93KB (300KB 미만)' },
{ criterionId: 'F4', level: 2, evidence: 'viewport meta 존재, 반응형. CWV 미측정' },
],
keyFindings: [
{
title: 'AI는 이 사이트를 읽을 수 있습니다',
detail: 'robots.txt가 AI 검색봇 12종을 모두 허용하고, 차단 계층도 없으며, 본문이 서버에서 렌더링되어 JS 없이도 페이지마다 3,700~7,700자가 그대로 읽힙니다. 응답 속도와 문서 크기도 기준 안입니다. 영문 답변에 잘 나오지 않는 원인은 접근 차단이 아니라 읽은 다음에 있습니다.',
severity: 'good',
},
{
title: '읽어도 어느 병원인지 확정할 근거가 없습니다',
detail: '영문 11개 페이지 전부 구조화 데이터가 0입니다. MedicalClinic 스키마도, Physician 스키마도, canonical도, sameAs도 없습니다. 언어판을 7종 운영하면서 hreflang 태그가 0개라 영어·중국어·일본어 페이지가 서로 같은 병원이라는 연결도 없습니다. 외국인 환자 유치가 주력인 사이트에서 이 항목들이 비어 있습니다.',
severity: 'critical',
},
{
title: '문장이 병원 이름을 데리고 다니지 않습니다',
detail: '단락 표본 10개 중 7개가 시술명을 담고 있어 내용 자체는 인용될 만합니다. 그런데 상호가 들어간 단락은 10개 중 1개뿐이고, 핵심 페이지의 첫 200자는 모두 슬로건입니다. 답변엔진이 단락을 가져가도 문장 안에 병원 이름이 남지 않아, 인용은 되고 이름은 안 나오는 상태가 됩니다.',
severity: 'critical',
},
{
title: '영문 권위 페이지가 발견 경로에서 빠져 있습니다',
detail: '영문 의료진 페이지에는 대표원장의 출신 학교·전공·수련 병원·외래교수 이력과 학회 언급 87회가 텍스트로 있고, 오시는 길 페이지에는 주소·전화·진료시간이 완전한 형태로 있습니다. 두 페이지 모두 직접 열면 정상입니다. 그런데 sitemap의 영문 55개 URL 목록에 둘 다 없습니다. 권위와 위치를 증명할 자료가 준비되어 있는데 크롤러에게 알려지지 않고 있습니다.',
severity: 'warning',
},
{
title: '해외 플랫폼에는 있고, 사이트 갱신은 28개월째 멈춰 있습니다',
detail: 'Bookimed·Medijump·MediGlobus·PlacidWay 등 해외 의료관광 플랫폼 6곳에 프로필이 있고 유튜브 채널과 전문지 기사도 있어, 오픈웹 표면 자체는 확보되어 있습니다. 반면 사이트의 최신 갱신 신호는 2024년 5월이 마지막이고 갱신 시점 마크업도 없습니다. 시술 페이지에 수술시간·실밥제거 같은 스펙은 있으나 가격은 한 건도 없어, 비용을 묻는 영문 질의에 답할 문장이 사이트 안에 없습니다.',
severity: 'warning',
},
],
actions: [
{
priority: 'P0',
title: '영문 핵심 페이지에 MedicalClinic·Physician 스키마와 canonical을 넣습니다',
detail: '오시는 길 페이지에 이미 텍스트로 있는 주소·전화·진료시간을 그대로 MedicalClinic 스키마(address·telephone·openingHoursSpecification·medicalSpecialty·geo)로 옮깁니다. 의료진 페이지의 학력·수련·외래교수 이력은 Physician 스키마로 구조화합니다. sameAs에 YouTube·Instagram·Facebook과 해외 플랫폼 프로필 URL을 연결하고, 전 페이지에 self-referencing canonical을 넣어 A4의 4변형 중복을 정리합니다.',
criterionIds: ['B1', 'B2', 'B3', 'B5', 'B6', 'A4'],
effort: '1주',
},
{
priority: 'P0',
title: '언어판 7종을 hreflang으로 연결합니다',
detail: '헤더에서 전환만 되고 태그로는 연결되지 않은 ENG·KOR·CHN·JPN·RUS·ARAB·IND 7종에 상호 hreflang과 x-default를 넣습니다. 외국인 환자 질의는 언어별로 다른 표면에서 일어나므로, 언어판이 하나의 병원으로 묶여야 각 언어 답변에서 같은 엔티티로 인식됩니다.',
criterionIds: ['B7'],
effort: '1주',
},
{
priority: 'P1',
title: 'sitemap에 영문 의료진·오시는 길 페이지를 넣고 lastmod를 되살립니다',
detail: '영문 55개 URL 목록에서 빠진 /en/about-us/our-value/doctors.asp 와 /en/about-us/medical-info/map.asp 를 추가합니다. 두 페이지는 이미 200으로 열립니다. 그리고 2024-05-28에 멈춘 lastmod를 실제 수정일로 갱신하고, 시술 페이지에 dateModified 마크업을 넣어 갱신 신호를 만듭니다.',
criterionIds: ['A5', 'C6'],
effort: '1일',
},
{
priority: 'P1',
title: '핵심 페이지 첫 단락을 슬로건에서 정의문으로 바꿉니다',
detail: '홈·시술·소개 페이지의 첫 200자를 "JK Plastic Surgery is a plastic surgery clinic in Apgujeong, Gangnam, Seoul, specializing in ... , certified by the Korean Ministry of Health and Welfare as an outstanding medical institution for foreign patients." 형태로 바꿉니다. 시술 페이지 본문 단락에도 상호를 넣어, 인용된 문장 안에 병원 이름이 남게 합니다.',
criterionIds: ['C1', 'C8'],
effort: '2~4주',
},
{
priority: 'P1',
title: '시술 페이지에 가격대와 FAQ를 텍스트로 넣습니다',
detail: '수술시간·실밥제거·입원 여부는 이미 텍스트로 있습니다. 여기에 USD 가격대와 체류 기간을 더하고, 시술별 FAQ 5~7문항을 FAQPage 스키마와 함께 넣습니다. 스펙을 이미지·div가 아닌 HTML 표로 옮겨 비교 구조를 만듭니다. 코 성형 페이지는 수치가 하나도 없어 먼저 채웁니다.',
criterionIds: ['C4', 'C2', 'C5'],
effort: '2~4주',
},
{
priority: 'P1',
title: 'Google 비즈니스 프로필과 해외 플랫폼 프로필을 실측하고 NAP을 맞춥니다',
detail: 'Google Maps에서 소유권·카테고리·리뷰·NAP을 직접 확인합니다. 2차 출처에 리뷰 약 80건·평점 4.5 언급이 있으나 이것으로 판정하지 않습니다. 확인 후 홈 푸터의 도로명 표기(835, Nonhyeon-ro)와 나머지 11개 페이지의 지번 표기(584-2 Sinsa-dong)를 한쪽으로 통일하고, 해외 플랫폼 6곳의 주소·전화도 같은 표기로 맞춥니다.',
criterionIds: ['D2', 'D4', 'B4', 'D1'],
effort: '1주',
},
{
priority: 'P2',
title: 'alt 누락 142장을 채웁니다',
detail: '영문 11개 페이지 이미지 483장 중 142장이 alt 없이 들어가 있습니다. 쌍꺼풀 페이지 36장, 홈 36장이 집중 구간입니다. 시술 전후 사진과 시술 설명 이미지부터 채웁니다.',
criterionIds: ['F1'],
effort: '1주',
},
{
priority: 'P2',
title: '영문 질의 기준선을 측정합니다',
detail: '영문 질의 10개를 5회씩 반복 조회해 언급률 기준선과 신뢰구간을 만듭니다. 서버 로그에서 AI 크롤러 히트를 IP 검증과 함께 집계하고, GA4에 AI 유입 커스텀 채널그룹을 만듭니다. 개선 전후 비교는 이 기준선이 있어야 말할 수 있습니다.',
criterionIds: ['E1', 'E2', 'E3', 'E4'],
effort: '지속',
},
],
notDoing: [
{ item: 'llms.txt 제작', reason: 'Google 공식(2026-06) 효과 0, 채택률 0.13%, 서버 로그에 요청 자체가 없다. 위약이다.' },
{ item: '"스키마 넣으면 AI가 인용합니다" 약속', reason: '인과 증거 없음. 스키마는 엔티티 식별을 돕는 위생 조치로만 설명한다.' },
{ item: '단발 조회로 순위·노출 보고', reason: 'LLM 답변 분산 10~34%. 반복 측정 + 신뢰구간으로만 보고한다.' },
{ item: '답변엔진 UI 자동 조회', reason: 'OpenAI·Perplexity 약관 위반. 주력 측정은 오너가 소유한 신호(로그·GA4)로 한다.' },
{ item: '네이버 블로그 증량', reason: 'blog.naver.com robots.txt가 AI 크롤링을 전면 금지. 영문 질의 기여도 0이다.' },
{ item: '국문 사이트 항목을 영문 점수에 합산', reason: '국문에만 있는 의료진 페이지·전화번호 6종은 영문 표면의 근거가 되지 않는다. 국문은 별도 진단으로 낸다.' },
],
};

View File

@ -20,6 +20,22 @@
--color-accent: #6C5CE7; /* Purple accent */
/* 흰·밝은 배경 위 강조 텍스트. haewon이 말하는 "인디고" */
--color-indigo: #1D0024;
/* 텍스트 역할 토큰.
ask = 요청문. 사람에게 무엇을 해달라고 하는 문장 (읽고 끝나는 설명과 구분한다)
body = 설명 본문
note = 보조 설명·출처 */
--color-text-ask: #1D0024;
--color-text-body: #475569;
--color-text-note: #94A3B8;
--color-ask-rule: #D5CDF5;
--text-ask: 1.0625rem; /* 17px. 본문(16px)보다 한 단 크게 */
--text-body: 1rem; /* 16px. 기존 text-base 와 같다 */
--text-note: 0.875rem; /* 14px. 기존 text-sm 과 같다 */
/* INFINITH Semantic Status Colors — pastel palette derived from brand gradient */
/* Critical/Negative: warm rose (peach-toned) */
--color-status-critical-bg: #FFF0F0;
@ -158,4 +174,99 @@
.animation-delay-14000 {
animation-delay: 14s;
}
}
/*
텍스트 역할 클래스
요청문(.ui-ask) 설명문(.ui-body) 눈에 띄게 가른다. 요청문은 사람이
무엇을 해야 하는지 말하는 문장이라, 읽고 넘어가는 설명과 같은 무게로 두면
지나친다. ·굵기·크기를 함께 올리고 왼쪽에 규칙선을 둔다.
한글 줄바꿈:
word-break: keep-all 단어(어절) 중간에서 자르지 않는다
overflow-wrap: anywhere URL·영문 토큰은 예외적으로 자른다
text-wrap: pretty 마지막 줄에 어절만 남는 것을 줄인다
max-width 62ch 줄이 길어 눈이 되돌아오는 것을 막는다
*/
.ui-ask,
.ui-body,
.ui-note {
word-break: keep-all;
overflow-wrap: anywhere;
text-wrap: pretty;
}
.ui-ask {
font-size: var(--text-ask);
line-height: 1.75;
font-weight: 600;
color: var(--color-text-ask);
max-width: 62ch;
}
/* 문단으로 서는 요청문. 왼쪽 규칙선으로 설명 본문과 구분한다 */
.ui-ask-block {
border-left: 3px solid var(--color-ask-rule);
padding-left: 0.875rem;
}
.ui-body {
font-size: var(--text-body);
line-height: 1.75;
color: var(--color-text-body);
max-width: 62ch;
}
.ui-note {
font-size: var(--text-note);
line-height: 1.7;
color: var(--color-text-note);
}
/* 다크 섹션 위에서는 같은 역할을 밝은 값으로 뒤집는다 */
.on-dark .ui-ask { color: #FFFFFF; }
.on-dark .ui-ask-block { border-left-color: rgba(213, 205, 245, 0.45); }
.on-dark .ui-body { color: rgba(255, 255, 255, 0.72); }
.on-dark .ui-note { color: rgba(255, 255, 255, 0.45); }
/* 다크 섹션 안의 카드는 다시 라이트 값으로 되돌린다.
이걸 두지 않으면 글씨가 배경에 얹혀 보이지 않는다. 카드에 .on-light 붙인다. */
.on-dark .on-light .ui-ask,
.on-light .ui-ask { color: var(--color-text-ask); }
.on-dark .on-light .ui-ask-block,
.on-light .ui-ask-block { border-left-color: var(--color-ask-rule); }
.on-dark .on-light .ui-body,
.on-light .ui-body { color: var(--color-text-body); }
.on-dark .on-light .ui-note,
.on-light .ui-note { color: var(--color-text-note); }
/* 제목도 어절 단위로 끊는다. 한글 제목이 글자 중간에서 잘리면 읽기 나쁘다 */
h1, h2, h3, h4, h5, h6 {
word-break: keep-all;
text-wrap: balance;
}
/* 다크 섹션(#0A1128) 위의 버튼.
기본 CTA 그라디언트(#4F1DA1 #021341) 배경용이라 다크 위에 올리면 배경에 묻힌다.
밝은 보라 + 글로우로 떼어 놓는다. */
.btn-on-dark {
display: inline-flex;
align-items: center;
gap: 0.625rem;
border-radius: 9999px;
padding: 1rem 2rem;
font-size: 1.0625rem;
font-weight: 700;
color: #FFFFFF;
background-image: linear-gradient(to right, #7C6DD8, #6C5CE7);
box-shadow: 0 6px 28px rgba(108, 92, 231, 0.55);
outline: 1px solid rgba(255, 255, 255, 0.25);
outline-offset: -1px;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.btn-on-dark:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 10px 34px rgba(108, 92, 231, 0.7);
}
.btn-on-dark:disabled { opacity: 0.6; }

47
src/lib/buildCoverage.ts Normal file
View File

@ -0,0 +1,47 @@
/**
* .
*
* . . .
* ( JSON-LD ·GBP ·H1 )
* . .
*
* . AI가 ,
* · . "실행 항목 중 몇 건 해결"
* "어느 약점을 우회해서 보완하는가" .
*/
import type { CategoryId, CategoryScore } from '../types/discovery';
/** 빌드가 보완할 수 있는 카테고리. 크롤러 접근(A)·측정(E)·기술 위생(F)은 홈페이지 쪽이라 손댈 수 없다. */
const ADDRESSABLE: CategoryId[] = ['content', 'entity', 'surface'];
/** 카테고리별로, 빌드가 무엇을 만들어 어떻게 보완하는지 */
export const COVERAGE_PLAN: Record<string, { now: string; fix: string }> = {
content: {
now: '패시지 단위로 인용할 문장이 없습니다',
fix: '병원을 알아보는 사람이 실제로 묻는 질문을 골라, 한 단락만 떼어내도 뜻이 통하는 글로 답합니다.',
},
entity: {
now: '누가 말하는지 기계가 확정하지 못합니다',
fix: '의료진 이력과 병원 사실을 한자리에 모으고, 글마다 작성자와 감수 원장을 구조화해 밝힙니다.',
},
surface: {
now: 'AI가 인용할 표면이 좁습니다',
fix: '원장 설명 영상과 언론 보도를 글의 근거로 연결하고, 회복 일정과 방문 안내를 별도 페이지로 만듭니다.',
},
};
export type WeakSpot = { id: CategoryId; name: string; pct: number; now: string; fix: string };
/** 빌드가 보완할 수 있는 카테고리 중 점수가 낮은 순 */
export function weakSpots(categories: CategoryScore[]): WeakSpot[] {
return categories
.filter((c) => ADDRESSABLE.includes(c.category.id) && COVERAGE_PLAN[c.category.id])
.sort((a, b) => a.pct - b.pct)
.map((c) => ({
id: c.category.id,
name: c.category.name,
pct: c.pct,
now: COVERAGE_PLAN[c.category.id].now,
fix: COVERAGE_PLAN[c.category.id].fix,
}));
}

120
src/lib/buildPhases.ts Normal file
View File

@ -0,0 +1,120 @@
/**
* 17 5 .
*
* (workers/supporters-build/run.mjs) PHASES .
* STEPS phases .
* , .
*/
/** 워커가 supporter_builds.phase 에 쓰는 값 */
export type WorkerPhase =
| 'evidence' | 'ocr' | 'data' | 'discover' | 'youtube' | 'news' | 'images'
| 'tourism' | 'recovery' | 'planner' | 'inputs' | 'briefs' | 'generate'
| 'build' | 'deploy' | 'publish' | 'done';
export type PhaseRow = { phase: string; status: 'running' | 'ok' | 'warning' | 'failed'; summary?: string };
export type BuildStep = {
key: string;
/** 진행 중일 때 문구 */
label: string;
/** 끝났을 때 문구 */
labelDone: string;
/** 무엇을 하는지 한 줄 */
detail: string;
phases: WorkerPhase[];
};
export const BUILD_STEPS: BuildStep[] = [
{
key: 'collect',
label: '홈페이지를 읽는 중입니다',
labelDone: '홈페이지 자료 확보',
detail: '공개된 페이지와 이미지 속 글자까지 읽어 병원 정보와 의료진을 정리합니다.',
phases: ['evidence', 'ocr', 'data'],
},
{
key: 'sources',
label: '채널과 근거를 모으는 중입니다',
labelDone: '채널과 근거 확보',
detail: '유튜브·인스타그램·블로그 채널을 찾고, 기사와 사진을 근거로 모읍니다.',
phases: ['discover', 'youtube', 'news', 'images'],
},
{
key: 'guides',
label: '방문·회복 안내를 만드는 중입니다',
labelDone: '방문·회복 안내 완성',
detail: '오시는 길, 회복 일정, 머무는 동안의 주변 안내를 병원 위치 기준으로 만듭니다.',
phases: ['tourism', 'recovery', 'planner'],
},
{
key: 'write',
label: '질문에 답하는 글을 쓰는 중입니다',
labelDone: '글 작성 완료',
detail: '병원을 알아보는 사람이 실제로 묻는 질문을 골라, 공개 자료를 근거로 답합니다.',
phases: ['inputs', 'briefs', 'generate'],
},
{
key: 'publish',
label: '사이트를 만들고 올리는 중입니다',
labelDone: '사이트 준비 완료',
detail: '발행 기준을 검사한 뒤 사이트를 만들어 올립니다. 검색 노출은 확인을 마친 뒤에 켭니다.',
phases: ['build', 'deploy', 'publish', 'done'],
},
];
const PHASE_TO_STEP = new Map<string, number>();
BUILD_STEPS.forEach((s, i) => s.phases.forEach((p) => PHASE_TO_STEP.set(p, i)));
/** 전체 워커 단계 수. 진행률을 단계 묶음이 아니라 실제 단계 기준으로 센다. */
const TOTAL_PHASES = BUILD_STEPS.reduce((n, s) => n + s.phases.length, 0);
export type BuildState = 'queued' | 'running' | 'gate_failed' | 'failed' | 'preview' | 'published' | 'published_pending_tasks' | 'approved';
/** 지금 어느 묶음에 있는지. 모르는 단계면 -1. */
export function stepIndexOf(phase: string | null | undefined): number {
if (!phase) return -1;
return PHASE_TO_STEP.get(phase) ?? -1;
}
/** 끝난 워커 단계 수 기준 진행률 0~1 */
export function progressOf(phases: PhaseRow[] | null | undefined, state: BuildState): number {
if (state === 'preview' || state === 'published' || state === 'published_pending_tasks' || state === 'approved') return 1;
if (!phases?.length) return 0;
const done = phases.filter((p) => (p.status === 'ok' || p.status === 'warning') && PHASE_TO_STEP.has(p.phase)).length;
return Math.min(done / TOTAL_PHASES, 0.98);
}
/** 묶음별 상태. 화면에서 체크·스피너·대기를 고르는 데 쓴다. */
export type StepState = 'done' | 'active' | 'waiting' | 'failed';
export function stepStates(phases: PhaseRow[] | null | undefined, phase: string | null, state: BuildState): StepState[] {
const finished = state === 'preview' || state === 'published' || state === 'published_pending_tasks' || state === 'approved';
const cur = stepIndexOf(phase);
const rows = new Map((phases ?? []).map((p) => [p.phase, p.status]));
return BUILD_STEPS.map((s, i) => {
if (finished) return 'done';
if ((state === 'failed' || state === 'gate_failed') && i === cur) return 'failed';
const allDone = s.phases.every((p) => { const st = rows.get(p); return st === 'ok' || st === 'warning'; });
if (allDone) return 'done';
if (i === cur) return 'active';
if (cur > i) return 'done';
return 'waiting';
});
}
/** 경고가 남은 단계. 완료 화면에서 "확인 대기"로 보여 준다. */
export function warningsOf(phases: PhaseRow[] | null | undefined): PhaseRow[] {
return (phases ?? []).filter((p) => p.status === 'warning');
}
export const STATE_LABEL: Record<BuildState, string> = {
queued: '차례를 기다리는 중입니다',
running: '만드는 중입니다',
gate_failed: '발행 기준에서 멈췄습니다',
failed: '만들다가 멈췄습니다',
preview: '준비됐습니다',
approved: '승인 완료',
published: '검색 노출까지 켜졌습니다',
published_pending_tasks: '발행됐고 남은 확인이 있습니다',
};

View File

@ -17,7 +17,10 @@ import ApiDashboardPage from './pages/ApiDashboardPage.tsx';
import LoginPage from './pages/LoginPage.tsx';
import DiscoveryLandingPage from './pages/DiscoveryLandingPage.tsx';
import DiscoveryReportPage from './pages/DiscoveryReportPage.tsx';
import ActionPlanPage from './pages/ActionPlanPage.tsx';
import SupportersBuildPage from './pages/SupportersBuildPage.tsx';
import ImageReviewPage from './pages/ImageReviewPage.tsx';
import SiteBuildPage from './pages/SiteBuildPage.tsx';
import './index.css';
// 배포별 루트 페이지 분기. Vercel 프로젝트의 환경변수로 정한다.
@ -47,7 +50,10 @@ createRoot(document.getElementById('root')!).render(
<Route path="api-dashboard" element={<ApiDashboardPage />} />
<Route path="discovery" element={<DiscoveryLandingPage />} />
<Route path="discovery/:id" element={<DiscoveryReportPage />} />
<Route path="actions/:id" element={<ActionPlanPage />} />
<Route path="supporters/:id" element={<SupportersBuildPage />} />
<Route path="images/:id" element={<ImageReviewPage />} />
<Route path="build/:id" element={<SiteBuildPage />} />
</Route>
</Routes>
</BrowserRouter>

View File

@ -0,0 +1,80 @@
/**
* /actions/:id (Action Plan). (/discovery/:id) .
*
* "지금 무엇이 문제인가"(), "그래서 무엇을 하는가"().
* , .
* .
*/
import { useMemo } from 'react';
import { useParams, Navigate } from 'react-router';
import { AEO_GEO_RUBRIC } from '../data/aeoGeoRubric';
import { AEO_GEO_RUBRIC_V2 } from '../data/aeoGeoRubricV2';
import { DISCOVERY_RESULTS } from '../data/discoveryResults';
import { scoreDiscovery } from '../lib/discoveryScore';
import { scoreDiscoveryV2 } from '../lib/discoveryScoreV2';
import { ActionPlanSection } from '../components/discovery/ActionPlanSection';
import { IntegritySection } from '../components/discovery/IntegritySection';
import { BuildPlanSection } from '../components/discovery/BuildPlanSection';
import { PrismFilled } from '../components/icons/FilledIcons';
export default function ActionPlanPage() {
const { id } = useParams<{ id: string }>();
const result = id ? DISCOVERY_RESULTS[id] : undefined;
const overall = useMemo(
() => (result ? scoreDiscovery(AEO_GEO_RUBRIC, result) : null),
[result],
);
const overallV2 = useMemo(
() => (result ? scoreDiscoveryV2(AEO_GEO_RUBRIC_V2, result) : null),
[result],
);
if (!id) return null;
if (!result || !overall) {
// 개선 제안은 진단 항목 위에 세운다. 진단이 없으면 여기서 보여줄 근거가 없으니
// 진단 리포트로 보낸다. 그 페이지가 "아직 진단 전" 안내와 다음 링크를 갖고 있다.
return <Navigate to={`/discovery/${id}`} replace />;
}
return (
<div data-report-content className="[word-break:keep-all]">
<section className="on-dark bg-[#0A1128] text-white relative overflow-hidden pt-28 pb-16 md:pt-36 md:pb-20 px-6">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(108,92,231,0.18),transparent_60%)]" />
<div className="relative max-w-7xl mx-auto">
<div className="inline-flex items-center gap-2 rounded-full bg-white/10 border border-white/10 px-4 py-1.5 mb-6">
<PrismFilled size={16} className="text-purple-300" />
<span className="text-sm font-semibold tracking-wide text-purple-200">
INFINITH AI Discovery · Action Plan
</span>
</div>
<h1 className="font-serif text-4xl md:text-6xl font-bold tracking-[-0.02em] mb-4">
<span className="bg-gradient-to-r from-purple-300 to-blue-300 bg-clip-text text-transparent">
</span>
</h1>
<p className="text-3xl font-bold mb-2">{result.clinicName}</p>
<p className="ui-body text-white/70 max-w-2xl">
(v{result.rubricVersion} · {overall.score}/{overall.grade}) ,
.
</p>
</div>
</section>
<ActionPlanSection result={result} />
<IntegritySection result={result} />
<BuildPlanSection
result={result}
overall={overall}
summary={{
rubricVersion: result.rubricVersion,
grade: overall.grade,
score: overall.score,
aeo: overallV2?.aeo.score,
geo: overallV2?.geo.score,
verified: overall.categories.reduce((n, c) => n + c.verifiedCount, 0),
total: AEO_GEO_RUBRIC.criteria.length,
}}
/>
</div>
);
}

View File

@ -192,7 +192,7 @@ export default function ClinicProfilePage() {
<div className="pt-20 min-h-screen bg-slate-50">
{/* ── Hero / Clinic Header ── */}
<div className="bg-[#0A1128] pt-10 pb-16 px-6 relative overflow-hidden">
<div className="on-dark bg-[#0A1128] pt-10 pb-16 px-6 relative overflow-hidden">
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-[#7B2D8E]/10 blur-[120px]" />
<div className="absolute bottom-0 left-0 w-[300px] h-[300px] rounded-full bg-purple-500/5 blur-[100px]" />
@ -253,7 +253,7 @@ export default function ClinicProfilePage() {
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
>
<h2 className="font-serif text-lg font-bold text-primary-900 mb-4 flex items-center gap-2">
<Building2 size={18} className="text-[#6C5CE7]" />
@ -304,7 +304,7 @@ export default function ClinicProfilePage() {
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
>
<h2 className="font-serif text-lg font-bold text-primary-900 mb-4 flex items-center gap-2">
<Star size={18} className="text-[#6C5CE7]" />
@ -348,7 +348,7 @@ export default function ClinicProfilePage() {
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
>
<h2 className="font-serif text-lg font-bold text-primary-900 mb-4 flex items-center gap-2">
<Stethoscope size={18} className="text-[#6C5CE7]" />
@ -396,7 +396,7 @@ export default function ClinicProfilePage() {
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
>
<h2 className="font-serif text-lg font-bold text-primary-900 mb-4 flex items-center gap-2">
<Heart size={18} className="text-[#6C5CE7]" />
@ -428,7 +428,7 @@ export default function ClinicProfilePage() {
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
>
<h2 className="font-serif text-lg font-bold text-primary-900 mb-4 flex items-center gap-2">
<ShieldCheck size={18} className="text-[#6C5CE7]" />
@ -464,7 +464,7 @@ export default function ClinicProfilePage() {
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
>
<h2 className="font-serif text-lg font-bold text-primary-900 mb-4 flex items-center gap-2">
<TrendingUp size={18} className="text-[#6C5CE7]" />

View File

@ -237,7 +237,7 @@ export default function DataValidationPage() {
return (
<div className="pt-20 min-h-screen">
{/* ── Header ── */}
<div className="bg-[#0A1128] py-14 px-6 relative overflow-hidden">
<div className="on-dark bg-[#0A1128] py-14 px-6 relative overflow-hidden">
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-[#6C5CE7]/10 blur-[120px]" />
<div className="absolute bottom-0 left-0 w-[300px] h-[300px] rounded-full bg-purple-500/5 blur-[100px]" />
<div className="max-w-6xl mx-auto relative">
@ -259,7 +259,7 @@ export default function DataValidationPage() {
<h2 className="font-serif text-2xl font-bold text-primary-900 mb-2"> </h2>
<p className="text-slate-500 text-sm mb-6">URL </p>
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 md:p-8">
<div className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 md:p-8">
<div className="flex flex-col md:flex-row items-start md:items-center gap-4 md:gap-0">
{CRAWL_STEPS.map((step, i) => {
const Icon = step.icon;
@ -309,7 +309,7 @@ export default function DataValidationPage() {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.1 }}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden"
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden"
>
{/* Card Header */}
<div className="p-5 pb-4 flex items-center justify-between">
@ -409,7 +409,7 @@ export default function DataValidationPage() {
</motion.div>
{/* Alternatives Table */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden">
<div className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden">
<div className="grid grid-cols-[1fr_1fr_auto_auto] gap-x-4 px-5 py-3 bg-slate-50 border-b border-slate-100 text-xs font-semibold text-slate-400 uppercase tracking-wider">
<span> </span>
<span> </span>
@ -443,7 +443,7 @@ export default function DataValidationPage() {
<h2 className="font-serif text-2xl font-bold text-primary-900 mb-2"> </h2>
<p className="text-slate-500 text-sm mb-6">3 100% </p>
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 md:p-8 space-y-5">
<div className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 md:p-8 space-y-5">
{COVERAGE.map((item, i) => (
<motion.div
key={item.method}
@ -548,7 +548,7 @@ export default function DataValidationPage() {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.08 }}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5 text-center"
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5 text-center"
>
<p className="text-xs text-slate-400 mb-1">{item.name}</p>
<p className="text-xl font-bold text-primary-900 mb-0.5">{item.cost}</p>

View File

@ -1,16 +1,15 @@
import { useMemo } from 'react';
import type { ComponentType, ReactNode } from 'react';
import type { ComponentType } from 'react';
import { useParams, Link } from 'react-router';
import { motion } from 'motion/react';
import { AEO_GEO_RUBRIC } from '../data/aeoGeoRubric';
import { AEO_GEO_RUBRIC_V2 } from '../data/aeoGeoRubricV2';
import { scoreDiscoveryV2 } from '../lib/discoveryScoreV2';
import { AeoGeoV2Panel } from '../components/discovery/AeoGeoV2Panel';
import { ClinicInputsPanel } from '../components/discovery/ClinicInputsPanel';
import { Tag } from '../components/discovery/Tag';
import { DISCOVERY_RESULTS } from '../data/discoveryResults';
import { scoreDiscovery, levelToSeverity } from '../lib/discoveryScore';
import type {
ActionPriority,
CategoryId,
CategoryScore,
ControlLevel,
@ -64,12 +63,6 @@ const METHOD_LABEL: Record<EvidenceMethod, string> = {
manual: '수동',
};
const PRIORITY_STYLE: Record<ActionPriority, string> = {
P0: 'bg-[#FFF0F0] text-[#7C3A4B] border-[#F5D5DC]',
P1: 'bg-[#FFF6ED] text-[#7C5C3A] border-[#F5E0C5]',
P2: 'bg-[#F3F0FF] text-[#4A3A7C] border-[#D5CDF5]',
};
const scoreColor = (pct: number) =>
pct <= 40 ? '#C084CF' : pct <= 60 ? '#8B9CF7' : pct <= 80 ? '#7C6DD8' : '#6C5CE7';
@ -87,13 +80,10 @@ export default function DiscoveryReportPage() {
[result],
);
// 진단은 AEO/GEO 36항목 실측 결과가 있어야 그린다. 사이트 빌드와는 별개 산출물이라
// 빌드만 끝난 병원은 여기에 들어올 수 있다. 막다른 길로 두지 않고 다음 단계로 잇는다.
if (!result || !overall) {
return (
<div className="pt-36 pb-24 px-6 text-center">
<p className="text-slate-600"> .</p>
<Link to="/" className="text-[#6C5CE7] text-base mt-4 inline-block"></Link>
</div>
);
return <NoReport clinicId={id ?? ''} />;
}
const resultById = new Map(result.results.map((r) => [r.criterionId, r]));
@ -101,7 +91,7 @@ export default function DiscoveryReportPage() {
return (
<div data-report-content className="[word-break:keep-all]">
{/* ── 1. 헤더 (dark) ── */}
<section className="bg-[#0A1128] text-white relative overflow-hidden pt-28 pb-16 md:pt-36 md:pb-20 px-6">
<section className="on-dark bg-[#0A1128] text-white relative overflow-hidden pt-28 pb-16 md:pt-36 md:pb-20 px-6">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(108,92,231,0.18),transparent_60%)]" />
<div className="relative max-w-7xl mx-auto grid lg:grid-cols-[1fr_auto] gap-12 items-center">
<div>
@ -132,19 +122,19 @@ export default function DiscoveryReportPage() {
</p>
</div>
<div className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-8 flex flex-col items-center gap-4 min-w-[260px]">
<div className="on-light bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-8 flex flex-col items-center gap-4 min-w-[260px]">
<div className="flex items-start gap-8">
{overallV2 && [overallV2.aeo, overallV2.geo].map((ax) => (
<div key={ax.axis.id} className="flex flex-col items-center gap-2">
<ScoreRing score={ax.score} size={130} label={ax.axis.nameEn} />
<div className="flex items-baseline gap-2">
<span className="font-serif text-3xl font-black text-[#0A1128]">{ax.grade}</span>
<span className="text-sm text-slate-500">{ax.axis.code} {ax.axis.name}</span>
<span className="ui-note">{ax.axis.code} {ax.axis.name}</span>
</div>
</div>
))}
</div>
<p className="text-sm text-slate-500">
<p className="ui-note">
v1.0 {overall.score}/{overall.grade} · {overall.earned} / {overall.possible}
</p>
</div>
@ -175,13 +165,13 @@ export default function DiscoveryReportPage() {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.08 }}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
>
<div className="flex items-start justify-between gap-4 mb-3">
<h3 className="text-xl font-bold text-[#0A1128]">{f.title}</h3>
<FindingIcon severity={f.severity} />
</div>
<p className="text-base text-slate-600 leading-relaxed">{f.detail}</p>
<p className="ui-body">{f.detail}</p>
</motion.div>
))}
</div>
@ -201,7 +191,7 @@ export default function DiscoveryReportPage() {
return (
<div
key={cs.category.id}
className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 text-[#0A1128]"
className="on-light bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 text-[#0A1128]"
>
<div className="flex flex-wrap items-center justify-between gap-4 mb-5">
<div>
@ -217,7 +207,7 @@ export default function DiscoveryReportPage() {
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-sm text-slate-400"> {cs.weight} · {cs.verifiedCount}/{cs.totalCount}</p>
<p className="ui-note"> {cs.weight} · {cs.verifiedCount}/{cs.totalCount}</p>
<p className="text-base font-semibold">{cs.earned} / {cs.possible}</p>
</div>
<ScoreRing score={cs.pct} size={64} />
@ -234,14 +224,14 @@ export default function DiscoveryReportPage() {
return (
<div key={c.id} className="py-4 grid md:grid-cols-[170px_1fr_auto] gap-3 items-start">
<div>
<span className="text-sm font-semibold text-slate-400">{c.id}</span>
<span className="ui-note font-semibold">{c.id}</span>
<p className="text-base font-bold">{c.name}</p>
<p className="text-sm text-slate-400 mt-0.5">{c.weight}</p>
<p className="ui-note mt-0.5">{c.weight}</p>
</div>
<div>
<p className="text-base text-slate-700">{r?.evidence}</p>
<p className="ui-body">{r?.evidence}</p>
{r?.source && (
<p className="text-sm text-slate-400 mt-1 font-mono break-all">{r.source}</p>
<p className="ui-note mt-1 font-mono break-all">{r.source}</p>
)}
<div className="flex flex-wrap gap-1.5 mt-2">
<Tag>{EVIDENCE_LABEL[c.evidenceGrade]}</Tag>
@ -252,7 +242,7 @@ export default function DiscoveryReportPage() {
<div className="flex flex-col items-end gap-1">
<SeverityBadge severity={levelToSeverity(level)} label={levelLabel} className="text-base font-bold px-4 py-1.5" />
{level !== 'unverified' && (
<span className="text-sm text-slate-400">
<span className="ui-note">
{Math.round((c.weight * level) / 3 * 10) / 10} / {c.weight}
</span>
)}
@ -267,106 +257,19 @@ export default function DiscoveryReportPage() {
</div>
</SectionWrapper>
{/* ── 4. 실행 계획 (light) ── */}
<SectionWrapper
id="actions"
title="Action Plan"
subtitle="우선순위별 실행 항목. 각 항목이 어느 기준을 올리는지 연결"
>
<div className="space-y-4">
{(['P0', 'P1', 'P2'] as ActionPriority[]).map((p) => (
<div key={p}>
<div className="flex items-center gap-3 mb-3 mt-6 first:mt-0">
<span className={`inline-flex items-center rounded-full text-sm font-semibold px-3 py-1 border ${PRIORITY_STYLE[p]}`}>
{p}
</span>
<span className="text-lg font-bold text-[#0A1128]">
{p === 'P0' ? '즉시: 크롤러 접근·엔티티·측정' : p === 'P1' ? '4주 내: 콘텐츠 구조' : '지속: 신선도·시딩'}
</span>
</div>
<div className="grid md:grid-cols-2 gap-4">
{result.actions.filter((a) => a.priority === p).map((a, i) => (
<motion.div
key={a.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.08 }}
className="bg-white rounded-2xl border border-slate-100 shadow-sm p-5"
>
<div className="flex items-start justify-between gap-3 mb-2">
<h4 className="font-bold text-[#0A1128]">{a.title}</h4>
<span className="text-sm text-slate-400 shrink-0">{a.effort}</span>
</div>
<p className="text-base text-slate-600 leading-relaxed mb-3">{a.detail}</p>
<div className="flex flex-wrap gap-1.5">
{a.criterionIds.map((cid) => (
<Tag key={cid}>{cid}</Tag>
))}
</div>
</motion.div>
))}
</div>
</div>
))}
</div>
</SectionWrapper>
{/* (Action Plan)· · · /actions/:id .
() ( ) .
/supporters/:id . */}
{/* ── 5. 하지 않을 것 + 측정 조건 (dark) ── */}
<SectionWrapper
id="integrity"
title="What We Don't Sell"
subtitle="근거 없는 것은 팔지 않는다. 이 리포트의 정직성 규약"
dark
>
<div className="grid lg:grid-cols-2 gap-6">
<div className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 text-[#0A1128]">
<h3 className="text-xl font-bold mb-4 font-sans text-[#0A1128]!"> </h3>
<ul className="space-y-4">
{result.notDoing.map((n) => (
<li key={n.item} className="flex gap-3">
<span className="shrink-0 text-[#D4889A] mt-0.5"><CrossFilled size={18} /></span>
<div>
<p className="text-base font-semibold">{n.item}</p>
<p className="text-sm text-slate-500 mt-0.5">{n.reason}</p>
</div>
</li>
))}
</ul>
</div>
<div className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 text-[#0A1128]">
<h3 className="text-xl font-bold mb-4 font-sans text-[#0A1128]!"> </h3>
<ul className="space-y-3">
{result.conditions.map((c) => (
<li key={c} className="flex gap-3">
<span className="shrink-0 text-[#9B8AD4] mt-0.5"><CheckFilled size={18} /></span>
<p className="text-base text-slate-600">{c}</p>
</li>
))}
</ul>
<div className="mt-6 rounded-xl p-4 bg-[linear-gradient(to_right,#fff3eb,#e4cfff,#f5f9ff)]">
<p className="text-sm text-slate-600 leading-relaxed">
<strong> </strong> . (·GBP·GA4·)
, . ,
·· <strong></strong> .
</p>
</div>
</div>
</div>
</SectionWrapper>
{/* ── 5b. 서포터즈 자동 빌드 · 병원 확인 항목 (light, v2 §7-5) ── */}
<ClinicInputsPanel clinicId={result.id} />
{/* ── 6. 채점 기준표 (light) ── */}
{/* ── 4. 채점 기준표 (light) ── */}
<SectionWrapper
id="rubric"
title="Scoring Rubric"
subtitle={`실측 기준표 v${AEO_GEO_RUBRIC.version} · ${AEO_GEO_RUBRIC.criteria.length}개 항목. v2.0 AEO/GEO 점수는 이 항목들을 파생 신호로 쓴다`}
>
<div className="overflow-x-auto rounded-2xl border border-slate-100 shadow-sm bg-white">
<div className="on-light overflow-x-auto rounded-2xl border border-slate-100 shadow-sm bg-white">
<table className="w-full text-base min-w-[860px]">
<thead className="bg-[#0A1128] text-white">
<thead className="on-dark bg-[#0A1128] text-white">
<tr>
<th className="text-left px-4 py-3 font-semibold w-16">ID</th>
<th className="text-left px-4 py-3 font-semibold"></th>
@ -395,6 +298,50 @@ export default function DiscoveryReportPage() {
/* ────────────────────────── 서브 컴포넌트 ────────────────────────── */
/** 진단 데이터가 없는 병원. 빌드·사진 확인으로 잇고, 진단이 무엇인지 한 줄로 알린다. */
function NoReport({ clinicId }: { clinicId: string }) {
return (
<div className="[word-break:keep-all]">
<section className="on-dark bg-[#0A1128] text-white relative overflow-hidden pt-28 pb-16 md:pt-36 md:pb-20 px-6">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(108,92,231,0.18),transparent_60%)]" />
<div className="relative max-w-3xl mx-auto">
<div className="inline-flex items-center gap-2 rounded-full bg-white/10 border border-white/10 px-4 py-1.5 mb-6">
<PrismFilled size={16} className="text-purple-300" />
<span className="text-sm font-semibold tracking-wide text-purple-200">INFINITH AI Discovery</span>
</div>
<h1 className="font-serif text-4xl md:text-5xl font-bold tracking-[-0.02em] mb-4">
<span className="bg-gradient-to-r from-purple-300 to-blue-300 bg-clip-text text-transparent">No Audit Yet</span>
</h1>
<p className="text-2xl font-bold mb-3">{clinicId}</p>
<p className="ui-body">
AEO/GEO .{' '}
36 , .
</p>
</div>
</section>
<section className="bg-white py-16 md:py-20 px-6">
<div className="max-w-3xl mx-auto">
<p className="ui-ask ui-ask-block mb-8"> .</p>
<div className="grid sm:grid-cols-2 gap-4">
<Link to={`/build/${clinicId}`} className="rounded-2xl border border-slate-200 p-5 hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow">
<p className="font-bold text-[#1D0024] mb-1"> </p>
<p className="ui-note"> .</p>
</Link>
<Link to={`/images/${clinicId}`} className="rounded-2xl border border-slate-200 p-5 hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow">
<p className="font-bold text-[#1D0024] mb-1"> </p>
<p className="ui-note"> .</p>
</Link>
</div>
<p className="ui-note mt-8">
<Link to="/discovery/viewclinic" className="underline underline-offset-4 text-[#4A3A7C]"> </Link> .
</p>
</div>
</section>
</div>
);
}
function CategoryBar({ cs, index }: { key?: string; cs: CategoryScore; index: number }) {
const Icon = CATEGORY_ICON[cs.category.id];
return (
@ -403,14 +350,14 @@ function CategoryBar({ cs, index }: { key?: string; cs: CategoryScore; index: nu
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 + index * 0.06 }}
className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow p-5 text-[#0A1128]"
className="on-light bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow p-5 text-[#0A1128]"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-[#6C5CE7]"><Icon size={18} /></span>
<span className="text-base font-bold">{cs.category.code}. {cs.category.name}</span>
</div>
<span className="text-sm text-slate-400">{cs.weight}</span>
<span className="ui-note">{cs.weight}</span>
</div>
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
<motion.div
@ -437,14 +384,6 @@ function FindingIcon({ severity }: { severity: 'critical' | 'warning' | 'good' }
return <span className="text-[#D4889A] shrink-0"><CrossFilled size={22} /></span>;
}
function Tag({ children }: { key?: string; children: ReactNode }) {
return (
<span className="inline-flex items-center rounded-full bg-slate-100 text-slate-600 text-sm font-medium px-2 py-0.5">
{children}
</span>
);
}
function RubricRows({ categoryName, criteria }: { key?: string; categoryName: string; criteria: RubricCriterion[] }) {
return (
<>
@ -458,8 +397,8 @@ function RubricRows({ categoryName, criteria }: { key?: string; categoryName: st
<td className="px-4 py-3 font-semibold text-slate-500">{c.id}</td>
<td className="px-4 py-3">
<p className="font-semibold text-[#0A1128]">{c.name}</p>
<p className="text-sm text-slate-500 mt-1 leading-relaxed">{c.rationale}</p>
<p className="text-sm text-slate-400 mt-1 font-mono">{c.howToCheck}</p>
<p className="ui-note mt-1">{c.rationale}</p>
<p className="ui-note mt-1 font-mono">{c.howToCheck}</p>
</td>
<td className="px-4 py-3 font-semibold">{c.weight}</td>
<td className="px-4 py-3 text-slate-600">{EVIDENCE_LABEL[c.evidenceGrade]}</td>

View File

@ -97,7 +97,7 @@ export default function DistributionPage() {
return (
<div className="pt-20 min-h-screen">
{/* Header */}
<div className="bg-[#0A1128] py-14 px-6 relative overflow-hidden">
<div className="on-dark bg-[#0A1128] py-14 px-6 relative overflow-hidden">
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-[#6C5CE7]/10 blur-[120px]" />
<div className="max-w-5xl mx-auto relative">
<p className="text-xs font-semibold text-purple-300 tracking-widest uppercase mb-3">Content Distribution</p>

View File

@ -0,0 +1,37 @@
/**
* /images/:id .
* . (/supporters/:id) supporter_inputs .
*/
import { useParams, Link } from 'react-router';
import { ImageReviewPanel } from '../components/discovery/ImageReviewPanel';
export default function ImageReviewPage() {
const { id } = useParams<{ id: string }>();
if (!id) return null;
return (
<div className="[word-break:keep-all]">
<section className="on-dark bg-[#0A1128] text-white relative overflow-hidden pt-28 pb-14 md:pt-36 md:pb-16 px-6">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(108,92,231,0.18),transparent_60%)]" />
<div className="relative max-w-7xl mx-auto">
<div className="inline-flex items-center gap-2 rounded-full bg-white/10 border border-white/10 px-4 py-1.5 mb-6">
<span className="text-sm font-semibold tracking-wide text-purple-200">INFINITH AI Discovery · Photo Check</span>
</div>
<h1 className="font-serif text-4xl md:text-6xl font-bold tracking-[-0.02em] mb-3">
<span className="bg-gradient-to-r from-purple-300 to-blue-300 bg-clip-text text-transparent"> </span>
</h1>
<p className="ui-body">
.{' '}
.
</p>
<p className="ui-ask ui-ask-block mt-4"> .</p>
<p className="text-purple-200 text-sm mt-4">
ID <span className="font-mono text-white">{id}</span>
{' · '}
<Link to={`/supporters/${id}`} className="underline underline-offset-4 text-purple-300"> </Link>
</p>
</div>
</section>
<ImageReviewPanel clinicId={id} />
</div>
);
}

View File

@ -143,7 +143,7 @@ export default function PerformancePage() {
return (
<div className="pt-20 min-h-screen">
{/* Header */}
<div className="bg-[#0A1128] py-14 px-6 relative overflow-hidden">
<div className="on-dark bg-[#0A1128] py-14 px-6 relative overflow-hidden">
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-[#6C5CE7]/10 blur-[120px]" />
<div className="absolute bottom-0 left-0 w-[300px] h-[300px] rounded-full bg-purple-500/5 blur-[100px]" />
<div className="max-w-6xl mx-auto relative">
@ -177,7 +177,7 @@ export default function PerformancePage() {
{OVERVIEW_STATS.map((stat, i) => (
<motion.div
key={stat.label}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-4"
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-4"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.05 }}
@ -190,7 +190,7 @@ export default function PerformancePage() {
</div>
{/* ═══ Section 1: Marketing Funnel ═══ */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
<div className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="font-serif font-bold text-xl text-[#0A1128]"> </h3>
@ -260,7 +260,7 @@ export default function PerformancePage() {
</div>
{/* ═══ Section 2: Channel Trend (Stacked Bar) ═══ */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
<div className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="font-serif font-bold text-xl text-[#0A1128]"> </h3>
@ -298,7 +298,7 @@ export default function PerformancePage() {
animate={{ scaleY: 1 }}
transition={{ duration: 0.5, delay: wi * 0.1 }}
>
<div className="absolute -top-8 left-1/2 -translate-x-1/2 hidden group-hover:block bg-[#0A1128] text-white text-xs px-2 py-1 rounded whitespace-nowrap z-10">
<div className="on-dark absolute -top-8 left-1/2 -translate-x-1/2 hidden group-hover:block bg-[#0A1128] text-white text-xs px-2 py-1 rounded whitespace-nowrap z-10">
{ch.label}: {val}K
</div>
</motion.div>
@ -321,7 +321,7 @@ export default function PerformancePage() {
</div>
{/* ═══ Section 3: Day × Time Heatmap ═══ */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
<div className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="font-serif font-bold text-xl text-[#0A1128]"> </h3>
@ -387,7 +387,7 @@ export default function PerformancePage() {
return (
<motion.div
key={ch.id}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5"
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.08 }}
@ -421,8 +421,8 @@ export default function PerformancePage() {
{/* Top Content */}
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4"> TOP 5</h3>
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden mb-10">
<div className="grid grid-cols-[1fr_100px_80px_80px_60px_70px_70px] gap-2 px-5 py-3 bg-[#0A1128] text-white text-xs font-medium">
<div className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden mb-10">
<div className="on-dark grid grid-cols-[1fr_100px_80px_80px_60px_70px_70px] gap-2 px-5 py-3 bg-[#0A1128] text-white text-xs font-medium">
<span></span>
<span></span>
<span className="text-right"></span>

View File

@ -194,7 +194,7 @@ const TierCard: React.FC<TierCardProps> = ({ tier, billing, onSelect }) => {
{/* 이름 + 태그라인 */}
<div className="mb-6">
<h3 className="text-2xl font-serif font-bold text-primary-900 mb-2">{tier.name}</h3>
<p className="text-sm text-slate-500 leading-relaxed break-keep">{tier.tagline}</p>
<p className="ui-note">{tier.tagline}</p>
</div>
{/* 가격 */}
@ -203,7 +203,7 @@ const TierCard: React.FC<TierCardProps> = ({ tier, billing, onSelect }) => {
<span className="text-4xl font-bold text-primary-900 tracking-tight">
{formatKRW(price)}
</span>
<span className="text-slate-500 text-sm">/</span>
<span className="ui-note">/</span>
</div>
{isAnnual && (
<p className="text-xs text-accent font-semibold mt-2">

241
src/pages/SiteBuildPage.tsx Normal file
View File

@ -0,0 +1,241 @@
/**
* /build/:id "사이트로 빌드" .
*
* (/report/loading) UX ,
* supporter_builds .
* .
*/
import { useEffect, useRef, useState } from 'react';
import { useParams, Link } from 'react-router';
import { motion } from 'motion/react';
import { supabase } from '../lib/supabase';
import { BUILD_STEPS, STATE_LABEL, progressOf, stepStates, warningsOf } from '../lib/buildPhases';
import type { BuildState, PhaseRow } from '../lib/buildPhases';
import { CheckFilled, WarningFilled, EyeFilled, PrismFilled } from '../components/icons/FilledIcons';
type Build = {
id: string;
clinic_id: string;
clinic_name: string | null;
status: BuildState;
phase: string | null;
phases: PhaseRow[] | null;
posts: Array<{ id: string; title: string }> | null;
preview_url: string | null;
error: string | null;
updated_at: string;
};
const POLL_MS = 4000;
export default function SiteBuildPage() {
const { id } = useParams<{ id: string }>();
const [build, setBuild] = useState<Build | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const timer = useRef<number | null>(null);
useEffect(() => {
if (!id) return;
let alive = true;
async function tick() {
const { data, error } = await supabase
.from('supporter_builds')
.select('id,clinic_id,clinic_name,status,phase,phases,posts,preview_url,error,updated_at')
.eq('clinic_id', id)
.order('created_at', { ascending: false })
.limit(1);
if (!alive) return;
if (error) setLoadError(error.message);
const row = (data?.[0] as Build) ?? null;
setBuild(row);
setLoading(false);
// 끝났으면 더 부르지 않는다.
const done = row && ['preview', 'published', 'published_pending_tasks', 'approved', 'failed', 'gate_failed'].includes(row.status);
if (!done) timer.current = window.setTimeout(tick, POLL_MS);
}
void tick();
return () => { alive = false; if (timer.current) window.clearTimeout(timer.current); };
}, [id]);
if (!id) return null;
const state: BuildState = build?.status ?? 'queued';
const finished = ['preview', 'published', 'published_pending_tasks', 'approved'].includes(state);
const stopped = state === 'failed' || state === 'gate_failed';
const states = stepStates(build?.phases, build?.phase ?? null, state);
const pct = Math.round(progressOf(build?.phases, state) * 100);
const warnings = warningsOf(build?.phases);
const name = build?.clinic_name || id;
return (
<div className="on-dark min-h-screen bg-[#0A1128] text-white relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(108,92,231,0.18),transparent_60%)]" />
<div className="relative max-w-3xl mx-auto px-6 pt-28 pb-24 md:pt-36">
<div className="inline-flex items-center gap-2 rounded-full bg-white/10 border border-white/10 px-4 py-1.5 mb-8">
<PrismFilled size={16} className="text-purple-300" />
<span className="text-sm font-semibold tracking-wide text-purple-200">INFINITH AI Discovery · Site Build</span>
</div>
<h1 className="font-serif text-4xl md:text-5xl font-bold tracking-[-0.02em] mb-3">
<span className="bg-gradient-to-r from-purple-300 to-blue-300 bg-clip-text text-transparent">
{finished ? 'Your Site Is Ready' : 'Building Your Site'}
</span>
</h1>
<p className="text-2xl font-bold mb-1">{name}</p>
<p className="text-purple-200 text-base mb-12">{STATE_LABEL[state]}</p>
{loading && <p className="text-white/50"> .</p>}
{!loading && !build && (
<div className="bg-white/5 border border-white/10 rounded-2xl p-8">
<h2 className="text-lg font-bold mb-2"> </h2>
<p className="text-white/60 text-base leading-[1.75]">
{loadError ? `상태를 불러오지 못했습니다. ${loadError}` : '진단 리포트에서 사이트로 빌드를 눌러 주세요.'}
</p>
<Link to={`/discovery/${id}`} className="inline-block mt-6 rounded-full bg-white/10 border border-white/20 px-6 py-3 text-base font-semibold hover:bg-white/15 transition-colors">
</Link>
</div>
)}
{!loading && build && (
<>
{/* 단계 */}
<div className="space-y-6 mb-12">
{BUILD_STEPS.map((step, i) => {
const st = states[i];
return (
<motion.div
key={step.key}
initial={{ opacity: 0, x: -16 }}
animate={{ opacity: st === 'waiting' ? 0.35 : 1, x: 0 }}
transition={{ duration: 0.4, delay: i * 0.08 }}
className="flex items-start gap-4"
>
<div className="w-7 h-7 shrink-0 flex items-center justify-center mt-0.5">
{st === 'done' ? (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
className="w-7 h-7 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#6C5CE7] flex items-center justify-center"
>
<CheckFilled size={14} className="text-white" />
</motion.div>
) : st === 'active' ? (
<div className="w-7 h-7 rounded-full border-2 border-purple-400 border-t-transparent animate-spin" />
) : st === 'failed' ? (
<div className="w-7 h-7 rounded-full bg-[#FFF0F0] flex items-center justify-center">
<WarningFilled size={14} className="text-[#7C3A4B]" />
</div>
) : (
<div className="w-7 h-7 rounded-full border-2 border-white/10" />
)}
</div>
<div>
<p className={`text-base font-semibold ${st === 'done' ? 'text-white' : st === 'active' ? 'text-purple-200' : st === 'failed' ? 'text-[#F5D5DC]' : 'text-white/40'}`}>
{st === 'done' ? step.labelDone : step.label}
</p>
<p className="ui-note mt-0.5">{step.detail}</p>
</div>
</motion.div>
);
})}
</div>
{/* 진행바 */}
<div className="w-full h-2 bg-white/10 rounded-full overflow-hidden">
<motion.div
initial={{ width: '0%' }}
animate={{ width: `${pct}%` }}
transition={{ duration: 0.8, ease: 'easeInOut' }}
className="h-full bg-gradient-to-r from-[#4F1DA1] to-[#6C5CE7] rounded-full"
/>
</div>
<p className="text-white/40 text-sm mt-4">
{finished
? `${build.posts?.length ?? 0}편의 글과 안내 페이지를 만들었습니다.`
: '창을 닫으셔도 계속 진행됩니다. 보통 10분 안팎 걸립니다.'}
</p>
{/* 멈춤 */}
{stopped && (
<div className="mt-10 bg-white/5 border border-[#F5D5DC]/30 rounded-2xl p-6">
<h2 className="text-lg font-bold mb-2 text-[#F5D5DC]">
{state === 'gate_failed' ? '발행 기준에서 멈췄습니다' : '만들다가 멈췄습니다'}
</h2>
<p className="ui-body">
{state === 'gate_failed'
? '사이트에 실으면 안 되는 내용이 걸려 배포하지 않고 멈췄습니다. 담당자가 확인한 뒤 다시 진행합니다.'
: '자동으로 처리하지 못한 지점이 있습니다. 담당자가 확인한 뒤 다시 진행합니다.'}
</p>
{build.error && <p className="text-white/40 text-sm mt-3 font-mono break-all">{build.error.slice(0, 300)}</p>}
</div>
)}
{/* 완료 */}
{finished && (
<div className="mt-12 grid gap-4">
<div className="on-light bg-white rounded-2xl p-6 md:p-7 shadow-[3px_4px_12px_rgba(0,0,0,0.2)]">
<h2 className="ui-ask text-lg mb-2"> </h2>
<p className="ui-body mb-5">
.{' '}
.
</p>
<div className="grid sm:grid-cols-2 gap-3">
<Link to={`/images/${build.clinic_id}`} className="rounded-xl border border-slate-200 p-4 hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow">
<p className="font-bold text-[#1D0024] mb-1"> </p>
<p className="ui-body"> .</p>
</Link>
<Link to={`/supporters/${build.clinic_id}`} className="rounded-xl border border-slate-200 p-4 hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow">
<p className="font-bold text-[#1D0024] mb-1"> </p>
<p className="ui-body"> .</p>
</Link>
</div>
</div>
{build.preview_url && (
<a
href={build.preview_url}
target="_blank"
rel="noreferrer"
className="btn-on-dark justify-center"
>
<EyeFilled size={16} /> {state === 'published' || state === 'approved' ? '공개본 사이트 열기' : '초안 사이트 열기'}
</a>
)}
{warnings.length > 0 && (
<div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-2 text-[#F5E0C5]">
<WarningFilled size={15} />
<h3 className="text-base font-bold"> </h3>
</div>
<ul className="flex flex-col gap-1.5">
{warnings.map((w) => (
<li key={w.phase} className="ui-note">
{w.summary?.split('\n')[0] ?? w.phase}
</li>
))}
</ul>
<p className="text-sm text-white/40 mt-3"> . .</p>
</div>
)}
</div>
)}
<p className="text-white/30 text-xs mt-10">
{build.updated_at.slice(0, 16).replace('T', ' ')}
{' · '}
<Link to={`/discovery/${build.clinic_id}`} className="underline underline-offset-4"> </Link>
</p>
</>
)}
</div>
</div>
);
}

View File

@ -10,7 +10,7 @@ export default function SupportersBuildPage() {
if (!id) return null;
return (
<div className="[word-break:keep-all]">
<section className="bg-[#0A1128] text-white relative overflow-hidden pt-28 pb-14 md:pt-36 md:pb-16 px-6">
<section className="on-dark bg-[#0A1128] text-white relative overflow-hidden pt-28 pb-14 md:pt-36 md:pb-16 px-6">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(108,92,231,0.18),transparent_60%)]" />
<div className="relative max-w-7xl mx-auto">
<div className="inline-flex items-center gap-2 rounded-full bg-white/10 border border-white/10 px-4 py-1.5 mb-6">
@ -20,8 +20,8 @@ export default function SupportersBuildPage() {
<span className="bg-gradient-to-r from-purple-300 to-blue-300 bg-clip-text text-transparent">Supporters Preview</span>
</h1>
<p className="text-purple-200 text-base"> ID <span className="font-mono text-white">{id}</span></p>
<p className="text-white/60 text-sm mt-4">
. , .
<p className="ui-ask ui-ask-block mt-4">
, .
{' '}<Link to="/discovery" className="underline underline-offset-4 text-purple-300">AI Discovery </Link>
</p>
</div>

View File

@ -100,6 +100,7 @@ document.addEventListener('click',function(e){var a=e.target.closest&&e.target.c
<header class="site-header">
<div class="wrap-wide bar">
<a class="brand" href={homeHref}>{has(S.logo?.src) ? <img class="brand-logo" src={S.logo.src} alt={S.logo.alt} width={S.logo.width} height={S.logo.height} /> : <span class="brand-logo brand-name">{v(f.shortName, '병원')}</span>}<span class="brand-sep" aria-hidden="true"></span><span class="brand-text">{u.brand} <small>{S.siteNameEn || 'SUPPORTERS'}</small></span></a>
{!INDEXABLE && <span class="draft-badge" title={u.draftBadgeAria} aria-label={u.draftBadgeAria}>{u.draftBadge}</span>}
<div class="right">
<nav class="nav">
{lang === 'ko' ? (

View File

@ -20,7 +20,9 @@ export const isEnPath = (pathname: string): boolean => /^\/en(\/|\.html$|$)/.tes
export const UI: Record<Lang, Record<string, string>> = {
ko: {
sample: '샘플 사이트입니다. 정식 공개 전입니다.',
sample: '초안입니다. 검색에 노출되지 않으며, 병원 확인 전입니다.',
draftBadge: '초안',
draftBadgeAria: '이 사이트는 초안입니다. 검색에 노출되지 않습니다.',
brand: '서포터즈',
nav_posts: '상담 전 질문', nav_videos: '의료진 영상', nav_news: '뉴스룸', nav_stay: '주변 안내', nav_visit: '방문 안내', nav_plan: '회복 일정', nav_about: '매체 소개',
cta: '상담 예약',
@ -32,7 +34,9 @@ export const UI: Record<Lang, Record<string, string>> = {
foot_site: '공식 홈페이지', foot_youtube: '유튜브', foot_gu: '강남언니',
},
en: {
sample: 'Sample site. Not yet public.',
sample: 'Draft. Not indexed by search engines, and not yet confirmed by the clinic.',
draftBadge: 'Draft',
draftBadgeAria: 'This site is a draft and is not indexed by search engines.',
brand: 'Supporters',
nav_home: 'Home', nav_stay: 'Nearby', nav_plan: 'Recovery Planner', nav_visit: 'Getting Here', nav_clinic: 'Clinic Facts', nav_korean: '한국어 전체 보기',
cta: 'Book a consultation',

View File

@ -67,7 +67,9 @@ td { color: var(--slate-700); }
.brand small { font-family: "Playfair Display", Georgia, serif; font-weight: 700; color: var(--slate-500); font-size: 0.8rem; letter-spacing: 0.05em; }
.nav a { color: var(--slate-600); margin-left: 1.3rem; font-size: 0.93rem; font-weight: 500; }
.nav a:hover { color: var(--primary-900); text-decoration: none; }
.sample-banner { background: var(--st-warning-bg); color: var(--st-warning-text); border-bottom: 1px solid var(--st-warning-border); font-size: 0.83rem; text-align: center; padding: 0.5rem 1rem; font-weight: 500; }
.sample-banner { background: var(--st-warning-bg); color: var(--st-warning-text); border-bottom: 1px solid var(--st-warning-border); font-size: 0.95rem; text-align: center; padding: 0.62rem 1rem; font-weight: 600; word-break: keep-all; }
/* 초안 배지. 띠 배너는 스크롤하면 사라지므로, sticky 헤더 안에도 같은 사실을 둔다. */
.draft-badge { flex-shrink: 0; margin-left: 0.55rem; padding: 0.2rem 0.6rem; border-radius: 999px; background: var(--st-warning-bg); color: var(--st-warning-text); border: 1px solid var(--st-warning-border); font-size: 0.75rem; font-weight: 700; letter-spacing: 0.01em; white-space: nowrap; }
/* pills & badges */
.pill { display: inline-flex; align-items: center; gap: 0.4rem; border-radius: 999px; padding: 0.35rem 0.9rem; font-size: 0.78rem; font-weight: 600; letter-spacing: 0.04em; }

View File

@ -163,7 +163,7 @@ button.btn { font: inherit; cursor: pointer; border: 0; }
}
@media print {
.site-header, .backbar-wrap, .totop, .site-footer, .sample-banner, .plan-input, .plan-actions, .toast, .lang-links, .sticky-bar { display: none !important; }
.site-header, .backbar-wrap, .totop, .site-footer, .sample-banner, .draft-badge, .plan-input, .plan-actions, .toast, .lang-links, .sticky-bar { display: none !important; }
.section { padding: 1rem 0 !important; }
.section.dark, .section.tint { background: #fff !important; color: var(--primary-900); }
.section.dark .sec-head h2, .plan-hero h1 { -webkit-text-fill-color: var(--primary-900); background: none; }

View File

@ -100,6 +100,7 @@ document.addEventListener('click',function(e){var a=e.target.closest&&e.target.c
<header class="site-header">
<div class="wrap-wide bar">
<a class="brand" href={homeHref}>{has(S.logo?.src) ? <img class="brand-logo" src={S.logo.src} alt={S.logo.alt} width={S.logo.width} height={S.logo.height} /> : <span class="brand-logo brand-name">{v(f.shortName, '병원')}</span>}<span class="brand-sep" aria-hidden="true"></span><span class="brand-text">{u.brand} <small>{S.siteNameEn || 'SUPPORTERS'}</small></span></a>
{!INDEXABLE && <span class="draft-badge" title={u.draftBadgeAria} aria-label={u.draftBadgeAria}>{u.draftBadge}</span>}
<div class="right">
<nav class="nav">
{lang === 'ko' ? (

View File

@ -20,7 +20,9 @@ export const isEnPath = (pathname: string): boolean => /^\/en(\/|\.html$|$)/.tes
export const UI: Record<Lang, Record<string, string>> = {
ko: {
sample: '샘플 사이트입니다. 정식 공개 전입니다.',
sample: '초안입니다. 검색에 노출되지 않으며, 병원 확인 전입니다.',
draftBadge: '초안',
draftBadgeAria: '이 사이트는 초안입니다. 검색에 노출되지 않습니다.',
brand: '서포터즈',
nav_posts: '상담 전 질문', nav_videos: '의료진 영상', nav_news: '뉴스룸', nav_stay: '주변 안내', nav_visit: '방문 안내', nav_plan: '회복 일정', nav_about: '매체 소개',
cta: '상담 예약',
@ -32,7 +34,9 @@ export const UI: Record<Lang, Record<string, string>> = {
foot_site: '공식 홈페이지', foot_youtube: '유튜브', foot_gu: '강남언니',
},
en: {
sample: 'Sample site. Not yet public.',
sample: 'Draft. Not indexed by search engines, and not yet confirmed by the clinic.',
draftBadge: 'Draft',
draftBadgeAria: 'This site is a draft and is not indexed by search engines.',
brand: 'Supporters',
nav_home: 'Home', nav_stay: 'Nearby', nav_plan: 'Recovery Planner', nav_visit: 'Getting Here', nav_clinic: 'Clinic Facts', nav_korean: '한국어 전체 보기',
cta: 'Book a consultation',

View File

@ -67,7 +67,9 @@ td { color: var(--slate-700); }
.brand small { font-family: "Playfair Display", Georgia, serif; font-weight: 700; color: var(--slate-500); font-size: 0.8rem; letter-spacing: 0.05em; }
.nav a { color: var(--slate-600); margin-left: 1.3rem; font-size: 0.93rem; font-weight: 500; }
.nav a:hover { color: var(--primary-900); text-decoration: none; }
.sample-banner { background: var(--st-warning-bg); color: var(--st-warning-text); border-bottom: 1px solid var(--st-warning-border); font-size: 0.83rem; text-align: center; padding: 0.5rem 1rem; font-weight: 500; }
.sample-banner { background: var(--st-warning-bg); color: var(--st-warning-text); border-bottom: 1px solid var(--st-warning-border); font-size: 0.95rem; text-align: center; padding: 0.62rem 1rem; font-weight: 600; word-break: keep-all; }
/* 초안 배지. 띠 배너는 스크롤하면 사라지므로, sticky 헤더 안에도 같은 사실을 둔다. */
.draft-badge { flex-shrink: 0; margin-left: 0.55rem; padding: 0.2rem 0.6rem; border-radius: 999px; background: var(--st-warning-bg); color: var(--st-warning-text); border: 1px solid var(--st-warning-border); font-size: 0.75rem; font-weight: 700; letter-spacing: 0.01em; white-space: nowrap; }
/* pills & badges */
.pill { display: inline-flex; align-items: center; gap: 0.4rem; border-radius: 999px; padding: 0.35rem 0.9rem; font-size: 0.78rem; font-weight: 600; letter-spacing: 0.04em; }

View File

@ -163,7 +163,7 @@ button.btn { font: inherit; cursor: pointer; border: 0; }
}
@media print {
.site-header, .backbar-wrap, .totop, .site-footer, .sample-banner, .plan-input, .plan-actions, .toast, .lang-links, .sticky-bar { display: none !important; }
.site-header, .backbar-wrap, .totop, .site-footer, .sample-banner, .draft-badge, .plan-input, .plan-actions, .toast, .lang-links, .sticky-bar { display: none !important; }
.section { padding: 1rem 0 !important; }
.section.dark, .section.tint { background: #fff !important; color: var(--primary-900); }
.section.dark .sec-head h2, .plan-hero h1 { -webkit-text-fill-color: var(--primary-900); background: none; }