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:
parent
14c4b82155
commit
f38c0f3bda
@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
179
src/components/discovery/BuildPlanSection.tsx
Normal file
179
src/components/discovery/BuildPlanSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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
47
src/lib/buildCoverage.ts
Normal 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@ -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,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user