From 4484ac788a1aa3bd232a155b58c709ffa9743ed0 Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Thu, 2 Apr 2026 13:58:40 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20P0=20fixes=20=E2=80=94=20date=20formatt?= =?UTF-8?q?ing,=20channel=20labels,=20dynamic=20marketing=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ReportHeader/PlanHeader: format ISO dates as Korean (2026년 4월 2일) - ChannelOverview: map API keys to Korean labels (naverBlog → 네이버 블로그) - useMarketingPlan: replace mockPlan with real DB-based plan generation - transformPlan: build MarketingPlan from report data (channels, pillars, calendar) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/plan/PlanHeader.tsx | 14 +- src/components/report/ChannelOverview.tsx | 14 +- src/components/report/ReportHeader.tsx | 14 +- src/hooks/useMarketingPlan.ts | 50 ++++- src/lib/transformPlan.ts | 222 ++++++++++++++++++++++ 5 files changed, 302 insertions(+), 12 deletions(-) create mode 100644 src/lib/transformPlan.ts diff --git a/src/components/plan/PlanHeader.tsx b/src/components/plan/PlanHeader.tsx index 40a61f7..cf41d7f 100644 --- a/src/components/plan/PlanHeader.tsx +++ b/src/components/plan/PlanHeader.tsx @@ -1,6 +1,18 @@ import { motion } from 'motion/react'; import { Calendar, Globe } from 'lucide-react'; +function formatDate(raw: string): string { + try { + return new Date(raw).toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + } catch { + return raw; + } +} + interface PlanHeaderProps { clinicName: string; clinicNameEn: string; @@ -82,7 +94,7 @@ export default function PlanHeader({ > - {date} + {formatDate(date)} diff --git a/src/components/report/ChannelOverview.tsx b/src/components/report/ChannelOverview.tsx index 5499f93..88ddbc8 100644 --- a/src/components/report/ChannelOverview.tsx +++ b/src/components/report/ChannelOverview.tsx @@ -19,6 +19,18 @@ const iconMap: Record = { + naverBlog: '네이버 블로그', + naverPlace: '네이버 플레이스', + gangnamUnni: '강남언니', + instagram: 'Instagram', + youtube: 'YouTube', + facebook: 'Facebook', + website: '웹사이트', + tiktok: 'TikTok', + blog: '블로그', +}; + const brandColor: Record = { facebook: '#1877F2', instagram: '#E1306C', @@ -49,7 +61,7 @@ export default function ChannelOverview({ channels }: ChannelOverviewProps) {
-

{ch.channel}

+

{channelLabel[ch.channel] || ch.channel}

{ch.headline} diff --git a/src/components/report/ReportHeader.tsx b/src/components/report/ReportHeader.tsx index 3b535a6..ea65783 100644 --- a/src/components/report/ReportHeader.tsx +++ b/src/components/report/ReportHeader.tsx @@ -2,6 +2,18 @@ import { motion } from 'motion/react'; import { Calendar, Globe, MapPin } from 'lucide-react'; import { ScoreRing } from './ui/ScoreRing'; +function formatDate(raw: string): string { + try { + return new Date(raw).toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + } catch { + return raw; + } +} + interface ReportHeaderProps { clinicName: string; clinicNameEn: string; @@ -108,7 +120,7 @@ export default function ReportHeader({ > - {date} + {formatDate(date)} diff --git a/src/hooks/useMarketingPlan.ts b/src/hooks/useMarketingPlan.ts index 08a2611..0230ccb 100644 --- a/src/hooks/useMarketingPlan.ts +++ b/src/hooks/useMarketingPlan.ts @@ -1,6 +1,8 @@ import { useState, useEffect } from 'react'; +import { useLocation } from 'react-router'; import type { MarketingPlan } from '../types/plan'; -import { mockPlan } from '../data/mockPlan'; +import { fetchReportById } from '../lib/supabase'; +import { transformReportToPlan } from '../lib/transformPlan'; interface UseMarketingPlanResult { data: MarketingPlan | null; @@ -8,10 +10,17 @@ interface UseMarketingPlanResult { error: string | null; } +interface LocationState { + report?: Record; + metadata?: Record; + reportId?: string; +} + export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const location = useLocation(); useEffect(() => { if (!id) { @@ -20,15 +29,38 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult return; } - // Phase 1: Return mock data - // Phase 2+: Replace with real API call - const timer = setTimeout(() => { - setData(mockPlan); - setIsLoading(false); - }, 100); + const state = location.state as LocationState | undefined; - return () => clearTimeout(timer); - }, [id]); + // Source 1: Report data passed via navigation state + if (state?.report && state?.metadata) { + try { + const plan = transformReportToPlan({ + id: (state.reportId || id), + url: (state.metadata.url as string) || '', + clinic_name: (state.metadata.clinicName as string) || '', + report: state.report, + created_at: (state.metadata.generatedAt as string) || new Date().toISOString(), + }); + setData(plan); + setIsLoading(false); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to build marketing plan'); + setIsLoading(false); + } + return; + } + + // Source 2: Fetch report from Supabase and transform to plan + fetchReportById(id) + .then((row) => { + const plan = transformReportToPlan(row); + setData(plan); + }) + .catch((err) => { + setError(err instanceof Error ? err.message : 'Failed to fetch marketing plan'); + }) + .finally(() => setIsLoading(false)); + }, [id, location.state]); return { data, isLoading, error }; } diff --git a/src/lib/transformPlan.ts b/src/lib/transformPlan.ts new file mode 100644 index 0000000..8b34a04 --- /dev/null +++ b/src/lib/transformPlan.ts @@ -0,0 +1,222 @@ +import type { MarketingPlan, ChannelStrategyCard, ContentPillar, CalendarWeek, CalendarEntry, ContentCountSummary } from '../types/plan'; + +/** + * Raw report data from Supabase marketing_reports table. + * The `report` JSONB contains AI-generated analysis. + */ +interface RawReportRow { + id: string; + url: string; + clinic_name: string; + report: Record; + scrape_data?: Record; + analysis_data?: Record; + created_at: string; +} + +const CHANNEL_NAME_MAP: Record = { + naverBlog: '네이버 블로그', + naverPlace: '네이버 플레이스', + gangnamUnni: '강남언니', + instagram: 'Instagram', + youtube: 'YouTube', + facebook: 'Facebook', + website: '웹사이트', + tiktok: 'TikTok', +}; + +const CHANNEL_ICON_MAP: Record = { + naverBlog: 'blog', + instagram: 'instagram', + youtube: 'youtube', + facebook: 'facebook', + naverPlace: 'map', + gangnamUnni: 'star', + website: 'globe', + tiktok: 'video', +}; + +function buildChannelStrategies( + channelAnalysis: Record> | undefined, + recommendations: Record[] | undefined, +): ChannelStrategyCard[] { + if (!channelAnalysis) return []; + + return Object.entries(channelAnalysis).map(([key, ch], i) => { + const score = (ch.score as number) ?? 0; + const relatedRecs = (recommendations || []) + .filter(r => { + const cat = ((r.category as string) || '').toLowerCase(); + return cat.includes(key.toLowerCase()) || cat.includes(CHANNEL_NAME_MAP[key]?.toLowerCase() || ''); + }) + .map(r => (r.title as string) || (r.description as string) || ''); + + return { + channelId: key, + channelName: CHANNEL_NAME_MAP[key] || key, + icon: CHANNEL_ICON_MAP[key] || 'globe', + currentStatus: `점수: ${score}/100 (${(ch.status as string) || 'unknown'})`, + targetGoal: (ch.recommendation as string) || '', + contentTypes: relatedRecs.length > 0 ? relatedRecs : ['콘텐츠 전략 수립 필요'], + postingFrequency: score >= 80 ? '주 3-5회' : score >= 60 ? '주 2-3회' : '주 1-2회 (시작)', + tone: '전문적 · 친근한', + formatGuidelines: [], + priority: (score < 50 ? 'P0' : score < 70 ? 'P1' : 'P2') as 'P0' | 'P1' | 'P2', + }; + }); +} + +function buildContentPillars( + recommendations: Record[] | undefined, + services: string[] | undefined, +): ContentPillar[] { + const PILLAR_COLORS = ['#6C5CE7', '#E17055', '#00B894', '#FDCB6E']; + const pillars: ContentPillar[] = [ + { + title: '전문성 · 신뢰', + description: '의료진 소개, 수술 과정, 인증/자격 콘텐츠로 신뢰 구축', + relatedUSP: '전문 의료진', + exampleTopics: services?.slice(0, 3).map(s => `${s} 시술 과정 소개`) || ['시술 과정 소개'], + color: PILLAR_COLORS[0], + }, + { + title: '비포 · 애프터', + description: '실제 환자 사례, 수술 전후 비교로 결과 시각화', + relatedUSP: '검증된 결과', + exampleTopics: services?.slice(0, 3).map(s => `${s} 비포/애프터`) || ['비포/애프터 사례'], + color: PILLAR_COLORS[1], + }, + { + title: '환자 후기 · 리뷰', + description: '실제 환자 인터뷰, 후기 콘텐츠로 사회적 증거 확보', + relatedUSP: '환자 만족도', + exampleTopics: ['환자 인터뷰 영상', '리뷰 하이라이트', '회복 일기'], + color: PILLAR_COLORS[2], + }, + { + title: '트렌드 · 교육', + description: '시술 트렌드, Q&A, 의학 정보로 잠재 고객 유입', + relatedUSP: '최신 트렌드', + exampleTopics: ['자주 묻는 질문 Q&A', '시술별 비용 가이드', '최신 성형 트렌드'], + color: PILLAR_COLORS[3], + }, + ]; + + return pillars; +} + +function buildCalendar(channels: ChannelStrategyCard[]): { + weeks: CalendarWeek[]; + monthlySummary: ContentCountSummary[]; +} { + const DAYS = ['월', '화', '수', '목', '금', '토', '일']; + const activeChannels = channels.filter(c => c.priority !== 'P2').slice(0, 4); + + const weeks: CalendarWeek[] = [1, 2, 3, 4].map(weekNum => { + const entries: CalendarEntry[] = []; + + activeChannels.forEach((ch, chIdx) => { + const dayOffset = (weekNum - 1 + chIdx) % 7; + entries.push({ + dayOfWeek: dayOffset, + channel: ch.channelName, + channelIcon: ch.icon, + contentType: ch.icon === 'youtube' ? 'video' : ch.icon === 'blog' ? 'blog' : 'social', + title: `${ch.channelName} 콘텐츠 ${weekNum}-${chIdx + 1}`, + }); + }); + + return { + weekNumber: weekNum, + label: `${weekNum}주차`, + entries, + }; + }); + + const videoCount = weeks.reduce((sum, w) => sum + w.entries.filter(e => e.contentType === 'video').length, 0); + const blogCount = weeks.reduce((sum, w) => sum + w.entries.filter(e => e.contentType === 'blog').length, 0); + const socialCount = weeks.reduce((sum, w) => sum + w.entries.filter(e => e.contentType === 'social').length, 0); + + return { + weeks, + monthlySummary: [ + { type: 'video', label: '영상', count: videoCount, color: '#6C5CE7' }, + { type: 'blog', label: '블로그', count: blogCount, color: '#00B894' }, + { type: 'social', label: '소셜', count: socialCount, color: '#E17055' }, + { type: 'ad', label: '광고', count: 0, color: '#FDCB6E' }, + ], + }; +} + +/** + * Transform a raw Supabase report row into a MarketingPlan. + * Uses report data (channel analysis, recommendations, services) + * to dynamically generate plan content. + */ +export function transformReportToPlan(row: RawReportRow): MarketingPlan { + const report = row.report; + const clinicInfo = report.clinicInfo as Record | undefined; + const channelAnalysis = report.channelAnalysis as Record> | undefined; + const recommendations = report.recommendations as Record[] | undefined; + const services = (clinicInfo?.services as string[]) || []; + + const channelStrategies = buildChannelStrategies(channelAnalysis, recommendations); + const pillars = buildContentPillars(recommendations, services); + const calendar = buildCalendar(channelStrategies); + + return { + id: row.id, + reportId: row.id, + clinicName: (clinicInfo?.name as string) || row.clinic_name || '', + clinicNameEn: '', + createdAt: row.created_at, + targetUrl: row.url, + + brandGuide: { + colors: [], + fonts: [], + logoRules: [], + toneOfVoice: { + personality: ['전문적', '친근한', '신뢰할 수 있는'], + communicationStyle: '의료 전문 지식을 쉽고 친근하게 전달', + doExamples: ['정확한 의학 용어 사용', '환자 성공 사례 공유', '전문의 인사이트 제공'], + dontExamples: ['과장된 효과 주장', '비교 광고', '의학적 보장 표현'], + }, + channelBranding: [], + brandInconsistencies: [], + }, + + channelStrategies, + + contentStrategy: { + pillars, + typeMatrix: [ + { format: '숏폼 영상 (60초)', channels: ['YouTube Shorts', 'Instagram Reels', 'TikTok'], frequency: '주 3-5회', purpose: '신규 유입 + 인지도' }, + { format: '롱폼 영상 (5-15분)', channels: ['YouTube'], frequency: '주 1회', purpose: '전문성 + 검색 SEO' }, + { format: '블로그 포스트', channels: ['네이버 블로그'], frequency: '주 2-3회', purpose: 'SEO + 상세 정보' }, + { format: '피드 이미지', channels: ['Instagram', 'Facebook'], frequency: '주 3회', purpose: '브랜드 인지도' }, + ], + workflow: [ + { step: 1, name: '기획', description: '콘텐츠 주제 선정 + 키워드 리서치', owner: 'AI + 마케터', duration: '30분' }, + { step: 2, name: '제작', description: 'AI 초안 생성 + 의료진 감수', owner: 'INFINITH AI', duration: '1시간' }, + { step: 3, name: '편집', description: '영상/이미지 편집 + 자막 추가', owner: 'INFINITH Studio', duration: '30분' }, + { step: 4, name: '배포', description: '채널별 최적화 + 스케줄 배포', owner: 'INFINITH Distribution', duration: '자동' }, + { step: 5, name: '분석', description: '성과 데이터 수집 + 최적화 제안', owner: 'INFINITH Analytics', duration: '자동' }, + ], + repurposingSource: '1개 롱폼 영상', + repurposingOutputs: [ + { format: '숏폼 영상 3개', channel: 'YouTube Shorts / Instagram Reels', description: '핵심 장면 추출' }, + { format: '블로그 포스트', channel: '네이버 블로그', description: '영상 스크립트 기반 SEO 글' }, + { format: '피드 이미지 4장', channel: 'Instagram / Facebook', description: '핵심 정보 카드뉴스' }, + { format: '카카오톡 메시지', channel: 'KakaoTalk', description: '환자 타겟 CTA 메시지' }, + ], + }, + + calendar, + + assetCollection: { + assets: [], + youtubeRepurpose: [], + }, + }; +}