diff --git a/package-lock.json b/package-lock.json index f4df359..ccb0d19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,6 @@ "@tanstack/react-query": "^5.59.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "html2canvas-pro": "^2.0.2", - "jspdf": "^4.2.1", "ky": "^1.7.5", "lucide-react": "^0.546.0", "motion": "^12.23.24", @@ -334,15 +332,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -4070,19 +4059,6 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/pako": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", - "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", - "license": "MIT" - }, - "node_modules/@types/raf": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", - "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", - "license": "MIT", - "optional": true - }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -4103,13 +4079,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -4381,15 +4350,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/base64-arraybuffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", - "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/baseline-browser-mapping": { "version": "2.10.29", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", @@ -4541,26 +4501,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/canvg": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", - "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", - "license": "MIT", - "optional": true, - "dependencies": { - "@babel/runtime": "^7.12.5", - "@types/raf": "^3.4.0", - "core-js": "^3.8.3", - "raf": "^3.4.1", - "regenerator-runtime": "^0.13.7", - "rgbcolor": "^1.0.1", - "stackblur-canvas": "^2.0.0", - "svg-pathdata": "^6.0.3" - }, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4694,18 +4634,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/core-js": { - "version": "3.49.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", - "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4721,15 +4649,6 @@ "node": ">= 8" } }, - "node_modules/css-line-break": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", - "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", - "license": "MIT", - "dependencies": { - "utrie": "^1.0.2" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -4884,16 +4803,6 @@ "node": ">=8" } }, - "node_modules/dompurify": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz", - "integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optional": true, - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5267,17 +5176,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-png": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", - "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", - "license": "MIT", - "dependencies": { - "@types/pako": "^2.0.3", - "iobuffer": "^5.3.2", - "pako": "^2.1.0" - } - }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -5312,12 +5210,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "license": "MIT" - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5736,33 +5628,6 @@ "node": ">= 0.4" } }, - "node_modules/html2canvas": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", - "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", - "license": "MIT", - "optional": true, - "dependencies": { - "css-line-break": "^2.1.0", - "text-segmentation": "^1.0.3" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/html2canvas-pro": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-2.0.2.tgz", - "integrity": "sha512-9G/t0XgCZWonLwL0JwI7su6NdbOPUY7Ur4Ihpp8+XMaW9ibA2nDXF181Jr6tm94k8lX6sthpaXB3XqEnsMd5Cw==", - "license": "MIT", - "dependencies": { - "css-line-break": "^2.1.0", - "text-segmentation": "^1.0.3" - }, - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/http2-client": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/http2-client/-/http2-client-1.3.5.tgz", @@ -5823,12 +5688,6 @@ "node": ">= 0.4" } }, - "node_modules/iobuffer": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", - "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", - "license": "MIT" - }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -6374,23 +6233,6 @@ "node": "*" } }, - "node_modules/jspdf": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", - "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.6", - "fast-png": "^6.2.0", - "fflate": "^0.8.1" - }, - "optionalDependencies": { - "canvg": "^3.0.11", - "core-js": "^3.6.0", - "dompurify": "^3.3.1", - "html2canvas": "^1.0.0-rc.5" - } - }, "node_modules/ky": { "version": "1.14.3", "resolved": "https://registry.npmjs.org/ky/-/ky-1.14.3.tgz", @@ -7337,12 +7179,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pako": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", - "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", - "license": "(MIT AND Zlib)" - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7373,13 +7209,6 @@ "node": ">=8" } }, - "node_modules/performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", - "license": "MIT", - "optional": true - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7575,16 +7404,6 @@ } } }, - "node_modules/raf": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", - "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", - "license": "MIT", - "optional": true, - "dependencies": { - "performance-now": "^2.1.0" - } - }, "node_modules/react": { "version": "19.2.6", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", @@ -7754,13 +7573,6 @@ "url": "https://github.com/Mermade/oas-kit?sponsor=1" } }, - "node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "license": "MIT", - "optional": true - }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -7813,16 +7625,6 @@ "node": ">=0.10.0" } }, - "node_modules/rgbcolor": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", - "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", - "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", - "optional": true, - "engines": { - "node": ">= 0.8.15" - } - }, "node_modules/rollup": { "version": "4.60.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", @@ -8211,16 +8013,6 @@ "node": ">=0.10.0" } }, - "node_modules/stackblur-canvas": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", - "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.1.14" - } - }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -8355,16 +8147,6 @@ "node": ">=8" } }, - "node_modules/svg-pathdata": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", - "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/swagger2openapi": { "version": "7.0.8", "resolved": "https://registry.npmjs.org/swagger2openapi/-/swagger2openapi-7.0.8.tgz", @@ -8434,15 +8216,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/text-segmentation": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", - "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", - "license": "MIT", - "dependencies": { - "utrie": "^1.0.2" - } - }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -8849,15 +8622,6 @@ "node": ">= 4" } }, - "node_modules/utrie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", - "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", - "license": "MIT", - "dependencies": { - "base64-arraybuffer": "^1.0.2" - } - }, "node_modules/validator": { "version": "13.15.23", "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz", diff --git a/package.json b/package.json index ac71575..5b74307 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,6 @@ "@tanstack/react-query": "^5.59.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "html2canvas-pro": "^2.0.2", - "jspdf": "^4.2.1", "ky": "^1.7.5", "lucide-react": "^0.546.0", "motion": "^12.23.24", diff --git a/src/features/clinics/components/cards/AnalysisCard.tsx b/src/features/clinics/components/cards/AnalysisCard.tsx index 65fdd65..310f73c 100644 --- a/src/features/clinics/components/cards/AnalysisCard.tsx +++ b/src/features/clinics/components/cards/AnalysisCard.tsx @@ -7,7 +7,8 @@ * - 분석 대상 플랫폼 칩(URL/핸들) 표시 */ import { Link, useNavigate } from 'react-router'; -import { Calendar, ArrowUpRight, FileText, Sparkles } from 'lucide-react'; +import { Calendar, ArrowUpRight } from 'lucide-react'; +import { AppIcon } from '@/shared/icons/AppIcon'; import { PlatformChips } from '../PlatformChips'; import type { WorkspacePlan, WorkspaceRun } from '../../types/workspace'; @@ -106,7 +107,7 @@ export function AnalysisCard({ clinicId, run, plan, highlighted = false }: Analy onClick={stop} className="inline-flex items-center justify-center gap-1.5 flex-1 md:flex-none px-4 py-2 rounded-full text-xs font-semibold text-white bg-gradient-to-r from-[#4F1DA1] to-[#021341] shadow-sm hover:opacity-90 transition-all" > - + 리포트 보기 @@ -116,7 +117,7 @@ export function AnalysisCard({ clinicId, run, plan, highlighted = false }: Analy onClick={stop} className="inline-flex items-center justify-center gap-1.5 flex-1 md:flex-none px-4 py-2 rounded-full text-xs font-medium text-brand-purple bg-brand-tint-purple/60 border border-brand-tint-lavender hover:bg-brand-tint-purple transition-colors" > - + 기획 보기 diff --git a/src/features/clinics/data/mockClinicWorkspace.ts b/src/features/clinics/data/mockClinicWorkspace.ts index cbeb628..cbc1d8c 100644 --- a/src/features/clinics/data/mockClinicWorkspace.ts +++ b/src/features/clinics/data/mockClinicWorkspace.ts @@ -8,8 +8,8 @@ import type { WorkspaceData } from '../types/workspace'; export const mockWorkspace: WorkspaceData = { clinic: { - clinicId: 'view-gangnam', - name: '뷰성형외과', + clinicId: 'view-clinic', + name: '뷰성형외과의원', nameEn: 'VIEW Plastic Surgery', location: '서울 강남구 신사동', brandColor: '#4F1DA1', diff --git a/src/features/plan/components/BrandAppliedPreview.tsx b/src/features/plan/components/BrandAppliedPreview.tsx index 03eb5dd..92901d2 100644 --- a/src/features/plan/components/BrandAppliedPreview.tsx +++ b/src/features/plan/components/BrandAppliedPreview.tsx @@ -90,7 +90,7 @@ export function BrandAppliedPreview({ 등록된 컬러·타이포가 실제 콘텐츠에 적용됐을 때의 모습입니다.

-
+
{/* ── Instagram Post Mockup ─────────────────────────── */} + + {isExporting ? ( + <> + + 생성 중... + + ) : ( + <> + + 다운로드 + + + )} + + + exportPDF(filename)}> + + PDF로 저장 + + exportPlanCSV(filename, plan)}> + + CSV로 저장 + + + + ); +} diff --git a/src/features/plan/data/mockPlan_banobagi.ts b/src/features/plan/data/mockPlan_banobagi.ts index 18e13a6..8a4ea15 100644 --- a/src/features/plan/data/mockPlan_banobagi.ts +++ b/src/features/plan/data/mockPlan_banobagi.ts @@ -1,7 +1,7 @@ import type { MarketingPlan } from '@/features/plan/types/plan'; /** - * 바노바기성형외과 — 데모 마케팅 플랜 + * 바노바기성형외과 — 데모 마케팅 기획 * * 리포트 근거 (`mockReport_banobagi.ts` 2026-04-14 실측): * - YouTube @banobagips: 13K 구독자, 925 영상 (최근 6개월 업로드 저조) diff --git a/src/features/plan/data/mockPlan_grand.ts b/src/features/plan/data/mockPlan_grand.ts index 9743438..5a39bdb 100644 --- a/src/features/plan/data/mockPlan_grand.ts +++ b/src/features/plan/data/mockPlan_grand.ts @@ -1,7 +1,7 @@ import type { MarketingPlan } from '@/features/plan/types/plan'; /** - * 그랜드성형외과 — 데모 마케팅 플랜 + * 그랜드성형외과 — 데모 마케팅 기획 * * 리포트 근거 (`mockReport_grand.ts` 2026-04-14 실측): * - YouTube @grandsurgery_QnA: 2.37K 구독자, 332 영상, 업로드 ~0/week (사실상 중단) diff --git a/src/features/plan/data/mockPlan_irum.ts b/src/features/plan/data/mockPlan_irum.ts index 5c85fcc..807c669 100644 --- a/src/features/plan/data/mockPlan_irum.ts +++ b/src/features/plan/data/mockPlan_irum.ts @@ -1,7 +1,7 @@ import type { MarketingPlan } from '@/features/plan/types/plan'; /** - * 이룸성형외과 (서울아이 / Seoul i Plastic Surgery) — 맞춤 마케팅 플랜 + * 이룸성형외과 (서울아이 / Seoul i Plastic Surgery) — 맞춤 마케팅 기획 * * 리포트 근거 (mockReport_irum.ts, 2026-04-14 실측): * - YouTube @SEOULiPS.: 구독자 322명, 영상 155개 (성장 완전 정체) diff --git a/src/features/plan/data/mockPlan_o2o.ts b/src/features/plan/data/mockPlan_o2o.ts index 0cf8d75..416f5b5 100644 --- a/src/features/plan/data/mockPlan_o2o.ts +++ b/src/features/plan/data/mockPlan_o2o.ts @@ -1,7 +1,7 @@ import type { MarketingPlan } from '@/features/plan/types/plan'; /** - * O2O Clinic — 가상 데모 마케팅 플랜 (외부 노출 가능) + * O2O Clinic — 가상 데모 마케팅 기획 (외부 노출 가능) * * 의료광고법·개인정보 우려 없이 자유롭게 광고/마케팅 자료로 사용 가능한 * INFINITH 솔루션 데모 자산. 6개 실제 병원 분석 패턴의 베스트 프랙티스를 diff --git a/src/features/plan/data/mockPlan_ts.ts b/src/features/plan/data/mockPlan_ts.ts index 7230473..435e992 100644 --- a/src/features/plan/data/mockPlan_ts.ts +++ b/src/features/plan/data/mockPlan_ts.ts @@ -1,7 +1,7 @@ import type { MarketingPlan } from '@/features/plan/types/plan'; /** - * 티에스성형외과 — 데모 마케팅 플랜 + * 티에스성형외과 — 데모 마케팅 기획 * * 리포트 근거 (`mockReport_ts.ts` 2026-04-14 실측): * - YouTube 티에스TV @TV-jm9dy: 8K 구독자, 715 영상, 주 1~2회 업로드 diff --git a/src/features/plan/data/mockPlan_wonjin.ts b/src/features/plan/data/mockPlan_wonjin.ts index 3cec655..bf52b85 100644 --- a/src/features/plan/data/mockPlan_wonjin.ts +++ b/src/features/plan/data/mockPlan_wonjin.ts @@ -1,7 +1,7 @@ import type { MarketingPlan } from '@/features/plan/types/plan'; /** - * 원진성형외과 — 데모 마케팅 플랜 + * 원진성형외과 — 데모 마케팅 기획 * * 리포트 근거 (`mockReport_wonjin.ts` 2026-04-14 실측): * - YouTube @wjwonjin: 14.1K 구독자, 350 영상, 주 3~5회 (Shorts 활발) diff --git a/src/features/plan/hooks/useExportPlanCSV.ts b/src/features/plan/hooks/useExportPlanCSV.ts new file mode 100644 index 0000000..849fe68 --- /dev/null +++ b/src/features/plan/hooks/useExportPlanCSV.ts @@ -0,0 +1,294 @@ +import { useCallback } from 'react'; +import type { MarketingPlan } from '@/features/plan/types/plan'; + +/** + * 마케팅 기획의 표 데이터 전체를 단일 CSV로 내보내는 훅. + * 섹션마다 `=== Section Title ===` 헤더로 구분. + * Excel/Numbers에서 한글 안 깨지도록 UTF-8 BOM 포함. + */ + +type Cell = string | number | boolean | 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 buildPlanCsv(plan: MarketingPlan): string { + const sections: Section[] = []; + + // ─── 메타 ─── + sections.push({ + title: 'Plan Metadata', + rows: [ + ['Field', 'Value'], + ['Clinic Name', plan.clinicName], + ['Clinic Name (EN)', plan.clinicNameEn], + ['Target URL', plan.targetUrl], + ['Created At', plan.createdAt], + ['Plan ID', plan.id], + ['Source Report ID', plan.reportId], + ], + }); + + // ─── Brand Guide ─── + const bg = plan.brandGuide; + if (bg?.colors?.length) { + sections.push({ + title: 'Brand Guide: Colors', + rows: [ + ['Name', 'Hex', 'Usage'], + ...bg.colors.map((c) => [c.name, c.hex, c.usage]), + ], + }); + } + if (bg?.fonts?.length) { + sections.push({ + title: 'Brand Guide: Fonts', + rows: [ + ['Family', 'Weight', 'Usage', 'Sample Text'], + ...bg.fonts.map((f) => [f.family, f.weight, f.usage, f.sampleText]), + ], + }); + } + if (bg?.logoRules?.length) { + sections.push({ + title: 'Brand Guide: Logo Rules', + rows: [ + ['Rule', 'Description', 'Correct'], + ...bg.logoRules.map((r) => [r.rule, r.description, r.correct ? 'Y' : 'N']), + ], + }); + } + if (bg?.toneOfVoice) { + const t = bg.toneOfVoice; + sections.push({ + title: 'Brand Guide: Tone of Voice', + rows: [ + ['Field', 'Value'], + ['Personality', (t.personality ?? []).join(' / ')], + ['Communication Style', t.communicationStyle], + ['Do Examples', (t.doExamples ?? []).join(' | ')], + ['Dont Examples', (t.dontExamples ?? []).join(' | ')], + ], + }); + } + if (bg?.channelBranding?.length) { + sections.push({ + title: 'Brand Guide: Channel Branding', + rows: [ + ['Channel', 'Profile Photo', 'Banner Spec', 'Bio Template', 'Current Status'], + ...bg.channelBranding.map((c) => [ + c.channel, + c.profilePhoto, + c.bannerSpec, + c.bioTemplate, + c.currentStatus, + ]), + ], + }); + } + if (bg?.brandInconsistencies?.length) { + const rows: Row[] = [['Field', 'Channel', 'Value', 'Correct', 'Impact', 'Recommendation']]; + bg.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: 'Brand Guide: Brand Inconsistencies', rows }); + } + + // ─── Channel Strategies ─── + if (plan.channelStrategies?.length) { + sections.push({ + title: 'Channel Strategies', + rows: [ + ['Channel', 'Current Status', 'Target Goal', 'Content Types', 'Posting Frequency', 'Tone', 'Priority', 'Journey Stage'], + ...plan.channelStrategies.map((s) => [ + s.channelName, + s.currentStatus, + s.targetGoal, + (s.contentTypes ?? []).join(' | '), + s.postingFrequency, + s.tone, + s.priority, + s.customerJourneyStage ?? '', + ]), + ], + }); + } + + // ─── Content Strategy ─── + const cs = plan.contentStrategy; + if (cs?.pillars?.length) { + sections.push({ + title: 'Content Pillars', + rows: [ + ['Title', 'Description', 'Related USP', 'Example Topics'], + ...cs.pillars.map((p) => [p.title, p.description, p.relatedUSP, (p.exampleTopics ?? []).join(' | ')]), + ], + }); + } + if (cs?.typeMatrix?.length) { + sections.push({ + title: 'Content Type Matrix', + rows: [ + ['Format', 'Channels', 'Frequency', 'Purpose'], + ...cs.typeMatrix.map((t) => [t.format, (t.channels ?? []).join(' | '), t.frequency, t.purpose]), + ], + }); + } + if (cs?.workflow?.length) { + sections.push({ + title: 'Content Workflow', + rows: [ + ['Step', 'Name', 'Description', 'Owner', 'Duration'], + ...cs.workflow.map((w) => [w.step, w.name, w.description, w.owner, w.duration]), + ], + }); + } + if (cs?.repurposingOutputs?.length) { + sections.push({ + title: `Repurposing Outputs (source: ${cs.repurposingSource ?? ''})`, + rows: [ + ['Format', 'Channel', 'Description'], + ...cs.repurposingOutputs.map((r) => [r.format, r.channel, r.description]), + ], + }); + } + + // ─── Calendar (주차 → 일별 항목으로 풀어 펼침) ─── + const cal = plan.calendar; + if (cal?.weeks?.length) { + const rows: Row[] = [['Week', 'Day of Week', 'Channel', 'Content Type', 'Title', 'Description', 'Pillar', 'Status']]; + cal.weeks.forEach((w) => { + w.entries.forEach((e, i) => { + rows.push([ + i === 0 ? w.label : '', + e.dayOfWeek, + e.channel, + e.contentType, + e.title, + e.description ?? '', + e.pillar ?? '', + e.status ?? '', + ]); + }); + }); + sections.push({ title: 'Content Calendar', rows }); + } + if (cal?.monthlySummary?.length) { + sections.push({ + title: 'Monthly Content Summary', + rows: [ + ['Type', 'Label', 'Count'], + ...cal.monthlySummary.map((s) => [s.type, s.label, s.count]), + ], + }); + } + + // ─── Asset Collection ─── + const ac = plan.assetCollection; + if (ac?.assets?.length) { + sections.push({ + title: 'Asset Collection', + rows: [ + ['Source', 'Type', 'Title', 'Description', 'Status', 'Repurposing Suggestions'], + ...ac.assets.map((a) => [ + a.sourceLabel, + a.type, + a.title, + a.description, + a.status, + (a.repurposingSuggestions ?? []).join(' | '), + ]), + ], + }); + } + if (ac?.youtubeRepurpose?.length) { + sections.push({ + title: 'YouTube Repurpose Sources', + rows: [ + ['Title', 'Views', 'Type', 'Repurpose As'], + ...ac.youtubeRepurpose.map((y) => [y.title, y.views, y.type, (y.repurposeAs ?? []).join(' | ')]), + ], + }); + } + + // ─── Repurposing Proposals ─── + if (plan.repurposingProposals?.length) { + sections.push({ + title: 'Repurposing Proposals', + rows: [ + ['Source Video', 'Source Views', 'Source Type', 'Output Count', 'Priority', 'Estimated Effort'], + ...plan.repurposingProposals.map((p) => [ + p.sourceVideo.title, + p.sourceVideo.views, + p.sourceVideo.type, + p.outputs.length, + p.priority, + p.estimatedEffort, + ]), + ], + }); + } + + // ─── Workflow Items ─── + if (plan.workflow?.items?.length) { + sections.push({ + title: 'Workflow Items', + rows: [ + ['Title', 'Content Type', 'Channel', 'Stage', 'Scheduled Date', 'User Notes'], + ...plan.workflow.items.map((w) => [ + w.title, + w.contentType, + w.channel, + w.stage, + w.scheduledDate ?? '', + w.userNotes ?? '', + ]), + ], + }); + } + + // ─── 조립 ─── + 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 useExportPlanCSV() { + const exportPlanCSV = useCallback((filename: string, plan: MarketingPlan) => { + const csv = '' + buildPlanCsv(plan); // UTF-8 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 { exportPlanCSV }; +} diff --git a/src/features/plan/hooks/useMarketingPlan.ts b/src/features/plan/hooks/useMarketingPlan.ts index 13837c5..7d2df6b 100644 --- a/src/features/plan/hooks/useMarketingPlan.ts +++ b/src/features/plan/hooks/useMarketingPlan.ts @@ -30,6 +30,8 @@ interface UseMarketingPlanResult { data: MarketingPlan | null; isLoading: boolean; error: string | null; + /** DB row 의 clinic_id — 게스트 페이지에서 워크스페이스 점프 버튼 노출에 사용 */ + clinicId: string | null; } interface LocationState { @@ -96,6 +98,7 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [clinicId, setClinicId] = useState(null); const location = useLocation(); useEffect(() => { @@ -106,17 +109,20 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult } const state = location.state as LocationState | undefined; + const stateClinicId = state?.clinicId ?? null; async function loadPlan() { try { // ─── 개발 / 데모: mock 데이터를 즉시 반환 ─── if (id === 'demo') { setData(mockPlan); + setClinicId(null); setIsLoading(false); return; } if (id && id in DEMO_PLANS) { setData(DEMO_PLANS[id]); + setClinicId(null); setIsLoading(false); return; } @@ -129,13 +135,15 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult try { const planRes = await getPlan(id!); if (planRes.status === 200 && planRes.data) { + const planRow = planRes.data as unknown as Record; const plan = buildPlanFromContentPlans( - planRes.data as unknown as Record, + planRow, clinicName, clinicNameEn, targetUrl, ); setData(plan); + setClinicId((planRow.clinic_id as string) || stateClinicId); setIsLoading(false); return; } @@ -153,6 +161,7 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult created_at: (state.metadata.generatedAt as string) || new Date().toISOString(), }); setData(plan); + setClinicId(stateClinicId); setIsLoading(false); return; } @@ -168,6 +177,7 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult created_at: (reportRow.created_at as string) || new Date().toISOString(), }); setData(plan); + setClinicId((reportRow.clinic_id as string) || stateClinicId); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to fetch marketing plan'); } finally { @@ -178,5 +188,5 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult loadPlan(); }, [id, location.state]); - return { data, isLoading, error }; + return { data, isLoading, error, clinicId }; } diff --git a/src/features/plan/pages/GuestPlanPage.tsx b/src/features/plan/pages/GuestPlanPage.tsx index 193f6d3..4ba38e3 100644 --- a/src/features/plan/pages/GuestPlanPage.tsx +++ b/src/features/plan/pages/GuestPlanPage.tsx @@ -7,13 +7,13 @@ */ import { useEffect } from 'react'; import { Link, useParams, useLocation } from 'react-router'; -import { ArrowRight, FileSearch } from 'lucide-react'; +import { ArrowRight, ArrowLeft } from 'lucide-react'; +import { AppIcon } from '@/shared/icons/AppIcon'; import { useMarketingPlan } from '../hooks/useMarketingPlan'; import { ReportNav } from '@/features/report/components/ReportNav'; -import { PdfDownloadButton } from '@/features/report/components/PdfDownloadButton'; +import { PlanDownloadMenuButton } from '../components/PlanDownloadMenuButton'; import { PLAN_SECTIONS } from '@/shared/constants/planSections'; import PlanBody from '../components/PlanBody'; -import PlanCTA from '../components/PlanCTA'; export default function GuestPlanPage() { const { id } = useParams<{ id: string }>(); @@ -62,14 +62,23 @@ export default function GuestPlanPage() {
+ + 클리닉으로 + + } rightSlot={
- + - + 분석 리포트 보기 @@ -77,7 +86,6 @@ export default function GuestPlanPage() { } /> -
); } diff --git a/src/features/plan/pages/UserPlanPage.tsx b/src/features/plan/pages/UserPlanPage.tsx index cef6fff..f03851e 100644 --- a/src/features/plan/pages/UserPlanPage.tsx +++ b/src/features/plan/pages/UserPlanPage.tsx @@ -3,24 +3,28 @@ * * 계약된 병원 유저가 워크스페이스에서 운영하는 마케팅 기획 화면. * GuestPlanPage 의 본문 + 워크스페이스 액션바 + 인터랙티브 섹션 - * (MyAssetUpload / StrategyAdjustmentSection / WorkflowTracker). + * (MyAssetUpload / WorkflowTracker). + * + * NOTE: StrategyAdjustmentSection(성과 기반 전략 조정) 은 성과 데이터 파이프라인 의존 → + * 본 차수 미포함, 후속 모듈로 이관. 아래 import/render 주석 처리. */ import { useEffect } from 'react'; import { Link, useParams, useLocation } from 'react-router'; -import { ArrowLeft, FileSearch } from 'lucide-react'; +import { ArrowLeft } from 'lucide-react'; +import { AppIcon } from '@/shared/icons/AppIcon'; import { useMarketingPlan } from '../hooks/useMarketingPlan'; import { ReportNav } from '@/features/report/components/ReportNav'; -import { PdfDownloadButton } from '@/features/report/components/PdfDownloadButton'; +import { PlanDownloadMenuButton } from '../components/PlanDownloadMenuButton'; import { PLAN_SECTIONS } from '@/shared/constants/planSections'; import PlanBody from '../components/PlanBody'; import MyAssetUpload from '../components/MyAssetUpload'; -import StrategyAdjustmentSection from '../components/StrategyAdjustmentSection'; +// import StrategyAdjustmentSection from '../components/StrategyAdjustmentSection'; import WorkflowTracker from '../components/WorkflowTracker'; export default function UserPlanPage() { const { clinicId, id } = useParams<{ clinicId: string; id: string }>(); const location = useLocation(); - const stateClinicId = (location.state as { clinicId?: string } | undefined)?.clinicId || null; + // const stateClinicId = (location.state as { clinicId?: string } | undefined)?.clinicId || null; const { data, isLoading, error } = useMarketingPlan(id); useEffect(() => { @@ -77,18 +81,18 @@ export default function UserPlanPage() { className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-500 hover:text-primary-900 transition-colors" > - 워크스페이스로 + 클리닉으로 } rightSlot={
- + {reportTargetId && ( - + 기반 리포트 )} @@ -109,9 +113,10 @@ export default function UserPlanPage() {
-
+ {/* 성과 기반 전략 조정 — 본 차수 미포함, 후속 모듈 이관 */} + {/*
-
+
*/}
); } diff --git a/src/features/report/components/ClinicSnapshot.tsx b/src/features/report/components/ClinicSnapshot.tsx index f1641f1..d765fab 100644 --- a/src/features/report/components/ClinicSnapshot.tsx +++ b/src/features/report/components/ClinicSnapshot.tsx @@ -1,5 +1,5 @@ import { motion } from 'motion/react'; -import { Calendar, Users, MapPin, Phone, Award, Star, Globe, ExternalLink, Building2 } from 'lucide-react'; +import { Calendar, Users, MapPin, Phone, BadgeCheck, Star, Globe, ExternalLink, Building2 } from 'lucide-react'; import { SectionWrapper } from './ui/SectionWrapper'; import type { ClinicSnapshot as ClinicSnapshotType } from '@/features/report/types/report'; @@ -32,54 +32,10 @@ const infoFields = (data: ClinicSnapshotType): InfoField[] => [ export default function ClinicSnapshot({ data }: ClinicSnapshotProps) { const fields = infoFields(data); - const isVerified = data.source === 'registry'; return ( - {/* 외부 검증 배지는 내부 관리용이므로 리포트에서 제외 */} - - {/* Registry 외부 링크 (강남언니, 네이버 플레이스, 구글 맵) */} - {isVerified && (data.registryData?.naverPlaceUrl || data.registryData?.gangnamUnniUrl || data.registryData?.googleMapsUrl) && ( - - {data.registryData?.gangnamUnniUrl && ( - - 강남언니 - - )} - {data.registryData?.naverPlaceUrl && ( - - 네이버 플레이스 - - )} - {data.registryData?.googleMapsUrl && ( - - Google Maps - - )} - - )} - -
+
{fields.map((field, i) => { const Icon = field.icon; return ( @@ -127,7 +83,7 @@ export default function ClinicSnapshot({ data }: ClinicSnapshotProps) { transition={{ duration: 0.5, delay: 0.3 }} >
- +

대표 원장

{data.leadDoctor.name}

@@ -173,7 +129,7 @@ export default function ClinicSnapshot({ data }: ClinicSnapshotProps) { key={cert} className="inline-flex items-center gap-1.5 rounded-full bg-brand-tint-purple border border-brand-tint-lavender px-3 py-1 text-sm font-medium text-brand-purple-muted" > - + {cert} ))} diff --git a/src/features/report/components/PdfDownloadButton.tsx b/src/features/report/components/PdfDownloadButton.tsx deleted file mode 100644 index 2cbb227..0000000 --- a/src/features/report/components/PdfDownloadButton.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/** - * PdfDownloadButton — 리포트/플랜 PDF 다운로드 버튼. - * - * 브라우저 네이티브 인쇄 다이얼로그를 띄워 "PDF로 저장" 흐름을 사용합니다. - * `@media print` 규칙(`src/styles/custom.css`)이 레이아웃·색상·페이지 분할을 담당하며, - * `data-no-print`/`data-report-nav`/`data-plan-nav`/`data-cta-card`/`nav` 요소는 - * 인쇄에서 자동 제외됩니다. - */ -import { Download, Loader2 } from 'lucide-react'; -import { useExportPDF } from '@/features/report/hooks/useExportPDF'; - -interface PdfDownloadButtonProps { - /** PDF 파일명. 자동으로 .pdf 확장자 붙음. 'Plan' 포함 시 푸터 라벨도 변경 */ - filename: string; - /** 버튼 표시 라벨. 기본: PDF */ - label?: string; - className?: string; -} - -export function PdfDownloadButton({ - filename, - label = 'PDF', - className, -}: PdfDownloadButtonProps) { - const { exportPDF, isExporting } = useExportPDF(); - - return ( - - ); -} diff --git a/src/features/report/components/ReportNav.tsx b/src/features/report/components/ReportNav.tsx index d7a65d4..fd3d009 100644 --- a/src/features/report/components/ReportNav.tsx +++ b/src/features/report/components/ReportNav.tsx @@ -39,13 +39,14 @@ export function ReportNav({ sections, leftSlot, rightSlot }: ReportNavProps) { useEffect(() => { const activeTab = tabRefs.current.get(activeId); - if (activeTab && navRef.current) { - activeTab.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - inline: 'center', - }); - } + const navEl = navRef.current; + if (!activeTab || !navEl) return; + + // scrollIntoView 는 sticky 컨테이너에서 세로 스크롤까지 트리거하는 부작용이 있어 + // (초기 마운트 시 sticky 위치 계산 전 탭의 실제 y 로 페이지를 끌어내림 → 첫 섹션 점프) + // 가로 스크롤만 직접 제어합니다. + const targetLeft = activeTab.offsetLeft - (navEl.clientWidth - activeTab.offsetWidth) / 2; + navEl.scrollTo({ left: targetLeft, behavior: 'smooth' }); }, [activeId]); const handleClick = (id: string) => { @@ -61,7 +62,7 @@ export function ReportNav({ sections, leftSlot, rightSlot }: ReportNavProps) {