= {
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: [],
+ },
+ };
+}