o2o-infinith-demo/src/pages/ClinicProfilePage.tsx

527 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { useState, useEffect } from 'react';
import { useParams } from 'react-router';
import { motion } from 'motion/react';
import {
YoutubeFilled,
InstagramFilled,
FacebookFilled,
GlobeFilled,
} from '../components/icons/FilledIcons';
import {
MapPin,
Phone,
Clock,
Award,
ShieldCheck,
Star,
ExternalLink,
Users,
Video,
MessageSquare,
ChevronRight,
Camera,
BadgeCheck,
Building2,
GraduationCap,
Stethoscope,
Heart,
TrendingUp,
} from 'lucide-react';
import { fetchReportById } from '../lib/supabase';
// ─── 기본값 (DB 데이터로 덮어쓸 수 있음) ───
const CLINIC: Record<string, any> = {
name: '뷰성형외과의원',
nameEn: 'VIEW Plastic Surgery',
logo: '/assets/clients/view-clinic/logo-circle.png',
brandColor: '#7B2D8E',
established: 2005,
location: '서울 강남구 봉은사로 107 뷰성형외과 빌딩',
nearestStation: '9호선 신논현역 3번 출구 도보 1분',
phone: '02-539-1177',
hours: [
{ day: '월~목', time: '10:00 19:00' },
{ day: '금요일', time: '10:00 21:00' },
{ day: '토요일', time: '10:00 17:00' },
{ day: '일/공휴일', time: '휴진' },
],
specialties: ['가슴성형', '안면윤곽', '양악', '눈성형', '코성형', '지방흡입', '리프팅', '피부시술', '필러/보톡스'],
certifications: [
'수술실 CCTV 운영',
'전담 마취과 전문의 상주',
'입원실 완비',
'의료진 실명 공개',
'응급 대응 체계',
'분야별 공동 진료',
'전용 휴식 공간',
'시술 후 관리',
'야간 진료',
'여성 의사 진료',
],
mediaAppearances: ['렛미인 TV 출연', '보건복지부장관 표창 수상', '안면윤곽 대상 수상', '모티바 사용량 1위'],
websites: [
{ label: '공식 홈페이지', url: 'viewclinic.com', primary: true },
{ label: '영문 사이트', url: 'viewplasticsurgery.com', primary: false },
],
socialChannels: [
{ platform: 'YouTube', handle: '@ViewclinicKR', url: 'youtube.com/@ViewclinicKR', followers: '103K 구독자', videos: '1,064개 영상', icon: YoutubeFilled, color: '#FF0000' },
{ platform: 'Instagram KR', handle: '@viewplastic', url: 'instagram.com/viewplastic', followers: '14K 팔로워', videos: '1,409 게시물', icon: InstagramFilled, color: '#E1306C' },
{ platform: 'Instagram EN', handle: '@view_plastic_surgery', url: 'instagram.com/view_plastic_surgery', followers: '68.8K 팔로워', videos: '2,524 게시물', icon: InstagramFilled, color: '#E1306C' },
{ platform: 'Facebook', handle: 'View Plastic Surgery', url: 'facebook.com/viewclinic', followers: '88K 팔로워', videos: '', icon: FacebookFilled, color: '#1877F2' },
{ platform: '카카오톡', handle: '뷰성형외과의원', url: 'pf.kakao.com/_xbtVxjl', followers: '상담 채널', videos: '', icon: MessageSquare as any, color: '#FEE500' },
],
};
// ─── 의료진 ───
const DOCTORS: Record<string, any>[] = [
{ name: '최순우', title: '대표원장', specialty: '가슴성형', credentials: '서울대 출신, 의학박사', rating: 9.4, reviews: 1812, featured: true },
{ name: '정재현', title: '원장', specialty: '가슴성형', credentials: '성형외과 전문의', rating: 9.6, reviews: 3177, featured: false },
{ name: '김정민', title: '원장', specialty: '리프팅 · 눈성형', credentials: '성형외과 전문의', rating: 9.7, reviews: 878, featured: false },
{ name: '윤창운', title: '원장', specialty: '안면윤곽 · 양악', credentials: '성형외과 전문의', rating: 9.6, reviews: 764, featured: false },
{ name: '조진우', title: '원장', specialty: '리프팅 · 지방 · 눈코', credentials: '성형외과 전문의', rating: 9.6, reviews: 1624, featured: false },
{ name: '김도형', title: '원장', specialty: '눈성형 · 코성형', credentials: '성형외과 전문의', rating: 9.7, reviews: 191, featured: false },
];
// ─── 플랫폼별 평점 ───
const RATINGS: Record<string, any>[] = [
{ platform: '강남언니', rating: '9.5', scale: '/10', reviews: '18,961건', color: '#FF6B8A', pct: 95 },
{ platform: '네이버 플레이스', rating: '4.6', scale: '/5', reviews: '324건', color: '#03C75A', pct: 92 },
{ platform: 'Google Maps', rating: '4.3', scale: '/5', reviews: '187건', color: '#4285F4', pct: 86 },
];
// ─── 시술 가격 (강남언니 실제 데이터) ───
const PROCEDURES = [
{ name: '가슴성형 (보형물)', price: '₩2,650,000~', category: '가슴' },
{ name: '모티바 가슴성형', price: '₩11,550,000~', category: '가슴' },
{ name: '눈성형 (매몰/절개)', price: '₩440,000~', category: '눈' },
{ name: '코성형', price: '₩990,000~', category: '코' },
{ name: '안면윤곽', price: '₩3,289,000~', category: '윤곽' },
{ name: '지방흡입', price: '₩1,100,000~', category: '바디' },
{ name: '실리프팅', price: '₩1,958,000~', category: '리프팅' },
{ name: '필러', price: '₩220,000~', category: '피부' },
];
// ─── Component ───
export default function ClinicProfilePage() {
const { id } = useParams<{ id: string }>();
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Override CLINIC data with DB data when available
useEffect(() => {
if (!id) { setIsLoading(false); return; }
fetchReportById(id)
.then((row) => {
const report = row.report as Record<string, unknown>;
const clinicInfo = report.clinicInfo as Record<string, unknown> | undefined;
const scrapeData = row.scrape_data as Record<string, unknown> | undefined;
const clinic = (scrapeData?.clinic as Record<string, unknown>) || {};
if (clinicInfo?.name) CLINIC.name = clinicInfo.name as string;
if (clinicInfo?.address) CLINIC.location = clinicInfo.address as string;
if (clinicInfo?.phone) CLINIC.phone = clinicInfo.phone as string;
if (clinicInfo?.services) CLINIC.specialties = clinicInfo.services as string[];
if (clinicInfo?.doctors) {
const docs = clinicInfo.doctors as { name: string; specialty: string }[];
DOCTORS.length = 0;
docs.forEach((d) => {
DOCTORS.push({ name: d.name, title: '원장', specialty: d.specialty, credentials: '성형외과 전문의', rating: 0, reviews: 0, featured: false });
});
if (DOCTORS.length > 0) DOCTORS[0].featured = true;
}
CLINIC.nameEn = (row.clinic_name || '').includes('의원') ? '' : row.clinic_name || '';
// Update website
const domain = (() => { try { return new URL(row.url || '').hostname; } catch { return row.url || ''; } })();
CLINIC.websites = [{ label: '공식 홈페이지', url: domain, primary: true }];
// Update social from socialHandles
const handles = report.socialHandles as Record<string, string | null> | undefined;
if (handles) {
CLINIC.socialChannels = [];
if (handles.instagram) CLINIC.socialChannels.push({ platform: 'Instagram', handle: `@${handles.instagram}`, url: `instagram.com/${handles.instagram}`, followers: '-', videos: '', icon: InstagramFilled, color: '#E1306C' });
if (handles.youtube) CLINIC.socialChannels.push({ platform: 'YouTube', handle: `@${handles.youtube}`, url: `youtube.com/${handles.youtube}`, followers: '-', videos: '', icon: YoutubeFilled, color: '#FF0000' });
if (handles.facebook) CLINIC.socialChannels.push({ platform: 'Facebook', handle: handles.facebook, url: `facebook.com/${handles.facebook}`, followers: '-', videos: '', icon: FacebookFilled, color: '#1877F2' });
}
// Update ratings from channel analysis
const chAnalysis = report.channelAnalysis as Record<string, Record<string, unknown>> | undefined;
if (chAnalysis) {
RATINGS.length = 0;
if (chAnalysis.gangnamUnni?.rating) {
const guRating = chAnalysis.gangnamUnni.rating as number;
const guScale = guRating > 5 ? '/10' : '/5';
const guPct = guRating > 5 ? (guRating / 10) * 100 : (guRating / 5) * 100;
RATINGS.push({ platform: '강남언니', rating: `${guRating}`, scale: guScale, reviews: `${chAnalysis.gangnamUnni.reviews ?? '-'}`, color: '#FF6B8A', pct: guPct });
}
if (chAnalysis.naverPlace?.rating) RATINGS.push({ platform: '네이버 플레이스', rating: `${chAnalysis.naverPlace.rating}`, scale: '/5', reviews: `${chAnalysis.naverPlace.reviews ?? '-'}`, color: '#03C75A', pct: ((chAnalysis.naverPlace.rating as number) / 5) * 100 });
}
})
.catch((err) => setError(err.message))
.finally(() => setIsLoading(false));
}, [id]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center pt-20">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-4 border-[#6C5CE7] border-t-transparent rounded-full animate-spin" />
<p className="text-slate-500 text-sm"> ...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center pt-20">
<div className="text-center">
<p className="text-[#7C3A4B] font-medium mb-2"> </p>
<p className="text-slate-500 text-sm">{error}</p>
</div>
</div>
);
}
return (
<div className="pt-20 min-h-screen bg-slate-50">
{/* ── Hero / Clinic Header ── */}
<div className="bg-[#0A1128] pt-10 pb-16 px-6 relative overflow-hidden">
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-[#7B2D8E]/10 blur-[120px]" />
<div className="absolute bottom-0 left-0 w-[300px] h-[300px] rounded-full bg-purple-500/5 blur-[100px]" />
<div className="max-w-5xl mx-auto relative">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-xs text-purple-300/60 mb-6">
<span> </span>
<ChevronRight size={12} />
<span></span>
<ChevronRight size={12} />
<span className="text-purple-200"></span>
</div>
<div className="flex flex-col md:flex-row items-start gap-6">
{/* Logo */}
<div className="w-20 h-20 rounded-2xl bg-white/10 backdrop-blur-sm border border-white/20 flex items-center justify-center flex-shrink-0 overflow-hidden">
<div className="w-16 h-16 rounded-xl flex items-center justify-center" style={{ backgroundColor: CLINIC.brandColor }}>
<span className="text-white font-serif font-bold text-lg">VIEW</span>
</div>
</div>
{/* Info */}
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<h1 className="font-serif text-2xl md:text-3xl font-bold text-white">{CLINIC.name}</h1>
<BadgeCheck size={22} className="text-[#9B8AD4]" />
</div>
<p className="text-purple-200/60 text-sm mb-4">{CLINIC.nameEn} · {CLINIC.established} · {new Date().getFullYear() - CLINIC.established}</p>
{/* Quick Stats */}
<div className="flex flex-wrap gap-3">
{[
{ icon: Star, label: '강남언니 9.5점', sub: '18,961 리뷰' },
{ icon: Users, label: `의료진 ${DOCTORS.length}`, sub: '성형외과 전문의' },
{ icon: Video, label: 'YouTube 103K', sub: '1,064 영상' },
].map((stat) => {
const Icon = stat.icon;
return (
<div key={stat.label} className="flex items-center gap-2 px-3 py-2 rounded-xl bg-white/5 border border-white/10">
<Icon size={16} className="text-purple-300" />
<div>
<p className="text-sm font-medium text-white">{stat.label}</p>
<p className="text-xs text-purple-300/60">{stat.sub}</p>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
<div className="max-w-5xl mx-auto px-6 -mt-6">
<div className="space-y-6">
{/* ── 기본 정보 카드 ── */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
>
<h2 className="font-serif text-lg font-bold text-primary-900 mb-4 flex items-center gap-2">
<Building2 size={18} className="text-[#6C5CE7]" />
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-3">
<div className="flex items-start gap-3">
<MapPin size={16} className="text-slate-400 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-primary-900">{CLINIC.location}</p>
<p className="text-xs text-slate-400">{CLINIC.nearestStation}</p>
</div>
</div>
<div className="flex items-center gap-3">
<Phone size={16} className="text-slate-400 flex-shrink-0" />
<p className="text-sm font-medium text-primary-900">{CLINIC.phone}</p>
</div>
<div className="flex items-start gap-3">
<GlobeFilled size={16} className="text-slate-400 flex-shrink-0" />
<div className="flex flex-wrap gap-2">
{CLINIC.websites.map(w => (
<span key={w.url} className={`text-sm ${w.primary ? 'text-[#6C5CE7] font-medium' : 'text-slate-400'}`}>
{w.url}
</span>
))}
</div>
</div>
</div>
<div>
<div className="flex items-start gap-3">
<Clock size={16} className="text-slate-400 mt-0.5 flex-shrink-0" />
<div className="space-y-1">
{CLINIC.hours.map(h => (
<div key={h.day} className="flex gap-3 text-sm">
<span className="text-slate-400 w-16">{h.day}</span>
<span className={`font-medium ${h.time === '휴진' ? 'text-[#D4889A]' : 'text-primary-900'}`}>{h.time}</span>
</div>
))}
</div>
</div>
</div>
</div>
</motion.div>
{/* ── 플랫폼별 통합 평점 ── */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
>
<h2 className="font-serif text-lg font-bold text-primary-900 mb-4 flex items-center gap-2">
<Star size={18} className="text-[#6C5CE7]" />
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{RATINGS.map((r, i) => (
<motion.div
key={r.platform}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.1 }}
className="rounded-xl border border-slate-100 p-4"
>
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-slate-500">{r.platform}</span>
<span className="text-xs text-slate-400">{r.reviews}</span>
</div>
<div className="flex items-baseline gap-1 mb-3">
<span className="text-3xl font-bold text-primary-900">{r.rating}</span>
<span className="text-sm text-slate-400">{r.scale}</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
whileInView={{ width: `${r.pct}%` }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: i * 0.1 + 0.2 }}
className="h-full rounded-full"
style={{ backgroundColor: r.color }}
/>
</div>
</motion.div>
))}
</div>
</motion.div>
{/* ── 의료진 ── */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
>
<h2 className="font-serif text-lg font-bold text-primary-900 mb-4 flex items-center gap-2">
<Stethoscope size={18} className="text-[#6C5CE7]" />
({DOCTORS.length})
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{DOCTORS.map((doc, i) => (
<motion.div
key={doc.name}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.06 }}
className={`rounded-xl border p-4 ${doc.featured ? 'border-[#D5CDF5] bg-[#F3F0FF]/30' : 'border-slate-100'}`}
>
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center flex-shrink-0">
<GraduationCap size={18} className="text-slate-400" />
</div>
<div>
<div className="flex items-center gap-1.5">
<p className="font-semibold text-primary-900">{doc.name}</p>
<span className="text-xs text-slate-400">{doc.title}</span>
{doc.featured && <span className="text-xs px-1.5 py-0.5 rounded bg-[#6C5CE7] text-white font-medium"></span>}
</div>
<p className="text-xs text-slate-400">{doc.credentials}</p>
</div>
</div>
<div className="flex items-center justify-between mt-3 pt-3 border-t border-slate-100">
<span className="text-xs text-slate-400">{doc.specialty}</span>
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-primary-900">{doc.rating}</span>
<span className="text-xs text-slate-400">/ 10</span>
<span className="text-xs text-slate-300">·</span>
<span className="text-xs text-slate-400">{doc.reviews.toLocaleString()}</span>
</div>
</div>
</motion.div>
))}
</div>
</motion.div>
{/* ── 시술 및 가격 ── */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
>
<h2 className="font-serif text-lg font-bold text-primary-900 mb-4 flex items-center gap-2">
<Heart size={18} className="text-[#6C5CE7]" />
</h2>
<p className="text-xs text-slate-400 mb-4"> · </p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{PROCEDURES.map((proc, i) => (
<motion.div
key={proc.name}
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.04 }}
className="flex items-center justify-between px-4 py-3 rounded-xl hover:bg-slate-50 transition-colors"
>
<div className="flex items-center gap-3">
<span className="text-xs px-2 py-0.5 rounded-full bg-[#F3F0FF] text-[#4A3A7C] font-medium">{proc.category}</span>
<span className="text-sm text-primary-900">{proc.name}</span>
</div>
<span className="text-sm font-bold text-primary-900">{proc.price}</span>
</motion.div>
))}
</div>
</motion.div>
{/* ── 인증 & 수상 ── */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
>
<h2 className="font-serif text-lg font-bold text-primary-900 mb-4 flex items-center gap-2">
<ShieldCheck size={18} className="text-[#6C5CE7]" />
</h2>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 mb-6">
{CLINIC.certifications.map((cert, i) => (
<motion.div
key={cert}
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ delay: i * 0.03 }}
className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-[#F3F0FF]/50 border border-[#D5CDF5]/50"
>
<BadgeCheck size={14} className="text-[#9B8AD4] flex-shrink-0" />
<span className="text-xs text-[#4A3A7C] font-medium">{cert}</span>
</motion.div>
))}
</div>
<div className="flex flex-wrap gap-2">
{CLINIC.mediaAppearances.map(m => (
<span key={m} className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[#FFF6ED] border border-[#F5E0C5] text-xs text-[#7C5C3A] font-medium">
<Award size={12} />
{m}
</span>
))}
</div>
</motion.div>
{/* ── 온라인 채널 ── */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
>
<h2 className="font-serif text-lg font-bold text-primary-900 mb-4 flex items-center gap-2">
<TrendingUp size={18} className="text-[#6C5CE7]" />
</h2>
<div className="space-y-3">
{CLINIC.socialChannels.map((ch, i) => {
const Icon = ch.icon;
return (
<motion.div
key={ch.platform}
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.06 }}
className="flex items-center gap-4 px-4 py-3 rounded-xl border border-slate-100 hover:border-slate-200 transition-colors"
>
<div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0" style={{ backgroundColor: `${ch.color}12` }}>
<Icon size={20} style={{ color: ch.color }} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-primary-900 text-sm">{ch.platform}</p>
<span className="text-xs text-slate-400">{ch.handle}</span>
</div>
<p className="text-xs text-slate-400">{ch.url}</p>
</div>
<div className="text-right flex-shrink-0">
<p className="text-sm font-semibold text-primary-900">{ch.followers}</p>
{ch.videos && <p className="text-xs text-slate-400">{ch.videos}</p>}
</div>
<ExternalLink size={14} className="text-slate-300 flex-shrink-0" />
</motion.div>
);
})}
</div>
</motion.div>
{/* ── 데이터 출처 고지 ── */}
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
className="rounded-2xl bg-slate-100/50 border border-slate-200/50 p-5 mb-10"
>
<p className="text-xs text-slate-400 leading-relaxed">
<span className="font-semibold text-slate-500"> :</span>{' '}
.
, , Google Maps ,
. .
: 2026-03-30 · : contact@infinith.io
</p>
</motion.div>
</div>
</div>
</div>
);
}