버튼만 두면 무엇에 동의하는지 모른 채 누르게 된다. 진단을 요약하고, 이 빌드가 무엇을 보완하는지 말한 뒤 누르게 한다. 처음 만든 판은 실행 항목을 카테고리로 갈라 "이 빌드로 채워지는 항목 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>
180 lines
9.0 KiB
TypeScript
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>
|
|
);
|
|
}
|