o2o-infinith-demo/src/components/discovery/SampleReport.tsx
Haewon Kam 76ec63bc1e feat(discovery): 기준표 v2.0 정식 채택 — 배지·헤더·랜딩 카피·비교 엑셀·덱 v2.0
- aeoGeoRubricV2 status active/2.0, 결과 rubricVersion 2.0. 리포트 헤더는 AEO·GEO 링 2개가 주, v1.0 종합은 보조 표기
- 랜딩: Hero 카피, ABMR 칩(AEO/GEO 목표), SampleReport(두 링 + 축별 8항목 바), Roadmap 제목, HowItWorks를 v2 계산값에서 파생. 발견 칩 수치 v1.1 스냅샷으로 정정
- 비교 엑셀 v2: scripts/build_supporters_v2_compare.ts(점수 계산 TS 단일 원본) → build_supporters_audit_v2_xlsx.py. 공식 AEO 42/GEO 56 · 서포터즈 noindex 상한 40(G1) · 색인 허용 AEO 94/GEO 77
- 덱 v2.0: build_viewclinic_deck_v2_0.py (17장, 표지 배지 v2.0, KPI AEO/GEO, 스코어카드 2장, Method v2.0) + 표준캔버스 + PDF. v1.0·v1.1 파일은 그대로
- docs/AEO_GEO_RUBRIC.md에 v2.0 채택 주석, AEO_GEO_RUBRIC_v2.md 재생성, 리포트 페이지 PDF 재출력

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 15:25:00 +09:00

172 lines
8.2 KiB
TypeScript

/**
* 샘플 리포트 섹션 — 뷰성형외과 실측 스냅샷.
* 점수·등급·항목 %·항목 수는 전부 discoveryScore(v1 실측)·discoveryScoreV2(AEO/GEO) 계산값에서 렌더 (하드코딩 금지).
* 발견 칩 3장의 카피는 result.keyFindings를 랜딩용으로 압축한 시안 확정 문안이다.
*/
import { motion } from 'motion/react';
import type { DiscoveryResult, OverallScore } from '../../types/discovery';
import { scorePctColor } from '../../lib/discoveryScore';
import { ScoreRing } from '../report/ui/ScoreRing';
import type { AxisSummary } from './AbmrLoop';
const GRADE_BADGE_STYLE: Record<OverallScore['grade'], string> = {
A: 'bg-[#EFF0FF] border-[#C5CBF5] text-[#3A3F7C]',
B: 'bg-[#F3F0FF] border-[#D5CDF5] text-[#4A3A7C]',
C: 'bg-[#FFF6ED] border-[#F5E0C5] text-[#7C5C3A]',
D: 'bg-[#FFF0F0] border-[#F5D5DC] text-[#7C3A4B]',
};
/** keyFindings 압축본 (시안 확정 카피). 수치는 2026-09-07 재검증(v1.1) 스냅샷과 일치한다. */
const FINDING_CHIPS = [
{
label: '치명',
chipClass: 'bg-[#FFF0F0] border-[#F5D5DC] text-[#7C3A4B]',
dotColor: '#D4889A',
title: 'AI가 누구인지 확정 못 함',
detail: 'MedicalClinic 스키마 없음. 시술 목록 JSON-LD는 문법 오류로 통째로 버려집니다.',
},
{
label: '보완',
chipClass: 'bg-[#FFF6ED] border-[#F5E0C5] text-[#7C5C3A]',
dotColor: '#D4A872',
title: '인용할 문장이 없음',
detail: '정의문 0개, HTML 표 0개, FAQ는 프리저베 한 페이지뿐. 답변엔진이 잘라 갈 단락이 없습니다.',
},
{
label: '강점',
chipClass: 'bg-[#F3F0FF] border-[#D5CDF5] text-[#4A3A7C]',
dotColor: '#9B8AD4',
title: '표면 자산은 이미 강함',
detail: '강남언니 후기 19,373건, GBP 4.9점. 홈페이지가 따라가면 바로 인용 근거가 됩니다.',
},
];
interface SampleReportProps {
overall: OverallScore;
axes: AxisSummary[];
result: DiscoveryResult;
}
export default function SampleReport({ overall, axes, result }: SampleReportProps) {
const totalCriteria = overall.categories.reduce((sum, c) => sum + c.totalCount, 0);
const verifiedCriteria = overall.categories.reduce((sum, c) => sum + c.verifiedCount, 0);
const unverifiedCriteria = totalCriteria - verifiedCriteria;
return (
<section id="sample-report" className="bg-[#F4F6FB] px-6 py-24">
<div className="max-w-7xl mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
>
<div className="text-sm font-bold tracking-[0.24em] text-accent mb-4">
SAMPLE REPORT · {result.clinicName}
</div>
<h2 className="font-serif text-3xl md:text-[40px] font-bold leading-tight text-primary-900 mb-3 break-keep">
, .
</h2>
<p className="text-[17px] leading-[1.7] text-slate-500 mb-12 break-keep">
1 . (AEO) (GEO) 100 ,
.
</p>
</motion.div>
<div className="grid lg:grid-cols-[380px_minmax(0,1fr)] gap-6">
{/* Score ring card */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.1 }}
className="flex flex-col items-center justify-center gap-4 bg-white border border-slate-200 rounded-2xl px-8 py-10 shadow-[0_8px_32px_rgba(31,38,135,0.07)]"
>
<div className="flex items-start gap-6">
{axes.map((ax) => (
<div key={ax.code} className="flex flex-col items-center gap-2.5">
<ScoreRing score={ax.score} size={120} valueClassName="text-[32px]" subLabel="/ 100" />
<div className={`px-3.5 py-[5px] border rounded-full ${GRADE_BADGE_STYLE[ax.grade]}`}>
<span className="text-sm font-bold">{ax.code} {ax.grade}</span>
</div>
<span className="text-sm text-slate-500">{ax.name}</span>
</div>
))}
</div>
<p className="text-[13.5px] leading-relaxed text-slate-400 text-center break-keep">
{totalCriteria} {verifiedCriteria} .
<br />
{unverifiedCriteria} . v1.0 {overall.score}.
</p>
</motion.div>
{/* Category bars */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.2 }}
className="flex flex-col justify-center gap-[18px] bg-white border border-slate-200 rounded-2xl px-6 md:px-10 py-9 shadow-[0_8px_32px_rgba(31,38,135,0.07)]"
>
<div className="grid md:grid-cols-2 gap-x-10 gap-y-[18px]">
{axes.map((ax) => (
<div key={ax.code} className="flex flex-col gap-[14px]">
<div className="flex justify-between items-baseline gap-3 border-b border-slate-100 pb-2">
<span className="text-[15px] font-bold text-primary-900 break-keep">{ax.code} · {ax.name}</span>
<span className="text-[15px] font-bold" style={{ color: scorePctColor(ax.score) }}>{ax.score}</span>
</div>
{ax.items.map((it) => {
const pct = it.pct ?? 0;
const color = it.pct === null ? '#CBD5E1' : scorePctColor(pct);
return (
<div key={it.id} className="flex flex-col gap-[6px]">
<div className="flex justify-between items-baseline gap-3">
<span className="text-[14px] font-medium text-primary-900 break-keep">{it.name}</span>
<span className="text-[14px] font-bold" style={{ color }}>{it.pct === null ? '미검증' : `${it.pct}%`}</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<motion.div
className="h-2 rounded-full"
style={{ backgroundColor: color }}
initial={{ width: 0 }}
whileInView={{ width: `${Math.max(pct, 2)}%` }}
viewport={{ once: true }}
transition={{ duration: 0.8, ease: 'easeOut' }}
/>
</div>
</div>
);
})}
</div>
))}
</div>
</motion.div>
</div>
{/* Finding chips */}
<div className="grid md:grid-cols-3 gap-6 mt-6">
{FINDING_CHIPS.map((finding, index) => (
<motion.div
key={finding.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: index * 0.1 }}
className="flex flex-col gap-2.5 bg-white border border-slate-200 rounded-2xl px-7 py-6"
>
<div
className={`inline-flex self-start items-center gap-[7px] px-3 py-1 border rounded-full ${finding.chipClass}`}
>
<span className="w-[7px] h-[7px] rounded-full" style={{ backgroundColor: finding.dotColor }} />
<span className="text-[13px] font-bold">{finding.label}</span>
</div>
<div className="text-[15.5px] font-semibold text-primary-900 break-keep">{finding.title}</div>
<p className="text-sm leading-[1.7] text-slate-500 break-keep">{finding.detail}</p>
</motion.div>
))}
</div>
</div>
</section>
);
}