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
parent
e32b8766de
commit
da267fd744
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
<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 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: i * 0.08 }}
|
||||
>
|
||||
{/* Severity dot */}
|
||||
<div className="absolute top-4 right-4">
|
||||
<span className={`block w-3 h-3 rounded-full ${severityDot[item.severity] ?? severityDot.unknown}`} />
|
||||
</div>
|
||||
{/* 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={`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.12 }}
|
||||
>
|
||||
{/* 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-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 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>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
</motion.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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,54 +7,74 @@ 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) => (
|
||||
<motion.div
|
||||
key={month.month}
|
||||
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-6"
|
||||
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">
|
||||
{month.month}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-serif font-bold text-xl md:text-2xl text-[#0A1128]">{month.title}</h3>
|
||||
<p className="text-sm text-slate-500">{month.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task checklist */}
|
||||
<ul className="space-y-3">
|
||||
{month.tasks.map((task, j) => (
|
||||
<motion.li
|
||||
key={j}
|
||||
className="flex items-start gap-3"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.3, delay: i * 0.15 + j * 0.05 }}
|
||||
{months.map((month, i) => {
|
||||
const meta = monthMeta[month.month] || monthMeta[1];
|
||||
return (
|
||||
<motion.div
|
||||
key={month.month}
|
||||
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 }}
|
||||
>
|
||||
{/* 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`}
|
||||
>
|
||||
{task.completed ? (
|
||||
<CheckCircle2 size={18} className="text-[#9B8AD4] shrink-0 mt-1" />
|
||||
) : (
|
||||
<div className="w-[18px] h-[18px] rounded-full border-2 border-slate-200 shrink-0 mt-1" />
|
||||
)}
|
||||
<span className={`text-sm ${task.completed ? 'text-slate-400 line-through' : 'text-slate-700'}`}>
|
||||
{task.task}
|
||||
</span>
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
))}
|
||||
{month.month}
|
||||
</div>
|
||||
<div>
|
||||
<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="p-6 pt-4 space-y-3">
|
||||
{month.tasks.map((task, j) => (
|
||||
<motion.li
|
||||
key={j}
|
||||
className="flex items-start gap-3"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.3, delay: i * 0.15 + j * 0.05 }}
|
||||
>
|
||||
{task.completed ? (
|
||||
<CheckCircle2 size={18} className="text-[#6C5CE7] shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<Circle size={18} className="text-slate-300 shrink-0 mt-0.5" />
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{data.diagnosis.map((item, i) => (
|
||||
<DiagnosisRow key={i} category={item.category} detail={item.detail} severity={item.severity} evidenceIds={item.evidenceIds} />
|
||||
))}
|
||||
<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>
|
||||
)}
|
||||
</>}
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 {
|
||||
weekNumber: weekNum,
|
||||
label: `${weekNum}주차`,
|
||||
entries,
|
||||
};
|
||||
return generateContentPlan({
|
||||
channels,
|
||||
pillars,
|
||||
services,
|
||||
youtubeVideos,
|
||||
clinicName,
|
||||
});
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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,34 +781,61 @@ export function mergeEnrichment(
|
|||
if (igAccounts.length > 0) {
|
||||
merged.instagramAudit = {
|
||||
...merged.instagramAudit,
|
||||
accounts: igAccounts.map((ig, idx) => ({
|
||||
handle: ig.username || '',
|
||||
language: (idx === 0 ? 'KR' : 'EN') as 'KR' | 'EN',
|
||||
label: igAccounts.length === 1 ? '메인' : idx === 0 ? '국내' : `해외 ${idx}`,
|
||||
posts: ig.posts ?? 0,
|
||||
followers: ig.followers ?? 0,
|
||||
following: ig.following ?? 0,
|
||||
category: '의료/건강',
|
||||
profileLink: ig.username ? `https://instagram.com/${ig.username}` : '',
|
||||
highlights: [],
|
||||
reelsCount: 0,
|
||||
contentFormat: ig.isBusinessAccount ? '비즈니스 계정' : '일반 계정',
|
||||
profilePhoto: '',
|
||||
bio: ig.bio || '',
|
||||
})),
|
||||
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}`,
|
||||
posts: ig.posts ?? 0,
|
||||
followers: ig.followers ?? 0,
|
||||
following: ig.following ?? 0,
|
||||
category: '의료/건강',
|
||||
profileLink: ig.username ? `https://instagram.com/${ig.username}` : '',
|
||||
highlights: [],
|
||||
reelsCount,
|
||||
contentFormat: ig.isBusinessAccount ? '비즈니스 계정' : '일반 계정',
|
||||
profilePhoto: '',
|
||||
bio: ig.bio || '',
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
// Update KPI with real follower data
|
||||
merged.kpiDashboard = merged.kpiDashboard.map(kpi =>
|
||||
kpi.metric === 'Instagram 팔로워' && ig.followers
|
||||
? {
|
||||
...kpi,
|
||||
current: `${ig.followers.toLocaleString()}`,
|
||||
target3Month: `${Math.round(ig.followers * 1.3).toLocaleString()}`,
|
||||
target12Month: `${Math.round(ig.followers * 2).toLocaleString()}`,
|
||||
}
|
||||
: kpi
|
||||
);
|
||||
// Update KPI with real follower data from first account
|
||||
const primaryIg = igAccounts[0];
|
||||
if (primaryIg?.followers) {
|
||||
merged.kpiDashboard = merged.kpiDashboard.map(kpi =>
|
||||
kpi.metric.includes('Instagram KR 팔로워') || kpi.metric === 'Instagram 팔로워'
|
||||
? {
|
||||
...kpi,
|
||||
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)
|
||||
|
|
@ -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 구독자'
|
||||
? {
|
||||
...kpi,
|
||||
current: `${yt.subscribers!.toLocaleString()}`,
|
||||
target3Month: `${Math.round(yt.subscribers! * 1.5).toLocaleString()}`,
|
||||
target12Month: `${Math.round(yt.subscribers! * 3).toLocaleString()}`,
|
||||
}
|
||||
: kpi
|
||||
);
|
||||
merged.kpiDashboard = merged.kpiDashboard.map(kpi => {
|
||||
if (kpi.metric === 'YouTube 구독자') {
|
||||
return {
|
||||
...kpi,
|
||||
current: fmt(yt.subscribers!),
|
||||
target3Month: fmt(Math.round(yt.subscribers! * 1.1)),
|
||||
target12Month: fmt(Math.round(yt.subscribers! * 2)),
|
||||
};
|
||||
}
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue