feat: P1-5/6/7 — AI KPI targets, website tech audit, dynamic clinic profile
- P1-5: Add kpiTargets schema to AI prompt, use AI-generated goals instead of hardcoded multipliers - P1-6: Extend website channelAnalysis with trackingPixels, snsLinksOnSite, additionalDomains, mainCTA - P1-7: ClinicProfilePage fetches data from DB by report ID instead of hardcoded VIEW clinic data Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>claude/bold-hawking
parent
7ea9972c7e
commit
200497fa1e
|
|
@ -24,6 +24,10 @@ interface ApiReport {
|
||||||
reviews?: number;
|
reviews?: number;
|
||||||
issues?: string[];
|
issues?: string[];
|
||||||
recommendation?: string;
|
recommendation?: string;
|
||||||
|
trackingPixels?: { name: string; installed: boolean }[];
|
||||||
|
snsLinksOnSite?: boolean;
|
||||||
|
additionalDomains?: { domain: string; purpose: string }[];
|
||||||
|
mainCTA?: string;
|
||||||
}>;
|
}>;
|
||||||
competitors?: {
|
competitors?: {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -51,6 +55,12 @@ interface ApiReport {
|
||||||
asIs?: string;
|
asIs?: string;
|
||||||
toBe?: string;
|
toBe?: string;
|
||||||
}[];
|
}[];
|
||||||
|
kpiTargets?: {
|
||||||
|
metric?: string;
|
||||||
|
current?: string;
|
||||||
|
target3Month?: string;
|
||||||
|
target12Month?: string;
|
||||||
|
}[];
|
||||||
marketTrends?: string[];
|
marketTrends?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -252,10 +262,16 @@ export function transformApiReport(
|
||||||
|
|
||||||
websiteAudit: {
|
websiteAudit: {
|
||||||
primaryDomain: new URL(metadata.url).hostname,
|
primaryDomain: new URL(metadata.url).hostname,
|
||||||
additionalDomains: [],
|
additionalDomains: (r.channelAnalysis?.website?.additionalDomains || []).map(d => ({
|
||||||
snsLinksOnSite: false,
|
domain: (d as { domain?: string }).domain || '',
|
||||||
trackingPixels: [],
|
purpose: (d as { purpose?: string }).purpose || '',
|
||||||
mainCTA: '',
|
})),
|
||||||
|
snsLinksOnSite: r.channelAnalysis?.website?.snsLinksOnSite ?? false,
|
||||||
|
trackingPixels: (r.channelAnalysis?.website?.trackingPixels || []).map(p => ({
|
||||||
|
name: (p as { name?: string }).name || '',
|
||||||
|
installed: (p as { installed?: boolean }).installed ?? false,
|
||||||
|
})),
|
||||||
|
mainCTA: r.channelAnalysis?.website?.mainCTA || '',
|
||||||
},
|
},
|
||||||
|
|
||||||
problemDiagnosis: buildDiagnosis(r),
|
problemDiagnosis: buildDiagnosis(r),
|
||||||
|
|
@ -304,13 +320,22 @@ export function transformApiReport(
|
||||||
.map(rec => ({ task: rec.title || rec.description || '', completed: false })),
|
.map(rec => ({ task: rec.title || rec.description || '', completed: false })),
|
||||||
})),
|
})),
|
||||||
|
|
||||||
kpiDashboard: [
|
kpiDashboard: r.kpiTargets?.length
|
||||||
|
? r.kpiTargets
|
||||||
|
.filter((k): k is { metric?: string; current?: string; target3Month?: string; target12Month?: string } => !!k?.metric)
|
||||||
|
.map(k => ({
|
||||||
|
metric: k.metric || '',
|
||||||
|
current: k.current || '-',
|
||||||
|
target3Month: k.target3Month || '-',
|
||||||
|
target12Month: k.target12Month || '-',
|
||||||
|
}))
|
||||||
|
: [
|
||||||
{ metric: '종합 점수', current: `${r.overallScore ?? '-'}`, target3Month: `${Math.min((r.overallScore ?? 50) + 15, 100)}`, target12Month: `${Math.min((r.overallScore ?? 50) + 30, 100)}` },
|
{ metric: '종합 점수', current: `${r.overallScore ?? '-'}`, target3Month: `${Math.min((r.overallScore ?? 50) + 15, 100)}`, target12Month: `${Math.min((r.overallScore ?? 50) + 30, 100)}` },
|
||||||
...(r.channelAnalysis?.instagram ? [
|
...(r.channelAnalysis?.instagram ? [
|
||||||
{ metric: 'Instagram 팔로워', current: `${r.channelAnalysis.instagram.followers ?? 0}`, target3Month: `${Math.round((r.channelAnalysis.instagram.followers ?? 0) * 1.3)}`, target12Month: `${Math.round((r.channelAnalysis.instagram.followers ?? 0) * 2)}` },
|
{ metric: 'Instagram 팔로워', current: `${r.channelAnalysis.instagram.followers ?? 0}`, target3Month: '-', target12Month: '-' },
|
||||||
] : []),
|
] : []),
|
||||||
...(r.channelAnalysis?.youtube ? [
|
...(r.channelAnalysis?.youtube ? [
|
||||||
{ metric: 'YouTube 구독자', current: `${r.channelAnalysis.youtube.subscribers ?? 0}`, target3Month: `${Math.round((r.channelAnalysis.youtube.subscribers ?? 0) * 1.5)}`, target12Month: `${Math.round((r.channelAnalysis.youtube.subscribers ?? 0) * 3)}` },
|
{ metric: 'YouTube 구독자', current: `${r.channelAnalysis.youtube.subscribers ?? 0}`, target3Month: '-', target12Month: '-' },
|
||||||
] : []),
|
] : []),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams } from 'react-router';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import {
|
import {
|
||||||
YoutubeFilled,
|
YoutubeFilled,
|
||||||
|
|
@ -25,10 +27,11 @@ import {
|
||||||
Heart,
|
Heart,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { fetchReportById } from '../lib/supabase';
|
||||||
|
|
||||||
// ─── 수집 데이터 기반 병원 프로필 (정적 데이터) ───
|
// ─── 기본값 (DB 데이터로 덮어쓸 수 있음) ───
|
||||||
|
|
||||||
const CLINIC = {
|
const CLINIC: Record<string, any> = {
|
||||||
name: '뷰성형외과의원',
|
name: '뷰성형외과의원',
|
||||||
nameEn: 'VIEW Plastic Surgery',
|
nameEn: 'VIEW Plastic Surgery',
|
||||||
logo: '/assets/clients/view-clinic/logo-circle.png',
|
logo: '/assets/clients/view-clinic/logo-circle.png',
|
||||||
|
|
@ -70,9 +73,9 @@ const CLINIC = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── 의료진 (강남언니 실제 데이터) ───
|
// ─── 의료진 ───
|
||||||
|
|
||||||
const DOCTORS = [
|
const DOCTORS: Record<string, any>[] = [
|
||||||
{ name: '최순우', title: '대표원장', specialty: '가슴성형', credentials: '서울대 출신, 의학박사', rating: 9.4, reviews: 1812, featured: true },
|
{ 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.6, reviews: 3177, featured: false },
|
||||||
{ name: '김정민', title: '원장', specialty: '리프팅 · 눈성형', credentials: '성형외과 전문의', rating: 9.7, reviews: 878, featured: false },
|
{ name: '김정민', title: '원장', specialty: '리프팅 · 눈성형', credentials: '성형외과 전문의', rating: 9.7, reviews: 878, featured: false },
|
||||||
|
|
@ -81,9 +84,9 @@ const DOCTORS = [
|
||||||
{ name: '김도형', title: '원장', specialty: '눈성형 · 코성형', credentials: '성형외과 전문의', rating: 9.7, reviews: 191, featured: false },
|
{ name: '김도형', title: '원장', specialty: '눈성형 · 코성형', credentials: '성형외과 전문의', rating: 9.7, reviews: 191, featured: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── 플랫폼별 평점 (집계) ───
|
// ─── 플랫폼별 평점 ───
|
||||||
|
|
||||||
const RATINGS = [
|
const RATINGS: Record<string, any>[] = [
|
||||||
{ platform: '강남언니', rating: '9.5', scale: '/10', reviews: '18,961건', color: '#FF6B8A', pct: 95 },
|
{ 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: '네이버 플레이스', 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 },
|
{ platform: 'Google Maps', rating: '4.3', scale: '/5', reviews: '187건', color: '#4285F4', pct: 86 },
|
||||||
|
|
@ -105,6 +108,81 @@ const PROCEDURES = [
|
||||||
// ─── Component ───
|
// ─── Component ───
|
||||||
|
|
||||||
export default function ClinicProfilePage() {
|
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 = new URL(row.url).hostname;
|
||||||
|
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) RATINGS.push({ platform: '강남언니', rating: `${chAnalysis.gangnamUnni.rating}`, scale: '/5', reviews: `${chAnalysis.gangnamUnni.reviews ?? '-'}건`, color: '#FF6B8A', pct: ((chAnalysis.gangnamUnni.rating as number) / 5) * 100 });
|
||||||
|
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 (
|
return (
|
||||||
<div className="pt-20 min-h-screen bg-slate-50">
|
<div className="pt-20 min-h-screen bg-slate-50">
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ ${JSON.stringify(analyzeResult.data?.analysis || {}, null, 2)}
|
||||||
"youtube": { "score": 0-100, "status": "active|inactive|weak", "subscribers": 0, "recommendation": "추천사항" },
|
"youtube": { "score": 0-100, "status": "active|inactive|weak", "subscribers": 0, "recommendation": "추천사항" },
|
||||||
"naverPlace": { "score": 0-100, "rating": 0, "reviews": 0, "recommendation": "추천사항" },
|
"naverPlace": { "score": 0-100, "rating": 0, "reviews": 0, "recommendation": "추천사항" },
|
||||||
"gangnamUnni": { "score": 0-100, "rating": 0, "reviews": 0, "recommendation": "추천사항" },
|
"gangnamUnni": { "score": 0-100, "rating": 0, "reviews": 0, "recommendation": "추천사항" },
|
||||||
"website": { "score": 0-100, "issues": [], "recommendation": "추천사항" }
|
"website": { "score": 0-100, "issues": [], "recommendation": "추천사항", "trackingPixels": [{"name": "Google Analytics|Facebook Pixel|Naver Analytics|etc", "installed": true}], "snsLinksOnSite": true, "additionalDomains": [{"domain": "example.com", "purpose": "용도"}], "mainCTA": "주요 전환 유도 요소" }
|
||||||
},
|
},
|
||||||
"competitors": [
|
"competitors": [
|
||||||
{ "name": "경쟁병원명", "strengths": ["강점1"], "weaknesses": ["약점1"], "marketingChannels": ["채널1"] }
|
{ "name": "경쟁병원명", "strengths": ["강점1"], "weaknesses": ["약점1"], "marketingChannels": ["채널1"] }
|
||||||
|
|
@ -123,6 +123,9 @@ ${JSON.stringify(analyzeResult.data?.analysis || {}, null, 2)}
|
||||||
{ "area": "해시태그/키워드", "asIs": "현재 해시태그 전략", "toBe": "최적화된 해시태그 세트" },
|
{ "area": "해시태그/키워드", "asIs": "현재 해시태그 전략", "toBe": "최적화된 해시태그 세트" },
|
||||||
{ "area": "포지셔닝", "asIs": "현재 시장 포지셔닝", "toBe": "목표 포지셔닝" }
|
{ "area": "포지셔닝", "asIs": "현재 시장 포지셔닝", "toBe": "목표 포지셔닝" }
|
||||||
],
|
],
|
||||||
|
"kpiTargets": [
|
||||||
|
{ "metric": "지표명 (종합 점수, Instagram 팔로워, YouTube 구독자, 네이버 블로그 방문자, 월간 상담 문의 등)", "current": "현재 수치", "target3Month": "3개월 목표 (현실적)", "target12Month": "12개월 목표 (도전적)" }
|
||||||
|
],
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
{ "priority": "high|medium|low", "category": "카테고리", "title": "제목", "description": "설명", "expectedImpact": "기대 효과" }
|
{ "priority": "high|medium|low", "category": "카테고리", "title": "제목", "description": "설명", "expectedImpact": "기대 효과" }
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue