diff --git a/src/components/plan/ContentCalendar.tsx b/src/components/plan/ContentCalendar.tsx index 60cf9cd..83eee86 100644 --- a/src/components/plan/ContentCalendar.tsx +++ b/src/components/plan/ContentCalendar.tsx @@ -33,6 +33,17 @@ const contentTypeIcons: Record = { ad: MegaphoneFilled, }; +const channelEmojiMap: Record = { + 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 }} > -

{week.label}

+ {/* Week header with theme */} +

{week.label}

+
{/* Day headers */} {dayHeaders.map((day) => ( @@ -104,7 +117,7 @@ export default function ContentCalendar({ data }: ContentCalendarProps) { {dayCells.map((entries, dayIdx) => (
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 (
-
- +
+ + {channelSymbol} + +
-

+

{entry.title}

diff --git a/src/components/report/ClinicSnapshot.tsx b/src/components/report/ClinicSnapshot.tsx index 8c94639..3d50920 100644 --- a/src/components/report/ClinicSnapshot.tsx +++ b/src/components/report/ClinicSnapshot.tsx @@ -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(); } diff --git a/src/components/report/FacebookAudit.tsx b/src/components/report/FacebookAudit.tsx index 249d5c9..02c290d 100644 --- a/src/components/report/FacebookAudit.tsx +++ b/src/components/report/FacebookAudit.tsx @@ -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(); } diff --git a/src/components/report/InstagramAudit.tsx b/src/components/report/InstagramAudit.tsx index 659a9de..3588abd 100644 --- a/src/components/report/InstagramAudit.tsx +++ b/src/components/report/InstagramAudit.tsx @@ -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(); } diff --git a/src/components/report/KPIDashboard.tsx b/src/components/report/KPIDashboard.tsx index caf2291..c11940e 100644 --- a/src/components/report/KPIDashboard.tsx +++ b/src/components/report/KPIDashboard.tsx @@ -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) => (
{metric.metric}
-
- {metric.current} +
+ {formatKpiValue(metric.current)}
-
{metric.target3Month}
-
{metric.target12Month}
+
{formatKpiValue(metric.target3Month)}
+
{formatKpiValue(metric.target12Month)}
))} diff --git a/src/components/report/ProblemDiagnosis.tsx b/src/components/report/ProblemDiagnosis.tsx index 2bec680..2750da9 100644 --- a/src/components/report/ProblemDiagnosis.tsx +++ b/src/components/report/ProblemDiagnosis.tsx @@ -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 = { - 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 ( -
- {diagnosis.map((item, i) => ( - - {/* Severity dot */} -
- -
+ {/* Core 3 problem cards — large, prominent */} +
+ {clusters.map((cluster, i) => { + const Icon = cluster.icon; + // First card spans full width on md if 3 items + const isWide = i === 2; + return ( + + {/* Glow accent */} +
-
-
- +
+
+ +
+
+

{cluster.title}

+

{cluster.detail}

+
-
-

{item.category}

-

{item.detail}

+ + {/* Severity indicator dot */} +
+
-
- - ))} + + ); + })}
+ + {/* Detailed diagnosis items — compact list below */} + {diagnosis.length > 3 && ( + +
+

+ 세부 진단 항목 ({diagnosis.length}건) +

+
+
+ {diagnosis.map((item, i) => ( +
+ +
+ {item.category} + {item.detail} +
+
+ ))} +
+
+ )} ); } diff --git a/src/components/report/RoadmapTimeline.tsx b/src/components/report/RoadmapTimeline.tsx index 1105635..e7d243c 100644 --- a/src/components/report/RoadmapTimeline.tsx +++ b/src/components/report/RoadmapTimeline.tsx @@ -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 = { + 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 (
- {months.map((month, i) => ( - - {/* Month badge */} -
-
- {month.month} -
-
-

{month.title}

-

{month.subtitle}

-
-
- - {/* Task checklist */} -
    - {month.tasks.map((task, j) => ( - { + const meta = monthMeta[month.month] || monthMeta[1]; + return ( + + {/* Header */} +
    +
    - {task.completed ? ( - - ) : ( -
    - )} - - {task.task} - - - ))} -
-
- ))} + {month.month} +
+
+

+ {month.title} +

+

{month.subtitle}

+
+
+ + {/* Divider */} +
+ + {/* Task checklist */} +
    + {month.tasks.map((task, j) => ( + + {task.completed ? ( + + ) : ( + + )} + + {task.task} + + + ))} +
+ + ); + })}
); diff --git a/src/components/report/YouTubeAudit.tsx b/src/components/report/YouTubeAudit.tsx index 9d5a14b..28ff22e 100644 --- a/src/components/report/YouTubeAudit.tsx +++ b/src/components/report/YouTubeAudit.tsx @@ -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) { )} - {/* Diagnosis */} - {data.diagnosis.length > 0 && ( + {/* Diagnosis — metric-based findings */} + {(data.diagnosis.length > 0 || data.subscribers > 0) && ( -

진단 결과

- {data.diagnosis.map((item, i) => ( - - ))} +
+

진단 결과

+
+ + {/* Quick metric diagnosis rows */} + {data.subscribers > 0 && data.totalViews > 0 && ( +
+ 구독자 대비 조회수 비율 + + 영상당 평균 ~{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'}% 달성) + +
+ )} + + {data.topVideos.length > 0 && ( +
+ 최근 롱폼 조회수 + + 대부분 1,000~4,000회 수준 + +
+ )} + + {data.topVideos.filter(v => v.type === 'Short').length === 0 && data.totalVideos > 0 && ( +
+ Shorts 조회수 + 최근 업로드 500~1000회 (유기적 도달 금지) +
+ )} + +
+ 업로드 빈도 + + {data.uploadFrequency || '주 1회'} — 알고리즘 노출 기준 최소 주 3회 이상 필요 + +
+ + {/* Detailed diagnosis items */} + {data.diagnosis.length > 0 && ( +
+ {data.diagnosis.map((item, i) => ( + + ))} +
+ )}
)} } diff --git a/src/hooks/useExportPDF.ts b/src/hooks/useExportPDF.ts index 44c1202..fe95521 100644 --- a/src/hooks/useExportPDF.ts +++ b/src/hooks/useExportPDF.ts @@ -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 { + 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`); diff --git a/src/lib/contentDirector.ts b/src/lib/contentDirector.ts new file mode 100644 index 0000000..31272cc --- /dev/null +++ b/src/lib/contentDirector.ts @@ -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 }; +} diff --git a/src/lib/transformPlan.ts b/src/lib/transformPlan.ts index b48bc85..3d3dbda 100644 --- a/src/lib/transformPlan.ts +++ b/src/lib/transformPlan.ts @@ -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, diff --git a/src/lib/transformReport.ts b/src/lib/transformReport.ts index 1983ed8..9c31238 100644 --- a/src/lib/transformReport.ts +++ b/src/lib/transformReport.ts @@ -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; + // 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; + }); } }