diff --git a/src/features/report/components/ui/SectionWrapper.tsx b/src/features/report/components/ui/SectionWrapper.tsx
index d124c9d..cd6fbf5 100644
--- a/src/features/report/components/ui/SectionWrapper.tsx
+++ b/src/features/report/components/ui/SectionWrapper.tsx
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react';
+import { PageContainer } from '@/shared/ui/page-container';
interface SectionWrapperProps {
id: string;
@@ -32,7 +33,7 @@ export function SectionWrapper({
{dark && (
+
{children}
-
+
);
}
diff --git a/src/features/report/data/mockReport.ts b/src/features/report/data/mockReport.ts
index 111c966..321de7d 100644
--- a/src/features/report/data/mockReport.ts
+++ b/src/features/report/data/mockReport.ts
@@ -311,12 +311,12 @@ export const mockReport: MarketingReport = {
{ platform: 'Naver Cafe', url: 'https://cafe.naver.com/bluectcom2', location: 'Footer' },
],
trackingPixels: [
- { name: 'Facebook Pixel', installed: true, details: 'ID: 299151214739571' },
- { name: 'Facebook Domain Verification', installed: true, details: 'lm854gkic9948c6xk2ti76inryqk65' },
- { name: 'Google Site Verification', installed: true, details: 'A8vo9aOWSvGL5-yFKhbtlHPqJCkH-egNdWVqVd9gKac' },
- { name: 'Naver Site Verification', installed: true, details: 'a8cb4fab1fdf7277c0892eeddf457b5c939349e8' },
+ { name: 'Facebook Pixel', installed: true },
+ { name: 'Facebook Domain Verification', installed: true },
+ { name: 'Google Site Verification', installed: true },
+ { name: 'Naver Site Verification', installed: true },
{ name: 'Kakao Pixel', installed: true },
- { name: 'Google Tag Manager', installed: true, details: 'GTM-52RT6DMK' },
+ { name: 'Google Tag Manager', installed: true },
],
mainCTA: '전화 + 카카오톡 상담 + 온라인 예약',
},
diff --git a/src/features/report/data/mockReport_banobagi.ts b/src/features/report/data/mockReport_banobagi.ts
index 6003f9e..4a5c8c0 100644
--- a/src/features/report/data/mockReport_banobagi.ts
+++ b/src/features/report/data/mockReport_banobagi.ts
@@ -202,8 +202,8 @@ export const mockReportBanobagi: MarketingReport = {
{ platform: 'Naver Blog', url: 'https://blog.naver.com/banobagips', location: 'Footer' },
],
trackingPixels: [
- { name: 'Facebook Pixel', installed: true, details: '설치 확인' },
- { name: 'Google Analytics', installed: true, details: 'GA4 추정' },
+ { name: 'Facebook Pixel', installed: true },
+ { name: 'Google Analytics', installed: true },
{ name: 'Naver Site Verification', installed: true },
],
mainCTA: '전화 상담 + 온라인 예약',
diff --git a/src/features/report/data/mockReport_grand.ts b/src/features/report/data/mockReport_grand.ts
index 2b4a1e7..8d84810 100644
--- a/src/features/report/data/mockReport_grand.ts
+++ b/src/features/report/data/mockReport_grand.ts
@@ -170,7 +170,7 @@ export const mockReportGrand: MarketingReport = {
{ platform: 'Naver Blog', url: 'https://blog.naver.com/grandprs', location: 'Footer' },
],
trackingPixels: [
- { name: 'Google Analytics', installed: true, details: 'GA4 추정' },
+ { name: 'Google Analytics', installed: true },
{ name: 'Naver Site Verification', installed: true },
],
mainCTA: '전화 상담 + 온라인 예약',
diff --git a/src/features/report/data/mockReport_irum.ts b/src/features/report/data/mockReport_irum.ts
index edf7cf5..360c712 100644
--- a/src/features/report/data/mockReport_irum.ts
+++ b/src/features/report/data/mockReport_irum.ts
@@ -187,7 +187,7 @@ export const mockReportIrum: MarketingReport = {
{ platform: 'Naver Blog', url: 'https://blog.naver.com/seoulips', location: 'Footer' },
],
trackingPixels: [
- { name: 'Google Analytics', installed: true, details: 'GA4 추정' },
+ { name: 'Google Analytics', installed: true },
],
mainCTA: '전화 상담 + 카카오톡',
},
diff --git a/src/features/report/data/mockReport_o2o.ts b/src/features/report/data/mockReport_o2o.ts
index e130fd8..c14c643 100644
--- a/src/features/report/data/mockReport_o2o.ts
+++ b/src/features/report/data/mockReport_o2o.ts
@@ -211,10 +211,10 @@ export const mockReportO2O: MarketingReport = {
{ platform: 'TikTok', url: 'https://www.tiktok.com/@o2oclinic', location: 'Footer' },
],
trackingPixels: [
- { name: 'Google Analytics 4', installed: true, details: 'GA4 + GTM 정상 운영' },
- { name: 'Meta Pixel', installed: true, details: 'Facebook/Instagram 광고 픽셀 설치 완료' },
- { name: 'Naver Analytics', installed: true, details: '네이버 검색광고 전환 추적' },
- { name: 'TikTok Pixel', installed: false, details: '미설치 — 도입 권장' },
+ { name: 'Google Analytics 4', installed: true },
+ { name: 'Meta Pixel', installed: true },
+ { name: 'Naver Analytics', installed: true },
+ { name: 'TikTok Pixel', installed: false },
],
mainCTA: '온라인 상담 예약 + 카카오톡 + WhatsApp (글로벌)',
},
diff --git a/src/features/report/data/mockReport_ts.ts b/src/features/report/data/mockReport_ts.ts
index 5f172bb..6afe380 100644
--- a/src/features/report/data/mockReport_ts.ts
+++ b/src/features/report/data/mockReport_ts.ts
@@ -171,7 +171,7 @@ export const mockReportTs: MarketingReport = {
{ platform: 'Naver Blog', url: 'https://blog.naver.com/tsprs', location: 'Footer' },
],
trackingPixels: [
- { name: 'Google Analytics', installed: true, details: 'GA4 추정' },
+ { name: 'Google Analytics', installed: true },
{ name: 'Naver Site Verification', installed: true },
],
mainCTA: '전화 상담 + 카카오톡 상담',
diff --git a/src/features/report/data/mockReport_wonjin.ts b/src/features/report/data/mockReport_wonjin.ts
index 76f5f73..7805852 100644
--- a/src/features/report/data/mockReport_wonjin.ts
+++ b/src/features/report/data/mockReport_wonjin.ts
@@ -187,8 +187,8 @@ export const mockReportWonjin: MarketingReport = {
{ platform: 'Naver Blog', url: 'https://blog.naver.com/popokpop', location: 'Footer' },
],
trackingPixels: [
- { name: 'Facebook Pixel', installed: true, details: '설치 확인 (추정)' },
- { name: 'Google Analytics', installed: true, details: 'GA4 추정' },
+ { name: 'Facebook Pixel', installed: true },
+ { name: 'Google Analytics', installed: true },
],
mainCTA: '전화 상담 + 다국어 온라인 예약',
},
diff --git a/src/features/report/hooks/useExportCSV.ts b/src/features/report/hooks/useExportCSV.ts
new file mode 100644
index 0000000..aea8843
--- /dev/null
+++ b/src/features/report/hooks/useExportCSV.ts
@@ -0,0 +1,248 @@
+import { useCallback } from 'react';
+import type { MarketingReport } from '@/features/report/types/report';
+
+/**
+ * 리포트의 표 데이터 전체를 단일 CSV로 내보내는 훅.
+ * 섹션마다 `=== Section Title ===` 헤더로 구분.
+ * Excel/Numbers에서 한글이 깨지지 않도록 UTF-8 BOM 포함.
+ */
+
+type Cell = string | number | null | undefined;
+type Row = Cell[];
+type Section = { title: string; rows: Row[] };
+
+function escapeCell(value: Cell): string {
+ const s = String(value ?? '');
+ if (/[",\n\r]/.test(s)) {
+ return `"${s.replace(/"/g, '""')}"`;
+ }
+ return s;
+}
+
+function buildReportCsv(report: MarketingReport): string {
+ const sections: Section[] = [];
+
+ // ─── KPI Dashboard ───
+ if (report.kpiDashboard?.length) {
+ sections.push({
+ title: 'KPI Dashboard',
+ rows: [
+ ['Metric', 'Current', '3-Month Target', '12-Month Target'],
+ ...report.kpiDashboard.map((m) => [m.metric, m.current, m.target3Month, m.target12Month]),
+ ],
+ });
+ }
+
+ // ─── Channel Scores ───
+ if (report.channelScores?.length) {
+ sections.push({
+ title: 'Channel Scores',
+ rows: [
+ ['Channel', 'Score', 'Max Score', 'Status', 'Headline'],
+ ...report.channelScores.map((c) => [c.channel, c.score, c.maxScore, c.status, c.headline]),
+ ],
+ });
+ }
+
+ // ─── Problem Diagnosis ───
+ if (report.problemDiagnosis?.length) {
+ sections.push({
+ title: 'Problem Diagnosis',
+ rows: [
+ ['Category', 'Detail', 'Severity'],
+ ...report.problemDiagnosis.map((d) => [d.category, d.detail, d.severity]),
+ ],
+ });
+ }
+
+ // ─── Roadmap (월별 → 태스크 단위로 풀어 펼침) ───
+ if (report.roadmap?.length) {
+ const rows: Row[] = [['Month', 'Title', 'Subtitle', 'Task', 'Completed']];
+ report.roadmap.forEach((m) => {
+ if (m.tasks.length === 0) {
+ rows.push([m.month, m.title, m.subtitle, '', '']);
+ return;
+ }
+ m.tasks.forEach((t, i) => {
+ rows.push([
+ i === 0 ? m.month : '',
+ i === 0 ? m.title : '',
+ i === 0 ? m.subtitle : '',
+ t.task,
+ t.completed ? 'Y' : 'N',
+ ]);
+ });
+ });
+ sections.push({ title: 'Roadmap', rows });
+ }
+
+ // ─── Transformation ───
+ const t = report.transformation;
+ const asIsToBe = (title: string, items: { area: string; asIs: string; toBe: string }[]) => {
+ if (!items?.length) return;
+ sections.push({
+ title,
+ rows: [['Area', 'As-Is', 'To-Be'], ...items.map((i) => [i.area, i.asIs, i.toBe])],
+ });
+ };
+ asIsToBe('Transformation: Brand Identity', t?.brandIdentity ?? []);
+ asIsToBe('Transformation: Content Strategy', t?.contentStrategy ?? []);
+ asIsToBe('Transformation: Website Improvements', t?.websiteImprovements ?? []);
+
+ if (t?.newChannelProposals?.length) {
+ sections.push({
+ title: 'Transformation: New Channel Proposals',
+ rows: [
+ ['Channel', 'Priority', 'Rationale'],
+ ...t.newChannelProposals.map((p) => [p.channel, p.priority, p.rationale]),
+ ],
+ });
+ }
+
+ if (t?.platformStrategies?.length) {
+ const rows: Row[] = [['Platform', 'Current Metric', 'Target Metric', 'Strategy', 'Detail']];
+ t.platformStrategies.forEach((p) => {
+ if (p.strategies.length === 0) {
+ rows.push([p.platform, p.currentMetric, p.targetMetric, '', '']);
+ return;
+ }
+ p.strategies.forEach((s, i) => {
+ rows.push([
+ i === 0 ? p.platform : '',
+ i === 0 ? p.currentMetric : '',
+ i === 0 ? p.targetMetric : '',
+ s.strategy,
+ s.detail,
+ ]);
+ });
+ });
+ sections.push({ title: 'Transformation: Platform Strategies', rows });
+ }
+
+ // ─── Channel diagnoses ───
+ const diagSection = (title: string, items: { category: string; detail: string; severity: string }[]) => {
+ if (!items?.length) return;
+ sections.push({
+ title,
+ rows: [['Category', 'Detail', 'Severity'], ...items.map((d) => [d.category, d.detail, d.severity])],
+ });
+ };
+ diagSection('YouTube Diagnosis', report.youtubeAudit?.diagnosis ?? []);
+ diagSection('Instagram Diagnosis', report.instagramAudit?.diagnosis ?? []);
+ diagSection('Facebook Diagnosis', report.facebookAudit?.diagnosis ?? []);
+
+ // ─── Facebook Brand Inconsistencies (채널별 값으로 풀어 펼침) ───
+ if (report.facebookAudit?.brandInconsistencies?.length) {
+ const rows: Row[] = [['Field', 'Channel', 'Value', 'Correct', 'Impact', 'Recommendation']];
+ report.facebookAudit.brandInconsistencies.forEach((b) => {
+ b.values.forEach((v, i) => {
+ rows.push([
+ i === 0 ? b.field : '',
+ v.channel,
+ v.value,
+ v.isCorrect ? 'Y' : 'N',
+ i === 0 ? b.impact : '',
+ i === 0 ? b.recommendation : '',
+ ]);
+ });
+ });
+ sections.push({ title: 'Facebook Brand Inconsistencies', rows });
+ }
+
+ // ─── Instagram Accounts (요약 컬럼만) ───
+ if (report.instagramAudit?.accounts?.length) {
+ sections.push({
+ title: 'Instagram Accounts',
+ rows: [
+ ['Handle', 'Language', 'Label', 'Followers', 'Following', 'Posts', 'Reels', 'Category'],
+ ...report.instagramAudit.accounts.map((a) => [
+ a.handle,
+ a.language,
+ a.label,
+ a.followers,
+ a.following,
+ a.posts,
+ a.reelsCount,
+ a.category,
+ ]),
+ ],
+ });
+ }
+
+ // ─── Facebook Pages (요약 컬럼만) ───
+ if (report.facebookAudit?.pages?.length) {
+ sections.push({
+ title: 'Facebook Pages',
+ rows: [
+ ['Page Name', 'Language', 'Label', 'Followers', 'Reviews', 'Category', 'URL'],
+ ...report.facebookAudit.pages.map((p) => [
+ p.pageName,
+ p.language,
+ p.label,
+ p.followers,
+ p.reviews,
+ p.category,
+ p.url,
+ ]),
+ ],
+ });
+ }
+
+ // ─── Other Channels ───
+ if (report.otherChannels?.length) {
+ sections.push({
+ title: 'Other Channels',
+ rows: [
+ ['Name', 'Status', 'Details', 'URL'],
+ ...report.otherChannels.map((c) => [c.name, c.status, c.details, c.url ?? '']),
+ ],
+ });
+ }
+
+ // ─── Website Audit (단일 행 요약) ───
+ if (report.websiteAudit) {
+ const w = report.websiteAudit;
+ sections.push({
+ title: 'Website Audit',
+ rows: [
+ ['Primary Domain', 'Main CTA', 'SNS Links On Site'],
+ [w.primaryDomain, w.mainCTA, w.snsLinksOnSite ? 'Y' : 'N'],
+ ],
+ });
+ if (w.trackingPixels?.length) {
+ sections.push({
+ title: 'Website: Tracking Pixels',
+ rows: [['Name', 'Installed'], ...w.trackingPixels.map((p) => [p.name, p.installed ? 'Y' : 'N'])],
+ });
+ }
+ }
+
+ // ─── 출력 조립: 섹션 사이 공백 행 한 줄 ───
+ const lines: string[] = [];
+ sections.forEach((sec, idx) => {
+ if (idx > 0) lines.push('');
+ lines.push(`=== ${sec.title} ===`);
+ sec.rows.forEach((row) => lines.push(row.map(escapeCell).join(',')));
+ });
+
+ return lines.join('\r\n');
+}
+
+export function useExportCSV() {
+ const exportReportCSV = useCallback((filename: string, report: MarketingReport) => {
+ const csv = '' + buildReportCsv(report); // Excel 한글 깨짐 방지 BOM
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${filename}.csv`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+
+ URL.revokeObjectURL(url);
+ }, []);
+
+ return { exportReportCSV };
+}
diff --git a/src/features/report/hooks/useExportPDF.ts b/src/features/report/hooks/useExportPDF.ts
index 6b02828..b072a52 100644
--- a/src/features/report/hooks/useExportPDF.ts
+++ b/src/features/report/hooks/useExportPDF.ts
@@ -1,284 +1,59 @@
import { useState, useCallback } from 'react';
/**
- * PDF Export — 블록 기반 전략.
+ * Chrome(브라우저) 네이티브 인쇄 다이얼로그로 PDF 저장.
*
- * 각 섹션은 페이지 간에 분할되지 않는 원자적 서브 블록으로 분리됨.
- * framer-motion 애니메이션이 있는 다크 섹션은 별도 처리 필요:
- * 모든 섹션을 뷰포트에 스크롤해서 `whileInView`를 트리거한 뒤,
- * html2canvas 캡처 전에 인라인 opacity/transform을 최종 상태로 강제 설정.
+ * 레이아웃·색상·페이지 분할은 `@media print` CSS가 담당 (`src/styles/custom.css`).
+ * 호출 직전 framer-motion `whileInView` 잔여 요소(opacity:0/transform)를
+ * 최종 상태로 강제하여 사용자가 보지 못한 섹션이 빈 페이지로 인쇄되는 것을 방지.
*/
-function isDarkSection(section: HTMLElement): boolean {
- const bg = window.getComputedStyle(section).backgroundColor;
- if (!bg || bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') return false;
- // rgb(r, g, b) 파싱 — 휘도가 낮으면 다크로 판단
- 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 forceMotionFinalState(): () => void {
+ const root =
+ (document.querySelector('[data-report-content]') as HTMLElement | null) ||
+ (document.querySelector('[data-plan-content]') as HTMLElement | null);
+ if (!root) return () => {};
-function collectSubBlocks(section: HTMLElement): HTMLElement[] {
- // 다크 섹션은 통째로 캡처 — 서브 블록으로 나누면 다크 배경이 사라짐
- if (isDarkSection(section)) return [section];
+ const saved: { el: HTMLElement; opacity: string; transform: string }[] = [];
- if (section.offsetHeight <= 900) return [section];
-
- const children = Array.from(section.children) as HTMLElement[];
- if (children.length <= 1) return [section];
-
- const blocks: HTMLElement[] = [];
- for (const child of children) {
- if (child.offsetHeight === 0 || child.offsetWidth === 0) continue;
- blocks.push(child);
- }
- return blocks.length > 0 ? blocks : [section];
-}
-
-/**
- * 모든 섹션을 스크롤하여 `whileInView` 애니메이션을 트리거한 뒤,
- * 안정화될 때까지 대기. 스냅샷 캡처 전에 framer-motion이 최종
- * 인라인 스타일을 적용하도록 보장.
- */
-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 });
- // framer-motion이 intersection을 감지하고 스타일을 적용할 시간 부여
- await new Promise((r) => setTimeout(r, 80));
-
- // 중첩된 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));
- }
- }
-
- // 최상단으로 스크롤하고 모두 안정화될 때까지 대기
- window.scrollTo(0, 0);
- await new Promise((r) => setTimeout(r, 200));
-}
-
-/**
- * 모든 요소를 완전히 보이도록 강제 — opacity 1, transform 없음.
- * whileInView가 도달하지 못한 잔여 요소를 처리.
- */
-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;
+ root.querySelectorAll('*').forEach((el) => {
const s = el.style;
+ const opacityOff = s.opacity !== '' && parseFloat(s.opacity) < 1;
+ const transformOn = s.transform !== '' && s.transform !== 'none';
+ if (!opacityOff && !transformOn) return;
- // 인라인과 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';
- }
- });
-
- // 2차 패스: computed opacity < 1 처리 (motion이 클래스로 설정했을 수도)
- 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';
- }
+ saved.push({ el, opacity: s.opacity, transform: s.transform });
+ if (opacityOff) s.opacity = '1';
+ if (transformOn) s.transform = 'none';
});
return () => {
- saved.forEach(({ el, cssText }) => {
- el.style.cssText = cssText;
+ saved.forEach(({ el, opacity, transform }) => {
+ el.style.opacity = opacity;
+ el.style.transform = transform;
});
- document.documentElement.style.removeProperty('--motion-duration');
};
}
-/**
- * Export용 CSS 오버라이드: overflow 해제, 클리핑 방지.
- */
-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);
- const exportPDF = useCallback(async (filename = 'INFINITH_Marketing_Intelligence_Report') => {
+ const exportPDF = useCallback((filename = 'INFINITH_Marketing_Intelligence_Report') => {
setIsExporting(true);
- try {
- const [{ default: html2canvas }, { jsPDF }] = await Promise.all([
- import('html2canvas-pro'),
- import('jspdf'),
- ]);
+ const originalTitle = document.title;
+ document.title = filename;
+ const restoreMotion = forceMotionFinalState();
- 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');
-
- // Step 1: whileInView 애니메이션 트리거를 위해 모든 섹션 스크롤
- await triggerAllAnimations(contentEl);
-
- // Step 2: 모든 요소를 강제로 visible 처리 (잔여 요소 처리)
- const restoreVisible = forceVisible(contentEl);
-
- // Step 3: CSS 오버라이드
- const restoreCSS = addExportCSS();
-
- // Step 4: UI 요소 숨기기
- const hideSelectors = [
- '[data-report-nav]',
- '[data-plan-nav]',
- 'nav',
- '[data-cta-card]',
- '[data-no-print]',
- ];
- const hiddenEls: { el: HTMLElement; display: string }[] = [];
- hideSelectors.forEach((sel) => {
- document.querySelectorAll(sel).forEach((el) => {
- const htmlEl = el as HTMLElement;
- hiddenEls.push({ el: htmlEl, display: htmlEl.style.display });
- htmlEl.style.display = 'none';
- });
- });
-
- await new Promise((r) => setTimeout(r, 150));
-
- // Step 5: PDF 생성
- const pdf = new jsPDF('p', 'mm', 'a4');
- const pageWidth = 210;
- const pageHeight = 297;
- const margin = 8;
- const usableWidth = pageWidth - margin * 2;
- const footerSpace = 10;
- const maxContentY = pageHeight - margin - footerSpace;
- let currentY = margin;
-
- const sections = Array.from(contentEl.children) as HTMLElement[];
-
- for (const section of sections) {
- if (section.offsetHeight === 0 || section.offsetWidth === 0) continue;
- if (window.getComputedStyle(section).display === 'none') continue;
-
- const blocks = collectSubBlocks(section);
-
- for (const block of blocks) {
- if (block.offsetHeight === 0) continue;
-
- const canvas = await html2canvas(block, {
- scale: 2,
- useCORS: true,
- logging: false,
- backgroundColor: null,
- windowWidth: 1280,
- removeContainer: true,
- });
-
- const blockHeightMM = (canvas.height * usableWidth) / canvas.width;
-
- // 블록이 안 들어가면 새 페이지
- if (currentY + blockHeightMM > maxContentY && currentY > margin + 5) {
- pdf.addPage();
- currentY = margin;
- }
-
- // 긴 블록: 페이지 단위로 슬라이싱
- if (blockHeightMM > maxContentY - margin) {
- const pxPerMM = canvas.width / usableWidth;
- let srcY = 0;
- let remainPx = canvas.height;
- let isFirst = true;
-
- while (remainPx > 0) {
- if (!isFirst) { pdf.addPage(); currentY = margin; }
- isFirst = false;
-
- const availPx = (maxContentY - currentY) * pxPerMM;
- const sliceH = Math.min(remainPx, availPx);
-
- const sliceCanvas = document.createElement('canvas');
- sliceCanvas.width = canvas.width;
- 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));
- }
-
- const sliceMM = sliceH / pxPerMM;
- pdf.addImage(sliceCanvas.toDataURL('image/jpeg', 0.9), 'JPEG', margin, currentY, usableWidth, sliceMM);
- currentY += sliceMM;
- srcY += sliceH;
- remainPx -= sliceH;
- }
- } else {
- pdf.addImage(canvas.toDataURL('image/jpeg', 0.9), 'JPEG', margin, currentY, usableWidth, blockHeightMM);
- currentY += blockHeightMM;
- }
- }
-
- currentY += 2; // 섹션 간 여백
- }
-
- // Step 6: 복원
- hiddenEls.forEach(({ el, display }) => { el.style.display = display; });
- restoreCSS();
- restoreVisible();
-
- // Step 7: 푸터
- 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(`${footerLabel} | Page ${i} / ${totalPages}`, pageWidth / 2, pageHeight - 5, { align: 'center' });
- }
-
- pdf.save(`${filename}.pdf`);
- } catch (err) {
- console.error('PDF export failed:', err);
- } finally {
+ const cleanup = () => {
+ restoreMotion();
+ document.title = originalTitle;
setIsExporting(false);
- }
+ window.removeEventListener('afterprint', cleanup);
+ };
+ window.addEventListener('afterprint', cleanup);
+
+ window.print();
}, []);
return { exportPDF, isExporting };
diff --git a/src/features/report/hooks/useReportPageData.ts b/src/features/report/hooks/useReportPageData.ts
new file mode 100644
index 0000000..50ee8d7
--- /dev/null
+++ b/src/features/report/hooks/useReportPageData.ts
@@ -0,0 +1,66 @@
+/**
+ * useReportPageData — Guest / User 리포트 페이지가 공통으로 쓰는 데이터 훅.
+ *
+ * 단순히 기존 `useReport` + `useEnrichment` 조합을 한 곳에 묶어
+ * 페이지 wrapper 두 곳에서 동일한 코드를 반복하지 않도록 합니다.
+ */
+import { useMemo } from 'react';
+import { useLocation } from 'react-router';
+import type { MarketingReport } from '@/features/report/types/report';
+import { useReport } from '@/features/report/hooks/useReport';
+import { useEnrichment } from '@/features/channels/hooks/useEnrichment';
+
+interface UseReportPageDataResult {
+ data: MarketingReport | null;
+ isLoading: boolean;
+ error: string | null;
+ enrichStatus: ReturnType['status'];
+}
+
+export function useReportPageData(id: string | undefined): UseReportPageDataResult {
+ const location = useLocation();
+ const {
+ data: baseData,
+ isLoading,
+ error,
+ isEnriched,
+ socialHandles: dbSocialHandles,
+ } = useReport(id);
+
+ const enrichmentParams = useMemo(() => {
+ if (!baseData || isEnriched) return null;
+
+ const state = location.state as Record | undefined;
+ const metadata = state?.metadata as Record | undefined;
+ const stateSocialHandles = metadata?.socialHandles as Record | undefined;
+
+ const handles = stateSocialHandles || dbSocialHandles;
+
+ const igHandles: string[] = Array.isArray(handles?.instagram)
+ ? (handles.instagram.filter(Boolean) as string[])
+ : handles?.instagram
+ ? [handles.instagram as string]
+ : [];
+
+ const ytHandle = handles?.youtube || baseData.youtubeAudit?.handle || undefined;
+ const fbHandle = handles?.facebook || undefined;
+
+ return {
+ reportId: baseData.id,
+ clinicName: baseData.clinicSnapshot.name,
+ instagramHandles: igHandles.length > 0 ? igHandles : undefined,
+ youtubeChannelId: ytHandle || undefined,
+ facebookHandle: fbHandle as string | undefined,
+ address: baseData.clinicSnapshot.location || undefined,
+ };
+ }, [baseData, isEnriched, dbSocialHandles, location.state]);
+
+ const { status: enrichStatus, enrichedReport } = useEnrichment(baseData, enrichmentParams);
+
+ return {
+ data: enrichedReport || baseData,
+ isLoading,
+ error,
+ enrichStatus,
+ };
+}
diff --git a/src/features/report/pages/GuestReportPage.tsx b/src/features/report/pages/GuestReportPage.tsx
new file mode 100644
index 0000000..8cc84cd
--- /dev/null
+++ b/src/features/report/pages/GuestReportPage.tsx
@@ -0,0 +1,104 @@
+/**
+ * GuestReportPage — `/report/:id`
+ *
+ * 손님(랜딩에서 분석을 실행한 비계약 방문자)이 보는 리포트 화면.
+ * 본문은 UserReportPage 와 동일하나, 하단에 도입 문의 CTA가 추가되고
+ * 워크스페이스용 액션바는 노출되지 않습니다.
+ */
+import { Link, useParams } from 'react-router';
+import { ArrowRight, Sparkles } from 'lucide-react';
+import { useReportPageData } from '../hooks/useReportPageData';
+import { ReportNav } from '../components/ReportNav';
+import { ScreenshotProvider } from '../stores/ScreenshotContext';
+import { REPORT_SECTIONS } from '@/shared/constants/reportSections';
+import { buildContactMailto } from '@/shared/lib/contact';
+import ReportBody from '../components/ReportBody';
+import { DownloadMenuButton } from '../components/DownloadMenuButton';
+
+export default function GuestReportPage() {
+ const { id } = useParams<{ id: string }>();
+ const { data, isLoading, error, enrichStatus } = useReportPageData(id);
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (error || !data) {
+ return (
+
+
+
오류가 발생했습니다
+
{error ?? '리포트를 찾을 수 없습니다.'}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ 마케팅 기획 보기
+
+
+
+ }
+ />
+
+ {enrichStatus === 'loading' && (
+
+ )}
+
+
+
+ {/* Guest 전용 — 도입 문의 CTA */}
+
+
+
+
+
+ INFINITH 도입 안내
+
+
+ 이 리포트의 다음 단계 — 마케팅 기획
+
+
+ 계약 병원 전용 워크스페이스에서 본 분석을 기반으로 콘텐츠 캘린더,
+ 자산 관리, 워크플로우 추적까지 한 곳에서 운영할 수 있습니다.
+
+
+ 도입 문의하기
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/report/pages/ReportPage.tsx b/src/features/report/pages/ReportPage.tsx
deleted file mode 100644
index dc5f694..0000000
--- a/src/features/report/pages/ReportPage.tsx
+++ /dev/null
@@ -1,144 +0,0 @@
-import { useMemo } from 'react';
-import { useParams, useLocation } from 'react-router';
-import { useReport } from '../hooks/useReport';
-import { useEnrichment } from '@/features/channels/hooks/useEnrichment';
-import { ReportNav } from '@/features/report/components/ReportNav';
-import { ScreenshotProvider } from '@/features/report/stores/ScreenshotContext';
-import { REPORT_SECTIONS } from '@/shared/constants/reportSections';
-
-import { SectionErrorBoundary } from '@/features/report/components/ui/SectionErrorBoundary';
-import ReportHeader from '@/features/report/components/ReportHeader';
-import ClinicSnapshot from '@/features/report/components/ClinicSnapshot';
-import ChannelOverview from '@/features/report/components/ChannelOverview';
-import YouTubeAudit from '@/features/report/components/YouTubeAudit';
-import InstagramAudit from '@/features/report/components/InstagramAudit';
-import FacebookAudit from '@/features/report/components/FacebookAudit';
-import OtherChannels from '@/features/report/components/OtherChannels';
-import ProblemDiagnosis from '@/features/report/components/ProblemDiagnosis';
-import TransformationProposal from '@/features/report/components/TransformationProposal';
-import RoadmapTimeline from '@/features/report/components/RoadmapTimeline';
-import KPIDashboard from '@/features/report/components/KPIDashboard';
-
-export default function ReportPage() {
- const { id } = useParams<{ id: string }>();
- const location = useLocation();
- const {
- data: baseData,
- isLoading,
- error,
- isEnriched,
- socialHandles: dbSocialHandles,
- } = useReport(id);
-
- // 보강 파라미터 생성 — 이미 보강된 경우(DB 데이터) 스킵
- const enrichmentParams = useMemo(() => {
- if (!baseData || isEnriched) return null;
-
- // 우선순위: location.state socialHandles > DB socialHandles > 변환된 데이터
- const state = location.state as Record