Compare commits

..

12 Commits

65 changed files with 13862 additions and 18796 deletions

13722
index.css

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,7 @@
<meta property="og:title" content="ADO2 - AI 마케팅 자동화 플랫폼" /> <meta property="og:title" content="ADO2 - AI 마케팅 자동화 플랫폼" />
<!-- ③ Open Graph 설명 --> <!-- ③ Open Graph 설명 -->
<meta property="og:description" content="ADO2는 ㈜에이아이오투오의 AI 기반 마케팅 자동화 플랫폼입니다. 콘텐츠 제작부터 채널 배포까지 자동으로 처리합니다." /> <meta property="og:description" content="ADO2는 ㈜에이아이오투오의 AI 기반 마케팅 자동화 플랫폼입니다. 콘텐츠 제작부터 채널 배포까지 자동으로 처리합니다." />
<meta property="og:url" content="https://demo.castad.net/" /> <meta property="og:url" content="https://ado2.o2osolution.ai" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<link rel="icon" type="image/svg+xml" href="/favicon_32.svg" sizes="32x32"> <link rel="icon" type="image/svg+xml" href="/favicon_32.svg" sizes="32x32">
<link rel="icon" type="image/svg+xml" href="/favicon_48.svg" sizes="48x48"> <link rel="icon" type="image/svg+xml" href="/favicon_48.svg" sizes="48x48">
@ -162,7 +162,6 @@
} }
} }
</script> </script>
<link rel="stylesheet" href="/index.css">
</head> </head>
<body class="bg-[#121a1d]"> <body class="bg-[#121a1d]">
<div id="root"></div> <div id="root"></div>

43
package-lock.json generated
View File

@ -16,8 +16,6 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2", "typescript": "~5.8.2",
"vite": "^6.2.0" "vite": "^6.2.0"
@ -54,7 +52,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@ -1291,32 +1288,10 @@
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@types/use-sync-external-store": { "node_modules/@types/use-sync-external-store": {
"version": "0.0.6", "version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@ -1374,7 +1349,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@ -1426,13 +1400,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": { "node_modules/d3-array": {
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
@ -1724,7 +1691,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4" "@babel/runtime": "^7.28.4"
}, },
@ -1845,7 +1811,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -1887,7 +1852,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -1897,7 +1861,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@ -1944,7 +1907,6 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/use-sync-external-store": "^0.0.6", "@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0" "use-sync-external-store": "^1.4.0"
@ -2007,8 +1969,7 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/redux-thunk": { "node_modules/redux-thunk": {
"version": "3.1.0", "version": "3.1.0",
@ -2122,7 +2083,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -2206,7 +2166,6 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",

View File

@ -17,8 +17,6 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2", "typescript": "~5.8.2",
"vite": "^6.2.0" "vite": "^6.2.0"

View File

@ -1,244 +0,0 @@
{
"status": "completed",
"image_list": [
"https://ldb-phinf.pstatic.net/20260112_189/1768176025117RmItD_PNG/ChatGPT_Image_2026%B3%E2_1%BF%F9_9%C0%CF_%BF%C0%C8%C4_03_21_56.png",
"https://ldb-phinf.pstatic.net/20251215_290/1765776039787CALYj_JPEG/IMG_0060.JPG",
"https://ldb-phinf.pstatic.net/20251215_70/1765776016674Nbo0Q_JPEG/IMG_0067_%BA%B9%BB%E7.jpg",
"https://ldb-phinf.pstatic.net/20251215_136/17657760168457kDf6_JPEG/KakaoTalk_20251214_181208552_03_%BA%B9%BB%E7.jpg",
"https://ldb-phinf.pstatic.net/20251215_38/1765776039760cYgOj_JPEG/IMG_0044.JPG",
"https://ldb-phinf.pstatic.net/20251128_248/1764320049551B1gHl_JPEG/KakaoTalk_20251128_174635084_27.jpg",
"https://ldb-phinf.pstatic.net/20251128_209/1764320105053L8V2f_JPEG/KakaoTalk_20251128_174635084_17.jpg",
"https://ldb-phinf.pstatic.net/20241205_181/1733360083850LsEVv_JPEG/KakaoTalk_20241203_155353799_09.jpg",
"https://ldb-phinf.pstatic.net/20251128_209/1764320017959Rh3vs_JPEG/KakaoTalk_20251128_174635084_13.jpg",
"https://ldb-phinf.pstatic.net/20251128_102/1764320169035vluJN_JPEG/KakaoTalk_20251128_174635084_12.jpg",
"https://ldb-phinf.pstatic.net/20241129_249/17328584834569zWJT_JPEG/6S5A0642-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20241129_196/1732858501391FjLpd_JPEG/6S5A0657.jpg",
"https://ldb-phinf.pstatic.net/20241202_230/1733105198846QGbcW_JPEG/8.jpg",
"https://ldb-phinf.pstatic.net/20241202_94/1733105214373Q8ncR_JPEG/%B5%BF%B1%D7%B6%F3%B9%CC_%C3%A2%B9%AE_%B9%E6%B0%A1%C0%BB_2.jpg",
"https://ldb-phinf.pstatic.net/20231207_110/1701910802632rDM5r_JPEG/a_%28301%29-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20241202_7/1733106037765MNR8e_JPEG/9-1.jpg",
"https://ldb-phinf.pstatic.net/20240118_42/17055625561156LIx7_JPEG/6S5A0809-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240118_246/1705562556109bihHB_JPEG/6S5A1820-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20241202_259/1733104881690lDgnH_JPEG/3-2.jpg",
"https://ldb-phinf.pstatic.net/20231207_76/1701910651888PpVap_JPEG/%C6%EA%B5%E5%B6%F3%C0%CC%B7%EB.jpg",
"https://ldb-phinf.pstatic.net/20231207_242/1701910651863IAAlE_JPEG/%B0%AD%BE%C6%C1%F6_%BA%F1%C7%B0.jpg",
"https://ldb-phinf.pstatic.net/20241202_120/17331038087267aQ0S_JPEG/KakaoTalk_20241015_154536888_04.jpg",
"https://ldb-phinf.pstatic.net/20250710_164/17521338301365LRwl_JPEG/6S5A6748-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20250710_169/1752133830016fqFlL_JPEG/6S5A6760-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20231207_80/1701920469022ef7TW_JPEG/6S5A0614-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_140/17173835278369nntD_JPEG/6S5A6707.jpg",
"https://ldb-phinf.pstatic.net/20250710_173/1752133829861tPALd_JPEG/6S5A7136.jpg",
"https://ldb-phinf.pstatic.net/20241202_248/1733106037776BGjJx_JPEG/1-1.jpg",
"https://ldb-phinf.pstatic.net/20240603_275/1717383527874oJOpz_JPEG/DJI_0040-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20250710_153/1752133811173ba7oE_JPEG/6S5A6458.jpg",
"https://ldb-phinf.pstatic.net/20250710_185/1752133829868FyD76_JPEG/6S5A6815.jpg",
"https://ldb-phinf.pstatic.net/20250710_220/1752133829963w1aee_JPEG/6S5A6856.jpg",
"https://ldb-phinf.pstatic.net/20250710_228/1752133830002U65jg_JPEG/6S5A6864.jpg",
"https://ldb-phinf.pstatic.net/20250710_178/1752133829793fQQ2E_JPEG/6S5A7016-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20250710_279/1752133829838lTSC9_JPEG/6S5A7034-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20250710_179/1752133811452tOl7F_JPEG/6S5A6363.jpg",
"https://ldb-phinf.pstatic.net/20250710_75/1752133811152vd5ts_JPEG/6S5A6513.jpg",
"https://ldb-phinf.pstatic.net/20250710_147/1752133810693TfR5M_JPEG/6S5A6556.jpg",
"https://ldb-phinf.pstatic.net/20250710_202/1752133811177NRBDn_JPEG/6S5A6619-2.jpg",
"https://ldb-phinf.pstatic.net/20250710_196/1752133811066ufvpA_JPEG/6S5A6657.jpg",
"https://ldb-phinf.pstatic.net/20250710_280/1752133811307QCs3n_JPEG/6S5A6709.jpg",
"https://ldb-phinf.pstatic.net/20240603_85/1717383527884ST2DF_JPEG/6S5A6540.jpg",
"https://ldb-phinf.pstatic.net/20240603_249/1717383528285Om6dI_JPEG/6S5A6648.jpg",
"https://ldb-phinf.pstatic.net/20240603_288/1717383528022TxA4c_JPEG/6S5A6785.jpg",
"https://ldb-phinf.pstatic.net/20240909_284/1725865224424ux2W4_JPEG/6S5A9299-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20241129_87/1732858513628bWPt7_JPEG/6S5A9837-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20241202_50/1733105457578cGfYD_JPEG/6S5A9807-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_100/1717383527950q4yNf_JPEG/6S5A7056.jpg",
"https://ldb-phinf.pstatic.net/20240603_221/1717383527791s4ziP_JPEG/6S5A6673.jpg",
"https://ldb-phinf.pstatic.net/20241202_134/17331166295182o4vs_JPEG/47%C6%F2_%2829%29.jpg",
"https://ldb-phinf.pstatic.net/20241202_249/17331060371685uFQi_JPEG/3-1_%281%29.jpg",
"https://ldb-phinf.pstatic.net/20240909_238/1725865224397wnWE5_JPEG/DJI_0157.jpg",
"https://ldb-phinf.pstatic.net/20231206_34/1701833355386Nsg5j_JPEG/6S5A9995.jpg",
"https://ldb-phinf.pstatic.net/20241202_293/1733105206236d8l2f_JPEG/8-1.jpg",
"https://ldb-phinf.pstatic.net/20241202_224/1733105239528RLN59_JPEG/3-1-min.jpg",
"https://ldb-phinf.pstatic.net/20241202_31/1733116629874BYgpO_JPEG/47%C6%F2_%2823%29.jpg",
"https://ldb-phinf.pstatic.net/20241205_106/1733360083918ER1RI_JPEG/KakaoTalk_20241203_155353799_27.jpg",
"https://ldb-phinf.pstatic.net/20241129_126/17328584834200qVDw_JPEG/6S5A0645.jpg",
"https://ldb-phinf.pstatic.net/20240909_35/1725865128231ar2eM_PNG/KakaoTalk_20240710_163045662.png",
"https://ldb-phinf.pstatic.net/20241202_153/1733105180908PyVIC_JPEG/KakaoTalk_20231027_143438563_25-min.jpg",
"https://ldb-phinf.pstatic.net/20241129_128/1732858517984PFmrJ_JPEG/KakaoTalk_20241119_105023750.jpg",
"https://ldb-phinf.pstatic.net/20231207_124/1701910720268qeACJ_JPEG/%B9%D9%BA%A3%C5%A5.jpg",
"https://ldb-phinf.pstatic.net/20241202_55/173310545746333mf8_JPEG/6S5A9837-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20241202_11/1733106430749ovoeY_JPEG/37%C6%F2_%2820%29.jpg",
"https://ldb-phinf.pstatic.net/20231207_185/1701910772479mexsh_JPEG/a_%28282%29.jpg",
"https://ldb-phinf.pstatic.net/20240118_211/17055625561294XsN2_JPEG/6S5A1803-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240118_156/17055625560847R7SL_JPEG/6S5A0934-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20231207_179/17019107724184CCEU_JPEG/6S5A9906.jpg",
"https://ldb-phinf.pstatic.net/20231207_287/1701910772339cG7K0_JPEG/a_%28119%29.jpg",
"https://ldb-phinf.pstatic.net/20231207_40/1701910675360dvC1i_JPEG/%C8%AD%C0%E5%BD%C7_%281%29.jpg",
"https://ldb-phinf.pstatic.net/20231207_263/1701910675262pevKk_JPEG/%C8%AD%C0%E5%BD%C7_%284%29.jpg",
"https://ldb-phinf.pstatic.net/20231207_73/1701910651864xPI48_JPEG/%BB%E7%B6%F7_%BC%A4%C7%AA.jpg",
"https://ldb-phinf.pstatic.net/20231207_265/17019204486629hzae_JPEG/6S5A0645.jpg",
"https://ldb-phinf.pstatic.net/20241202_147/1733103809000xsMrG_JPEG/KakaoTalk_20241015_154532027_01.jpg",
"https://ldb-phinf.pstatic.net/20241202_77/1733103809037HSO4S_JPEG/KakaoTalk_20241015_154532027_02.jpg",
"https://ldb-phinf.pstatic.net/20241202_233/1733103809020IMEdL_JPEG/KakaoTalk_20241015_154532027_06.jpg",
"https://ldb-phinf.pstatic.net/20241202_53/1733103808802Wui9x_JPEG/KakaoTalk_20241015_154532027_04.jpg",
"https://ldb-phinf.pstatic.net/20241202_230/1733103808998dOq0b_JPEG/KakaoTalk_20241015_154532027_15.jpg",
"https://ldb-phinf.pstatic.net/20241202_122/1733103808509lOt9x_JPEG/KakaoTalk_20241015_154536888_13.jpg",
"https://ldb-phinf.pstatic.net/20241202_246/1733103809045RdnEP_JPEG/KakaoTalk_20241015_154536888_11.jpg",
"https://ldb-phinf.pstatic.net/20241202_287/1733103808656oB1xz_JPEG/KakaoTalk_20241015_154536888_06.jpg",
"https://ldb-phinf.pstatic.net/20241202_222/1733103808508hkf5K_JPEG/KakaoTalk_20241015_154536888_08.jpg",
"https://ldb-phinf.pstatic.net/20241202_229/1733103808818KGAps_JPEG/KakaoTalk_20241015_154536888_02.jpg",
"https://ldb-phinf.pstatic.net/20241202_12/1733103808751cVAsT_JPEG/KakaoTalk_20241015_154536888_03.jpg",
"https://ldb-phinf.pstatic.net/20240909_261/1725865224473Uo7V9_JPEG/6S5A9371-%C6%ED%C1%FD-%C6%ED%C1%FD-2.jpg",
"https://ldb-phinf.pstatic.net/20240909_22/1725865224495AL798_JPEG/6S5A9557-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240909_177/1725865224462eX1Pi_JPEG/DJI_0149-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240909_165/1725865224480S7c1j_JPEG/6S5A9468-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240909_85/1725865224421CNRrk_JPEG/DJI_0155.jpg",
"https://ldb-phinf.pstatic.net/20240909_62/1725865224361fP2d2_JPEG/6S5A9434.jpg",
"https://ldb-phinf.pstatic.net/20240909_155/1725865224363Xxt8y_JPEG/6S5A9431-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240909_13/1725865224457BGLGs_JPEG/6S5A9456.jpg",
"https://ldb-phinf.pstatic.net/20240603_162/1717383527882Vnrsk_JPEG/6S5A6786.jpg",
"https://ldb-phinf.pstatic.net/20240603_256/1717383527892WXOKA_JPEG/6S5A6896.jpg",
"https://ldb-phinf.pstatic.net/20240603_130/1717383527800oHSKA_JPEG/6S5A6904.jpg",
"https://ldb-phinf.pstatic.net/20240603_118/17173835277794IXlt_JPEG/6S5A6946.jpg",
"https://ldb-phinf.pstatic.net/20240603_257/1717383527822ITvHM_JPEG/6S5A6973.jpg",
"https://ldb-phinf.pstatic.net/20240603_170/1717383527880sTdFE_JPEG/DJI_0039-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_10/1717383527863CVLJ2_JPEG/DJI_0087-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_94/1717383527921sDeDB_JPEG/DJI_0091-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20231206_81/1701833426718D5Qwk_JPEG/b_%28119%29-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240118_153/17055625561274LAs5_JPEG/6S5A1460-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240118_8/1705562556139zGkjb_JPEG/6S5A1780.jpg",
"https://ldb-phinf.pstatic.net/20240118_57/1705562556129IR40O_JPEG/6S5A1199.jpg",
"https://ldb-phinf.pstatic.net/20240118_260/1705562556111cA7PG_JPEG/6S5A1927-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20231207_183/1701910720221oI66j_JPEG/%B9%D9%BA%A3%C5%A52.jpg",
"https://ldb-phinf.pstatic.net/20231207_247/1701910720113MBgKD_JPEG/%B9%D9%BA%A3%C5%A5_%282%29.jpg",
"https://ldb-phinf.pstatic.net/20231207_227/1701910720098FE7Si_JPEG/6S5A0001.jpg",
"https://ldb-phinf.pstatic.net/20231206_24/1701833408064fgoVj_JPEG/b_%2874%29.jpg",
"https://ldb-phinf.pstatic.net/20240603_100/1717383527883LWVq4_JPEG/6S5A6022-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_169/17173835278856AEUV_JPEG/6S5A5974-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_155/1717383527937gxiyc_JPEG/6S5A5988.jpg",
"https://ldb-phinf.pstatic.net/20231206_160/170183333003287LrV_JPEG/6S5A0243-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_217/1717383528190t41X5_JPEG/6S5A6027.jpg",
"https://ldb-phinf.pstatic.net/20240603_142/1717383527892jHtgI_JPEG/6S5A6643.jpg",
"https://ldb-phinf.pstatic.net/20240603_251/1717383527948jTN2a_JPEG/6S5A6125.jpg",
"https://ldb-phinf.pstatic.net/20240603_189/1717383527880wu4Bv_JPEG/6S5A6326.jpg",
"https://ldb-phinf.pstatic.net/20240603_14/1717383527794qCuuu_JPEG/6S5A6337.jpg",
"https://ldb-phinf.pstatic.net/20240603_220/1717383528017rKdOw_JPEG/6S5A6773.jpg"
],
"image_count": 119,
"m_id": 1,
"processed_info": {
"customer_name": "풀스테이 스테이펫 홍천",
"region": "",
"detail_region_info": "강원 홍천군 화촌면 답연밭길 5-2 스테이펫 홍천"
},
"marketing_analysis": {
"brand_identity": {
"location_feature_analysis": "강원 홍천 화촌면의 자연 친화적 입지는 도심 소음에서 벗어난 ‘조용한 숲세권’ 체류 니즈에 최적이며, 수도권에서 주말 이동이 가능한 거리감으로 숏브레이크 수요를 흡수하기 유리합니다.",
"concept_scalability": "‘스테이펫’ 콘셉트는 반려동물 동반 숙박을 넘어 펫 어메니티·펫 포토·펫 웰니스(산책 코스/케어 서비스)까지 확장 가능해, 체류 전 과정의 경험 가치를 패키지화하기 좋습니다."
},
"market_positioning": {
"category_definition": "자연 속 프라이빗 애견 동반 힐링 스테이",
"core_value": "반려동물과 ‘눈치 없이’ 온전히 쉬는 프라이빗한 쉼"
},
"target_persona": [
{
"persona": "주말 펫캉스 커플(반려견 1마리) 조용한 자연에서 둘+한 마리의 리셋 여행을 찾는 2030",
"age": {
"min_age": 27,
"max_age": 39
},
"favor_target": [
"반려견 동반 가능 숙소",
"자연 뷰/산책하기 좋은 동선",
"청결한 침구와 탈취 관리",
"프라이빗한 공간 구성",
"감성 사진이 잘 나오는 무드"
],
"decision_trigger": "반려견 동반 + 프라이빗하게 쉴 수 있다는 확신(후기/시설 안내)"
},
{
"persona": "도심 탈출 1인/2인 힐링러 조용한 곳에서 휴식·독서를 즐기며 재충전하는 직장인",
"age": {
"min_age": 29,
"max_age": 45
},
"favor_target": [
"소음 적은 입지",
"자연 속 뷰",
"편안한 침대/휴식형 라운지",
"야간 감성 조명",
"체크인/이용 동선의 간편함"
],
"decision_trigger": "‘조용함’과 ‘자연 몰입’이 보장되는 입지/분위기"
},
{
"persona": "가족형 펫오너 반려견과 함께 안전하고 편하게 머물며 추억을 만드는 3040",
"age": {
"min_age": 33,
"max_age": 49
},
"favor_target": [
"반려견 안전 동선(미끄럼/울타리 등)",
"넓은 휴식 공간",
"바베큐 등 가족형 액티비티",
"주차/이동 편의",
"위생/청결 신뢰"
],
"decision_trigger": "가족 단위도 불편 없는 공간감 + 펫 프렌들리 정책"
}
],
"selling_points": [
{
"english_category": "PET FRIENDLY",
"korean_category": "애견 동반",
"description": "반려견과 함께하는 완전한 휴식",
"score": 95
},
{
"english_category": "HEALING",
"korean_category": "힐링 요소",
"description": "홍천 자연 속 리셋 스테이",
"score": 90
},
{
"english_category": "PRIVACY",
"korean_category": "프라이버시",
"description": "눈치 없이 즐기는 프라이빗 쉼",
"score": 88
},
{
"english_category": "SHORT GETAWAY",
"korean_category": "숏브레이크",
"description": "주말에 딱, 가볍게 떠나는 홍천",
"score": 82
},
{
"english_category": "NIGHT MOOD",
"korean_category": "야간 감성",
"description": "밤이 더 예쁜 감성 무드",
"score": 78
},
{
"english_category": "BBQ PARTY",
"korean_category": "바베큐",
"description": "자연을 곁들인 바베큐 한 판",
"score": 75
},
{
"english_category": "PHOTO SPOT",
"korean_category": "포토 스팟",
"description": "반려견 인생샷 남기는 공간",
"score": 73
}
],
"target_keywords": [
"홍천애견동반숙소",
"강원도애견동반펜션",
"스테이펫",
"펫캉스",
"반려견동반여행",
"홍천감성숙소",
"강원도힐링스테이",
"숲뷰숙소",
"주말여행추천",
"프라이빗스테이"
]
}
}

View File

@ -1,244 +0,0 @@
{
"status": "completed",
"image_list": [
"https://ldb-phinf.pstatic.net/20260112_189/1768176025117RmItD_PNG/ChatGPT_Image_2026%B3%E2_1%BF%F9_9%C0%CF_%BF%C0%C8%C4_03_21_56.png",
"https://ldb-phinf.pstatic.net/20251215_290/1765776039787CALYj_JPEG/IMG_0060.JPG",
"https://ldb-phinf.pstatic.net/20251215_70/1765776016674Nbo0Q_JPEG/IMG_0067_%BA%B9%BB%E7.jpg",
"https://ldb-phinf.pstatic.net/20251215_136/17657760168457kDf6_JPEG/KakaoTalk_20251214_181208552_03_%BA%B9%BB%E7.jpg",
"https://ldb-phinf.pstatic.net/20251215_38/1765776039760cYgOj_JPEG/IMG_0044.JPG",
"https://ldb-phinf.pstatic.net/20251128_248/1764320049551B1gHl_JPEG/KakaoTalk_20251128_174635084_27.jpg",
"https://ldb-phinf.pstatic.net/20251128_209/1764320105053L8V2f_JPEG/KakaoTalk_20251128_174635084_17.jpg",
"https://ldb-phinf.pstatic.net/20241205_181/1733360083850LsEVv_JPEG/KakaoTalk_20241203_155353799_09.jpg",
"https://ldb-phinf.pstatic.net/20251128_209/1764320017959Rh3vs_JPEG/KakaoTalk_20251128_174635084_13.jpg",
"https://ldb-phinf.pstatic.net/20251128_102/1764320169035vluJN_JPEG/KakaoTalk_20251128_174635084_12.jpg",
"https://ldb-phinf.pstatic.net/20241129_249/17328584834569zWJT_JPEG/6S5A0642-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20241129_196/1732858501391FjLpd_JPEG/6S5A0657.jpg",
"https://ldb-phinf.pstatic.net/20241202_230/1733105198846QGbcW_JPEG/8.jpg",
"https://ldb-phinf.pstatic.net/20241202_94/1733105214373Q8ncR_JPEG/%B5%BF%B1%D7%B6%F3%B9%CC_%C3%A2%B9%AE_%B9%E6%B0%A1%C0%BB_2.jpg",
"https://ldb-phinf.pstatic.net/20231207_110/1701910802632rDM5r_JPEG/a_%28301%29-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20241202_7/1733106037765MNR8e_JPEG/9-1.jpg",
"https://ldb-phinf.pstatic.net/20240118_42/17055625561156LIx7_JPEG/6S5A0809-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240118_246/1705562556109bihHB_JPEG/6S5A1820-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20241202_259/1733104881690lDgnH_JPEG/3-2.jpg",
"https://ldb-phinf.pstatic.net/20231207_76/1701910651888PpVap_JPEG/%C6%EA%B5%E5%B6%F3%C0%CC%B7%EB.jpg",
"https://ldb-phinf.pstatic.net/20231207_242/1701910651863IAAlE_JPEG/%B0%AD%BE%C6%C1%F6_%BA%F1%C7%B0.jpg",
"https://ldb-phinf.pstatic.net/20241202_120/17331038087267aQ0S_JPEG/KakaoTalk_20241015_154536888_04.jpg",
"https://ldb-phinf.pstatic.net/20250710_164/17521338301365LRwl_JPEG/6S5A6748-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20250710_169/1752133830016fqFlL_JPEG/6S5A6760-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20231207_80/1701920469022ef7TW_JPEG/6S5A0614-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_140/17173835278369nntD_JPEG/6S5A6707.jpg",
"https://ldb-phinf.pstatic.net/20250710_173/1752133829861tPALd_JPEG/6S5A7136.jpg",
"https://ldb-phinf.pstatic.net/20241202_248/1733106037776BGjJx_JPEG/1-1.jpg",
"https://ldb-phinf.pstatic.net/20240603_275/1717383527874oJOpz_JPEG/DJI_0040-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20250710_153/1752133811173ba7oE_JPEG/6S5A6458.jpg",
"https://ldb-phinf.pstatic.net/20250710_185/1752133829868FyD76_JPEG/6S5A6815.jpg",
"https://ldb-phinf.pstatic.net/20250710_220/1752133829963w1aee_JPEG/6S5A6856.jpg",
"https://ldb-phinf.pstatic.net/20250710_228/1752133830002U65jg_JPEG/6S5A6864.jpg",
"https://ldb-phinf.pstatic.net/20250710_178/1752133829793fQQ2E_JPEG/6S5A7016-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20250710_279/1752133829838lTSC9_JPEG/6S5A7034-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20250710_179/1752133811452tOl7F_JPEG/6S5A6363.jpg",
"https://ldb-phinf.pstatic.net/20250710_75/1752133811152vd5ts_JPEG/6S5A6513.jpg",
"https://ldb-phinf.pstatic.net/20250710_147/1752133810693TfR5M_JPEG/6S5A6556.jpg",
"https://ldb-phinf.pstatic.net/20250710_202/1752133811177NRBDn_JPEG/6S5A6619-2.jpg",
"https://ldb-phinf.pstatic.net/20250710_196/1752133811066ufvpA_JPEG/6S5A6657.jpg",
"https://ldb-phinf.pstatic.net/20250710_280/1752133811307QCs3n_JPEG/6S5A6709.jpg",
"https://ldb-phinf.pstatic.net/20240603_85/1717383527884ST2DF_JPEG/6S5A6540.jpg",
"https://ldb-phinf.pstatic.net/20240603_249/1717383528285Om6dI_JPEG/6S5A6648.jpg",
"https://ldb-phinf.pstatic.net/20240603_288/1717383528022TxA4c_JPEG/6S5A6785.jpg",
"https://ldb-phinf.pstatic.net/20240909_284/1725865224424ux2W4_JPEG/6S5A9299-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20241129_87/1732858513628bWPt7_JPEG/6S5A9837-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20241202_50/1733105457578cGfYD_JPEG/6S5A9807-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_100/1717383527950q4yNf_JPEG/6S5A7056.jpg",
"https://ldb-phinf.pstatic.net/20240603_221/1717383527791s4ziP_JPEG/6S5A6673.jpg",
"https://ldb-phinf.pstatic.net/20241202_134/17331166295182o4vs_JPEG/47%C6%F2_%2829%29.jpg",
"https://ldb-phinf.pstatic.net/20241202_249/17331060371685uFQi_JPEG/3-1_%281%29.jpg",
"https://ldb-phinf.pstatic.net/20240909_238/1725865224397wnWE5_JPEG/DJI_0157.jpg",
"https://ldb-phinf.pstatic.net/20231206_34/1701833355386Nsg5j_JPEG/6S5A9995.jpg",
"https://ldb-phinf.pstatic.net/20241202_293/1733105206236d8l2f_JPEG/8-1.jpg",
"https://ldb-phinf.pstatic.net/20241202_224/1733105239528RLN59_JPEG/3-1-min.jpg",
"https://ldb-phinf.pstatic.net/20241202_31/1733116629874BYgpO_JPEG/47%C6%F2_%2823%29.jpg",
"https://ldb-phinf.pstatic.net/20241205_106/1733360083918ER1RI_JPEG/KakaoTalk_20241203_155353799_27.jpg",
"https://ldb-phinf.pstatic.net/20241129_126/17328584834200qVDw_JPEG/6S5A0645.jpg",
"https://ldb-phinf.pstatic.net/20240909_35/1725865128231ar2eM_PNG/KakaoTalk_20240710_163045662.png",
"https://ldb-phinf.pstatic.net/20241202_153/1733105180908PyVIC_JPEG/KakaoTalk_20231027_143438563_25-min.jpg",
"https://ldb-phinf.pstatic.net/20241129_128/1732858517984PFmrJ_JPEG/KakaoTalk_20241119_105023750.jpg",
"https://ldb-phinf.pstatic.net/20231207_124/1701910720268qeACJ_JPEG/%B9%D9%BA%A3%C5%A7.jpg",
"https://ldb-phinf.pstatic.net/20241202_55/173310545746333mf8_JPEG/6S5A9837-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20241202_11/1733106430749ovoeY_JPEG/37%C6%F2_%2820%29.jpg",
"https://ldb-phinf.pstatic.net/20231207_185/1701910772479mexsh_JPEG/a_%28282%29.jpg",
"https://ldb-phinf.pstatic.net/20240118_211/17055625561294XsN2_JPEG/6S5A1803-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240118_156/17055625560847R7SL_JPEG/6S5A0934-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20231207_179/17019107724184CCEU_JPEG/6S5A9906.jpg",
"https://ldb-phinf.pstatic.net/20231207_287/1701910772339cG7K0_JPEG/a_%28119%29.jpg",
"https://ldb-phinf.pstatic.net/20231207_40/1701910675360dvC1i_JPEG/%C8%AD%C0%E5%BD%C7_%281%29.jpg",
"https://ldb-phinf.pstatic.net/20231207_263/1701910675262pevKk_JPEG/%C8%AD%C0%E5%BD%C7_%284%29.jpg",
"https://ldb-phinf.pstatic.net/20231207_73/1701910651864xPI48_JPEG/%BB%E7%B6%F7_%BC%A4%C7%AA.jpg",
"https://ldb-phinf.pstatic.net/20231207_265/17019204486629hzae_JPEG/6S5A0645.jpg",
"https://ldb-phinf.pstatic.net/20241202_147/1733103809000xsMrG_JPEG/KakaoTalk_20241015_154532027_01.jpg",
"https://ldb-phinf.pstatic.net/20241202_77/1733103809037HSO4S_JPEG/KakaoTalk_20241015_154532027_02.jpg",
"https://ldb-phinf.pstatic.net/20241202_233/1733103809020IMEdL_JPEG/KakaoTalk_20241015_154532027_06.jpg",
"https://ldb-phinf.pstatic.net/20241202_53/1733103808802Wui9x_JPEG/KakaoTalk_20241015_154532027_04.jpg",
"https://ldb-phinf.pstatic.net/20241202_230/1733103808998dOq0b_JPEG/KakaoTalk_20241015_154532027_15.jpg",
"https://ldb-phinf.pstatic.net/20241202_122/1733103808509lOt9x_JPEG/KakaoTalk_20241015_154536888_13.jpg",
"https://ldb-phinf.pstatic.net/20241202_246/1733103809045RdnEP_JPEG/KakaoTalk_20241015_154536888_11.jpg",
"https://ldb-phinf.pstatic.net/20241202_287/1733103808656oB1xz_JPEG/KakaoTalk_20241015_154536888_06.jpg",
"https://ldb-phinf.pstatic.net/20241202_222/1733103808508hkf5K_JPEG/KakaoTalk_20241015_154536888_08.jpg",
"https://ldb-phinf.pstatic.net/20241202_229/1733103808818KGAps_JPEG/KakaoTalk_20241015_154536888_02.jpg",
"https://ldb-phinf.pstatic.net/20241202_12/1733103808751cVAsT_JPEG/KakaoTalk_20241015_154536888_03.jpg",
"https://ldb-phinf.pstatic.net/20240909_261/1725865224473Uo7V9_JPEG/6S5A9371-%C6%ED%C1%FD-%C6%ED%C1%FD-2.jpg",
"https://ldb-phinf.pstatic.net/20240909_22/1725865224495AL798_JPEG/6S5A9557-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240909_177/1725865224462eX1Pi_JPEG/DJI_0149-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240909_165/1725865224480S7c1j_JPEG/6S5A9468-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240909_85/1725865224421CNRrk_JPEG/DJI_0155.jpg",
"https://ldb-phinf.pstatic.net/20240909_62/1725865224361fP2d2_JPEG/6S5A9434.jpg",
"https://ldb-phinf.pstatic.net/20240909_155/1725865224363Xxt8y_JPEG/6S5A9431-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240909_13/1725865224457BGLGs_JPEG/6S5A9456.jpg",
"https://ldb-phinf.pstatic.net/20240603_162/1717383527882Vnrsk_JPEG/6S5A6786.jpg",
"https://ldb-phinf.pstatic.net/20240603_256/1717383527892WXOKA_JPEG/6S5A6896.jpg",
"https://ldb-phinf.pstatic.net/20240603_130/1717383527800oHSKA_JPEG/6S5A6904.jpg",
"https://ldb-phinf.pstatic.net/20240603_118/17173835277794IXlt_JPEG/6S5A6946.jpg",
"https://ldb-phinf.pstatic.net/20240603_257/1717383527822ITvHM_JPEG/6S5A6973.jpg",
"https://ldb-phinf.pstatic.net/20240603_170/1717383527880sTdFE_JPEG/DJI_0039-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_10/1717383527863CVLJ2_JPEG/DJI_0087-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_94/1717383527921sDeDB_JPEG/DJI_0091-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20231206_81/1701833426718D5Qwk_JPEG/b_%28119%29-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240118_153/17055625561274LAs5_JPEG/6S5A1460-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240118_8/1705562556139zGkjb_JPEG/6S5A1780.jpg",
"https://ldb-phinf.pstatic.net/20240118_57/1705562556129IR40O_JPEG/6S5A1199.jpg",
"https://ldb-phinf.pstatic.net/20240118_260/1705562556111cA7PG_JPEG/6S5A1927-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20231207_183/1701910720221oI66j_JPEG/%B9%D9%BA%A3%C5%A52.jpg",
"https://ldb-phinf.pstatic.net/20231207_247/1701910720113MBgKD_JPEG/%B9%D9%BA%A3%C5%A5_%282%29.jpg",
"https://ldb-phinf.pstatic.net/20231207_227/1701910720098FE7Si_JPEG/6S5A0001.jpg",
"https://ldb-phinf.pstatic.net/20231206_24/1701833408064fgoVj_JPEG/b_%2874%29.jpg",
"https://ldb-phinf.pstatic.net/20240603_100/1717383527883LWVq4_JPEG/6S5A6022-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_169/17173835278856AEUV_JPEG/6S5A5974-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_155/1717383527937gxiyc_JPEG/6S5A5988.jpg",
"https://ldb-phinf.pstatic.net/20231206_160/170183333003287LrV_JPEG/6S5A0243-%C6%ED%C1%FD.jpg",
"https://ldb-phinf.pstatic.net/20240603_217/1717383528190t41X5_JPEG/6S5A6027.jpg",
"https://ldb-phinf.pstatic.net/20240603_142/1717383527892jHtgI_JPEG/6S5A6643.jpg",
"https://ldb-phinf.pstatic.net/20240603_251/1717383527948jTN2a_JPEG/6S5A6125.jpg",
"https://ldb-phinf.pstatic.net/20240603_189/1717383527880wu4Bv_JPEG/6S5A6326.jpg",
"https://ldb-phinf.pstatic.net/20240603_14/1717383527794qCuuu_JPEG/6S5A6337.jpg",
"https://ldb-phinf.pstatic.net/20240603_220/1717383528017rKdOw_JPEG/6S5A6773.jpg"
],
"image_count": 119,
"m_id": 1,
"processed_info": {
"customer_name": "Fullstay StayPet Hongcheon",
"region": "",
"detail_region_info": "5-2 Dapyeonbat-gil, Hwachon-myeon, Hongcheon-gun, Gangwon-do, Korea"
},
"marketing_analysis": {
"brand_identity": {
"location_feature_analysis": "Located in the nature-rich Hwachon-myeon area of Hongcheon, Gangwon-do, this property offers the ideal setting for a 'quiet forest retreat' away from urban noise. Its proximity to the Seoul metropolitan area makes it perfect for weekend short-break getaways.",
"concept_scalability": "The 'StayPet' concept extends beyond simple pet-friendly accommodation to include pet amenities, pet photo zones, and pet wellness experiences (walking trails/care services), enabling a comprehensive packaged experience throughout the entire stay."
},
"market_positioning": {
"category_definition": "Private Pet-Friendly Healing Stay in Nature",
"core_value": "A private retreat where you and your pet can relax without worry"
},
"target_persona": [
{
"persona": "Weekend Pet-cation Couples (with 1 dog) Millennials seeking a reset trip for two + one furry friend in peaceful nature",
"age": {
"min_age": 27,
"max_age": 39
},
"favor_target": [
"Pet-friendly accommodation",
"Nature views / great walking trails",
"Clean bedding and odor management",
"Private space configuration",
"Aesthetic mood for great photos"
],
"decision_trigger": "Confidence in pet-friendly + private relaxation (verified through reviews and facility info)"
},
{
"persona": "Urban Escape Solo/Duo Healers Working professionals recharging with quiet rest and reading in nature",
"age": {
"min_age": 29,
"max_age": 45
},
"favor_target": [
"Low-noise location",
"Nature views",
"Comfortable beds / relaxation lounge",
"Ambient night lighting",
"Simple check-in / easy access"
],
"decision_trigger": "Guaranteed 'quietness' and 'nature immersion' through location and atmosphere"
},
{
"persona": "Family Pet Owners Families in their 30s-40s creating memories with their pets in a safe, comfortable stay",
"age": {
"min_age": 33,
"max_age": 49
},
"favor_target": [
"Pet safety features (non-slip floors / fencing)",
"Spacious relaxation areas",
"Family activities like BBQ",
"Parking / travel convenience",
"Hygiene / cleanliness assurance"
],
"decision_trigger": "Spacious enough for families + reliable pet-friendly policies"
}
],
"selling_points": [
{
"english_category": "PET FRIENDLY",
"korean_category": "Pet-Friendly",
"description": "A complete retreat with your beloved pet",
"score": 95
},
{
"english_category": "HEALING",
"korean_category": "Healing",
"description": "A reset stay in Hongcheon's natural surroundings",
"score": 90
},
{
"english_category": "PRIVACY",
"korean_category": "Privacy",
"description": "A private retreat — relax without a care",
"score": 88
},
{
"english_category": "SHORT GETAWAY",
"korean_category": "Short Getaway",
"description": "A quick weekend escape to Hongcheon",
"score": 82
},
{
"english_category": "NIGHT MOOD",
"korean_category": "Night Mood",
"description": "An aesthetic ambiance that shines at night",
"score": 78
},
{
"english_category": "BBQ PARTY",
"korean_category": "BBQ Party",
"description": "A BBQ feast surrounded by nature",
"score": 75
},
{
"english_category": "PHOTO SPOT",
"korean_category": "Photo Spot",
"description": "The perfect spot for unforgettable pet photos",
"score": 73
}
],
"target_keywords": [
"Hongcheon pet-friendly stay",
"Gangwon-do pet pension",
"StayPet",
"Pet-cation",
"Travel with pets",
"Hongcheon aesthetic stay",
"Gangwon healing retreat",
"Forest view accommodation",
"Weekend trip recommendation",
"Private stay"
]
}
}

View File

@ -84,7 +84,7 @@
<li><strong>회사명:</strong> ㈜에이아이오투오</li> <li><strong>회사명:</strong> ㈜에이아이오투오</li>
<li><strong>서비스명:</strong> ADO2</li> <li><strong>서비스명:</strong> ADO2</li>
<li><strong>이메일:</strong> <a href="mailto:o2oteam@o2o.kr">o2oteam@o2o.kr</a></li> <li><strong>이메일:</strong> <a href="mailto:o2oteam@o2o.kr">o2oteam@o2o.kr</a></li>
<li><strong>웹사이트:</strong> https://demo.castad.net</li> <li><strong>웹사이트:</strong> https://ado2.o2osolution.ai</li>
</ul> </ul>
<p style="margin-top:40px; font-size:14px; color:#999;">본 방침은 2026년 5월 7일부터 시행됩니다. 최종 수정일: 2026년 5월 27일</p> <p style="margin-top:40px; font-size:14px; color:#999;">본 방침은 2026년 5월 7일부터 시행됩니다. 최종 수정일: 2026년 5월 27일</p>

View File

@ -58,7 +58,7 @@
<li><strong>회사명:</strong> ㈜에이아이오투오</li> <li><strong>회사명:</strong> ㈜에이아이오투오</li>
<li><strong>서비스명:</strong> ADO2</li> <li><strong>서비스명:</strong> ADO2</li>
<li><strong>이메일:</strong> o2oteam@o2o.kr</li> <li><strong>이메일:</strong> o2oteam@o2o.kr</li>
<li><strong>웹사이트:</strong> https://demo.castad.net</li> <li><strong>웹사이트:</strong> https://ado2.o2osolution.ai</li>
</ul> </ul>
<p style="margin-top:40px; font-size:14px; color:#999;">본 약관은 2026년 5월 7일부터 시행됩니다.</p> <p style="margin-top:40px; font-size:14px; color:#999;">본 약관은 2026년 5월 7일부터 시행됩니다.</p>

File diff suppressed because it is too large Load Diff

View File

@ -1,22 +0,0 @@
{
"name": "@ado2/higgsfield-remotion",
"version": "0.1.0",
"private": true,
"description": "ADO2 Higgsfield Shorts — Remotion 자막·타이틀 오버레이 합성",
"scripts": {
"studio": "remotion studio",
"render": "remotion render MumumShort out/mumum-poc2-final.mp4",
"upgrade": "remotion upgrade"
},
"dependencies": {
"@remotion/cli": "4.0.468",
"@remotion/google-fonts": "4.0.468",
"remotion": "4.0.468",
"react": "19.2.0",
"react-dom": "19.2.0"
},
"devDependencies": {
"@types/react": "19.2.0",
"typescript": "5.7.3"
}
}

View File

@ -1,6 +0,0 @@
import { Config } from "@remotion/cli/config";
Config.setVideoImageFormat("jpeg");
Config.setOverwriteOutput(true);
// H.264 + AAC, 모바일 9:16 Shorts/Reels 호환
Config.setCodec("h264");

View File

@ -1,58 +0,0 @@
import React from "react";
import { AbsoluteFill, OffthreadVideo, Sequence, staticFile } from "remotion";
import { ShortProps } from "./data/types";
import { HookTitle } from "./components/HookTitle";
import { BrandLines } from "./components/BrandLines";
import { SellingBadge } from "./components/SellingBadge";
import { EndCard } from "./components/EndCard";
// Higgsfield 완성본을 배경으로 깔고(오디오 포함), 자막/타이틀만 오버레이.
// 모든 콘텐츠는 props로 주입 (펜션/제품마다 교체).
export const MumumShort: React.FC<ShortProps> = ({
videoSrc,
hook,
sellingPoint,
brandLines,
endCard,
}) => {
return (
<AbsoluteFill style={{ backgroundColor: "black" }}>
<OffthreadVideo
src={staticFile(videoSrc)}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
{/* 상단 후킹 타이틀 */}
<Sequence from={hook.fromFrame} durationInFrames={hook.toFrame - hook.fromFrame}>
<HookTitle
eyebrow={hook.eyebrow}
title={hook.title}
fromFrame={0}
toFrame={hook.toFrame - hook.fromFrame}
/>
</Sequence>
{/* 셀링포인트: 3개 독립 박스 */}
<SellingBadge
items={sellingPoint.items}
fromFrame={sellingPoint.fromFrame}
toFrame={sellingPoint.toFrame}
/>
{/* 브랜드 감성 2줄 (상단 중앙) */}
<BrandLines
lines={brandLines.lines}
fromFrame={brandLines.fromFrame}
toFrame={brandLines.toFrame}
/>
{/* 엔드카드 + AI 디스클로저 */}
<EndCard
brand={endCard.brand}
location={endCard.location}
disclosure={endCard.disclosure}
fromFrame={endCard.fromFrame}
/>
</AbsoluteFill>
);
};

View File

@ -1,25 +0,0 @@
import React from "react";
import { Composition } from "remotion";
import { MumumShort } from "./MumumShort";
import { defaultProps } from "./data/mumum";
export const RemotionRoot: React.FC = () => {
return (
<Composition
id="MumumShort"
component={MumumShort}
defaultProps={defaultProps}
durationInFrames={defaultProps.durationInFrames}
fps={defaultProps.fps}
width={defaultProps.width}
height={defaultProps.height}
// props(영상 크기·길이)로 메타데이터 동적 설정 → 한 컴포지션이 모든 job 처리
calculateMetadata={({ props }) => ({
durationInFrames: props.durationInFrames,
fps: props.fps,
width: props.width,
height: props.height,
})}
/>
);
};

View File

@ -1,63 +0,0 @@
import React from "react";
import { useCurrentFrame, interpolate, AbsoluteFill } from "remotion";
import { serifFont } from "../fonts";
// 브랜드 감성 2줄: 우상단 통일 위치에 한 덩어리로 쌓아 노출.
// 첫 줄 먼저, 둘째 줄 살짝 뒤따라 등장 → 같은 자리에서 읽힘.
export const BrandLines: React.FC<{
lines: string[];
fromFrame: number;
toFrame: number;
}> = ({ lines, fromFrame, toFrame }) => {
const frame = useCurrentFrame();
const LINE_STAGGER = 18; // 줄 간 등장 간격(frame)
return (
<AbsoluteFill
style={{
justifyContent: "flex-start",
alignItems: "center", // 가로 중앙 정렬
flexDirection: "column",
paddingTop: 230, // 상단 (~18%) 높이 유지
gap: 8,
}}
>
{lines.map((line, i) => {
const start = fromFrame + i * LINE_STAGGER;
// interpolate는 배열이 단조증가해야 한다.
// 영상이 짧아 toFrame 이 start+24 이하로 내려오면 범위가 뒤집혀 크래시 발생.
const fadeIn = start + 12;
const fadeOut = Math.max(fadeIn + 1, toFrame - 12);
const end = Math.max(fadeOut + 1, toFrame);
const opacity = interpolate(
frame,
[start, fadeIn, fadeOut, end],
[0, 1, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
);
const rise = interpolate(frame, [start, start + 16], [16, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<div
key={i}
style={{
opacity,
transform: `translateY(${rise}px)`,
fontFamily: serifFont,
fontWeight: 700,
fontSize: 46,
color: "#FFFFFF",
textAlign: "center",
letterSpacing: 1,
textShadow: "0 2px 16px rgba(0,0,0,0.85)",
}}
>
{line}
</div>
);
})}
</AbsoluteFill>
);
};

View File

@ -1,91 +0,0 @@
import React from "react";
import {
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
AbsoluteFill,
} from "remotion";
import { serifFont } from "../fonts";
// 엔드카드: 브랜드 + 위치 + AI 디스클로저 (가드레일 필수)
export const EndCard: React.FC<{
brand: string;
location: string;
disclosure: string;
fromFrame: number;
}> = ({ brand, location, disclosure, fromFrame }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const appear = interpolate(frame, [fromFrame, fromFrame + 12], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const brandSpring = spring({
frame: frame - fromFrame,
fps,
config: { damping: 200, mass: 0.5 },
});
const brandScale = interpolate(brandSpring, [0, 1], [0.92, 1]);
return (
<AbsoluteFill
style={{
justifyContent: "center",
alignItems: "center",
// 하단을 살짝 어둡게 덮어 브랜드 가독성 확보
background: `rgba(0,0,0,${0.42 * appear})`,
opacity: appear,
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
transform: `scale(${brandScale})`,
}}
>
<div
style={{
fontFamily: serifFont,
fontWeight: 800,
fontSize: 76,
color: "#FFFFFF",
letterSpacing: 1,
textShadow: "0 3px 20px rgba(0,0,0,0.7)",
}}
>
{brand}
</div>
<div
style={{
fontFamily: serifFont,
fontWeight: 400,
fontSize: 30,
color: "#FFE9C7",
marginTop: 18,
letterSpacing: 2,
}}
>
{location}
</div>
</div>
<div
style={{
position: "absolute",
bottom: 70,
fontFamily: serifFont,
fontWeight: 400,
fontSize: 19,
color: "rgba(255,255,255,0.72)",
textAlign: "center",
padding: "0 40px",
}}
>
{disclosure}
</div>
</AbsoluteFill>
);
};

View File

@ -1,93 +0,0 @@
import React from "react";
import {
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
AbsoluteFill,
} from "remotion";
import { hookFont } from "../fonts";
export const HookTitle: React.FC<{
eyebrow: string;
title: string;
fromFrame: number;
toFrame: number;
}> = ({ eyebrow, title, fromFrame, toFrame }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// 스프링 슬라이드-다운 등장
const enter = spring({
frame: frame - fromFrame,
fps,
config: { damping: 200, mass: 0.6 },
});
const translateY = interpolate(enter, [0, 1], [-60, 0]);
// 퇴장 페이드
const fadeIn = fromFrame + 8;
const fadeOut = Math.max(fadeIn + 1, toFrame - 12);
const end = Math.max(fadeOut + 1, toFrame);
const opacity = interpolate(
frame,
[fromFrame, fadeIn, fadeOut, end],
[0, 1, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
);
return (
<AbsoluteFill style={{ opacity }}>
{/* 상단 가독성 그라데이션 */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 580,
background:
"linear-gradient(180deg, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0) 100%)",
}}
/>
<div
style={{
position: "absolute",
top: 280,
left: 0,
right: 0,
transform: `translateY(${translateY}px)`,
display: "flex",
flexDirection: "column",
alignItems: "center",
padding: "0 48px",
textAlign: "center",
}}
>
<div
style={{
fontFamily: hookFont,
fontSize: 34,
color: "#FFE9C7",
letterSpacing: 2,
marginBottom: 14,
textShadow: "0 2px 12px rgba(0,0,0,0.7)",
}}
>
{eyebrow}
</div>
<div
style={{
fontFamily: hookFont,
fontSize: 62,
lineHeight: 1.18,
color: "#FFFFFF",
textShadow: "0 3px 18px rgba(0,0,0,0.85)",
WebkitTextStroke: "1.5px rgba(0,0,0,0.35)",
}}
>
{title}
</div>
</div>
</AbsoluteFill>
);
};

View File

@ -1,72 +0,0 @@
import React from "react";
import {
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
AbsoluteFill,
} from "remotion";
import { serifFont } from "../fonts";
// 셀링포인트: 3개 독립 pill 박스를 세로로 쌓고 순차 등장.
// 컬러 지양 → 반투명 다크 pill + 흰색 텍스트. 앞 구간 단독 노출(시선 집중).
export const SellingBadge: React.FC<{
items: string[];
fromFrame: number;
toFrame: number;
}> = ({ items, fromFrame, toFrame }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const STAGGER = 9; // 박스 간 등장 간격(frame)
return (
<AbsoluteFill
style={{
justifyContent: "flex-start",
alignItems: "center",
flexDirection: "column",
paddingTop: 200, // 상단 배치 (~16%) — 이미지 가림 최소화
gap: 18,
}}
>
{items.map((item, i) => {
const start = fromFrame + i * STAGGER;
const enter = spring({
frame: frame - start,
fps,
config: { damping: 200, mass: 0.5 },
});
const rise = interpolate(enter, [0, 1], [22, 0]);
const fadeIn = start + 8;
const fadeOut = Math.max(fadeIn + 1, toFrame - 10);
const end = Math.max(fadeOut + 1, toFrame);
const appear = interpolate(
frame,
[start, fadeIn, fadeOut, end],
[0, 1, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
);
return (
<div
key={i}
style={{
opacity: appear,
transform: `translateY(${rise}px)`,
fontFamily: serifFont,
fontWeight: 700,
fontSize: 38,
color: "#FFFFFF",
letterSpacing: 0.5,
whiteSpace: "nowrap",
// 배경 제거 → 블러리 소프트 쉐도우로 가독성 확보
textShadow:
"0 2px 22px rgba(0,0,0,0.95), 0 0 14px rgba(0,0,0,0.85), 0 0 40px rgba(0,0,0,0.6)",
}}
>
{item}
</div>
);
})}
</AbsoluteFill>
);
};

View File

@ -1,53 +0,0 @@
import React from "react";
import {
useCurrentFrame,
interpolate,
AbsoluteFill,
} from "remotion";
import { serifFont } from "../fonts";
// 브랜드 자막: 페이드 + 미세 상승 (Profile A 모션 프리셋)
export const Subtitle: React.FC<{
text: string;
fromFrame: number;
toFrame: number;
}> = ({ text, fromFrame, toFrame }) => {
const frame = useCurrentFrame();
const opacity = interpolate(
frame,
[fromFrame, fromFrame + 10, toFrame - 10, toFrame],
[0, 1, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
);
const rise = interpolate(frame, [fromFrame, fromFrame + 18], [18, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<AbsoluteFill
style={{
justifyContent: "flex-end",
alignItems: "center",
paddingBottom: 220,
opacity,
}}
>
<div
style={{
fontFamily: serifFont,
fontWeight: 700,
fontSize: 50,
color: "#FFFFFF",
textAlign: "center",
letterSpacing: 1,
transform: `translateY(${rise}px)`,
textShadow: "0 2px 16px rgba(0,0,0,0.8)",
}}
>
{text}
</div>
</AbsoluteFill>
);
};

View File

@ -1,35 +0,0 @@
// Default props = the verified 머뭄 PoC #2 layout.
// The Python render bridge overrides these per-job via --props.
import { ShortProps } from "./types";
export const defaultProps: ShortProps = {
videoSrc: "mumum_marketing_v1.mp4",
fps: 30,
width: 720,
height: 1280,
durationInFrames: 241, // ≈ 8.04s
hook: {
eyebrow: "아무도 모르는 군산",
title: "골목 안, 단 한 채의 독채 스테이 머뭄",
fromFrame: 0,
toFrame: 78,
},
sellingPoint: {
items: ["프라이빗 독채펜션", "넓은 정원", "핵심 관광지 가까이"],
fromFrame: 80,
toFrame: 138,
},
brandLines: {
lines: ["머무는 시간이", "다르게 흐르는 곳"],
fromFrame: 138,
toFrame: 196,
},
endCard: {
brand: "Stay, 머뭄",
location: "전북 군산시 절골길 18",
disclosure: "실제 사진 기반, AI 카메라 효과를 적용한 영상입니다.",
fromFrame: 192,
toFrame: 241,
},
};

View File

@ -1,19 +0,0 @@
// Props contract — shared by Remotion composition and the Python render bridge.
// Mirrors server/app/schemas.py VideoSpec (+ video config + timings).
export type ShortProps = {
videoSrc: string; // file in public/ (staticFile name)
fps: number;
width: number;
height: number;
durationInFrames: number;
hook: { eyebrow: string; title: string; fromFrame: number; toFrame: number };
sellingPoint: { items: string[]; fromFrame: number; toFrame: number };
brandLines: { lines: string[]; fromFrame: number; toFrame: number };
endCard: {
brand: string;
location: string;
disclosure: string;
fromFrame: number;
toFrame: number;
};
};

View File

@ -1,21 +0,0 @@
// 한글 폰트 로딩 (@remotion/google-fonts, korean subset)
// Hook = Black Han Sans (굵고 강렬한 바이럴 헤드라인)
// Body/Brand = Nanum Myeongjo (우아한 명조 — 머뭄의 정적 브랜드 톤)
//
// 주의: korean subset 자체가 unicode-range 파일이 많아 요청 수가 많다.
// weights 를 최소화(NanumMyeongjo는 400/700만 실제 존재)해 요청 수를 줄임.
// latin 서브셋 제거 — 콘텐츠가 한국어 전용이므로 불필요.
import { loadFont as loadHook } from "@remotion/google-fonts/BlackHanSans";
import { loadFont as loadSerif } from "@remotion/google-fonts/NanumMyeongjo";
export const hookFont = loadHook("normal", {
weights: ["400"],
subsets: ["korean"],
ignoreTooManyRequestsWarning: true,
}).fontFamily;
export const serifFont = loadSerif("normal", {
weights: ["400", "700"], // 800은 NanumMyeongjo에 없음 → 제거
subsets: ["korean"],
ignoreTooManyRequestsWarning: true,
}).fontFamily;

View File

@ -1,4 +0,0 @@
import { registerRoot } from "remotion";
import { RemotionRoot } from "./Root";
registerRoot(RemotionRoot);

View File

@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true
},
"include": ["src"]
}

View File

@ -334,14 +334,14 @@ const App: React.FC = () => {
}; };
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출 // 업체명·주소 수동 입력으로 마케팅 분석 API 호출
const handleManualInput = async (businessName: string, address: string) => { const handleManualInput = async (businessName: string, address: string, category: string) => {
setAfterLoadTarget('generation_flow'); setAfterLoadTarget('generation_flow');
setViewMode('loading'); setViewMode('loading');
setIsAnalysisComplete(false); setIsAnalysisComplete(false);
setError(null); setError(null);
try { try {
const data = await marketingAnalysis(businessName, address); const data = await marketingAnalysis(businessName, address, category);
if (!validateCrawlingResponse(data)) { if (!validateCrawlingResponse(data)) {
throw new Error(t('app.autocompleteError')); throw new Error(t('app.autocompleteError'));
@ -358,6 +358,32 @@ const App: React.FC = () => {
} }
}; };
// 테스트 데이터로 브랜드 분석 페이지 이동
const handleTestData = (data: CrawlingResponse) => {
const tagged = { ...data, _isTestData: true };
setAnalysisData(tagged);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
setViewMode('analysis');
};
// 언어 변경 시 테스트 데이터 다시 로드
useEffect(() => {
const saved = localStorage.getItem(ANALYSIS_DATA_KEY);
if (!saved) return;
try {
const parsed = JSON.parse(saved);
if (!parsed._isTestData) return;
const jsonFile = i18n.language === 'en' ? '/example_analysis_en.json' : '/example_analysis.json';
fetch(jsonFile)
.then(res => res.json())
.then((data: CrawlingResponse) => {
const tagged = { ...data, _isTestData: true };
setAnalysisData(tagged);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
})
.catch(err => console.error('Failed to reload test data:', err));
} catch { /* ignore */ }
}, [i18n.language]);
const handleToLogin = async () => { const handleToLogin = async () => {
// 이미 로그인된 상태면 바로 generation_flow로 이동 // 이미 로그인된 상태면 바로 generation_flow로 이동
@ -478,8 +504,11 @@ const App: React.FC = () => {
<Header onStartClick={handleHeaderStart} /> <Header onStartClick={handleHeaderStart} />
<section className="landing-section"> <section className="landing-section">
<HeroSection <HeroSection
onLogin={handleToLogin} onAnalyze={handleStartAnalysis}
onAutocomplete={handleAutocomplete}
onManualInput={handleManualInput}
onNext={() => scrollToSection(1)} onNext={() => scrollToSection(1)}
error={error}
scrollProgress={scrollProgress} scrollProgress={scrollProgress}
/> />
</section> </section>

View File

@ -5,7 +5,7 @@ import CitySelectModal, { REGIONS } from './CitySelectModal';
interface BusinessNameInputModalProps { interface BusinessNameInputModalProps {
onClose: () => void; onClose: () => void;
onSubmit: (businessName: string, address: string) => void; onSubmit: (businessName: string, address: string, category: string) => void;
} }
const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose, onSubmit }) => { const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose, onSubmit }) => {
@ -13,6 +13,7 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
const [businessName, setBusinessName] = useState(''); const [businessName, setBusinessName] = useState('');
const [selectedCity, setSelectedCity] = useState(''); const [selectedCity, setSelectedCity] = useState('');
const [detailAddress, setDetailAddress] = useState(''); const [detailAddress, setDetailAddress] = useState('');
const [category, setCategory] = useState('');
const [isCityModalOpen, setIsCityModalOpen] = useState(false); const [isCityModalOpen, setIsCityModalOpen] = useState(false);
useEffect(() => { useEffect(() => {
@ -41,7 +42,7 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
const handleSubmit = () => { const handleSubmit = () => {
if (!isValid) return; if (!isValid) return;
const fullAddress = `${selectedCity} ${detailAddress.trim()}`; const fullAddress = `${selectedCity} ${detailAddress.trim()}`;
onSubmit(businessName.trim(), fullAddress); onSubmit(businessName.trim(), fullAddress, category.trim());
onClose(); onClose();
}; };
@ -60,6 +61,7 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
<span className="manual-modal-title">{t('landing.hero.manualModalTitle')}</span> <span className="manual-modal-title">{t('landing.hero.manualModalTitle')}</span>
<button className="manual-modal-close" onClick={onClose}></button> <button className="manual-modal-close" onClick={onClose}></button>
</div> </div>
<p className="manual-modal-subtitle">{t('landing.hero.manualModalSubtitle')}</p>
<div className="manual-modal-body"> <div className="manual-modal-body">
<div className="manual-modal-field"> <div className="manual-modal-field">
@ -103,6 +105,19 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
/> />
</div> </div>
<div className="manual-modal-field">
<label className="manual-modal-label">{t('landing.hero.manualLabelCategory')}</label>
<input
type="text"
className="manual-modal-input"
placeholder={t('landing.hero.manualPlaceholderCategory')}
value={category}
onChange={e => setCategory(e.target.value)}
onKeyDown={handleKeyDown}
maxLength={30}
/>
</div>
<div className="manual-modal-actions"> <div className="manual-modal-actions">
<button type="button" className="manual-modal-cancel" onClick={onClose}> <button type="button" className="manual-modal-cancel" onClick={onClose}>
{t('common.cancel')} {t('common.cancel')}
@ -113,7 +128,7 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
onClick={handleSubmit} onClick={handleSubmit}
disabled={!isValid} disabled={!isValid}
> >
{t('landing.hero.analyzeButton')} {t('landing.hero.manualAnalyzeButton')}
</button> </button>
</div> </div>
</div> </div>

View File

@ -0,0 +1,228 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { toggleVideoLike } from '../utils/api';
interface ContentCardSocialActionsProps {
videoId: number;
storeName: string;
region?: string;
commentCount: number;
initialLikeCount: number;
initialIsLiked?: boolean;
}
const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
videoId,
storeName,
region,
commentCount,
initialLikeCount,
initialIsLiked = false,
}) => {
const { t } = useTranslation();
const [likeCount, setLikeCount] = useState(initialLikeCount);
const [isLiked, setIsLiked] = useState(initialIsLiked);
const [copied, setCopied] = useState(false);
const [shareMenuOpen, setShareMenuOpen] = useState(false);
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
const [anchorRect, setAnchorRect] = useState<{ top: number; bottom: number; left: number } | null>(null);
const shareBtnRef = useRef<HTMLButtonElement>(null);
const shareMenuRef = useRef<HTMLDivElement>(null);
const likeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const shareUrl = `${window.location.origin}/video/${videoId}`;
useEffect(() => {
if (!shareMenuOpen) return;
const closeMenu = () => setShareMenuOpen(false);
const handleClickOutside = (e: MouseEvent) => {
if (
shareMenuRef.current && !shareMenuRef.current.contains(e.target as Node) &&
shareBtnRef.current && !shareBtnRef.current.contains(e.target as Node)
) {
closeMenu();
}
};
document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('scroll', closeMenu, true);
window.addEventListener('resize', closeMenu);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('scroll', closeMenu, true);
window.removeEventListener('resize', closeMenu);
};
}, [shareMenuOpen]);
// 메뉴가 뷰포트 밖으로 넘어가지 않도록 실제 렌더된 크기 기준으로 위치 보정
useLayoutEffect(() => {
if (!shareMenuOpen || !anchorRect || !shareMenuRef.current) return;
const margin = 8;
const menuRect = shareMenuRef.current.getBoundingClientRect();
let left = anchorRect.left;
if (left + menuRect.width > window.innerWidth - margin) {
left = window.innerWidth - menuRect.width - margin;
}
left = Math.max(margin, left);
let top = anchorRect.bottom + 6;
if (top + menuRect.height > window.innerHeight - margin) {
top = anchorRect.top - menuRect.height - 6;
}
top = Math.max(margin, top);
setMenuPos((prev) => (prev && prev.top === top && prev.left === left ? prev : { top, left }));
}, [shareMenuOpen, anchorRect]);
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(shareUrl);
} catch {
// clipboard API 미지원 환경에서는 무시
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleKakaoShare = () => {
const kakao = window.Kakao;
if (kakao?.Share) {
kakao.Share.sendDefault({
objectType: 'feed',
content: {
title: storeName || t('videoDetail.kakaoDefaultTitle'),
description: t('videoDetail.kakaoDescription', { region: region ?? '' }),
imageUrl: 'https://ado2.o2osolution.ai/favicon_48.svg',
link: { mobileWebUrl: shareUrl, webUrl: shareUrl },
},
buttons: [{ title: t('videoDetail.kakaoButtonTitle'), link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }],
});
} else if (navigator.share) {
navigator.share({ url: shareUrl }).catch(() => {});
} else {
handleCopyLink();
}
setShareMenuOpen(false);
};
const handleFacebookShare = () => {
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
setShareMenuOpen(false);
};
const handleTwitterShare = () => {
window.open(`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
setShareMenuOpen(false);
};
const handleLikeClick = (e: React.MouseEvent) => {
e.stopPropagation();
const prevLiked = isLiked;
const prevCount = likeCount;
setIsLiked(!prevLiked);
setLikeCount(prevLiked ? prevCount - 1 : prevCount + 1);
if (likeDebounceRef.current) clearTimeout(likeDebounceRef.current);
likeDebounceRef.current = setTimeout(async () => {
try {
const res = await toggleVideoLike(String(videoId));
setIsLiked(res.is_liked);
setLikeCount(res.like_count);
} catch (err) {
console.error('Failed to toggle like:', err);
setIsLiked(prevLiked);
setLikeCount(prevCount);
}
}, 500);
};
const handleShareBtnClick = (e: React.MouseEvent) => {
e.stopPropagation();
const rect = shareBtnRef.current?.getBoundingClientRect();
if (rect) {
// 초기 추정 위치(측정 전 첫 렌더용) — useLayoutEffect가 실제 크기 기준으로 다시 보정한다
setAnchorRect({ top: rect.top, bottom: rect.bottom, left: rect.left });
setMenuPos({ top: rect.bottom + 6, left: rect.left });
}
setShareMenuOpen((v) => !v);
};
return (
<div className="content-card-social" onClick={(e) => e.stopPropagation()}>
<button
className={`content-card-like-btn ${isLiked ? 'liked' : ''}`}
onClick={handleLikeClick}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill={isLiked ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
{likeCount}
</button>
<span className="content-card-comment">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
{commentCount}
</span>
<button
ref={shareBtnRef}
className="content-card-share-btn"
onClick={handleShareBtnClick}
title={t('videoDetail.share')}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" /><line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
</svg>
</button>
{shareMenuOpen && menuPos && createPortal(
<div
ref={shareMenuRef}
className="video-detail-share-menu"
style={{ position: 'fixed', top: menuPos.top, left: menuPos.left }}
>
<button className="video-detail-share-item" onClick={handleKakaoShare}>
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="20" height="20" rx="4" fill="#FEE500" />
<path fillRule="evenodd" clipRule="evenodd" d="M10 3.5C6.134 3.5 3 6.01 3 9.1c0 1.98 1.2 3.72 3.01 4.76l-.74 2.75a.19.19 0 0 0 .28.21l3.37-2.23c.34.04.69.06 1.06.06 3.866 0 7-2.51 7-5.6S13.866 3.5 10 3.5z" fill="#3C1E1E" />
</svg>
{t('videoDetail.shareKakao')}
</button>
<button className="video-detail-share-item" onClick={handleFacebookShare}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="#1877F2">
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.235 2.686.235v2.97h-1.513c-1.491 0-1.956.93-1.956 1.887v2.268h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z" />
</svg>
{t('videoDetail.shareFacebook')}
</button>
<button className="video-detail-share-item" onClick={handleTwitterShare}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
{t('videoDetail.shareTwitter')}
</button>
<button className="video-detail-share-item" onClick={() => { handleCopyLink(); setShareMenuOpen(false); }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
{copied ? t('videoDetail.copied') : t('videoDetail.copyUrl')}
</button>
</div>,
document.body
)}
</div>
);
};
export default ContentCardSocialActions;

View File

@ -11,9 +11,9 @@ const Footer: React.FC = () => {
<img src="/assets/images/ado2-sidebar-logo.svg" alt="ADO2" className="footer-logo" /> <img src="/assets/images/ado2-sidebar-logo.svg" alt="ADO2" className="footer-logo" />
<p className="footer-copyright">Copyright O2O Inc. All rights reserved</p> <p className="footer-copyright">Copyright O2O Inc. All rights reserved</p>
<div className="footer-links"> <div className="footer-links">
<a href="https://demo.castad.net/privacy.html" rel="noopener noreferrer" className="footer-link">{t('footer.privacyPolicy')}</a> <a href="https://ado2.o2osolution.ai/privacy.html" rel="noopener noreferrer" className="footer-link">{t('footer.privacyPolicy')}</a>
<span className="footer-link-divider">|</span> <span className="footer-link-divider">|</span>
<a href="https://demo.castad.net/terms.html" rel="noopener noreferrer" className="footer-link">{t('footer.termsOfService')}</a> <a href="https://ado2.o2osolution.ai/terms.html" rel="noopener noreferrer" className="footer-link">{t('footer.termsOfService')}</a>
</div> </div>
</div> </div>
<div className="footer-right"> <div className="footer-right">

View File

@ -1,6 +1,6 @@
import React, { useState, useRef, useCallback, useEffect } from 'react'; import React, { useState, useRef, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../utils/api'; import { searchAccommodation, cleanSearchTitle, AccommodationSearchItem, AutocompleteRequest } from '../utils/api';
import { useSearchHistory } from './SearchHistory/useSearchHistory'; import { useSearchHistory } from './SearchHistory/useSearchHistory';
import SearchHistoryDropdown from './SearchHistory/SearchHistoryDropdown'; import SearchHistoryDropdown from './SearchHistory/SearchHistoryDropdown';
import BusinessNameInputModal from './BusinessNameInputModal'; import BusinessNameInputModal from './BusinessNameInputModal';
@ -18,13 +18,29 @@ const isValidUrl = (string: string): boolean => {
} }
}; };
// URL 형태로 판단되는 입력: 프로토콜/www. 시작 또는 도메인 형태(naver.me/xxx 등)
const isUrlLike = (value: string): boolean => {
const trimmed = value.trim();
return /^(https?:\/\/|www\.)/i.test(trimmed)
|| /^[a-z0-9-]+(\.[a-z0-9-]+)+([/?#]|$)/i.test(trimmed);
};
const sanitizeUrlInput = (value: string): string => {
return value.replace(/[^\x00-\x7F]/g, '');
};
// 텍스트 안에 포함된 첫 번째 URL을 추출 (없으면 null)
const extractUrl = (text: string): string | null => {
const match = text.match(/(https?:\/\/|www\.)[^\s]+/i);
return match ? match[0] : null;
};
interface SearchInputFormProps { interface SearchInputFormProps {
onAnalyze?: (value: string, type: SearchType) => void; onAnalyze?: (value: string, type: SearchType) => void;
onAutocomplete?: (data: AutocompleteRequest) => void; onAutocomplete?: (data: AutocompleteRequest) => void;
onManualInput?: (businessName: string, address: string) => void; onManualInput?: (businessName: string, address: string, category: string) => void;
/** 직접입력 버튼 클릭 시 기본 동작(모달 열기)을 대체합니다. 제공 시 모달을 직접 관리해야 합니다. */ /** 직접입력 버튼 클릭 시 기본 동작(모달 열기)을 대체합니다. 제공 시 모달을 직접 관리해야 합니다. */
onManualButtonClick?: () => void; onManualButtonClick?: () => void;
onShortform?: () => void;
error?: string | null; error?: string | null;
} }
@ -33,13 +49,11 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
onAutocomplete, onAutocomplete,
onManualInput, onManualInput,
onManualButtonClick, onManualButtonClick,
onShortform,
error: externalError, error: externalError,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [searchType, setSearchType] = useState<SearchType>('name'); const [searchType] = useState<SearchType>('name');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const [isManualModalOpen, setIsManualModalOpen] = useState(false); const [isManualModalOpen, setIsManualModalOpen] = useState(false);
const [localError, setLocalError] = useState(''); const [localError, setLocalError] = useState('');
@ -49,16 +63,12 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null); const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1); const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType === 'manual' ? 'name' : searchType); const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory('name');
const debounceRef = useRef<NodeJS.Timeout | null>(null); const debounceRef = useRef<NodeJS.Timeout | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const autocompleteRef = useRef<HTMLDivElement>(null); const autocompleteRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownOpen(false);
}
if (autocompleteRef.current && !autocompleteRef.current.contains(event.target as Node)) { if (autocompleteRef.current && !autocompleteRef.current.contains(event.target as Node)) {
setShowAutocomplete(false); setShowAutocomplete(false);
} }
@ -67,20 +77,8 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
const searchTypeOptions = [
{ value: 'url' as SearchType, label: 'URL' },
{ value: 'name' as SearchType, label: t('landing.hero.searchTypeBusinessName') },
{ value: 'manual' as SearchType, label: t('landing.hero.searchTypeManual') },
];
const getPlaceholder = () =>
searchType === 'url' ? 'https://naver.me/abcdef' : t('landing.hero.placeholderBusinessName');
const getGuideText = () =>
searchType === 'url' ? t('landing.hero.guideUrl') : t('landing.hero.guideBusinessName');
const handleAutocompleteSearch = useCallback(async (query: string) => { const handleAutocompleteSearch = useCallback(async (query: string) => {
if (!query.trim() || searchType !== 'name') { if (!query.trim()) {
setAutocompleteResults([]); setAutocompleteResults([]);
setShowAutocomplete(false); setShowAutocomplete(false);
return; return;
@ -96,11 +94,12 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
} finally { } finally {
setIsAutocompleteLoading(false); setIsAutocompleteLoading(false);
} }
}, [searchType]); }, []);
const handleSelectAutocomplete = (item: AccommodationSearchItem) => { const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
setInputValue(item.title.replace(/<[^>]*>/g, '')); const cleanTitle = cleanSearchTitle(item.title);
setSelectedItem(item); setInputValue(cleanTitle);
setSelectedItem({ ...item, title: cleanTitle });
setShowAutocomplete(false); setShowAutocomplete(false);
setAutocompleteResults([]); setAutocompleteResults([]);
setHighlightedIndex(-1); setHighlightedIndex(-1);
@ -136,18 +135,20 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
}; };
const handleStart = () => { const handleStart = () => {
if (!inputValue.trim()) { const trimmed = inputValue.trim();
setLocalError(searchType === 'url' ? t('landing.hero.errorUrlRequired') : t('landing.hero.errorNameRequired')); if (!trimmed) {
setLocalError(t('landing.hero.errorInputRequired'));
return; return;
} }
if (searchType === 'url' && !isValidUrl(inputValue.trim())) { if (isValidUrl(trimmed)) {
setLocalError(t('landing.hero.errorInvalidUrl')); setLocalError('');
onAnalyze?.(trimmed, 'url');
return; return;
} }
setLocalError(''); setLocalError('');
if (searchType === 'name' && selectedItem && onAutocomplete) { if (selectedItem && onAutocomplete) {
onAutocomplete({ onAutocomplete({
address: selectedItem.address, address: selectedItem.address,
roadAddress: selectedItem.roadAddress, roadAddress: selectedItem.roadAddress,
@ -158,6 +159,67 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
} }
}; };
const applyInputChange = (value: string) => {
setInputValue(value);
setSelectedItem(null);
setHighlightedIndex(-1);
hideOnInput(value);
if (localError) setLocalError('');
if (debounceRef.current) clearTimeout(debounceRef.current);
if (isValidUrl(value)) {
setAutocompleteResults([]);
setShowAutocomplete(false);
} else {
debounceRef.current = setTimeout(() => handleAutocompleteSearch(value), 300);
}
};
// 모바일 키보드 클립보드 추천 등 paste 이벤트 없이 여러 글자가 한 번에 들어오는 경우 처리
const handleChange = (value: string) => {
const isBulkInsert = value.length - inputValue.length > 1;
if (isBulkInsert) {
const extracted = extractUrl(value);
if (extracted && extracted !== value.trim()) {
applyInputChange(sanitizeUrlInput(extracted));
return;
}
if (isUrlLike(value)) {
const sanitized = sanitizeUrlInput(value);
if (sanitized !== value) {
applyInputChange(sanitized);
return;
}
}
}
applyInputChange(value);
};
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
const pastedText = e.clipboardData.getData('text');
// 붙여넣은 텍스트에 URL이 포함되어 있고 URL 외 다른 텍스트도 있으면 URL만 추출해서 입력창에 넣기
const extracted = extractUrl(pastedText);
if (extracted && extracted !== pastedText.trim()) {
e.preventDefault();
applyInputChange(sanitizeUrlInput(extracted));
return;
}
const input = e.currentTarget;
const start = input.selectionStart ?? inputValue.length;
const end = input.selectionEnd ?? inputValue.length;
const merged = inputValue.slice(0, start) + pastedText + inputValue.slice(end);
// 붙여넣은 후 완성될 전체 값이 URL 형태일 때만 비영문 문자 제거
if (!isUrlLike(merged)) return;
const sanitized = sanitizeUrlInput(merged);
if (sanitized === merged) return;
e.preventDefault();
applyInputChange(sanitized);
};
const handleManualButtonClick = () => { const handleManualButtonClick = () => {
if (onManualButtonClick) { if (onManualButtonClick) {
onManualButtonClick(); onManualButtonClick();
@ -171,88 +233,30 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
return ( return (
<div className="hero-form"> <div className="hero-form">
<div className={`hero-input-wrapper ${isFocused ? 'focused' : ''} ${error ? 'error' : ''} ${inputValue && !isFocused ? 'filled' : ''}`}> <div className={`hero-input-wrapper ${isFocused ? 'focused' : ''} ${error ? 'error' : ''} ${inputValue && !isFocused ? 'filled' : ''}`}>
{/* 드롭다운 */} {/* 유형 라벨 */}
<div className="hero-dropdown-container" ref={dropdownRef}> {/* <div className="hero-dropdown-container">
<button <div className="hero-dropdown-trigger">
type="button" <span className="hero-dropdown-main">
className="hero-dropdown-trigger" {t('landing.hero.searchTypeLabel')}
onClick={() => setIsDropdownOpen(!isDropdownOpen)} </span>
>
<span>{searchTypeOptions.find(opt => opt.value === searchType)?.label}</span>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={`hero-dropdown-arrow ${isDropdownOpen ? 'open' : ''}`}
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{isDropdownOpen && (
<div className="hero-dropdown-menu">
{searchTypeOptions.map((option) => (
<button
key={option.value}
type="button"
className={`hero-dropdown-item ${searchType === option.value ? 'active' : ''}`}
onClick={() => {
setIsDropdownOpen(false);
if (option.value === 'manual') {
handleManualButtonClick();
} else {
setSearchType(option.value);
}
}}
>
{option.label}
</button>
))}
</div>
)}
</div> </div>
</div> */}
{/* 입력 필드 */} {/* 입력 필드 */}
<div className="hero-input-container" ref={autocompleteRef}> <div className="hero-input-container" ref={autocompleteRef}>
<input <input
type="text" type="text"
value={inputValue} value={inputValue}
onChange={(e) => { onChange={(e) => handleChange(e.target.value)}
let value = e.target.value; onPaste={handlePaste}
if (searchType === 'url') {
const urlMatch = value.match(/https?:\/\/\S+/);
if (urlMatch && urlMatch[0] !== value) value = urlMatch[0];
}
setInputValue(value);
setSelectedItem(null);
setHighlightedIndex(-1);
hideOnInput(value);
if (localError) setLocalError('');
if (searchType === 'name') {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => handleAutocompleteSearch(value), 300);
}
}}
onPaste={(e: React.ClipboardEvent<HTMLInputElement>) => {
if (searchType !== 'url') return;
const pasted = e.clipboardData.getData('text');
const urlMatch = pasted.match(/https?:\/\/[^\s]+/);
if (urlMatch) {
e.preventDefault();
setInputValue(urlMatch[0]);
if (localError) setLocalError('');
}
}}
onFocus={() => { onFocus={() => {
setIsFocused(true); setIsFocused(true);
if (searchType === 'name' && autocompleteResults.length > 0) setShowAutocomplete(true); if (autocompleteResults.length > 0) setShowAutocomplete(true);
openHistory(inputValue); openHistory(inputValue);
}} }}
onBlur={() => { setIsFocused(false); setTimeout(() => closeHistory(), 150); }} onBlur={() => { setIsFocused(false); setTimeout(() => closeHistory(), 150); }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={getPlaceholder()} placeholder={t('landing.hero.placeholderCombined')}
className={`hero-input ${inputValue ? 'has-value' : ''}`} className={`hero-input ${inputValue ? 'has-value' : ''}`}
/> />
@ -262,12 +266,8 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
items={filteredHistory} items={filteredHistory}
onSelect={(item) => { onSelect={(item) => {
closeHistory(); closeHistory();
if (item.type === 'url') {
setSearchType('url');
setInputValue(item.value);
} else {
setSearchType('name');
setInputValue(item.value); setInputValue(item.value);
if (item.type === 'name') {
setSelectedItem({ title: item.value, address: item.address || '', roadAddress: item.roadAddress || '' }); setSelectedItem({ title: item.value, address: item.address || '', roadAddress: item.roadAddress || '' });
} }
}} }}
@ -280,7 +280,7 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
)} )}
{/* 자동완성 결과 */} {/* 자동완성 결과 */}
{showAutocomplete && searchType === 'name' && ( {showAutocomplete && (
<div className="hero-autocomplete-dropdown"> <div className="hero-autocomplete-dropdown">
{isAutocompleteLoading ? ( {isAutocompleteLoading ? (
<div className="hero-autocomplete-loading">{t('landing.hero.searching')}</div> <div className="hero-autocomplete-loading">{t('landing.hero.searching')}</div>
@ -320,7 +320,7 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
)} )}
</div> </div>
<span className="hero-input-hint">{getGuideText()}</span> <span className="hero-input-hint">{t('landing.hero.guideCombined')}</span>
{error && <p className="hero-error">{error}</p>} {error && <p className="hero-error">{error}</p>}
@ -328,27 +328,21 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
{t('landing.hero.analyzeButton')} {t('landing.hero.analyzeButton')}
</button> </button>
{onShortform && ( <div className="hero-divider"><span></span></div>
<button
type="button" <div className="hero-manual-card" onClick={handleManualButtonClick} role="button" tabIndex={0}
className="url-input-shortform-btn" onKeyDown={(e) => e.key === 'Enter' && handleManualButtonClick()}>
onClick={onShortform} <span className="hero-manual-card-title">{t('landing.hero.searchTypeManual')}</span>
> <p className="hero-manual-button-desc">{t('landing.hero.manualButtonDesc')}</p>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> </div>
<polygon points="23 7 16 12 23 17 23 7" />
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
</svg>
</button>
)}
{/* onManualButtonClick 미제공 시 모달을 내부에서 관리 */} {/* onManualButtonClick 미제공 시 모달을 내부에서 관리 */}
{!onManualButtonClick && isManualModalOpen && ( {!onManualButtonClick && isManualModalOpen && (
<BusinessNameInputModal <BusinessNameInputModal
onClose={() => setIsManualModalOpen(false)} onClose={() => setIsManualModalOpen(false)}
onSubmit={(businessName, address) => { onSubmit={(businessName, address, category) => {
setIsManualModalOpen(false); setIsManualModalOpen(false);
onManualInput?.(businessName, address); onManualInput?.(businessName, address, category);
}} }}
/> />
)} )}

View File

@ -380,7 +380,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
} else { } else {
// 즉시 업로드: 완료될 때까지 폴링 // 즉시 업로드: 완료될 때까지 폴링
const result = await waitForUploadComplete( const result = await waitForUploadComplete(
uploadResponse.upload_id, uploadResponse.upload_id.toString(),
(status, progress) => { (status, progress) => {
setUploadStatus(status as UploadStatus); setUploadStatus(status as UploadStatus);
setUploadProgress(progress || 0); setUploadProgress(progress || 0);

View File

@ -45,7 +45,35 @@ export const TUTORIAL_PAGE_GROUPS: string[][] = [
export const tutorialSteps: TutorialStepDef[] = [ export const tutorialSteps: TutorialStepDef[] = [
{ {
key: TUTORIAL_KEYS.LANDING, key: TUTORIAL_KEYS.LANDING,
hints: [], hints: [
{
targetSelector: '.hero-input-wrapper',
titleKey: 'tutorial.landing.field.title',
descriptionKey: 'tutorial.landing.field.desc',
position: 'top',
clickToAdvance: false,
noSpotlight: true,
variant: 'bubble',
},
{
targetSelector: '.hero-manual-card-title',
titleKey: 'tutorial.landing.manual.title',
descriptionKey: 'tutorial.landing.manual.desc',
position: 'top',
clickToAdvance: false,
noSpotlight: true,
variant: 'bubble',
},
{
targetSelector: '.hero-button',
titleKey: 'tutorial.landing.button.title',
descriptionKey: 'tutorial.landing.button.desc',
position: 'bottom',
clickToAdvance: true,
noSpotlight: true,
variant: 'bubble',
},
],
}, },
{ {
key: TUTORIAL_KEYS.ASSET, key: TUTORIAL_KEYS.ASSET,

View File

@ -94,7 +94,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
const date = new Date(dateString); const date = new Date(dateString);
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`; return t('videoDetail.dateFormat', { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() });
}; };
const formatCommentDate = (dateString: string) => { const formatCommentDate = (dateString: string) => {
@ -120,12 +120,12 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
kakao.Share.sendDefault({ kakao.Share.sendDefault({
objectType: 'feed', objectType: 'feed',
content: { content: {
title: video?.store_name ?? 'ADO2 영상', title: video?.store_name ?? t('videoDetail.kakaoDefaultTitle'),
description: `${video?.region ?? ''} · ADO2 AI 마케팅 영상`, description: t('videoDetail.kakaoDescription', { region: video?.region ?? '' }),
imageUrl: 'https://demo.castad.net/favicon_48.svg', imageUrl: 'https://ado2.o2osolution.ai/favicon_48.svg',
link: { mobileWebUrl: shareUrl, webUrl: shareUrl }, link: { mobileWebUrl: shareUrl, webUrl: shareUrl },
}, },
buttons: [{ title: '영상 보기', link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }], buttons: [{ title: t('videoDetail.kakaoButtonTitle'), link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }],
}); });
} else if (navigator.share) { } else if (navigator.share) {
navigator.share({ url: shareUrl }).catch(() => {}); navigator.share({ url: shareUrl }).catch(() => {});
@ -222,7 +222,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
}; };
const renderCommentContent = (content: string | null, isDeleted: boolean) => { const renderCommentContent = (content: string | null, isDeleted: boolean) => {
if (isDeleted) return <span style={{ color: '#6B9EA0', fontStyle: 'italic' }}>( .)</span>; if (isDeleted) return <span style={{ color: '#6B9EA0', fontStyle: 'italic' }}>{t('videoDetail.deletedComment')}</span>;
return content; return content;
}; };
@ -231,7 +231,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
{/* 헤더 */} {/* 헤더 */}
<div className="video-detail-header"> <div className="video-detail-header">
{isModal ? ( {isModal ? (
<button className="video-detail-close-btn" onClick={handleHeaderAction} aria-label="닫기"> <button className="video-detail-close-btn" onClick={handleHeaderAction} aria-label={t('videoDetail.closeAriaLabel')}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/> <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg> </svg>
@ -293,7 +293,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/> <circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/> <line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg> </svg>
{copied ? '복사됨!' : '공유하기'} {copied ? t('videoDetail.copied') : t('videoDetail.share')}
</button> </button>
{shareMenuOpen && ( {shareMenuOpen && (
<div className="video-detail-share-menu"> <div className="video-detail-share-menu">
@ -303,21 +303,21 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
<rect width="20" height="20" rx="4" fill="#FEE500"/> <rect width="20" height="20" rx="4" fill="#FEE500"/>
<path fillRule="evenodd" clipRule="evenodd" d="M10 3.5C6.134 3.5 3 6.01 3 9.1c0 1.98 1.2 3.72 3.01 4.76l-.74 2.75a.19.19 0 0 0 .28.21l3.37-2.23c.34.04.69.06 1.06.06 3.866 0 7-2.51 7-5.6S13.866 3.5 10 3.5z" fill="#3C1E1E"/> <path fillRule="evenodd" clipRule="evenodd" d="M10 3.5C6.134 3.5 3 6.01 3 9.1c0 1.98 1.2 3.72 3.01 4.76l-.74 2.75a.19.19 0 0 0 .28.21l3.37-2.23c.34.04.69.06 1.06.06 3.866 0 7-2.51 7-5.6S13.866 3.5 10 3.5z" fill="#3C1E1E"/>
</svg> </svg>
{t('videoDetail.shareKakao')}
</button> </button>
{/* 페이스북 */} {/* 페이스북 */}
<button className="video-detail-share-item" onClick={handleFacebookShare}> <button className="video-detail-share-item" onClick={handleFacebookShare}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="#1877F2"> <svg width="18" height="18" viewBox="0 0 24 24" fill="#1877F2">
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.235 2.686.235v2.97h-1.513c-1.491 0-1.956.93-1.956 1.887v2.268h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z"/> <path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.235 2.686.235v2.97h-1.513c-1.491 0-1.956.93-1.956 1.887v2.268h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z"/>
</svg> </svg>
{t('videoDetail.shareFacebook')}
</button> </button>
{/* X (트위터) */} {/* X (트위터) */}
<button className="video-detail-share-item" onClick={handleTwitterShare}> <button className="video-detail-share-item" onClick={handleTwitterShare}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"> <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/> <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg> </svg>
X () {t('videoDetail.shareTwitter')}
</button> </button>
{/* URL 복사 */} {/* URL 복사 */}
<button className="video-detail-share-item" onClick={() => { handleCopyLink(); setShareMenuOpen(false); }}> <button className="video-detail-share-item" onClick={() => { handleCopyLink(); setShareMenuOpen(false); }}>
@ -325,7 +325,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
<rect x="9" y="9" width="13" height="13" rx="2"/> <rect x="9" y="9" width="13" height="13" rx="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/> <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg> </svg>
{copied ? '복사됨!' : 'URL 복사'} {copied ? t('videoDetail.copied') : t('videoDetail.copyUrl')}
</button> </button>
</div> </div>
)} )}
@ -335,7 +335,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
{/* 댓글 섹션 */} {/* 댓글 섹션 */}
<div className="video-detail-comments"> <div className="video-detail-comments">
<div className="video-detail-comments-header"> <div className="video-detail-comments-header">
<h3 className="video-detail-comments-title"></h3> <h3 className="video-detail-comments-title">{t('videoDetail.commentsTitle')}</h3>
<span className="video-detail-comments-count">{commentsTotal}</span> <span className="video-detail-comments-count">{commentsTotal}</span>
</div> </div>
@ -344,16 +344,16 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
<div className="video-detail-comment-profile"> <div className="video-detail-comment-profile">
<img <img
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${commentAvatarSeed}`} src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${commentAvatarSeed}`}
alt="아바타 변경" alt={t('videoDetail.changeAvatarTitle')}
className="video-detail-comment-avatar" className="video-detail-comment-avatar"
onClick={handleChangeAvatar} onClick={handleChangeAvatar}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
title="클릭하여 아바타 변경" title={t('videoDetail.changeAvatarTitle')}
/> />
<input <input
className="video-detail-nickname-input" className="video-detail-nickname-input"
type="text" type="text"
placeholder="작성자 이름" placeholder={t('videoDetail.nicknamePlaceholder')}
value={commentNickname} value={commentNickname}
onChange={(e) => setCommentNickname(e.target.value)} onChange={(e) => setCommentNickname(e.target.value)}
maxLength={20} maxLength={20}
@ -365,7 +365,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
<textarea <textarea
ref={commentTextareaRef} ref={commentTextareaRef}
className="video-detail-comment-input" className="video-detail-comment-input"
placeholder={authed ? '댓글을 입력하세요...' : '로그인 후 댓글을 작성할 수 있습니다'} placeholder={authed ? t('videoDetail.commentPlaceholder') : t('videoDetail.commentLoginRequired')}
maxLength={500} maxLength={500}
rows={1} rows={1}
value={commentInput} value={commentInput}
@ -388,12 +388,12 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
onClick={handleCommentSubmit} onClick={handleCommentSubmit}
disabled={!authed || !commentInput.trim() || commentSubmitting} disabled={!authed || !commentInput.trim() || commentSubmitting}
> >
{commentSubmitting ? '작성 중' : '작성'} {commentSubmitting ? t('videoDetail.commentSubmitting') : t('videoDetail.commentSubmit')}
</button> </button>
</div> </div>
{comments.length === 0 && !commentsLoading ? ( {comments.length === 0 && !commentsLoading ? (
<p className="video-detail-comments-empty"> .</p> <p className="video-detail-comments-empty">{t('videoDetail.noComments')}</p>
) : ( ) : (
<ul className="video-detail-comment-list"> <ul className="video-detail-comment-list">
{comments.map((c) => ( {comments.map((c) => (
@ -405,7 +405,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
/> />
<div className="video-detail-comment-body"> <div className="video-detail-comment-body">
<span className="video-detail-comment-nickname"> <span className="video-detail-comment-nickname">
{c.nickname || '익명'} {c.nickname || t('videoDetail.anonymous')}
</span> </span>
<p className="video-detail-comment-text"> <p className="video-detail-comment-text">
{renderCommentContent(c.content, c.is_deleted)} {renderCommentContent(c.content, c.is_deleted)}
@ -417,7 +417,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
className="video-detail-comment-delete" className="video-detail-comment-delete"
onClick={() => handleDeleteComment(c.id)} onClick={() => handleDeleteComment(c.id)}
> >
{t('videoDetail.deleteComment')}
</button> </button>
)} )}
</div> </div>
@ -434,7 +434,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
/> />
<div className="video-detail-comment-body"> <div className="video-detail-comment-body">
<span className="video-detail-comment-nickname"> <span className="video-detail-comment-nickname">
{r.nickname || '익명'} {r.nickname || t('videoDetail.anonymous')}
</span> </span>
<p className="video-detail-comment-text"> <p className="video-detail-comment-text">
{renderCommentContent(r.content, r.is_deleted)} {renderCommentContent(r.content, r.is_deleted)}
@ -446,7 +446,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
className="video-detail-comment-delete" className="video-detail-comment-delete"
onClick={() => handleDeleteComment(r.id)} onClick={() => handleDeleteComment(r.id)}
> >
{t('videoDetail.deleteComment')}
</button> </button>
)} )}
</div> </div>
@ -467,7 +467,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
onClick={() => fetchComments(commentsPage + 1, true)} onClick={() => fetchComments(commentsPage + 1, true)}
disabled={commentsLoading} disabled={commentsLoading}
> >
{commentsLoading ? '불러오는 중...' : '댓글 더 보기'} {commentsLoading ? t('videoDetail.loadingComments') : t('videoDetail.loadMoreComments')}
</button> </button>
)} )}
</div> </div>

View File

@ -41,7 +41,7 @@ const VideoDetailModal: React.FC<VideoDetailModalProps> = ({ videoId, onClose })
background: '#01282A', background: '#01282A',
borderRadius: '16px', borderRadius: '16px',
width: '100%', width: '100%',
maxWidth: '900px', maxWidth: '800px',
margin: 'auto', margin: 'auto',
position: 'relative', position: 'relative',
}} }}

View File

@ -0,0 +1,53 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
interface WizardStepperProps {
currentStep: number; // 0~3, matching wizardStep in GenerationFlow
}
const WizardStepper: React.FC<WizardStepperProps> = ({ currentStep }) => {
const { t } = useTranslation();
const steps = [
t('wizardSteps.brandAnalysis'),
t('wizardSteps.asset'),
t('wizardSteps.sound'),
t('wizardSteps.video'),
];
return (
<div className="wizard-stepper">
{steps.map((label, index) => {
const isDone = index < currentStep;
const isCurrent = index === currentStep;
const stateClass = isDone ? ' done' : isCurrent ? ' current' : ' pending';
return (
<React.Fragment key={index}>
{/* 노드: 2rem 고정 너비. 라벨은 absolute로 노드 중심 아래에 붙음 */}
<div className={`wizard-step${stateClass}`}>
<div className="wizard-stepper-node">
{isDone ? (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M2.5 7L5.5 10L11.5 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<span>{index + 1}</span>
)}
</div>
<span className="wizard-stepper-label">{label}</span>
</div>
{/* 연결선: flex:1로 남은 공간을 균등하게 채움 */}
{index < steps.length - 1 && (
<div className={`wizard-step-line${isDone ? ' done' : ''}`} />
)}
</React.Fragment>
);
})}
</div>
);
};
export default WizardStepper;

View File

@ -2,6 +2,7 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import './i18n'; import './i18n';
import './styles/index.css';
import App from './App'; import App from './App';
declare global { declare global {

View File

@ -1,4 +1,10 @@
{ {
"wizardSteps": {
"brandAnalysis": "Analysis",
"asset": "Image",
"sound": "Sound",
"video": "Video"
},
"header": { "header": {
"loginFailedAlert": "Failed to get login URL. Please try again.", "loginFailedAlert": "Failed to get login URL. Please try again.",
"start": "Get Started", "start": "Get Started",
@ -28,8 +34,7 @@
"finish": "Done", "finish": "Done",
"landing": { "landing": {
"intro": { "title": "Welcome to ADO2 Tutorial", "desc": "We'll guide you through ADO2 step by step." }, "intro": { "title": "Welcome to ADO2 Tutorial", "desc": "We'll guide you through ADO2 step by step." },
"dropdown": { "title": "Choose Search Type", "desc": "Select URL or business name from the dropdown." }, "field": { "title": "Enter Search Term", "desc": "Paste a Naver Maps share URL,\nor type a business name and select from the list." },
"field": { "title": "Enter Search Term", "desc": "For URL, paste a Naver Maps share URL.\nFor business name, type the name and select from the list." },
"manual": { "title": "Direct Input", "desc": "You can also enter the business name and address manually to start analysis." }, "manual": { "title": "Direct Input", "desc": "You can also enter the business name and address manually to start analysis." },
"button": { "title": "Start Brand Analysis", "desc": "Click the button to let AI start analyzing your brand." } "button": { "title": "Start Brand Analysis", "desc": "Click the button to let AI start analyzing your brand." }
}, },
@ -42,12 +47,18 @@
"sound": { "sound": {
"genre": { "title": "Select Genre", "desc": "Pick a music genre that fits your brand.", "note": "Background music is coming soon." }, "genre": { "title": "Select Genre", "desc": "Pick a music genre that fits your brand.", "note": "Background music is coming soon." },
"language": { "title": "Select Language", "desc": "You can choose the language for the sound.\nWant to continue with Korean?" }, "language": { "title": "Select Language", "desc": "You can choose the language for the sound.\nWant to continue with Korean?" },
"generate": { "title": "Generate Sound", "desc": "Click the button and AI will generate lyrics and music in your chosen genre and language." }, "generate": { "title": "Generate Sound", "desc": "Click the button and AI will generate lyrics and music." },
"lyrics": { "title": "Lyrics Complete", "desc": "AI wrote lyrics in your selected language.\nCheck the generated lyrics." }, "lyrics": { "title": "Lyrics Complete", "desc": "AI wrote lyrics in your selected language.\nCheck the generated lyrics." },
"lyricsWait": { "title": "Generating Music", "desc": "AI is composing music based on the lyrics.\nPlease wait a moment." }, "lyricsWait": { "title": "Generating Music", "desc": "AI is composing music based on the lyrics.\nPlease wait a moment." },
"audioPlayer": { "title": "Preview the Music", "desc": "Music generation is complete.\nPress play to listen to the generated music." }, "audioPlayer": { "title": "Preview the Music", "desc": "Music generation is complete.\nPress play to listen to the generated music." },
"video": { "title": "Generate Video", "desc": "Click the button to start generating your video." } "video": { "title": "Generate Video", "desc": "Click the button to start generating your video." }
}, },
"completion": {
"contentInfo": { "title": "Content Info", "desc": "Check the file name, genre, resolution, and lyrics of the generated content." },
"generating": { "title": "Generating Video", "desc": "AI is creating your video.\nPlease wait a moment." },
"completion": { "title": "Video Complete!", "desc": "Your video is ready. Want to take a look?" },
"myInfo": { "title": "Connect Social Account", "desc": "To upload your video to YouTube, connect your social account in My Info. Click to go there." }
},
"myInfo": { "myInfo": {
"myInfo": { "title": "My Info", "desc": "In My Info, you can manage your social connections and view connected accounts." }, "myInfo": { "title": "My Info", "desc": "In My Info, you can manage your social connections and view connected accounts." },
"connect": { "title": "Connect Now", "desc": "Click the YouTube connect button to go to the connection page.", "note": "Instagram connection is coming soon." }, "connect": { "title": "Connect Now", "desc": "Click the YouTube connect button to go to the connection page.", "note": "Instagram connection is coming soon." },
@ -60,12 +71,6 @@
"delete": { "title": "Delete", "desc": "Remove videos you no longer need." }, "delete": { "title": "Delete", "desc": "Remove videos you no longer need." },
"upload": { "title": "Upload to Social Media", "desc": "Select a video and upload it to social media." } "upload": { "title": "Upload to Social Media", "desc": "Select a video and upload it to social media." }
}, },
"completion": {
"contentInfo": { "title": "Content Info", "desc": "Check the title, genre, resolution, and lyrics of the generated content." },
"generating": { "title": "Generating Video", "desc": "AI is creating your video.\nPlease wait a moment." },
"completion": { "title": "Video Complete!", "desc": "Your video is ready. Want to take a look?" },
"myInfo": { "title": "Connect Social Account", "desc": "To upload your video to YouTube, connect your social account in My Info. Click to go there." }
},
"upload": { "upload": {
"seo": { "title": "Title & Description", "desc": "AI is generating the title and description for your video. Please wait a moment." }, "seo": { "title": "Title & Description", "desc": "AI is generating the title and description for your video. Please wait a moment." },
"required": { "title": "Required Fields", "desc": "Fields marked with * are required.\nPlease check them before uploading." }, "required": { "title": "Required Fields", "desc": "Fields marked with * are required.\nPlease check them before uploading." },
@ -123,7 +128,7 @@
"privacyPrivate": "Private", "privacyPrivate": "Private",
"publishTimeLabel": "Publish Time", "publishTimeLabel": "Publish Time",
"publishNow": "Publish Now", "publishNow": "Publish Now",
"publishSchedule": "Schedule (Coming Soon)", "publishSchedule": "Schedule",
"footerNote": "", "footerNote": "",
"footerNoteLink": "", "footerNoteLink": "",
"cancel": "Cancel", "cancel": "Cancel",
@ -137,7 +142,29 @@
"uploadFailed": "Upload failed.", "uploadFailed": "Upload failed.",
"autoSeoTitle": "Auto-generating... (3060 sec)", "autoSeoTitle": "Auto-generating... (3060 sec)",
"autoSeoDescription": "Auto-generating... (3060 sec)", "autoSeoDescription": "Auto-generating... (3060 sec)",
"autoSeoTags": "Auto-generating... (3060 sec)" "autoSeoTags": "Auto-generating... (3060 sec)",
"youtubeCallback": {
"connecting": "Connecting YouTube...",
"pleaseWait": "Please wait a moment.",
"failed": "Connection Failed",
"errorAuthCancelled": "Google authentication was cancelled or failed.",
"errorNoCode": "Authorization code was not received.",
"errorProcessing": "An error occurred while processing the YouTube connection."
},
"connectError": {
"title": "{{platform}} Connection Failed",
"defaultError": "An unknown error occurred.",
"detail": "Please try again. If the problem persists, contact customer support.",
"retry": "Try Again"
},
"connectSuccess": {
"title": "{{name}} Connected",
"messageWithChannel": "{{platform}} account has been successfully connected.",
"messageWithoutChannel": "Account has been successfully connected.",
"verified": "Verified",
"countdown": "Redirecting in {{count}} seconds...",
"goNow": "Go Now"
}
}, },
"upload": { "upload": {
"title": "YouTube Upload", "title": "YouTube Upload",
@ -159,29 +186,29 @@
}, },
"landing": { "landing": {
"hero": { "hero": {
"searchTypeBusinessName": "Business Name", "searchTypeLabel": "Business Name | URL",
"placeholderBusinessName": "Enter a business name", "placeholderCombined": "Enter a business name or Naver Maps/Place URL",
"guideUrl": "Search for a place on Naver Maps, tap Share,\nthen paste the URL here.", "guideCombined": "Paste a Naver Maps share URL, or enter a business name to automatically retrieve information.",
"guideBusinessName": "Search by business name to retrieve information.", "errorInputRequired": "Please enter a business name or URL.",
"errorUrlRequired": "Please enter a URL.", "errorInvalidUrl": "Invalid URL format. (e.g., https://naver.me/abcdef)",
"errorNameRequired": "Please enter a business name.",
"errorInvalidUrl": "Invalid URL format. (e.g., https://example.com)",
"analyzeButton": "Brand Analysis", "analyzeButton": "Brand Analysis",
"scrollMore": "Scroll to see more", "scrollMore": "Scroll to see more",
"testDataLoading": "Loading...",
"testData": "Test Data",
"testDataLoadFailed": "Failed to load test data.",
"searching": "Searching...", "searching": "Searching...",
"searchTypeManual": "Manual Input", "searchTypeManual": "Not on Naver? Enter manually",
"manualModalTitle": "Enter Business Info", "manualButtonDesc": "Even businesses not listed on Naver Maps/Place\ncan get lyrics, music, and video production\njust by entering the name, region, and address.",
"manualModalTitle": "Enter Business Info Directly",
"manualModalSubtitle": "Even businesses not on Naver can get lyrics, music, and video production by filling in the details below.",
"manualAnalyzeButton": "Get Started",
"manualLabelName": "Business Name", "manualLabelName": "Business Name",
"manualLabelAddress": "Address", "manualLabelAddress": "Address",
"manualLabelRegion": "Region", "manualLabelRegion": "Region",
"manualLabelDetail": "Detail Address", "manualLabelDetail": "Detail Address",
"manualLabelCategory": "Category",
"manualPlaceholderName": "Enter the business name", "manualPlaceholderName": "Enter the business name",
"manualPlaceholderAddress": "Enter the address", "manualPlaceholderAddress": "Enter the address",
"manualPlaceholderRegion": "Select a region", "manualPlaceholderRegion": "Select a region",
"manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)" "manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)",
"manualPlaceholderCategory": "Enter the business category (e.g. pension, cafe, salon)"
}, },
"welcome": { "welcome": {
"title": "Welcome to ADO2.AI", "title": "Welcome to ADO2.AI",
@ -211,8 +238,6 @@
"guideBusinessName": "Search by business name to retrieve information.", "guideBusinessName": "Search by business name to retrieve information.",
"searchButton": "Search", "searchButton": "Search",
"searching": "Searching...", "searching": "Searching...",
"testDataLoading": "Loading...",
"testData": "Test Data",
"searchTypeManual": "Manual Input", "searchTypeManual": "Manual Input",
"errorSelectFromList": "Please select a business from the list.", "errorSelectFromList": "Please select a business from the list.",
"errorUrlRequired": "Please enter a URL.", "errorUrlRequired": "Please enter a URL.",
@ -223,10 +248,12 @@
"manualLabelAddress": "Address", "manualLabelAddress": "Address",
"manualLabelRegion": "Region", "manualLabelRegion": "Region",
"manualLabelDetail": "Detail Address", "manualLabelDetail": "Detail Address",
"manualLabelCategory": "Category",
"manualPlaceholderName": "Enter the business name", "manualPlaceholderName": "Enter the business name",
"manualPlaceholderAddress": "Enter the address", "manualPlaceholderAddress": "Enter the address",
"manualPlaceholderRegion": "Select a region", "manualPlaceholderRegion": "Select a region",
"manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)" "manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)",
"manualPlaceholderCategory": "Enter the business category (e.g. pension, cafe, salon)"
}, },
"assetManagement": { "assetManagement": {
"title": "Brand Assets", "title": "Brand Assets",
@ -242,7 +269,7 @@
"back": "Go Back", "back": "Go Back",
"loadMore": "Load more", "loadMore": "Load more",
"uploadFailed": "Image upload failed.", "uploadFailed": "Image upload failed.",
"uploading": "Uploading... (30 sec 2 min)", "uploading": "Uploading... (30 sec 1 min)",
"nextStep": "Next Step" "nextStep": "Next Step"
}, },
"soundStudio": { "soundStudio": {
@ -255,7 +282,7 @@
"genreLabel": "Select Genre", "genreLabel": "Select Genre",
"genreAuto": "Auto Select", "genreAuto": "Auto Select",
"genreBallad": "Ballad", "genreBallad": "Ballad",
"languageLabel": "Language", "languageLabel": "Select Language",
"languageKorean": "Korean", "languageKorean": "Korean",
"lyricsColumn": "Lyrics", "lyricsColumn": "Lyrics",
"lyricsHint": "Select the lyrics area to edit", "lyricsHint": "Select the lyrics area to edit",
@ -269,8 +296,8 @@
"videoGenerating": "Generating Video", "videoGenerating": "Generating Video",
"noBusinessInfo": "No business information. Please try again.", "noBusinessInfo": "No business information. Please try again.",
"noImageUploadInfo": "No image upload information. Please go back to the previous step and try again.", "noImageUploadInfo": "No image upload information. Please go back to the previous step and try again.",
"generatingLyrics": "Generating lyrics...\n(3060 sec)", "generatingLyrics": "Generating lyrics... (3060 sec)",
"generatingSong": "Generating music...\n(12 min)", "generatingSong": "Generating music... (12 min)",
"songQueued": "Song generation queued...", "songQueued": "Song generation queued...",
"retryMessage": "Regenerating due to timeout... ({{count}}/{{max}})", "retryMessage": "Regenerating due to timeout... ({{count}}/{{max}})",
"lyricGenerationFailed": "Lyrics generation request failed.", "lyricGenerationFailed": "Lyrics generation request failed.",
@ -284,7 +311,7 @@
"songRegenerationError": "An error occurred during music regeneration.", "songRegenerationError": "An error occurred during music regeneration.",
"creditsRemaining": "{{count}} left", "creditsRemaining": "{{count}} left",
"creditsExhausted": "Not enough credits.", "creditsExhausted": "Not enough credits.",
"chargeCredits": "Purchase credits" "chargeCredits": "Top Up Credits"
}, },
"completion": { "completion": {
"back": "Go Back", "back": "Go Back",
@ -338,7 +365,7 @@
"noLyricsBGM": "Background music is generated without lyrics.", "noLyricsBGM": "Background music is generated without lyrics.",
"downloading": "Downloading...", "downloading": "Downloading...",
"download": "Download", "download": "Download",
"uploadToSocial": "Upload to Social" "uploadToSocial": "Upload"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@ -497,7 +524,7 @@
"delete": "Delete", "delete": "Delete",
"previous": "Previous", "previous": "Previous",
"next": "Next", "next": "Next",
"uploadToSocial": "Upload to social media", "uploadToSocial": "Upload",
"sortLatest": "Latest", "sortLatest": "Latest",
"sortOldest": "Oldest", "sortOldest": "Oldest",
"sortLikes": "Most Liked", "sortLikes": "Most Liked",
@ -526,9 +553,9 @@
"marketPositioning": "Market Positioning", "marketPositioning": "Market Positioning",
"coreValue": "Core Value", "coreValue": "Core Value",
"categoryDefinition": "Category Definition", "categoryDefinition": "Category Definition",
"targetPersona": "Target Persona", "targetPersona": "Key Customer Type",
"ageSuffix": "years old", "ageSuffix": "years old",
"sellingPoints": "Unique Selling Points (USP)", "sellingPoints": "Key Selling Points",
"recommendedKeywords": "Recommended Target Keywords", "recommendedKeywords": "Recommended Target Keywords",
"generateContent": "Generate Content", "generateContent": "Generate Content",
"pageDescBefore": " reveals ", "pageDescBefore": " reveals ",
@ -569,7 +596,7 @@
"loading": "Loading...", "loading": "Loading...",
"noResults": "No recent results", "noResults": "No recent results",
"noResultsDesc": "Upload your content to social channels", "noResultsDesc": "Upload your content to social channels",
"ado2Contents": "ADO2 Contents", "myContents": "My Contents",
"cancel": "Cancel", "cancel": "Cancel",
"retry": "Retry", "retry": "Retry",
"cancelConfirm": "Are you sure you want to cancel this reservation?", "cancelConfirm": "Are you sure you want to cancel this reservation?",
@ -581,7 +608,33 @@
}, },
"loginPrompt": { "loginPrompt": {
"title": "Login Required", "title": "Login Required",
"loginBtn": "Login with Kakao" "loginBtn": "Login"
},
"videoDetail": {
"dateFormat": "{{month}}/{{day}}/{{year}}",
"kakaoDefaultTitle": "ADO2 Video",
"kakaoDescription": "{{region}} · ADO2 AI Marketing Video",
"kakaoButtonTitle": "Watch Video",
"deletedComment": "(This comment has been deleted.)",
"closeAriaLabel": "Close",
"share": "Share",
"copied": "Copied!",
"shareKakao": "KakaoTalk",
"shareFacebook": "Facebook",
"shareTwitter": "X (Twitter)",
"copyUrl": "Copy URL",
"commentsTitle": "Comments",
"changeAvatarTitle": "Click to change avatar",
"nicknamePlaceholder": "Author name",
"commentPlaceholder": "Write a comment...",
"commentLoginRequired": "Please log in to write a comment",
"commentSubmitting": "Submitting",
"commentSubmit": "Submit",
"noComments": "No comments yet.",
"anonymous": "Anonymous",
"deleteComment": "Delete",
"loadMoreComments": "Load more comments",
"loadingComments": "Loading..."
}, },
"app": { "app": {
"loginProcessing": "Processing login...", "loginProcessing": "Processing login...",

View File

@ -1,4 +1,10 @@
{ {
"wizardSteps": {
"brandAnalysis": "분석",
"asset": "이미지",
"sound": "사운드",
"video": "영상"
},
"header": { "header": {
"loginFailedAlert": "로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.", "loginFailedAlert": "로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.",
"start": "시작하기", "start": "시작하기",
@ -28,8 +34,7 @@
"finish": "완료", "finish": "완료",
"landing": { "landing": {
"intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요." }, "intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요." },
"dropdown": { "title": "검색 방식 선택", "desc": "드롭다운에서 URL 또는 업체명 중 원하는 방식을 선택하세요." }, "field": { "title": "입력하기", "desc": "네이버 지도 공유 URL을 붙여넣거나,\n업체명을 입력하면 자동으로 목록에서 선택할 수 있어요." },
"field": { "title": "입력하기", "desc": "URL 방식이라면 네이버 지도 공유 URL,\n업체명 방식이라면 업체명을 입력하고 목록에서 선택하세요." },
"manual": { "title": "직접 입력", "desc": "업체명과 주소를 직접 입력해서 분석을 시작할 수도 있어요." }, "manual": { "title": "직접 입력", "desc": "업체명과 주소를 직접 입력해서 분석을 시작할 수도 있어요." },
"button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." } "button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." }
}, },
@ -137,7 +142,29 @@
"uploadFailed": "업로드에 실패했습니다.", "uploadFailed": "업로드에 실패했습니다.",
"autoSeoTitle": "자동으로 작성중입니다. (30~60초 소요)", "autoSeoTitle": "자동으로 작성중입니다. (30~60초 소요)",
"autoSeoDescription": "자동으로 작성중입니다. (30~60초 소요)", "autoSeoDescription": "자동으로 작성중입니다. (30~60초 소요)",
"autoSeoTags": "자동으로 작성중입니다. (30~60초 소요)" "autoSeoTags": "자동으로 작성중입니다. (30~60초 소요)",
"youtubeCallback": {
"connecting": "YouTube 연결 중...",
"pleaseWait": "잠시만 기다려주세요.",
"failed": "연결 실패",
"errorAuthCancelled": "Google 인증이 취소되었거나 실패했습니다.",
"errorNoCode": "인증 코드를 받지 못했습니다.",
"errorProcessing": "YouTube 연결 처리 중 오류가 발생했습니다."
},
"connectError": {
"title": "{{platform}} 연결 실패",
"defaultError": "알 수 없는 오류가 발생했습니다.",
"detail": "다시 시도해주세요. 문제가 지속되면 고객 지원에 문의해주세요.",
"retry": "다시 시도"
},
"connectSuccess": {
"title": "{{name}} 연결 완료",
"messageWithChannel": "{{platform}} 계정이 성공적으로 연결되었습니다.",
"messageWithoutChannel": "계정이 성공적으로 연결되었습니다.",
"verified": "인증됨",
"countdown": "{{count}}초 후 자동으로 이동합니다...",
"goNow": "지금 이동"
}
}, },
"upload": { "upload": {
"title": "YouTube 업로드", "title": "YouTube 업로드",
@ -155,33 +182,32 @@
"close": "닫기", "close": "닫기",
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.", "doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.",
"goToCalendar": "캘린더에서 확인", "goToCalendar": "캘린더에서 확인",
"scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다." "scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다." },
},
"landing": { "landing": {
"hero": { "hero": {
"searchTypeBusinessName": "업체명", "searchTypeLabel": "업체명 | URL",
"placeholderBusinessName": "업체명을 입력하세요.", "placeholderCombined": "업체명 또는 URL을 입력하세요.",
"guideUrl": "네이버지도에서 장소를 검색하고 공유 선택, \n나오는 URL을 붙여 넣어 주세요.", "guideCombined": "네이버 지도 공유 URL을 붙여넣거나,\n업체명을 입력하면 자동으로 정보를 가져옵니다.",
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.", "errorInputRequired": "업체명 또는 URL을 입력해주세요.",
"errorUrlRequired": "URL을 입력해주세요.", "errorInvalidUrl": "올바른 URL 형식이 아닙니다. (예: https://naver.me/abcdef)",
"errorNameRequired": "업체명을 입력해주세요.",
"errorInvalidUrl": "올바른 URL 형식이 아닙니다. (예: https://example.com)",
"analyzeButton": "브랜드 분석", "analyzeButton": "브랜드 분석",
"scrollMore": "스크롤하여 더 보기", "scrollMore": "스크롤하여 더 보기",
"testDataLoading": "로딩 중...",
"testData": "테스트 데이터",
"testDataLoadFailed": "테스트 데이터를 불러오는데 실패했습니다.",
"searching": "검색 중...", "searching": "검색 중...",
"searchTypeManual": "직접 입력", "searchTypeManual": "네이버에 없는 업체인가요? 직접 입력하기",
"manualModalTitle": "업체 정보 입력", "manualButtonDesc": "네이버 지도/플레이스에 등록되지 않은 업체도\n업체명·지역·상세주소만 입력하면\n가사·음악·영상 제작까지 가능합니다.",
"manualModalTitle": "업체 정보 직접 입력",
"manualModalSubtitle": "네이버에 등록되지 않은 업체도 아래 정보만 입력하면 가사·음악·영상 제작까지 할 수 있어요.",
"manualAnalyzeButton": "시작하기",
"manualLabelName": "업체명", "manualLabelName": "업체명",
"manualLabelAddress": "주소", "manualLabelAddress": "주소",
"manualLabelRegion": "지역", "manualLabelRegion": "지역",
"manualLabelDetail": "상세 주소", "manualLabelDetail": "상세 주소",
"manualLabelCategory": "업종",
"manualPlaceholderName": "업체명을 입력하세요.", "manualPlaceholderName": "업체명을 입력하세요.",
"manualPlaceholderAddress": "주소를 입력하세요.", "manualPlaceholderAddress": "주소를 입력하세요.",
"manualPlaceholderRegion": "지역을 선택하세요.", "manualPlaceholderRegion": "지역을 선택하세요.",
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)" "manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)",
"manualPlaceholderCategory": "업종을 입력하세요. (예: 펜션, 카페, 미용실)"
}, },
"welcome": { "welcome": {
"title": "ADO2.AI에 오신 것을 환영합니다.", "title": "ADO2.AI에 오신 것을 환영합니다.",
@ -211,8 +237,6 @@
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.", "guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
"searchButton": "검색하기", "searchButton": "검색하기",
"searching": "검색 중...", "searching": "검색 중...",
"testDataLoading": "로딩 중...",
"testData": "테스트 데이터",
"searchTypeManual": "직접 입력", "searchTypeManual": "직접 입력",
"errorSelectFromList": "목록에서 업체를 선택해 주세요.", "errorSelectFromList": "목록에서 업체를 선택해 주세요.",
"errorUrlRequired": "URL을 입력해주세요.", "errorUrlRequired": "URL을 입력해주세요.",
@ -223,10 +247,12 @@
"manualLabelAddress": "주소", "manualLabelAddress": "주소",
"manualLabelRegion": "지역", "manualLabelRegion": "지역",
"manualLabelDetail": "상세 주소", "manualLabelDetail": "상세 주소",
"manualLabelCategory": "업종",
"manualPlaceholderName": "업체명을 입력하세요.", "manualPlaceholderName": "업체명을 입력하세요.",
"manualPlaceholderAddress": "주소를 입력하세요.", "manualPlaceholderAddress": "주소를 입력하세요.",
"manualPlaceholderRegion": "지역을 선택하세요.", "manualPlaceholderRegion": "지역을 선택하세요.",
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)" "manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)",
"manualPlaceholderCategory": "업종을 입력하세요. (예: 펜션, 카페, 미용실)"
}, },
"assetManagement": { "assetManagement": {
"title": "브랜드 에셋", "title": "브랜드 에셋",
@ -242,7 +268,7 @@
"back": "뒤로가기", "back": "뒤로가기",
"loadMore": "더보기", "loadMore": "더보기",
"uploadFailed": "이미지 업로드에 실패했습니다.", "uploadFailed": "이미지 업로드에 실패했습니다.",
"uploading": "업로드 중 (30초~2분 소요)", "uploading": "업로드 중 (30~60초 소요)",
"nextStep": "다음 단계" "nextStep": "다음 단계"
}, },
"soundStudio": { "soundStudio": {
@ -338,7 +364,7 @@
"noLyricsBGM": "배경음악은 가사 없이 생성됩니다.", "noLyricsBGM": "배경음악은 가사 없이 생성됩니다.",
"downloading": "다운로드 중...", "downloading": "다운로드 중...",
"download": "다운로드", "download": "다운로드",
"uploadToSocial": "소셜 채널 업로드" "uploadToSocial": "업로드"
}, },
"dashboard": { "dashboard": {
"title": "대시보드", "title": "대시보드",
@ -497,7 +523,7 @@
"delete": "삭제", "delete": "삭제",
"previous": "이전", "previous": "이전",
"next": "다음", "next": "다음",
"uploadToSocial": "소셜 미디어에 업로드", "uploadToSocial": "업로드",
"sortLatest": "최신순", "sortLatest": "최신순",
"sortOldest": "오래된순", "sortOldest": "오래된순",
"sortLikes": "좋아요 많은순", "sortLikes": "좋아요 많은순",
@ -569,7 +595,7 @@
"loading": "불러오는 중...", "loading": "불러오는 중...",
"noResults": "최근 결과 없음", "noResults": "최근 결과 없음",
"noResultsDesc": "제작한 콘텐츠를 소셜 채널에 업로드해 보세요", "noResultsDesc": "제작한 콘텐츠를 소셜 채널에 업로드해 보세요",
"ado2Contents": "ADO2 콘텐츠", "myContents": "내 콘텐츠",
"cancel": "취소", "cancel": "취소",
"retry": "재시도", "retry": "재시도",
"cancelConfirm": "예약을 취소하시겠습니까?", "cancelConfirm": "예약을 취소하시겠습니까?",
@ -583,6 +609,32 @@
"title": "로그인이 필요합니다.", "title": "로그인이 필요합니다.",
"loginBtn": "로그인" "loginBtn": "로그인"
}, },
"videoDetail": {
"dateFormat": "{{year}}년 {{month}}월 {{day}}일",
"kakaoDefaultTitle": "ADO2 영상",
"kakaoDescription": "{{region}} · ADO2 AI 마케팅 영상",
"kakaoButtonTitle": "영상 보기",
"deletedComment": "(삭제된 댓글입니다.)",
"closeAriaLabel": "닫기",
"share": "공유하기",
"copied": "복사됨!",
"shareKakao": "카카오톡",
"shareFacebook": "페이스북",
"shareTwitter": "X (트위터)",
"copyUrl": "URL 복사",
"commentsTitle": "댓글",
"changeAvatarTitle": "클릭하여 아바타 변경",
"nicknamePlaceholder": "작성자 이름",
"commentPlaceholder": "댓글을 입력하세요...",
"commentLoginRequired": "로그인 후 댓글을 작성할 수 있습니다",
"commentSubmitting": "작성 중",
"commentSubmit": "작성",
"noComments": "아직 댓글이 없습니다.",
"anonymous": "익명",
"deleteComment": "삭제",
"loadMoreComments": "댓글 더 보기",
"loadingComments": "불러오는 중..."
},
"app": { "app": {
"loginProcessing": "로그인 처리 중...", "loginProcessing": "로그인 처리 중...",
"loginFailed": "로그인 처리에 실패했습니다. 다시 시도해주세요.", "loginFailed": "로그인 처리에 실패했습니다. 다시 시도해주세요.",

View File

@ -22,7 +22,7 @@ const LoadingSection: React.FC<LoadingSectionProps> = ({ onComplete, isComplete
const increment = prev < 70 ? 0.5 : prev < 90 ? 0.2 : 0.1; const increment = prev < 70 ? 0.5 : prev < 90 ? 0.2 : 0.1;
return Math.min(prev + increment, 100); return Math.min(prev + increment, 100);
}); });
}, 100); }, 200);
return () => { return () => {
if (intervalRef.current) clearInterval(intervalRef.current); if (intervalRef.current) clearInterval(intervalRef.current);
}; };

View File

@ -5,6 +5,7 @@ import { VideoListItem } from '../../types/api';
import LoginPromptModal from '../../components/LoginPromptModal'; import LoginPromptModal from '../../components/LoginPromptModal';
import VideoDetailModal from '../../components/VideoDetailModal'; import VideoDetailModal from '../../components/VideoDetailModal';
import CitySelectModal from '../../components/CitySelectModal'; import CitySelectModal from '../../components/CitySelectModal';
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
interface ADO2ContentsPageProps { interface ADO2ContentsPageProps {
onBack?: () => void; onBack?: () => void;
@ -181,16 +182,14 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
<h3 className="content-card-title">{video.store_name}</h3> <h3 className="content-card-title">{video.store_name}</h3>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<p className="content-card-date">{formatDate(video.created_at)}</p> <p className="content-card-date">{formatDate(video.created_at)}</p>
<span className="content-card-like"> <ContentCardSocialActions
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2"> videoId={video.video_id}
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/> storeName={video.store_name}
</svg> region={video.region}
{video.like_count ?? 0} commentCount={video.comment_count ?? 0}
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: '6px' }}> initialLikeCount={video.like_count ?? 0}
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/> initialIsLiked={video.is_liked_by_me}
</svg> />
{video.comment_count ?? 0}
</span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -26,6 +26,7 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
const { t } = useTranslation(); const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState<string | null>(null); const [uploadError, setUploadError] = useState<string | null>(null);
const [videoRatio, setVideoRatio] = useState<VideoRatio>('vertical'); const [videoRatio, setVideoRatio] = useState<VideoRatio>('vertical');
const [displayCount, setDisplayCount] = useState(IMAGES_PER_PAGE); const [displayCount, setDisplayCount] = useState(IMAGES_PER_PAGE);
@ -43,15 +44,23 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
}; };
const getImageSrc = (item: ImageItem): string => { const getImageSrc = (item: ImageItem): string => {
return item.type === 'url' ? item.url : item.preview; return item.type === 'url' ? item.preview_url : item.preview;
}; };
const handleNextWithUpload = async () => { const handleNextWithUpload = async () => {
if (imageList.length === 0) return; if (imageList.length === 0) return;
setIsUploading(true); setIsUploading(true);
setUploadProgress(0);
setUploadError(null); setUploadError(null);
const interval = setInterval(() => {
setUploadProgress(prev => {
if (prev >= 99) return prev;
return prev + (prev < 50 ? 0.6 : prev < 80 ? 0.3 : prev < 90 ? 0.15 : 0.11);
});
}, 200);
try { try {
const urlImages: ImageUrlItem[] = imageList const urlImages: ImageUrlItem[] = imageList
.filter((item: ImageItem): item is ImageItem & { type: 'url' } => item.type === 'url') .filter((item: ImageItem): item is ImageItem & { type: 'url' } => item.type === 'url')
@ -63,13 +72,23 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
if (urlImages.length > 0 || fileImages.length > 0) { if (urlImages.length > 0 || fileImages.length > 0) {
const response = await uploadImages(urlImages, fileImages); const response = await uploadImages(urlImages, fileImages);
clearInterval(interval);
setUploadProgress(100);
// 이미지 변경 시 이전 음악/영상 생성 결과 초기화 — 반드시 재생성하도록 강제
localStorage.removeItem('castad_song_generation');
localStorage.removeItem('castad_song_completion');
localStorage.removeItem('castad_song_task_id');
localStorage.removeItem('castad_video_generation');
localStorage.removeItem('castad_video_complete');
onNext(response.task_id); onNext(response.task_id);
} }
} catch (error) { } catch (error) {
clearInterval(interval);
console.error('Image upload failed:', error); console.error('Image upload failed:', error);
setUploadError(error instanceof Error ? error.message : t('assetManagement.uploadFailed')); setUploadError(error instanceof Error ? error.message : t('assetManagement.uploadFailed'));
} finally { } finally {
setIsUploading(false); setIsUploading(false);
setUploadProgress(0);
} }
}; };
@ -104,6 +123,29 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
return ( return (
<main className="asset-page"> <main className="asset-page">
{isUploading && (
<div className="asset-upload-overlay">
<div className="asset-upload-overlay-content">
<div className="loading-spinner">
<div className="loading-ring"></div>
<div className="loading-dot">
<div className="loading-dot-inner"></div>
</div>
</div>
<p className="comp2-loading-text">{t('assetManagement.uploading')}</p>
<div className="loading-progress-wrapper">
<div className="loading-progress-bar">
<div
className="loading-progress-fill"
style={{ width: `${uploadProgress}%` }}
/>
</div>
<span className="loading-progress-text">{Math.floor(uploadProgress)}%</span>
</div>
</div>
</div>
)}
{/* Fixed Header - 뒤로가기 버튼 */} {/* Fixed Header - 뒤로가기 버튼 */}
<div className="asset-sticky-header"> <div className="asset-sticky-header">
{onBack && ( {onBack && (
@ -145,6 +187,7 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
<img <img
src={getImageSrc(item)} src={getImageSrc(item)}
alt={`${t('assetManagement.imageAlt')} ${i + 1}`} alt={`${t('assetManagement.imageAlt')} ${i + 1}`}
referrerPolicy="no-referrer"
/> />
{item.type === 'file' && ( {item.type === 'file' && (
<div className="asset-image-badge">{t('assetManagement.uploadBadge')}</div> <div className="asset-image-badge">{t('assetManagement.uploadBadge')}</div>

View File

@ -1,21 +0,0 @@
import React from 'react';
import Sidebar from '../../components/Sidebar';
import AssetManagementContent from './AssetManagementContent';
interface AssetManagementSectionProps {
onBack: () => void;
}
const AssetManagementSection: React.FC<AssetManagementSectionProps> = ({ onBack }) => {
return (
<div className="flex w-full h-screen bg-[#002224] text-white overflow-hidden">
<Sidebar />
<div className="flex-1 overflow-y-auto">
<AssetManagementContent onBack={onBack} onNext={() => {}} />
</div>
</div>
);
};
export default AssetManagementSection;

View File

@ -409,14 +409,14 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
return ( return (
<main className="comp2-page"> <main className="comp2-page">
<div className="comp2-header"> {/* <div className="comp2-header">
<button onClick={onBack} className="comp2-back-btn"> <button onClick={onBack} className="comp2-back-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" /> <path d="M15 18l-6-6 6-6" />
</svg> </svg>
<span>{t('completion.back')}</span> <span>{t('completion.back')}</span>
</button> </button>
</div> </div> */}
<div className="comp2-title-row"> <div className="comp2-title-row">
<h1 className="comp2-page-title">{t('completion.contentComplete')}</h1> <h1 className="comp2-page-title">{t('completion.contentComplete')}</h1>
@ -476,11 +476,11 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
<div className="comp2-info-header"> <div className="comp2-info-header">
<span className="comp2-info-label">{t('completion.contentInfo')}</span> <span className="comp2-info-label">{t('completion.contentInfo')}</span>
</div> </div>
<div className="comp2-info-content">
<div className="comp2-file-info"> <div className="comp2-file-info">
<h3 className="comp2-filename">{getFileName()}</h3> <h3 className="comp2-filename">{getFileName()}</h3>
{/* <p className="comp2-filesize">19.6MB</p> */} {/* <p className="comp2-filesize">19.6MB</p> */}
</div> </div>
<div className="comp2-info-content">
<div className="comp2-meta-grid"> <div className="comp2-meta-grid">
<div className="comp2-meta-item"> <div className="comp2-meta-item">
<span className="comp2-meta-label">{t('completion.genre')} : {songCompletionData?.genre || 'K-POP'}</span> <span className="comp2-meta-label">{t('completion.genre')} : {songCompletionData?.genre || 'K-POP'}</span>
@ -500,19 +500,22 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
return <p className="comp2-lyrics-text">{t('completion.noLyricsBGM')}</p>; return <p className="comp2-lyrics-text">{t('completion.noLyricsBGM')}</p>;
} }
const lines = songCompletionData.lyrics.split('\n').filter((l: string) => l.trim()); const lines = songCompletionData.lyrics.split('\n').filter((l: string) => l.trim());
const outro = lines.slice(-1); const sections: { tag: string | null; lines: string[] }[] = [];
const body = lines.slice(0, -1); lines.forEach((line: string) => {
const half = Math.ceil(body.length / 2); const tagMatch = line.trim().match(/^\[(.+)\]$/);
const sections = [ if (tagMatch) {
{ tag: '[Verse]', lines: body.slice(0, half) }, sections.push({ tag: `[${tagMatch[1]}]`, lines: [] });
{ tag: '[Chorus]', lines: body.slice(half) }, } else if (sections.length === 0) {
{ tag: '[Outro]', lines: outro }, sections.push({ tag: null, lines: [line] });
].filter(s => s.lines.length > 0); } else {
sections[sections.length - 1].lines.push(line);
}
});
return ( return (
<div className="comp2-lyrics-paragraphs"> <div className="comp2-lyrics-paragraphs">
{sections.map((section, i) => ( {sections.filter(s => s.lines.length > 0).map((section, i) => (
<div key={i} className="comp2-lyrics-para-section"> <div key={i} className="comp2-lyrics-para-section">
<span className="comp2-lyrics-tag">{section.tag}</span> {section.tag && <span className="comp2-lyrics-tag">{section.tag}</span>}
<p className="comp2-lyrics-text">{section.lines.join('\n')}</p> <p className="comp2-lyrics-text">{section.lines.join('\n')}</p>
</div> </div>
))} ))}

View File

@ -645,7 +645,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
</p> </p>
</div> </div>
<button <button
onClick={() => onNavigate?.('ADO2 콘텐츠')} onClick={() => onNavigate?.(' 콘텐츠')}
style={{ style={{
height: 34, padding: '0 10px', borderRadius: 8, border: 'none', height: 34, padding: '0 10px', borderRadius: 8, border: 'none',
backgroundColor: '#a65eff', cursor: 'pointer', backgroundColor: '#a65eff', cursor: 'pointer',
@ -653,7 +653,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
color: '#ffffff', color: '#ffffff',
}} }}
> >
{t('contentCalendar.ado2Contents')} {t('contentCalendar.myContents')}
</button> </button>
</div> </div>
); );
@ -717,7 +717,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
</div> </div>
{/* 바텀시트 */} {/* 바텀시트 */}
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 100, display: 'flex', flexDirection: 'column' }}> <div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 10, display: 'flex', flexDirection: 'column' }}>
<div style={{ <div style={{
backgroundColor: '#01393b', backgroundColor: '#01393b',
borderTop: '1px solid #046266', borderTop: '1px solid #046266',

View File

@ -2,7 +2,6 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Sidebar from '../../components/Sidebar'; import Sidebar from '../../components/Sidebar';
import ShortformContent from './ShortformContent';
import AssetManagementContent from './AssetManagementContent'; import AssetManagementContent from './AssetManagementContent';
import SoundStudioContent from './SoundStudioContent'; import SoundStudioContent from './SoundStudioContent';
import CompletionContent from './CompletionContent'; import CompletionContent from './CompletionContent';
@ -15,11 +14,12 @@ import MyInfoContent from './MyInfoContent';
import ContentCalendarContent from './ContentCalendarContent'; import ContentCalendarContent from './ContentCalendarContent';
import LoadingSection from '../Analysis/LoadingSection'; import LoadingSection from '../Analysis/LoadingSection';
import AnalysisResultSection from '../Analysis/AnalysisResultSection'; import AnalysisResultSection from '../Analysis/AnalysisResultSection';
import { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/api'; import { ImageItem, type ImageListItem, CrawlingResponse, UserMeResponse } from '../../types/api';
import { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens } from '../../utils/api'; import { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens } from '../../utils/api';
import { useTutorial } from '../../components/Tutorial/useTutorial'; import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps'; import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay'; import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay';
import WizardStepper from '../../components/WizardStepper';
const WIZARD_STEP_KEY = 'castad_wizard_step'; const WIZARD_STEP_KEY = 'castad_wizard_step';
const ACTIVE_ITEM_KEY = 'castad_active_item'; const ACTIVE_ITEM_KEY = 'castad_active_item';
@ -52,10 +52,9 @@ interface BusinessInfo {
interface GenerationFlowProps { interface GenerationFlowProps {
onHome: () => void; onHome: () => void;
initialActiveItem?: string; initialActiveItem?: string;
initialImageList?: string[]; initialImageList?: ImageListItem[];
businessInfo?: BusinessInfo; businessInfo?: BusinessInfo;
initialAnalysisData?: CrawlingResponse | null; initialAnalysisData?: CrawlingResponse | null;
initialShortform?: boolean;
} }
// 위저드 단계: // 위저드 단계:
@ -71,8 +70,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
initialActiveItem = '대시보드', initialActiveItem = '대시보드',
initialImageList = [], initialImageList = [],
businessInfo, businessInfo,
initialAnalysisData, initialAnalysisData
initialShortform = false,
}) => { }) => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
// localStorage에서 저장된 상태 복원 // localStorage에서 저장된 상태 복원
@ -97,9 +95,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
// 초기 위저드 단계 결정 // 초기 위저드 단계 결정
const getInitialWizardStep = (): number => { const getInitialWizardStep = (): number => {
// 숏폼 진입 플래그가 있으면 숏폼 화면(-3)으로 바로
if (initialShortform) return -3;
if (savedWizardStep !== null) { if (savedWizardStep !== null) {
const step = parseInt(savedWizardStep, 10); const step = parseInt(savedWizardStep, 10);
// 분석 데이터가 있는데 URL 입력(-2) 또는 로딩(-1) 단계로 저장된 경우: // 분석 데이터가 있는데 URL 입력(-2) 또는 로딩(-1) 단계로 저장된 경우:
@ -174,9 +169,9 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
// URL 이미지를 ImageItem 형태로 변환하여 초기화 // URL 이미지를 ImageItem 형태로 변환하여 초기화
const getInitialImageList = (): ImageItem[] => { const getInitialImageList = (): ImageItem[] => {
if (analysisData?.image_list && analysisData.image_list.length > 0) { if (analysisData?.image_list && analysisData.image_list.length > 0) {
return analysisData.image_list.map(url => ({ type: 'url', url })); return analysisData.image_list.map(item => ({ type: 'url' as const, url: item.original, preview_url: item.preview }));
} }
return initialImageList.map(url => ({ type: 'url', url })); return initialImageList.map(item => ({ type: 'url' as const, url: item.original, preview_url: item.preview }));
}; };
const [imageList, setImageList] = useState<ImageItem[]>(getInitialImageList()); const [imageList, setImageList] = useState<ImageItem[]>(getInitialImageList());
@ -188,7 +183,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
console.log('[GenerationFlow] analysisData updated, m_id:', analysisData?.m_id); console.log('[GenerationFlow] analysisData updated, m_id:', analysisData?.m_id);
if (analysisData?.image_list && analysisData.image_list.length > 0) { if (analysisData?.image_list && analysisData.image_list.length > 0) {
if (prevAnalysisMIdRef.current !== analysisData.m_id) { if (prevAnalysisMIdRef.current !== analysisData.m_id) {
setImageList(analysisData.image_list.map(url => ({ type: 'url', url }))); setImageList(analysisData.image_list.map(item => ({ type: 'url' as const, url: item.original, preview_url: item.preview })));
} }
} }
prevAnalysisMIdRef.current = analysisData?.m_id; prevAnalysisMIdRef.current = analysisData?.m_id;
@ -268,13 +263,13 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
}; };
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출 // 업체명·주소 수동 입력으로 마케팅 분석 API 호출
const handleManualInput = async (businessName: string, address: string) => { const handleManualInput = async (businessName: string, address: string, category: string) => {
goToWizardStep(-1); goToWizardStep(-1);
setIsAnalysisComplete(false); setIsAnalysisComplete(false);
setAnalysisError(null); setAnalysisError(null);
try { try {
const data = await marketingAnalysis(businessName, address); const data = await marketingAnalysis(businessName, address, category);
if (data.processed_info) { if (data.processed_info) {
data.processed_info.customer_name = data.processed_info.customer_name || businessName; data.processed_info.customer_name = data.processed_info.customer_name || businessName;
@ -294,7 +289,25 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
} }
}; };
// 테스트용 랜덤 m_id 생성 (99 ~ 300) // 언어 변경 시 테스트 데이터 다시 로드
useEffect(() => {
const saved = localStorage.getItem(ANALYSIS_DATA_KEY);
if (!saved) return;
try {
const parsed = JSON.parse(saved);
if (!parsed._isTestData) return;
const jsonFile = i18n.language === 'en' ? '/example_analysis_en.json' : '/example_analysis.json';
fetch(jsonFile)
.then(res => res.json())
.then((data: CrawlingResponse) => {
// 언어 변경 시에는 기존 m_id 유지
const tagged = { ...data, m_id: parsed.m_id, _isTestData: true };
setAnalysisData(tagged);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
})
.catch(err => console.error('Failed to reload test data:', err));
} catch { /* ignore */ }
}, [i18n.language]);
// URL 분석 시작 // URL 분석 시작
const handleStartAnalysis = async (url: string) => { const handleStartAnalysis = async (url: string) => {
@ -407,13 +420,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
// 새 프로젝트 만들기 - 단계별 컨텐츠 렌더링 // 새 프로젝트 만들기 - 단계별 컨텐츠 렌더링
const renderWizardContent = () => { const renderWizardContent = () => {
switch (wizardStep) { switch (wizardStep) {
case -3:
// 숏폼 생성 단계
return (
<ShortformContent
onComplete={refreshCredits}
/>
);
case -2: case -2:
// URL 입력 단계 // URL 입력 단계
return ( return (
@ -421,7 +427,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
onAnalyze={handleStartAnalysis} onAnalyze={handleStartAnalysis}
onAutocomplete={handleAutocomplete} onAutocomplete={handleAutocomplete}
onManualInput={handleManualInput} onManualInput={handleManualInput}
onShortform={() => goToWizardStep(-3)}
error={analysisError} error={analysisError}
/> />
); );
@ -478,6 +483,7 @@ onShortform={() => goToWizardStep(-3)}
businessInfo={currentBusinessInfo} businessInfo={currentBusinessInfo}
imageTaskId={imageTaskId} imageTaskId={imageTaskId}
mId={analysisData?.m_id ?? 0} mId={analysisData?.m_id ?? 0}
industry={analysisData?.industry ?? ''}
videoGenerationStatus={videoGenerationStatus} videoGenerationStatus={videoGenerationStatus}
videoGenerationProgress={videoGenerationProgress} videoGenerationProgress={videoGenerationProgress}
onGoToPayment={handleGoToPayment} onGoToPayment={handleGoToPayment}
@ -533,12 +539,22 @@ onShortform={() => goToWizardStep(-3)}
case '내 정보': case '내 정보':
return <MyInfoContent initialTab={myInfoInitialTab} />; return <MyInfoContent initialTab={myInfoInitialTab} />;
case '새 프로젝트 만들기': case '새 프로젝트 만들기':
// 브랜드 분석(0), 로딩(-1), 숏폼(-3)은 전체 화면으로 표시 // 로딩(-1), URL 입력(-2)은 스텝퍼 없이 전체 화면으로 표시
if (wizardStep === 0 || wizardStep === -1 || wizardStep === -3) { if (wizardStep === -1 || wizardStep === -2) {
return renderWizardContent(); return renderWizardContent();
} }
// 브랜드 분석(0)은 전체 화면 스크롤이지만 스텝퍼는 표시
if (wizardStep === 0) {
return (
<>
<WizardStepper currentStep={wizardStep} />
{renderWizardContent()}
</>
);
}
return ( return (
<div className="wizard-page-container"> <div className="wizard-page-container">
<WizardStepper currentStep={wizardStep} />
{renderWizardContent()} {renderWizardContent()}
</div> </div>
); );
@ -630,7 +646,7 @@ onShortform={() => goToWizardStep(-3)}
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} tutorialAvailable={!!getCurrentTutorialKey()} tutorialEnabled={tutorial.isEnabled} onToggleTutorial={() => tutorial.toggleTutorial(getCurrentTutorialKey())} /> <Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} tutorialAvailable={!!getCurrentTutorialKey()} tutorialEnabled={tutorial.isEnabled} onToggleTutorial={() => tutorial.toggleTutorial(getCurrentTutorialKey())} />
)} )}
{tutorialUI} {tutorialUI}
<div className={`flex-1 relative pl-0 md:pl-0 ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'}`}> <div className={`flex-1 relative pl-0 md:pl-0 bg-[#001a1c] ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'}`}>
{renderContent()} {renderContent()}
</div> </div>
</div> </div>

View File

@ -5,6 +5,7 @@ import { getVideosList, deleteVideo } from '../../utils/api';
import { VideoListItem } from '../../types/api'; import { VideoListItem } from '../../types/api';
import SocialPostingModal from '../../components/SocialPostingModal'; import SocialPostingModal from '../../components/SocialPostingModal';
import VideoDetailModal from '../../components/VideoDetailModal'; import VideoDetailModal from '../../components/VideoDetailModal';
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
import { useTutorial } from '../../components/Tutorial/useTutorial'; import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps'; import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
@ -278,16 +279,14 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
<p className="content-card-date"> <p className="content-card-date">
{formatDate(video.created_at)} {formatDate(video.created_at)}
</p> </p>
<span className="content-card-like"> <ContentCardSocialActions
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2"> videoId={video.video_id}
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/> storeName={video.store_name}
</svg> region={video.region}
{video.like_count ?? 0} commentCount={video.comment_count ?? 0}
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: '6px' }}> initialLikeCount={video.like_count ?? 0}
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/> initialIsLiked={video.is_liked_by_me}
</svg> />
{video.comment_count ?? 0}
</span>
</div> </div>
</div> </div>

View File

@ -1,478 +0,0 @@
import React, { useRef, useState } from 'react';
import { uploadImages, generateShortform, waitForShortformComplete, previewShortform } from '../../utils/api';
import { ShortformStatusResponse, VideoSpec } from '../../types/api';
type Kind = 'place' | 'product' | 'message';
type Step = 1 | 2 | 3 | 4;
interface ShortformContentProps {
onComplete?: () => void;
}
const KIND_OPTIONS: { value: Kind; label: string; desc: string }[] = [
{ value: 'place', label: '장소', desc: '펜션·카페·식당·매장 등 공간' },
{ value: 'product', label: '물건', desc: '제품, 음식 등 시각적인 아이템' },
{ value: 'message', label: '메시지', desc: '축하·안부·홍보 등' },
];
const STEP_META = [
{ label: '사진 업로드', sub: '4~5장' },
{ label: '정보 입력', sub: '몇 가지만 답하기' },
{ label: '영상 생성', sub: 'AI 카메라 + 자막' },
{ label: '완성', sub: '다운로드' },
];
const MIN_PHOTOS = 4;
const MIN_PHOTOS_LABEL = `${MIN_PHOTOS}`;
const ShortformContent: React.FC<ShortformContentProps> = ({ onComplete }) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const dropRef = useRef<HTMLDivElement>(null);
// 단계
const [step, setStep] = useState<Step>(1);
// Step 1 — 사진
const [files, setFiles] = useState<File[]>([]);
const [previews, setPreviews] = useState<string[]>([]);
// Step 2 — 폼
const [kind, setKind] = useState<Kind>('place');
const [bizName, setBizName] = useState('');
const [selling, setSelling] = useState('');
const [addr, setAddr] = useState('');
const [price, setPrice] = useState('');
// Step 3/4 — 생성 상태
const [statusMsg, setStatusMsg] = useState('');
const [result, setResult] = useState<ShortformStatusResponse | null>(null);
const [errorMsg, setErrorMsg] = useState('');
// 자막 미리보기
const [preview, setPreview] = useState<VideoSpec | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState('');
// --- 사진 관리 ---
const addFiles = (newFiles: File[]) => {
const images = newFiles.filter(f => f.type.startsWith('image/'));
if (!images.length) return;
const newUrls = images.map(f => URL.createObjectURL(f));
setFiles(prev => [...prev, ...images]);
setPreviews(prev => [...prev, ...newUrls]);
};
const removeFile = (index: number) => {
URL.revokeObjectURL(previews[index]);
setFiles(prev => prev.filter((_, i) => i !== index));
setPreviews(prev => prev.filter((_, i) => i !== index));
};
const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); };
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
addFiles(Array.from(e.dataTransfer.files));
};
// --- 미리보기 필드 수정 ---
const patchPreview = (updater: (prev: NonNullable<typeof preview>) => NonNullable<typeof preview>) => {
setPreview(prev => prev ? updater(prev) : prev);
};
// --- 자막 미리보기 ---
const handlePreview = async () => {
if (!selling.trim()) return;
setPreviewLoading(true);
setPreviewError('');
try {
const spec = await previewShortform({
kind,
biz_name: bizName.trim(),
selling: selling.trim(),
addr: addr.trim() || undefined,
price: price.trim() || undefined,
});
setPreview(spec);
} catch (err) {
setPreviewError(err instanceof Error ? err.message : '미리보기 생성에 실패했습니다.');
} finally {
setPreviewLoading(false);
}
};
// --- 다운로드 ---
const [isDownloading, setIsDownloading] = useState(false);
const handleDownload = async (url: string) => {
if (isDownloading) return;
setIsDownloading(true);
try {
const resp = await fetch(url);
const blob = await resp.blob();
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = `${bizName || 'shortform'}.mp4`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
} catch {
const link = document.createElement('a');
link.href = url;
link.download = `${bizName || 'shortform'}.mp4`;
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} finally {
setIsDownloading(false);
}
};
// --- 생성 실행 ---
const handleGenerate = async () => {
setStep(3);
setErrorMsg('');
setResult(null);
try {
setStatusMsg('사진 업로드 중...');
const uploadResp = await uploadImages([], files);
setStatusMsg('영상 생성 요청 중...');
await generateShortform({
task_id: uploadResp.task_id,
kind,
biz_name: bizName.trim(),
addr: addr.trim() || undefined,
price: price.trim() || undefined,
selling: selling.trim(),
spec: preview ?? undefined,
dry_run: true,
});
setStatusMsg('AI 카메라 무브·자막 렌더링 중...');
const statusResp = await waitForShortformComplete(uploadResp.task_id, s => {
setStatusMsg(s === 'processing' ? 'AI 카메라 무브·자막 렌더링 중...' : s);
});
setResult(statusResp);
setStep(4);
onComplete?.();
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : '생성 중 오류가 발생했습니다.');
setStep(2);
}
};
const step1Valid = files.length >= MIN_PHOTOS;
const step2Valid = bizName.trim() && selling.trim();
// ===================== 렌더 =====================
// 스텝 인디케이터
const StepBar = () => (
<div className="sf-step-bar">
{STEP_META.map((s, i) => {
const n = (i + 1) as Step;
const done = step > n;
const active = step === n;
return (
<div key={n} className={`sf-step-item ${active ? 'active' : ''} ${done ? 'done' : ''}`}>
<div className="sf-step-circle">
{done
? <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3"><polyline points="20 6 9 17 4 12"/></svg>
: n}
</div>
<div className="sf-step-label">
<span className="sf-step-name">{s.label}</span>
<span className="sf-step-sub">{s.sub}</span>
</div>
</div>
);
})}
</div>
);
// ---- Step 1: 사진 업로드 ----
if (step === 1) return (
<main className="sf-page">
<div className="sf-scroll-area">
<div className="sf-hero">
<h1 className="sf-hero-title"> , <span className="sf-hero-accent">8 </span> </h1>
<p className="sf-hero-desc"> , AI .</p>
</div>
<StepBar />
<div className="sf-card">
<h2 className="sf-card-title"> </h2>
<p className="sf-card-desc"> 4~5. 1· 2~3 .</p>
<div
ref={dropRef}
className="sf-drop-zone"
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<div className="sf-drop-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
</div>
<p className="sf-drop-text"> </p>
<p className="sf-drop-hint">JPG·PNG·{MIN_PHOTOS_LABEL} </p>
</div>
<input ref={fileInputRef} type="file" accept="image/*" multiple style={{ display: 'none' }} onChange={e => { addFiles(Array.from(e.target.files || [])); e.target.value = ''; }} />
{previews.length > 0 && (
<div className="sf-photo-grid">
{previews.map((url, i) => (
<div key={i} className="sf-photo-item">
<span className="sf-photo-num">{i + 1}</span>
<img src={url} alt={`사진 ${i + 1}`} />
<button className="sf-photo-remove" onClick={() => removeFile(i)}>×</button>
</div>
))}
</div>
)}
</div>
</div>
<div className="sf-footer">
<div className="sf-footer-info">
<span className="sf-footer-count">{files.length}</span>
{step1Valid
? <span className="sf-footer-status ok">· </span>
: <span className="sf-footer-status">{MIN_PHOTOS - files.length} </span>}
</div>
<button
className="sf-btn-next"
disabled={!step1Valid}
onClick={() => setStep(2)}
>
:
</button>
</div>
</main>
);
// ---- Step 2: 정보 입력 ----
if (step === 2) return (
<main className="sf-page">
<div className="sf-scroll-area">
<StepBar />
<div className="sf-card">
<h2 className="sf-card-title"> </h2>
<p className="sf-card-desc">· .</p>
{/* Kind */}
<div className="sf-field">
<label className="sf-label"> ? <span className="sf-required">*</span></label>
<div className="sf-kind-grid">
{KIND_OPTIONS.map(opt => (
<button
key={opt.value}
type="button"
className={`sf-kind-card ${kind === opt.value ? 'active' : ''}`}
onClick={() => setKind(opt.value)}
>
<span className="sf-kind-label">{opt.label}</span>
<span className="sf-kind-desc">{opt.desc}</span>
</button>
))}
</div>
</div>
{/* 업체명 */}
<div className="sf-field">
<label className="sf-label">·· <span className="sf-required">*</span></label>
<input
className="sf-input"
type="text"
placeholder="예: 스테이 머뭄"
value={bizName}
onChange={e => setBizName(e.target.value)}
maxLength={50}
/>
</div>
{/* 셀링포인트 */}
<div className="sf-field">
<label className="sf-label"> <span className="sf-required">*</span></label>
<textarea
className="sf-textarea"
placeholder="예: 열방 손님 없는 완전한 독채, 개별 정원까지"
value={selling}
onChange={e => setSelling(e.target.value)}
maxLength={100}
rows={3}
/>
<p className="sf-field-hint"> · . .</p>
</div>
{/* 자막 미리보기 */}
<div className="sf-field">
<div className="sf-subtitle-header">
<label className="sf-label"> ·</label>
<button
className="sf-subtitle-btn"
disabled={!selling.trim() || previewLoading}
onClick={handlePreview}
>
{previewLoading ? '생성 중...' : preview ? '재생성하기' : '자막 생성하기'}
</button>
</div>
<p className="sf-field-hint"> , AI ·· ·CTA 4 .</p>
{previewError && <p className="sf-field-error">{previewError}</p>}
{preview && (
<div className="sf-preview-spec">
<div className="sf-preview-block">
<span className="sf-preview-tag"></span>
<input className="sf-preview-input" value={preview.hook.eyebrow}
onChange={e => patchPreview(p => ({ ...p, hook: { ...p.hook, eyebrow: e.target.value } }))} />
<input className="sf-preview-input sf-preview-input-bold" value={preview.hook.title}
onChange={e => patchPreview(p => ({ ...p, hook: { ...p.hook, title: e.target.value } }))} />
</div>
<div className="sf-preview-block">
<span className="sf-preview-tag"></span>
<div className="sf-preview-badge-inputs">
{preview.selling_point.items.slice(0, 3).map((item, i) => (
<input key={i} className="sf-preview-badge-input" value={item}
onChange={e => patchPreview(p => {
const items = [...p.selling_point.items];
items[i] = e.target.value;
return { ...p, selling_point: { items } };
})} />
))}
</div>
</div>
<div className="sf-preview-block">
<span className="sf-preview-tag"> </span>
{preview.brand_lines.slice(0, 2).map((line, i) => (
<input key={i} className="sf-preview-input" value={line}
onChange={e => patchPreview(p => {
const brand_lines = [...p.brand_lines];
brand_lines[i] = e.target.value;
return { ...p, brand_lines };
})} />
))}
</div>
<div className="sf-preview-block">
<span className="sf-preview-tag">CTA</span>
<input className="sf-preview-input" value={preview.end_card.brand}
onChange={e => patchPreview(p => ({ ...p, end_card: { ...p.end_card, brand: e.target.value } }))} />
<input className="sf-preview-input" value={preview.end_card.location}
onChange={e => patchPreview(p => ({ ...p, end_card: { ...p.end_card, location: e.target.value } }))} />
</div>
</div>
)}
</div>
{/* 주소 */}
<div className="sf-field">
<label className="sf-label"> URL <span className="sf-optional">()</span></label>
<input
className="sf-input"
type="text"
placeholder="예: 전북 군산시 절골길 18"
value={addr}
onChange={e => setAddr(e.target.value)}
maxLength={100}
/>
<p className="sf-field-hint"> · .</p>
</div>
{/* 가격 */}
<div className="sf-field">
<label className="sf-label"> <span className="sf-optional">()</span></label>
<input
className="sf-input"
type="text"
placeholder="예: 1박 19만원~"
value={price}
onChange={e => setPrice(e.target.value)}
maxLength={50}
/>
</div>
</div>
</div>
<div className="sf-footer sf-footer-two">
<button className="sf-btn-prev" onClick={() => setStep(1)}> </button>
<button className="sf-btn-generate" disabled={!step2Valid} onClick={handleGenerate}>
</button>
</div>
{errorMsg && (
<div className="sf-error-toast">{errorMsg}</div>
)}
</main>
);
// ---- Step 3: 생성 중 ----
if (step === 3) return (
<main className="sf-page">
<div className="sf-scroll-area">
<StepBar />
<div className="sf-generating">
<div className="sf-gen-orb-ring">
<div className="sf-gen-spinner-lg" />
<div className="sf-gen-icon">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 10l4.553-2.069A1 1 0 0121 8.87V15.13a1 1 0 01-1.447.899L15 14M3 8a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V8z"/>
</svg>
</div>
</div>
<h2 className="sf-gen-title">AI </h2>
<p className="sf-gen-msg">{statusMsg || 'AI 카메라 무브·자막 렌더링 중...'}</p>
<p className="sf-gen-sub"> 1~3 . .</p>
<div className="sf-gen-steps">
<div className={`sf-gen-step ${statusMsg.includes('업로드') ? 'active' : statusMsg ? 'done' : ''}`}>
<span className="sf-gen-step-dot" />
</div>
<div className={`sf-gen-step ${statusMsg.includes('요청') ? 'active' : statusMsg.includes('렌더링') || statusMsg.includes('완성') ? 'done' : ''}`}>
<span className="sf-gen-step-dot" />
</div>
<div className={`sf-gen-step ${statusMsg.includes('렌더링') ? 'active' : ''}`}>
<span className="sf-gen-step-dot" />AI
</div>
</div>
</div>
</div>
</main>
);
// ---- Step 4: 완성 ----
return (
<main className="sf-page">
<div className="sf-scroll-area">
<StepBar />
<div className="sf-result">
<p className="sf-result-label"> !</p>
{result?.result_url && (
<div className="sf-result-video-wrap">
<video className="sf-result-video" src={result.result_url} controls playsInline />
</div>
)}
<div className="sf-result-actions">
{result?.result_url && (
<button
className="sf-download-btn"
disabled={isDownloading}
onClick={() => handleDownload(result.result_url!)}
>
{isDownloading ? '다운로드 중...' : '다운로드'}
</button>
)}
<button className="sf-btn-prev" onClick={() => { setStep(1); setFiles([]); setPreviews([]); setBizName(''); setSelling(''); setAddr(''); setPrice(''); }}>
</button>
</div>
</div>
</div>
</main>
);
};
export default ShortformContent;

View File

@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { generateLyric, waitForLyricComplete, generateSong, waitForSongComplete } from '../../utils/api'; import { generateLyric, waitForLyricComplete, generateSong, waitForSongComplete } from '../../utils/api';
import { LANGUAGE_MAP } from '../../types/api'; import { LANGUAGE_MAP } from '../../types/api';
@ -16,6 +16,7 @@ interface SoundStudioContentProps {
businessInfo?: BusinessInfo; businessInfo?: BusinessInfo;
imageTaskId: string | null; imageTaskId: string | null;
mId: number; mId: number;
industry?: string;
onStatusChange?: (status: string) => void; onStatusChange?: (status: string) => void;
videoGenerationStatus?: 'idle' | 'generating' | 'complete' | 'error'; videoGenerationStatus?: 'idle' | 'generating' | 'complete' | 'error';
videoGenerationProgress?: number; videoGenerationProgress?: number;
@ -26,6 +27,46 @@ type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polli
const MAX_RETRY_COUNT = 3; const MAX_RETRY_COUNT = 3;
// 생성 완료된 곡 상태를 새로고침 후에도 복원하기 위한 키
// GenerationFlow의 clearAllProjectStorage가 이 키를 포함해 정리함
const SONG_GENERATION_KEY = 'castad_song_generation';
const GENRE_VALUES = ['kpop', 'pop', 'ballad', 'hip-hop', 'rnb', 'edm', 'jazz', 'rock'];
const genreMap: Record<string, string> = {
'자동 선택': '', // 런타임에 랜덤 결정
'K-POP': 'kpop',
'POP': 'pop',
'발라드': 'ballad',
'Hip-Hop': 'hip-hop',
'R&B': 'rnb',
'EDM': 'edm',
'JAZZ': 'jazz',
'ROCK': 'rock',
};
const getEffectiveGenre = (selectedGenre: string): string => {
if (selectedGenre === '자동 선택') {
return GENRE_VALUES[Math.floor(Math.random() * GENRE_VALUES.length)];
}
return genreMap[selectedGenre] || 'pop';
};
// 실제 영상 생성에 사용된 genre 값(kpop, hip-hop 등)을 화면 표시용 라벨로 변환
const getGenreDisplayLabel = (genreValue: string, t: (key: string) => string): string => {
const displayMap: Record<string, string> = {
kpop: 'K-POP',
pop: 'POP',
ballad: t('soundStudio.genreBallad'),
'hip-hop': 'Hip-Hop',
rnb: 'R&B',
edm: 'EDM',
jazz: 'JAZZ',
rock: 'ROCK',
};
return displayMap[genreValue] || genreValue;
};
const sanitizeError = (message: string, fallback: string): string => { const sanitizeError = (message: string, fallback: string): string => {
if ( if (
message.includes('Traceback') || message.includes('Traceback') ||
@ -54,6 +95,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
businessInfo, businessInfo,
imageTaskId, imageTaskId,
mId, mId,
industry = '',
onStatusChange, onStatusChange,
videoGenerationStatus = 'idle', videoGenerationStatus = 'idle',
videoGenerationProgress = 0, videoGenerationProgress = 0,
@ -63,8 +105,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
const [selectedType, setSelectedType] = useState('보컬'); const [selectedType, setSelectedType] = useState('보컬');
const [selectedLang, setSelectedLang] = useState('한국어'); const [selectedLang, setSelectedLang] = useState('한국어');
const [selectedGenre, setSelectedGenre] = useState('자동 선택'); const [selectedGenre, setSelectedGenre] = useState('자동 선택');
const [usedGenre, setUsedGenre] = useState('');
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [status, setStatus] = useState<GenerationStatus>('idle'); const [status, setStatus] = useState<GenerationStatus>('idle');
const [lyrics, setLyrics] = useState(''); const [lyrics, setLyrics] = useState('');
const [audioUrl, setAudioUrl] = useState<string | null>(null); const [audioUrl, setAudioUrl] = useState<string | null>(null);
@ -75,20 +117,40 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
const [statusMessage, setStatusMessage] = useState(''); const [statusMessage, setStatusMessage] = useState('');
const [retryCount, setRetryCount] = useState(0); const [retryCount, setRetryCount] = useState(0);
const [songTaskId, setSongTaskId] = useState<string | null>(null); const [songTaskId, setSongTaskId] = useState<string | null>(null);
const progressBarRef = useRef<HTMLDivElement>(null);
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
// 완료 데이터를 localStorage에 저장 // 완료 데이터를 localStorage에 저장
const saveSongCompletionData = () => { const saveSongCompletionData = () => {
const completionData = { const completionData = {
businessName: businessInfo?.customer_name || '알 수 없음', businessName: businessInfo?.customer_name || '알 수 없음',
genre: selectedGenre === '자동 선택' ? 'K-POP' : selectedGenre, // '자동 선택'처럼 UI에서 고른 값이 아니라, 실제 영상 생성에 사용된 genre(usedGenre)를 저장
genre: usedGenre ? getGenreDisplayLabel(usedGenre, t) : selectedGenre,
lyrics: lyrics, lyrics: lyrics,
timestamp: Date.now(), timestamp: Date.now(),
}; };
localStorage.setItem('castad_song_completion', JSON.stringify(completionData)); localStorage.setItem('castad_song_completion', JSON.stringify(completionData));
}; };
// 새로고침 후 생성 완료된 곡 상태 복원
useEffect(() => {
const saved = localStorage.getItem(SONG_GENERATION_KEY);
if (!saved) return;
try {
const parsed = JSON.parse(saved);
if (parsed.status !== 'complete' || !parsed.audioUrl || !parsed.songTaskId) return;
// 저장된 URL로 복원 (public Blob URL이라 만료되지 않음)
// 백엔드에 /song/download 엔드포인트가 없으므로 재조회하지 않는다.
setSongTaskId(parsed.songTaskId);
setAudioUrl(parsed.audioUrl);
setLyrics(parsed.lyrics ?? '');
setStatus('complete');
} catch {
// 파싱 실패 시 무시
}
}, []); // 마운트 1회만 실행
// Close language dropdown when clicking outside // Close language dropdown when clicking outside
// Auto-navigate to next page when video generation is complete // Auto-navigate to next page when video generation is complete
useEffect(() => { useEffect(() => {
@ -128,6 +190,18 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
setStatusMessage(''); setStatusMessage('');
setRetryCount(0); setRetryCount(0);
// 새로고침 후에도 복원할 수 있도록 완료된 곡 상태를 저장
try {
localStorage.setItem(SONG_GENERATION_KEY, JSON.stringify({
audioUrl: statusResponse.song_result_url,
songTaskId: taskId,
lyrics: currentLyrics,
status: 'complete',
}));
} catch {
// localStorage 쓰기 실패는 무시
}
} catch (error) { } catch (error) {
console.error('Polling failed:', error); console.error('Polling failed:', error);
@ -158,20 +232,13 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
try { try {
const language = LANGUAGE_MAP[selectedLang] || 'Korean'; const language = LANGUAGE_MAP[selectedLang] || 'Korean';
const genreMap: Record<string, string> = {
'자동 선택': 'pop',
'K-POP': 'kpop',
'발라드': 'ballad',
'Hip-Hop': 'hip-hop',
'R&B': 'rnb',
'EDM': 'edm',
'JAZZ': 'jazz',
'ROCK': 'rock',
};
const isInstrumental = selectedType === '배경음악'; const isInstrumental = selectedType === '배경음악';
const effectiveGenre = getEffectiveGenre(selectedGenre);
setUsedGenre(effectiveGenre);
const songResponse = await generateSong(imageTaskId, { const songResponse = await generateSong(imageTaskId, {
genre: genreMap[selectedGenre] || 'pop', genre: effectiveGenre,
...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: currentLyrics }), ...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: currentLyrics }),
}); });
@ -192,41 +259,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
} }
}; };
const handleMove = (clientX: number) => {
if (!progressBarRef.current || !audioRef.current) return;
const rect = progressBarRef.current.getBoundingClientRect();
const newProgress = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
const newTime = (newProgress / 100) * duration;
audioRef.current.currentTime = newTime;
setProgress(newProgress);
setCurrentTime(newTime);
};
const onMouseDown = (e: React.MouseEvent) => {
if (!audioUrl) return;
setIsDragging(true);
handleMove(e.clientX);
};
useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
if (isDragging) handleMove(e.clientX);
};
const onMouseUp = () => {
setIsDragging(false);
};
if (isDragging) {
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
}, [isDragging]);
useEffect(() => { useEffect(() => {
const audio = audioRef.current; const audio = audioRef.current;
if (!audio) return; if (!audio) return;
@ -286,6 +318,9 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
return; return;
} }
// 새 곡 생성 시작 — 이전에 저장된 완료 곡 제거
localStorage.removeItem(SONG_GENERATION_KEY);
setStatus('generating_lyric'); setStatus('generating_lyric');
setErrorMessage(null); setErrorMessage(null);
setStatusMessage(t('soundStudio.generatingLyrics')); setStatusMessage(t('soundStudio.generatingLyrics'));
@ -296,6 +331,9 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
try { try {
const language = LANGUAGE_MAP[selectedLang] || 'Korean'; const language = LANGUAGE_MAP[selectedLang] || 'Korean';
const isInstrumental = selectedType === '배경음악'; const isInstrumental = selectedType === '배경음악';
// 수동 선택한 장르는 가사 생성 시점부터 넘겨서, 그 장르 템포에 맞는 줄 수로 가사를 쓰게 한다.
// '자동 선택'이면 넘기지 않고, 가사 생성 결과로 받은 recommended genre를 그대로 쓴다.
const manualGenre = selectedGenre === '자동 선택' ? undefined : genreMap[selectedGenre];
// 1. 가사 생성 요청 // 1. 가사 생성 요청
console.log('[SoundStudio] Sending m_id:', mId); console.log('[SoundStudio] Sending m_id:', mId);
@ -306,26 +344,20 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
m_id: mId, m_id: mId,
region: businessInfo.region, region: businessInfo.region,
task_id: imageTaskId, task_id: imageTaskId,
industry,
orientation, orientation,
...(isInstrumental && { instrumental: true }), ...(isInstrumental && { instrumental: true }),
...(manualGenre && { genre: manualGenre }),
}); });
if (!lyricResponse.success || !lyricResponse.task_id) { if (!lyricResponse.success || !lyricResponse.task_id) {
throw new Error(lyricResponse.error_message || t('soundStudio.lyricGenerationFailed')); throw new Error(lyricResponse.error_message || t('soundStudio.lyricGenerationFailed'));
} }
const genreMap: Record<string, string> = {
'자동 선택': 'pop',
'K-POP': 'kpop',
'발라드': 'ballad',
'Hip-Hop': 'hip-hop',
'R&B': 'rnb',
'EDM': 'edm',
'JAZZ': 'jazz',
'ROCK': 'rock',
};
let songLyrics: string | undefined; let songLyrics: string | undefined;
// BGM 모드는 백엔드가 가사/장르 추천 없이 즉시 completed 처리하므로 GPT 추천 장르가 없다.
// 이 경우에만 기존 랜덤 선택 로직으로 폴백한다.
let effectiveGenre = manualGenre || getEffectiveGenre(selectedGenre);
if (!isInstrumental) { if (!isInstrumental) {
// 2. 가사 생성 상태 폴링 → 완료 시 상세 조회 // 2. 가사 생성 상태 폴링 → 완료 시 상세 조회
@ -350,13 +382,16 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
setLyrics(lyricDetailResponse.lyric_result); setLyrics(lyricDetailResponse.lyric_result);
songLyrics = lyricDetailResponse.lyric_result; songLyrics = lyricDetailResponse.lyric_result;
// 가사와 함께 확정된 장르(수동 선택값 또는 GPT 추천값)를 그대로 음악 생성에 사용
effectiveGenre = lyricDetailResponse.genre || effectiveGenre;
} }
setStatus('generating_song'); setStatus('generating_song');
setStatusMessage(t('soundStudio.generatingSong')); setStatusMessage(t('soundStudio.generatingSong'));
setUsedGenre(effectiveGenre);
const songResponse = await generateSong(imageTaskId, { const songResponse = await generateSong(imageTaskId, {
genre: genreMap[selectedGenre] || 'pop', genre: effectiveGenre,
...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: songLyrics }), ...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: songLyrics }),
}); });
@ -402,6 +437,24 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
const isGenerating = status === 'generating_lyric' || status === 'generating_song' || status === 'polling'; const isGenerating = status === 'generating_lyric' || status === 'generating_song' || status === 'polling';
// 드래그로 인한 잦은 리렌더에서도 가사 파싱(정규식/배열 처리)이 매번 재실행되지 않도록 메모이제이션
const lyricsSections = useMemo(() => {
if (!lyrics) return [];
const lines = lyrics.split('\n').filter(l => l.trim());
const sections: { tag: string | null; lines: string[] }[] = [];
lines.forEach(line => {
const tagMatch = line.trim().match(/^\[(.+)\]$/);
if (tagMatch) {
sections.push({ tag: `[${tagMatch[1]}]`, lines: [] });
} else if (sections.length === 0) {
sections.push({ tag: null, lines: [line] });
} else {
sections[sections.length - 1].lines.push(line);
}
});
return sections.filter(s => s.lines.length > 0);
}, [lyrics]);
// status 변경 시 부모에 알림 // status 변경 시 부모에 알림
useEffect(() => { useEffect(() => {
onStatusChange?.(status); onStatusChange?.(status);
@ -495,7 +548,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
))} ))}
</div> </div>
<div className="genre-row"> <div className="genre-row">
{['JAZZ', 'ROCK'].map(genre => ( {['JAZZ', 'ROCK', 'POP'].map(genre => (
<button <button
key={genre} key={genre}
onClick={() => setSelectedGenre(genre)} onClick={() => setSelectedGenre(genre)}
@ -505,7 +558,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
{genre} {genre}
</button> </button>
))} ))}
<div className="genre-btn-placeholder"></div>
</div> </div>
</div> </div>
</div> </div>
@ -615,9 +667,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
)} )}
</button> </button>
{/* 진행 표시 전용 — Azure Blob 익명 응답에 Accept-Ranges가 없어 seek가 불안정하므로 드래그 미지원 */}
<div <div
ref={progressBarRef}
onMouseDown={onMouseDown}
className={`audio-progress-container ${!audioUrl ? 'disabled' : ''}`} className={`audio-progress-container ${!audioUrl ? 'disabled' : ''}`}
> >
<div <div
@ -633,27 +684,16 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
{/* Lyrics Display */} {/* Lyrics Display */}
<div className="lyrics-display"> <div className="lyrics-display">
{lyrics ? (() => { {lyrics ? (
const lines = lyrics.split('\n').filter(l => l.trim());
const outro = lines.slice(-1);
const body = lines.slice(0, -1);
const half = Math.ceil(body.length / 2);
const sections = [
{ tag: '[Verse]', lines: body.slice(0, half) },
{ tag: '[Chorus]', lines: body.slice(half) },
{ tag: '[Outro]', lines: outro },
].filter(s => s.lines.length > 0);
return (
<div className="lyrics-paragraphs"> <div className="lyrics-paragraphs">
{sections.map((section, i) => ( {lyricsSections.map((section, i) => (
<div key={i} className="lyrics-section"> <div key={i} className="lyrics-section">
<span className="lyrics-tag">{section.tag}</span> {section.tag && <span className="lyrics-tag">{section.tag}</span>}
<p className="lyrics-paragraph">{section.lines.join('\n')}</p> <p className="lyrics-paragraph">{section.lines.join('\n')}</p>
</div> </div>
))} ))}
</div> </div>
); ) : (
})() : (
<div className="lyrics-placeholder"> <div className="lyrics-placeholder">
{selectedType === '배경음악' ? t('soundStudio.lyricsPlaceholderBGM') : t('soundStudio.lyricsPlaceholder')} {selectedType === '배경음악' ? t('soundStudio.lyricsPlaceholderBGM') : t('soundStudio.lyricsPlaceholder')}
</div> </div>

View File

@ -5,16 +5,14 @@ import SearchInputForm, { SearchType } from '../../components/SearchInputForm';
interface UrlInputContentProps { interface UrlInputContentProps {
onAnalyze: (value: string, type?: SearchType) => void; onAnalyze: (value: string, type?: SearchType) => void;
onAutocomplete?: (data: AutocompleteRequest) => void; onAutocomplete?: (data: AutocompleteRequest) => void;
onManualInput?: (businessName: string, address: string) => void; onManualInput?: (businessName: string, address: string, category: string) => void;
onShortform?: () => void;
error: string | null; error: string | null;
} }
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onManualInput, onShortform, error }) => { const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onManualInput, error }) => {
return ( return (
<div className="url-input-container"> <div className="url-input-container">
<div className="url-input-content"> <div className="url-input-content">
{/* 로고 */}
<div className="url-input-logo"> <div className="url-input-logo">
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" /> <img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
</div> </div>
@ -23,7 +21,6 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
onAnalyze={onAnalyze} onAnalyze={onAnalyze}
onAutocomplete={onAutocomplete} onAutocomplete={onAutocomplete}
onManualInput={onManualInput} onManualInput={onManualInput}
onShortform={onShortform}
error={error} error={error}
/> />
</div> </div>

View File

@ -1,4 +1,14 @@
import React, { useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { AutocompleteRequest, isLoggedIn } from '../../utils/api';
import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
import BusinessNameInputModal from '../../components/BusinessNameInputModal';
import LoginPromptModal from '../../components/LoginPromptModal';
import SearchInputForm from '../../components/SearchInputForm';
import { SearchType } from '../../components/SearchInputForm';
// Orb configuration with movement zones to prevent overlap // Orb configuration with movement zones to prevent overlap
interface OrbConfig { interface OrbConfig {
@ -23,14 +33,31 @@ const orbConfigs: OrbConfig[] = [
]; ];
interface HeroSectionProps { interface HeroSectionProps {
onLogin?: () => void; onAnalyze?: (value: string, type?: SearchType) => void;
onAutocomplete?: (data: AutocompleteRequest) => void;
onManualInput?: (businessName: string, address: string, category: string) => void;
onNext?: () => void; onNext?: () => void;
error?: string | null;
scrollProgress?: number; scrollProgress?: number;
} }
const HeroSection: React.FC<HeroSectionProps> = ({ onLogin, onNext, scrollProgress = 0 }) => { const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onManualInput, onNext, error: externalError, scrollProgress = 0 }) => {
const { t } = useTranslation();
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
const [isLoginPromptOpen, setIsLoginPromptOpen] = useState(false);
const orbRefs = useRef<(HTMLDivElement | null)[]>([]); const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
const animationRefs = useRef<number[]>([]); const animationRefs = useRef<number[]>([]);
const tutorial = useTutorial();
// 첫 방문 시 랜딩 튜토리얼 시작
useEffect(() => {
if (!tutorial.hasSeen(TUTORIAL_KEYS.LANDING)) {
const timer = setTimeout(() => {
tutorial.startTutorial(TUTORIAL_KEYS.LANDING);
}, 800);
return () => clearTimeout(timer);
}
}, []);
// Orb 랜덤 이동 애니메이션 // Orb 랜덤 이동 애니메이션
useEffect(() => { useEffect(() => {
@ -118,20 +145,56 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onLogin, onNext, scrollProgre
className="hero-logo" className="hero-logo"
/> />
<button className="hero-login-btn" onClick={onLogin}> <SearchInputForm
onAnalyze={onAnalyze}
</button> onAutocomplete={onAutocomplete}
onManualInput={onManualInput}
onManualButtonClick={() => {
if (isLoggedIn()) {
setIsManualModalOpen(true);
} else {
setIsLoginPromptOpen(true);
}
}}
error={externalError || null}
/>
</div> </div>
{/* Footer Indicator */} {/* Footer Indicator */}
<button onClick={onNext} className="scroll-indicator"> <button onClick={onNext} className="scroll-indicator">
<span className="scroll-indicator-text"> </span> <span className="scroll-indicator-text">{t('landing.hero.scrollMore')}</span>
<div className="scroll-indicator-icon"> <div className="scroll-indicator-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
<path d="M7 10l5 5 5-5" /> <path d="M7 10l5 5 5-5" />
</svg> </svg>
</div> </div>
</button> </button>
{tutorial.isActive && !isManualModalOpen && (
<TutorialOverlay
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/>
)}
{isManualModalOpen && (
<BusinessNameInputModal
onClose={() => setIsManualModalOpen(false)}
onSubmit={(businessName, address, category) => {
if (tutorial.isActive) tutorial.nextHint();
setIsManualModalOpen(false);
onManualInput?.(businessName, address, category);
}}
/>
)}
{isLoginPromptOpen && (
<LoginPromptModal onClose={() => setIsLoginPromptOpen(false)} />
)}
</div> </div>
); );
}; };

View File

@ -1,11 +1,13 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
const SocialConnectError: React.FC = () => { const SocialConnectError: React.FC = () => {
// URL 파라미터 파싱 const { t } = useTranslation();
const searchParams = new URLSearchParams(window.location.search); const searchParams = new URLSearchParams(window.location.search);
const platform = searchParams.get('platform') || 'unknown'; const platform = searchParams.get('platform') || 'unknown';
const errorMessage = searchParams.get('error') || '알 수 없는 오류가 발생했습니다.'; const errorMessage = searchParams.get('error') || t('social.connectError.defaultError');
const platformName = platform === 'youtube' ? 'YouTube' : const platformName = platform === 'youtube' ? 'YouTube' :
platform === 'instagram' ? 'Instagram' : platform === 'instagram' ? 'Instagram' :
@ -25,19 +27,19 @@ const SocialConnectError: React.FC = () => {
<line x1="9" y1="9" x2="15" y2="15" /> <line x1="9" y1="9" x2="15" y2="15" />
</svg> </svg>
</div> </div>
<h1 className="social-connect-title">{platformName} </h1> <h1 className="social-connect-title">{t('social.connectError.title', { platform: platformName })}</h1>
<p className="social-connect-message"> <p className="social-connect-message">
{errorMessage} {errorMessage}
</p> </p>
<p className="social-connect-detail"> <p className="social-connect-detail">
. . {t('social.connectError.detail')}
</p> </p>
<div className="social-connect-actions"> <div className="social-connect-actions">
<button <button
onClick={navigateToHome} onClick={navigateToHome}
className="social-connect-button" className="social-connect-button"
> >
{t('social.connectError.retry')}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
// 연결된 소셜 계정 정보를 저장하는 localStorage 키
const SOCIAL_CONNECTED_KEY = 'castad_social_connected'; const SOCIAL_CONNECTED_KEY = 'castad_social_connected';
interface ConnectedSocialAccount { interface ConnectedSocialAccount {
@ -13,9 +13,9 @@ interface ConnectedSocialAccount {
} }
const SocialConnectSuccess: React.FC = () => { const SocialConnectSuccess: React.FC = () => {
const { t } = useTranslation();
const [countdown, setCountdown] = useState(3); const [countdown, setCountdown] = useState(3);
// URL 파라미터 파싱
const searchParams = new URLSearchParams(window.location.search); const searchParams = new URLSearchParams(window.location.search);
const platform = searchParams.get('platform') || 'unknown'; const platform = searchParams.get('platform') || 'unknown';
const accountId = searchParams.get('account_id') || ''; const accountId = searchParams.get('account_id') || '';
@ -26,7 +26,6 @@ const SocialConnectSuccess: React.FC = () => {
platform === 'instagram' ? 'Instagram' : platform === 'instagram' ? 'Instagram' :
platform === 'facebook' ? 'Facebook' : platform; platform === 'facebook' ? 'Facebook' : platform;
// 연결된 계정 정보를 localStorage에 저장
useEffect(() => { useEffect(() => {
if (platform && accountId) { if (platform && accountId) {
const connectedAccount: ConnectedSocialAccount = { const connectedAccount: ConnectedSocialAccount = {
@ -42,7 +41,6 @@ const SocialConnectSuccess: React.FC = () => {
}, [platform, accountId, channelName, profileImage]); }, [platform, accountId, channelName, profileImage]);
const navigateToHome = () => { const navigateToHome = () => {
// URL을 루트로 변경하고 페이지 새로고침하여 generation_flow로 이동
window.location.href = '/'; window.location.href = '/';
}; };
@ -64,7 +62,6 @@ const SocialConnectSuccess: React.FC = () => {
return ( return (
<div className="social-connect-page"> <div className="social-connect-page">
<div className="social-connect-card success"> <div className="social-connect-card success">
{/* 프로필 이미지가 있으면 표시 */}
{profileImage ? ( {profileImage ? (
<img <img
src={profileImage} src={profileImage}
@ -81,15 +78,13 @@ const SocialConnectSuccess: React.FC = () => {
)} )}
<h1 className="social-connect-title"> <h1 className="social-connect-title">
{channelName || platformName} {t('social.connectSuccess.title', { name: channelName || platformName })}
</h1> </h1>
<p className="social-connect-message"> <p className="social-connect-message">
{channelName ? ( {channelName
<>{platformName} .</> ? t('social.connectSuccess.messageWithChannel', { platform: platformName })
) : ( : t('social.connectSuccess.messageWithoutChannel')}
<> .</>
)}
</p> </p>
{channelName && ( {channelName && (
@ -98,20 +93,20 @@ const SocialConnectSuccess: React.FC = () => {
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12" /> <polyline points="20 6 9 17 4 12" />
</svg> </svg>
{t('social.connectSuccess.verified')}
</span> </span>
</div> </div>
)} )}
<p className="social-connect-countdown"> <p className="social-connect-countdown">
{countdown} ... {t('social.connectSuccess.countdown', { count: countdown })}
</p> </p>
<button <button
onClick={navigateToHome} onClick={navigateToHome}
className="social-connect-button" className="social-connect-button"
> >
{t('social.connectSuccess.goNow')}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,9 +1,11 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44'; const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
const YouTubeOAuthCallback: React.FC = () => { const YouTubeOAuthCallback: React.FC = () => {
const { t } = useTranslation();
const [status, setStatus] = useState<'processing' | 'error'>('processing'); const [status, setStatus] = useState<'processing' | 'error'>('processing');
const [errorMessage, setErrorMessage] = useState<string>(''); const [errorMessage, setErrorMessage] = useState<string>('');
@ -18,7 +20,7 @@ const YouTubeOAuthCallback: React.FC = () => {
if (error) { if (error) {
console.error('[YouTube OAuth] Error from Google:', error); console.error('[YouTube OAuth] Error from Google:', error);
setStatus('error'); setStatus('error');
setErrorMessage('Google 인증이 취소되었거나 실패했습니다.'); setErrorMessage(t('social.youtubeCallback.errorAuthCancelled'));
setTimeout(() => { setTimeout(() => {
window.location.href = '/social/connect/error?platform=youtube&error=' + encodeURIComponent(error); window.location.href = '/social/connect/error?platform=youtube&error=' + encodeURIComponent(error);
}, 1500); }, 1500);
@ -28,7 +30,7 @@ const YouTubeOAuthCallback: React.FC = () => {
if (!code) { if (!code) {
console.error('[YouTube OAuth] No code received'); console.error('[YouTube OAuth] No code received');
setStatus('error'); setStatus('error');
setErrorMessage('인증 코드를 받지 못했습니다.'); setErrorMessage(t('social.youtubeCallback.errorNoCode'));
setTimeout(() => { setTimeout(() => {
window.location.href = '/social/connect/error?platform=youtube&error=no_code'; window.location.href = '/social/connect/error?platform=youtube&error=no_code';
}, 1500); }, 1500);
@ -46,7 +48,7 @@ const YouTubeOAuthCallback: React.FC = () => {
} catch (err) { } catch (err) {
console.error('[YouTube OAuth] Failed to process callback:', err); console.error('[YouTube OAuth] Failed to process callback:', err);
setStatus('error'); setStatus('error');
setErrorMessage('YouTube 연결 처리 중 오류가 발생했습니다.'); setErrorMessage(t('social.youtubeCallback.errorProcessing'));
setTimeout(() => { setTimeout(() => {
window.location.href = '/social/connect/error?platform=youtube&error=processing_failed'; window.location.href = '/social/connect/error?platform=youtube&error=processing_failed';
}, 1500); }, 1500);
@ -66,8 +68,8 @@ const YouTubeOAuthCallback: React.FC = () => {
<circle cx="25" cy="25" r="20" fill="none" strokeWidth="4" /> <circle cx="25" cy="25" r="20" fill="none" strokeWidth="4" />
</svg> </svg>
</div> </div>
<h1 className="social-connect-title">YouTube ...</h1> <h1 className="social-connect-title">{t('social.youtubeCallback.connecting')}</h1>
<p className="social-connect-message"> .</p> <p className="social-connect-message">{t('social.youtubeCallback.pleaseWait')}</p>
</> </>
) : ( ) : (
<> <>
@ -78,7 +80,7 @@ const YouTubeOAuthCallback: React.FC = () => {
<line x1="9" y1="9" x2="15" y2="15" /> <line x1="9" y1="9" x2="15" y2="15" />
</svg> </svg>
</div> </div>
<h1 className="social-connect-title"> </h1> <h1 className="social-connect-title">{t('social.youtubeCallback.failed')}</h1>
<p className="social-connect-message">{errorMessage}</p> <p className="social-connect-message">{errorMessage}</p>
</> </>
)} )}

View File

@ -0,0 +1,947 @@
/* =====================================================
Layout Components
===================================================== */
/* Landing Page Container - Free Scroll */
.landing-container {
width: 100%;
height: 100dvh;
overflow-y: scroll;
overflow-x: hidden;
background-color: var(--color-bg-darker);
}
.landing-section {
background: var(--Color-teal-800, #002224);
min-height: 100dvh;
width: 100%;
}
/* Page Container */
.page-container {
display: flex;
flex-direction: column;
height: 100%;
padding: var(--spacing-page);
overflow: hidden;
background-color: #002224;
position: relative;
}
@media (min-width: 768px) {
.page-container {
padding: var(--spacing-page-md);
}
}
/* Page Container - Full Height with Background */
.page-container-full {
width: 100%;
min-height: 100dvh;
color: var(--color-text-white);
display: flex;
flex-direction: column;
padding: var(--spacing-page);
background-color: var(--color-bg-dark);
}
@media (min-width: 768px) {
.page-container-full {
padding: var(--spacing-page-md);
}
}
@media (min-width: 1024px) {
.page-container-full {
padding: 2.5rem;
}
}
/* Wizard Page Container - 단계별 페이지 전환용 */
.wizard-page-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--color-bg-darker);
overflow-y: auto;
}
/* Main Content Area */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 72rem;
margin-left: auto;
margin-right: auto;
width: 100%;
min-height: 0;
overflow: hidden;
}
@media (min-width: 1024px) {
.main-content {
flex-direction: row;
}
}
/* Dashboard Layout */
.dashboard-layout {
display: flex;
width: 100%;
min-height: 100dvh;
background-color: var(--color-bg-darker);
color: var(--color-text-white);
}
.dashboard-content {
flex: 1;
min-height: 0;
position: relative;
overflow-y: auto;
padding-left: 0;
}
/* =====================================================
Card Components
===================================================== */
/* Base Card */
.card {
background-color: var(--color-bg-card);
border-radius: var(--radius-3xl);
padding: var(--spacing-page);
border: 1px solid var(--color-border-white-5);
box-shadow: var(--shadow-xl);
}
@media (min-width: 768px) {
.card {
padding: var(--spacing-page-md);
}
}
/* Card Variants */
.card-flex {
display: flex;
flex-direction: column;
}
.card-flex-1 {
flex: 1;
}
.card-flex-2 {
flex: 2;
}
.card-overflow-hidden {
overflow: hidden;
}
/* Card Inner (Darker Background) */
.card-inner {
background-color: rgba(18, 26, 29, 0.5);
border-radius: var(--radius-2xl);
border: 1px solid var(--color-border-white-5);
padding: 1rem;
}
/* =====================================================
Header Components
===================================================== */
/* Page Header */
.page-header {
text-align: center;
margin-bottom: 2rem;
flex-shrink: 0;
}
/* Page Title */
.page-title {
font-size: var(--text-3xl);
font-weight: 700;
margin-bottom: 0.5rem;
letter-spacing: -0.025em;
}
@media (min-width: 768px) {
.page-title {
font-size: var(--text-4xl);
}
}
/* Page Subtitle */
.page-subtitle {
color: var(--color-text-gray-400);
font-size: var(--text-base);
}
@media (min-width: 768px) {
.page-subtitle {
font-size: var(--text-lg);
}
}
/* Section Title (Mint color uppercase) */
.section-title {
color: #94FBE0;
font-size: 16px;
font-weight: 600;
letter-spacing: -0.006em;
margin-bottom: 0;
flex-shrink: 0;
}
/* Section Title Purple */
.section-title-purple {
color: #AE72F9;
font-size: 16px;
font-weight: 600;
letter-spacing: -0.006em;
margin-bottom: 0;
display: block;
}
/* =====================================================
Button Components
===================================================== */
/* Primary Button (Purple) */
.btn-primary {
background-color: var(--color-purple);
color: var(--color-text-white);
font-weight: 700;
padding: 1rem 4rem;
border-radius: var(--radius-full);
transition: all var(--transition-normal);
transform: scale(1);
box-shadow: var(--shadow-purple);
font-size: var(--text-lg);
border: none;
cursor: pointer;
}
.btn-primary:hover {
background-color: var(--color-purple-hover);
}
.btn-primary:active {
transform: scale(0.95);
}
.btn-primary:disabled {
background-color: #4b5563;
color: var(--color-text-gray-400);
cursor: not-allowed;
box-shadow: none;
}
/* Secondary Button (Mint) */
.btn-secondary {
background-color: var(--color-mint);
color: var(--color-bg-dark);
font-weight: 700;
padding: 1rem;
border-radius: var(--radius-xl);
transition: all var(--transition-normal);
transform: scale(1);
font-size: var(--text-base);
border: none;
cursor: pointer;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn-secondary:hover {
background-color: var(--color-mint-hover);
}
.btn-secondary:active {
transform: scale(0.98);
}
.btn-secondary:disabled {
background-color: rgba(166, 255, 234, 0.5);
color: rgba(18, 26, 29, 0.5);
cursor: not-allowed;
}
/* Outline Button */
.btn-outline {
background-color: transparent;
border: 1px solid var(--color-border-gray-600);
color: var(--color-text-white);
font-weight: 700;
padding: 1rem 4rem;
border-radius: var(--radius-full);
transition: all var(--transition-normal);
font-size: var(--text-base);
cursor: pointer;
}
.btn-outline:hover {
border-color: var(--color-text-gray-500);
}
/* Back Button */
.btn-back {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1.25rem;
border-radius: var(--radius-full);
border: 1px solid var(--color-border-gray-600);
background-color: transparent;
color: var(--color-text-gray-300);
font-size: var(--text-base);
transition: background-color var(--transition-normal);
cursor: pointer;
}
.btn-back:hover {
background-color: rgba(31, 41, 55, 1);
}
/* Regenerate Button (Mint Outline) */
.btn-regenerate {
margin-top: 1rem;
width: 100%;
padding: 0.75rem;
background-color: var(--color-mint-20);
color: var(--color-mint);
border: 1px solid var(--color-mint-30);
font-weight: 700;
border-radius: var(--radius-xl);
transition: background-color var(--transition-normal);
font-size: var(--text-base);
flex-shrink: 0;
cursor: pointer;
}
.btn-regenerate:hover {
background-color: var(--color-mint-30);
}
.btn-regenerate:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Selection Button */
.btn-select {
padding: 0.75rem;
border-radius: var(--radius-xl);
border: 1px solid var(--color-border-gray-700);
background-color: rgba(18, 26, 29, 0.4);
color: var(--color-text-gray-400);
font-size: var(--text-sm);
font-weight: 700;
transition: all var(--transition-normal);
cursor: pointer;
}
.btn-select:hover {
border-color: var(--color-border-gray-600);
}
.btn-select.active {
border-color: var(--color-mint);
background-color: var(--color-bg-dark);
color: var(--color-mint);
}
.btn-select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* =====================================================
Form Components
===================================================== */
/* Select Input */
.select-input {
width: 100%;
padding: 0.75rem 1rem;
border-radius: var(--radius-xl);
background-color: var(--color-bg-dark);
border: 1px solid var(--color-border-gray-700);
font-size: var(--text-base);
color: var(--color-text-gray-300);
}
.select-input:focus {
outline: none;
border-color: var(--color-mint);
}
.select-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Text Input */
.text-input {
width: 100%;
padding: 0.75rem 1rem;
border-radius: var(--radius-xl);
background-color: var(--color-bg-dark);
border: 1px solid var(--color-border-gray-700);
font-size: var(--text-base);
color: var(--color-text-gray-300);
}
.text-input:focus {
outline: none;
border-color: var(--color-mint);
}
/* Textarea - Lyrics */
.textarea-lyrics {
flex: 1;
overflow-y: auto;
font-size: var(--text-sm);
color: var(--color-text-gray-400);
background-color: transparent;
resize: none;
border: none;
padding-right: 0.5rem;
line-height: 1.625;
min-height: 0;
}
.textarea-lyrics:focus {
outline: none;
color: var(--color-text-gray-300);
}
/* =====================================================
Navigation Components
===================================================== */
/* Back Button Container */
.back-button-container {
width: 100%;
max-width: 1040px;
display: flex;
justify-content: flex-start;
margin-bottom: 1.5rem;
flex-shrink: 0;
}
/* Bottom Button Container */
.bottom-button-container {
position: fixed;
bottom: 32px;
left: 0;
right: 0;
display: flex;
justify-content: center;
z-index: 30;
pointer-events: none;
}
.bottom-button-container > * {
pointer-events: all;
}
@media (min-width: 768px) {
.bottom-button-container {
left: 240px;
}
}
/* =====================================================
Sidebar Components
===================================================== */
/* Sidebar Container */
.sidebar {
position: fixed;
height: 100vh;
display: flex;
flex-direction: column;
background-color: var(--color-bg-dark);
border-right: 1px solid var(--color-border-white-5);
transition: all var(--transition-slow);
z-index: 50;
}
@media (min-width: 768px) {
.sidebar {
position: sticky;
top: 0;
flex-shrink: 0;
}
}
.sidebar.expanded {
width: 15rem;
}
.sidebar.collapsed {
width: 5rem;
}
.sidebar.mobile-open {
transform: translateX(0);
}
.sidebar.mobile-closed {
transform: translateX(-100%);
}
@media (min-width: 768px) {
.sidebar.mobile-closed {
transform: translateX(0);
}
}
/* Sidebar Header */
.sidebar-header {
padding: 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.sidebar-header.collapsed {
flex-direction: column;
gap: 1rem;
}
/* Sidebar Logo */
.sidebar-logo {
font-family: 'Playfair Display', serif;
font-style: italic;
font-size: var(--text-2xl);
font-weight: 700;
letter-spacing: -0.025em;
color: var(--color-text-white);
cursor: pointer;
transition: color var(--transition-normal);
}
.sidebar-logo:hover {
color: var(--color-mint);
}
/* Sidebar Menu */
.sidebar-menu {
flex: 1;
padding: 0 0.75rem;
margin-top: 1rem;
overflow-y: auto;
}
/* Sidebar Menu Item */
.sidebar-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: var(--radius-xl);
transition: all var(--transition-normal);
cursor: pointer;
margin-bottom: 0.25rem;
position: relative;
overflow: hidden;
}
.sidebar-item.collapsed {
justify-content: center;
width: 3rem;
height: 3rem;
margin-left: auto;
margin-right: auto;
padding: 0;
}
.sidebar-item.active {
background-color: var(--color-mint);
color: var(--color-bg-dark);
}
.sidebar-item:not(.active) {
color: var(--color-text-gray-400);
}
.sidebar-item:not(.active):not(.disabled):hover {
background-color: rgba(255, 255, 255, 0.05);
color: var(--color-text-white);
}
.sidebar-item.disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
.sidebar-item-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
transition: color var(--transition-normal);
}
.sidebar-item.active .sidebar-item-icon {
color: var(--color-bg-dark);
}
.sidebar-item-label {
font-size: 16px;
font-weight: 700;
white-space: nowrap;
}
/* Sidebar Footer */
.sidebar-footer {
padding: 1rem;
margin-top: auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Credit Card */
.credit-card {
background-color: var(--color-bg-card);
border-radius: var(--radius-2xl);
padding: 1rem;
border: 1px solid var(--color-border-white-5);
}
.credit-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.credit-label {
color: var(--color-text-gray-400);
font-size: var(--text-sm);
font-weight: 500;
}
.credit-value {
color: var(--color-text-white);
font-size: var(--text-sm);
font-weight: 700;
}
.credit-bar {
width: 100%;
height: 0.375rem;
background-color: #1f2937;
border-radius: var(--radius-full);
overflow: hidden;
margin-bottom: 0.75rem;
}
.credit-bar-fill {
height: 100%;
background-color: var(--color-mint);
}
.credit-upgrade-btn {
width: 100%;
padding: 0.625rem;
background-color: var(--color-bg-dark);
color: var(--color-text-white);
font-size: var(--text-sm);
font-weight: 700;
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-gray-700);
transition: background-color var(--transition-normal);
cursor: pointer;
}
.credit-upgrade-btn:hover {
background-color: #1f2937;
}
/* Profile Section */
.profile-section {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0 0.5rem;
}
.profile-section.collapsed {
flex-direction: column;
}
.profile-avatar {
width: 2.5rem;
height: 2.5rem;
border-radius: var(--radius-full);
border: 1px solid var(--color-border-gray-700);
object-fit: cover;
}
.profile-avatar-default {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-bg-gray-700);
color: var(--color-text-gray-400);
}
.profile-name {
color: var(--color-text-white);
font-size: var(--text-sm);
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-credits {
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
font-weight: 500;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Sidebar Language Switch */
.sidebar-language-switch {
padding: 0 1rem 0.75rem;
display: flex;
justify-content: flex-start;
}
/* Toggle container */
.lang-toggle {
position: relative;
display: flex;
background: rgba(255, 255, 255, 0.08);
border-radius: 999px;
padding: 2px;
width: 100%;
max-width: 120px;
}
.lang-toggle-option {
flex: 1;
position: relative;
z-index: 1;
padding: 4px 0;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.03em;
color: rgba(255, 255, 255, 0.4);
background: none;
border: none;
cursor: pointer;
transition: color 0.3s ease;
text-align: center;
}
.lang-toggle-option.active {
color: #ffffff;
}
.lang-toggle-option:hover:not(.active) {
color: rgba(255, 255, 255, 0.6);
}
/* Sliding indicator */
.lang-toggle-slider {
position: absolute;
top: 2px;
bottom: 2px;
width: calc(50% - 2px);
background: rgba(255, 255, 255, 0.15);
border-radius: 999px;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
.lang-toggle-slider.left {
transform: translateX(2px);
}
.lang-toggle-slider.right {
transform: translateX(calc(100% + 2px));
}
/* Collapsed state - simple button */
.lang-toggle-collapsed {
padding: 4px 6px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.03em;
color: rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.08);
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
}
.lang-toggle-collapsed:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.15);
}
/* Sidebar Footer Actions */
.sidebar-footer-actions {
display: flex;
flex-direction: column;
}
.sidebar-footer-actions .logout-btn {
width: 100%;
}
.sidebar-inquiry-btn {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
color: var(--color-text-gray-400);
text-decoration: none;
transition: color var(--transition-normal);
white-space: nowrap;
font-size: var(--text-sm);
font-weight: 700;
flex-shrink: 0;
}
.sidebar-inquiry-btn:hover {
color: var(--color-text-white);
}
.sidebar-inquiry-btn.collapsed {
justify-content: center;
padding: 0.75rem;
}
/* Logout Button */
.logout-btn {
width: 100%;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
color: var(--color-text-gray-400);
background: none;
border: none;
transition: color var(--transition-normal);
cursor: pointer;
}
.logout-btn:hover {
color: var(--color-text-white);
}
.logout-btn.collapsed {
justify-content: center;
}
.logout-btn-label {
font-size: var(--text-sm);
font-weight: 700;
}
/* 모바일 전용 사이드바 튜토리얼 토글 */
.sidebar-tutorial-btn {
display: none;
}
@media (max-width: 767px) {
.sidebar-tutorial-btn {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
margin-top: 0.5rem;
padding: 0.75rem;
border-radius: var(--radius-lg);
color: var(--color-text-gray-400);
border: none;
background: none;
cursor: pointer;
font-size: var(--text-sm);
font-weight: 500;
transition: color var(--transition-normal), background-color var(--transition-normal);
}
.sidebar-tutorial-btn:hover {
color: var(--color-text-white);
background-color: rgba(255, 255, 255, 0.05);
}
.sidebar-tutorial-btn.active {
color: var(--color-mint);
}
.sidebar-tutorial-label {
text-align: left;
}
.sidebar-tutorial-badge {
font-size: 11px;
font-weight: 700;
}
.sidebar-tutorial-badge.on {
color: var(--color-mint);
}
.sidebar-tutorial-badge.off {
color: var(--color-text-gray-400);
}
}
/* Mobile Menu Button */
.mobile-menu-btn {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 40;
padding: 0.625rem;
background-color: var(--color-bg-card);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-white-10);
color: var(--color-text-gray-400);
cursor: pointer;
}
.mobile-menu-btn:hover {
color: var(--color-text-white);
}
@media (min-width: 768px) {
.mobile-menu-btn {
display: none;
}
}
/* Mobile Overlay */
.mobile-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 40;
}
@media (min-width: 768px) {
.mobile-overlay {
display: none;
}
}

View File

@ -0,0 +1,503 @@
/* =====================================================
Business Settings Components
===================================================== */
/* Settings Container */
.settings-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.75rem;
background-color: var(--color-bg-dark);
color: var(--color-text-white);
overflow: hidden;
}
@media (min-width: 640px) {
.settings-container {
padding: 1rem;
}
}
@media (min-width: 768px) {
.settings-container {
padding: 1.5rem;
}
}
/* Settings Header */
.settings-header {
text-align: center;
margin-bottom: 1rem;
max-width: 42rem;
margin-left: 2.5rem;
}
@media (min-width: 640px) {
.settings-header {
margin-bottom: 1.5rem;
}
}
@media (min-width: 768px) {
.settings-header {
margin-bottom: 2rem;
margin-left: 0;
}
}
.settings-title {
font-size: var(--text-lg);
font-weight: 700;
margin-bottom: 0.25rem;
}
@media (min-width: 640px) {
.settings-title {
font-size: var(--text-xl);
margin-bottom: 0.5rem;
}
}
@media (min-width: 768px) {
.settings-title {
font-size: 1.5rem;
}
}
@media (min-width: 1024px) {
.settings-title {
font-size: var(--text-3xl);
}
}
.settings-description {
color: var(--color-text-gray-400);
font-size: 9px;
font-weight: 300;
line-height: 1.625;
opacity: 0.8;
}
@media (min-width: 640px) {
.settings-description {
font-size: 10px;
}
}
@media (min-width: 768px) {
.settings-description {
font-size: var(--text-xs);
}
}
/* Settings Card */
.settings-card {
width: 100%;
max-width: 36rem;
}
.settings-card-inner {
background-color: var(--color-bg-card);
border-radius: var(--radius-xl);
padding: 0.75rem;
border: 1px solid var(--color-border-white-5);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
@media (min-width: 640px) {
.settings-card-inner {
padding: 1rem;
border-radius: var(--radius-2xl);
}
}
@media (min-width: 768px) {
.settings-card-inner {
padding: 1.5rem;
border-radius: var(--radius-3xl);
}
}
.settings-card-title {
color: var(--color-mint);
font-size: 9px;
font-weight: 700;
margin-bottom: 0.5rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
@media (min-width: 640px) {
.settings-card-title {
font-size: 10px;
margin-bottom: 0.75rem;
}
}
@media (min-width: 768px) {
.settings-card-title {
font-size: var(--text-xs);
margin-bottom: 1rem;
}
}
/* Social Items */
.social-items {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
@media (min-width: 640px) {
.social-items {
gap: 0.75rem;
}
}
.social-item {
background-color: var(--color-bg-dark);
padding: 0.5rem;
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-white-5);
display: flex;
align-items: center;
justify-content: space-between;
transition: border-color var(--transition-normal);
}
@media (min-width: 640px) {
.social-item {
padding: 0.75rem;
border-radius: var(--radius-xl);
}
}
.social-item:hover {
border-color: var(--color-border-white-10);
}
.social-item-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
@media (min-width: 640px) {
.social-item-left {
gap: 0.75rem;
}
}
.social-item-icon {
width: 1.5rem;
height: 1.5rem;
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
}
@media (min-width: 640px) {
.social-item-icon {
width: 2rem;
height: 2rem;
}
}
.social-item-icon-inner {
width: 0.75rem;
height: 0.75rem;
}
@media (min-width: 640px) {
.social-item-icon-inner {
width: 1rem;
height: 1rem;
}
}
.social-item-name {
font-size: 10px;
font-weight: 700;
color: var(--color-text-gray-300);
}
@media (min-width: 640px) {
.social-item-name {
font-size: var(--text-xs);
}
}
.social-item-connect {
color: var(--color-mint);
font-size: 9px;
font-weight: 700;
background: none;
border: none;
cursor: pointer;
}
@media (min-width: 640px) {
.social-item-connect {
font-size: 10px;
}
}
.social-item-connect:hover {
text-decoration: underline;
}
/* =====================================================
Footer Component
===================================================== */
/* Landing Footer */
.landing-footer {
width: 100%;
background: var(--Color-teal-800, #002224);
padding: 40px 1.5rem;
}
@media (min-width: 768px) {
.landing-footer {
padding: 48px 5rem;
}
}
@media (min-width: 1024px) {
.landing-footer {
padding: 60px 100px;
}
}
.footer-content {
display: flex;
flex-direction: column;
gap: 2rem;
max-width: 1144px;
margin: 0 auto;
}
@media (min-width: 768px) {
.footer-content {
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 4rem;
}
}
.footer-left {
min-width: 262px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.footer-logo {
height: 24px;
width: auto;
object-fit: contain;
}
.footer-copyright {
font-size: 14px;
font-weight: 400;
color: #379599;
letter-spacing: -0.006em;
line-height: 1.19;
text-align: center;
}
.footer-links {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.footer-link {
font-size: 13px;
color: #379599;
text-decoration: none;
opacity: 0.8;
}
.footer-link:hover {
opacity: 1;
text-decoration: underline;
}
.footer-link-divider {
font-size: 13px;
color: #379599;
opacity: 0.5;
}
.footer-right {
max-width: 600px;
display: flex;
flex-direction: column;
gap: 4px;
}
.footer-info {
font-size: 12px;
font-weight: 400;
color: #CEE5E6;
letter-spacing: -0.006em;
line-height: 1.19;
}
@media (min-width: 768px) {
.footer-info {
font-size: 14px;
}
}
/* =====================================================
Header Component
===================================================== */
/* Landing Header */
.landing-header {
width: calc(100% - 2rem);
max-width: 1280px;
padding: 12px 12px 12px 24px;
display: flex;
justify-content: space-between;
align-items: center;
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 10100;
background: rgba(0, 34, 36, 0.3);
border: 1px solid rgba(229, 241, 242, 0.2);
border-radius: 999px;
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
}
@media (min-width: 768px) {
.landing-header {
width: calc(100% - 10rem);
}
}
.header-logo {
display: flex;
align-items: center;
height: 16px;
}
.header-logo img {
height: 100%;
width: auto;
object-fit: contain;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.header-actions .lang-toggle {
width: auto;
max-width: none;
padding: 2px;
flex-shrink: 0;
}
.header-actions .lang-toggle-option {
padding: 4px 10px;
white-space: nowrap;
}
.header-login-btn {
padding: 10px 24px;
border-radius: 999px;
background-color: #94FBE0;
color: #000000;
font-weight: 600;
font-size: 14px;
border: none;
cursor: pointer;
transition: all var(--transition-normal);
letter-spacing: -0.006em;
line-height: 1.19;
}
.header-login-btn:hover {
background-color: #7fe8cc;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(148, 251, 224, 0.3);
}
.header-login-btn:active {
transform: scale(0.95);
}
.header-start-btn {
padding: 10px 24px;
border-radius: 999px;
background-color: #AE72F9;
color: #FFFFFF;
font-weight: 600;
font-size: 14px;
border: none;
cursor: pointer;
transition: all var(--transition-normal);
letter-spacing: -0.006em;
line-height: 1.19;
}
.header-start-btn:hover {
background-color: #9B5DE5;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(174, 114, 249, 0.3);
}
.header-start-btn:active {
transform: scale(0.95);
}
.header-avatar {
width: 2rem;
height: 2rem;
border-radius: var(--radius-full);
overflow: hidden;
border: 1px solid var(--color-border-gray-700);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
@media (min-width: 640px) {
.header-avatar {
width: 2.5rem;
height: 2.5rem;
}
}
.header-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* =====================================================
Content Safe Area (for Landing Pages)
===================================================== */
.content-safe-area {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}

File diff suppressed because it is too large Load Diff

1187
src/styles/dashboard.css Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

12
src/styles/index.css Normal file
View File

@ -0,0 +1,12 @@
/* CASTAD Design System - 도메인별 CSS 파일 매니페스트 */
@import './tokens.css';
@import './base-components.css';
@import './media-components.css';
@import './generation-flow.css';
@import './landing.css';
@import './dashboard.css';
@import './business-settings.css';
@import './studio-assets.css';
@import './contents-social.css';
@import './modals-overlays.css';

1619
src/styles/landing.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,593 @@
/* =====================================================
Player Components
===================================================== */
/* Player Bar */
.player-bar {
background-color: rgba(0, 0, 0, 0.4);
border-radius: var(--radius-full);
padding: 0.75rem;
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
}
/* Play Button */
.play-btn {
color: var(--color-mint);
flex-shrink: 0;
transition: transform var(--transition-normal);
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.play-btn:hover {
transform: scale(1.1);
}
.play-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Progress Bar */
.progress-bar-container {
flex: 1;
height: 0.375rem;
background-color: #1f2937;
border-radius: var(--radius-full);
position: relative;
cursor: pointer;
}
.progress-bar-container:disabled,
.progress-bar-container.disabled {
cursor: not-allowed;
opacity: 0.5;
}
.progress-bar-fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
background-color: var(--color-mint);
border-radius: var(--radius-full);
}
.progress-bar-thumb {
position: absolute;
top: 50%;
transform: translateY(-50%) translateX(-50%);
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.progress-bar-thumb-glow {
position: absolute;
width: 1rem;
height: 1rem;
background-color: var(--color-mint);
border-radius: var(--radius-full);
opacity: 0.2;
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.progress-bar-thumb-dot {
width: 0.75rem;
height: 0.75rem;
background-color: var(--color-mint);
border-radius: var(--radius-full);
box-shadow: var(--shadow-mint-glow);
border: 1px solid rgba(18, 26, 29, 0.2);
}
/* Time Display */
.time-display {
font-size: var(--text-sm);
color: var(--color-text-gray-500);
font-family: monospace;
text-align: right;
white-space: nowrap;
}
/* =====================================================
Image Grid Components
===================================================== */
/* Image Grid */
.image-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
}
.image-grid-4 {
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
}
/* Image Item */
.image-item {
aspect-ratio: 1;
background-color: rgba(18, 26, 29, 0.5);
border-radius: var(--radius-xl);
border: 1px solid var(--color-border-white-5);
position: relative;
overflow: hidden;
}
.image-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-item-badge {
position: absolute;
top: 0.5rem;
left: 0.5rem;
padding: 0.25rem 0.5rem;
background-color: var(--color-purple-80);
border-radius: 0.25rem;
font-size: var(--text-xs);
color: var(--color-text-white);
font-weight: 500;
}
.image-item-remove {
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: 1.75rem;
height: 1.75rem;
background-color: rgba(0, 0, 0, 0.6);
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.7);
border: none;
cursor: pointer;
transition: all var(--transition-normal);
}
.image-item-remove:hover {
background-color: rgba(239, 68, 68, 0.8);
color: var(--color-text-white);
}
/* =====================================================
Upload Components
===================================================== */
/* Upload Zone */
.upload-zone {
flex: 1;
border: 2px dashed var(--color-border-gray-600);
border-radius: var(--radius-2xl);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem;
text-align: center;
cursor: pointer;
transition: border-color var(--transition-normal);
}
.upload-zone:hover {
border-color: rgba(166, 255, 234, 0.5);
}
.upload-zone-icon {
margin-bottom: 1rem;
color: var(--color-text-gray-500);
transition: color var(--transition-normal);
}
.upload-zone:hover .upload-zone-icon {
color: var(--color-mint);
}
.upload-zone-title {
color: var(--color-text-gray-300);
font-size: var(--text-base);
font-weight: 500;
line-height: 1.625;
margin-bottom: 0.5rem;
}
.upload-zone-subtitle {
color: var(--color-text-gray-500);
font-size: var(--text-sm);
}
/* =====================================================
Tag Components
===================================================== */
/* Tag */
.tag {
padding: 0.625rem 1.25rem;
background-color: var(--color-bg-card-inner);
border-radius: var(--radius-full);
font-size: var(--text-base);
color: #e5e7eb;
border: 1px solid var(--color-border-white-10);
}
/* Tag with Dot */
.tag-dot {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: var(--text-sm);
color: var(--color-text-gray-400);
}
.tag-dot::before {
content: '';
width: 0.375rem;
height: 0.375rem;
border-radius: var(--radius-full);
background-color: var(--color-mint);
}
/* =====================================================
Social Card Components
===================================================== */
/* Social Card */
.social-card {
display: flex;
align-items: center;
gap: 1rem;
background-color: var(--color-bg-dark);
padding: 1rem;
border-radius: var(--radius-2xl);
border: 1px solid var(--color-border-white-5);
transition: all var(--transition-normal);
cursor: pointer;
}
.social-card:hover {
border-color: var(--color-border-white-10);
}
.social-card.selected {
border-color: var(--color-mint);
box-shadow: 0 0 15px rgba(166, 255, 234, 0.15);
background-color: var(--color-bg-card);
}
.social-icon {
width: 2.5rem;
height: 2.5rem;
border-radius: var(--radius-xl);
display: flex;
align-items: center;
justify-content: center;
position: relative;
transition: transform var(--transition-slow);
}
.social-icon-inner {
width: 1.5rem;
height: 1.5rem;
color: var(--color-text-white);
}
.social-check {
position: absolute;
top: -0.25rem;
right: -0.25rem;
width: 1.25rem;
height: 1.25rem;
background-color: var(--color-mint);
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-bg-dark);
border: 2px solid var(--color-bg-dark);
}
.social-name {
font-size: var(--text-base);
font-weight: 700;
transition: color var(--transition-normal);
}
.social-card.selected .social-name {
color: var(--color-text-white);
}
.social-card:not(.selected) .social-name {
color: var(--color-text-gray-300);
}
.social-email {
margin-left: auto;
font-size: var(--text-sm);
color: var(--color-purple);
}
/* =====================================================
Video Preview Components
===================================================== */
/* Video Container */
.video-container {
flex: 1;
background-color: #000;
border-radius: var(--radius-2xl);
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
}
.video-pattern {
position: absolute;
inset: 0;
opacity: 0.1;
background-image:
linear-gradient(45deg, var(--color-bg-dark) 25%, transparent 25%),
linear-gradient(-45deg, var(--color-bg-dark) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--color-bg-dark) 75%),
linear-gradient(-45deg, transparent 75%, var(--color-bg-dark) 75%);
background-size: 40px 40px;
background-position: 0 0, 0 20px, 20px -20px, -20px 0px;
}
.video-controls {
position: absolute;
bottom: 1rem;
left: 1rem;
right: 1rem;
z-index: 10;
}
.video-controls-inner {
display: flex;
align-items: center;
gap: 0.75rem;
}
.video-play-btn {
color: var(--color-text-white);
background: none;
border: none;
cursor: pointer;
transition: color var(--transition-normal);
}
.video-play-btn:hover {
color: var(--color-mint);
}
.video-progress {
flex: 1;
height: 0.375rem;
background-color: rgba(255, 255, 255, 0.2);
border-radius: var(--radius-full);
overflow: hidden;
}
.video-progress-fill {
height: 100%;
background-color: var(--color-mint);
}
/* Video Player */
.video-player {
width: 100%;
height: 100%;
object-fit: contain;
cursor: pointer;
}
/* Video Loading State */
.video-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
}
/* Video Error State */
.video-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
}
.video-error svg {
width: 4rem;
height: 4rem;
color: #ef4444;
margin-bottom: 1rem;
}
/* Social Card Disabled State */
.social-card.disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* =====================================================
Tags Container
===================================================== */
.tags-container {
margin-top: 1rem;
background-color: rgba(18, 26, 29, 0.6);
border-radius: var(--radius-xl);
padding: 0.75rem 1rem;
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: center;
border: 1px solid var(--color-border-white-5);
flex-shrink: 0;
}
/* =====================================================
Status Messages
===================================================== */
/* Error Message */
.error-message {
margin-top: 1rem;
padding: 0.75rem;
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-xl);
color: #f87171;
font-size: var(--text-sm);
flex-shrink: 0;
}
/* Status Message */
.status-message {
margin-top: 1rem;
padding: 0.75rem;
background-color: var(--color-mint-10);
border: 1px solid var(--color-mint-30);
border-radius: var(--radius-xl);
color: var(--color-mint);
font-size: var(--text-sm);
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
/* =====================================================
Utility Classes
===================================================== */
/* Flex utilities */
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex-col {
display: flex;
flex-direction: column;
}
.flex-1 {
flex: 1;
}
.flex-2 {
flex: 2;
}
.shrink-0 {
flex-shrink: 0;
}
.min-h-0 {
min-height: 0;
}
/* Gap utilities */
.gap-2 { gap: 0.5rem; }
.gap-3 { gap: 0.75rem; }
.gap-4 { gap: 1rem; }
.gap-6 { gap: 1.5rem; }
/* Margin utilities */
.mb-1 { margin-bottom: 0.25rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-3 { margin-bottom: 0.75rem; }
.mb-4 { margin-bottom: 1rem; }
.mb-6 { margin-bottom: 1.5rem; }
.mt-4 { margin-top: 1rem; }
.mt-6 { margin-top: 1.5rem; }
/* Text utilities */
.text-center { text-align: center; }
.text-right { text-align: right; }
.font-bold { font-weight: 700; }
.font-medium { font-weight: 500; }
/* Overflow utilities */
.overflow-hidden { overflow: hidden; }
.overflow-y-auto { overflow-y: auto; }
/* Width utilities */
.w-full { width: 100%; }
/* Animation */
@keyframes pulse {
0%, 100% { opacity: 0.2; }
50% { opacity: 0.4; }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 1s linear infinite;
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* Grid utilities */
.grid-cols-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
/* Space utilities */
.space-y-4 > * + * {
margin-top: 1rem;
}
.space-y-5 > * + * {
margin-top: 1.25rem;
}
/* Min width */
.min-w-280 {
min-width: 280px;
}
/* Scrollbar utility */
.scrollbar-hide {
scrollbar-width: none;
-ms-overflow-style: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}

File diff suppressed because it is too large Load Diff

1373
src/styles/studio-assets.css Normal file

File diff suppressed because it is too large Load Diff

74
src/styles/tokens.css Normal file
View File

@ -0,0 +1,74 @@
/* =====================================================
CASTAD Design System - CSS Variables & Component Classes
===================================================== */
/* =====================================================
CSS Variables - Design Tokens
===================================================== */
:root {
/* Primary Colors */
--color-mint: #a6ffea;
--color-mint-hover: #8affda;
--color-mint-glow: rgba(166, 255, 234, 0.6);
--color-mint-10: rgba(166, 255, 234, 0.1);
--color-mint-20: rgba(166, 255, 234, 0.2);
--color-mint-30: rgba(166, 255, 234, 0.3);
--color-mint-50: rgba(166, 255, 234, 0.5);
--color-purple: #a682ff;
--color-purple-hover: #9570f0;
--color-purple-glow: rgba(166, 130, 255, 0.2);
--color-purple-80: rgba(166, 130, 255, 0.8);
/* Background Colors - Teal-600 based */
--color-bg-dark: #002224;
--color-bg-darker: #001a1c;
--color-bg-card: #003538;
--color-bg-card-inner: #004548;
/* Text Colors */
--color-text-white: #ffffff;
--color-text-gray-300: #d1d5db;
--color-text-gray-400: #9ca3af;
--color-text-gray-500: #6b7280;
/* Border Colors */
--color-border-white-5: rgba(255, 255, 255, 0.05);
--color-border-white-10: rgba(255, 255, 255, 0.1);
--color-border-gray-600: #4b5563;
--color-border-gray-700: #374151;
/* Shadows */
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
--shadow-purple: 0 10px 15px -3px rgba(166, 130, 255, 0.2);
--shadow-mint-glow: 0 0 10px rgba(166, 255, 234, 0.6);
/* Spacing */
--spacing-page: 1.5rem;
--spacing-page-md: 2rem;
/* Border Radius */
--radius-sm: 0.5rem;
--radius-md: 0.75rem;
--radius-lg: 1rem;
--radius-xl: 1.25rem;
--radius-2xl: 1rem;
--radius-3xl: 1.5rem;
--radius-full: 9999px;
/* Font Sizes */
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--text-4xl: 2.25rem;
/* Transitions */
--transition-fast: 150ms;
--transition-normal: 200ms;
--transition-slow: 300ms;
}

View File

@ -32,14 +32,21 @@ export interface MarketingAnalysis {
target_keywords: string[]; target_keywords: string[];
} }
export interface ImageListItem {
preview: string; // 미리보기용 CDN 리사이즈 URL
original: string; // 업로드용 원본 URL
}
export interface CrawlingResponse { export interface CrawlingResponse {
image_list: string[]; image_list: ImageListItem[] | null;
image_count: number; image_count: number;
m_id: number; m_id: number;
industry: string;
processed_info: { processed_info: {
customer_name: string; customer_name: string;
region: string; region: string;
detail_region_info: string; detail_region_info: string;
industry: string;
}; };
marketing_analysis: MarketingAnalysis; marketing_analysis: MarketingAnalysis;
} }
@ -47,7 +54,8 @@ export interface CrawlingResponse {
// URL 이미지 (서버에서 가져온 이미지) // URL 이미지 (서버에서 가져온 이미지)
export interface UrlImage { export interface UrlImage {
type: 'url'; type: 'url';
url: string; url: string; // 업로드용 원본 URL
preview_url: string; // 미리보기용 CDN 리사이즈 URL
} }
// 업로드된 파일 이미지 // 업로드된 파일 이미지
@ -67,8 +75,10 @@ export interface LyricGenerateRequest {
m_id: number; m_id: number;
region: string; region: string;
task_id: string; task_id: string;
industry?: string;
orientation?: 'vertical' | 'horizontal'; orientation?: 'vertical' | 'horizontal';
instrumental?: boolean; instrumental?: boolean;
genre?: string; // 사용자가 수동으로 고른 장르. '자동 선택'이면 생략 (GPT가 추천)
} }
// 가사 생성 응답 // 가사 생성 응답
@ -98,6 +108,7 @@ export interface LyricDetailResponse {
status: string; status: string;
lyric_prompt: string; lyric_prompt: string;
lyric_result: string; lyric_result: string;
genre?: string | null; // 확정된 장르 (수동 선택값 또는 GPT 추천값)
created_at: string; created_at: string;
} }
@ -207,58 +218,6 @@ export interface ImageUploadResponse {
}>; }>;
} }
// 숏폼 생성 요청
export interface ShortformGenerateRequest {
task_id: string;
kind: string;
biz_name: string;
addr?: string;
price?: string;
selling: string;
spec?: VideoSpec;
dry_run?: boolean;
}
// 숏폼 생성 응답
export interface ShortformGenerateResponse {
task_id: string;
video_id: number;
status: string;
}
// 숏폼 상태 조회 응답
export interface ShortformStatusResponse {
task_id: string;
status: 'processing' | 'completed' | 'failed';
result_url: string | null;
}
// 숏폼 미리보기 요청
export interface ShortformPreviewRequest {
kind: string;
biz_name: string;
selling: string;
addr?: string;
price?: string;
}
// 숏폼 미리보기 응답 (VideoSpec)
export interface VideoSpec {
hook: {
eyebrow: string;
title: string;
};
selling_point: {
items: string[];
};
brand_lines: string[];
end_card: {
brand: string;
location: string;
};
caption: string;
}
// 언어 매핑 // 언어 매핑
export const LANGUAGE_MAP: Record<string, string> = { export const LANGUAGE_MAP: Record<string, string> = {
'한국어': 'Korean', '한국어': 'Korean',

View File

@ -34,11 +34,6 @@ import {
CommentsResponse, CommentsResponse,
CommentItem, CommentItem,
LikeToggleResponse, LikeToggleResponse,
ShortformGenerateRequest,
ShortformGenerateResponse,
ShortformStatusResponse,
ShortformPreviewRequest,
VideoSpec,
} from '../types/api'; } from '../types/api';
export const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44'; export const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
@ -562,85 +557,6 @@ export async function waitForVideoComplete(
return poll(); return poll();
} }
// ============================================
// 숏폼 생성 API
// ============================================
// 숏폼 생성 요청 (POST /shortform/generate, 202 Accepted)
export async function generateShortform(req: ShortformGenerateRequest): Promise<ShortformGenerateResponse> {
const response = await authenticatedFetch(`${API_URL}/shortform/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(req),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 숏폼 상태 조회 (GET /shortform/status/{task_id})
export async function getShortformStatus(taskId: string): Promise<ShortformStatusResponse> {
const response = await authenticatedFetch(`${API_URL}/shortform/status/${taskId}`, {
method: 'GET',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 숏폼 완료까지 폴링 (10분 타임아웃, 3초 간격)
const SHORTFORM_POLL_TIMEOUT = 10 * 60 * 1000;
const SHORTFORM_POLL_INTERVAL = 3000;
export async function waitForShortformComplete(
taskId: string,
onStatusChange?: (status: string) => void
): Promise<ShortformStatusResponse> {
const startTime = Date.now();
const poll = async (): Promise<ShortformStatusResponse> => {
if (Date.now() - startTime > SHORTFORM_POLL_TIMEOUT) {
throw new Error('TIMEOUT');
}
const statusResponse = await getShortformStatus(taskId);
onStatusChange?.(statusResponse.status);
if (statusResponse.status === 'completed') {
return statusResponse;
} else if (statusResponse.status === 'failed') {
throw new Error('Shortform generation failed');
}
await new Promise(resolve => setTimeout(resolve, SHORTFORM_POLL_INTERVAL));
return poll();
};
return poll();
}
// 숏폼 자막 미리보기 (POST /shortform/preview)
export async function previewShortform(req: ShortformPreviewRequest): Promise<VideoSpec> {
const response = await authenticatedFetch(`${API_URL}/shortform/preview`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error((err as { detail?: string }).detail || '미리보기 생성에 실패했습니다.');
}
return response.json();
}
// ============================================ // ============================================
// 카카오 인증 API // 카카오 인증 API
// ============================================ // ============================================
@ -928,6 +844,16 @@ export interface AccommodationSearchItem {
title: string; title: string;
} }
// 네이버 검색 API는 title에 <b> 태그와 &amp; 같은 HTML 엔티티를 함께 내려주므로
// 태그 제거 후 엔티티까지 디코딩해야 "&amp;" 같은 원문이 그대로 노출되지 않는다.
// textarea는 DOM에 붙이지 않고 content model상 스크립트를 실행하지 않으므로 엔티티 디코딩 용도로 안전하다.
export const cleanSearchTitle = (title: string): string => {
const withoutTags = title.replace(/<[^>]*>/g, '');
const textarea = document.createElement('textarea');
textarea.innerHTML = withoutTags;
return textarea.value;
};
export interface AccommodationSearchResponse { export interface AccommodationSearchResponse {
count: number; count: number;
items: AccommodationSearchItem[]; items: AccommodationSearchItem[];
@ -985,7 +911,7 @@ export async function autocomplete(request: AutocompleteRequest): Promise<Crawli
} }
// 업체명·주소 직접 입력으로 마케팅 분석 // 업체명·주소 직접 입력으로 마케팅 분석
export async function marketingAnalysis(storeName: string, address: string): Promise<CrawlingResponse> { export async function marketingAnalysis(storeName: string, address: string, category = ''): Promise<CrawlingResponse> {
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT); const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT);
@ -995,7 +921,7 @@ export async function marketingAnalysis(storeName: string, address: string): Pro
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ store_name: storeName, address }), body: JSON.stringify({ store_name: storeName, address, category }),
signal: controller.signal, signal: controller.signal,
}); });