feat:05/14 UI/기능 변경건 커밋

main
Mina Choi 2026-05-14 17:06:33 +09:00
parent 49367756ea
commit 327a50bd41
69 changed files with 1175 additions and 1029 deletions

236
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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"
>
<FileText size={12} />
<AppIcon kind="report" size={12} />
<ArrowUpRight size={11} className="opacity-80" />
</Link>
@ -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"
>
<Sparkles size={12} />
<AppIcon kind="plan" size={12} />
</Link>
</div>

View File

@ -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',

View File

@ -90,7 +90,7 @@ export function BrandAppliedPreview({
· .
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-2xl">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-2xl items-start">
{/* ── Instagram Post Mockup ─────────────────────────── */}
<motion.figure
initial={{ opacity: 0, y: 12 }}

View File

@ -0,0 +1,64 @@
/**
* PlanDownloadMenuButton (PDF / CSV).
*
* PDF: (window.print) "PDF로 저장"
* CSV: CSV
*/
import { Download, FileText, FileSpreadsheet, ChevronDown, Loader2 } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from '@/shared/ui/dropdown-menu';
import { useExportPDF } from '@/features/report/hooks/useExportPDF';
import { useExportPlanCSV } from '@/features/plan/hooks/useExportPlanCSV';
import type { MarketingPlan } from '@/features/plan/types/plan';
interface PlanDownloadMenuButtonProps {
/** 파일명 베이스. 자동으로 .pdf / .csv 확장자 붙음 */
filename: string;
/** 서버에서 받아온 기획 전체 (CSV 생성에 사용) */
plan: MarketingPlan;
className?: string;
}
export function PlanDownloadMenuButton({ filename, plan, className }: PlanDownloadMenuButtonProps) {
const { exportPDF, isExporting } = useExportPDF();
const { exportPlanCSV } = useExportPlanCSV();
return (
<DropdownMenu>
<DropdownMenuTrigger
disabled={isExporting}
className={
className ??
'inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-xs font-medium text-slate-600 bg-slate-50 border border-slate-200 hover:bg-white hover:border-slate-300 transition-all disabled:opacity-60 disabled:cursor-wait'
}
>
{isExporting ? (
<>
<Loader2 size={12} className="animate-spin" />
...
</>
) : (
<>
<Download size={12} />
<ChevronDown size={12} className="opacity-70" />
</>
)}
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onSelect={() => exportPDF(filename)}>
<FileText size={14} className="text-slate-500" />
<span>PDF </span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => exportPlanCSV(filename, plan)}>
<FileSpreadsheet size={14} className="text-slate-500" />
<span>CSV </span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -1,7 +1,7 @@
import type { MarketingPlan } from '@/features/plan/types/plan';
/**
*
*
*
* (`mockReport_banobagi.ts` 2026-04-14 ):
* - YouTube @banobagips: 13K , 925 ( 6 )

View File

@ -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 ( )

View File

@ -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 ( )

View File

@ -1,7 +1,7 @@
import type { MarketingPlan } from '@/features/plan/types/plan';
/**
* O2O Clinic ( )
* O2O Clinic ( )
*
* · /
* INFINITH . 6

View File

@ -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

View File

@ -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 )

View File

@ -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 };
}

View File

@ -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<MarketingPlan | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [clinicId, setClinicId] = useState<string | null>(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<string, unknown>;
const plan = buildPlanFromContentPlans(
planRes.data as unknown as Record<string, unknown>,
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 };
}

View File

@ -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() {
<div className="pt-20">
<ReportNav
sections={PLAN_SECTIONS}
leftSlot={
<Link
to="/clinics/view-clinic"
className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-500 hover:text-primary-900 transition-colors"
>
<ArrowLeft size={14} />
</Link>
}
rightSlot={
<div className="flex items-center gap-2">
<PdfDownloadButton filename={`${data.clinicName}_Marketing_Plan`} />
<PlanDownloadMenuButton filename={`${data.clinicName}_Marketing_Plan`} plan={data} />
<Link
to={`/report/${id}`}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-full text-xs font-semibold text-slate-700 bg-white border border-slate-200 hover:border-slate-300 hover:bg-slate-50 transition-colors"
>
<FileSearch size={12} />
<AppIcon kind="report" size={12} />
<ArrowRight size={11} className="opacity-80" />
</Link>
@ -77,7 +86,6 @@ export default function GuestPlanPage() {
}
/>
<PlanBody data={data} />
<PlanCTA />
</div>
);
}

View File

@ -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"
>
<ArrowLeft size={14} />
</Link>
}
rightSlot={
<div className="flex items-center gap-2">
<PdfDownloadButton filename={`${data.clinicName}_Marketing_Plan`} />
<PlanDownloadMenuButton filename={`${data.clinicName}_Marketing_Plan`} plan={data} />
{reportTargetId && (
<Link
to={`/clinics/${clinicId}/report/${reportTargetId}`}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-xs font-medium text-slate-600 bg-slate-50 border border-slate-200 hover:bg-white hover:border-slate-300 transition-all"
>
<FileSearch size={12} />
<AppIcon kind="report" size={12} />
</Link>
)}
@ -109,9 +113,10 @@ export default function UserPlanPage() {
<MyAssetUpload />
</div>
<div data-no-print>
{/* 성과 기반 전략 조정 — 본 차수 미포함, 후속 모듈 이관 */}
{/* <div data-no-print>
<StrategyAdjustmentSection clinicId={clinicId ?? stateClinicId} planId={data.id} />
</div>
</div> */}
</div>
);
}

View File

@ -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 (
<SectionWrapper id="clinic-snapshot" title="Clinic Overview" subtitle="병원 기본 정보">
{/* 외부 검증 배지는 내부 관리용이므로 리포트에서 제외 */}
{/* Registry 외부 링크 (강남언니, 네이버 플레이스, 구글 맵) */}
{isVerified && (data.registryData?.naverPlaceUrl || data.registryData?.gangnamUnniUrl || data.registryData?.googleMapsUrl) && (
<motion.div
className="flex flex-wrap gap-2 mb-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
{data.registryData?.gangnamUnniUrl && (
<a
href={data.registryData.gangnamUnniUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg bg-pink-50 border border-pink-200 px-3 py-1.5 text-xs font-medium text-pink-700 hover:bg-pink-100 transition-colors"
>
<ExternalLink size={11} />
</a>
)}
{data.registryData?.naverPlaceUrl && (
<a
href={data.registryData.naverPlaceUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg bg-green-50 border border-green-200 px-3 py-1.5 text-xs font-medium text-green-700 hover:bg-green-100 transition-colors"
>
<ExternalLink size={11} />
</a>
)}
{data.registryData?.googleMapsUrl && (
<a
href={data.registryData.googleMapsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-50 border border-blue-200 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100 transition-colors"
>
Google Maps <ExternalLink size={11} />
</a>
)}
</motion.div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
{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 }}
>
<div className="flex items-center gap-2 mb-3">
<Award size={20} className="text-[#6C5CE7]" />
<BadgeCheck size={20} className="text-[#6C5CE7]" />
<h3 className="font-serif font-bold text-2xl text-[#0A1128]"> </h3>
</div>
<p className="text-xl font-bold text-[#0A1128] mb-1">{data.leadDoctor.name}</p>
@ -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"
>
<Award size={12} className="text-brand-purple" />
<BadgeCheck size={12} className="text-brand-purple" />
{cert}
</span>
))}

View File

@ -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 (
<button
type="button"
onClick={() => exportPDF(filename)}
disabled={isExporting}
className={
className ??
'inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-xs font-medium text-slate-600 bg-slate-50 border border-slate-200 hover:bg-white hover:border-slate-300 transition-all disabled:opacity-60 disabled:cursor-wait'
}
title="PDF로 다운로드"
>
{isExporting ? (
<>
<Loader2 size={12} className="animate-spin" />
...
</>
) : (
<>
<Download size={12} />
{label}
</>
)}
</button>
);
}

View File

@ -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) {
<nav data-report-nav className="sticky top-20 z-40 bg-white/95 border-b border-slate-100">
<PageContainer className="px-0 flex items-center gap-3">
{leftSlot && (
<div className="shrink-0 pl-4 md:pl-6" data-no-print>
<div className="shrink-0 pl-4 md:pl-6 pt-2" data-no-print>
{leftSlot}
</div>
)}

View File

@ -1,5 +1,5 @@
import { motion } from 'motion/react';
import { Youtube, Users, Video, Eye, TrendingUp, ExternalLink, ListVideo } from 'lucide-react';
import { Youtube, Users, Video, Eye, TrendingUp, ExternalLink, Play } from 'lucide-react';
import { SectionWrapper } from './ui/SectionWrapper';
import { EmptyState } from './ui/EmptyState';
import { MetricCard } from './ui/MetricCard';
@ -125,7 +125,7 @@ export default function YouTubeAudit({ data }: YouTubeAuditProps) {
key={pl}
className="inline-flex items-center gap-1.5 rounded-full bg-rose-50 border border-rose-200 px-3 py-1 text-sm font-medium text-rose-700"
>
<ListVideo size={12} className="text-rose-500" />
<Play size={12} className="text-rose-500 fill-rose-500" />
{pl}
</span>
))}

View File

@ -0,0 +1,309 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate, useLocation, useParams } from 'react-router';
import { useCreateClinic } from '@/shared/api/generated/clinics/clinics';
import { useStartAnalysis, getAnalysisStatus } from '@/shared/api/generated/analyses/analyses';
import { getReport } from '@/shared/api/generated/reports/reports';
export type ManualChannels = {
instagram?: string;
youtube?: string;
facebook?: string;
naverBlog?: string;
gangnamUnni?: string;
};
export type Phase =
| 'resuming'
| 'discovering'
| 'collecting'
| 'generating'
| 'planning'
| 'complete';
const SESSION_KEYS = {
reportId: 'infinith_reportId',
clinicId: 'infinith_clinicId',
runId: 'infinith_runId',
url: 'infinith_url',
};
function saveSession(data: { reportId: string; clinicId?: string; runId?: string; url?: string }) {
sessionStorage.setItem(SESSION_KEYS.reportId, data.reportId);
if (data.clinicId) sessionStorage.setItem(SESSION_KEYS.clinicId, data.clinicId);
if (data.runId) sessionStorage.setItem(SESSION_KEYS.runId, data.runId);
if (data.url) sessionStorage.setItem(SESSION_KEYS.url, data.url);
}
function loadSession() {
return {
reportId: sessionStorage.getItem(SESSION_KEYS.reportId),
clinicId: sessionStorage.getItem(SESSION_KEYS.clinicId),
runId: sessionStorage.getItem(SESSION_KEYS.runId),
url: sessionStorage.getItem(SESSION_KEYS.url),
};
}
function clearSession() {
Object.values(SESSION_KEYS).forEach((k) => sessionStorage.removeItem(k));
}
// 백엔드 AnalysisStatus enum: discovering | collecting | analyzing | completed | failed
// UI Phase 스텝과 매핑
function phaseFromStatus(s: string): Phase {
if (s === 'discovering') return 'collecting';
if (s === 'collecting') return 'generating';
if (s === 'analyzing') return 'planning';
if (s === 'completed') return 'complete';
return 'discovering';
}
function resumePhaseFromStatus(s: string): Phase {
if (s === 'discovering') return 'collecting';
if (s === 'collecting') return 'generating';
if (s === 'analyzing') return 'planning';
return 'discovering';
}
interface UseAnalysisPipelineResult {
phase: Phase;
error: string | null;
errorCode: string | null;
errorDomain: string | null;
errorDetails: Record<string, string> | null;
url: string | undefined;
sessionUrl: string | null;
handleRetry: () => void;
handleAbort: () => void;
}
export function useAnalysisPipeline(): UseAnalysisPipelineResult {
const [phase, setPhase] = useState<Phase>('discovering');
const [error, setError] = useState<string | null>(null);
const [errorCode, setErrorCode] = useState<string | null>(null);
const [errorDomain, setErrorDomain] = useState<string | null>(null);
const [errorDetails, setErrorDetails] = useState<Record<string, string> | null>(null);
const navigate = useNavigate();
const location = useLocation();
const { reportId: urlReportId } = useParams<{ reportId?: string }>();
const locState = (location.state as { url?: string; manualChannels?: ManualChannels }) ?? {};
const url = locState.url;
const manualChannels = locState.manualChannels;
const hasStarted = useRef(false);
const { mutateAsync: createClinicAsync } = useCreateClinic();
const { mutateAsync: startAnalysisAsync } = useStartAnalysis();
const runPipeline = useCallback(
async (
startUrl?: string,
resumeFrom?: { reportId: string; clinicId?: string; runId?: string; phase: Phase },
) => {
try {
let reportId = resumeFrom?.reportId || '';
let clinicId = resumeFrom?.clinicId;
let runId = resumeFrom?.runId;
// Phase 1: 신규 분석이면 createClinic → startAnalysis
if (!resumeFrom?.runId) {
if (!startUrl) throw new Error('No URL provided');
setPhase('discovering');
// 1-a) 클리닉 등록
const clinicRes = await createClinicAsync({ data: { url: startUrl } });
if (clinicRes.status !== 201) throw new Error('병원 등록에 실패했습니다.');
clinicId = clinicRes.data.id;
// 1-b) 분석 시작
const startRes = await startAnalysisAsync({
data: {
clinic_id: clinicId,
channels: {
youtube: manualChannels?.youtube ?? null,
instagram: manualChannels?.instagram ?? null,
facebook: manualChannels?.facebook ?? null,
naver_blog: manualChannels?.naverBlog ?? null,
gangnam_unni: manualChannels?.gangnamUnni ?? null,
},
},
});
if (startRes.status !== 202) throw new Error('분석 시작에 실패했습니다.');
runId = startRes.data.analysis_run_id;
reportId = runId; // 백엔드: analysis_run_id 가 report 조회 키와 동일
saveSession({ reportId, clinicId, runId, url: startUrl });
window.history.replaceState(null, '', `/report/loading/${reportId}`);
}
if (!runId) throw new Error('runId 없음');
// Phase 2-4: 상태 폴링 (1.5초 간격, 최대 ~3분)
const maxAttempts = 120;
let attempts = 0;
while (attempts < maxAttempts) {
attempts++;
const statusRes = await getAnalysisStatus(runId);
if (statusRes.status !== 200) throw new Error('분석 상태 조회에 실패했습니다.');
const statusData = statusRes.data;
setPhase(phaseFromStatus(statusData.status));
if (statusData.channel_errors && Object.keys(statusData.channel_errors).length > 0) {
console.warn('[pipeline] Partial failures:', statusData.channel_errors);
}
if (statusData.status === 'completed') break;
if (statusData.status === 'failed') {
throw new Error('파이프라인 실패: failed');
}
await new Promise((r) => setTimeout(r, 1500));
}
setPhase('complete');
clearSession();
// 최종 리포트 조회 (best effort)
const reportRes = await getReport(reportId).catch(() => null);
const reportPayload = reportRes && reportRes.status === 200 ? reportRes.data : null;
setTimeout(() => {
navigate(`/report/${reportId}`, {
replace: true,
state: reportPayload
? {
report: reportPayload,
metadata: {
url: startUrl,
clinicName: '',
generatedAt: new Date().toISOString(),
},
reportId,
clinicId,
}
: undefined,
});
}, 800);
} catch (err) {
const msg = err instanceof Error ? err.message : 'An error occurred';
setError(msg);
if (err && typeof err === 'object' && 'code' in err) {
setErrorCode((err as { code?: string }).code || null);
}
if (err && typeof err === 'object' && 'domain' in err) {
setErrorDomain((err as { domain?: string }).domain || null);
}
}
},
[navigate, manualChannels, createClinicAsync, startAnalysisAsync],
);
const handleRetry = useCallback(() => {
setError(null);
setErrorDetails(null);
const session = loadSession();
if (session.reportId) {
runPipeline(undefined, {
reportId: session.reportId,
clinicId: session.clinicId || undefined,
runId: session.runId || undefined,
phase,
});
} else if (url || session.url) {
hasStarted.current = false;
runPipeline(url || session.url || undefined);
}
}, [phase, url, runPipeline]);
const handleAbort = useCallback(() => {
clearSession();
navigate('/', { replace: true });
}, [navigate]);
useEffect(() => {
if (hasStarted.current) return;
hasStarted.current = true;
// 1. URL 파라미터 resume (예: /report/loading/abc-123)
if (urlReportId) {
setPhase('resuming');
const session = loadSession();
const runId = session.runId || urlReportId;
getAnalysisStatus(runId)
.then((res) => {
if (res.status !== 200) throw new Error('status fetch failed');
const status = res.data;
if (status.status === 'completed') {
navigate(`/report/${urlReportId}`, { replace: true });
return;
}
if (status.status === 'failed') {
setError('Data collection failed. Please retry.');
setPhase('collecting');
return;
}
saveSession({
reportId: urlReportId,
clinicId: session.clinicId || undefined,
runId,
url: session.url || undefined,
});
runPipeline(undefined, {
reportId: urlReportId,
clinicId: session.clinicId || undefined,
runId,
phase: resumePhaseFromStatus(status.status),
});
})
.catch(() => {
setError('Could not resume analysis. Please try again.');
});
return;
}
// 2. sessionStorage resume
const session = loadSession();
if (session.reportId && !url) {
setPhase('resuming');
const runId = session.runId || session.reportId;
getAnalysisStatus(runId)
.then((res) => {
if (res.status !== 200) throw new Error('status fetch failed');
const status = res.data;
if (status.status === 'completed') {
clearSession();
navigate(`/report/${session.reportId}`, { replace: true });
return;
}
runPipeline(undefined, {
reportId: session.reportId!,
clinicId: session.clinicId || undefined,
runId,
phase: resumePhaseFromStatus(status.status),
});
})
.catch(() => {
clearSession();
navigate('/', { replace: true });
});
return;
}
// 3. URL 없이 진입 → 홈으로
if (!url) {
navigate('/', { replace: true });
return;
}
// 4. 신규 분석 시작
runPipeline(url);
}, [url, urlReportId, navigate, runPipeline]);
return {
phase,
error,
errorCode,
errorDomain,
errorDetails,
url,
sessionUrl: loadSession().url,
handleRetry,
handleAbort,
};
}

View File

@ -40,6 +40,8 @@ interface UseReportResult {
isEnriched: boolean;
/** DB 또는 API 메타데이터에서 복원한 정규화된 소셜 핸들 */
socialHandles: Record<string, string | null> | null;
/** DB row의 clinic_id (FK). 게스트 페이지에서 워크스페이스 점프 버튼 노출에 사용 */
clinicId: string | null;
}
interface LocationState {
@ -61,6 +63,7 @@ export function useReport(id: string | undefined): UseReportResult {
const [error, setError] = useState<string | null>(null);
const [isEnriched, setIsEnriched] = useState(false);
const [socialHandles, setSocialHandles] = useState<Record<string, string | null> | null>(null);
const [clinicId, setClinicId] = useState<string | null>(null);
const location = useLocation();
useEffect(() => {
@ -69,11 +72,13 @@ export function useReport(id: string | undefined): UseReportResult {
setData(DEMO_REPORTS[id]);
setIsEnriched(true);
setSocialHandles(DEMO_HANDLES[id] ?? null);
setClinicId(null); // 데모는 워크스페이스 연결 없음
setIsLoading(false);
return;
}
const state = location.state as LocationState | undefined;
const stateClinicId = (state as Record<string, unknown> | undefined)?.clinicId as string | undefined;
// Source 1: 네비게이션 state로 전달된 리포트 데이터 (AnalysisLoadingPage에서)
if (state?.report && state?.metadata) {
@ -84,6 +89,7 @@ export function useReport(id: string | undefined): UseReportResult {
state.report,
state.metadata,
);
setClinicId(stateClinicId ?? null);
// V2 파이프라인: 리포트에 Phase 2의 channelEnrichment 이미 포함됨
const enrichment = state.report.channelEnrichment as EnrichmentData | undefined;
if (enrichment) {
@ -112,6 +118,7 @@ export function useReport(id: string | undefined): UseReportResult {
}
const row = res.data as unknown as Record<string, unknown>;
const reportJson = (row.report as Record<string, unknown>) || row;
setClinicId((row.clinic_id as string) || stateClinicId || null);
// V2 파이프라인: 리포트가 비어있지만 status가 'complete'가 아니면 메시지 표시
if (!reportJson || Object.keys(reportJson).length === 0 || reportJson.parseError) {
@ -191,5 +198,5 @@ export function useReport(id: string | undefined): UseReportResult {
setIsLoading(false);
}, [id, location.state]);
return { data, isLoading, error, isEnriched, socialHandles };
return { data, isLoading, error, isEnriched, socialHandles, clinicId };
}

View File

@ -15,6 +15,8 @@ interface UseReportPageDataResult {
isLoading: boolean;
error: string | null;
enrichStatus: ReturnType<typeof useEnrichment>['status'];
/** DB row 의 clinic_id — 게스트 페이지에서 워크스페이스 점프 버튼 노출에 사용 */
clinicId: string | null;
}
export function useReportPageData(id: string | undefined): UseReportPageDataResult {
@ -25,6 +27,7 @@ export function useReportPageData(id: string | undefined): UseReportPageDataResu
error,
isEnriched,
socialHandles: dbSocialHandles,
clinicId,
} = useReport(id);
const enrichmentParams = useMemo(() => {
@ -62,5 +65,6 @@ export function useReportPageData(id: string | undefined): UseReportPageDataResu
isLoading,
error,
enrichStatus,
clinicId,
};
}

View File

@ -1,266 +1,31 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate, useLocation, useParams } from 'react-router';
import { motion } from 'motion/react';
import { Check, AlertCircle, RefreshCw, ShieldX } from 'lucide-react';
import { useStartAnalysis, getAnalysisStatus } from '@/shared/api/generated/analyses/analyses';
import { getReport } from '@/shared/api/generated/reports/reports';
import { useAnalysisPipeline, type Phase } from '../hooks/useAnalysisPipeline';
// 분석 파이프라인은 단일 startAnalysis + 폴링 모델로 통합됨.
// 기존의 discoverChannels / collectChannelData / generateReportV2 / generateContentPlan
// 단계는 백엔드(FastAPI) 비동기 워커가 수행하므로 여기서는 startAnalysis 1회 호출 후
// /api/analyses/{run_id}/status 폴링으로 대체.
export type ManualChannels = {
instagram?: string;
youtube?: string;
facebook?: string;
};
type Phase = 'resuming' | 'discovering' | 'collecting' | 'generating' | 'planning' | 'complete';
const PHASE_STEPS = [
{ key: 'discovering' as Phase, label: 'Scanning website & discovering channels...', labelDone: 'Channels discovered' },
{ key: 'collecting' as Phase, label: 'Collecting channel data & market analysis...', labelDone: 'Data collected' },
{ key: 'generating' as Phase, label: 'Generating AI marketing report...', labelDone: 'Report generated' },
{ key: 'planning' as Phase, label: 'Generating AI content strategy...', labelDone: 'Content plan generated' },
{ key: 'complete' as Phase, label: 'Finalizing report...', labelDone: 'Complete' },
const PHASE_STEPS: { key: Phase; label: string; labelDone: string }[] = [
{ key: 'discovering', label: 'Scanning website & discovering channels...', labelDone: 'Channels discovered' },
{ key: 'collecting', label: 'Collecting channel data & market analysis...', labelDone: 'Data collected' },
{ key: 'generating', label: 'Generating AI marketing report...', labelDone: 'Report generated' },
{ key: 'planning', label: 'Generating AI content strategy...', labelDone: 'Content plan generated' },
{ key: 'complete', label: 'Finalizing report...', labelDone: 'Complete' },
];
// 파이프라인 resume용 세션 키
const SESSION_KEYS = {
reportId: 'infinith_reportId',
clinicId: 'infinith_clinicId',
runId: 'infinith_runId',
url: 'infinith_url',
};
function saveSession(data: { reportId: string; clinicId?: string; runId?: string; url?: string }) {
sessionStorage.setItem(SESSION_KEYS.reportId, data.reportId);
if (data.clinicId) sessionStorage.setItem(SESSION_KEYS.clinicId, data.clinicId);
if (data.runId) sessionStorage.setItem(SESSION_KEYS.runId, data.runId);
if (data.url) sessionStorage.setItem(SESSION_KEYS.url, data.url);
}
function loadSession() {
return {
reportId: sessionStorage.getItem(SESSION_KEYS.reportId),
clinicId: sessionStorage.getItem(SESSION_KEYS.clinicId),
runId: sessionStorage.getItem(SESSION_KEYS.runId),
url: sessionStorage.getItem(SESSION_KEYS.url),
};
}
function clearSession() {
Object.values(SESSION_KEYS).forEach(k => sessionStorage.removeItem(k));
}
export default function AnalysisLoadingPage() {
const [phase, setPhase] = useState<Phase>('discovering');
const [error, setError] = useState<string | null>(null);
const [errorCode, setErrorCode] = useState<string | null>(null);
const [errorDomain, setErrorDomain] = useState<string | null>(null);
const [errorDetails, setErrorDetails] = useState<Record<string, string> | null>(null);
const navigate = useNavigate();
const location = useLocation();
const { reportId: urlReportId } = useParams<{ reportId?: string }>();
const locState = (location.state as {
url?: string;
manualChannels?: ManualChannels;
}) ?? {};
const url = locState.url;
const manualChannels = locState.manualChannels;
const hasStarted = useRef(false);
const { mutateAsync: startAnalysisAsync } = useStartAnalysis();
const phaseIndex = PHASE_STEPS.findIndex(s => s.key === phase);
const runPipeline = useCallback(async (
startUrl?: string,
resumeFrom?: { reportId: string; clinicId?: string; runId?: string; phase: Phase },
) => {
try {
let reportId = resumeFrom?.reportId || '';
let clinicId = resumeFrom?.clinicId;
let runId = resumeFrom?.runId;
// Phase 1: 백엔드 분석 시작 (기존 runId로 resume 중이면 스킵)
if (!resumeFrom?.runId) {
if (!startUrl) throw new Error('No URL provided');
setPhase('discovering');
const startRes = await startAnalysisAsync({
data: {
url: startUrl,
// manualChannels는 백엔드에서 verified_channels로 주입
manual_channels: manualChannels,
} as unknown as Parameters<typeof startAnalysisAsync>[0]['data'],
});
if (startRes.status !== 202) {
throw new Error('분석 시작에 실패했습니다.');
}
const startData = startRes.data as unknown as { run_id?: string; report_id?: string; clinic_id?: string };
runId = startData.run_id || '';
reportId = startData.report_id || runId;
clinicId = startData.clinic_id;
saveSession({ reportId, clinicId, runId, url: startUrl });
window.history.replaceState(null, '', `/report/loading/${reportId}`);
}
if (!runId) throw new Error('runId 없음');
// Phase 2-4: 완료될 때까지 상태 폴링
const phaseFromStatus = (s: string): Phase => {
if (['discovering', 'discovered'].includes(s)) return 'collecting';
if (['collecting', 'collected', 'partial'].includes(s)) return 'generating';
if (['generating'].includes(s)) return 'planning';
if (['complete'].includes(s)) return 'complete';
return 'discovering';
};
// backoff 적용 폴링 루프
const maxAttempts = 120; // ~2분
let attempts = 0;
while (attempts < maxAttempts) {
attempts++;
const statusRes = await getAnalysisStatus(runId);
if (statusRes.status !== 200) {
throw new Error('분석 상태 조회에 실패했습니다.');
}
const statusData = statusRes.data as unknown as { status: string; channel_errors?: Record<string, string> };
const next = phaseFromStatus(statusData.status);
setPhase(next);
if (statusData.channel_errors && Object.keys(statusData.channel_errors).length > 0) {
console.warn('[pipeline] Partial failures:', statusData.channel_errors);
}
if (statusData.status === 'complete') break;
if (statusData.status === 'failed' || statusData.status === 'collection_failed') {
throw new Error(`파이프라인 실패: ${statusData.status}`);
}
await new Promise((r) => setTimeout(r, 1500));
}
setPhase('complete');
clearSession();
// 최종 리포트 조회 (best effort)
const reportRes = await getReport(reportId).catch(() => null);
const reportPayload = reportRes && reportRes.status === 200 ? reportRes.data : null;
setTimeout(() => {
navigate(`/report/${reportId}`, {
replace: true,
state: reportPayload
? { report: reportPayload, metadata: { url: startUrl, clinicName: '', generatedAt: new Date().toISOString() }, reportId, clinicId }
: undefined,
});
}, 800);
} catch (err) {
const msg = err instanceof Error ? err.message : 'An error occurred';
setError(msg);
if (err && typeof err === 'object' && 'code' in err) {
setErrorCode((err as { code?: string }).code || null);
}
if (err && typeof err === 'object' && 'domain' in err) {
setErrorDomain((err as { domain?: string }).domain || null);
}
}
}, [navigate, manualChannels, startAnalysisAsync]);
// 현재 실패한 단계부터 재시도
const handleRetry = useCallback(() => {
setError(null);
setErrorDetails(null);
const session = loadSession();
if (session.reportId) {
// 실패한 단계부터 resume
runPipeline(undefined, {
reportId: session.reportId,
clinicId: session.clinicId || undefined,
runId: session.runId || undefined,
const {
phase,
});
} else if (url || session.url) {
// 처음부터 재시작
hasStarted.current = false;
runPipeline(url || session.url || undefined);
}
}, [phase, url, runPipeline]);
error,
errorCode,
errorDomain,
errorDetails,
url,
sessionUrl,
handleRetry,
handleAbort,
} = useAnalysisPipeline();
useEffect(() => {
if (hasStarted.current) return;
hasStarted.current = true;
// 1. URL 파라미터 resume 시도 (예: /report/loading/abc-123)
if (urlReportId) {
setPhase('resuming');
const session = loadSession();
const runId = session.runId || urlReportId;
getAnalysisStatus(runId)
.then((res) => {
if (res.status !== 200) throw new Error('status fetch failed');
const status = res.data as unknown as { status: string };
if (status.status === 'complete') {
navigate(`/report/${urlReportId}`, { replace: true });
return;
}
if (status.status === 'collection_failed') {
setError('Data collection failed. Please retry.');
setPhase('collecting');
return;
}
let resumePhase: Phase = 'discovering';
if (['discovered', 'discovering'].includes(status.status)) resumePhase = 'collecting';
else if (['collecting', 'collected', 'partial'].includes(status.status)) resumePhase = 'generating';
saveSession({ reportId: urlReportId, clinicId: session.clinicId || undefined, runId, url: session.url || undefined });
runPipeline(undefined, { reportId: urlReportId, clinicId: session.clinicId || undefined, runId, phase: resumePhase });
})
.catch(() => {
setError('Could not resume analysis. Please try again.');
});
return;
}
// 2. sessionStorage resume 시도
const session = loadSession();
if (session.reportId && !url) {
setPhase('resuming');
const runId = session.runId || session.reportId;
getAnalysisStatus(runId)
.then((res) => {
if (res.status !== 200) throw new Error('status fetch failed');
const status = res.data as unknown as { status: string };
if (status.status === 'complete') {
clearSession();
navigate(`/report/${session.reportId}`, { replace: true });
return;
}
let resumePhase: Phase = 'discovering';
if (['discovered', 'discovering'].includes(status.status)) resumePhase = 'collecting';
else if (['collecting', 'collected', 'partial'].includes(status.status)) resumePhase = 'generating';
runPipeline(undefined, {
reportId: session.reportId!,
clinicId: session.clinicId || undefined,
runId,
phase: resumePhase,
});
})
.catch(() => {
clearSession();
navigate('/', { replace: true });
});
return;
}
// 3. URL로 처음부터 시작
if (!url) {
navigate('/', { replace: true });
return;
}
runPipeline(url);
}, [url, urlReportId, navigate, runPipeline]);
// 'resuming' 상태일 때 phaseIndex 조정
const phaseIndex = PHASE_STEPS.findIndex((s) => s.key === phase);
const displayPhaseIndex = phase === 'resuming' ? -1 : phaseIndex;
const displayUrl = url || sessionUrl;
return (
<div className="relative min-h-screen bg-primary-900 flex flex-col items-center justify-center px-6 overflow-hidden">
@ -282,14 +47,14 @@ export default function AnalysisLoadingPage() {
INFINITH
</motion.h1>
{(url || loadSession().url) && (
{displayUrl && (
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.1 }}
className="text-purple-300/80 text-sm font-mono mb-12 truncate max-w-full"
>
{url || loadSession().url}
{displayUrl}
</motion.p>
)}
@ -309,7 +74,7 @@ export default function AnalysisLoadingPage() {
.
</p>
<button
onClick={() => { clearSession(); navigate('/', { replace: true }); }}
onClick={handleAbort}
className="px-6 py-2 text-sm font-medium text-white bg-purple-600/30 rounded-lg hover:bg-purple-600/50 transition-colors"
>
@ -344,7 +109,7 @@ export default function AnalysisLoadingPage() {
Retry
</button>
<button
onClick={() => { clearSession(); navigate('/', { replace: true }); }}
onClick={handleAbort}
className="px-6 py-2 text-sm font-medium text-white bg-white/10 rounded-lg hover:bg-white/20 transition-colors"
>
Start Over
@ -352,9 +117,7 @@ export default function AnalysisLoadingPage() {
</div>
</motion.div>
)
) : (
<>
{phase === 'resuming' ? (
) : phase === 'resuming' ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@ -367,7 +130,8 @@ export default function AnalysisLoadingPage() {
<>
<div className="w-full space-y-5 mb-14">
{PHASE_STEPS.map((step, index) => {
const isCompleted = displayPhaseIndex > index || (step.key === 'complete' && phase === 'complete');
const isCompleted =
displayPhaseIndex > index || (step.key === 'complete' && phase === 'complete');
const isActive = displayPhaseIndex === index && phase !== 'complete';
return (
@ -409,7 +173,9 @@ export default function AnalysisLoadingPage() {
<div className="w-full h-2 bg-white/10 rounded-full overflow-hidden">
<motion.div
initial={{ width: '0%' }}
animate={{ width: `${((displayPhaseIndex + (phase === 'complete' ? 1 : 0.5)) / PHASE_STEPS.length) * 100}%` }}
animate={{
width: `${((displayPhaseIndex + (phase === 'complete' ? 1 : 0.5)) / PHASE_STEPS.length) * 100}%`,
}}
transition={{ duration: 0.8, ease: 'easeInOut' }}
className="h-full bg-gradient-to-r from-[#4F1DA1] to-[#6C5CE7] rounded-full"
/>
@ -420,8 +186,6 @@ export default function AnalysisLoadingPage() {
</p>
</>
)}
</>
)}
</div>
</div>
);

View File

@ -6,7 +6,8 @@
* .
*/
import { Link, useParams } from 'react-router';
import { ArrowRight, Sparkles } from 'lucide-react';
import { ArrowRight, ArrowLeft } from 'lucide-react';
import { AppIcon } from '@/shared/icons/AppIcon';
import { useReportPageData } from '../hooks/useReportPageData';
import { ReportNav } from '../components/ReportNav';
import { ScreenshotProvider } from '../stores/ScreenshotContext';
@ -46,6 +47,15 @@ export default function GuestReportPage() {
<div className="pt-20">
<ReportNav
sections={REPORT_SECTIONS}
leftSlot={
<Link
to="/clinics/view-clinic"
className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-500 hover:text-primary-900 transition-colors"
>
<ArrowLeft size={14} />
</Link>
}
rightSlot={
<div className="flex items-center gap-2">
<DownloadMenuButton
@ -56,7 +66,7 @@ export default function GuestReportPage() {
to={`/plan/${id}`}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-full text-xs font-semibold text-brand-purple bg-brand-tint-purple/60 border border-brand-tint-lavender hover:bg-brand-tint-purple transition-colors"
>
<Sparkles size={12} />
<AppIcon kind="plan" size={12} />
<ArrowRight size={11} className="opacity-80" />
</Link>

View File

@ -7,7 +7,8 @@
* - CTA
*/
import { Link, useParams } from 'react-router';
import { ArrowLeft, Sparkles, RefreshCw } from 'lucide-react';
import { ArrowLeft, RefreshCw } from 'lucide-react';
import { AppIcon } from '@/shared/icons/AppIcon';
import { useReportPageData } from '../hooks/useReportPageData';
import { ReportNav } from '../components/ReportNav';
import { ScreenshotProvider } from '../stores/ScreenshotContext';
@ -52,7 +53,7 @@ export default function UserReportPage() {
className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-500 hover:text-primary-900 transition-colors"
>
<ArrowLeft size={14} />
</Link>
}
rightSlot={
@ -72,7 +73,7 @@ export default function UserReportPage() {
to={`/clinics/${clinicId}/plan/${id}`}
className="inline-flex items-center gap-1.5 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"
>
<Sparkles size={12} />
<AppIcon kind="plan" size={12} />
</Link>
</div>

View File

@ -8,6 +8,8 @@
*/
import ky, { type KyInstance } from 'ky'
const API_KEY = import.meta.env.VITE_API_KEY as string | undefined;
export const kyInstance: KyInstance = ky.create({
timeout: 10_000,
retry: 1,
@ -15,6 +17,9 @@ export const kyInstance: KyInstance = ky.create({
throwHttpErrors: false,
hooks: {
beforeRequest: [
(request) => {
if (API_KEY) request.headers.set('x-api-key', API_KEY);
},
// TODO: 인증 토큰 주입
// request => {
// const token = localStorage.getItem('token')

View File

@ -1,120 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* FastAPI
* OpenAPI spec version: 0.1.0
*/
import {
useMutation
} from '@tanstack/react-query';
import type {
MutationFunction,
QueryClient,
UseMutationOptions,
UseMutationResult
} from '@tanstack/react-query';
import type {
ChannelVerifyRequest,
ChannelVerifyResponse,
HTTPValidationError
} from '../../model';
import { customFetcher } from '../../api';
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* @summary Verify Channels
*/
export type verifyChannelsResponse200 = {
data: ChannelVerifyResponse
status: 200
}
export type verifyChannelsResponse422 = {
data: HTTPValidationError
status: 422
}
export type verifyChannelsResponseSuccess = (verifyChannelsResponse200) & {
headers: Headers;
};
export type verifyChannelsResponseError = (verifyChannelsResponse422) & {
headers: Headers;
};
export type verifyChannelsResponse = (verifyChannelsResponseSuccess | verifyChannelsResponseError)
export const getVerifyChannelsUrl = () => {
return `/api/channels/verify`
}
export const verifyChannels = async (channelVerifyRequest: ChannelVerifyRequest, options?: RequestInit): Promise<verifyChannelsResponse> => {
return customFetcher<verifyChannelsResponse>(getVerifyChannelsUrl(),
{
...options,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(
channelVerifyRequest,)
}
);}
export const getVerifyChannelsMutationOptions = <TError = HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof verifyChannels>>, TError,{data: ChannelVerifyRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}
): UseMutationOptions<Awaited<ReturnType<typeof verifyChannels>>, TError,{data: ChannelVerifyRequest}, TContext> => {
const mutationKey = ['verifyChannels'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof verifyChannels>>, {data: ChannelVerifyRequest}> = (props) => {
const {data} = props ?? {};
return verifyChannels(data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type VerifyChannelsMutationResult = NonNullable<Awaited<ReturnType<typeof verifyChannels>>>
export type VerifyChannelsMutationBody = ChannelVerifyRequest
export type VerifyChannelsMutationError = HTTPValidationError
/**
* @summary Verify Channels
*/
export const useVerifyChannels = <TError = HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof verifyChannels>>, TError,{data: ChannelVerifyRequest}, TContext>, request?: SecondParameter<typeof customFetcher>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof verifyChannels>>,
TError,
{data: ChannelVerifyRequest},
TContext
> => {
const mutationOptions = getVerifyChannelsMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}

View File

@ -27,6 +27,7 @@ import type {
ClinicCreate,
ClinicCreateResponse,
ClinicHistoryResponse,
ClinicResponse,
HTTPValidationError
} from '../../model';
@ -128,6 +129,125 @@ export const useCreateClinic = <TError = HTTPValidationError,
return useMutation(mutationOptions, queryClient);
}
/**
* @summary Get Clinic
*/
export type getClinicResponse200 = {
data: ClinicResponse
status: 200
}
export type getClinicResponse422 = {
data: HTTPValidationError
status: 422
}
export type getClinicResponseSuccess = (getClinicResponse200) & {
headers: Headers;
};
export type getClinicResponseError = (getClinicResponse422) & {
headers: Headers;
};
export type getClinicResponse = (getClinicResponseSuccess | getClinicResponseError)
export const getGetClinicUrl = (hospitalId: string,) => {
return `/api/clinics/${hospitalId}`
}
export const getClinic = async (hospitalId: string, options?: RequestInit): Promise<getClinicResponse> => {
return customFetcher<getClinicResponse>(getGetClinicUrl(hospitalId),
{
...options,
method: 'GET'
}
);}
export const getGetClinicQueryKey = (hospitalId?: string,) => {
return [
`/api/clinics/${hospitalId}`
] as const;
}
export const getGetClinicQueryOptions = <TData = Awaited<ReturnType<typeof getClinic>>, TError = HTTPValidationError>(hospitalId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getClinic>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetClinicQueryKey(hospitalId);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getClinic>>> = () => getClinic(hospitalId, requestOptions);
return { queryKey, queryFn, enabled: !!(hospitalId), staleTime: 60000, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getClinic>>, TError, TData> & { queryKey: DataTag<QueryKey, TData> }
}
export type GetClinicQueryResult = NonNullable<Awaited<ReturnType<typeof getClinic>>>
export type GetClinicQueryError = HTTPValidationError
export function useGetClinic<TData = Awaited<ReturnType<typeof getClinic>>, TError = HTTPValidationError>(
hospitalId: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getClinic>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getClinic>>,
TError,
Awaited<ReturnType<typeof getClinic>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetcher>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData> }
export function useGetClinic<TData = Awaited<ReturnType<typeof getClinic>>, TError = HTTPValidationError>(
hospitalId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getClinic>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getClinic>>,
TError,
Awaited<ReturnType<typeof getClinic>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetcher>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData> }
export function useGetClinic<TData = Awaited<ReturnType<typeof getClinic>>, TError = HTTPValidationError>(
hospitalId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getClinic>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData> }
/**
* @summary Get Clinic
*/
export function useGetClinic<TData = Awaited<ReturnType<typeof getClinic>>, TError = HTTPValidationError>(
hospitalId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getClinic>>, TError, TData>>, request?: SecondParameter<typeof customFetcher>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData> } {
const queryOptions = getGetClinicQueryOptions(hospitalId,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData> };
query.queryKey = queryOptions.queryKey ;
return query;
}
/**
* @summary Get Clinic History
*/
export type getClinicHistoryResponse200 = {

View File

@ -20,8 +20,8 @@ import type {
} from '@tanstack/react-query';
import type {
HTTPValidationError,
ReportResponse
GetReport200,
HTTPValidationError
} from '../../model';
import { customFetcher } from '../../api';
@ -35,7 +35,7 @@ type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
* @summary Get Report
*/
export type getReportResponse200 = {
data: ReportResponse
data: GetReport200
status: 200
}

View File

@ -4,14 +4,11 @@
* FastAPI
* OpenAPI spec version: 0.1.0
*/
import type { AnalysisCreateClinicId } from './analysisCreateClinicId';
import type { AnalysisCreateUrl } from './analysisCreateUrl';
import type { Channels } from './channels';
import type { AnalysisOptions } from './analysisOptions';
export interface AnalysisCreate {
clinic_id?: AnalysisCreateClinicId;
url?: AnalysisCreateUrl;
clinic_id: string;
channels: Channels;
options?: AnalysisOptions;
}

View File

@ -0,0 +1,13 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* FastAPI
* OpenAPI spec version: 0.1.0
*/
export interface ChannelScore {
score: number;
summary: string;
strengths: string[];
weaknesses: string[];
}

View File

@ -1,13 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* FastAPI
* OpenAPI spec version: 0.1.0
*/
import type { ChannelVerifyRequestYoutube } from './channelVerifyRequestYoutube';
import type { ChannelVerifyRequestInstagram } from './channelVerifyRequestInstagram';
export interface ChannelVerifyRequest {
youtube?: ChannelVerifyRequestYoutube;
instagram?: ChannelVerifyRequestInstagram;
}

View File

@ -1,13 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* FastAPI
* OpenAPI spec version: 0.1.0
*/
import type { ChannelVerifyResponseYoutube } from './channelVerifyResponseYoutube';
import type { ChannelVerifyResponseInstagram } from './channelVerifyResponseInstagram';
export interface ChannelVerifyResponse {
youtube?: ChannelVerifyResponseYoutube;
instagram?: ChannelVerifyResponseInstagram;
}

View File

@ -1,9 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* FastAPI
* OpenAPI spec version: 0.1.0
*/
import type { ChannelVerifyResponseInstagramAnyOfItem } from './channelVerifyResponseInstagramAnyOfItem';
export type ChannelVerifyResponseInstagram = ChannelVerifyResponseInstagramAnyOfItem[] | null;

View File

@ -1,8 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* FastAPI
* OpenAPI spec version: 0.1.0
*/
export type ChannelVerifyResponseInstagramAnyOfItem = { [key: string]: unknown };

View File

@ -1,9 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* FastAPI
* OpenAPI spec version: 0.1.0
*/
import type { ChannelVerifyResponseYoutubeAnyOf } from './channelVerifyResponseYoutubeAnyOf';
export type ChannelVerifyResponseYoutube = ChannelVerifyResponseYoutubeAnyOf | null;

View File

@ -1,8 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* FastAPI
* OpenAPI spec version: 0.1.0
*/
export type ChannelVerifyResponseYoutubeAnyOf = { [key: string]: unknown };

View File

@ -4,12 +4,7 @@
* FastAPI
* OpenAPI spec version: 0.1.0
*/
import type { ClinicCreateNameEn } from './clinicCreateNameEn';
import type { ClinicCreateAddress } from './clinicCreateAddress';
export interface ClinicCreate {
url: string;
name: string;
name_en?: ClinicCreateNameEn;
address?: ClinicCreateAddress;
}

View File

@ -1,8 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* FastAPI
* OpenAPI spec version: 0.1.0
*/
export type ClinicCreateNameEn = string | null;

View File

@ -0,0 +1,22 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* FastAPI
* OpenAPI spec version: 0.1.0
*/
import type { ClinicResponseHospitalNameEn } from './clinicResponseHospitalNameEn';
import type { ClinicResponseRoadAddress } from './clinicResponseRoadAddress';
import type { ClinicResponseUrl } from './clinicResponseUrl';
import type { ClinicResponseRawData } from './clinicResponseRawData';
export interface ClinicResponse {
hospital_id: string;
hospital_name: string;
hospital_name_en: ClinicResponseHospitalNameEn;
road_address: ClinicResponseRoadAddress;
url: ClinicResponseUrl;
status: string;
raw_data: ClinicResponseRawData;
created_at: string;
updated_at: string;
}

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0
*/
export type ChannelVerifyRequestYoutube = string | null;
export type ClinicResponseHospitalNameEn = string | null;

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* FastAPI
* OpenAPI spec version: 0.1.0
*/
import type { ClinicResponseRawDataAnyOf } from './clinicResponseRawDataAnyOf';
export type ClinicResponseRawData = ClinicResponseRawDataAnyOf | null;

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0
*/
export type ChannelVerifyRequestInstagram = string[] | null;
export type ClinicResponseRawDataAnyOf = { [key: string]: unknown };

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0
*/
export type ClinicCreateAddress = string | null;
export type ClinicResponseRoadAddress = string | null;

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0
*/
export type AnalysisCreateUrl = string | null;
export type ClinicResponseUrl = string | null;

View File

@ -5,7 +5,7 @@
* OpenAPI spec version: 0.1.0
*/
export interface ClinicInfo {
name: string;
url: string;
export interface ConversionStrategy {
summary: string;
actions: string[];
}

View File

@ -4,5 +4,6 @@
* FastAPI
* OpenAPI spec version: 0.1.0
*/
import type { ReportOutput } from './reportOutput';
export type AnalysisCreateClinicId = string | null;
export type GetReport200 = ReportOutput | null;

View File

@ -6,22 +6,13 @@
*/
export * from './analysisCreate';
export * from './analysisCreateClinicId';
export * from './analysisCreateUrl';
export * from './analysisOptions';
export * from './analysisStartResponse';
export * from './analysisStatus';
export * from './analysisStatusResponse';
export * from './analysisStatusResponseChannelErrors';
export * from './analysisStatusResponseCompletedAt';
export * from './channelVerifyRequest';
export * from './channelVerifyRequestInstagram';
export * from './channelVerifyRequestYoutube';
export * from './channelVerifyResponse';
export * from './channelVerifyResponseInstagram';
export * from './channelVerifyResponseInstagramAnyOfItem';
export * from './channelVerifyResponseYoutube';
export * from './channelVerifyResponseYoutubeAnyOf';
export * from './channelScore';
export * from './channels';
export * from './channelsFacebook';
export * from './channelsGangnamUnni';
@ -29,25 +20,28 @@ export * from './channelsInstagram';
export * from './channelsNaverBlog';
export * from './channelsYoutube';
export * from './clinicCreate';
export * from './clinicCreateAddress';
export * from './clinicCreateNameEn';
export * from './clinicCreateResponse';
export * from './clinicHistoryResponse';
export * from './clinicHistoryResponseMetricsTimeseries';
export * from './clinicInfo';
export * from './clinicResponse';
export * from './clinicResponseHospitalNameEn';
export * from './clinicResponseRawData';
export * from './clinicResponseRawDataAnyOf';
export * from './clinicResponseRoadAddress';
export * from './clinicResponseUrl';
export * from './conversionStrategy';
export * from './getReport200';
export * from './hTTPValidationError';
export * from './planCreate';
export * from './planResponse';
export * from './planResponseBrandGuide';
export * from './planResponseContentStrategy';
export * from './reportResponse';
export * from './reportResponseConversionStrategy';
export * from './reportResponseFacebook';
export * from './reportResponseGangnamUnni';
export * from './reportResponseInstagram';
export * from './reportResponseNaverBlog';
export * from './reportResponseNaverPlace';
export * from './reportResponseYoutube';
export * from './reportOutput';
export * from './reportOutputFacebook';
export * from './reportOutputGangnamUnni';
export * from './reportOutputInstagram';
export * from './reportOutputNaverBlog';
export * from './reportOutputYoutube';
export * from './runSummary';
export * from './runSummaryCompletedAt';
export * from './runSummaryOverallScore';

View File

@ -0,0 +1,24 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* FastAPI
* OpenAPI spec version: 0.1.0
*/
import type { ReportOutputInstagram } from './reportOutputInstagram';
import type { ReportOutputFacebook } from './reportOutputFacebook';
import type { ReportOutputNaverBlog } from './reportOutputNaverBlog';
import type { ReportOutputYoutube } from './reportOutputYoutube';
import type { ReportOutputGangnamUnni } from './reportOutputGangnamUnni';
import type { ConversionStrategy } from './conversionStrategy';
export interface ReportOutput {
overall_score: number;
instagram?: ReportOutputInstagram;
facebook?: ReportOutputFacebook;
naver_blog?: ReportOutputNaverBlog;
youtube?: ReportOutputYoutube;
gangnam_unni?: ReportOutputGangnamUnni;
conversion_strategy: ConversionStrategy;
roadmap: string[];
kpis: string[];
}

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* FastAPI
* OpenAPI spec version: 0.1.0
*/
import type { ChannelScore } from './channelScore';
export type ReportOutputFacebook = ChannelScore | null;

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* FastAPI
* OpenAPI spec version: 0.1.0
*/
import type { ChannelScore } from './channelScore';
export type ReportOutputGangnamUnni = ChannelScore | null;

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* FastAPI
* OpenAPI spec version: 0.1.0
*/
import type { ChannelScore } from './channelScore';
export type ReportOutputInstagram = ChannelScore | null;

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* FastAPI
* OpenAPI spec version: 0.1.0
*/
import type { ChannelScore } from './channelScore';
export type ReportOutputNaverBlog = ChannelScore | null;

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* FastAPI
* OpenAPI spec version: 0.1.0
*/
import type { ChannelScore } from './channelScore';
export type ReportOutputYoutube = ChannelScore | null;

View File

@ -1,30 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* FastAPI
* OpenAPI spec version: 0.1.0
*/
import type { ClinicInfo } from './clinicInfo';
import type { ReportResponseYoutube } from './reportResponseYoutube';
import type { ReportResponseInstagram } from './reportResponseInstagram';
import type { ReportResponseFacebook } from './reportResponseFacebook';
import type { ReportResponseNaverPlace } from './reportResponseNaverPlace';
import type { ReportResponseNaverBlog } from './reportResponseNaverBlog';
import type { ReportResponseGangnamUnni } from './reportResponseGangnamUnni';
import type { ReportResponseConversionStrategy } from './reportResponseConversionStrategy';
export interface ReportResponse {
id: string;
clinic: ClinicInfo;
overall_score: number;
youtube: ReportResponseYoutube;
instagram: ReportResponseInstagram;
facebook: ReportResponseFacebook;
naver_place: ReportResponseNaverPlace;
naver_blog: ReportResponseNaverBlog;
gangnam_unni: ReportResponseGangnamUnni;
conversion_strategy: ReportResponseConversionStrategy;
roadmap: unknown[];
kpis: unknown[];
generated_at: string;
}

View File

@ -1,8 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* FastAPI
* OpenAPI spec version: 0.1.0
*/
export type ReportResponseConversionStrategy = { [key: string]: unknown };

View File

@ -1,8 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* FastAPI
* OpenAPI spec version: 0.1.0
*/
export type ReportResponseFacebook = { [key: string]: unknown };

View File

@ -1,8 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* FastAPI
* OpenAPI spec version: 0.1.0
*/
export type ReportResponseGangnamUnni = { [key: string]: unknown };

View File

@ -1,8 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* FastAPI
* OpenAPI spec version: 0.1.0
*/
export type ReportResponseInstagram = { [key: string]: unknown };

View File

@ -1,8 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* FastAPI
* OpenAPI spec version: 0.1.0
*/
export type ReportResponseNaverBlog = { [key: string]: unknown };

View File

@ -1,8 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* FastAPI
* OpenAPI spec version: 0.1.0
*/
export type ReportResponseNaverPlace = { [key: string]: unknown };

View File

@ -1,8 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* FastAPI
* OpenAPI spec version: 0.1.0
*/
export type ReportResponseYoutube = { [key: string]: unknown };

View File

@ -7,5 +7,6 @@ export const PLAN_SECTIONS = [
{ id: 'repurposing-proposal', label: '리퍼포징 제안' },
{ id: 'workflow-tracker', label: '제작 파이프라인' },
{ id: 'my-asset-upload', label: '나의 소재' },
{ id: 'strategy-adjustment', label: '전략 조정' },
// 성과 기반 전략 조정 — 본 차수 미포함, 후속 모듈 이관
// { id: 'strategy-adjustment', label: '전략 조정' },
];

View File

@ -0,0 +1,39 @@
import { FileText, Sparkles } from 'lucide-react';
export type AppIconKind = 'report' | 'plan';
export type AppIconVariant = 'inline' | 'circled';
interface AppIconProps {
kind: AppIconKind;
variant?: AppIconVariant;
size?: number;
className?: string;
}
const ICON_MAP = {
report: FileText,
plan: Sparkles,
} as const;
const CIRCLE_TINT: Record<AppIconKind, string> = {
report: 'bg-brand-tint-purple text-brand-purple',
plan: 'bg-brand-tint-lavender text-brand-purple',
};
export function AppIcon({ kind, variant = 'inline', size = 12, className }: AppIconProps) {
const Icon = ICON_MAP[kind];
if (variant === 'circled') {
const boxSize = size * 2;
return (
<span
className={`inline-flex items-center justify-center rounded-full ${CIRCLE_TINT[kind]} ${className ?? ''}`}
style={{ width: boxSize, height: boxSize }}
>
<Icon size={size} />
</span>
);
}
return <Icon size={size} className={className} />;
}

View File

@ -16,16 +16,16 @@ export default function Footer() {
</div>
</div>
<div className="text-sm text-slate-500 leading-relaxed space-y-1">
<p className="font-medium text-slate-600"></p>
<p className="font-semibold text-slate-700"></p>
<p> : 620-87-00810 | : </p>
<p> : 41593 111, 5 A05</p>
<p> : 13453 32 () ()KT 504~505 (East)</p>
<p> : 070-4260-8310 | 010-2755-6463</p>
<p> : o2oteam@o2o.kr</p>
</div>
<div className="text-xs text-slate-400 border-t border-slate-100 pt-4">
<p className="text-xs text-slate-400 border-t border-slate-100 pt-4">
Copyright O2O Inc. All rights reserved
</div>
</p>
</PageContainer>
</footer>
);

View File

@ -37,7 +37,7 @@ export default function Navbar() {
{/* 가운데 메뉴 — 랜딩 페이지 섹션 순서 그대로 */}
<div className="hidden md:flex items-center gap-7 text-sm font-medium text-slate-600">
<a href="/#home" className="hover:text-primary-900 transition-colors">
Home
Product
</a>
<a href="/#audience" className="hover:text-primary-900 transition-colors">
Audience
@ -46,7 +46,7 @@ export default function Navbar() {
Problems
</a>
<a href="/#solution" className="hover:text-primary-900 transition-colors">
Product
Solution
</a>
<a href="/#modules" className="hover:text-primary-900 transition-colors">
Modules

View File

@ -58,7 +58,7 @@
@media print {
@page {
size: A4;
margin: 10mm 10mm;
margin: 15mm 10mm 10mm 10mm;
}
/* 브랜드 색·다크 섹션·그라데이션 그대로 유지 */
@ -116,10 +116,10 @@
padding-top: 0 !important;
}
/* ReportHeader/PlanHeader 등 첫 섹션은 페이지 맨 위에 바로 붙도록 상단 패딩 제거 */
/* ReportHeader/PlanHeader 등 첫 섹션 상단 — @page top margin (15mm) 외 약간의 여유 */
[data-report-content] > section:first-child,
[data-plan-content] > section:first-child {
padding-top: 0 !important;
padding-top: 4mm !important;
}
/* break-inside: avoid .

10
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
readonly VITE_API_KEY?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}