o2o-infinith-demo/src/components/discovery/SampleReport.tsx
Haewon Kam 7e515b70ae feat(supporters): 승인 UI·입력 반영·워커 — §7-5 ClinicInputsPanel + supporter_builds/inputs 마이그레이션 + apply_inputs, §12-2 상단 고지·개설자 승인·색인 빌드 필터, §7-6 워커 E2E(뷰성형외과 URL → 5편 → 게이트 0오류)
- 게이트: DISCLOSURE_TOP_MISSING, APPROVED_*, UNAPPROVED_IN_INDEXABLE_BUILD, PRESS_VOICE 규칙화, 픽스처 추가 (16/16)
- 생성기: --site 옵션, 병원 확인 답변(clinicAnswers) 근거 [A]
- default_briefs.mjs 첫 배치 자동 기획, workers/supporters-build/run.mjs
- 랜딩: SAMPLE REPORT 라벨 "실제 사례"로 (haewon 지시)
- v2 §11-6·§11-7 기록, E2E 리포트

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 17:07:06 +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 · 실제 사례
</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>
);
}