feat: P0 fixes — date formatting, channel labels, dynamic marketing plan

- 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) <noreply@anthropic.com>
claude/bold-hawking
Haewon Kam 2026-04-02 13:58:40 +09:00
parent bd7bc45192
commit 4484ac788a
5 changed files with 302 additions and 12 deletions

View File

@ -1,6 +1,18 @@
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { Calendar, Globe } from 'lucide-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 { interface PlanHeaderProps {
clinicName: string; clinicName: string;
clinicNameEn: string; clinicNameEn: string;
@ -82,7 +94,7 @@ export default function PlanHeader({
> >
<span className="inline-flex items-center gap-2 rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700"> <span className="inline-flex items-center gap-2 rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700">
<Calendar size={14} className="text-slate-400" /> <Calendar size={14} className="text-slate-400" />
{date} {formatDate(date)}
</span> </span>
<span className="inline-flex items-center gap-2 rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700"> <span className="inline-flex items-center gap-2 rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700">
<Globe size={14} className="text-slate-400" /> <Globe size={14} className="text-slate-400" />

View File

@ -19,6 +19,18 @@ const iconMap: Record<string, ComponentType<{ size?: number; className?: string
search: Search, search: Search,
}; };
const channelLabel: Record<string, string> = {
naverBlog: '네이버 블로그',
naverPlace: '네이버 플레이스',
gangnamUnni: '강남언니',
instagram: 'Instagram',
youtube: 'YouTube',
facebook: 'Facebook',
website: '웹사이트',
tiktok: 'TikTok',
blog: '블로그',
};
const brandColor: Record<string, string> = { const brandColor: Record<string, string> = {
facebook: '#1877F2', facebook: '#1877F2',
instagram: '#E1306C', instagram: '#E1306C',
@ -49,7 +61,7 @@ export default function ChannelOverview({ channels }: ChannelOverviewProps) {
<div className="w-10 h-10 rounded-xl bg-slate-50 flex items-center justify-center"> <div className="w-10 h-10 rounded-xl bg-slate-50 flex items-center justify-center">
<Icon size={20} style={color ? { color } : undefined} className={color ? '' : 'text-slate-500'} /> <Icon size={20} style={color ? { color } : undefined} className={color ? '' : 'text-slate-500'} />
</div> </div>
<p className="text-sm font-medium text-[#0A1128]">{ch.channel}</p> <p className="text-sm font-medium text-[#0A1128]">{channelLabel[ch.channel] || ch.channel}</p>
<ScoreRing score={ch.score} maxScore={ch.maxScore} size={60} color={color} /> <ScoreRing score={ch.score} maxScore={ch.maxScore} size={60} color={color} />
<p className="text-xs text-slate-500 line-clamp-2 leading-relaxed"> <p className="text-xs text-slate-500 line-clamp-2 leading-relaxed">
{ch.headline} {ch.headline}

View File

@ -2,6 +2,18 @@ import { motion } from 'motion/react';
import { Calendar, Globe, MapPin } from 'lucide-react'; import { Calendar, Globe, MapPin } from 'lucide-react';
import { ScoreRing } from './ui/ScoreRing'; 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 { interface ReportHeaderProps {
clinicName: string; clinicName: string;
clinicNameEn: string; clinicNameEn: string;
@ -108,7 +120,7 @@ export default function ReportHeader({
> >
<span className="inline-flex items-center gap-2 rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700"> <span className="inline-flex items-center gap-2 rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700">
<Calendar size={14} className="text-slate-400" /> <Calendar size={14} className="text-slate-400" />
{date} {formatDate(date)}
</span> </span>
<span className="inline-flex items-center gap-2 rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700"> <span className="inline-flex items-center gap-2 rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700">
<Globe size={14} className="text-slate-400" /> <Globe size={14} className="text-slate-400" />

View File

@ -1,6 +1,8 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useLocation } from 'react-router';
import type { MarketingPlan } from '../types/plan'; import type { MarketingPlan } from '../types/plan';
import { mockPlan } from '../data/mockPlan'; import { fetchReportById } from '../lib/supabase';
import { transformReportToPlan } from '../lib/transformPlan';
interface UseMarketingPlanResult { interface UseMarketingPlanResult {
data: MarketingPlan | null; data: MarketingPlan | null;
@ -8,10 +10,17 @@ interface UseMarketingPlanResult {
error: string | null; error: string | null;
} }
interface LocationState {
report?: Record<string, unknown>;
metadata?: Record<string, unknown>;
reportId?: string;
}
export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult { export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult {
const [data, setData] = useState<MarketingPlan | null>(null); const [data, setData] = useState<MarketingPlan | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const location = useLocation();
useEffect(() => { useEffect(() => {
if (!id) { if (!id) {
@ -20,15 +29,38 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
return; return;
} }
// Phase 1: Return mock data const state = location.state as LocationState | undefined;
// Phase 2+: Replace with real API call
const timer = setTimeout(() => {
setData(mockPlan);
setIsLoading(false);
}, 100);
return () => clearTimeout(timer); // Source 1: Report data passed via navigation state
}, [id]); 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 }; return { data, isLoading, error };
} }

222
src/lib/transformPlan.ts Normal file
View File

@ -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<string, unknown>;
scrape_data?: Record<string, unknown>;
analysis_data?: Record<string, unknown>;
created_at: string;
}
const CHANNEL_NAME_MAP: Record<string, string> = {
naverBlog: '네이버 블로그',
naverPlace: '네이버 플레이스',
gangnamUnni: '강남언니',
instagram: 'Instagram',
youtube: 'YouTube',
facebook: 'Facebook',
website: '웹사이트',
tiktok: 'TikTok',
};
const CHANNEL_ICON_MAP: Record<string, string> = {
naverBlog: 'blog',
instagram: 'instagram',
youtube: 'youtube',
facebook: 'facebook',
naverPlace: 'map',
gangnamUnni: 'star',
website: 'globe',
tiktok: 'video',
};
function buildChannelStrategies(
channelAnalysis: Record<string, Record<string, unknown>> | undefined,
recommendations: Record<string, unknown>[] | 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<string, unknown>[] | 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<string, unknown> | undefined;
const channelAnalysis = report.channelAnalysis as Record<string, Record<string, unknown>> | undefined;
const recommendations = report.recommendations as Record<string, unknown>[] | 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: [],
},
};
}