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>
This commit is contained in:
Haewon Kam 2026-09-18 16:56:34 +09:00
parent 74c70d656e
commit be1ea72fcc
7 changed files with 697 additions and 33 deletions

View File

@ -0,0 +1,86 @@
/**
* .
*
* (Product·Pricing·Use Cases) .
* ,
* . .
*
* . .
*/
import { Link, useLocation } from 'react-router';
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: '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));
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>
);
}

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

@ -0,0 +1,156 @@
/**
* .
*
* Action Plan . ,
* "그래서 무엇을 만들어 주는가" .
* supporter_builds queued /build/:clinicId . .
*/
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 { FileTextFilled, ShieldFilled, MapPinFilled, CalendarFilled, VideoFilled } from '../icons/FilledIcons';
// CTA 버튼의 ArrowRight 는 라인 아이콘 금지 규칙의 유일한 예외다(디자인 시스템).
import { ArrowRight } from 'lucide-react';
type Props = {
clinicId: string;
clinicName: string;
clinicUrl?: string;
/** 진단에서 나온 실행 항목 수. 맥락으로만 쓴다. */
actionCounts?: { p0: number; p1: number };
};
const OUTPUTS = [
{ icon: FileTextFilled, title: '질문에 답하는 글', detail: '병원을 알아보는 사람이 실제로 묻는 질문을 골라 공개 자료를 근거로 답합니다.' },
{ icon: ShieldFilled, title: '의료진과 안전 근거', detail: '홈페이지에 흩어져 있는 의료진 이력과 안전 시스템을 한자리에 모읍니다.' },
{ icon: VideoFilled, title: '원장 설명 영상과 뉴스룸', detail: '유튜브 설명 영상과 언론 보도를 글의 근거로 연결합니다.' },
{ icon: CalendarFilled, title: '회복 일정 안내', detail: '시술별 회복 기간을 병원 원문 기준으로 정리합니다.' },
{ icon: MapPinFilled, title: '오시는 길과 주변 안내', detail: '병원 위치를 기준으로 머무는 동안 필요한 정보를 붙입니다.' },
];
export function BuildSiteCta({ clinicId, clinicName, clinicUrl, actionCounts }: Props) {
const navigate = useNavigate();
const [existing, setExisting] = useState<{ status: BuildState; preview_url: string | null } | null>(null);
const [checking, setChecking] = useState(true);
const [starting, setStarting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
void (async () => {
const { data } = await supabase
.from('supporter_builds')
.select('status,preview_url')
.eq('clinic_id', clinicId)
.order('created_at', { ascending: false })
.limit(1);
if (!alive) return;
setExisting((data?.[0] as { status: BuildState; preview_url: string | null }) ?? null);
setChecking(false);
})();
return () => { alive = false; };
}, [clinicId]);
async function startBuild() {
setStarting(true);
setError(null);
const { error: e } = await supabase.from('supporter_builds').insert({
clinic_id: clinicId,
clinic_name: clinicName,
url: clinicUrl ?? '',
status: 'queued',
});
setStarting(false);
if (e) { setError(e.message); return; }
navigate(`/build/${clinicId}`);
}
const hasBuild = Boolean(existing);
const inFlight = existing?.status === 'queued' || existing?.status === '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-7xl mx-auto">
<div className="max-w-[62ch] mb-10">
<h2 className="font-serif text-3xl 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">Build the Site</span>
</h2>
<p className="text-lg text-purple-200 mb-3"> </p>
<p className="ui-body">
AI가 .{' '}
, .
{actionCounts && (actionCounts.p0 + actionCounts.p1 > 0) && (
<> (P0 {actionCounts.p0} · P1 {actionCounts.p1}) .</>
)}
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4 mb-10">
{OUTPUTS.map((o, i) => {
const Icon = o.icon;
return (
<motion.div
key={o.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.07 }}
className="on-light bg-white rounded-2xl p-5 shadow-[3px_4px_12px_rgba(0,0,0,0.2)]"
>
<Icon size={20} className="text-[#6C5CE7] mb-3" />
<h3 className="font-bold text-[#1D0024] mb-1.5">{o.title}</h3>
<p className="ui-body">{o.detail}</p>
</motion.div>
);
})}
</div>
<div className="flex flex-wrap items-center gap-4">
{checking ? (
<span className="text-white/50 text-base"> .</span>
) : hasBuild ? (
<>
<button
type="button"
onClick={() => navigate(`/build/${clinicId}`)}
className="inline-flex items-center gap-2 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white font-semibold px-7 py-4 text-base"
>
{inFlight ? '진행 상황 보기' : '빌드 결과 보기'} <ArrowRight className="w-4 h-4" />
</button>
<span className="text-purple-200 text-base">{STATE_LABEL[existing!.status]}</span>
{!inFlight && (
<button
type="button"
onClick={() => void startBuild()}
disabled={starting}
className="rounded-full bg-white/10 border border-white/15 text-white font-semibold px-6 py-4 text-base disabled:opacity-50"
>
{starting ? '시작하는 중' : '다시 빌드'}
</button>
)}
</>
) : (
<button
type="button"
onClick={() => void startBuild()}
disabled={starting}
className="inline-flex items-center gap-2 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white font-semibold px-7 py-4 text-base disabled:opacity-50"
>
{starting ? '시작하는 중' : '사이트로 빌드'} <ArrowRight className="w-4 h-4" />
</button>
)}
</div>
{error && <p className="text-[#F5D5DC] text-base mt-4"> . {error}</p>}
<p className="ui-note mt-5">
.{' '}
.
</p>
</div>
</section>
);
}

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

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

View File

@ -7,6 +7,7 @@ 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 { BuildSiteCta } from '../components/discovery/BuildSiteCta';
import { DISCOVERY_RESULTS } from '../data/discoveryResults';
import { scoreDiscovery, levelToSeverity } from '../lib/discoveryScore';
import type {
@ -87,13 +88,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 +99,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 +130,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 +173,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 +199,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 +215,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 +232,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 +250,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>
)}
@ -292,13 +290,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-sm p-5"
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="text-sm text-slate-400 shrink-0">{a.effort}</span>
<span className="ui-note shrink-0">{a.effort}</span>
</div>
<p className="text-base text-slate-600 leading-relaxed mb-3">{a.detail}</p>
<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>
@ -312,6 +310,17 @@ export default function DiscoveryReportPage() {
</div>
</SectionWrapper>
{/* ── 4b. 사이트로 빌드 (dark) ── */}
<BuildSiteCta
clinicId={result.id}
clinicName={result.clinicName}
clinicUrl={result.url}
actionCounts={{
p0: result.actions.filter((a) => a.priority === 'P0').length,
p1: result.actions.filter((a) => a.priority === 'P1').length,
}}
/>
{/* ── 5. 하지 않을 것 + 측정 조건 (dark) ── */}
<SectionWrapper
id="integrity"
@ -320,7 +329,7 @@ export default function DiscoveryReportPage() {
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]">
<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) => (
@ -328,19 +337,19 @@ export default function DiscoveryReportPage() {
<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>
<p className="ui-note 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]">
<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="text-base text-slate-600">{c}</p>
<p className="ui-body">{c}</p>
</li>
))}
</ul>
@ -364,9 +373,9 @@ export default function DiscoveryReportPage() {
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 +404,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 +456,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
@ -458,8 +511,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>

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 px-6 py-3 text-base font-semibold">
</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="inline-flex items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white font-semibold px-7 py-4 text-base"
>
<EyeFilled size={16} />
</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>
);
}