Compare commits

..

1 Commits

Author SHA1 Message Date
김성경 530416026d 숏폼생성 기능 추가 2026-06-12 09:44:02 +09:00
65 changed files with 18796 additions and 13862 deletions

13722
index.css Normal file

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://ado2.o2osolution.ai" /> <meta property="og:url" content="https://demo.castad.net/" />
<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,6 +162,7 @@
} }
} }
</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,6 +16,8 @@
}, },
"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"
@ -52,6 +54,7 @@
"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",
@ -1288,10 +1291,32 @@
"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",
@ -1349,6 +1374,7 @@
} }
], ],
"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",
@ -1400,6 +1426,13 @@
"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",
@ -1691,6 +1724,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4" "@babel/runtime": "^7.28.4"
}, },
@ -1811,6 +1845,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -1852,6 +1887,7 @@
"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"
} }
@ -1861,6 +1897,7 @@
"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"
}, },
@ -1907,6 +1944,7 @@
"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"
@ -1969,7 +2007,8 @@
"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",
@ -2083,6 +2122,7 @@
"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"
@ -2166,6 +2206,7 @@
"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,6 +17,8 @@
}, },
"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

@ -0,0 +1,244 @@
{
"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

@ -0,0 +1,244 @@
{
"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://ado2.o2osolution.ai</li> <li><strong>웹사이트:</strong> https://demo.castad.net</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://ado2.o2osolution.ai</li> <li><strong>웹사이트:</strong> https://demo.castad.net</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>

2907
remotion/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
remotion/package.json Normal file
View File

@ -0,0 +1,22 @@
{
"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"
}
}

Binary file not shown.

View File

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

View File

@ -0,0 +1,58 @@
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>
);
};

25
remotion/src/Root.tsx Normal file
View File

@ -0,0 +1,25 @@
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

@ -0,0 +1,63 @@
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

@ -0,0 +1,91 @@
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

@ -0,0 +1,93 @@
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

@ -0,0 +1,72 @@
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

@ -0,0 +1,53 @@
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

@ -0,0 +1,35 @@
// 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

@ -0,0 +1,19 @@
// 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;
};
};

21
remotion/src/fonts.ts Normal file
View File

@ -0,0 +1,21 @@
// 한글 폰트 로딩 (@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;

4
remotion/src/index.ts Normal file
View File

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

15
remotion/tsconfig.json Normal file
View File

@ -0,0 +1,15 @@
{
"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, category: string) => { const handleManualInput = async (businessName: string, address: 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, category); const data = await marketingAnalysis(businessName, address);
if (!validateCrawlingResponse(data)) { if (!validateCrawlingResponse(data)) {
throw new Error(t('app.autocompleteError')); throw new Error(t('app.autocompleteError'));
@ -358,32 +358,6 @@ 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로 이동
@ -504,11 +478,8 @@ const App: React.FC = () => {
<Header onStartClick={handleHeaderStart} /> <Header onStartClick={handleHeaderStart} />
<section className="landing-section"> <section className="landing-section">
<HeroSection <HeroSection
onAnalyze={handleStartAnalysis} onLogin={handleToLogin}
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, category: string) => void; onSubmit: (businessName: string, address: string) => void;
} }
const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose, onSubmit }) => { const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose, onSubmit }) => {
@ -13,7 +13,6 @@ 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(() => {
@ -42,7 +41,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, category.trim()); onSubmit(businessName.trim(), fullAddress);
onClose(); onClose();
}; };
@ -61,7 +60,6 @@ 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">
@ -105,19 +103,6 @@ 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')}
@ -128,7 +113,7 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
onClick={handleSubmit} onClick={handleSubmit}
disabled={!isValid} disabled={!isValid}
> >
{t('landing.hero.manualAnalyzeButton')} {t('landing.hero.analyzeButton')}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,228 +0,0 @@
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://ado2.o2osolution.ai/privacy.html" rel="noopener noreferrer" className="footer-link">{t('footer.privacyPolicy')}</a> <a href="https://demo.castad.net/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://ado2.o2osolution.ai/terms.html" rel="noopener noreferrer" className="footer-link">{t('footer.termsOfService')}</a> <a href="https://demo.castad.net/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, cleanSearchTitle, AccommodationSearchItem, AutocompleteRequest } from '../utils/api'; import { searchAccommodation, 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,29 +18,13 @@ 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, category: string) => void; onManualInput?: (businessName: string, address: string) => void;
/** 직접입력 버튼 클릭 시 기본 동작(모달 열기)을 대체합니다. 제공 시 모달을 직접 관리해야 합니다. */ /** 직접입력 버튼 클릭 시 기본 동작(모달 열기)을 대체합니다. 제공 시 모달을 직접 관리해야 합니다. */
onManualButtonClick?: () => void; onManualButtonClick?: () => void;
onShortform?: () => void;
error?: string | null; error?: string | null;
} }
@ -49,11 +33,13 @@ 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] = useState<SearchType>('name'); const [searchType, setSearchType] = 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('');
@ -63,12 +49,16 @@ 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('name'); const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType === 'manual' ? 'name' : searchType);
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);
} }
@ -77,8 +67,20 @@ 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()) { if (!query.trim() || searchType !== 'name') {
setAutocompleteResults([]); setAutocompleteResults([]);
setShowAutocomplete(false); setShowAutocomplete(false);
return; return;
@ -94,12 +96,11 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
} finally { } finally {
setIsAutocompleteLoading(false); setIsAutocompleteLoading(false);
} }
}, []); }, [searchType]);
const handleSelectAutocomplete = (item: AccommodationSearchItem) => { const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
const cleanTitle = cleanSearchTitle(item.title); setInputValue(item.title.replace(/<[^>]*>/g, ''));
setInputValue(cleanTitle); setSelectedItem(item);
setSelectedItem({ ...item, title: cleanTitle });
setShowAutocomplete(false); setShowAutocomplete(false);
setAutocompleteResults([]); setAutocompleteResults([]);
setHighlightedIndex(-1); setHighlightedIndex(-1);
@ -135,20 +136,18 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
}; };
const handleStart = () => { const handleStart = () => {
const trimmed = inputValue.trim(); if (!inputValue.trim()) {
if (!trimmed) { setLocalError(searchType === 'url' ? t('landing.hero.errorUrlRequired') : t('landing.hero.errorNameRequired'));
setLocalError(t('landing.hero.errorInputRequired'));
return; return;
} }
if (isValidUrl(trimmed)) { if (searchType === 'url' && !isValidUrl(inputValue.trim())) {
setLocalError(''); setLocalError(t('landing.hero.errorInvalidUrl'));
onAnalyze?.(trimmed, 'url');
return; return;
} }
setLocalError(''); setLocalError('');
if (selectedItem && onAutocomplete) { if (searchType === 'name' && selectedItem && onAutocomplete) {
onAutocomplete({ onAutocomplete({
address: selectedItem.address, address: selectedItem.address,
roadAddress: selectedItem.roadAddress, roadAddress: selectedItem.roadAddress,
@ -159,67 +158,6 @@ 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();
@ -233,30 +171,88 @@ 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"> <div className="hero-dropdown-container" ref={dropdownRef}>
<div className="hero-dropdown-trigger"> <button
<span className="hero-dropdown-main"> type="button"
{t('landing.hero.searchTypeLabel')} className="hero-dropdown-trigger"
</span> onClick={() => setIsDropdownOpen(!isDropdownOpen)}
>
<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) => handleChange(e.target.value)} onChange={(e) => {
onPaste={handlePaste} let value = e.target.value;
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 (autocompleteResults.length > 0) setShowAutocomplete(true); if (searchType === 'name' && 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={t('landing.hero.placeholderCombined')} placeholder={getPlaceholder()}
className={`hero-input ${inputValue ? 'has-value' : ''}`} className={`hero-input ${inputValue ? 'has-value' : ''}`}
/> />
@ -266,8 +262,12 @@ 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 && ( {showAutocomplete && searchType === 'name' && (
<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">{t('landing.hero.guideCombined')}</span> <span className="hero-input-hint">{getGuideText()}</span>
{error && <p className="hero-error">{error}</p>} {error && <p className="hero-error">{error}</p>}
@ -328,21 +328,27 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
{t('landing.hero.analyzeButton')} {t('landing.hero.analyzeButton')}
</button> </button>
<div className="hero-divider"><span></span></div> {onShortform && (
<button
<div className="hero-manual-card" onClick={handleManualButtonClick} role="button" tabIndex={0} type="button"
onKeyDown={(e) => e.key === 'Enter' && handleManualButtonClick()}> className="url-input-shortform-btn"
<span className="hero-manual-card-title">{t('landing.hero.searchTypeManual')}</span> onClick={onShortform}
<p className="hero-manual-button-desc">{t('landing.hero.manualButtonDesc')}</p> >
</div> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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, category) => { onSubmit={(businessName, address) => {
setIsManualModalOpen(false); setIsManualModalOpen(false);
onManualInput?.(businessName, address, category); onManualInput?.(businessName, address);
}} }}
/> />
)} )}

View File

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

View File

@ -45,35 +45,7 @@ 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 t('videoDetail.dateFormat', { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() }); return `${date.getFullYear()}${date.getMonth() + 1}${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 ?? t('videoDetail.kakaoDefaultTitle'), title: video?.store_name ?? 'ADO2 영상',
description: t('videoDetail.kakaoDescription', { region: video?.region ?? '' }), description: `${video?.region ?? ''} · ADO2 AI 마케팅 영상`,
imageUrl: 'https://ado2.o2osolution.ai/favicon_48.svg', imageUrl: 'https://demo.castad.net/favicon_48.svg',
link: { mobileWebUrl: shareUrl, webUrl: shareUrl }, link: { mobileWebUrl: shareUrl, webUrl: shareUrl },
}, },
buttons: [{ title: t('videoDetail.kakaoButtonTitle'), link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }], buttons: [{ title: '영상 보기', 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' }}>{t('videoDetail.deletedComment')}</span>; if (isDeleted) return <span style={{ color: '#6B9EA0', fontStyle: 'italic' }}>( .)</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={t('videoDetail.closeAriaLabel')}> <button className="video-detail-close-btn" onClick={handleHeaderAction} aria-label="닫기">
<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 ? t('videoDetail.copied') : t('videoDetail.share')} {copied ? '복사됨!' : '공유하기'}
</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>
{t('videoDetail.shareTwitter')} X ()
</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 ? t('videoDetail.copied') : t('videoDetail.copyUrl')} {copied ? '복사됨!' : 'URL 복사'}
</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">{t('videoDetail.commentsTitle')}</h3> <h3 className="video-detail-comments-title"></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={t('videoDetail.changeAvatarTitle')} alt="아바타 변경"
className="video-detail-comment-avatar" className="video-detail-comment-avatar"
onClick={handleChangeAvatar} onClick={handleChangeAvatar}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
title={t('videoDetail.changeAvatarTitle')} title="클릭하여 아바타 변경"
/> />
<input <input
className="video-detail-nickname-input" className="video-detail-nickname-input"
type="text" type="text"
placeholder={t('videoDetail.nicknamePlaceholder')} placeholder="작성자 이름"
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 ? t('videoDetail.commentPlaceholder') : t('videoDetail.commentLoginRequired')} placeholder={authed ? '댓글을 입력하세요...' : '로그인 후 댓글을 작성할 수 있습니다'}
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 ? t('videoDetail.commentSubmitting') : t('videoDetail.commentSubmit')} {commentSubmitting ? '작성 중' : '작성'}
</button> </button>
</div> </div>
{comments.length === 0 && !commentsLoading ? ( {comments.length === 0 && !commentsLoading ? (
<p className="video-detail-comments-empty">{t('videoDetail.noComments')}</p> <p className="video-detail-comments-empty"> .</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 || t('videoDetail.anonymous')} {c.nickname || '익명'}
</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 || t('videoDetail.anonymous')} {r.nickname || '익명'}
</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 ? t('videoDetail.loadingComments') : t('videoDetail.loadMoreComments')} {commentsLoading ? '불러오는 중...' : '댓글 더 보기'}
</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: '800px', maxWidth: '900px',
margin: 'auto', margin: 'auto',
position: 'relative', position: 'relative',
}} }}

View File

@ -1,53 +0,0 @@
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,7 +2,6 @@
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,10 +1,4 @@
{ {
"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",
@ -34,7 +28,8 @@
"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." },
"field": { "title": "Enter Search Term", "desc": "Paste a Naver Maps share URL,\nor type a business name and select from the list." }, "dropdown": { "title": "Choose Search Type", "desc": "Select URL or business name from the dropdown." },
"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." }
}, },
@ -47,18 +42,12 @@
"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." }, "generate": { "title": "Generate Sound", "desc": "Click the button and AI will generate lyrics and music in your chosen genre and language." },
"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." },
@ -71,6 +60,12 @@
"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." },
@ -128,7 +123,7 @@
"privacyPrivate": "Private", "privacyPrivate": "Private",
"publishTimeLabel": "Publish Time", "publishTimeLabel": "Publish Time",
"publishNow": "Publish Now", "publishNow": "Publish Now",
"publishSchedule": "Schedule", "publishSchedule": "Schedule (Coming Soon)",
"footerNote": "", "footerNote": "",
"footerNoteLink": "", "footerNoteLink": "",
"cancel": "Cancel", "cancel": "Cancel",
@ -142,29 +137,7 @@
"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",
@ -186,29 +159,29 @@
}, },
"landing": { "landing": {
"hero": { "hero": {
"searchTypeLabel": "Business Name | URL", "searchTypeBusinessName": "Business Name",
"placeholderCombined": "Enter a business name or Naver Maps/Place URL", "placeholderBusinessName": "Enter a business name",
"guideCombined": "Paste a Naver Maps share URL, or enter a business name to automatically retrieve information.", "guideUrl": "Search for a place on Naver Maps, tap Share,\nthen paste the URL here.",
"errorInputRequired": "Please enter a business name or URL.", "guideBusinessName": "Search by business name to retrieve information.",
"errorInvalidUrl": "Invalid URL format. (e.g., https://naver.me/abcdef)", "errorUrlRequired": "Please enter a URL.",
"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": "Not on Naver? Enter manually", "searchTypeManual": "Manual Input",
"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",
"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",
@ -238,6 +211,8 @@
"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.",
@ -248,12 +223,10 @@
"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",
@ -269,7 +242,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 1 min)", "uploading": "Uploading... (30 sec 2 min)",
"nextStep": "Next Step" "nextStep": "Next Step"
}, },
"soundStudio": { "soundStudio": {
@ -282,7 +255,7 @@
"genreLabel": "Select Genre", "genreLabel": "Select Genre",
"genreAuto": "Auto Select", "genreAuto": "Auto Select",
"genreBallad": "Ballad", "genreBallad": "Ballad",
"languageLabel": "Select Language", "languageLabel": "Language",
"languageKorean": "Korean", "languageKorean": "Korean",
"lyricsColumn": "Lyrics", "lyricsColumn": "Lyrics",
"lyricsHint": "Select the lyrics area to edit", "lyricsHint": "Select the lyrics area to edit",
@ -296,8 +269,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... (3060 sec)", "generatingLyrics": "Generating lyrics...\n(3060 sec)",
"generatingSong": "Generating music... (12 min)", "generatingSong": "Generating music...\n(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.",
@ -311,7 +284,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": "Top Up Credits" "chargeCredits": "Purchase credits"
}, },
"completion": { "completion": {
"back": "Go Back", "back": "Go Back",
@ -365,7 +338,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" "uploadToSocial": "Upload to Social"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@ -524,7 +497,7 @@
"delete": "Delete", "delete": "Delete",
"previous": "Previous", "previous": "Previous",
"next": "Next", "next": "Next",
"uploadToSocial": "Upload", "uploadToSocial": "Upload to social media",
"sortLatest": "Latest", "sortLatest": "Latest",
"sortOldest": "Oldest", "sortOldest": "Oldest",
"sortLikes": "Most Liked", "sortLikes": "Most Liked",
@ -553,9 +526,9 @@
"marketPositioning": "Market Positioning", "marketPositioning": "Market Positioning",
"coreValue": "Core Value", "coreValue": "Core Value",
"categoryDefinition": "Category Definition", "categoryDefinition": "Category Definition",
"targetPersona": "Key Customer Type", "targetPersona": "Target Persona",
"ageSuffix": "years old", "ageSuffix": "years old",
"sellingPoints": "Key Selling Points", "sellingPoints": "Unique Selling Points (USP)",
"recommendedKeywords": "Recommended Target Keywords", "recommendedKeywords": "Recommended Target Keywords",
"generateContent": "Generate Content", "generateContent": "Generate Content",
"pageDescBefore": " reveals ", "pageDescBefore": " reveals ",
@ -596,7 +569,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",
"myContents": "My Contents", "ado2Contents": "ADO2 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?",
@ -608,33 +581,7 @@
}, },
"loginPrompt": { "loginPrompt": {
"title": "Login Required", "title": "Login Required",
"loginBtn": "Login" "loginBtn": "Login with Kakao"
},
"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,10 +1,4 @@
{ {
"wizardSteps": {
"brandAnalysis": "분석",
"asset": "이미지",
"sound": "사운드",
"video": "영상"
},
"header": { "header": {
"loginFailedAlert": "로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.", "loginFailedAlert": "로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.",
"start": "시작하기", "start": "시작하기",
@ -34,7 +28,8 @@
"finish": "완료", "finish": "완료",
"landing": { "landing": {
"intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요." }, "intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요." },
"field": { "title": "입력하기", "desc": "네이버 지도 공유 URL을 붙여넣거나,\n업체명을 입력하면 자동으로 목록에서 선택할 수 있어요." }, "dropdown": { "title": "검색 방식 선택", "desc": "드롭다운에서 URL 또는 업체명 중 원하는 방식을 선택하세요." },
"field": { "title": "입력하기", "desc": "URL 방식이라면 네이버 지도 공유 URL,\n업체명 방식이라면 업체명을 입력하고 목록에서 선택하세요." },
"manual": { "title": "직접 입력", "desc": "업체명과 주소를 직접 입력해서 분석을 시작할 수도 있어요." }, "manual": { "title": "직접 입력", "desc": "업체명과 주소를 직접 입력해서 분석을 시작할 수도 있어요." },
"button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." } "button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." }
}, },
@ -142,29 +137,7 @@
"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 업로드",
@ -182,32 +155,33 @@
"close": "닫기", "close": "닫기",
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.", "doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.",
"goToCalendar": "캘린더에서 확인", "goToCalendar": "캘린더에서 확인",
"scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다." }, "scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다."
},
"landing": { "landing": {
"hero": { "hero": {
"searchTypeLabel": "업체명 | URL", "searchTypeBusinessName": "업체명",
"placeholderCombined": "업체명 또는 URL을 입력하세요.", "placeholderBusinessName": "업체명을 입력하세요.",
"guideCombined": "네이버 지도 공유 URL을 붙여넣거나,\n업체명을 입력하면 자동으로 정보를 가져옵니다.", "guideUrl": "네이버지도에서 장소를 검색하고 공유 선택, \n나오는 URL을 붙여 넣어 주세요.",
"errorInputRequired": "업체명 또는 URL을 입력해주세요.", "guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
"errorInvalidUrl": "올바른 URL 형식이 아닙니다. (예: https://naver.me/abcdef)", "errorUrlRequired": "URL을 입력해주세요.",
"errorNameRequired": "업체명을 입력해주세요.",
"errorInvalidUrl": "올바른 URL 형식이 아닙니다. (예: https://example.com)",
"analyzeButton": "브랜드 분석", "analyzeButton": "브랜드 분석",
"scrollMore": "스크롤하여 더 보기", "scrollMore": "스크롤하여 더 보기",
"testDataLoading": "로딩 중...",
"testData": "테스트 데이터",
"testDataLoadFailed": "테스트 데이터를 불러오는데 실패했습니다.",
"searching": "검색 중...", "searching": "검색 중...",
"searchTypeManual": "네이버에 없는 업체인가요? 직접 입력하기", "searchTypeManual": "직접 입력",
"manualButtonDesc": "네이버 지도/플레이스에 등록되지 않은 업체도\n업체명·지역·상세주소만 입력하면\n가사·음악·영상 제작까지 가능합니다.", "manualModalTitle": "업체 정보 입력",
"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에 오신 것을 환영합니다.",
@ -237,6 +211,8 @@
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.", "guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
"searchButton": "검색하기", "searchButton": "검색하기",
"searching": "검색 중...", "searching": "검색 중...",
"testDataLoading": "로딩 중...",
"testData": "테스트 데이터",
"searchTypeManual": "직접 입력", "searchTypeManual": "직접 입력",
"errorSelectFromList": "목록에서 업체를 선택해 주세요.", "errorSelectFromList": "목록에서 업체를 선택해 주세요.",
"errorUrlRequired": "URL을 입력해주세요.", "errorUrlRequired": "URL을 입력해주세요.",
@ -247,12 +223,10 @@
"manualLabelAddress": "주소", "manualLabelAddress": "주소",
"manualLabelRegion": "지역", "manualLabelRegion": "지역",
"manualLabelDetail": "상세 주소", "manualLabelDetail": "상세 주소",
"manualLabelCategory": "업종",
"manualPlaceholderName": "업체명을 입력하세요.", "manualPlaceholderName": "업체명을 입력하세요.",
"manualPlaceholderAddress": "주소를 입력하세요.", "manualPlaceholderAddress": "주소를 입력하세요.",
"manualPlaceholderRegion": "지역을 선택하세요.", "manualPlaceholderRegion": "지역을 선택하세요.",
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)", "manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)"
"manualPlaceholderCategory": "업종을 입력하세요. (예: 펜션, 카페, 미용실)"
}, },
"assetManagement": { "assetManagement": {
"title": "브랜드 에셋", "title": "브랜드 에셋",
@ -268,7 +242,7 @@
"back": "뒤로가기", "back": "뒤로가기",
"loadMore": "더보기", "loadMore": "더보기",
"uploadFailed": "이미지 업로드에 실패했습니다.", "uploadFailed": "이미지 업로드에 실패했습니다.",
"uploading": "업로드 중 (30~60초 소요)", "uploading": "업로드 중 (30~2분 소요)",
"nextStep": "다음 단계" "nextStep": "다음 단계"
}, },
"soundStudio": { "soundStudio": {
@ -364,7 +338,7 @@
"noLyricsBGM": "배경음악은 가사 없이 생성됩니다.", "noLyricsBGM": "배경음악은 가사 없이 생성됩니다.",
"downloading": "다운로드 중...", "downloading": "다운로드 중...",
"download": "다운로드", "download": "다운로드",
"uploadToSocial": "업로드" "uploadToSocial": "소셜 채널 업로드"
}, },
"dashboard": { "dashboard": {
"title": "대시보드", "title": "대시보드",
@ -523,7 +497,7 @@
"delete": "삭제", "delete": "삭제",
"previous": "이전", "previous": "이전",
"next": "다음", "next": "다음",
"uploadToSocial": "업로드", "uploadToSocial": "소셜 미디어에 업로드",
"sortLatest": "최신순", "sortLatest": "최신순",
"sortOldest": "오래된순", "sortOldest": "오래된순",
"sortLikes": "좋아요 많은순", "sortLikes": "좋아요 많은순",
@ -595,7 +569,7 @@
"loading": "불러오는 중...", "loading": "불러오는 중...",
"noResults": "최근 결과 없음", "noResults": "최근 결과 없음",
"noResultsDesc": "제작한 콘텐츠를 소셜 채널에 업로드해 보세요", "noResultsDesc": "제작한 콘텐츠를 소셜 채널에 업로드해 보세요",
"myContents": "내 콘텐츠", "ado2Contents": "ADO2 콘텐츠",
"cancel": "취소", "cancel": "취소",
"retry": "재시도", "retry": "재시도",
"cancelConfirm": "예약을 취소하시겠습니까?", "cancelConfirm": "예약을 취소하시겠습니까?",
@ -609,32 +583,6 @@
"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);
}); });
}, 200); }, 100);
return () => { return () => {
if (intervalRef.current) clearInterval(intervalRef.current); if (intervalRef.current) clearInterval(intervalRef.current);
}; };

View File

@ -5,7 +5,6 @@ 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;
@ -182,14 +181,16 @@ 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>
<ContentCardSocialActions <span className="content-card-like">
videoId={video.video_id} <svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
storeName={video.store_name} <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"/>
region={video.region} </svg>
commentCount={video.comment_count ?? 0} {video.like_count ?? 0}
initialLikeCount={video.like_count ?? 0} <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: '6px' }}>
initialIsLiked={video.is_liked_by_me} <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>
{video.comment_count ?? 0}
</span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -26,7 +26,6 @@ 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);
@ -44,23 +43,15 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
}; };
const getImageSrc = (item: ImageItem): string => { const getImageSrc = (item: ImageItem): string => {
return item.type === 'url' ? item.preview_url : item.preview; return item.type === 'url' ? item.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')
@ -72,23 +63,13 @@ 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);
} }
}; };
@ -123,29 +104,6 @@ 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 && (
@ -187,7 +145,6 @@ 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

@ -0,0 +1,21 @@
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,22 +500,19 @@ 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 sections: { tag: string | null; lines: string[] }[] = []; const outro = lines.slice(-1);
lines.forEach((line: string) => { const body = lines.slice(0, -1);
const tagMatch = line.trim().match(/^\[(.+)\]$/); const half = Math.ceil(body.length / 2);
if (tagMatch) { const sections = [
sections.push({ tag: `[${tagMatch[1]}]`, lines: [] }); { tag: '[Verse]', lines: body.slice(0, half) },
} else if (sections.length === 0) { { tag: '[Chorus]', lines: body.slice(half) },
sections.push({ tag: null, lines: [line] }); { tag: '[Outro]', lines: outro },
} else { ].filter(s => s.lines.length > 0);
sections[sections.length - 1].lines.push(line);
}
});
return ( return (
<div className="comp2-lyrics-paragraphs"> <div className="comp2-lyrics-paragraphs">
{sections.filter(s => s.lines.length > 0).map((section, i) => ( {sections.map((section, i) => (
<div key={i} className="comp2-lyrics-para-section"> <div key={i} className="comp2-lyrics-para-section">
{section.tag && <span className="comp2-lyrics-tag">{section.tag}</span>} <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?.(' 콘텐츠')} onClick={() => onNavigate?.('ADO2 콘텐츠')}
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.myContents')} {t('contentCalendar.ado2Contents')}
</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: 10, display: 'flex', flexDirection: 'column' }}> <div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 100, display: 'flex', flexDirection: 'column' }}>
<div style={{ <div style={{
backgroundColor: '#01393b', backgroundColor: '#01393b',
borderTop: '1px solid #046266', borderTop: '1px solid #046266',

View File

@ -2,6 +2,7 @@
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';
@ -14,12 +15,11 @@ 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, type ImageListItem, CrawlingResponse, UserMeResponse } from '../../types/api'; import { ImageItem, 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,9 +52,10 @@ interface BusinessInfo {
interface GenerationFlowProps { interface GenerationFlowProps {
onHome: () => void; onHome: () => void;
initialActiveItem?: string; initialActiveItem?: string;
initialImageList?: ImageListItem[]; initialImageList?: string[];
businessInfo?: BusinessInfo; businessInfo?: BusinessInfo;
initialAnalysisData?: CrawlingResponse | null; initialAnalysisData?: CrawlingResponse | null;
initialShortform?: boolean;
} }
// 위저드 단계: // 위저드 단계:
@ -70,7 +71,8 @@ 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에서 저장된 상태 복원
@ -95,6 +97,9 @@ 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) 단계로 저장된 경우:
@ -169,9 +174,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(item => ({ type: 'url' as const, url: item.original, preview_url: item.preview })); return analysisData.image_list.map(url => ({ type: 'url', url }));
} }
return initialImageList.map(item => ({ type: 'url' as const, url: item.original, preview_url: item.preview })); return initialImageList.map(url => ({ type: 'url', url }));
}; };
const [imageList, setImageList] = useState<ImageItem[]>(getInitialImageList()); const [imageList, setImageList] = useState<ImageItem[]>(getInitialImageList());
@ -183,7 +188,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(item => ({ type: 'url' as const, url: item.original, preview_url: item.preview }))); setImageList(analysisData.image_list.map(url => ({ type: 'url', url })));
} }
} }
prevAnalysisMIdRef.current = analysisData?.m_id; prevAnalysisMIdRef.current = analysisData?.m_id;
@ -263,13 +268,13 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
}; };
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출 // 업체명·주소 수동 입력으로 마케팅 분석 API 호출
const handleManualInput = async (businessName: string, address: string, category: string) => { const handleManualInput = async (businessName: string, address: string) => {
goToWizardStep(-1); goToWizardStep(-1);
setIsAnalysisComplete(false); setIsAnalysisComplete(false);
setAnalysisError(null); setAnalysisError(null);
try { try {
const data = await marketingAnalysis(businessName, address, category); const data = await marketingAnalysis(businessName, address);
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;
@ -289,25 +294,7 @@ 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) => {
@ -420,6 +407,13 @@ 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 (
@ -427,6 +421,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
onAnalyze={handleStartAnalysis} onAnalyze={handleStartAnalysis}
onAutocomplete={handleAutocomplete} onAutocomplete={handleAutocomplete}
onManualInput={handleManualInput} onManualInput={handleManualInput}
onShortform={() => goToWizardStep(-3)}
error={analysisError} error={analysisError}
/> />
); );
@ -483,7 +478,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
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}
@ -539,22 +533,12 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
case '내 정보': case '내 정보':
return <MyInfoContent initialTab={myInfoInitialTab} />; return <MyInfoContent initialTab={myInfoInitialTab} />;
case '새 프로젝트 만들기': case '새 프로젝트 만들기':
// 로딩(-1), URL 입력(-2)은 스텝퍼 없이 전체 화면으로 표시 // 브랜드 분석(0), 로딩(-1), 숏폼(-3)은 전체 화면으로 표시
if (wizardStep === -1 || wizardStep === -2) { if (wizardStep === 0 || wizardStep === -1 || wizardStep === -3) {
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>
); );
@ -646,7 +630,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
<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 bg-[#001a1c] ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'}`}> <div className={`flex-1 relative pl-0 md:pl-0 ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'}`}>
{renderContent()} {renderContent()}
</div> </div>
</div> </div>

View File

@ -5,7 +5,6 @@ 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';
@ -279,14 +278,16 @@ 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>
<ContentCardSocialActions <span className="content-card-like">
videoId={video.video_id} <svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
storeName={video.store_name} <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"/>
region={video.region} </svg>
commentCount={video.comment_count ?? 0} {video.like_count ?? 0}
initialLikeCount={video.like_count ?? 0} <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: '6px' }}>
initialIsLiked={video.is_liked_by_me} <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>
{video.comment_count ?? 0}
</span>
</div> </div>
</div> </div>

View File

@ -0,0 +1,478 @@
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, useMemo } from 'react'; import React, { useState, useRef, useEffect } 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,7 +16,6 @@ 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;
@ -27,46 +26,6 @@ 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') ||
@ -95,7 +54,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
businessInfo, businessInfo,
imageTaskId, imageTaskId,
mId, mId,
industry = '',
onStatusChange, onStatusChange,
videoGenerationStatus = 'idle', videoGenerationStatus = 'idle',
videoGenerationProgress = 0, videoGenerationProgress = 0,
@ -105,8 +63,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);
@ -117,40 +75,20 @@ 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 || '알 수 없음',
// '자동 선택'처럼 UI에서 고른 값이 아니라, 실제 영상 생성에 사용된 genre(usedGenre)를 저장 genre: selectedGenre === '자동 선택' ? 'K-POP' : selectedGenre,
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(() => {
@ -190,18 +128,6 @@ 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);
@ -232,13 +158,20 @@ 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: effectiveGenre, genre: genreMap[selectedGenre] || 'pop',
...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: currentLyrics }), ...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: currentLyrics }),
}); });
@ -259,6 +192,41 @@ 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;
@ -318,9 +286,6 @@ 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'));
@ -331,9 +296,6 @@ 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);
@ -344,20 +306,26 @@ 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. 가사 생성 상태 폴링 → 완료 시 상세 조회
@ -382,16 +350,13 @@ 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: effectiveGenre, genre: genreMap[selectedGenre] || 'pop',
...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: songLyrics }), ...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: songLyrics }),
}); });
@ -437,24 +402,6 @@ 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);
@ -548,7 +495,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
))} ))}
</div> </div>
<div className="genre-row"> <div className="genre-row">
{['JAZZ', 'ROCK', 'POP'].map(genre => ( {['JAZZ', 'ROCK'].map(genre => (
<button <button
key={genre} key={genre}
onClick={() => setSelectedGenre(genre)} onClick={() => setSelectedGenre(genre)}
@ -558,6 +505,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
{genre} {genre}
</button> </button>
))} ))}
<div className="genre-btn-placeholder"></div>
</div> </div>
</div> </div>
</div> </div>
@ -667,8 +615,9 @@ 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
@ -684,16 +633,27 @@ 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">
{lyricsSections.map((section, i) => ( {sections.map((section, i) => (
<div key={i} className="lyrics-section"> <div key={i} className="lyrics-section">
{section.tag && <span className="lyrics-tag">{section.tag}</span>} <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,14 +5,16 @@ 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, category: string) => void; onManualInput?: (businessName: string, address: string) => void;
onShortform?: () => void;
error: string | null; error: string | null;
} }
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onManualInput, error }) => { const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onManualInput, onShortform, 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>
@ -21,6 +23,7 @@ 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,14 +1,4 @@
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 {
@ -33,31 +23,14 @@ const orbConfigs: OrbConfig[] = [
]; ];
interface HeroSectionProps { interface HeroSectionProps {
onAnalyze?: (value: string, type?: SearchType) => void; onLogin?: () => 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> = ({ onAnalyze, onAutocomplete, onManualInput, onNext, error: externalError, scrollProgress = 0 }) => { const HeroSection: React.FC<HeroSectionProps> = ({ onLogin, onNext, 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(() => {
@ -145,56 +118,20 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
className="hero-logo" className="hero-logo"
/> />
<SearchInputForm <button className="hero-login-btn" onClick={onLogin}>
onAnalyze={onAnalyze}
onAutocomplete={onAutocomplete} </button>
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">{t('landing.hero.scrollMore')}</span> <span className="scroll-indicator-text"> </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,13 +1,11 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
const SocialConnectError: React.FC = () => { const SocialConnectError: React.FC = () => {
const { t } = useTranslation(); // 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 errorMessage = searchParams.get('error') || t('social.connectError.defaultError'); const errorMessage = searchParams.get('error') || '알 수 없는 오류가 발생했습니다.';
const platformName = platform === 'youtube' ? 'YouTube' : const platformName = platform === 'youtube' ? 'YouTube' :
platform === 'instagram' ? 'Instagram' : platform === 'instagram' ? 'Instagram' :
@ -27,19 +25,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">{t('social.connectError.title', { platform: platformName })}</h1> <h1 className="social-connect-title">{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,6 +26,7 @@ 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 = {
@ -41,6 +42,7 @@ 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 = '/';
}; };
@ -62,6 +64,7 @@ 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}
@ -78,13 +81,15 @@ const SocialConnectSuccess: React.FC = () => {
)} )}
<h1 className="social-connect-title"> <h1 className="social-connect-title">
{t('social.connectSuccess.title', { name: channelName || platformName })} {channelName || platformName}
</h1> </h1>
<p className="social-connect-message"> <p className="social-connect-message">
{channelName {channelName ? (
? t('social.connectSuccess.messageWithChannel', { platform: platformName }) <>{platformName} .</>
: t('social.connectSuccess.messageWithoutChannel')} ) : (
<> .</>
)}
</p> </p>
{channelName && ( {channelName && (
@ -93,20 +98,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">
{t('social.connectSuccess.countdown', { count: countdown })} {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,11 +1,9 @@
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>('');
@ -20,7 +18,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(t('social.youtubeCallback.errorAuthCancelled')); setErrorMessage('Google 인증이 취소되었거나 실패했습니다.');
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);
@ -30,7 +28,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(t('social.youtubeCallback.errorNoCode')); setErrorMessage('인증 코드를 받지 못했습니다.');
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);
@ -48,7 +46,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(t('social.youtubeCallback.errorProcessing')); setErrorMessage('YouTube 연결 처리 중 오류가 발생했습니다.');
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);
@ -68,8 +66,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">{t('social.youtubeCallback.connecting')}</h1> <h1 className="social-connect-title">YouTube ...</h1>
<p className="social-connect-message">{t('social.youtubeCallback.pleaseWait')}</p> <p className="social-connect-message"> .</p>
</> </>
) : ( ) : (
<> <>
@ -80,7 +78,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">{t('social.youtubeCallback.failed')}</h1> <h1 className="social-connect-title"> </h1>
<p className="social-connect-message">{errorMessage}</p> <p className="social-connect-message">{errorMessage}</p>
</> </>
)} )}

View File

@ -1,947 +0,0 @@
/* =====================================================
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

@ -1,503 +0,0 @@
/* =====================================================
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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +0,0 @@
/* 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';

File diff suppressed because it is too large Load Diff

View File

@ -1,593 +0,0 @@
/* =====================================================
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

File diff suppressed because it is too large Load Diff

View File

@ -1,74 +0,0 @@
/* =====================================================
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,21 +32,14 @@ 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: ImageListItem[] | null; image_list: string[];
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;
} }
@ -54,8 +47,7 @@ export interface CrawlingResponse {
// URL 이미지 (서버에서 가져온 이미지) // URL 이미지 (서버에서 가져온 이미지)
export interface UrlImage { export interface UrlImage {
type: 'url'; type: 'url';
url: string; // 업로드용 원본 URL url: string;
preview_url: string; // 미리보기용 CDN 리사이즈 URL
} }
// 업로드된 파일 이미지 // 업로드된 파일 이미지
@ -75,10 +67,8 @@ 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가 추천)
} }
// 가사 생성 응답 // 가사 생성 응답
@ -108,7 +98,6 @@ 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;
} }
@ -218,6 +207,58 @@ 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,6 +34,11 @@ 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';
@ -557,6 +562,85 @@ 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
// ============================================ // ============================================
@ -844,16 +928,6 @@ 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[];
@ -911,7 +985,7 @@ export async function autocomplete(request: AutocompleteRequest): Promise<Crawli
} }
// 업체명·주소 직접 입력으로 마케팅 분석 // 업체명·주소 직접 입력으로 마케팅 분석
export async function marketingAnalysis(storeName: string, address: string, category = ''): Promise<CrawlingResponse> { export async function marketingAnalysis(storeName: string, address: string): Promise<CrawlingResponse> {
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT); const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT);
@ -921,7 +995,7 @@ export async function marketingAnalysis(storeName: string, address: string, cate
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ store_name: storeName, address, category }), body: JSON.stringify({ store_name: storeName, address }),
signal: controller.signal, signal: controller.signal,
}); });