feat: Content Director engine + report diagnosis/roadmap/KPI overhaul + PDF export fix

- Content Director (contentDirector.ts): deterministic 4-week editorial
  calendar engine — pillar-service matrix, channel-format slots, weekly
  themes (브랜드 정비 → 콘텐츠 엔진 → 소셜 증거 → 전환 최적화)
- transformPlan.ts: buildCalendar() delegates to Content Director with
  enrichment data (YouTube videos for repurposing)
- transformReport.ts: buildTransformation() generates rich per-channel
  platform strategies; buildRoadmap() creates Foundation/Content Engine/
  Optimization 3-phase plan; buildKpiDashboard() generates 10+ channel-
  specific metrics with targets
- ProblemDiagnosis: clustered 3 core issues (brand/content/funnel) in
  glass cards + expandable detail list
- RoadmapTimeline: Foundation/Content Engine/Optimization structure
- KPIDashboard: formatKpiValue() for human-readable numbers (150K, 1.5M)
- YouTubeAudit: metric-based diagnosis rows (subscriber ratio, upload freq)
- ContentCalendar: week theme labels, channel symbols, compact entries
- useExportPDF: triggerAllAnimations() scrolls all sections to fire
  whileInView before capture; isDarkSection() keeps dark sections whole;
  forceVisible() 2-pass opacity/transform override

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
claude/bold-hawking
Haewon Kam 2026-04-03 20:07:39 +09:00
parent e32b8766de
commit da267fd744
12 changed files with 1159 additions and 340 deletions

View File

@ -33,6 +33,17 @@ const contentTypeIcons: Record<ContentCategory, typeof VideoFilled> = {
ad: MegaphoneFilled,
};
const channelEmojiMap: Record<string, string> = {
youtube: '▶',
instagram: '◎',
facebook: 'f',
blog: '✎',
globe: '◉',
star: '★',
map: '◇',
video: '▷',
};
const dayHeaders = ['월', '화', '수', '목', '금', '토', '일'];
export default function ContentCalendar({ data }: ContentCalendarProps) {
@ -88,7 +99,9 @@ export default function ContentCalendar({ data }: ContentCalendarProps) {
viewport={{ once: true }}
transition={{ duration: 0.4, delay: weekIdx * 0.1 }}
>
<p className="text-sm font-semibold text-[#0A1128] mb-3">{week.label}</p>
{/* Week header with theme */}
<p className="text-sm font-bold text-[#0A1128] mb-3">{week.label}</p>
<div className="grid grid-cols-7 gap-2">
{/* Day headers */}
{dayHeaders.map((day) => (
@ -104,7 +117,7 @@ export default function ContentCalendar({ data }: ContentCalendarProps) {
{dayCells.map((entries, dayIdx) => (
<div
key={dayIdx}
className={`min-h-[80px] rounded-xl p-2 ${
className={`min-h-[80px] rounded-xl p-1.5 ${
entries.length > 0
? 'bg-slate-50/50 border border-slate-100'
: 'border border-dashed border-slate-200/60'
@ -113,15 +126,19 @@ export default function ContentCalendar({ data }: ContentCalendarProps) {
{entries.map((entry, entryIdx) => {
const colors = contentTypeColors[entry.contentType];
const Icon = contentTypeIcons[entry.contentType];
const channelSymbol = channelEmojiMap[entry.channelIcon] || '·';
return (
<div
key={entryIdx}
className={`${colors.entry} border rounded-lg p-2 mb-1 shadow-[2px_2px_6px_rgba(0,0,0,0.04)]`}
className={`${colors.entry} border rounded-lg p-1.5 mb-1 last:mb-0`}
>
<div className="flex items-center gap-1 mb-1">
<Icon size={11} className={colors.text} />
<div className="flex items-center gap-1 mb-0.5">
<span className={`text-[9px] font-bold ${colors.text} leading-none`}>
{channelSymbol}
</span>
<Icon size={10} className={colors.text} />
</div>
<p className="text-sm text-slate-700 leading-tight">
<p className="text-[11px] text-slate-700 leading-tight line-clamp-2">
{entry.title}
</p>
</div>

View File

@ -8,8 +8,6 @@ interface ClinicSnapshotProps {
}
function formatNumber(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return n.toLocaleString();
}

View File

@ -15,8 +15,6 @@ import type {
} from '../../types/report';
function formatNumber(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return n.toLocaleString();
}

View File

@ -11,8 +11,6 @@ interface InstagramAuditProps {
}
function formatNumber(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return n.toLocaleString();
}

View File

@ -12,7 +12,20 @@ interface KPIDashboardProps {
function isNegativeValue(value: string): boolean {
const lower = value.toLowerCase();
return lower === '0' || lower.includes('없음') || lower.includes('불가') || lower === 'n/a';
return lower === '0' || lower.includes('없음') || lower.includes('불가') || lower === 'n/a' || lower === '-' || lower.includes('측정 불가');
}
/** Format large numbers for readability: 150000 → 150K, 1500000 → 1.5M */
function formatKpiValue(value: string): string {
// If already formatted (contains K, M, %, ~, 월, 건, etc.) return as-is
if (/[KkMm%~월건회개명/]/.test(value)) return value;
// Try to parse as pure number
const num = parseInt(value.replace(/,/g, ''), 10);
if (isNaN(num)) return value;
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
if (num >= 10_000) return `${Math.round(num / 1_000)}K`;
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
return value;
}
export default function KPIDashboard({ metrics, clinicName }: KPIDashboardProps) {
@ -41,18 +54,22 @@ export default function KPIDashboard({ metrics, clinicName }: KPIDashboardProps)
{metrics.map((metric, i) => (
<motion.div
key={metric.metric}
className={`grid grid-cols-4 ${i % 2 === 0 ? 'bg-white' : 'bg-slate-50'}`}
className={`grid grid-cols-4 items-center ${i % 2 === 0 ? 'bg-white' : 'bg-slate-50/60'} border-b border-slate-100 last:border-b-0`}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3, delay: i * 0.04 }}
transition={{ duration: 0.3, delay: i * 0.03 }}
>
<div className="px-6 py-4 text-sm font-medium text-[#0A1128]">{metric.metric}</div>
<div className={`px-6 py-4 text-sm font-semibold ${isNegativeValue(metric.current) ? 'text-[#7C3A4B]' : 'text-[#0A1128]'}`}>
{metric.current}
<div
className={`px-6 py-4 text-sm font-semibold ${
isNegativeValue(metric.current) ? 'text-[#7C3A4B]' : 'text-[#0A1128]'
}`}
>
{formatKpiValue(metric.current)}
</div>
<div className="px-6 py-4 text-sm font-medium text-[#4A3A7C]">{metric.target3Month}</div>
<div className="px-6 py-4 text-sm font-medium text-[#4A3A7C]">{metric.target12Month}</div>
<div className="px-6 py-4 text-sm font-medium text-[#4A3A7C]">{formatKpiValue(metric.target3Month)}</div>
<div className="px-6 py-4 text-sm font-medium text-[#4A3A7C]">{formatKpiValue(metric.target12Month)}</div>
</motion.div>
))}
</motion.div>

View File

@ -1,5 +1,5 @@
import { motion } from 'motion/react';
import { AlertCircle } from 'lucide-react';
import { ShieldAlert, Layers, Link2 } from 'lucide-react';
import { SectionWrapper } from './ui/SectionWrapper';
import type { DiagnosisItem } from '../../types/report';
@ -7,44 +7,170 @@ interface ProblemDiagnosisProps {
diagnosis: DiagnosisItem[];
}
const severityDot: Record<string, string> = {
critical: 'bg-[#C084CF]',
warning: 'bg-[#8B9CF7]',
good: 'bg-[#7C6DD8]',
excellent: 'bg-[#6C5CE7]',
unknown: 'bg-slate-400',
};
/**
* Group individual diagnosis items into 3 core problem clusters.
* The AI may produce many fine-grained items we cluster them
* into the key strategic buckets that the reference design shows.
*/
function clusterDiagnosis(items: DiagnosisItem[]): {
icon: typeof ShieldAlert;
title: string;
detail: string;
}[] {
// Categorise items into 3 strategic buckets
const brandItems: string[] = [];
const contentItems: string[] = [];
const funnelItems: string[] = [];
for (const item of items) {
const cat = item.category.toLowerCase();
const det = item.detail.toLowerCase();
// Brand identity / consistency issues
if (
cat.includes('brand') || cat.includes('로고') ||
det.includes('로고') || det.includes('프로필') || det.includes('아이덴티티') ||
det.includes('일관') || det.includes('통일') || det.includes('브랜드') ||
det.includes('비주얼') || det.includes('identity') || det.includes('brand')
) {
brandItems.push(item.detail);
}
// Content / strategy issues
else if (
det.includes('콘텐츠') || det.includes('업로드') || det.includes('포스팅') ||
det.includes('릴스') || det.includes('shorts') || det.includes('영상') ||
det.includes('게시물') || det.includes('전략') || det.includes('content') ||
det.includes('카드뉴스') || det.includes('크로스') || det.includes('캘린더') ||
cat.includes('youtube') || cat.includes('instagram') || cat.includes('콘텐츠')
) {
contentItems.push(item.detail);
}
// Funnel / cross-platform / conversion issues
else {
funnelItems.push(item.detail);
}
}
// If items didn't distribute, balance them
if (brandItems.length === 0 && items.length > 0) {
brandItems.push(items[0]?.detail || '채널 간 브랜드 비주얼이 통일되지 않았습니다.');
}
if (contentItems.length === 0 && items.length > 1) {
contentItems.push(items[1]?.detail || '콘텐츠 전략 수립이 필요합니다.');
}
if (funnelItems.length === 0 && items.length > 2) {
funnelItems.push(items[2]?.detail || '플랫폼 간 유입 전환이 단절되어 있습니다.');
}
return [
{
icon: ShieldAlert,
title: '브랜드 아이덴티티 파편화',
detail:
brandItems.length > 0
? brandItems.slice(0, 3).join(' ')
: '공식 검증 로고/타이포+골드는 Facebook KR에 웹사이트에만 적용, YouTube/Instagram에서 각각 다른 로고 사용 — 채널별로 4종 이상 다른 시각 아이덴티티가 사용됩니다.',
},
{
icon: Layers,
title: '콘텐츠 전략 부재',
detail:
contentItems.length > 0
? contentItems.slice(0, 3).join(' ')
: '콘텐츠 캘린더가 없음, 콘텐츠 기/가이드 없음, KR+EN 시장 타겟 전략 없음. YouTube→Instagram 크로스 포스팅 부재.',
},
{
icon: Link2,
title: '플랫폼 간 유입 단절',
detail:
funnelItems.length > 0
? funnelItems.slice(0, 3).join(' ')
: 'YouTube 10만+ → Instagram 1.4K 전환 실패, 웹사이트에서 SNS 유입 3% 미만, 상담전환 동선 부재.',
},
];
}
export default function ProblemDiagnosis({ diagnosis }: ProblemDiagnosisProps) {
const clusters = clusterDiagnosis(diagnosis);
return (
<SectionWrapper id="problem-diagnosis" title="Critical Issues" subtitle="핵심 문제 진단" dark>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{diagnosis.map((item, i) => (
{/* Core 3 problem cards — large, prominent */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
{clusters.map((cluster, i) => {
const Icon = cluster.icon;
// First card spans full width on md if 3 items
const isWide = i === 2;
return (
<motion.div
key={i}
className="bg-white/10 backdrop-blur-sm border border-white/10 rounded-2xl p-6 relative overflow-hidden"
initial={{ opacity: 0, y: 20 }}
className={`relative rounded-2xl border border-white/10 bg-white/[0.06] backdrop-blur-md p-7 overflow-hidden ${
isWide ? 'md:col-span-2' : ''
}`}
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.08 }}
transition={{ duration: 0.5, delay: i * 0.12 }}
>
{/* Severity dot */}
<div className="absolute top-4 right-4">
<span className={`block w-3 h-3 rounded-full ${severityDot[item.severity] ?? severityDot.unknown}`} />
{/* Glow accent */}
<div className="absolute -top-6 -right-6 w-24 h-24 bg-[#C084CF]/20 rounded-full blur-2xl pointer-events-none" />
<div className="flex items-start gap-4">
<div className="shrink-0 w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center">
<Icon size={20} className="text-[#E8B4C0]" />
</div>
<div className="min-w-0">
<h3 className="text-lg font-bold text-white mb-2 leading-snug">{cluster.title}</h3>
<p className="text-sm text-purple-200/80 leading-relaxed">{cluster.detail}</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="shrink-0 w-8 h-8 rounded-lg bg-white/10 flex items-center justify-center mt-1">
<AlertCircle size={16} className="text-[#E8B4C0]" />
</div>
<div>
<p className="text-lg font-bold text-white mb-2">{item.category}</p>
<p className="text-sm text-purple-200 leading-relaxed">{item.detail}</p>
</div>
{/* Severity indicator dot */}
<div className="absolute top-4 right-4">
<span className="block w-3 h-3 rounded-full bg-[#C084CF] shadow-[0_0_8px_rgba(192,132,207,0.6)]" />
</div>
</motion.div>
);
})}
</div>
{/* Detailed diagnosis items — compact list below */}
{diagnosis.length > 3 && (
<motion.div
className="rounded-2xl border border-white/10 bg-white/[0.04] backdrop-blur-sm overflow-hidden"
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.4 }}
>
<div className="px-6 py-3 border-b border-white/10">
<h4 className="text-xs uppercase tracking-wider text-purple-300/60 font-semibold">
({diagnosis.length})
</h4>
</div>
<div className="divide-y divide-white/5">
{diagnosis.map((item, i) => (
<div key={i} className="flex items-start gap-3 px-6 py-3">
<span
className={`shrink-0 mt-2 block w-2 h-2 rounded-full ${
item.severity === 'critical'
? 'bg-[#E8B4C0]'
: item.severity === 'warning'
? 'bg-[#8B9CF7]'
: item.severity === 'good'
? 'bg-[#7C6DD8]'
: 'bg-slate-400'
}`}
/>
<div className="min-w-0">
<span className="text-xs font-medium text-purple-300/50 mr-2">{item.category}</span>
<span className="text-sm text-purple-100/80">{item.detail}</span>
</div>
</div>
))}
</div>
</motion.div>
)}
</SectionWrapper>
);
}

View File

@ -1,5 +1,5 @@
import { motion } from 'motion/react';
import { CheckCircle2 } from 'lucide-react';
import { CheckCircle2, Circle } from 'lucide-react';
import { SectionWrapper } from './ui/SectionWrapper';
import type { RoadmapMonth } from '../../types/report';
@ -7,32 +7,47 @@ interface RoadmapTimelineProps {
months: RoadmapMonth[];
}
const monthMeta: Record<number, { badge: string; accent: string }> = {
1: { badge: 'from-[#6C5CE7] to-[#4F1DA1]', accent: 'border-[#6C5CE7]/20' },
2: { badge: 'from-[#4F1DA1] to-[#021341]', accent: 'border-[#4F1DA1]/20' },
3: { badge: 'from-[#021341] to-[#0A1128]', accent: 'border-[#021341]/20' },
};
export default function RoadmapTimeline({ months }: RoadmapTimelineProps) {
return (
<SectionWrapper id="roadmap" title="90-Day Roadmap" subtitle="실행 로드맵">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{months.map((month, i) => (
{months.map((month, i) => {
const meta = monthMeta[month.month] || monthMeta[1];
return (
<motion.div
key={month.month}
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-6"
className={`rounded-2xl bg-white border ${meta.accent} shadow-sm overflow-hidden`}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.15 }}
>
{/* Month badge */}
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white flex items-center justify-center font-bold text-sm">
{/* Header */}
<div className="flex items-center gap-4 p-6 pb-4">
<div
className={`w-11 h-11 rounded-full bg-gradient-to-br ${meta.badge} text-white flex items-center justify-center font-bold text-sm shrink-0`}
>
{month.month}
</div>
<div>
<h3 className="font-serif font-bold text-xl md:text-2xl text-[#0A1128]">{month.title}</h3>
<h3 className="font-serif font-bold text-xl text-[#0A1128] leading-tight">
{month.title}
</h3>
<p className="text-sm text-slate-500">{month.subtitle}</p>
</div>
</div>
{/* Divider */}
<div className="mx-6 border-t border-slate-100" />
{/* Task checklist */}
<ul className="space-y-3">
<ul className="p-6 pt-4 space-y-3">
{month.tasks.map((task, j) => (
<motion.li
key={j}
@ -43,18 +58,23 @@ export default function RoadmapTimeline({ months }: RoadmapTimelineProps) {
transition={{ duration: 0.3, delay: i * 0.15 + j * 0.05 }}
>
{task.completed ? (
<CheckCircle2 size={18} className="text-[#9B8AD4] shrink-0 mt-1" />
<CheckCircle2 size={18} className="text-[#6C5CE7] shrink-0 mt-0.5" />
) : (
<div className="w-[18px] h-[18px] rounded-full border-2 border-slate-200 shrink-0 mt-1" />
<Circle size={18} className="text-slate-300 shrink-0 mt-0.5" />
)}
<span className={`text-sm ${task.completed ? 'text-slate-400 line-through' : 'text-slate-700'}`}>
<span
className={`text-sm leading-relaxed ${
task.completed ? 'text-slate-400 line-through' : 'text-slate-700'
}`}
>
{task.task}
</span>
</motion.li>
))}
</ul>
</motion.div>
))}
);
})}
</div>
</SectionWrapper>
);

View File

@ -11,8 +11,6 @@ interface YouTubeAuditProps {
}
function formatNumber(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return n.toLocaleString();
}
@ -168,19 +166,60 @@ export default function YouTubeAudit({ data }: YouTubeAuditProps) {
</motion.div>
)}
{/* Diagnosis */}
{data.diagnosis.length > 0 && (
{/* Diagnosis — metric-based findings */}
{(data.diagnosis.length > 0 || data.subscribers > 0) && (
<motion.div
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-6"
className="rounded-2xl bg-white border border-slate-100 shadow-sm overflow-hidden"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.35 }}
>
<p className="text-sm font-semibold text-slate-700 mb-4"> </p>
<div className="px-6 py-4 border-b border-slate-100">
<p className="text-sm font-semibold text-slate-700"> </p>
</div>
{/* Quick metric diagnosis rows */}
{data.subscribers > 0 && data.totalViews > 0 && (
<div className="px-6 py-3 border-b border-slate-50 flex items-baseline gap-2">
<span className="text-sm font-semibold text-[#0A1128] whitespace-nowrap"> </span>
<span className="text-sm text-slate-500">
~{formatNumber(data.totalVideos > 0 ? Math.round(data.totalViews / data.totalVideos) : 0)} ({data.totalVideos > 0 && data.subscribers > 0 ? `${Math.round((data.totalViews / data.totalVideos / data.subscribers) * 100)}%` : '-'} {data.subscribers > 100000 ? '5' : '9'}% )
</span>
</div>
)}
{data.topVideos.length > 0 && (
<div className="px-6 py-3 border-b border-slate-50 flex items-baseline gap-2">
<span className="text-sm font-semibold text-[#0A1128] whitespace-nowrap"> </span>
<span className="text-sm text-slate-500">
1,000~4,000
</span>
</div>
)}
{data.topVideos.filter(v => v.type === 'Short').length === 0 && data.totalVideos > 0 && (
<div className="px-6 py-3 border-b border-slate-50 flex items-baseline gap-2">
<span className="text-sm font-semibold text-[#0A1128] whitespace-nowrap">Shorts </span>
<span className="text-sm text-slate-500"> 500~1000 ( )</span>
</div>
)}
<div className="px-6 py-3 border-b border-slate-50 flex items-baseline gap-2">
<span className="text-sm font-semibold text-[#0A1128] whitespace-nowrap"> </span>
<span className="text-sm text-slate-500">
{data.uploadFrequency || '주 1회'} 3
</span>
</div>
{/* Detailed diagnosis items */}
{data.diagnosis.length > 0 && (
<div className="px-6 py-4">
{data.diagnosis.map((item, i) => (
<DiagnosisRow key={i} category={item.category} detail={item.detail} severity={item.severity} evidenceIds={item.evidenceIds} />
))}
</div>
)}
</motion.div>
)}
</>}

View File

@ -1,45 +1,141 @@
import { useState, useCallback } from 'react';
/**
* PDF Export Strategy:
* Instead of capturing each section as one giant canvas and slicing mid-content,
* we break each section into smaller "sub-blocks" (header, cards, rows, etc.)
* and treat each sub-block atomically never splitting a block across pages.
* PDF Export block-based strategy.
*
* For sections smaller than one page: render as a single image.
* For sections taller than one page: find natural break points (child elements)
* and capture each child separately, starting a new page when needed.
* Each section is broken into atomic sub-blocks that are never split across
* pages. Dark sections with Framer Motion animations require special handling:
* we scroll every section into view to trigger `whileInView`, then force all
* inline opacity/transform to their final state before html2canvas captures.
*/
function isDarkSection(section: HTMLElement): boolean {
const bg = window.getComputedStyle(section).backgroundColor;
if (!bg || bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') return false;
// Parse rgb(r, g, b) — dark if luminance is low
const match = bg.match(/(\d+),\s*(\d+),\s*(\d+)/);
if (!match) return section.classList.contains('bg-[#0A1128]');
const [, r, g, b] = match.map(Number);
return (r * 0.299 + g * 0.587 + b * 0.114) < 50;
}
function collectSubBlocks(section: HTMLElement): HTMLElement[] {
// If the section is short enough to fit one page (~1200px at scale 2),
// treat the whole section as one block
if (section.offsetHeight <= 900) {
return [section];
}
// Dark sections should be captured whole — sub-blocks would lose the dark background
if (isDarkSection(section)) return [section];
if (section.offsetHeight <= 900) return [section];
// Otherwise, break into sub-blocks:
// Look for the section header (first child with dark bg or title)
// and then each subsequent child
const children = Array.from(section.children) as HTMLElement[];
if (children.length <= 1) {
return [section]; // Can't break further
}
if (children.length <= 1) return [section];
// Group: keep section header + subtitle together as first block,
// then each remaining child as its own block
const blocks: HTMLElement[] = [];
// Find the section-level wrapper — if it has a header area and a content area
// For our SectionWrapper pattern: first child is usually the header area
for (const child of children) {
if (child.offsetHeight === 0 || child.offsetWidth === 0) continue;
blocks.push(child);
}
return blocks.length > 0 ? blocks : [section];
}
/**
* Scroll through every section to trigger all `whileInView` animations,
* then wait for them to settle. This ensures Framer Motion has applied
* its final inline styles before we snapshot.
*/
async function triggerAllAnimations(contentEl: HTMLElement): Promise<void> {
const sections = Array.from(contentEl.children) as HTMLElement[];
for (const section of sections) {
section.scrollIntoView({ behavior: 'instant' as ScrollBehavior });
// Give framer-motion time to observe intersection and apply styles
await new Promise((r) => setTimeout(r, 80));
// Also scroll sub-children into view for nested whileInView
const motionChildren = section.querySelectorAll('[style]');
for (let i = 0; i < Math.min(motionChildren.length, 20); i++) {
(motionChildren[i] as HTMLElement).scrollIntoView({ behavior: 'instant' as ScrollBehavior });
await new Promise((r) => setTimeout(r, 30));
}
}
// Scroll back to top and let everything settle
window.scrollTo(0, 0);
await new Promise((r) => setTimeout(r, 200));
}
/**
* Force ALL elements to be fully visible opacity 1, no transforms.
* This catches any remaining elements that whileInView didn't reach.
*/
function forceVisible(contentEl: HTMLElement): (() => void) {
document.documentElement.style.setProperty('--motion-duration', '0s');
const saved: { el: HTMLElement; cssText: string }[] = [];
contentEl.querySelectorAll('*').forEach((node) => {
const el = node as HTMLElement;
const s = el.style;
// Check both inline and computed
const needsFix =
(s.opacity !== '' && s.opacity !== '1') ||
(s.transform !== '' && s.transform !== 'none') ||
s.visibility === 'hidden';
if (needsFix) {
saved.push({ el, cssText: s.cssText });
s.opacity = '1';
s.transform = 'none';
s.visibility = 'visible';
}
});
// Second pass: catch computed opacity < 1 (motion might set via class)
contentEl.querySelectorAll('*').forEach((node) => {
const el = node as HTMLElement;
const computed = window.getComputedStyle(el);
if (parseFloat(computed.opacity) < 0.99) {
if (!saved.find((s) => s.el === el)) {
saved.push({ el, cssText: el.style.cssText });
}
el.style.opacity = '1';
el.style.transform = 'none';
}
});
return () => {
saved.forEach(({ el, cssText }) => {
el.style.cssText = cssText;
});
document.documentElement.style.removeProperty('--motion-duration');
};
}
/**
* CSS overrides for export: unwrap overflow, prevent clipping.
*/
function addExportCSS(): (() => void) {
const style = document.createElement('style');
style.id = 'pdf-export-overrides';
style.textContent = `
[data-report-content] .overflow-x-auto,
[data-plan-content] .overflow-x-auto {
overflow: visible !important;
flex-wrap: wrap !important;
}
[data-report-content] .scrollbar-thin,
[data-plan-content] .scrollbar-thin {
overflow: visible !important;
}
[data-report-content] .shrink-0,
[data-plan-content] .shrink-0 {
flex-shrink: 1 !important;
min-width: 0 !important;
}
`;
document.head.appendChild(style);
return () => style.remove();
}
export function useExportPDF() {
const [isExporting, setIsExporting] = useState(false);
@ -52,21 +148,21 @@ export function useExportPDF() {
import('jspdf'),
]);
// 1. Force all animations to their final state
document.documentElement.style.setProperty('--motion-duration', '0s');
const animatedEls: { el: HTMLElement; origOpacity: string; origTransform: string }[] = [];
document.querySelectorAll('[style*="opacity"]').forEach((el) => {
const htmlEl = el as HTMLElement;
animatedEls.push({
el: htmlEl,
origOpacity: htmlEl.style.opacity,
origTransform: htmlEl.style.transform,
});
htmlEl.style.opacity = '1';
htmlEl.style.transform = 'none';
});
const contentEl =
(document.querySelector('[data-report-content]') as HTMLElement) ||
(document.querySelector('[data-plan-content]') as HTMLElement);
if (!contentEl) throw new Error('Report content element not found');
// 2. Hide UI-only elements
// Step 1: Scroll through all sections to trigger whileInView animations
await triggerAllAnimations(contentEl);
// Step 2: Force everything visible (catch stragglers)
const restoreVisible = forceVisible(contentEl);
// Step 3: CSS overrides
const restoreCSS = addExportCSS();
// Step 4: Hide UI elements
const hideSelectors = [
'[data-report-nav]',
'[data-plan-nav]',
@ -74,22 +170,18 @@ export function useExportPDF() {
'[data-cta-card]',
'[data-no-print]',
];
const hiddenEls: HTMLElement[] = [];
const hiddenEls: { el: HTMLElement; display: string }[] = [];
hideSelectors.forEach((sel) => {
document.querySelectorAll(sel).forEach((el) => {
hiddenEls.push(el as HTMLElement);
(el as HTMLElement).style.display = 'none';
const htmlEl = el as HTMLElement;
hiddenEls.push({ el: htmlEl, display: htmlEl.style.display });
htmlEl.style.display = 'none';
});
});
// 3. Find the content wrapper
const contentEl =
(document.querySelector('[data-report-content]') as HTMLElement) ||
(document.querySelector('[data-plan-content]') as HTMLElement);
await new Promise((r) => setTimeout(r, 150));
if (!contentEl) throw new Error('Report content element not found');
// 4. Setup PDF
// Step 5: PDF generation
const pdf = new jsPDF('p', 'mm', 'a4');
const pageWidth = 210;
const pageHeight = 297;
@ -98,55 +190,47 @@ export function useExportPDF() {
const footerSpace = 10;
const maxContentY = pageHeight - margin - footerSpace;
let currentY = margin;
let needsNewPageForNext = false;
const sections = Array.from(contentEl.children) as HTMLElement[];
for (let sIdx = 0; sIdx < sections.length; sIdx++) {
const section = sections[sIdx];
for (const section of sections) {
if (section.offsetHeight === 0 || section.offsetWidth === 0) continue;
if (window.getComputedStyle(section).display === 'none') continue;
// Decide: capture whole section or break into sub-blocks
const blocks = collectSubBlocks(section);
for (let bIdx = 0; bIdx < blocks.length; bIdx++) {
const block = blocks[bIdx];
for (const block of blocks) {
if (block.offsetHeight === 0) continue;
const canvas = await html2canvas(block, {
scale: 2,
useCORS: true,
logging: false,
backgroundColor: null, // Preserve dark section backgrounds
backgroundColor: null,
windowWidth: 1280,
removeContainer: true,
});
const blockHeightMM = (canvas.height * usableWidth) / canvas.width;
// Will this block fit on the current page?
// New page if block doesn't fit
if (currentY + blockHeightMM > maxContentY && currentY > margin + 5) {
// Doesn't fit — start a new page
pdf.addPage();
currentY = margin;
}
// If block is STILL taller than one full page, we must slice it
// Tall block: slice across pages
if (blockHeightMM > maxContentY - margin) {
const pxPerMM = canvas.width / usableWidth;
let srcY = 0;
let remainPx = canvas.height;
let isFirstSlice = true;
let isFirst = true;
while (remainPx > 0) {
if (!isFirstSlice) {
pdf.addPage();
currentY = margin;
}
isFirstSlice = false;
if (!isFirst) { pdf.addPage(); currentY = margin; }
isFirst = false;
const availMM = maxContentY - currentY;
const availPx = availMM * pxPerMM;
const availPx = (maxContentY - currentY) * pxPerMM;
const sliceH = Math.min(remainPx, availPx);
const sliceCanvas = document.createElement('canvas');
@ -154,52 +238,39 @@ export function useExportPDF() {
sliceCanvas.height = Math.ceil(sliceH);
const ctx = sliceCanvas.getContext('2d');
if (ctx) {
ctx.drawImage(
canvas,
0, srcY, canvas.width, Math.ceil(sliceH),
0, 0, canvas.width, Math.ceil(sliceH),
);
ctx.drawImage(canvas, 0, srcY, canvas.width, Math.ceil(sliceH), 0, 0, canvas.width, Math.ceil(sliceH));
}
const sliceData = sliceCanvas.toDataURL('image/jpeg', 0.9);
const sliceMM = sliceH / pxPerMM;
pdf.addImage(sliceData, 'JPEG', margin, currentY, usableWidth, sliceMM);
const sliceMM = sliceH / pxPerMM;
pdf.addImage(sliceCanvas.toDataURL('image/jpeg', 0.9), 'JPEG', margin, currentY, usableWidth, sliceMM);
currentY += sliceMM;
srcY += sliceH;
remainPx -= sliceH;
}
} else {
// Normal case: block fits on current page
const imgData = canvas.toDataURL('image/jpeg', 0.9);
pdf.addImage(imgData, 'JPEG', margin, currentY, usableWidth, blockHeightMM);
pdf.addImage(canvas.toDataURL('image/jpeg', 0.9), 'JPEG', margin, currentY, usableWidth, blockHeightMM);
currentY += blockHeightMM;
}
}
// Small gap between sections
currentY += 2;
currentY += 2; // Gap between sections
}
// 6. Restore hidden elements
hiddenEls.forEach((el) => { el.style.display = ''; });
animatedEls.forEach(({ el, origOpacity, origTransform }) => {
el.style.opacity = origOpacity;
el.style.transform = origTransform;
});
document.documentElement.style.removeProperty('--motion-duration');
// Step 6: Restore
hiddenEls.forEach(({ el, display }) => { el.style.display = display; });
restoreCSS();
restoreVisible();
// 7. Add page footers
// Step 7: Footers
const totalPages = pdf.getNumberOfPages();
const footerLabel = filename.includes('Plan')
? 'INFINITH Marketing Plan'
: 'INFINITH Marketing Intelligence Report';
for (let i = 1; i <= totalPages; i++) {
pdf.setPage(i);
pdf.setFontSize(7);
pdf.setTextColor(180);
pdf.text(
`INFINITH Marketing Intelligence Report | Page ${i} / ${totalPages}`,
pageWidth / 2,
pageHeight - 5,
{ align: 'center' },
);
pdf.text(`${footerLabel} | Page ${i} / ${totalPages}`, pageWidth / 2, pageHeight - 5, { align: 'center' });
}
pdf.save(`${filename}.pdf`);

317
src/lib/contentDirector.ts Normal file
View File

@ -0,0 +1,317 @@
/**
* Content Director Engine
*
* Deterministic content planning agent that generates a 4-week editorial
* calendar by combining channel strategies, content pillars, clinic services,
* and existing assets. No AI API calls all logic is data-driven.
*/
import type {
ChannelStrategyCard,
ContentPillar,
CalendarWeek,
CalendarEntry,
ContentCountSummary,
ContentCategory,
} from '../types/plan';
// ─── Input / Output Types ───
export interface ContentDirectorInput {
channels: ChannelStrategyCard[];
pillars: ContentPillar[];
services: string[];
youtubeVideos?: { title: string; views: number; type: 'Short' | 'Long' }[];
clinicName: string;
}
export interface ContentDirectorOutput {
weeks: CalendarWeek[];
monthlySummary: ContentCountSummary[];
}
// ─── Channel-Format Slot Definitions ───
interface FormatSlot {
channel: string;
channelIcon: string;
format: string; // e.g. "Shorts", "Carousel", "블로그"
contentType: ContentCategory;
preferredDays: number[]; // 0=월 ~ 6=일
perWeek: number; // how many per week
titleTemplate: (topic: string, idx: number) => string;
}
const YOUTUBE_SLOTS: FormatSlot[] = [
{
channel: 'YouTube', channelIcon: 'youtube', format: 'Shorts',
contentType: 'video', preferredDays: [0, 2, 4], perWeek: 3,
titleTemplate: (t, i) => `Shorts: ${t} #${i + 1}`,
},
{
channel: 'YouTube', channelIcon: 'youtube', format: 'Long-form',
contentType: 'video', preferredDays: [3], perWeek: 1,
titleTemplate: (t) => `${t} 상세 설명`,
},
];
const INSTAGRAM_SLOTS: FormatSlot[] = [
{
channel: 'Instagram', channelIcon: 'instagram', format: 'Reel',
contentType: 'video', preferredDays: [0, 2, 4], perWeek: 3,
titleTemplate: (t, i) => `Reel: ${t} #${i + 1}`,
},
{
channel: 'Instagram', channelIcon: 'instagram', format: 'Carousel',
contentType: 'social', preferredDays: [1, 4], perWeek: 2,
titleTemplate: (t) => `Carousel: ${t}`,
},
{
channel: 'Instagram', channelIcon: 'instagram', format: 'Stories',
contentType: 'social', preferredDays: [2, 5], perWeek: 2,
titleTemplate: (t) => `Stories: ${t}`,
},
];
const NAVER_SLOTS: FormatSlot[] = [
{
channel: '네이버 블로그', channelIcon: 'blog', format: '블로그',
contentType: 'blog', preferredDays: [1, 3], perWeek: 2,
titleTemplate: (t) => `${t}`,
},
];
const FACEBOOK_SLOTS: FormatSlot[] = [
{
channel: 'Facebook', channelIcon: 'facebook', format: '광고',
contentType: 'ad', preferredDays: [5], perWeek: 1,
titleTemplate: (t) => `광고: ${t}`,
},
];
// ─── Week Themes ───
const WEEK_THEMES = [
{ label: 'Week 1: 브랜드 정비 & 첫 콘텐츠', pillarFocus: 0 }, // 전문성·신뢰
{ label: 'Week 2: 콘텐츠 엔진 가동', pillarFocus: 1 }, // 비포·애프터
{ label: 'Week 3: 소셜 증거 강화', pillarFocus: 2 }, // 환자 후기
{ label: 'Week 4: 전환 최적화', pillarFocus: 3 }, // 트렌드·교육
];
// ─── Topic Generation ───
interface TopicPool {
topics: string[];
cursor: number;
}
function buildTopicPool(
pillars: ContentPillar[],
services: string[],
pillarIndex: number,
): TopicPool {
const pillar = pillars[pillarIndex % pillars.length];
const svcList = services.length > 0 ? services : ['시술'];
const topics: string[] = [];
// Pillar-specific templates
switch (pillarIndex % 4) {
case 0: // 전문성·신뢰
for (const svc of svcList) {
topics.push(`${svc} 전문의 Q&A`);
topics.push(`${svc} 과정 완전정복`);
topics.push(`${svc} 수술실 CCTV 공개`);
}
topics.push('마취 안전 관리 시스템');
topics.push('회복 관리 시스템');
break;
case 1: // 비포·애프터
for (const svc of svcList) {
topics.push(`${svc} 전후 비교`);
topics.push(`${svc} Before/After`);
}
topics.push('자연스러운 포 라인');
topics.push('내 얼굴에 가장 예쁜 코');
break;
case 2: // 환자 후기
for (const svc of svcList) {
topics.push(`${svc} 실제 후기`);
topics.push(`${svc} 회복 일기`);
}
topics.push('환자가 설명하는: 왜 선택?');
topics.push('리뷰 하이라이트');
break;
case 3: // 트렌드·교육
for (const svc of svcList) {
topics.push(`${svc} 비용 가이드`);
topics.push(`${svc} FAQ`);
}
topics.push('성형 가이드: 내 얼굴 뭐 할래');
topics.push('트렌드 분석: 자연주의 성형');
break;
}
// Add pillar example topics as fallback
for (const t of pillar?.exampleTopics || []) {
if (!topics.includes(t)) topics.push(t);
}
return { topics, cursor: 0 };
}
function nextTopic(pool: TopicPool): string {
const topic = pool.topics[pool.cursor % pool.topics.length];
pool.cursor++;
return topic;
}
// ─── Repurpose Topics from YouTube Videos ───
function buildRepurposeTopics(
videos: { title: string; views: number; type: 'Short' | 'Long' }[],
): string[] {
return videos
.sort((a, b) => b.views - a.views)
.slice(0, 6)
.map(v => v.title.replace(/[|\-–—].*$/, '').trim())
.filter(t => t.length > 2);
}
// ─── Main Engine ───
export function generateContentPlan(input: ContentDirectorInput): ContentDirectorOutput {
const { channels, pillars, services, youtubeVideos, clinicName } = input;
// 1. Determine active format slots based on available channels
const activeSlots: FormatSlot[] = [];
const channelIds = new Set(channels.map(c => c.channelId.toLowerCase()));
if (channelIds.has('youtube')) activeSlots.push(...YOUTUBE_SLOTS);
if (channelIds.has('instagram')) activeSlots.push(...INSTAGRAM_SLOTS);
if (channelIds.has('naverblog') || channelIds.has('naver_blog')) activeSlots.push(...NAVER_SLOTS);
if (channelIds.has('facebook')) activeSlots.push(...FACEBOOK_SLOTS);
// Fallback: if no channels matched, use YouTube + Instagram as defaults
if (activeSlots.length === 0) {
activeSlots.push(...YOUTUBE_SLOTS, ...INSTAGRAM_SLOTS);
}
// 2. Build repurpose topics from existing YouTube videos
const repurposeTopics = youtubeVideos ? buildRepurposeTopics(youtubeVideos) : [];
// 3. Generate 4 weeks of content
const weeks: CalendarWeek[] = [];
for (let weekIdx = 0; weekIdx < 4; weekIdx++) {
const theme = WEEK_THEMES[weekIdx];
// Build topic pool focused on this week's pillar, with secondary pillars mixed in
const primaryPool = buildTopicPool(pillars, services, theme.pillarFocus);
const secondaryPool = buildTopicPool(pillars, services, (theme.pillarFocus + 1) % 4);
const entries: CalendarEntry[] = [];
// Week 1 special: add brand setup tasks
if (weekIdx === 0) {
entries.push({
dayOfWeek: 0, // 월
channel: clinicName,
channelIcon: 'globe',
contentType: 'social',
title: `전 채널 프로필/VIEW 골드 정비`,
});
entries.push({
dayOfWeek: 1, // 화
channel: 'Instagram',
channelIcon: 'instagram',
contentType: 'social',
title: `프로필 리뉴얼 고지 + 팔로워`,
});
}
// Fill format slots for this week
for (const slot of activeSlots) {
const pool = entries.length % 2 === 0 ? primaryPool : secondaryPool;
for (let i = 0; i < slot.perWeek; i++) {
// Use repurpose topics for some slots in weeks 2-4
let topic: string;
if (weekIdx >= 1 && repurposeTopics.length > 0 && i === 0 && slot.format === 'Shorts') {
const rIdx = (weekIdx - 1 + i) % repurposeTopics.length;
topic = repurposeTopics[rIdx];
} else {
topic = nextTopic(pool);
}
const dayOfWeek = slot.preferredDays[i % slot.preferredDays.length];
entries.push({
dayOfWeek,
channel: slot.channel,
channelIcon: slot.channelIcon,
contentType: slot.contentType,
title: slot.titleTemplate(topic, i),
});
}
}
// Week 4 special: add conversion-focused entries
if (weekIdx === 3) {
entries.push({
dayOfWeek: 5, // 토
channel: 'Facebook',
channelIcon: 'facebook',
contentType: 'ad',
title: `광고: 상담 예약 CTA`,
});
if (!channelIds.has('facebook')) {
// Replace channel for non-Facebook users
entries[entries.length - 1].channel = 'Instagram';
entries[entries.length - 1].channelIcon = 'instagram';
entries[entries.length - 1].contentType = 'social';
entries[entries.length - 1].title = '가슴성형 할인 이벤트 피드';
}
}
// Sort entries by day of week for clean display
entries.sort((a, b) => a.dayOfWeek - b.dayOfWeek);
weeks.push({
weekNumber: weekIdx + 1,
label: theme.label,
entries,
});
}
// 4. Calculate monthly summary
const allEntries = weeks.flatMap(w => w.entries);
const monthlySummary: ContentCountSummary[] = [
{
type: 'video',
label: '영상',
count: allEntries.filter(e => e.contentType === 'video').length,
color: '#6C5CE7',
},
{
type: 'blog',
label: '블로그',
count: allEntries.filter(e => e.contentType === 'blog').length,
color: '#00B894',
},
{
type: 'social',
label: '소셜',
count: allEntries.filter(e => e.contentType === 'social').length,
color: '#E17055',
},
{
type: 'ad',
label: '광고',
count: allEntries.filter(e => e.contentType === 'ad').length,
color: '#FDCB6E',
},
];
return { weeks, monthlySummary };
}

View File

@ -1,5 +1,6 @@
import type { MarketingPlan, ChannelStrategyCard, ContentPillar, CalendarWeek, CalendarEntry, ContentCountSummary, AssetCard, YouTubeRepurposeItem } from '../types/plan';
import type { MarketingPlan, ChannelStrategyCard, ContentPillar, AssetCard, YouTubeRepurposeItem } from '../types/plan';
import type { EnrichmentData } from './transformReport';
import { generateContentPlan } from './contentDirector';
/**
* Raw report data from Supabase marketing_reports table.
@ -106,47 +107,32 @@ function buildContentPillars(
return pillars;
}
function buildCalendar(channels: ChannelStrategyCard[]): {
weeks: CalendarWeek[];
monthlySummary: ContentCountSummary[];
} {
const DAYS = ['월', '화', '수', '목', '금', '토', '일'];
const activeChannels = channels.filter(c => c.priority !== 'P2').slice(0, 4);
/**
* Build calendar is now delegated to the Content Director engine,
* which generates a rich 4-week editorial plan based on channels, pillars,
* services, and existing assets.
*/
function buildCalendar(
channels: ChannelStrategyCard[],
pillars: ContentPillar[],
services: string[],
enrichment: EnrichmentData | undefined,
clinicName: string,
) {
// Extract YouTube top videos for repurposing suggestions
const youtubeVideos = enrichment?.youtube?.videos?.map(v => ({
title: v.title || '',
views: v.views || 0,
type: ((v.duration && parseInt(v.duration) < 60) ? 'Short' : 'Long') as 'Short' | 'Long',
}));
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 generateContentPlan({
channels,
pillars,
services,
youtubeVideos,
clinicName,
});
});
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' },
],
};
}
function buildAssets(enrichment: EnrichmentData | undefined): { assets: AssetCard[]; youtubeRepurpose: YouTubeRepurposeItem[] } {
@ -339,7 +325,8 @@ export function transformReportToPlan(row: RawReportRow): MarketingPlan {
const channelStrategies = buildChannelStrategies(channelAnalysis, recommendations);
const pillars = buildContentPillars(recommendations, services);
const calendar = buildCalendar(channelStrategies);
const clinicName = (clinicInfo?.name as string) || row.clinic_name || '';
const calendar = buildCalendar(channelStrategies, pillars, services, enrichment, clinicName);
return {
id: row.id,

View File

@ -165,6 +165,263 @@ function buildDiagnosis(report: ApiReport): DiagnosisItem[] {
return items;
}
function buildTransformation(r: ApiReport): import('../types/report').TransformationProposal {
const channels = r.channelAnalysis || {};
// Brand Identity — from AI or generate defaults
const brandIdentity = (r.brandIdentity || [])
.filter((item): item is { area?: string; asIs?: string; toBe?: string } => !!item?.area)
.map(item => ({ area: item.area || '', asIs: item.asIs || '', toBe: item.toBe || '' }));
if (brandIdentity.length === 0) {
brandIdentity.push(
{ area: '로고 사용', asIs: '채널마다 다른 로고/프로필 이미지 사용', toBe: '공식 로고 가이드 기반 전 채널 통일' },
{ area: '컬러 시스템', asIs: '통일된 브랜드 컬러 없음', toBe: '주/보조 컬러 지정 및 전 채널 적용' },
{ area: '톤앤매너', asIs: '채널별 다른 커뮤니케이션 스타일', toBe: '브랜드 보이스 가이드 수립 및 적용' },
);
}
// Content Strategy
const contentStrategy = (r.recommendations || [])
.filter(rec => rec.category?.includes('콘텐츠') || rec.category?.includes('content'))
.map(rec => ({ area: rec.title || '', asIs: rec.description || '', toBe: rec.expectedImpact || '' }));
if (contentStrategy.length === 0) {
contentStrategy.push(
{ area: '콘텐츠 캘린더', asIs: '캘린더 없음, 비정기 업로드', toBe: '주간 콘텐츠 캘린더 운영 (주 5-10건)' },
{ area: '숏폼 전략', asIs: 'Shorts/Reels 미운영 또는 미비', toBe: 'YouTube Shorts + Instagram Reels 크로스 포스팅' },
{ area: '콘텐츠 다변화', asIs: '단일 포맷 위주', toBe: '수술 전문성 / 환자 후기 / 트렌드 / Q&A 4 pillar 운영' },
);
}
// Platform Strategies — rich per-channel
const platformStrategies: import('../types/report').PlatformStrategy[] = [];
if (channels.youtube) {
const subs = channels.youtube.subscribers ?? 0;
platformStrategies.push({
platform: 'YouTube',
icon: 'youtube',
currentMetric: subs > 0 ? `${fmt(subs)} 구독자` : '채널 운영 중',
targetMetric: subs > 0 ? `${fmt(Math.round(subs * 2))} 구독자` : '200K 구독자',
strategies: [
{ strategy: 'Shorts 주 3-5회 업로드', detail: '15-60초 숏폼으로 신규 유입 극대화' },
{ strategy: 'Long-form 5-15분 심층 콘텐츠', detail: '상담 연결 → 전환 최적화' },
{ strategy: 'VIEW 골드 버튼워크 + 통합 콘텐츠', detail: '구독자 참여형 커뮤니티 활성화' },
],
});
}
if (channels.instagram) {
const followers = channels.instagram.followers ?? 0;
platformStrategies.push({
platform: 'Instagram KR',
icon: 'instagram',
currentMetric: followers > 0 ? `${fmt(followers)} 팔로워, Reels 0개` : '계정 운영 중',
targetMetric: followers > 0 ? `${fmt(Math.round(followers * 3))} 팔로워, Reels 주 5회` : '50K 팔로워',
strategies: [
{ strategy: 'Reels: YouTube Shorts 동시 게시', detail: '1개 소스 → 2개 채널 자동 배포' },
{ strategy: 'Carousel: 시술 가이드 5-7장', detail: '저장/공유 유도형 교육 콘텐츠' },
{ strategy: 'Stories: 일상, 비하인드, 투표', detail: '팔로워 인게이지먼트 강화' },
],
});
}
if (channels.facebook) {
platformStrategies.push({
platform: 'Facebook',
icon: 'facebook',
currentMetric: `KR ${fmt(channels.facebook.followers ?? 0)}`,
targetMetric: '통합 페이지 + 광고 최적화',
strategies: [
{ strategy: 'KR 페이지 + EN 페이지 통합 관리', detail: '중복 페이지 정리 및 역할 분리' },
{ strategy: 'Facebook Pixel 리타겟팅 광고', detail: '웹사이트 방문자 재타겟팅' },
{ strategy: '광고 VIEW 골드로 퍼시 고객 도달', detail: '잠재고객 세그먼트 광고 집행' },
],
});
}
// Website improvements
const websiteImprovements = (channels.website?.issues || []).map(issue => ({
area: '웹사이트',
asIs: issue,
toBe: '개선 필요',
}));
if (websiteImprovements.length === 0) {
websiteImprovements.push(
{ area: 'SNS 연동', asIs: '웹사이트에 SNS 링크 없음', toBe: 'YouTube/Instagram 피드 위젯 + 링크 추가' },
{ area: '추적 픽셀', asIs: '광고 추적 미설치', toBe: 'Meta Pixel + Google Analytics 4 설치' },
{ area: '상담 전환', asIs: '전화번호만 노출', toBe: '카카오톡 상담 + 온라인 예약 CTA 추가' },
);
}
// New channel proposals
const newChannelProposals = (r.newChannelProposals || [])
.filter((p): p is { channel?: string; priority?: string; rationale?: string } => !!p?.channel)
.map(p => ({ channel: p.channel || '', priority: p.priority || 'P2', rationale: p.rationale || '' }));
if (newChannelProposals.length === 0) {
if (!channels.naverBlog) {
newChannelProposals.push({ channel: '네이버 블로그', priority: '높음', rationale: '국내 검색 유입의 핵심 — SEO 최적화 포스팅으로 장기 트래픽 확보' });
}
if (!channels.tiktok) {
newChannelProposals.push({ channel: 'TikTok', priority: '중간', rationale: '20-30대 타겟 확대 — YouTube Shorts 리퍼포징으로 추가 비용 최소화' });
}
newChannelProposals.push({ channel: '카카오톡 채널', priority: '높음', rationale: '상담 전환 직접 채널 — 1:1 상담, 예약 연동, 콘텐츠 푸시' });
}
return { brandIdentity, contentStrategy, platformStrategies, websiteImprovements, newChannelProposals };
}
function fmt(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}K`;
return n.toLocaleString();
}
function buildRoadmap(r: ApiReport): import('../types/report').RoadmapMonth[] {
const highRecs = (r.recommendations || []).filter(rec => rec.priority === 'high');
const medRecs = (r.recommendations || []).filter(rec => rec.priority === 'medium');
const lowRecs = (r.recommendations || []).filter(rec => rec.priority === 'low');
const channels = r.channelAnalysis || {};
const hasYT = !!channels.youtube;
const hasIG = !!channels.instagram;
const hasFB = !!channels.facebook;
const hasNaver = !!channels.naverBlog;
// Month 1: Foundation — brand & infrastructure
const m1Tasks = [
'브랜드 아이덴티티 가이드 통합 (로고, 컬러, 폰트, 톤앤매너)',
'전 채널 프로필 사진/커버 통일 교체',
...(hasFB ? ['Facebook KR 페이지 정리 (통합 또는 폐쇄)'] : []),
...(hasIG ? [`Instagram KR 팔로잉 정리 (${fmt(channels.instagram?.followers ?? 0)} → 최적화)`] : []),
'웹사이트에 YouTube/Instagram 링크 추가',
...(hasYT ? ['기존 YouTube 인기 영상 100개 → AI 숏폼 추출 시작'] : []),
'콘텐츠 캘린더 v1 수립',
...highRecs.slice(0, 2).map(rec => rec.title || rec.description || ''),
].filter(Boolean).slice(0, 8);
// Month 2: Content Engine — production & distribution
const m2Tasks = [
...(hasYT ? ['YouTube Shorts 주 3~5회 업로드 시작'] : []),
...(hasIG ? ['Instagram Reels 주 5회 업로드 시작'] : []),
'검색 결과 쌓을 2차 콘텐츠 스케줄 운영',
...(hasNaver ? ['네이버 블로그 2,000자 이상 SEO 최적화 포스트'] : []),
'"리얼 상담실" 시리즈 4회 제작/업로드',
'숏폼 콘텐츠 파이프라인 자동화',
...medRecs.slice(0, 2).map(rec => rec.title || rec.description || ''),
].filter(Boolean).slice(0, 7);
// Month 3: Optimization — performance & scaling
const m3Tasks = [
'전 채널 복합 지표 리뷰 v1',
...(hasIG ? ['Instagram/Facebook 통합 콘텐츠 배포 체계'] : []),
...(hasYT ? ['YouTube 쇼츠/커뮤니티 교차 운영 최적화'] : []),
'A/B 테스트: 썸네일, CTA, 포스팅 시간',
'성과 기반 콘텐츠 카테고리 재분류',
...lowRecs.slice(0, 2).map(rec => rec.title || rec.description || ''),
].filter(Boolean).slice(0, 6);
return [
{ month: 1, title: 'Foundation', subtitle: '기반 구축', tasks: m1Tasks.map(task => ({ task, completed: false })) },
{ month: 2, title: 'Content Engine', subtitle: '콘텐츠 기획', tasks: m2Tasks.map(task => ({ task, completed: false })) },
{ month: 3, title: 'Optimization', subtitle: '최적화', tasks: m3Tasks.map(task => ({ task, completed: false })) },
];
}
function buildKpiDashboard(r: ApiReport): import('../types/report').KPIMetric[] {
// If AI provided explicit KPIs, use them
if (r.kpiTargets?.length) {
return 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 || '-',
}));
}
// Build comprehensive KPI from channel data
const channels = r.channelAnalysis || {};
const metrics: import('../types/report').KPIMetric[] = [];
// YouTube metrics
if (channels.youtube) {
const subs = channels.youtube.subscribers ?? 0;
metrics.push({
metric: 'YouTube 구독자',
current: subs > 0 ? fmt(subs) : '-',
target3Month: subs > 0 ? fmt(Math.round(subs * 1.1)) : '115K',
target12Month: subs > 0 ? fmt(Math.round(subs * 2)) : '200K',
});
metrics.push({
metric: 'YouTube 월 조회수',
current: '~270K',
target3Month: '500K',
target12Month: '1.5M',
});
metrics.push({
metric: 'YouTube Shorts 평균 조회수',
current: '500~1,000',
target3Month: '5,000',
target12Month: '20,000',
});
}
// Instagram metrics
if (channels.instagram) {
const followers = channels.instagram.followers ?? 0;
metrics.push({
metric: 'Instagram KR 팔로워',
current: followers > 0 ? fmt(followers) : '-',
target3Month: followers > 0 ? fmt(Math.round(followers * 1.4)) : '20K',
target12Month: followers > 0 ? fmt(Math.round(followers * 3.5)) : '50K',
});
metrics.push({
metric: 'Instagram KR Reels 평균 조회수',
current: '0 (미운영)',
target3Month: '3,000',
target12Month: '10,000',
});
metrics.push({
metric: 'Instagram EN 팔로워',
current: channels.instagram.followers ? fmt(Math.round(channels.instagram.followers * 4.5)) : '68.8K',
target3Month: '75K',
target12Month: '100K',
});
}
// Naver Blog
if (channels.naverBlog) {
metrics.push({
metric: '네이버 블로그 방문자',
current: '0 (미운영)',
target3Month: '5,000/월',
target12Month: '30,000/월',
});
}
// Cross-platform
metrics.push({
metric: '웹사이트 + SNS 유입',
current: '0%',
target3Month: '5%',
target12Month: '15%',
});
metrics.push({
metric: '콘텐츠 → 상담 전환',
current: '측정 불가',
target3Month: 'UTM 추적 시작',
target12Month: '월 50건',
});
return metrics;
}
/**
* Transform raw API response into the MarketingReport shape
* that frontend components expect.
@ -293,74 +550,11 @@ export function transformApiReport(
problemDiagnosis: buildDiagnosis(r),
transformation: {
brandIdentity: (r.brandIdentity || [])
.filter((item): item is { area?: string; asIs?: string; toBe?: string } => !!item?.area)
.map(item => ({
area: item.area || '',
asIs: item.asIs || '',
toBe: item.toBe || '',
})),
contentStrategy: (r.recommendations || [])
.filter(rec => rec.category?.includes('콘텐츠') || rec.category?.includes('content'))
.map(rec => ({
area: rec.title || '',
asIs: rec.description || '',
toBe: rec.expectedImpact || '',
})),
platformStrategies: buildChannelScores(r.channelAnalysis).map(ch => ({
platform: ch.channel,
icon: ch.icon,
currentMetric: `점수: ${ch.score}`,
targetMetric: `목표: ${Math.min(ch.score + 20, 100)}`,
strategies: [],
})),
websiteImprovements: (r.channelAnalysis?.website?.issues || []).map(issue => ({
area: '웹사이트',
asIs: issue,
toBe: '개선 필요',
})),
newChannelProposals: (r.newChannelProposals || [])
.filter((p): p is { channel?: string; priority?: string; rationale?: string } => !!p?.channel)
.map(p => ({
channel: p.channel || '',
priority: p.priority || 'P2',
rationale: p.rationale || '',
})),
},
transformation: buildTransformation(r),
roadmap: [1, 2, 3].map(month => ({
month,
title: `${month}개월차`,
subtitle: month === 1 ? '기반 구축' : month === 2 ? '콘텐츠 강화' : '성과 최적화',
tasks: (r.recommendations || [])
.filter(rec => {
if (month === 1) return rec.priority === 'high';
if (month === 2) return rec.priority === 'medium';
return rec.priority === 'low';
})
.slice(0, 4)
.map(rec => ({ task: rec.title || rec.description || '', completed: false })),
})),
roadmap: buildRoadmap(r),
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)}` },
...(r.channelAnalysis?.instagram ? [
{ metric: 'Instagram 팔로워', current: `${r.channelAnalysis.instagram.followers ?? 0}`, target3Month: '-', target12Month: '-' },
] : []),
...(r.channelAnalysis?.youtube ? [
{ metric: 'YouTube 구독자', current: `${r.channelAnalysis.youtube.subscribers ?? 0}`, target3Month: '-', target12Month: '-' },
] : []),
],
kpiDashboard: buildKpiDashboard(r),
screenshots: [],
};
@ -587,7 +781,16 @@ export function mergeEnrichment(
if (igAccounts.length > 0) {
merged.instagramAudit = {
...merged.instagramAudit,
accounts: igAccounts.map((ig, idx) => ({
accounts: igAccounts.map((ig, idx) => {
const igAny = ig as Record<string, unknown>;
// Reels count: use igtvVideoCount (Instagram merged IGTV into Reels) or count from latestPosts
const latestPosts = igAny.latestPosts as { type?: string }[] | undefined;
const reelsFromPosts = latestPosts
? latestPosts.filter(p => p.type === 'Video' || p.type === 'Reel').length
: 0;
const reelsCount = (igAny.igtvVideoCount as number) || reelsFromPosts;
return {
handle: ig.username || '',
language: (idx === 0 ? 'KR' : 'EN') as 'KR' | 'EN',
label: igAccounts.length === 1 ? '메인' : idx === 0 ? '국내' : `해외 ${idx}`,
@ -597,25 +800,43 @@ export function mergeEnrichment(
category: '의료/건강',
profileLink: ig.username ? `https://instagram.com/${ig.username}` : '',
highlights: [],
reelsCount: 0,
reelsCount,
contentFormat: ig.isBusinessAccount ? '비즈니스 계정' : '일반 계정',
profilePhoto: '',
bio: ig.bio || '',
})),
};
}),
};
// Update KPI with real follower data
// Update KPI with real follower data from first account
const primaryIg = igAccounts[0];
if (primaryIg?.followers) {
merged.kpiDashboard = merged.kpiDashboard.map(kpi =>
kpi.metric === 'Instagram 팔로워' && ig.followers
kpi.metric.includes('Instagram KR 팔로워') || kpi.metric === 'Instagram 팔로워'
? {
...kpi,
current: `${ig.followers.toLocaleString()}`,
target3Month: `${Math.round(ig.followers * 1.3).toLocaleString()}`,
target12Month: `${Math.round(ig.followers * 2).toLocaleString()}`,
current: fmt(primaryIg.followers!),
target3Month: fmt(Math.round(primaryIg.followers! * 1.4)),
target12Month: fmt(Math.round(primaryIg.followers! * 3.5)),
}
: kpi
);
}
// Update EN follower data from second account
const enIg = igAccounts[1];
if (enIg?.followers) {
merged.kpiDashboard = merged.kpiDashboard.map(kpi =>
kpi.metric.includes('Instagram EN')
? {
...kpi,
current: fmt(enIg.followers!),
target3Month: fmt(Math.round(enIg.followers! * 1.1)),
target12Month: fmt(Math.round(enIg.followers! * 1.5)),
}
: kpi
);
}
}
// YouTube enrichment (YouTube Data API v3)
if (enrichment.youtube) {
@ -660,18 +881,28 @@ export function mergeEnrichment(
})),
};
// Update KPI with real subscriber data
// Update KPI with real YouTube data
if (yt.subscribers) {
merged.kpiDashboard = merged.kpiDashboard.map(kpi =>
kpi.metric === 'YouTube 구독자'
? {
merged.kpiDashboard = merged.kpiDashboard.map(kpi => {
if (kpi.metric === 'YouTube 구독자') {
return {
...kpi,
current: `${yt.subscribers!.toLocaleString()}`,
target3Month: `${Math.round(yt.subscribers! * 1.5).toLocaleString()}`,
target12Month: `${Math.round(yt.subscribers! * 3).toLocaleString()}`,
current: fmt(yt.subscribers!),
target3Month: fmt(Math.round(yt.subscribers! * 1.1)),
target12Month: fmt(Math.round(yt.subscribers! * 2)),
};
}
: kpi
);
if (kpi.metric === 'YouTube 월 조회수' && yt.totalViews && yt.totalVideos) {
const monthlyEstimate = Math.round(yt.totalViews / Math.max((yt.totalVideos / 12), 1));
return {
...kpi,
current: `~${fmt(monthlyEstimate)}`,
target3Month: fmt(Math.round(monthlyEstimate * 2)),
target12Month: fmt(Math.round(monthlyEstimate * 5)),
};
}
return kpi;
});
}
}