453 lines
20 KiB
TypeScript
453 lines
20 KiB
TypeScript
import { useMemo } from 'react';
|
|
import type { ComponentType, ReactNode } from 'react';
|
|
import { useParams, Link } from 'react-router';
|
|
import { motion } from 'motion/react';
|
|
import { AEO_GEO_RUBRIC } from '../data/aeoGeoRubric';
|
|
import { DISCOVERY_RESULTS } from '../data/discovery_viewclinic';
|
|
import { scoreDiscovery, levelToSeverity } from '../lib/discoveryScore';
|
|
import type {
|
|
ActionPriority,
|
|
CategoryId,
|
|
CategoryScore,
|
|
ControlLevel,
|
|
EvidenceGrade,
|
|
EvidenceMethod,
|
|
RubricCriterion,
|
|
} from '../types/discovery';
|
|
import { SectionWrapper } from '../components/report/ui/SectionWrapper';
|
|
import { ScoreRing } from '../components/report/ui/ScoreRing';
|
|
import { SeverityBadge } from '../components/report/ui/SeverityBadge';
|
|
import {
|
|
ServerFilled,
|
|
DatabaseFilled,
|
|
FileTextFilled,
|
|
ShareFilled,
|
|
EyeFilled,
|
|
ShieldFilled,
|
|
PrismFilled,
|
|
CheckFilled,
|
|
CrossFilled,
|
|
WarningFilled,
|
|
} from '../components/icons/FilledIcons';
|
|
|
|
/* ────────────────────────── 라벨 맵 ────────────────────────── */
|
|
|
|
const CATEGORY_ICON: Record<CategoryId, ComponentType<{ size?: number; className?: string }>> = {
|
|
access: ServerFilled,
|
|
entity: DatabaseFilled,
|
|
content: FileTextFilled,
|
|
surface: ShareFilled,
|
|
measure: EyeFilled,
|
|
hygiene: ShieldFilled,
|
|
};
|
|
|
|
const EVIDENCE_LABEL: Record<EvidenceGrade, string> = {
|
|
measured: '실측',
|
|
academic: '학술',
|
|
vendor: '벤더',
|
|
estimate: '추정',
|
|
};
|
|
|
|
const CONTROL_LABEL: Record<ControlLevel, string> = {
|
|
direct: '직접 통제',
|
|
influence: '영향 가능',
|
|
none: '통제 불가',
|
|
};
|
|
|
|
const METHOD_LABEL: Record<EvidenceMethod, string> = {
|
|
auto: '자동',
|
|
semi: '반자동',
|
|
manual: '수동',
|
|
};
|
|
|
|
const PRIORITY_STYLE: Record<ActionPriority, string> = {
|
|
P0: 'bg-[#FFF0F0] text-[#7C3A4B] border-[#F5D5DC]',
|
|
P1: 'bg-[#FFF6ED] text-[#7C5C3A] border-[#F5E0C5]',
|
|
P2: 'bg-[#F3F0FF] text-[#4A3A7C] border-[#D5CDF5]',
|
|
};
|
|
|
|
const scoreColor = (pct: number) =>
|
|
pct <= 40 ? '#C084CF' : pct <= 60 ? '#8B9CF7' : pct <= 80 ? '#7C6DD8' : '#6C5CE7';
|
|
|
|
/* ────────────────────────── 페이지 ────────────────────────── */
|
|
|
|
export default function DiscoveryReportPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const result = id ? DISCOVERY_RESULTS[id] : undefined;
|
|
const overall = useMemo(
|
|
() => (result ? scoreDiscovery(AEO_GEO_RUBRIC, result) : null),
|
|
[result],
|
|
);
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
const resultById = new Map(result.results.map((r) => [r.criterionId, r]));
|
|
|
|
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">
|
|
<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>
|
|
<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 · AEO / GEO Audit v{result.rubricVersion}
|
|
</span>
|
|
</div>
|
|
<h1 className="font-serif text-4xl md:text-6xl font-bold tracking-[-0.02em] mb-4">
|
|
<span className="bg-gradient-to-r from-purple-300 to-blue-300 bg-clip-text text-transparent">
|
|
AI Answer Readiness
|
|
</span>
|
|
</h1>
|
|
<p className="text-3xl font-bold mb-2">{result.clinicName}</p>
|
|
<p className="text-purple-200 text-base mb-1">{result.industry}</p>
|
|
<a
|
|
href={result.url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-purple-300 text-base underline underline-offset-4"
|
|
>
|
|
{result.url}
|
|
</a>
|
|
<p className="text-white/60 text-sm mt-4">
|
|
실측일 {result.auditedAt} · 검증 항목 {overall.categories.reduce((s, c) => s + c.verifiedCount, 0)} /
|
|
{' '}{AEO_GEO_RUBRIC.criteria.length} · 미검증 항목은 분모 제외
|
|
</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-3 min-w-[260px]">
|
|
<ScoreRing score={overall.score} size={160} label="AI Answer Readiness" />
|
|
<div className="flex items-baseline gap-2">
|
|
<span className="font-serif text-4xl font-black text-[#0A1128]">{overall.grade}</span>
|
|
<span className="text-sm text-slate-500">
|
|
{overall.earned} / {overall.possible}점
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 카테고리 바 */}
|
|
<div className="relative max-w-7xl mx-auto mt-14 grid md:grid-cols-3 gap-4">
|
|
{overall.categories.map((cs, i) => (
|
|
<CategoryBar key={cs.category.id} cs={cs} index={i} />
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── 2. 핵심 발견 (light) ── */}
|
|
<SectionWrapper
|
|
id="findings"
|
|
title="What AI Sees"
|
|
subtitle="AI 답변엔진이 지금 이 병원을 어떻게 읽는지. 진단의 결론"
|
|
>
|
|
<div className="grid md:grid-cols-2 gap-5">
|
|
{result.keyFindings.map((f, i) => (
|
|
<motion.div
|
|
key={f.title}
|
|
initial={{ opacity: 0, y: 20 }}
|
|
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"
|
|
>
|
|
<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>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
</SectionWrapper>
|
|
|
|
{/* ── 3. 카테고리별 상세 (dark) ── */}
|
|
<SectionWrapper
|
|
id="breakdown"
|
|
title="Scorecard by Category"
|
|
subtitle="36개 항목 · 각 항목의 실측 근거와 근거 등급"
|
|
dark
|
|
>
|
|
<div className="space-y-6">
|
|
{overall.categories.map((cs) => {
|
|
const Icon = CATEGORY_ICON[cs.category.id];
|
|
const criteria = AEO_GEO_RUBRIC.criteria.filter((c) => c.category === cs.category.id);
|
|
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]"
|
|
>
|
|
<div className="flex flex-wrap items-center justify-between gap-4 mb-5">
|
|
<div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 shrink-0 rounded-xl bg-[#F3F0FF] flex items-center justify-center text-[#6C5CE7]">
|
|
<Icon size={22} />
|
|
</div>
|
|
<p className="font-serif text-[28px] font-bold text-[#1D0024] tracking-[-0.01em] leading-10">
|
|
{cs.category.code} · {cs.category.nameEn}
|
|
</p>
|
|
</div>
|
|
<h3 className="text-xl font-bold mt-1 pl-[52px]">{cs.category.name}</h3>
|
|
</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="text-base font-semibold">{cs.earned} / {cs.possible}점</p>
|
|
</div>
|
|
<ScoreRing score={cs.pct} size={64} />
|
|
</div>
|
|
</div>
|
|
<p className="text-lg text-[#4F1DA1] mb-4">{cs.category.description}</p>
|
|
|
|
<div className="divide-y divide-slate-100">
|
|
{criteria.map((c) => {
|
|
const r = resultById.get(c.id);
|
|
const level = r?.level ?? 'unverified';
|
|
const levelLabel =
|
|
level === 'unverified' ? '미검증' : c.levels[level].label;
|
|
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>
|
|
<p className="text-base font-bold">{c.name}</p>
|
|
<p className="text-sm text-slate-400 mt-0.5">{c.weight}점</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-base text-slate-700">{r?.evidence}</p>
|
|
{r?.source && (
|
|
<p className="text-sm text-slate-400 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>
|
|
<Tag>{CONTROL_LABEL[c.control]}</Tag>
|
|
<Tag>{METHOD_LABEL[c.method]}</Tag>
|
|
</div>
|
|
</div>
|
|
<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">
|
|
{Math.round((c.weight * level) / 3 * 10) / 10} / {c.weight}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</SectionWrapper>
|
|
|
|
{/* ── 4. 실행 계획 (light) ── */}
|
|
<SectionWrapper
|
|
id="actions"
|
|
title="Action Plan"
|
|
subtitle="우선순위별 실행 항목. 각 항목이 어느 기준을 올리는지 연결"
|
|
>
|
|
<div className="space-y-4">
|
|
{(['P0', 'P1', 'P2'] as ActionPriority[]).map((p) => (
|
|
<div key={p}>
|
|
<div className="flex items-center gap-3 mb-3 mt-6 first:mt-0">
|
|
<span className={`inline-flex items-center rounded-full text-sm font-semibold px-3 py-1 border ${PRIORITY_STYLE[p]}`}>
|
|
{p}
|
|
</span>
|
|
<span className="text-lg font-bold text-[#0A1128]">
|
|
{p === 'P0' ? '즉시: 크롤러 접근·엔티티·측정' : p === 'P1' ? '4주 내: 콘텐츠 구조' : '지속: 신선도·시딩'}
|
|
</span>
|
|
</div>
|
|
<div className="grid md:grid-cols-2 gap-4">
|
|
{result.actions.filter((a) => a.priority === p).map((a, i) => (
|
|
<motion.div
|
|
key={a.title}
|
|
initial={{ opacity: 0, y: 20 }}
|
|
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"
|
|
>
|
|
<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>
|
|
</div>
|
|
<p className="text-base text-slate-600 leading-relaxed mb-3">{a.detail}</p>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{a.criterionIds.map((cid) => (
|
|
<Tag key={cid}>{cid}</Tag>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SectionWrapper>
|
|
|
|
{/* ── 5. 하지 않을 것 + 측정 조건 (dark) ── */}
|
|
<SectionWrapper
|
|
id="integrity"
|
|
title="What We Don't Sell"
|
|
subtitle="근거 없는 것은 팔지 않는다. 이 리포트의 정직성 규약"
|
|
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]">
|
|
<h3 className="text-xl font-bold mb-4">하지 않을 것</h3>
|
|
<ul className="space-y-4">
|
|
{result.notDoing.map((n) => (
|
|
<li key={n.item} className="flex gap-3">
|
|
<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>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
<div className="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">측정 조건</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>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<div className="mt-6 rounded-xl p-4 bg-[linear-gradient(to_right,#fff3eb,#e4cfff,#f5f9ff)]">
|
|
<p className="text-sm text-slate-600 leading-relaxed">
|
|
점수는 <strong>검증된 항목만</strong>으로 정규화했습니다. 미검증 항목(강남언니·GBP·GA4·로그)을
|
|
확인하면 점수가 오를 수도, 내릴 수도 있습니다. 이 리포트는 답변엔진 노출을 보장하지 않으며,
|
|
크롤러 접근·엔티티·콘텐츠 구조라는 <strong>전제조건</strong>의 충족 여부를 진단합니다.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</SectionWrapper>
|
|
|
|
{/* ── 6. 채점 기준표 (light) ── */}
|
|
<SectionWrapper
|
|
id="rubric"
|
|
title="Scoring Rubric"
|
|
subtitle={`AEO/GEO 채점 기준표 v${AEO_GEO_RUBRIC.version} · ${AEO_GEO_RUBRIC.criteria.length}개 항목 · 100점`}
|
|
>
|
|
<div className="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">
|
|
<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>
|
|
<th className="text-left px-4 py-3 font-semibold w-14">배점</th>
|
|
<th className="text-left px-4 py-3 font-semibold w-16">근거</th>
|
|
<th className="text-left px-4 py-3 font-semibold w-20">통제</th>
|
|
<th className="text-left px-4 py-3 font-semibold w-16">채점</th>
|
|
<th className="text-left px-4 py-3 font-semibold">3점 기준</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{AEO_GEO_RUBRIC.categories.map((cat) => (
|
|
<RubricRows
|
|
key={cat.id}
|
|
categoryName={`${cat.code}. ${cat.name} (${cat.weight})`}
|
|
criteria={AEO_GEO_RUBRIC.criteria.filter((c) => c.category === cat.id)}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</SectionWrapper>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ────────────────────────── 서브 컴포넌트 ────────────────────────── */
|
|
|
|
function CategoryBar({ cs, index }: { key?: string; cs: CategoryScore; index: number }) {
|
|
const Icon = CATEGORY_ICON[cs.category.id];
|
|
return (
|
|
<motion.a
|
|
href="#breakdown"
|
|
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]"
|
|
>
|
|
<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>
|
|
</div>
|
|
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
|
|
<motion.div
|
|
className="h-full rounded-full"
|
|
style={{ background: scoreColor(cs.pct) }}
|
|
initial={{ width: 0 }}
|
|
animate={{ width: `${cs.pct}%` }}
|
|
transition={{ duration: 0.9, ease: 'easeOut', delay: 0.3 + index * 0.06 }}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-between mt-2 text-sm">
|
|
<span className="font-semibold">{cs.pct}%</span>
|
|
<span className="text-slate-400">
|
|
{cs.verifiedCount < cs.totalCount ? `미검증 ${cs.totalCount - cs.verifiedCount}` : '전 항목 검증'}
|
|
</span>
|
|
</div>
|
|
</motion.a>
|
|
);
|
|
}
|
|
|
|
function FindingIcon({ severity }: { severity: 'critical' | 'warning' | 'good' }) {
|
|
if (severity === 'good') return <span className="text-[#9B8AD4] shrink-0"><CheckFilled size={22} /></span>;
|
|
if (severity === 'warning') return <span className="text-[#D4A872] shrink-0"><WarningFilled size={22} /></span>;
|
|
return <span className="text-[#D4889A] shrink-0"><CrossFilled size={22} /></span>;
|
|
}
|
|
|
|
function Tag({ children }: { key?: string; children: ReactNode }) {
|
|
return (
|
|
<span className="inline-flex items-center rounded-full bg-slate-100 text-slate-600 text-sm font-medium px-2 py-0.5">
|
|
{children}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function RubricRows({ categoryName, criteria }: { key?: string; categoryName: string; criteria: RubricCriterion[] }) {
|
|
return (
|
|
<>
|
|
<tr className="bg-[#F4F6FB]">
|
|
<td colSpan={7} className="px-4 py-2 text-sm font-bold text-[#4A3A7C] tracking-wide">
|
|
{categoryName}
|
|
</td>
|
|
</tr>
|
|
{criteria.map((c) => (
|
|
<tr key={c.id} className="border-t border-slate-100 align-top">
|
|
<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>
|
|
</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>
|
|
<td className="px-4 py-3 text-slate-600">{CONTROL_LABEL[c.control]}</td>
|
|
<td className="px-4 py-3 text-slate-600">{METHOD_LABEL[c.method]}</td>
|
|
<td className="px-4 py-3 text-slate-600">{c.levels[3].label}</td>
|
|
</tr>
|
|
))}
|
|
</>
|
|
);
|
|
}
|