feat:05/14 UI/기능 변경건 커밋
parent
49367756ea
commit
327a50bd41
|
|
@ -12,8 +12,6 @@
|
||||||
"@tanstack/react-query": "^5.59.0",
|
"@tanstack/react-query": "^5.59.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"html2canvas-pro": "^2.0.2",
|
|
||||||
"jspdf": "^4.2.1",
|
|
||||||
"ky": "^1.7.5",
|
"ky": "^1.7.5",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
|
|
@ -334,15 +332,6 @@
|
||||||
"@babel/core": "^7.0.0-0"
|
"@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": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||||
|
|
@ -4070,19 +4059,6 @@
|
||||||
"undici-types": "~6.21.0"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
|
|
@ -4103,13 +4079,6 @@
|
||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/@types/unist": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||||
|
|
@ -4381,15 +4350,6 @@
|
||||||
"node": "18 || 20 || >=22"
|
"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": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.29",
|
"version": "2.10.29",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
|
||||||
|
|
@ -4541,26 +4501,6 @@
|
||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
|
@ -4694,18 +4634,6 @@
|
||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
|
@ -4721,15 +4649,6 @@
|
||||||
"node": ">= 8"
|
"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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
|
@ -4884,16 +4803,6 @@
|
||||||
"node": ">=8"
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
|
@ -5267,17 +5176,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fast-safe-stringify": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||||
|
|
@ -5312,12 +5210,6 @@
|
||||||
"reusify": "^1.0.4"
|
"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": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
|
|
@ -5736,33 +5628,6 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/http2-client": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/http2-client/-/http2-client-1.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/http2-client/-/http2-client-1.3.5.tgz",
|
||||||
|
|
@ -5823,12 +5688,6 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||||
|
|
@ -6374,23 +6233,6 @@
|
||||||
"node": "*"
|
"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": {
|
"node_modules/ky": {
|
||||||
"version": "1.14.3",
|
"version": "1.14.3",
|
||||||
"resolved": "https://registry.npmjs.org/ky/-/ky-1.14.3.tgz",
|
"resolved": "https://registry.npmjs.org/ky/-/ky-1.14.3.tgz",
|
||||||
|
|
@ -7337,12 +7179,6 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
|
|
@ -7373,13 +7209,6 @@
|
||||||
"node": ">=8"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"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": {
|
"node_modules/react": {
|
||||||
"version": "19.2.6",
|
"version": "19.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||||
|
|
@ -7754,13 +7573,6 @@
|
||||||
"url": "https://github.com/Mermade/oas-kit?sponsor=1"
|
"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": {
|
"node_modules/regexp.prototype.flags": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||||
|
|
@ -7813,16 +7625,6 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/rollup": {
|
||||||
"version": "4.60.3",
|
"version": "4.60.3",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz",
|
||||||
|
|
@ -8211,16 +8013,6 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/stop-iteration-iterator": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||||
|
|
@ -8355,16 +8147,6 @@
|
||||||
"node": ">=8"
|
"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": {
|
"node_modules/swagger2openapi": {
|
||||||
"version": "7.0.8",
|
"version": "7.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/swagger2openapi/-/swagger2openapi-7.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/swagger2openapi/-/swagger2openapi-7.0.8.tgz",
|
||||||
|
|
@ -8434,15 +8216,6 @@
|
||||||
"url": "https://opencollective.com/webpack"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||||
|
|
@ -8849,15 +8622,6 @@
|
||||||
"node": ">= 4"
|
"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": {
|
"node_modules/validator": {
|
||||||
"version": "13.15.23",
|
"version": "13.15.23",
|
||||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz",
|
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz",
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,6 @@
|
||||||
"@tanstack/react-query": "^5.59.0",
|
"@tanstack/react-query": "^5.59.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"html2canvas-pro": "^2.0.2",
|
|
||||||
"jspdf": "^4.2.1",
|
|
||||||
"ky": "^1.7.5",
|
"ky": "^1.7.5",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@
|
||||||
* - 분석 대상 플랫폼 칩(URL/핸들) 표시
|
* - 분석 대상 플랫폼 칩(URL/핸들) 표시
|
||||||
*/
|
*/
|
||||||
import { Link, useNavigate } from 'react-router';
|
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 { PlatformChips } from '../PlatformChips';
|
||||||
import type { WorkspacePlan, WorkspaceRun } from '../../types/workspace';
|
import type { WorkspacePlan, WorkspaceRun } from '../../types/workspace';
|
||||||
|
|
||||||
|
|
@ -106,7 +107,7 @@ export function AnalysisCard({ clinicId, run, plan, highlighted = false }: Analy
|
||||||
onClick={stop}
|
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"
|
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" />
|
<ArrowUpRight size={11} className="opacity-80" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -116,7 +117,7 @@ export function AnalysisCard({ clinicId, run, plan, highlighted = false }: Analy
|
||||||
onClick={stop}
|
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"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import type { WorkspaceData } from '../types/workspace';
|
||||||
|
|
||||||
export const mockWorkspace: WorkspaceData = {
|
export const mockWorkspace: WorkspaceData = {
|
||||||
clinic: {
|
clinic: {
|
||||||
clinicId: 'view-gangnam',
|
clinicId: 'view-clinic',
|
||||||
name: '뷰성형외과',
|
name: '뷰성형외과의원',
|
||||||
nameEn: 'VIEW Plastic Surgery',
|
nameEn: 'VIEW Plastic Surgery',
|
||||||
location: '서울 강남구 신사동',
|
location: '서울 강남구 신사동',
|
||||||
brandColor: '#4F1DA1',
|
brandColor: '#4F1DA1',
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ export function BrandAppliedPreview({
|
||||||
등록된 컬러·타이포가 실제 콘텐츠에 적용됐을 때의 모습입니다.
|
등록된 컬러·타이포가 실제 콘텐츠에 적용됐을 때의 모습입니다.
|
||||||
</p>
|
</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 ─────────────────────────── */}
|
{/* ── Instagram Post Mockup ─────────────────────────── */}
|
||||||
<motion.figure
|
<motion.figure
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { MarketingPlan } from '@/features/plan/types/plan';
|
import type { MarketingPlan } from '@/features/plan/types/plan';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 바노바기성형외과 — 데모 마케팅 플랜
|
* 바노바기성형외과 — 데모 마케팅 기획
|
||||||
*
|
*
|
||||||
* 리포트 근거 (`mockReport_banobagi.ts` 2026-04-14 실측):
|
* 리포트 근거 (`mockReport_banobagi.ts` 2026-04-14 실측):
|
||||||
* - YouTube @banobagips: 13K 구독자, 925 영상 (최근 6개월 업로드 저조)
|
* - YouTube @banobagips: 13K 구독자, 925 영상 (최근 6개월 업로드 저조)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { MarketingPlan } from '@/features/plan/types/plan';
|
import type { MarketingPlan } from '@/features/plan/types/plan';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 그랜드성형외과 — 데모 마케팅 플랜
|
* 그랜드성형외과 — 데모 마케팅 기획
|
||||||
*
|
*
|
||||||
* 리포트 근거 (`mockReport_grand.ts` 2026-04-14 실측):
|
* 리포트 근거 (`mockReport_grand.ts` 2026-04-14 실측):
|
||||||
* - YouTube @grandsurgery_QnA: 2.37K 구독자, 332 영상, 업로드 ~0/week (사실상 중단)
|
* - YouTube @grandsurgery_QnA: 2.37K 구독자, 332 영상, 업로드 ~0/week (사실상 중단)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { MarketingPlan } from '@/features/plan/types/plan';
|
import type { MarketingPlan } from '@/features/plan/types/plan';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이룸성형외과 (서울아이 / Seoul i Plastic Surgery) — 맞춤 마케팅 플랜
|
* 이룸성형외과 (서울아이 / Seoul i Plastic Surgery) — 맞춤 마케팅 기획
|
||||||
*
|
*
|
||||||
* 리포트 근거 (mockReport_irum.ts, 2026-04-14 실측):
|
* 리포트 근거 (mockReport_irum.ts, 2026-04-14 실측):
|
||||||
* - YouTube @SEOULiPS.: 구독자 322명, 영상 155개 (성장 완전 정체)
|
* - YouTube @SEOULiPS.: 구독자 322명, 영상 155개 (성장 완전 정체)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { MarketingPlan } from '@/features/plan/types/plan';
|
import type { MarketingPlan } from '@/features/plan/types/plan';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* O2O Clinic — 가상 데모 마케팅 플랜 (외부 노출 가능)
|
* O2O Clinic — 가상 데모 마케팅 기획 (외부 노출 가능)
|
||||||
*
|
*
|
||||||
* 의료광고법·개인정보 우려 없이 자유롭게 광고/마케팅 자료로 사용 가능한
|
* 의료광고법·개인정보 우려 없이 자유롭게 광고/마케팅 자료로 사용 가능한
|
||||||
* INFINITH 솔루션 데모 자산. 6개 실제 병원 분석 패턴의 베스트 프랙티스를
|
* INFINITH 솔루션 데모 자산. 6개 실제 병원 분석 패턴의 베스트 프랙티스를
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { MarketingPlan } from '@/features/plan/types/plan';
|
import type { MarketingPlan } from '@/features/plan/types/plan';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 티에스성형외과 — 데모 마케팅 플랜
|
* 티에스성형외과 — 데모 마케팅 기획
|
||||||
*
|
*
|
||||||
* 리포트 근거 (`mockReport_ts.ts` 2026-04-14 실측):
|
* 리포트 근거 (`mockReport_ts.ts` 2026-04-14 실측):
|
||||||
* - YouTube 티에스TV @TV-jm9dy: 8K 구독자, 715 영상, 주 1~2회 업로드
|
* - YouTube 티에스TV @TV-jm9dy: 8K 구독자, 715 영상, 주 1~2회 업로드
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { MarketingPlan } from '@/features/plan/types/plan';
|
import type { MarketingPlan } from '@/features/plan/types/plan';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 원진성형외과 — 데모 마케팅 플랜
|
* 원진성형외과 — 데모 마케팅 기획
|
||||||
*
|
*
|
||||||
* 리포트 근거 (`mockReport_wonjin.ts` 2026-04-14 실측):
|
* 리포트 근거 (`mockReport_wonjin.ts` 2026-04-14 실측):
|
||||||
* - YouTube @wjwonjin: 14.1K 구독자, 350 영상, 주 3~5회 (Shorts 활발)
|
* - YouTube @wjwonjin: 14.1K 구독자, 350 영상, 주 3~5회 (Shorts 활발)
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,8 @@ interface UseMarketingPlanResult {
|
||||||
data: MarketingPlan | null;
|
data: MarketingPlan | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
/** DB row 의 clinic_id — 게스트 페이지에서 워크스페이스 점프 버튼 노출에 사용 */
|
||||||
|
clinicId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LocationState {
|
interface LocationState {
|
||||||
|
|
@ -96,6 +98,7 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
|
||||||
const [data, setData] = useState<MarketingPlan | null>(null);
|
const [data, setData] = useState<MarketingPlan | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [clinicId, setClinicId] = useState<string | null>(null);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -106,17 +109,20 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = location.state as LocationState | undefined;
|
const state = location.state as LocationState | undefined;
|
||||||
|
const stateClinicId = state?.clinicId ?? null;
|
||||||
|
|
||||||
async function loadPlan() {
|
async function loadPlan() {
|
||||||
try {
|
try {
|
||||||
// ─── 개발 / 데모: mock 데이터를 즉시 반환 ───
|
// ─── 개발 / 데모: mock 데이터를 즉시 반환 ───
|
||||||
if (id === 'demo') {
|
if (id === 'demo') {
|
||||||
setData(mockPlan);
|
setData(mockPlan);
|
||||||
|
setClinicId(null);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (id && id in DEMO_PLANS) {
|
if (id && id in DEMO_PLANS) {
|
||||||
setData(DEMO_PLANS[id]);
|
setData(DEMO_PLANS[id]);
|
||||||
|
setClinicId(null);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -129,13 +135,15 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
|
||||||
try {
|
try {
|
||||||
const planRes = await getPlan(id!);
|
const planRes = await getPlan(id!);
|
||||||
if (planRes.status === 200 && planRes.data) {
|
if (planRes.status === 200 && planRes.data) {
|
||||||
|
const planRow = planRes.data as unknown as Record<string, unknown>;
|
||||||
const plan = buildPlanFromContentPlans(
|
const plan = buildPlanFromContentPlans(
|
||||||
planRes.data as unknown as Record<string, unknown>,
|
planRow,
|
||||||
clinicName,
|
clinicName,
|
||||||
clinicNameEn,
|
clinicNameEn,
|
||||||
targetUrl,
|
targetUrl,
|
||||||
);
|
);
|
||||||
setData(plan);
|
setData(plan);
|
||||||
|
setClinicId((planRow.clinic_id as string) || stateClinicId);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -153,6 +161,7 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
|
||||||
created_at: (state.metadata.generatedAt as string) || new Date().toISOString(),
|
created_at: (state.metadata.generatedAt as string) || new Date().toISOString(),
|
||||||
});
|
});
|
||||||
setData(plan);
|
setData(plan);
|
||||||
|
setClinicId(stateClinicId);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -168,6 +177,7 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
|
||||||
created_at: (reportRow.created_at as string) || new Date().toISOString(),
|
created_at: (reportRow.created_at as string) || new Date().toISOString(),
|
||||||
});
|
});
|
||||||
setData(plan);
|
setData(plan);
|
||||||
|
setClinicId((reportRow.clinic_id as string) || stateClinicId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to fetch marketing plan');
|
setError(err instanceof Error ? err.message : 'Failed to fetch marketing plan');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -178,5 +188,5 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
|
||||||
loadPlan();
|
loadPlan();
|
||||||
}, [id, location.state]);
|
}, [id, location.state]);
|
||||||
|
|
||||||
return { data, isLoading, error };
|
return { data, isLoading, error, clinicId };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,13 @@
|
||||||
*/
|
*/
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Link, useParams, useLocation } from 'react-router';
|
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 { useMarketingPlan } from '../hooks/useMarketingPlan';
|
||||||
import { ReportNav } from '@/features/report/components/ReportNav';
|
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 { PLAN_SECTIONS } from '@/shared/constants/planSections';
|
||||||
import PlanBody from '../components/PlanBody';
|
import PlanBody from '../components/PlanBody';
|
||||||
import PlanCTA from '../components/PlanCTA';
|
|
||||||
|
|
||||||
export default function GuestPlanPage() {
|
export default function GuestPlanPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
@ -62,14 +62,23 @@ export default function GuestPlanPage() {
|
||||||
<div className="pt-20">
|
<div className="pt-20">
|
||||||
<ReportNav
|
<ReportNav
|
||||||
sections={PLAN_SECTIONS}
|
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={
|
rightSlot={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<PdfDownloadButton filename={`${data.clinicName}_Marketing_Plan`} />
|
<PlanDownloadMenuButton filename={`${data.clinicName}_Marketing_Plan`} plan={data} />
|
||||||
<Link
|
<Link
|
||||||
to={`/report/${id}`}
|
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"
|
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" />
|
<ArrowRight size={11} className="opacity-80" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -77,7 +86,6 @@ export default function GuestPlanPage() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<PlanBody data={data} />
|
<PlanBody data={data} />
|
||||||
<PlanCTA />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,24 +3,28 @@
|
||||||
*
|
*
|
||||||
* 계약된 병원 유저가 워크스페이스에서 운영하는 마케팅 기획 화면.
|
* 계약된 병원 유저가 워크스페이스에서 운영하는 마케팅 기획 화면.
|
||||||
* GuestPlanPage 의 본문 + 워크스페이스 액션바 + 인터랙티브 섹션
|
* GuestPlanPage 의 본문 + 워크스페이스 액션바 + 인터랙티브 섹션
|
||||||
* (MyAssetUpload / StrategyAdjustmentSection / WorkflowTracker).
|
* (MyAssetUpload / WorkflowTracker).
|
||||||
|
*
|
||||||
|
* NOTE: StrategyAdjustmentSection(성과 기반 전략 조정) 은 성과 데이터 파이프라인 의존 →
|
||||||
|
* 본 차수 미포함, 후속 모듈로 이관. 아래 import/render 주석 처리.
|
||||||
*/
|
*/
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Link, useParams, useLocation } from 'react-router';
|
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 { useMarketingPlan } from '../hooks/useMarketingPlan';
|
||||||
import { ReportNav } from '@/features/report/components/ReportNav';
|
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 { PLAN_SECTIONS } from '@/shared/constants/planSections';
|
||||||
import PlanBody from '../components/PlanBody';
|
import PlanBody from '../components/PlanBody';
|
||||||
import MyAssetUpload from '../components/MyAssetUpload';
|
import MyAssetUpload from '../components/MyAssetUpload';
|
||||||
import StrategyAdjustmentSection from '../components/StrategyAdjustmentSection';
|
// import StrategyAdjustmentSection from '../components/StrategyAdjustmentSection';
|
||||||
import WorkflowTracker from '../components/WorkflowTracker';
|
import WorkflowTracker from '../components/WorkflowTracker';
|
||||||
|
|
||||||
export default function UserPlanPage() {
|
export default function UserPlanPage() {
|
||||||
const { clinicId, id } = useParams<{ clinicId: string; id: string }>();
|
const { clinicId, id } = useParams<{ clinicId: string; id: string }>();
|
||||||
const location = useLocation();
|
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);
|
const { data, isLoading, error } = useMarketingPlan(id);
|
||||||
|
|
||||||
useEffect(() => {
|
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"
|
className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-500 hover:text-primary-900 transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={14} />
|
<ArrowLeft size={14} />
|
||||||
워크스페이스로
|
클리닉으로
|
||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
rightSlot={
|
rightSlot={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<PdfDownloadButton filename={`${data.clinicName}_Marketing_Plan`} />
|
<PlanDownloadMenuButton filename={`${data.clinicName}_Marketing_Plan`} plan={data} />
|
||||||
{reportTargetId && (
|
{reportTargetId && (
|
||||||
<Link
|
<Link
|
||||||
to={`/clinics/${clinicId}/report/${reportTargetId}`}
|
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"
|
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>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
@ -109,9 +113,10 @@ export default function UserPlanPage() {
|
||||||
<MyAssetUpload />
|
<MyAssetUpload />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-no-print>
|
{/* 성과 기반 전략 조정 — 본 차수 미포함, 후속 모듈 이관 */}
|
||||||
|
{/* <div data-no-print>
|
||||||
<StrategyAdjustmentSection clinicId={clinicId ?? stateClinicId} planId={data.id} />
|
<StrategyAdjustmentSection clinicId={clinicId ?? stateClinicId} planId={data.id} />
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { motion } from 'motion/react';
|
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 { SectionWrapper } from './ui/SectionWrapper';
|
||||||
import type { ClinicSnapshot as ClinicSnapshotType } from '@/features/report/types/report';
|
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) {
|
export default function ClinicSnapshot({ data }: ClinicSnapshotProps) {
|
||||||
const fields = infoFields(data);
|
const fields = infoFields(data);
|
||||||
const isVerified = data.source === 'registry';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionWrapper id="clinic-snapshot" title="Clinic Overview" subtitle="병원 기본 정보">
|
<SectionWrapper id="clinic-snapshot" title="Clinic Overview" subtitle="병원 기본 정보">
|
||||||
{/* 외부 검증 배지는 내부 관리용이므로 리포트에서 제외 */}
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
|
||||||
|
|
||||||
{/* 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">
|
|
||||||
{fields.map((field, i) => {
|
{fields.map((field, i) => {
|
||||||
const Icon = field.icon;
|
const Icon = field.icon;
|
||||||
return (
|
return (
|
||||||
|
|
@ -127,7 +83,7 @@ export default function ClinicSnapshot({ data }: ClinicSnapshotProps) {
|
||||||
transition={{ duration: 0.5, delay: 0.3 }}
|
transition={{ duration: 0.5, delay: 0.3 }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-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>
|
<h3 className="font-serif font-bold text-2xl text-[#0A1128]">대표 원장</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xl font-bold text-[#0A1128] mb-1">{data.leadDoctor.name}</p>
|
<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}
|
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"
|
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}
|
{cert}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -39,13 +39,14 @@ export function ReportNav({ sections, leftSlot, rightSlot }: ReportNavProps) {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const activeTab = tabRefs.current.get(activeId);
|
const activeTab = tabRefs.current.get(activeId);
|
||||||
if (activeTab && navRef.current) {
|
const navEl = navRef.current;
|
||||||
activeTab.scrollIntoView({
|
if (!activeTab || !navEl) return;
|
||||||
behavior: 'smooth',
|
|
||||||
block: 'nearest',
|
// scrollIntoView 는 sticky 컨테이너에서 세로 스크롤까지 트리거하는 부작용이 있어
|
||||||
inline: 'center',
|
// (초기 마운트 시 sticky 위치 계산 전 탭의 실제 y 로 페이지를 끌어내림 → 첫 섹션 점프)
|
||||||
});
|
// 가로 스크롤만 직접 제어합니다.
|
||||||
}
|
const targetLeft = activeTab.offsetLeft - (navEl.clientWidth - activeTab.offsetWidth) / 2;
|
||||||
|
navEl.scrollTo({ left: targetLeft, behavior: 'smooth' });
|
||||||
}, [activeId]);
|
}, [activeId]);
|
||||||
|
|
||||||
const handleClick = (id: string) => {
|
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">
|
<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">
|
<PageContainer className="px-0 flex items-center gap-3">
|
||||||
{leftSlot && (
|
{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}
|
{leftSlot}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { motion } from 'motion/react';
|
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 { SectionWrapper } from './ui/SectionWrapper';
|
||||||
import { EmptyState } from './ui/EmptyState';
|
import { EmptyState } from './ui/EmptyState';
|
||||||
import { MetricCard } from './ui/MetricCard';
|
import { MetricCard } from './ui/MetricCard';
|
||||||
|
|
@ -125,7 +125,7 @@ export default function YouTubeAudit({ data }: YouTubeAuditProps) {
|
||||||
key={pl}
|
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"
|
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}
|
{pl}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,8 @@ interface UseReportResult {
|
||||||
isEnriched: boolean;
|
isEnriched: boolean;
|
||||||
/** DB 또는 API 메타데이터에서 복원한 정규화된 소셜 핸들 */
|
/** DB 또는 API 메타데이터에서 복원한 정규화된 소셜 핸들 */
|
||||||
socialHandles: Record<string, string | null> | null;
|
socialHandles: Record<string, string | null> | null;
|
||||||
|
/** DB row의 clinic_id (FK). 게스트 페이지에서 워크스페이스 점프 버튼 노출에 사용 */
|
||||||
|
clinicId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LocationState {
|
interface LocationState {
|
||||||
|
|
@ -61,6 +63,7 @@ export function useReport(id: string | undefined): UseReportResult {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isEnriched, setIsEnriched] = useState(false);
|
const [isEnriched, setIsEnriched] = useState(false);
|
||||||
const [socialHandles, setSocialHandles] = useState<Record<string, string | null> | null>(null);
|
const [socialHandles, setSocialHandles] = useState<Record<string, string | null> | null>(null);
|
||||||
|
const [clinicId, setClinicId] = useState<string | null>(null);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -69,11 +72,13 @@ export function useReport(id: string | undefined): UseReportResult {
|
||||||
setData(DEMO_REPORTS[id]);
|
setData(DEMO_REPORTS[id]);
|
||||||
setIsEnriched(true);
|
setIsEnriched(true);
|
||||||
setSocialHandles(DEMO_HANDLES[id] ?? null);
|
setSocialHandles(DEMO_HANDLES[id] ?? null);
|
||||||
|
setClinicId(null); // 데모는 워크스페이스 연결 없음
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = location.state as LocationState | undefined;
|
const state = location.state as LocationState | undefined;
|
||||||
|
const stateClinicId = (state as Record<string, unknown> | undefined)?.clinicId as string | undefined;
|
||||||
|
|
||||||
// Source 1: 네비게이션 state로 전달된 리포트 데이터 (AnalysisLoadingPage에서)
|
// Source 1: 네비게이션 state로 전달된 리포트 데이터 (AnalysisLoadingPage에서)
|
||||||
if (state?.report && state?.metadata) {
|
if (state?.report && state?.metadata) {
|
||||||
|
|
@ -84,6 +89,7 @@ export function useReport(id: string | undefined): UseReportResult {
|
||||||
state.report,
|
state.report,
|
||||||
state.metadata,
|
state.metadata,
|
||||||
);
|
);
|
||||||
|
setClinicId(stateClinicId ?? null);
|
||||||
// V2 파이프라인: 리포트에 Phase 2의 channelEnrichment 이미 포함됨
|
// V2 파이프라인: 리포트에 Phase 2의 channelEnrichment 이미 포함됨
|
||||||
const enrichment = state.report.channelEnrichment as EnrichmentData | undefined;
|
const enrichment = state.report.channelEnrichment as EnrichmentData | undefined;
|
||||||
if (enrichment) {
|
if (enrichment) {
|
||||||
|
|
@ -112,6 +118,7 @@ export function useReport(id: string | undefined): UseReportResult {
|
||||||
}
|
}
|
||||||
const row = res.data as unknown as Record<string, unknown>;
|
const row = res.data as unknown as Record<string, unknown>;
|
||||||
const reportJson = (row.report as Record<string, unknown>) || row;
|
const reportJson = (row.report as Record<string, unknown>) || row;
|
||||||
|
setClinicId((row.clinic_id as string) || stateClinicId || null);
|
||||||
|
|
||||||
// V2 파이프라인: 리포트가 비어있지만 status가 'complete'가 아니면 메시지 표시
|
// V2 파이프라인: 리포트가 비어있지만 status가 'complete'가 아니면 메시지 표시
|
||||||
if (!reportJson || Object.keys(reportJson).length === 0 || reportJson.parseError) {
|
if (!reportJson || Object.keys(reportJson).length === 0 || reportJson.parseError) {
|
||||||
|
|
@ -191,5 +198,5 @@ export function useReport(id: string | undefined): UseReportResult {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}, [id, location.state]);
|
}, [id, location.state]);
|
||||||
|
|
||||||
return { data, isLoading, error, isEnriched, socialHandles };
|
return { data, isLoading, error, isEnriched, socialHandles, clinicId };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ interface UseReportPageDataResult {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
enrichStatus: ReturnType<typeof useEnrichment>['status'];
|
enrichStatus: ReturnType<typeof useEnrichment>['status'];
|
||||||
|
/** DB row 의 clinic_id — 게스트 페이지에서 워크스페이스 점프 버튼 노출에 사용 */
|
||||||
|
clinicId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useReportPageData(id: string | undefined): UseReportPageDataResult {
|
export function useReportPageData(id: string | undefined): UseReportPageDataResult {
|
||||||
|
|
@ -25,6 +27,7 @@ export function useReportPageData(id: string | undefined): UseReportPageDataResu
|
||||||
error,
|
error,
|
||||||
isEnriched,
|
isEnriched,
|
||||||
socialHandles: dbSocialHandles,
|
socialHandles: dbSocialHandles,
|
||||||
|
clinicId,
|
||||||
} = useReport(id);
|
} = useReport(id);
|
||||||
|
|
||||||
const enrichmentParams = useMemo(() => {
|
const enrichmentParams = useMemo(() => {
|
||||||
|
|
@ -62,5 +65,6 @@ export function useReportPageData(id: string | undefined): UseReportPageDataResu
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
enrichStatus,
|
enrichStatus,
|
||||||
|
clinicId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,266 +1,31 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
||||||
import { useNavigate, useLocation, useParams } from 'react-router';
|
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { Check, AlertCircle, RefreshCw, ShieldX } from 'lucide-react';
|
import { Check, AlertCircle, RefreshCw, ShieldX } from 'lucide-react';
|
||||||
import { useStartAnalysis, getAnalysisStatus } from '@/shared/api/generated/analyses/analyses';
|
import { useAnalysisPipeline, type Phase } from '../hooks/useAnalysisPipeline';
|
||||||
import { getReport } from '@/shared/api/generated/reports/reports';
|
|
||||||
|
|
||||||
// 분석 파이프라인은 단일 startAnalysis + 폴링 모델로 통합됨.
|
const PHASE_STEPS: { key: Phase; label: string; labelDone: string }[] = [
|
||||||
// 기존의 discoverChannels / collectChannelData / generateReportV2 / generateContentPlan
|
{ key: 'discovering', label: 'Scanning website & discovering channels...', labelDone: 'Channels discovered' },
|
||||||
// 단계는 백엔드(FastAPI) 비동기 워커가 수행하므로 여기서는 startAnalysis 1회 호출 후
|
{ key: 'collecting', label: 'Collecting channel data & market analysis...', labelDone: 'Data collected' },
|
||||||
// /api/analyses/{run_id}/status 폴링으로 대체.
|
{ key: 'generating', label: 'Generating AI marketing report...', labelDone: 'Report generated' },
|
||||||
|
{ key: 'planning', label: 'Generating AI content strategy...', labelDone: 'Content plan generated' },
|
||||||
export type ManualChannels = {
|
{ key: 'complete', label: 'Finalizing report...', labelDone: 'Complete' },
|
||||||
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' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// 파이프라인 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() {
|
export default function AnalysisLoadingPage() {
|
||||||
const [phase, setPhase] = useState<Phase>('discovering');
|
const {
|
||||||
const [error, setError] = useState<string | null>(null);
|
phase,
|
||||||
const [errorCode, setErrorCode] = useState<string | null>(null);
|
error,
|
||||||
const [errorDomain, setErrorDomain] = useState<string | null>(null);
|
errorCode,
|
||||||
const [errorDetails, setErrorDetails] = useState<Record<string, string> | null>(null);
|
errorDomain,
|
||||||
const navigate = useNavigate();
|
errorDetails,
|
||||||
const location = useLocation();
|
url,
|
||||||
const { reportId: urlReportId } = useParams<{ reportId?: string }>();
|
sessionUrl,
|
||||||
const locState = (location.state as {
|
handleRetry,
|
||||||
url?: string;
|
handleAbort,
|
||||||
manualChannels?: ManualChannels;
|
} = useAnalysisPipeline();
|
||||||
}) ?? {};
|
|
||||||
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 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,
|
|
||||||
phase,
|
|
||||||
});
|
|
||||||
} else if (url || session.url) {
|
|
||||||
// 처음부터 재시작
|
|
||||||
hasStarted.current = false;
|
|
||||||
runPipeline(url || session.url || undefined);
|
|
||||||
}
|
|
||||||
}, [phase, url, runPipeline]);
|
|
||||||
|
|
||||||
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 displayPhaseIndex = phase === 'resuming' ? -1 : phaseIndex;
|
const displayPhaseIndex = phase === 'resuming' ? -1 : phaseIndex;
|
||||||
|
const displayUrl = url || sessionUrl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen bg-primary-900 flex flex-col items-center justify-center px-6 overflow-hidden">
|
<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
|
INFINITH
|
||||||
</motion.h1>
|
</motion.h1>
|
||||||
|
|
||||||
{(url || loadSession().url) && (
|
{displayUrl && (
|
||||||
<motion.p
|
<motion.p
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.6, delay: 0.1 }}
|
transition={{ duration: 0.6, delay: 0.1 }}
|
||||||
className="text-purple-300/80 text-sm font-mono mb-12 truncate max-w-full"
|
className="text-purple-300/80 text-sm font-mono mb-12 truncate max-w-full"
|
||||||
>
|
>
|
||||||
{url || loadSession().url}
|
{displayUrl}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -309,7 +74,7 @@ export default function AnalysisLoadingPage() {
|
||||||
현재 분석 대상 병원 목록에 포함되지 않은 도메인입니다.
|
현재 분석 대상 병원 목록에 포함되지 않은 도메인입니다.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<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"
|
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
|
Retry
|
||||||
</button>
|
</button>
|
||||||
<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"
|
className="px-6 py-2 text-sm font-medium text-white bg-white/10 rounded-lg hover:bg-white/20 transition-colors"
|
||||||
>
|
>
|
||||||
Start Over
|
Start Over
|
||||||
|
|
@ -352,74 +117,73 @@ export default function AnalysisLoadingPage() {
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)
|
)
|
||||||
|
) : phase === 'resuming' ? (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="flex flex-col items-center gap-4 mb-14"
|
||||||
|
>
|
||||||
|
<div className="w-7 h-7 rounded-full border-2 border-purple-400 border-t-transparent animate-spin" />
|
||||||
|
<p className="text-purple-200 text-sm">Resuming analysis...</p>
|
||||||
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{phase === 'resuming' ? (
|
<div className="w-full space-y-5 mb-14">
|
||||||
<motion.div
|
{PHASE_STEPS.map((step, index) => {
|
||||||
initial={{ opacity: 0 }}
|
const isCompleted =
|
||||||
animate={{ opacity: 1 }}
|
displayPhaseIndex > index || (step.key === 'complete' && phase === 'complete');
|
||||||
className="flex flex-col items-center gap-4 mb-14"
|
const isActive = displayPhaseIndex === index && phase !== 'complete';
|
||||||
>
|
|
||||||
<div className="w-7 h-7 rounded-full border-2 border-purple-400 border-t-transparent animate-spin" />
|
|
||||||
<p className="text-purple-200 text-sm">Resuming analysis...</p>
|
|
||||||
</motion.div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="w-full space-y-5 mb-14">
|
|
||||||
{PHASE_STEPS.map((step, index) => {
|
|
||||||
const isCompleted = displayPhaseIndex > index || (step.key === 'complete' && phase === 'complete');
|
|
||||||
const isActive = displayPhaseIndex === index && phase !== 'complete';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
|
||||||
key={step.key}
|
|
||||||
initial={{ opacity: 0, x: -20 }}
|
|
||||||
animate={{ opacity: isActive || isCompleted ? 1 : 0.3, x: 0 }}
|
|
||||||
transition={{ duration: 0.4, delay: index * 0.15 }}
|
|
||||||
className="flex items-center gap-4"
|
|
||||||
>
|
|
||||||
<div className="w-7 h-7 flex-shrink-0 flex items-center justify-center">
|
|
||||||
{isCompleted ? (
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0 }}
|
|
||||||
animate={{ scale: 1 }}
|
|
||||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
|
||||||
className="w-7 h-7 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#6C5CE7] flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<Check className="w-4 h-4 text-white" strokeWidth={3} />
|
|
||||||
</motion.div>
|
|
||||||
) : isActive ? (
|
|
||||||
<div className="w-7 h-7 rounded-full border-2 border-purple-400 border-t-transparent animate-spin" />
|
|
||||||
) : (
|
|
||||||
<div className="w-7 h-7 rounded-full border-2 border-white/10" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`text-base font-sans transition-colors duration-300 ${
|
|
||||||
isCompleted ? 'text-white' : isActive ? 'text-purple-200' : 'text-white/30'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isCompleted ? step.labelDone : step.label}
|
|
||||||
</span>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ width: '0%' }}
|
key={step.key}
|
||||||
animate={{ width: `${((displayPhaseIndex + (phase === 'complete' ? 1 : 0.5)) / PHASE_STEPS.length) * 100}%` }}
|
initial={{ opacity: 0, x: -20 }}
|
||||||
transition={{ duration: 0.8, ease: 'easeInOut' }}
|
animate={{ opacity: isActive || isCompleted ? 1 : 0.3, x: 0 }}
|
||||||
className="h-full bg-gradient-to-r from-[#4F1DA1] to-[#6C5CE7] rounded-full"
|
transition={{ duration: 0.4, delay: index * 0.15 }}
|
||||||
/>
|
className="flex items-center gap-4"
|
||||||
</div>
|
>
|
||||||
|
<div className="w-7 h-7 flex-shrink-0 flex items-center justify-center">
|
||||||
|
{isCompleted ? (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||||
|
className="w-7 h-7 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#6C5CE7] flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4 text-white" strokeWidth={3} />
|
||||||
|
</motion.div>
|
||||||
|
) : isActive ? (
|
||||||
|
<div className="w-7 h-7 rounded-full border-2 border-purple-400 border-t-transparent animate-spin" />
|
||||||
|
) : (
|
||||||
|
<div className="w-7 h-7 rounded-full border-2 border-white/10" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-base font-sans transition-colors duration-300 ${
|
||||||
|
isCompleted ? 'text-white' : isActive ? 'text-purple-200' : 'text-white/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isCompleted ? step.labelDone : step.label}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="text-white/40 text-xs mt-4">
|
<div className="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
||||||
AI가 마케팅 데이터를 분석하고 있습니다. 약 1~2분 소요됩니다.
|
<motion.div
|
||||||
</p>
|
initial={{ width: '0%' }}
|
||||||
</>
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-white/40 text-xs mt-4">
|
||||||
|
AI가 마케팅 데이터를 분석하고 있습니다. 약 1~2분 소요됩니다.
|
||||||
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@
|
||||||
* 워크스페이스용 액션바는 노출되지 않습니다.
|
* 워크스페이스용 액션바는 노출되지 않습니다.
|
||||||
*/
|
*/
|
||||||
import { Link, useParams } from 'react-router';
|
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 { useReportPageData } from '../hooks/useReportPageData';
|
||||||
import { ReportNav } from '../components/ReportNav';
|
import { ReportNav } from '../components/ReportNav';
|
||||||
import { ScreenshotProvider } from '../stores/ScreenshotContext';
|
import { ScreenshotProvider } from '../stores/ScreenshotContext';
|
||||||
|
|
@ -46,6 +47,15 @@ export default function GuestReportPage() {
|
||||||
<div className="pt-20">
|
<div className="pt-20">
|
||||||
<ReportNav
|
<ReportNav
|
||||||
sections={REPORT_SECTIONS}
|
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={
|
rightSlot={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<DownloadMenuButton
|
<DownloadMenuButton
|
||||||
|
|
@ -56,7 +66,7 @@ export default function GuestReportPage() {
|
||||||
to={`/plan/${id}`}
|
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"
|
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" />
|
<ArrowRight size={11} className="opacity-80" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@
|
||||||
* - 하단의 도입 문의 CTA 없음
|
* - 하단의 도입 문의 CTA 없음
|
||||||
*/
|
*/
|
||||||
import { Link, useParams } from 'react-router';
|
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 { useReportPageData } from '../hooks/useReportPageData';
|
||||||
import { ReportNav } from '../components/ReportNav';
|
import { ReportNav } from '../components/ReportNav';
|
||||||
import { ScreenshotProvider } from '../stores/ScreenshotContext';
|
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"
|
className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-500 hover:text-primary-900 transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={14} />
|
<ArrowLeft size={14} />
|
||||||
워크스페이스로
|
클리닉으로
|
||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
rightSlot={
|
rightSlot={
|
||||||
|
|
@ -72,7 +73,7 @@ export default function UserReportPage() {
|
||||||
to={`/clinics/${clinicId}/plan/${id}`}
|
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"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
*/
|
*/
|
||||||
import ky, { type KyInstance } from 'ky'
|
import ky, { type KyInstance } from 'ky'
|
||||||
|
|
||||||
|
const API_KEY = import.meta.env.VITE_API_KEY as string | undefined;
|
||||||
|
|
||||||
export const kyInstance: KyInstance = ky.create({
|
export const kyInstance: KyInstance = ky.create({
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
|
|
@ -15,6 +17,9 @@ export const kyInstance: KyInstance = ky.create({
|
||||||
throwHttpErrors: false,
|
throwHttpErrors: false,
|
||||||
hooks: {
|
hooks: {
|
||||||
beforeRequest: [
|
beforeRequest: [
|
||||||
|
(request) => {
|
||||||
|
if (API_KEY) request.headers.set('x-api-key', API_KEY);
|
||||||
|
},
|
||||||
// TODO: 인증 토큰 주입
|
// TODO: 인증 토큰 주입
|
||||||
// request => {
|
// request => {
|
||||||
// const token = localStorage.getItem('token')
|
// const token = localStorage.getItem('token')
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -27,6 +27,7 @@ import type {
|
||||||
ClinicCreate,
|
ClinicCreate,
|
||||||
ClinicCreateResponse,
|
ClinicCreateResponse,
|
||||||
ClinicHistoryResponse,
|
ClinicHistoryResponse,
|
||||||
|
ClinicResponse,
|
||||||
HTTPValidationError
|
HTTPValidationError
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
|
||||||
|
|
@ -128,6 +129,125 @@ export const useCreateClinic = <TError = HTTPValidationError,
|
||||||
return useMutation(mutationOptions, queryClient);
|
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
|
* @summary Get Clinic History
|
||||||
*/
|
*/
|
||||||
export type getClinicHistoryResponse200 = {
|
export type getClinicHistoryResponse200 = {
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ import type {
|
||||||
} from '@tanstack/react-query';
|
} from '@tanstack/react-query';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
HTTPValidationError,
|
GetReport200,
|
||||||
ReportResponse
|
HTTPValidationError
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
|
||||||
import { customFetcher } from '../../api';
|
import { customFetcher } from '../../api';
|
||||||
|
|
@ -35,7 +35,7 @@ type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||||
* @summary Get Report
|
* @summary Get Report
|
||||||
*/
|
*/
|
||||||
export type getReportResponse200 = {
|
export type getReportResponse200 = {
|
||||||
data: ReportResponse
|
data: GetReport200
|
||||||
status: 200
|
status: 200
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,11 @@
|
||||||
* FastAPI
|
* FastAPI
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
import type { AnalysisCreateClinicId } from './analysisCreateClinicId';
|
|
||||||
import type { AnalysisCreateUrl } from './analysisCreateUrl';
|
|
||||||
import type { Channels } from './channels';
|
import type { Channels } from './channels';
|
||||||
import type { AnalysisOptions } from './analysisOptions';
|
import type { AnalysisOptions } from './analysisOptions';
|
||||||
|
|
||||||
export interface AnalysisCreate {
|
export interface AnalysisCreate {
|
||||||
clinic_id?: AnalysisCreateClinicId;
|
clinic_id: string;
|
||||||
url?: AnalysisCreateUrl;
|
|
||||||
channels: Channels;
|
channels: Channels;
|
||||||
options?: AnalysisOptions;
|
options?: AnalysisOptions;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -4,12 +4,7 @@
|
||||||
* FastAPI
|
* FastAPI
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
import type { ClinicCreateNameEn } from './clinicCreateNameEn';
|
|
||||||
import type { ClinicCreateAddress } from './clinicCreateAddress';
|
|
||||||
|
|
||||||
export interface ClinicCreate {
|
export interface ClinicCreate {
|
||||||
url: string;
|
url: string;
|
||||||
name: string;
|
|
||||||
name_en?: ClinicCreateNameEn;
|
|
||||||
address?: ClinicCreateAddress;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -5,4 +5,4 @@
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ChannelVerifyRequestYoutube = string | null;
|
export type ClinicResponseHospitalNameEn = string | null;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -5,4 +5,4 @@
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ChannelVerifyRequestInstagram = string[] | null;
|
export type ClinicResponseRawDataAnyOf = { [key: string]: unknown };
|
||||||
|
|
@ -5,4 +5,4 @@
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ClinicCreateAddress = string | null;
|
export type ClinicResponseRoadAddress = string | null;
|
||||||
|
|
@ -5,4 +5,4 @@
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type AnalysisCreateUrl = string | null;
|
export type ClinicResponseUrl = string | null;
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface ClinicInfo {
|
export interface ConversionStrategy {
|
||||||
name: string;
|
summary: string;
|
||||||
url: string;
|
actions: string[];
|
||||||
}
|
}
|
||||||
|
|
@ -4,5 +4,6 @@
|
||||||
* FastAPI
|
* FastAPI
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
import type { ReportOutput } from './reportOutput';
|
||||||
|
|
||||||
export type AnalysisCreateClinicId = string | null;
|
export type GetReport200 = ReportOutput | null;
|
||||||
|
|
@ -6,22 +6,13 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './analysisCreate';
|
export * from './analysisCreate';
|
||||||
export * from './analysisCreateClinicId';
|
|
||||||
export * from './analysisCreateUrl';
|
|
||||||
export * from './analysisOptions';
|
export * from './analysisOptions';
|
||||||
export * from './analysisStartResponse';
|
export * from './analysisStartResponse';
|
||||||
export * from './analysisStatus';
|
export * from './analysisStatus';
|
||||||
export * from './analysisStatusResponse';
|
export * from './analysisStatusResponse';
|
||||||
export * from './analysisStatusResponseChannelErrors';
|
export * from './analysisStatusResponseChannelErrors';
|
||||||
export * from './analysisStatusResponseCompletedAt';
|
export * from './analysisStatusResponseCompletedAt';
|
||||||
export * from './channelVerifyRequest';
|
export * from './channelScore';
|
||||||
export * from './channelVerifyRequestInstagram';
|
|
||||||
export * from './channelVerifyRequestYoutube';
|
|
||||||
export * from './channelVerifyResponse';
|
|
||||||
export * from './channelVerifyResponseInstagram';
|
|
||||||
export * from './channelVerifyResponseInstagramAnyOfItem';
|
|
||||||
export * from './channelVerifyResponseYoutube';
|
|
||||||
export * from './channelVerifyResponseYoutubeAnyOf';
|
|
||||||
export * from './channels';
|
export * from './channels';
|
||||||
export * from './channelsFacebook';
|
export * from './channelsFacebook';
|
||||||
export * from './channelsGangnamUnni';
|
export * from './channelsGangnamUnni';
|
||||||
|
|
@ -29,25 +20,28 @@ export * from './channelsInstagram';
|
||||||
export * from './channelsNaverBlog';
|
export * from './channelsNaverBlog';
|
||||||
export * from './channelsYoutube';
|
export * from './channelsYoutube';
|
||||||
export * from './clinicCreate';
|
export * from './clinicCreate';
|
||||||
export * from './clinicCreateAddress';
|
|
||||||
export * from './clinicCreateNameEn';
|
|
||||||
export * from './clinicCreateResponse';
|
export * from './clinicCreateResponse';
|
||||||
export * from './clinicHistoryResponse';
|
export * from './clinicHistoryResponse';
|
||||||
export * from './clinicHistoryResponseMetricsTimeseries';
|
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 './hTTPValidationError';
|
||||||
export * from './planCreate';
|
export * from './planCreate';
|
||||||
export * from './planResponse';
|
export * from './planResponse';
|
||||||
export * from './planResponseBrandGuide';
|
export * from './planResponseBrandGuide';
|
||||||
export * from './planResponseContentStrategy';
|
export * from './planResponseContentStrategy';
|
||||||
export * from './reportResponse';
|
export * from './reportOutput';
|
||||||
export * from './reportResponseConversionStrategy';
|
export * from './reportOutputFacebook';
|
||||||
export * from './reportResponseFacebook';
|
export * from './reportOutputGangnamUnni';
|
||||||
export * from './reportResponseGangnamUnni';
|
export * from './reportOutputInstagram';
|
||||||
export * from './reportResponseInstagram';
|
export * from './reportOutputNaverBlog';
|
||||||
export * from './reportResponseNaverBlog';
|
export * from './reportOutputYoutube';
|
||||||
export * from './reportResponseNaverPlace';
|
|
||||||
export * from './reportResponseYoutube';
|
|
||||||
export * from './runSummary';
|
export * from './runSummary';
|
||||||
export * from './runSummaryCompletedAt';
|
export * from './runSummaryCompletedAt';
|
||||||
export * from './runSummaryOverallScore';
|
export * from './runSummaryOverallScore';
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -7,5 +7,6 @@ export const PLAN_SECTIONS = [
|
||||||
{ id: 'repurposing-proposal', label: '리퍼포징 제안' },
|
{ id: 'repurposing-proposal', label: '리퍼포징 제안' },
|
||||||
{ id: 'workflow-tracker', label: '제작 파이프라인' },
|
{ id: 'workflow-tracker', label: '제작 파이프라인' },
|
||||||
{ id: 'my-asset-upload', label: '나의 소재' },
|
{ id: 'my-asset-upload', label: '나의 소재' },
|
||||||
{ id: 'strategy-adjustment', label: '전략 조정' },
|
// 성과 기반 전략 조정 — 본 차수 미포함, 후속 모듈 이관
|
||||||
|
// { id: 'strategy-adjustment', label: '전략 조정' },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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} />;
|
||||||
|
}
|
||||||
|
|
@ -16,16 +16,16 @@ export default function Footer() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-slate-500 leading-relaxed space-y-1">
|
<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>사업자 등록번호 : 620-87-00810 | 대표 : 안성민</p>
|
||||||
<p>본사 : 41593 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호</p>
|
<p>본사 : 41593 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호</p>
|
||||||
<p>연구소 : 13453 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)</p>
|
<p>연구소 : 13453 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)</p>
|
||||||
<p>전화 : 070-4260-8310 | 010-2755-6463</p>
|
<p>전화 : 070-4260-8310 | 010-2755-6463</p>
|
||||||
<p>이메일 : o2oteam@o2o.kr</p>
|
<p>이메일 : o2oteam@o2o.kr</p>
|
||||||
</div>
|
</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
|
Copyright ⓒ O2O Inc. All rights reserved
|
||||||
</div>
|
</p>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ export default function Navbar() {
|
||||||
{/* 가운데 메뉴 — 랜딩 페이지 섹션 순서 그대로 */}
|
{/* 가운데 메뉴 — 랜딩 페이지 섹션 순서 그대로 */}
|
||||||
<div className="hidden md:flex items-center gap-7 text-sm font-medium text-slate-600">
|
<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">
|
<a href="/#home" className="hover:text-primary-900 transition-colors">
|
||||||
Home
|
Product
|
||||||
</a>
|
</a>
|
||||||
<a href="/#audience" className="hover:text-primary-900 transition-colors">
|
<a href="/#audience" className="hover:text-primary-900 transition-colors">
|
||||||
Audience
|
Audience
|
||||||
|
|
@ -46,7 +46,7 @@ export default function Navbar() {
|
||||||
Problems
|
Problems
|
||||||
</a>
|
</a>
|
||||||
<a href="/#solution" className="hover:text-primary-900 transition-colors">
|
<a href="/#solution" className="hover:text-primary-900 transition-colors">
|
||||||
Product
|
Solution
|
||||||
</a>
|
</a>
|
||||||
<a href="/#modules" className="hover:text-primary-900 transition-colors">
|
<a href="/#modules" className="hover:text-primary-900 transition-colors">
|
||||||
Modules
|
Modules
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@
|
||||||
@media print {
|
@media print {
|
||||||
@page {
|
@page {
|
||||||
size: A4;
|
size: A4;
|
||||||
margin: 10mm 10mm;
|
margin: 15mm 10mm 10mm 10mm;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 브랜드 색·다크 섹션·그라데이션 그대로 유지 */
|
/* 브랜드 색·다크 섹션·그라데이션 그대로 유지 */
|
||||||
|
|
@ -116,10 +116,10 @@
|
||||||
padding-top: 0 !important;
|
padding-top: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ReportHeader/PlanHeader 등 첫 섹션은 페이지 맨 위에 바로 붙도록 상단 패딩 제거 */
|
/* ReportHeader/PlanHeader 등 첫 섹션 상단 — @page top margin (15mm) 외 약간의 여유 */
|
||||||
[data-report-content] > section:first-child,
|
[data-report-content] > section:first-child,
|
||||||
[data-plan-content] > section:first-child {
|
[data-plan-content] > section:first-child {
|
||||||
padding-top: 0 !important;
|
padding-top: 4mm !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* break-inside: avoid는 의도적으로 안 씀.
|
/* break-inside: avoid는 의도적으로 안 씀.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue