o2o-infinith-demo/src/components/report/ClinicSnapshot.tsx
Haewon Kam 9c4d10609f feat: 스크린샷 리포트 반영 + 영구 저장 인프라 강화
- transformReport: channel_data.screenshots → report.screenshots 자동 매핑
- transformReport: youtubeAudit/instagramAudit/facebookAudit diagnosis에 evidenceIds 자동 연결 (채널별 스크린샷 → 진단 항목 연결)
- collect-channel-data: 스크린샷 아카이브를 병렬→순차로 변경 (rate-limit 방지), 실패 시 상세 로그
- scripts/archive-screenshots.py: 기존 GCS 임시 URL → Supabase Storage 일괄 재아카이브 스크립트 추가
- TypeScript 기존 에러 3개 수정 (SectionErrorBoundary, ClinicSnapshot, reviewCount 유니언 타입)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:43:25 +09:00

185 lines
8.2 KiB
TypeScript

import { motion } from 'motion/react';
import { Calendar, Users, MapPin, Phone, Award, Star, Globe, ExternalLink, Building2 } from 'lucide-react';
import { SectionWrapper } from './ui/SectionWrapper';
import type { ClinicSnapshot as ClinicSnapshotType } from '../../types/report';
interface ClinicSnapshotProps {
data: ClinicSnapshotType;
}
function formatNumber(n: number): string {
return n.toLocaleString();
}
interface InfoField {
label: string;
value: string;
icon: typeof Calendar;
href?: string;
}
const infoFields = (data: ClinicSnapshotType): InfoField[] => [
data.established ? { label: '개원', value: `${data.established} (${data.yearsInBusiness}년)`, icon: Calendar } : null,
data.staffCount > 0 ? { label: '전문의', value: `${data.staffCount}명`, icon: Users } : null,
data.overallRating > 0 ? { label: '강남언니 평점', value: data.overallRating > 5 ? `${data.overallRating} / 10` : `${data.overallRating} / 5.0`, icon: Star } : null,
data.totalReviews > 0 ? { label: '리뷰 수', value: formatNumber(data.totalReviews), icon: Star } : null,
data.registryData?.district ? { label: '지역구', value: data.registryData.district, icon: Building2 } : null,
data.location ? { label: '위치', value: data.nearestStation ? `${data.location} (${data.nearestStation})` : data.location, icon: MapPin } : null,
data.phone ? { label: '전화', value: data.phone, icon: Phone, href: `tel:${data.phone.replace(/[^+0-9]/g, '')}` } : null,
data.domain ? { label: '도메인', value: data.domain, icon: Globe, href: `https://${data.domain.replace(/^https?:\/\//, '')}` } : null,
data.registryData?.websiteEn ? { label: '영문 사이트', value: data.registryData.websiteEn, icon: Globe, href: data.registryData.websiteEn } : null,
].filter(Boolean) as InfoField[];
export default function ClinicSnapshot({ data }: ClinicSnapshotProps) {
const fields = infoFields(data);
const isVerified = data.source === 'registry';
return (
<SectionWrapper id="clinic-snapshot" title="Clinic Overview" subtitle="병원 기본 정보">
{/* 외부 검증 배지는 내부 관리용이므로 리포트에서 제외 */}
{/* Registry 외부 링크 (강남언니, 네이버 플레이스, 구글 맵) */}
{isVerified && (data.registryData?.naverPlaceUrl || data.registryData?.gangnamUnniUrl || data.registryData?.googleMapsUrl) && (
<motion.div
className="flex flex-wrap gap-2 mb-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
{data.registryData?.gangnamUnniUrl && (
<a
href={data.registryData.gangnamUnniUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg bg-pink-50 border border-pink-200 px-3 py-1.5 text-xs font-medium text-pink-700 hover:bg-pink-100 transition-colors"
>
강남언니 <ExternalLink size={11} />
</a>
)}
{data.registryData?.naverPlaceUrl && (
<a
href={data.registryData.naverPlaceUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg bg-green-50 border border-green-200 px-3 py-1.5 text-xs font-medium text-green-700 hover:bg-green-100 transition-colors"
>
네이버 플레이스 <ExternalLink size={11} />
</a>
)}
{data.registryData?.googleMapsUrl && (
<a
href={data.registryData.googleMapsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-50 border border-blue-200 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100 transition-colors"
>
Google Maps <ExternalLink size={11} />
</a>
)}
</motion.div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
{fields.map((field, i) => {
const Icon = field.icon;
return (
<motion.div
key={field.label}
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-5"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.05 }}
>
<div className="flex items-start gap-3">
<div className="shrink-0 w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
<Icon size={16} className="text-slate-400" />
</div>
<div className="min-w-0">
<p className="text-xs text-slate-500 uppercase tracking-wide">{field.label}</p>
{field.href ? (
<a
href={field.href}
target={field.href.startsWith('tel:') ? undefined : '_blank'}
rel="noopener noreferrer"
className="text-lg font-semibold text-[#0A1128] mt-1 hover:text-[#6C5CE7] inline-flex items-center gap-1.5"
>
{field.value}
{!field.href.startsWith('tel:') && <ExternalLink size={14} className="text-[#6C5CE7] shrink-0" />}
</a>
) : (
<p className="text-lg font-semibold text-[#0A1128] mt-1">{field.value}</p>
)}
</div>
</div>
</motion.div>
);
})}
</div>
{/* Lead Doctor Highlight — only show if doctor name exists */}
{data.leadDoctor.name && (
<motion.div
className="rounded-2xl bg-gradient-to-r from-[#4F1DA1]/5 to-[#021341]/5 border border-purple-100 p-6 mb-8"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<div className="flex items-center gap-2 mb-3">
<Award size={20} className="text-[#6C5CE7]" />
<h3 className="font-serif font-bold text-2xl text-[#0A1128]">대표 원장</h3>
</div>
<p className="text-xl font-bold text-[#0A1128] mb-1">{data.leadDoctor.name}</p>
{data.leadDoctor.credentials && (
<p className="text-sm text-slate-600 mb-3">{data.leadDoctor.credentials}</p>
)}
{data.leadDoctor.rating > 0 && (
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
size={16}
className={i < Math.round(data.leadDoctor.rating) ? 'text-[#6B2D8B] fill-[#6B2D8B]' : 'text-slate-200'}
/>
))}
<span className="text-sm font-semibold text-[#0A1128] ml-1">
{data.leadDoctor.rating}
</span>
</div>
{data.leadDoctor.reviewCount > 0 && (
<span className="text-sm text-slate-500">
리뷰 {formatNumber(data.leadDoctor.reviewCount)}건
</span>
)}
</div>
)}
</motion.div>
)}
{/* Certifications */}
{data.certifications.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.4 }}
>
<p className="text-sm font-semibold text-slate-700 mb-3">인증 및 자격</p>
<div className="flex flex-wrap gap-2">
{data.certifications.map((cert) => (
<span
key={cert}
className="rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700"
>
{cert}
</span>
))}
</div>
</motion.div>
)}
</SectionWrapper>
);
}