o2o-infinith-demo/src/components/discovery/BuildPlanSection.tsx
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

180 lines
9.0 KiB
TypeScript

/**
* 진단 요약 → 이렇게 보완하겠습니다 → 빌드.
*
* 버튼만 두면 무엇에 동의하는지 모른 채 누르게 된다. 진단을 요약하고, 무엇을 보완하는지 말한 뒤 누르게 한다.
*
* 이 화면에서 틀리면 안 되는 것: 빌드는 병원 홈페이지를 한 줄도 고치지 않는다.
* 그래서 진단의 실행 항목은 단 하나도 이 빌드로 해결되지 않는다. 전부 병원 몫이다.
* 빌드가 하는 일은 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>
);
}