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>
This commit is contained in:
Haewon Kam 2026-09-18 17:25:50 +09:00
parent 14c4b82155
commit f38c0f3bda
7 changed files with 298 additions and 167 deletions

View File

@ -8,6 +8,7 @@
* 단계의 완료 여부는 각 화면이 판단한다. 여기서는 위치만 표시한다. * 단계의 완료 여부는 각 화면이 판단한다. 여기서는 위치만 표시한다.
*/ */
import { Link, useLocation } from 'react-router'; import { Link, useLocation } from 'react-router';
import { motion } from 'motion/react';
import { PrismFilled } from './icons/FilledIcons'; import { PrismFilled } from './icons/FilledIcons';
type Step = { key: string; label: string; to: (id: string) => string; match: RegExp }; type Step = { key: string; label: string; to: (id: string) => string; match: RegExp };
@ -30,8 +31,10 @@ export function clinicIdFromPath(pathname: string): string | null {
export default function ClinicNav({ clinicId }: { clinicId: string }) { export default function ClinicNav({ clinicId }: { clinicId: string }) {
const { pathname } = useLocation(); const { pathname } = useLocation();
const currentIndex = STEPS.findIndex((s) => s.match.test(pathname)); const currentIndex = STEPS.findIndex((s) => s.match.test(pathname));
const prev = currentIndex > 0 ? STEPS[currentIndex - 1] : null;
return ( 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> <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"> <div className="max-w-7xl mx-auto px-6 h-20 flex items-center gap-6">
{/* 로고 + 병원 */} {/* 로고 + 병원 */}
@ -82,5 +85,35 @@ export default function ClinicNav({ clinicId }: { clinicId: string }) {
</ol> </ol>
</div> </div>
</nav> </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>
)}
</>
); );
} }

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

@ -1,156 +0,0 @@
/**
* 진단 리포트 → 사이트 빌드로 넘어가는 구간.
*
* 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>
);
}

View File

@ -246,3 +246,27 @@ h1, h2, h3, h4, h5, h6 {
word-break: keep-all; word-break: keep-all;
text-wrap: balance; 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,
}));
}

View File

@ -7,7 +7,7 @@ import { AEO_GEO_RUBRIC_V2 } from '../data/aeoGeoRubricV2';
import { scoreDiscoveryV2 } from '../lib/discoveryScoreV2'; import { scoreDiscoveryV2 } from '../lib/discoveryScoreV2';
import { AeoGeoV2Panel } from '../components/discovery/AeoGeoV2Panel'; import { AeoGeoV2Panel } from '../components/discovery/AeoGeoV2Panel';
import { ClinicInputsPanel } from '../components/discovery/ClinicInputsPanel'; import { ClinicInputsPanel } from '../components/discovery/ClinicInputsPanel';
import { BuildSiteCta } from '../components/discovery/BuildSiteCta'; import { BuildPlanSection } from '../components/discovery/BuildPlanSection';
import { DISCOVERY_RESULTS } from '../data/discoveryResults'; import { DISCOVERY_RESULTS } from '../data/discoveryResults';
import { scoreDiscovery, levelToSeverity } from '../lib/discoveryScore'; import { scoreDiscovery, levelToSeverity } from '../lib/discoveryScore';
import type { import type {
@ -310,14 +310,18 @@ export default function DiscoveryReportPage() {
</div> </div>
</SectionWrapper> </SectionWrapper>
{/* ── 4b. 사이트로 빌드 (dark) ── */} {/* ── 4b. 진단 요약 → 개선 계획 → 빌드 (dark) ── */}
<BuildSiteCta <BuildPlanSection
clinicId={result.id} result={result}
clinicName={result.clinicName} overall={overall}
clinicUrl={result.url} summary={{
actionCounts={{ rubricVersion: result.rubricVersion,
p0: result.actions.filter((a) => a.priority === 'P0').length, grade: overall.grade,
p1: result.actions.filter((a) => a.priority === 'P1').length, 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,
}} }}
/> />

View File

@ -96,7 +96,7 @@ export default function SiteBuildPage() {
<p className="text-white/60 text-base leading-[1.75]"> <p className="text-white/60 text-base leading-[1.75]">
{loadError ? `상태를 불러오지 못했습니다. ${loadError}` : '진단 리포트에서 사이트로 빌드를 눌러 주세요.'} {loadError ? `상태를 불러오지 못했습니다. ${loadError}` : '진단 리포트에서 사이트로 빌드를 눌러 주세요.'}
</p> </p>
<Link to={`/discovery/${id}`} className="inline-block mt-6 rounded-full bg-white/10 px-6 py-3 text-base font-semibold"> <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> </Link>
</div> </div>
@ -203,7 +203,7 @@ export default function SiteBuildPage() {
href={build.preview_url} href={build.preview_url}
target="_blank" target="_blank"
rel="noreferrer" 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" className="btn-on-dark justify-center"
> >
<EyeFilled size={16} /> 사이트 열어 보기 <EyeFilled size={16} /> 사이트 열어 보기
</a> </a>