로컬라이제이션 적용 (영어 / 한국어 )
parent
29fdb7e65c
commit
ad6f9e09a1
|
|
@ -9,7 +9,8 @@
|
|||
"Bash(python3:*)",
|
||||
"mcp__figma__get_figma_data",
|
||||
"mcp__figma__download_figma_images",
|
||||
"Bash(npx tsc:*)"
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(grep:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
92
index.css
92
index.css
|
|
@ -765,6 +765,87 @@
|
|||
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: 11px;
|
||||
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);
|
||||
}
|
||||
|
||||
/* Logout Button */
|
||||
.logout-btn {
|
||||
width: 100%;
|
||||
|
|
@ -6125,6 +6206,17 @@
|
|||
object-fit: contain;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-actions .lang-toggle {
|
||||
max-width: 100px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.header-login-btn {
|
||||
padding: 10px 24px;
|
||||
border-radius: 999px;
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@
|
|||
"name": "castad---marketing-automation",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"i18next": "^25.8.4",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3"
|
||||
"react-dom": "^19.2.3",
|
||||
"react-i18next": "^16.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
|
|
@ -253,6 +255,15 @@
|
|||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||
|
|
@ -1385,6 +1396,47 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "25.8.4",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.4.tgz",
|
||||
"integrity": "sha512-a9A0MnUjKvzjEN/26ZY1okpra9kA8MEwzYEz1BNm+IyxUKPRH6ihf0p7vj8YvULwZHKHl3zkJ6KOt4hewxBecQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://locize.com"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://locize.com/i18next.html"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
|
@ -1533,6 +1585,33 @@
|
|||
"react": "^19.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "16.5.4",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.4.tgz",
|
||||
"integrity": "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 25.6.2",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||
|
|
@ -1632,8 +1711,9 @@
|
|||
"version": "5.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -1680,6 +1760,15 @@
|
|||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
|
|
@ -1756,6 +1845,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18next": "^25.8.4",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3"
|
||||
"react-dom": "^19.2.3",
|
||||
"react-i18next": "^16.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
|
|
|
|||
18
src/App.tsx
18
src/App.tsx
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Header from './components/Header';
|
||||
import HeroSection from './pages/Landing/HeroSection';
|
||||
import WelcomeSection from './pages/Landing/WelcomeSection';
|
||||
|
|
@ -43,6 +44,7 @@ const initializeOnNewSession = () => {
|
|||
initializeOnNewSession();
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLElement>(null);
|
||||
|
||||
// localStorage에서 저장된 상태 복원 (새 세션이면 이미 초기화됨)
|
||||
|
|
@ -148,7 +150,7 @@ const App: React.FC = () => {
|
|||
}
|
||||
} catch (err) {
|
||||
console.error('Token callback failed:', err);
|
||||
alert('로그인 처리에 실패했습니다. 다시 시도해주세요.');
|
||||
alert(t('app.loginFailed'));
|
||||
} finally {
|
||||
setIsProcessingCallback(false);
|
||||
}
|
||||
|
|
@ -162,7 +164,7 @@ const App: React.FC = () => {
|
|||
window.location.href = response.redirect_url;
|
||||
} catch (err) {
|
||||
console.error('Kakao callback failed:', err);
|
||||
alert('카카오 로그인에 실패했습니다. 다시 시도해주세요.');
|
||||
alert(t('app.kakaoLoginFailed'));
|
||||
|
||||
// URL에서 code 파라미터 제거
|
||||
const url = new URL(window.location.href);
|
||||
|
|
@ -265,7 +267,7 @@ const App: React.FC = () => {
|
|||
|
||||
// 응답 유효성 검사
|
||||
if (!validateCrawlingResponse(data)) {
|
||||
throw new Error('유효하지 않은 URL입니다. 네이버 지도 URL을 입력해주세요.');
|
||||
throw new Error(t('app.invalidUrl'));
|
||||
}
|
||||
|
||||
setAnalysisData(data);
|
||||
|
|
@ -273,7 +275,7 @@ const App: React.FC = () => {
|
|||
setViewMode('analysis');
|
||||
} catch (err) {
|
||||
console.error('Crawling failed:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : '분석 중 오류가 발생했습니다. 다시 시도해주세요.';
|
||||
const errorMessage = err instanceof Error ? err.message : t('app.analysisError');
|
||||
setError(errorMessage);
|
||||
setViewMode('landing');
|
||||
}
|
||||
|
|
@ -289,7 +291,7 @@ const App: React.FC = () => {
|
|||
|
||||
// 응답 유효성 검사
|
||||
if (!validateCrawlingResponse(data)) {
|
||||
throw new Error('업체 정보를 가져오는데 실패했습니다. 다시 시도해주세요.');
|
||||
throw new Error(t('app.autocompleteError'));
|
||||
}
|
||||
|
||||
setAnalysisData(data);
|
||||
|
|
@ -297,7 +299,7 @@ const App: React.FC = () => {
|
|||
setViewMode('analysis');
|
||||
} catch (err) {
|
||||
console.error('Autocomplete failed:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : '업체 정보 조회 중 오류가 발생했습니다. 다시 시도해주세요.';
|
||||
const errorMessage = err instanceof Error ? err.message : t('app.autocompleteGeneralError');
|
||||
setError(errorMessage);
|
||||
setViewMode('landing');
|
||||
}
|
||||
|
|
@ -325,7 +327,7 @@ const App: React.FC = () => {
|
|||
window.location.href = response.auth_url;
|
||||
} catch (err) {
|
||||
console.error('Failed to get Kakao login URL:', err);
|
||||
alert('로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.');
|
||||
alert(t('app.loginUrlFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -364,7 +366,7 @@ const App: React.FC = () => {
|
|||
return (
|
||||
<div className="login-container" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ textAlign: 'center', color: '#fff' }}>
|
||||
<p style={{ fontSize: '18px' }}>로그인 처리 중...</p>
|
||||
<p style={{ fontSize: '18px' }}>{t('app.loginProcessing')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const Footer: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<footer className="landing-footer">
|
||||
<div className="footer-content">
|
||||
|
|
@ -10,11 +12,11 @@ const Footer: React.FC = () => {
|
|||
<p className="footer-copyright">Copyright ⓒ O2O Inc. All rights reserved</p>
|
||||
</div>
|
||||
<div className="footer-right">
|
||||
<p className="footer-info">사업자 등록번호 : 620-87-00810 | 대표 : 안성민</p>
|
||||
<p className="footer-info">본사 : 41593 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호</p>
|
||||
<p className="footer-info">연구소 : 13453 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)</p>
|
||||
<p className="footer-info">전화 : 070-4260-8310 | 010-2755-6463</p>
|
||||
<p className="footer-info">이메일 : o2oteam@o2o.kr</p>
|
||||
<p className="footer-info">{t('footer.businessNumber')}</p>
|
||||
<p className="footer-info">{t('footer.headquarters')}</p>
|
||||
<p className="footer-info">{t('footer.researchCenter')}</p>
|
||||
<p className="footer-info">{t('footer.phone')}</p>
|
||||
<p className="footer-info">{t('footer.email')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getKakaoLoginUrl, isLoggedIn } from '../utils/api';
|
||||
import LanguageSwitch from './LanguageSwitch';
|
||||
|
||||
interface HeaderProps {
|
||||
onStartClick?: () => void;
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({ onStartClick }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const loggedIn = isLoggedIn();
|
||||
|
||||
|
|
@ -16,11 +19,10 @@ const Header: React.FC<HeaderProps> = ({ onStartClick }) => {
|
|||
setIsLoading(true);
|
||||
try {
|
||||
const response = await getKakaoLoginUrl();
|
||||
// 카카오 로그인 페이지로 리다이렉트
|
||||
window.location.href = response.auth_url;
|
||||
} catch (err) {
|
||||
console.error('Failed to get Kakao login URL:', err);
|
||||
alert('로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.');
|
||||
alert(t('header.loginFailedAlert'));
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
|
@ -36,22 +38,25 @@ const Header: React.FC<HeaderProps> = ({ onStartClick }) => {
|
|||
<div className="header-logo">
|
||||
<img src="/assets/images/ado2-header-logo.svg" alt="ADO2" />
|
||||
</div>
|
||||
{loggedIn ? (
|
||||
<button
|
||||
className="header-start-btn"
|
||||
onClick={handleStart}
|
||||
>
|
||||
시작하기
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="header-login-btn"
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? '로딩...' : '로그인'}
|
||||
</button>
|
||||
)}
|
||||
<div className="header-actions">
|
||||
<LanguageSwitch />
|
||||
{loggedIn ? (
|
||||
<button
|
||||
className="header-start-btn"
|
||||
onClick={handleStart}
|
||||
>
|
||||
{t('header.start')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="header-login-btn"
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? t('header.loading') : t('header.login')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface LanguageSwitchProps {
|
||||
isCollapsed?: boolean;
|
||||
}
|
||||
|
||||
const LanguageSwitch: React.FC<LanguageSwitchProps> = ({ isCollapsed = false }) => {
|
||||
const { i18n } = useTranslation();
|
||||
const isEnglish = i18n.language === 'en';
|
||||
|
||||
const switchTo = (lang: string) => {
|
||||
if (i18n.language !== lang) {
|
||||
i18n.changeLanguage(lang);
|
||||
}
|
||||
};
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<button
|
||||
className="lang-toggle-collapsed"
|
||||
onClick={() => switchTo(isEnglish ? 'ko' : 'en')}
|
||||
title={isEnglish ? '한국어로 전환' : 'Switch to English'}
|
||||
>
|
||||
{isEnglish ? 'EN' : 'KO'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="lang-toggle">
|
||||
<button
|
||||
className={`lang-toggle-option ${!isEnglish ? 'active' : ''}`}
|
||||
onClick={() => switchTo('ko')}
|
||||
>
|
||||
KO
|
||||
</button>
|
||||
<button
|
||||
className={`lang-toggle-option ${isEnglish ? 'active' : ''}`}
|
||||
onClick={() => switchTo('en')}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
<div className={`lang-toggle-slider ${isEnglish ? 'right' : 'left'}`} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSwitch;
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UserMeResponse } from '../types/api';
|
||||
import { logout } from '../utils/api';
|
||||
import LanguageSwitch from './LanguageSwitch';
|
||||
|
||||
interface SidebarItemProps {
|
||||
icon: React.ReactNode;
|
||||
|
|
@ -36,6 +38,7 @@ interface SidebarProps {
|
|||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userInfo, onLogout }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isMobileOpen, setIsMobileOpen] = useState(false);
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
|
|
@ -76,11 +79,11 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
|
|||
};
|
||||
|
||||
const menuItems = [
|
||||
{ id: '대시보드', label: '대시보드', disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg> },
|
||||
{ id: '새 프로젝트 만들기', label: '새 프로젝트 만들기', disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> },
|
||||
{ id: 'ADO2 콘텐츠', label: 'ADO2 콘텐츠', disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> },
|
||||
{ id: '내 콘텐츠', label: '내 콘텐츠', disabled: true, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> },
|
||||
{ id: '내 정보', label: '내 정보', disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> },
|
||||
{ id: '대시보드', label: t('sidebar.dashboard'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg> },
|
||||
{ id: '새 프로젝트 만들기', label: t('sidebar.newProject'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> },
|
||||
{ id: 'ADO2 콘텐츠', label: t('sidebar.ado2Contents'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> },
|
||||
{ id: '내 콘텐츠', label: t('sidebar.myContents'), disabled: true, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> },
|
||||
{ id: '내 정보', label: t('sidebar.myInfo'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> },
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -145,6 +148,10 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
|
|||
</div>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<div className="sidebar-language-switch">
|
||||
<LanguageSwitch isCollapsed={isCollapsed} />
|
||||
</div>
|
||||
|
||||
<div className={`profile-section ${isCollapsed ? 'collapsed' : ''}`}>
|
||||
{userInfo?.profile_image_url || userInfo?.thumbnail_image_url ? (
|
||||
<img
|
||||
|
|
@ -162,7 +169,7 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
|
|||
)}
|
||||
{!isCollapsed && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="profile-name">{userInfo?.nickname || '사용자'}</p>
|
||||
<p className="profile-name">{userInfo?.nickname || t('sidebar.defaultUser')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -175,7 +182,7 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
|
|||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
{!isCollapsed && <span className="logout-btn-label">{isLoggingOut ? '로그아웃 중...' : '로그아웃'}</span>}
|
||||
{!isCollapsed && <span className="logout-btn-label">{isLoggingOut ? t('sidebar.loggingOut') : t('sidebar.logout')}</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getSocialAccounts, uploadToSocial, waitForUploadComplete, TokenExpiredError, handleSocialReconnect } from '../utils/api';
|
||||
import { SocialAccount, VideoListItem, SocialUploadStatusResponse } from '../types/api';
|
||||
import UploadProgressModal, { UploadStatus } from './UploadProgressModal';
|
||||
|
|
@ -30,6 +31,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||
onClose,
|
||||
video
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
|
||||
const [selectedChannel, setSelectedChannel] = useState<string>('');
|
||||
const [title, setTitle] = useState('');
|
||||
|
|
@ -93,7 +95,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||
}
|
||||
} catch (error) {
|
||||
if (error instanceof TokenExpiredError) {
|
||||
alert('YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다.');
|
||||
alert(t('social.youtubeExpiredAlert'));
|
||||
handleSocialReconnect(error.reconnectUrl);
|
||||
return;
|
||||
}
|
||||
|
|
@ -105,20 +107,20 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||
|
||||
const handlePost = async () => {
|
||||
if (!selectedChannel || !title.trim() || !video) {
|
||||
alert('채널과 제목을 입력해주세요.');
|
||||
alert(t('social.channelAndTitleRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedAcc = socialAccounts.find(acc => acc.platform_user_id === selectedChannel);
|
||||
if (!selectedAcc) {
|
||||
alert('채널을 선택해주세요.');
|
||||
alert(t('social.selectChannel'));
|
||||
return;
|
||||
}
|
||||
|
||||
// video.video_id 검증 - 반드시 존재해야 함
|
||||
if (!video.video_id) {
|
||||
console.error('Video object missing video_id:', video);
|
||||
alert('영상 정보가 올바르지 않습니다. (video_id 누락)');
|
||||
alert(t('social.invalidVideoInfo'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -157,7 +159,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||
const uploadResponse = await uploadToSocial(requestPayload);
|
||||
|
||||
if (!uploadResponse.success) {
|
||||
throw new Error(uploadResponse.message || '업로드 시작에 실패했습니다.');
|
||||
throw new Error(uploadResponse.message || t('social.uploadStartFailed'));
|
||||
}
|
||||
|
||||
// Poll for upload completion
|
||||
|
|
@ -180,13 +182,13 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||
} catch (error) {
|
||||
if (error instanceof TokenExpiredError) {
|
||||
setShowUploadProgress(false);
|
||||
alert('YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다.');
|
||||
alert(t('social.youtubeExpiredAlert'));
|
||||
handleSocialReconnect(error.reconnectUrl);
|
||||
return;
|
||||
}
|
||||
console.error('Upload failed:', error);
|
||||
setUploadStatus('failed');
|
||||
setUploadErrorMessage(error instanceof Error ? error.message : '업로드에 실패했습니다.');
|
||||
setUploadErrorMessage(error instanceof Error ? error.message : t('social.uploadFailed'));
|
||||
} finally {
|
||||
setIsPosting(false);
|
||||
}
|
||||
|
|
@ -221,9 +223,9 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||
const selectedAccount = socialAccounts.find(acc => acc.platform_user_id === selectedChannel);
|
||||
|
||||
const privacyOptions = [
|
||||
{ value: 'public', label: '공개' },
|
||||
{ value: 'unlisted', label: '미등록 (링크로만 접근)' },
|
||||
{ value: 'private', label: '비공개' }
|
||||
{ value: 'public', label: t('social.privacyPublic') },
|
||||
{ value: 'unlisted', label: t('social.privacyUnlisted') },
|
||||
{ value: 'private', label: t('social.privacyPrivate') }
|
||||
];
|
||||
|
||||
const selectedPrivacyOption = privacyOptions.find(opt => opt.value === privacy);
|
||||
|
|
@ -253,7 +255,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||
<div className="social-posting-modal" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="social-posting-header">
|
||||
<h2 className="social-posting-title">소셜 미디어 포스팅</h2>
|
||||
<h2 className="social-posting-title">{t('social.title')}</h2>
|
||||
<button className="social-posting-close" onClick={handleClose}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
|
|
@ -280,7 +282,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||
<div className="social-posting-form">
|
||||
{/* Video Info */}
|
||||
<div className="social-posting-video-info">
|
||||
<span className="social-posting-label-badge">게시물 1</span>
|
||||
<span className="social-posting-label-badge">{t('social.postNumber')}</span>
|
||||
<span className="social-posting-add-btn">+</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -288,20 +290,20 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||
<p className="social-posting-video-title">
|
||||
{video.store_name} {new Date(video.created_at).toLocaleString('ko-KR')}
|
||||
</p>
|
||||
<p className="social-posting-video-specs">1080x1920 · 10초</p>
|
||||
<p className="social-posting-video-specs">{t('social.videoSpecs')}</p>
|
||||
</div>
|
||||
|
||||
{/* Channel Selector - Custom Dropdown */}
|
||||
<div className="social-posting-field">
|
||||
<label className="social-posting-label">
|
||||
게시 채널 <span className="required">*</span>
|
||||
{t('social.channelLabel')} <span className="required">*</span>
|
||||
</label>
|
||||
{isLoadingAccounts ? (
|
||||
<div className="social-posting-loading">계정 로딩 중...</div>
|
||||
<div className="social-posting-loading">{t('social.loadingAccounts')}</div>
|
||||
) : socialAccounts.length === 0 ? (
|
||||
<div className="social-posting-no-accounts">
|
||||
<p>연결된 소셜 계정이 없습니다.</p>
|
||||
<p className="social-posting-no-accounts-hint">내 정보에서 계정을 연결해주세요.</p>
|
||||
<p>{t('social.noAccounts')}</p>
|
||||
<p className="social-posting-no-accounts-hint">{t('social.noAccountsHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="social-posting-channel-dropdown" ref={channelDropdownRef}>
|
||||
|
|
@ -355,13 +357,13 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||
{/* Title */}
|
||||
<div className="social-posting-field">
|
||||
<label className="social-posting-label">
|
||||
게시물 제목 <span className="required">*</span>
|
||||
{t('social.postTitleLabel')} <span className="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="게시물 제목을 입력하세요."
|
||||
placeholder={t('social.postTitlePlaceholder')}
|
||||
className="social-posting-input"
|
||||
maxLength={100}
|
||||
/>
|
||||
|
|
@ -370,11 +372,11 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||
|
||||
{/* Description */}
|
||||
<div className="social-posting-field">
|
||||
<label className="social-posting-label">게시물 내용</label>
|
||||
<label className="social-posting-label">{t('social.postContentLabel')}</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="게시물 내용을 입력하세요."
|
||||
placeholder={t('social.postContentPlaceholder')}
|
||||
className="social-posting-textarea"
|
||||
maxLength={5000}
|
||||
rows={4}
|
||||
|
|
@ -384,12 +386,12 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||
|
||||
{/* Tags */}
|
||||
<div className="social-posting-field">
|
||||
<label className="social-posting-label">태그</label>
|
||||
<label className="social-posting-label">{t('social.tagsLabel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="태그를 입력하세요. (쉼표로 구분)"
|
||||
placeholder={t('social.tagsPlaceholder')}
|
||||
className="social-posting-input"
|
||||
maxLength={500}
|
||||
/>
|
||||
|
|
@ -399,7 +401,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||
{/* Privacy - Custom Dropdown */}
|
||||
<div className="social-posting-field">
|
||||
<label className="social-posting-label">
|
||||
게시물 공개 범위 <span className="required">*</span>
|
||||
{t('social.privacyLabel')} <span className="required">*</span>
|
||||
</label>
|
||||
<div className="social-posting-channel-dropdown" ref={privacyDropdownRef}>
|
||||
<button
|
||||
|
|
@ -437,7 +439,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||
{/* Publish Time */}
|
||||
<div className="social-posting-field">
|
||||
<label className="social-posting-label">
|
||||
게시 시간 <span className="required">*</span>
|
||||
{t('social.publishTimeLabel')} <span className="required">*</span>
|
||||
</label>
|
||||
<div className="social-posting-radio-group">
|
||||
<label className="social-posting-radio">
|
||||
|
|
@ -448,7 +450,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||
checked={publishTime === 'now'}
|
||||
onChange={() => setPublishTime('now')}
|
||||
/>
|
||||
<span className="radio-label">지금 게시</span>
|
||||
<span className="radio-label">{t('social.publishNow')}</span>
|
||||
</label>
|
||||
<label className="social-posting-radio">
|
||||
<input
|
||||
|
|
@ -459,7 +461,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||
onChange={() => setPublishTime('schedule')}
|
||||
disabled
|
||||
/>
|
||||
<span className="radio-label disabled">시간 설정 (준비 중)</span>
|
||||
<span className="radio-label disabled">{t('social.publishSchedule')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -469,7 +471,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||
{/* Footer */}
|
||||
<div className="social-posting-footer">
|
||||
<p className="social-posting-footer-note">
|
||||
게시는 <a href="#" className="social-posting-link">요금제 업그레이드</a> 후 가능합니다.
|
||||
{t('social.footerNote', { link: '' })}<a href="#" className="social-posting-link">{t('social.footerNoteLink')}</a>
|
||||
</p>
|
||||
<div className="social-posting-actions">
|
||||
<button
|
||||
|
|
@ -477,14 +479,14 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
|||
onClick={handleClose}
|
||||
disabled={isPosting}
|
||||
>
|
||||
취소
|
||||
{t('social.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="social-posting-btn submit"
|
||||
onClick={handlePost}
|
||||
disabled={isPosting || socialAccounts.length === 0 || !title.trim()}
|
||||
>
|
||||
{isPosting ? '게시 중...' : '게시'}
|
||||
{isPosting ? t('social.posting') : t('social.post')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type UploadStatus = 'pending' | 'uploading' | 'completed' | 'failed';
|
||||
|
||||
|
|
@ -24,20 +25,21 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
|
|||
youtubeUrl,
|
||||
errorMessage,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getStatusText = () => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return '업로드 준비 중...';
|
||||
return t('upload.statusPending');
|
||||
case 'uploading':
|
||||
return '업로드 중...';
|
||||
return t('upload.statusUploading');
|
||||
case 'completed':
|
||||
return '업로드 완료!';
|
||||
return t('upload.statusCompleted');
|
||||
case 'failed':
|
||||
return '업로드 실패';
|
||||
return t('upload.statusFailed');
|
||||
default:
|
||||
return '처리 중...';
|
||||
return t('upload.statusDefault');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -81,7 +83,7 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
|
|||
<div className="upload-progress-modal">
|
||||
{/* Header */}
|
||||
<div className="upload-progress-header">
|
||||
<h2 className="upload-progress-title">YouTube 업로드</h2>
|
||||
<h2 className="upload-progress-title">{t('upload.title')}</h2>
|
||||
{canClose && (
|
||||
<button className="upload-progress-close" onClick={onClose}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
|
|
@ -114,11 +116,11 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
|
|||
{/* Video Info */}
|
||||
<div className="upload-progress-info">
|
||||
<div className="upload-progress-info-row">
|
||||
<span className="upload-progress-label">영상 제목</span>
|
||||
<span className="upload-progress-label">{t('upload.videoTitleLabel')}</span>
|
||||
<span className="upload-progress-value">{videoTitle}</span>
|
||||
</div>
|
||||
<div className="upload-progress-info-row">
|
||||
<span className="upload-progress-label">채널</span>
|
||||
<span className="upload-progress-label">{t('upload.channelLabel')}</span>
|
||||
<span className="upload-progress-value">{channelName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -139,7 +141,7 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
|
|||
className="upload-progress-youtube-link"
|
||||
>
|
||||
<img src="/assets/images/social-youtube.png" alt="YouTube" className="upload-youtube-icon" />
|
||||
YouTube에서 보기
|
||||
{t('upload.viewOnYoutube')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -148,10 +150,10 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
|
|||
<div className="upload-progress-footer">
|
||||
{canClose ? (
|
||||
<button className="upload-progress-btn primary" onClick={onClose}>
|
||||
{status === 'completed' ? '확인' : '닫기'}
|
||||
{status === 'completed' ? t('upload.confirm') : t('upload.close')}
|
||||
</button>
|
||||
) : (
|
||||
<p className="upload-progress-note">업로드가 진행 중입니다. 창을 닫지 마세요.</p>
|
||||
<p className="upload-progress-note">{t('upload.doNotClose')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import ko from '../locales/ko.json';
|
||||
import en from '../locales/en.json';
|
||||
|
||||
const LANGUAGE_KEY = 'castad_language';
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
ko: { translation: ko },
|
||||
en: { translation: en },
|
||||
},
|
||||
lng: localStorage.getItem(LANGUAGE_KEY) || 'ko',
|
||||
fallbackLng: 'ko',
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
|
||||
i18n.on('languageChanged', (lng) => {
|
||||
localStorage.setItem(LANGUAGE_KEY, lng);
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './i18n';
|
||||
import App from './App';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,371 @@
|
|||
{
|
||||
"header": {
|
||||
"loginFailedAlert": "Failed to get login URL. Please try again.",
|
||||
"start": "Get Started",
|
||||
"loading": "Loading...",
|
||||
"login": "Log In"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Dashboard",
|
||||
"newProject": "Create New Project",
|
||||
"ado2Contents": "ADO2 Contents",
|
||||
"myContents": "My Contents",
|
||||
"myInfo": "My Info",
|
||||
"defaultUser": "User",
|
||||
"loggingOut": "Logging out...",
|
||||
"logout": "Log Out"
|
||||
},
|
||||
"footer": {
|
||||
"businessNumber": "Business Registration No. : 620-87-00810 | CEO : Ahn Sungmin",
|
||||
"headquarters": "HQ : 41593 Unicorn Lab Daegu A05, 5F, 111 Oksan-ro, Buk-gu, Daegu, Korea",
|
||||
"researchCenter": "R&D : 13453 Rooms 504-505 (East), KT Pangyo Bldg, 32 Geumto-ro, Sujeong-gu, Seongnam-si, Gyeonggi-do, Korea",
|
||||
"phone": "Tel : 070-4260-8310 | 010-2755-6463",
|
||||
"email": "Email : o2oteam@o2o.kr"
|
||||
},
|
||||
"social": {
|
||||
"title": "Social Media Posting",
|
||||
"postNumber": "Post 1",
|
||||
"videoSpecs": "1080x1920 · 10 sec",
|
||||
"channelLabel": "Post Channel",
|
||||
"loadingAccounts": "Loading accounts...",
|
||||
"noAccounts": "No connected social accounts.",
|
||||
"noAccountsHint": "Please connect an account in My Info.",
|
||||
"postTitleLabel": "Post Title",
|
||||
"postTitlePlaceholder": "Enter a post title.",
|
||||
"postContentLabel": "Post Content",
|
||||
"postContentPlaceholder": "Enter post content.",
|
||||
"tagsLabel": "Tags",
|
||||
"tagsPlaceholder": "Enter tags. (separated by commas)",
|
||||
"privacyLabel": "Post Visibility",
|
||||
"privacyPublic": "Public",
|
||||
"privacyUnlisted": "Unlisted (accessible by link only)",
|
||||
"privacyPrivate": "Private",
|
||||
"publishTimeLabel": "Publish Time",
|
||||
"publishNow": "Publish Now",
|
||||
"publishSchedule": "Schedule (Coming Soon)",
|
||||
"footerNote": "Posting is available after {link}.",
|
||||
"footerNoteLink": "plan upgrade",
|
||||
"cancel": "Cancel",
|
||||
"posting": "Posting...",
|
||||
"post": "Post",
|
||||
"youtubeExpiredAlert": "YouTube authentication has expired. Redirecting to reconnect page.",
|
||||
"channelAndTitleRequired": "Please enter a channel and title.",
|
||||
"selectChannel": "Please select a channel.",
|
||||
"invalidVideoInfo": "Video information is invalid. (missing video_id)",
|
||||
"uploadStartFailed": "Failed to start upload.",
|
||||
"uploadFailed": "Upload failed."
|
||||
},
|
||||
"upload": {
|
||||
"title": "YouTube Upload",
|
||||
"statusPending": "Preparing upload...",
|
||||
"statusUploading": "Uploading...",
|
||||
"statusCompleted": "Upload complete!",
|
||||
"statusFailed": "Upload failed",
|
||||
"statusDefault": "Processing...",
|
||||
"videoTitleLabel": "Video Title",
|
||||
"channelLabel": "Channel",
|
||||
"viewOnYoutube": "View on YouTube",
|
||||
"confirm": "OK",
|
||||
"close": "Close",
|
||||
"doNotClose": "Upload is in progress. Do not close this window."
|
||||
},
|
||||
"landing": {
|
||||
"hero": {
|
||||
"searchTypeBusinessName": "Business Name",
|
||||
"placeholderBusinessName": "Enter a business name",
|
||||
"guideUrl": "A video will be automatically generated from the information gathered from the URL.",
|
||||
"guideBusinessName": "Search by business name to retrieve information.",
|
||||
"errorUrlRequired": "Please enter a URL.",
|
||||
"errorNameRequired": "Please enter a business name.",
|
||||
"errorInvalidUrl": "Invalid URL format. (e.g., https://example.com)",
|
||||
"analyzeButton": "Brand Analysis",
|
||||
"scrollMore": "Scroll to see more",
|
||||
"testDataLoading": "Loading...",
|
||||
"testData": "Test Data",
|
||||
"testDataLoadFailed": "Failed to load test data.",
|
||||
"searching": "Searching..."
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Welcome to ADO2.AI",
|
||||
"subtitle": "Automate the entire content marketing process from analysis, creation, to distribution",
|
||||
"feature1Title": "Business Core Information Analysis",
|
||||
"feature1Desc": "Enter the URL of your homepage,\nNaver Map, blog, etc.",
|
||||
"feature2Title": "Automated Promotional Content Creation",
|
||||
"feature2Desc": "Based on analyzed information,\nautomatically create music, subtitles, songs,\nand videos tailored to your business",
|
||||
"feature3Title": "Multi-Channel Auto Distribution",
|
||||
"feature3Desc": "Completed videos can be downloaded\nor uploaded directly to social media"
|
||||
},
|
||||
"display": {
|
||||
"startButton": "Get Started"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"back": "Go Back",
|
||||
"kakaoLoginFailed": "Kakao login failed. Please try again.",
|
||||
"loginUrlFailed": "Failed to get login URL. Please try again.",
|
||||
"loggingIn": "Logging in...",
|
||||
"kakaoStart": "Start with Kakao"
|
||||
},
|
||||
"urlInput": {
|
||||
"searchTypeBusinessName": "Business Name",
|
||||
"placeholderBusinessName": "Enter a business name",
|
||||
"guideUrl": "A video will be automatically generated from the information gathered from the URL.",
|
||||
"guideBusinessName": "Search by business name to retrieve information.",
|
||||
"searchButton": "Search",
|
||||
"searching": "Searching...",
|
||||
"testDataLoading": "Loading...",
|
||||
"testData": "Test Data"
|
||||
},
|
||||
"assetManagement": {
|
||||
"title": "Brand Assets",
|
||||
"selectedImages": "Selected Images",
|
||||
"imageAlt": "Image",
|
||||
"uploadBadge": "Uploaded",
|
||||
"imageUpload": "Image Upload",
|
||||
"dragAndDrop": "Drag and drop\nimages to upload",
|
||||
"videoRatio": "Video Ratio",
|
||||
"uploadFailed": "Image upload failed.",
|
||||
"uploading": "Uploading...",
|
||||
"nextStep": "Next Step"
|
||||
},
|
||||
"soundStudio": {
|
||||
"back": "Go Back",
|
||||
"title": "Sound Studio",
|
||||
"soundColumn": "Sound",
|
||||
"soundTypeLabel": "Select AI Sound Type",
|
||||
"soundTypeVocal": "Vocal",
|
||||
"soundTypeBGM": "Background Music",
|
||||
"soundTypeNarration": "Voice Narration",
|
||||
"genreLabel": "Select Genre",
|
||||
"genreAuto": "Auto Select",
|
||||
"genreBallad": "Ballad",
|
||||
"languageLabel": "Language",
|
||||
"languageKorean": "Korean",
|
||||
"lyricsColumn": "Lyrics",
|
||||
"lyricsHint": "Select the lyrics area to edit",
|
||||
"lyricsPlaceholder": "Lyrics will be displayed when sound is generated.",
|
||||
"generateButton": "Generate Sound",
|
||||
"generating": "Generating...",
|
||||
"generateVideo": "Generate Video",
|
||||
"videoGenerating": "Generating Video",
|
||||
"noBusinessInfo": "No business information. Please try again.",
|
||||
"noImageUploadInfo": "No image upload information. Please go back to the previous step and try again.",
|
||||
"generatingLyrics": "Generating lyrics...",
|
||||
"generatingSong": "Generating song...",
|
||||
"songQueued": "Song generation queued...",
|
||||
"retryMessage": "Regenerating due to timeout... ({{count}}/{{max}})",
|
||||
"lyricGenerationFailed": "Lyrics generation request failed.",
|
||||
"lyricNotReceived": "Lyrics were not received.",
|
||||
"lyricGenerationError": "Lyrics generation failed. Please try again.",
|
||||
"songGenerationFailed": "Music generation request failed.",
|
||||
"songIdMissing": "song_id was not received from the server.",
|
||||
"musicGenerationFailed": "Music generation failed.",
|
||||
"multipleRetryFailed": "Music generation failed after multiple attempts. Please try again.",
|
||||
"musicGenerationError": "An error occurred during music generation.",
|
||||
"songRegenerationError": "An error occurred during music regeneration."
|
||||
},
|
||||
"completion": {
|
||||
"back": "Go Back",
|
||||
"titleGenerating": "Generating Video",
|
||||
"titleError": "Video Generation Failed",
|
||||
"titleComplete": "Content Creation Complete",
|
||||
"imageAndVideo": "Images & Video",
|
||||
"requestingGeneration": "Requesting video generation...",
|
||||
"generatingVideo": "Generating video...",
|
||||
"processingAfterRefresh": "Processing video... (recovered after refresh)",
|
||||
"generationFailed": "Video generation request failed.",
|
||||
"generationError": "An error occurred during video generation.",
|
||||
"videoUrlMissing": "Video URL was not received.",
|
||||
"generationTimeout": "Video generation timed out. Please try again.",
|
||||
"retry": "Retry",
|
||||
"statusPlanned": "Scheduled",
|
||||
"statusWaiting": "Waiting",
|
||||
"statusTranscribing": "Transcribing",
|
||||
"statusRendering": "Rendering",
|
||||
"statusSucceeded": "Completed",
|
||||
"statusDefault": "Processing...",
|
||||
"aiOptimization": "AI Optimization",
|
||||
"aiTagColorCorrection": "Color Correction",
|
||||
"aiTagDynamicSubtitle": "Dynamic Subtitles",
|
||||
"aiTagBeatSync": "Beat Sync",
|
||||
"aiTagSEOMeta": "SEO Meta Tags",
|
||||
"sharing": "Share",
|
||||
"connecting": "Connecting...",
|
||||
"authenticated": "Verified",
|
||||
"disconnect": "Disconnect",
|
||||
"connectAccount": "Connect Account",
|
||||
"comingSoon": "Coming Soon",
|
||||
"youtubeConnectFailed": "Failed to connect YouTube.",
|
||||
"disconnectFailed": "Failed to disconnect.",
|
||||
"deployToSocial": "Deploy to Social Channel",
|
||||
"downloadMp4": "Download MP4 File",
|
||||
"selectSocialChannel": "Please select a social channel to deploy.",
|
||||
"videoNotReady": "The video is not ready yet.",
|
||||
"youtubeUploadMessage": "Uploading video to YouTube channel \"{{channelName}}\".\n\n(Upload feature coming soon)",
|
||||
"deployComingSoon": "The deployment feature for the selected social channel is coming soon.",
|
||||
"youtubeExpiredAlert": "YouTube authentication has expired. Redirecting to reconnect page."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"description": "Check your real-time marketing performance.",
|
||||
"lastUpdated": "Last updated:",
|
||||
"contentPerformance": "Content Performance",
|
||||
"metricImpressions": "Total Impressions",
|
||||
"metricReach": "Reach",
|
||||
"metricLikes": "Likes",
|
||||
"metricComments": "Comments",
|
||||
"metricShares": "Shares",
|
||||
"metricSaves": "Saves",
|
||||
"metricEngagement": "Engagement Rate",
|
||||
"metricContent": "Content",
|
||||
"yearOverYear": "Year-over-Year Growth",
|
||||
"thisYear": "This Year",
|
||||
"lastYear": "Last Year",
|
||||
"popularContent": "Popular Content",
|
||||
"audienceInsights": "Audience Insights",
|
||||
"ageDistribution": "Age Distribution",
|
||||
"genderDistribution": "Gender Distribution",
|
||||
"male": "Male",
|
||||
"female": "Female",
|
||||
"topRegions": "Top Regions",
|
||||
"platformMetrics": "Platform Metrics",
|
||||
"months": {
|
||||
"jan": "Jan",
|
||||
"feb": "Feb",
|
||||
"mar": "Mar",
|
||||
"apr": "Apr",
|
||||
"may": "May",
|
||||
"jun": "Jun",
|
||||
"jul": "Jul",
|
||||
"aug": "Aug",
|
||||
"sep": "Sep",
|
||||
"oct": "Oct",
|
||||
"nov": "Nov",
|
||||
"dec": "Dec"
|
||||
},
|
||||
"youtubeMetrics": {
|
||||
"views": "Views",
|
||||
"watchTime": "Watch Time",
|
||||
"watchTimeUnit": "hours",
|
||||
"avgViewDuration": "Avg. View Duration",
|
||||
"subscribers": "Subscribers",
|
||||
"newSubscribers": "New Subscribers",
|
||||
"engagement": "Engagement Rate",
|
||||
"ctr": "Click-Through Rate (CTR)",
|
||||
"revenue": "Estimated Revenue"
|
||||
},
|
||||
"instagramMetrics": {
|
||||
"reach": "Reach",
|
||||
"impressions": "Impressions",
|
||||
"profileVisits": "Profile Visits",
|
||||
"followers": "Followers",
|
||||
"newFollowers": "New Followers",
|
||||
"storyViews": "Story Views",
|
||||
"reelPlays": "Reel Plays",
|
||||
"websiteClicks": "Website Clicks"
|
||||
},
|
||||
"regions": {
|
||||
"seoul": "Seoul",
|
||||
"gyeonggi": "Gyeonggi",
|
||||
"busan": "Busan",
|
||||
"incheon": "Incheon",
|
||||
"daegu": "Daegu"
|
||||
},
|
||||
"topContentTitles": {
|
||||
"winterPromotion": "Winter Pension Promotion Video",
|
||||
"stayIntroReel": "Stay Meomum Introduction Reel",
|
||||
"newYearEvent": "New Year Special Event",
|
||||
"nightTimelapse": "Pension Night View Timelapse"
|
||||
}
|
||||
},
|
||||
"myInfo": {
|
||||
"title": "My Info",
|
||||
"tabBasic": "Basic Info",
|
||||
"tabPayment": "Payment Info",
|
||||
"tabBusiness": "My Business & Social Channel Management",
|
||||
"basicPlaceholder": "Basic information settings are coming soon.",
|
||||
"paymentPlaceholder": "Payment information settings are coming soon.",
|
||||
"myBusiness": "My Business",
|
||||
"noBusinessTitle": "No registered business yet",
|
||||
"noBusinessDesc": "Enter a Naver Map URL to automatically import the information needed for video production",
|
||||
"naverMapUrlPlaceholder": "Enter Naver Map URL",
|
||||
"registerBusiness": "Register Business",
|
||||
"socialChannels": "Social Channels",
|
||||
"youtubeConnecting": "Connecting...",
|
||||
"youtubeConnect": "Connect YouTube",
|
||||
"instagramConnect": "Connect Instagram",
|
||||
"connected": "Connected",
|
||||
"disconnectAccount": "Disconnect",
|
||||
"loadingAccounts": "Loading account information...",
|
||||
"youtubeExpiredAlert": "YouTube authentication has expired. Redirecting to reconnect page."
|
||||
},
|
||||
"ado2Contents": {
|
||||
"title": "ADO2 Contents",
|
||||
"totalCount": "Total {{count}}",
|
||||
"loading": "Loading contents...",
|
||||
"loadFailed": "Failed to load contents.",
|
||||
"retry": "Retry",
|
||||
"noContent": "No content created yet.",
|
||||
"download": "Download",
|
||||
"downloadFailed": "Download failed.",
|
||||
"deleteFailed": "Deletion failed.",
|
||||
"deleteConfirmTitle": "Are you sure you want to delete this content?",
|
||||
"deleteConfirmDesc": "Deleted files cannot be recovered.",
|
||||
"cancel": "Cancel",
|
||||
"deleting": "Deleting...",
|
||||
"delete": "Delete",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"uploadToSocial": "Upload to social media"
|
||||
},
|
||||
"businessSettings": {
|
||||
"title": "Business Settings",
|
||||
"description": "Set up your pension information and YouTube channel to enable automatic uploads",
|
||||
"sharing": "Share",
|
||||
"connect": "Connect"
|
||||
},
|
||||
"analysis": {
|
||||
"back": "Go Back",
|
||||
"pageTitle": "Brand Intelligence",
|
||||
"pageDescHighlight": "AI Data Analysis",
|
||||
"defaultBrandName": "Brand",
|
||||
"brandIdentity": "Brand Identity",
|
||||
"brandNameFallback": "Brand Name",
|
||||
"addressFallback": "No address information",
|
||||
"locationAnalysis": "Location Feature Analysis",
|
||||
"conceptScalability": "Concept Scalability",
|
||||
"noInfo": "No information",
|
||||
"marketPositioning": "Market Positioning",
|
||||
"coreValue": "Core Value",
|
||||
"categoryDefinition": "Category Definition",
|
||||
"targetPersona": "Target Persona",
|
||||
"ageSuffix": "years old",
|
||||
"sellingPoints": "Unique Selling Points (USP)",
|
||||
"recommendedKeywords": "Recommended Target Keywords",
|
||||
"generateContent": "Generate Content",
|
||||
"pageDescBefore": " reveals ",
|
||||
"pageDescAfter": "'s core strategy.",
|
||||
"loadingTitle": "Analyzing Brand"
|
||||
},
|
||||
"common": {
|
||||
"back": "Go Back",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "OK",
|
||||
"close": "Close",
|
||||
"retry": "Retry",
|
||||
"loading": "Loading...",
|
||||
"required": "*",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"app": {
|
||||
"loginProcessing": "Processing login...",
|
||||
"loginFailed": "Login processing failed. Please try again.",
|
||||
"kakaoLoginFailed": "Kakao login failed. Please try again.",
|
||||
"loginUrlFailed": "Failed to get login URL. Please try again.",
|
||||
"invalidUrl": "Invalid URL. Please enter a Naver Map URL.",
|
||||
"analysisError": "An error occurred during analysis. Please try again.",
|
||||
"autocompleteError": "Failed to retrieve business information.",
|
||||
"autocompleteGeneralError": "An error occurred while retrieving business information. Please try again.",
|
||||
"pageComingSoon": "{{page}} page is coming soon."
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,371 @@
|
|||
{
|
||||
"header": {
|
||||
"loginFailedAlert": "로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.",
|
||||
"start": "시작하기",
|
||||
"loading": "로딩...",
|
||||
"login": "로그인"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "대시보드",
|
||||
"newProject": "새 프로젝트 만들기",
|
||||
"ado2Contents": "ADO2 콘텐츠",
|
||||
"myContents": "내 콘텐츠",
|
||||
"myInfo": "내 정보",
|
||||
"defaultUser": "사용자",
|
||||
"loggingOut": "로그아웃 중...",
|
||||
"logout": "로그아웃"
|
||||
},
|
||||
"footer": {
|
||||
"businessNumber": "사업자 등록번호 : 620-87-00810 | 대표 : 안성민",
|
||||
"headquarters": "본사 : 41593 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호",
|
||||
"researchCenter": "연구소 : 13453 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)",
|
||||
"phone": "전화 : 070-4260-8310 | 010-2755-6463",
|
||||
"email": "이메일 : o2oteam@o2o.kr"
|
||||
},
|
||||
"social": {
|
||||
"title": "소셜 미디어 포스팅",
|
||||
"postNumber": "게시물 1",
|
||||
"videoSpecs": "1080x1920 · 10초",
|
||||
"channelLabel": "게시 채널",
|
||||
"loadingAccounts": "계정 로딩 중...",
|
||||
"noAccounts": "연결된 소셜 계정이 없습니다.",
|
||||
"noAccountsHint": "내 정보에서 계정을 연결해주세요.",
|
||||
"postTitleLabel": "게시물 제목",
|
||||
"postTitlePlaceholder": "게시물 제목을 입력하세요.",
|
||||
"postContentLabel": "게시물 내용",
|
||||
"postContentPlaceholder": "게시물 내용을 입력하세요.",
|
||||
"tagsLabel": "태그",
|
||||
"tagsPlaceholder": "태그를 입력하세요. (쉼표로 구분)",
|
||||
"privacyLabel": "게시물 공개 범위",
|
||||
"privacyPublic": "공개",
|
||||
"privacyUnlisted": "미등록 (링크로만 접근)",
|
||||
"privacyPrivate": "비공개",
|
||||
"publishTimeLabel": "게시 시간",
|
||||
"publishNow": "지금 게시",
|
||||
"publishSchedule": "시간 설정 (준비 중)",
|
||||
"footerNote": "게시는 {link} 후 가능합니다.",
|
||||
"footerNoteLink": "요금제 업그레이드",
|
||||
"cancel": "취소",
|
||||
"posting": "게시 중...",
|
||||
"post": "게시",
|
||||
"youtubeExpiredAlert": "YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다.",
|
||||
"channelAndTitleRequired": "채널과 제목을 입력해주세요.",
|
||||
"selectChannel": "채널을 선택해주세요.",
|
||||
"invalidVideoInfo": "영상 정보가 올바르지 않습니다. (video_id 누락)",
|
||||
"uploadStartFailed": "업로드 시작에 실패했습니다.",
|
||||
"uploadFailed": "업로드에 실패했습니다."
|
||||
},
|
||||
"upload": {
|
||||
"title": "YouTube 업로드",
|
||||
"statusPending": "업로드 준비 중...",
|
||||
"statusUploading": "업로드 중...",
|
||||
"statusCompleted": "업로드 완료!",
|
||||
"statusFailed": "업로드 실패",
|
||||
"statusDefault": "처리 중...",
|
||||
"videoTitleLabel": "영상 제목",
|
||||
"channelLabel": "채널",
|
||||
"viewOnYoutube": "YouTube에서 보기",
|
||||
"confirm": "확인",
|
||||
"close": "닫기",
|
||||
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요."
|
||||
},
|
||||
"landing": {
|
||||
"hero": {
|
||||
"searchTypeBusinessName": "업체명",
|
||||
"placeholderBusinessName": "업체명을 입력하세요",
|
||||
"guideUrl": "URL에서 가져온 정보로 영상이 자동 생성됩니다.",
|
||||
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
|
||||
"errorUrlRequired": "URL을 입력해주세요.",
|
||||
"errorNameRequired": "업체명을 입력해주세요.",
|
||||
"errorInvalidUrl": "올바른 URL 형식이 아닙니다. (예: https://example.com)",
|
||||
"analyzeButton": "브랜드 분석",
|
||||
"scrollMore": "스크롤하여 더 보기",
|
||||
"testDataLoading": "로딩 중...",
|
||||
"testData": "테스트 데이터",
|
||||
"testDataLoadFailed": "테스트 데이터를 불러오는데 실패했습니다.",
|
||||
"searching": "검색 중..."
|
||||
},
|
||||
"welcome": {
|
||||
"title": "ADO2.AI에 오신 것을 환영합니다.",
|
||||
"subtitle": "분석, 제작, 배포까지 콘텐츠 마케팅의 전과정을 자동화",
|
||||
"feature1Title": "비즈니스 핵심 정보 분석",
|
||||
"feature1Desc": "홈페이지, 네이버 지도, 블로그 등의\nURL을 입력하세요",
|
||||
"feature2Title": "홍보 콘텐츠 자동 제작",
|
||||
"feature2Desc": "분석된 정보를 바탕으로\n비즈니스에 맞는 음악, 자막, 노래, 영상을\n자동으로 제작해요",
|
||||
"feature3Title": "멀티채널 자동 배포",
|
||||
"feature3Desc": "완성된 영상은 다운로드하거나\n바로 SNS에 업로드 할 수 있어요"
|
||||
},
|
||||
"display": {
|
||||
"startButton": "시작하기"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"back": "뒤로가기",
|
||||
"kakaoLoginFailed": "카카오 로그인에 실패했습니다. 다시 시도해주세요.",
|
||||
"loginUrlFailed": "로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.",
|
||||
"loggingIn": "로그인 중...",
|
||||
"kakaoStart": "카카오로 시작하기"
|
||||
},
|
||||
"urlInput": {
|
||||
"searchTypeBusinessName": "업체명",
|
||||
"placeholderBusinessName": "업체명을 입력하세요",
|
||||
"guideUrl": "URL에서 가져온 정보로 영상이 자동 생성됩니다.",
|
||||
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
|
||||
"searchButton": "검색하기",
|
||||
"searching": "검색 중...",
|
||||
"testDataLoading": "로딩 중...",
|
||||
"testData": "테스트 데이터"
|
||||
},
|
||||
"assetManagement": {
|
||||
"title": "브랜드 에셋",
|
||||
"selectedImages": "선택된 이미지",
|
||||
"imageAlt": "이미지",
|
||||
"uploadBadge": "업로드",
|
||||
"imageUpload": "이미지 업로드",
|
||||
"dragAndDrop": "이미지를 드래그하여\n업로드",
|
||||
"videoRatio": "영상 비율",
|
||||
"uploadFailed": "이미지 업로드에 실패했습니다.",
|
||||
"uploading": "업로드 중...",
|
||||
"nextStep": "다음 단계"
|
||||
},
|
||||
"soundStudio": {
|
||||
"back": "뒤로가기",
|
||||
"title": "사운드 스튜디오",
|
||||
"soundColumn": "사운드",
|
||||
"soundTypeLabel": "AI 사운드 유형 선택",
|
||||
"soundTypeVocal": "보컬",
|
||||
"soundTypeBGM": "배경음악",
|
||||
"soundTypeNarration": "성우 내레이션",
|
||||
"genreLabel": "장르 선택",
|
||||
"genreAuto": "자동 선택",
|
||||
"genreBallad": "발라드",
|
||||
"languageLabel": "언어",
|
||||
"languageKorean": "한국어",
|
||||
"lyricsColumn": "가사",
|
||||
"lyricsHint": "가사 영역을 선택해서 수정 가능해요",
|
||||
"lyricsPlaceholder": "사운드 생성 시 가사 표시됩니다.",
|
||||
"generateButton": "사운드 생성",
|
||||
"generating": "생성 중...",
|
||||
"generateVideo": "영상 생성하기",
|
||||
"videoGenerating": "영상 생성 중",
|
||||
"noBusinessInfo": "비즈니스 정보가 없습니다. 다시 시도해주세요.",
|
||||
"noImageUploadInfo": "이미지 업로드 정보가 없습니다. 이전 단계로 돌아가 다시 시도해주세요.",
|
||||
"generatingLyrics": "가사를 생성하고 있습니다...",
|
||||
"generatingSong": "노래를 생성하고 있습니다...",
|
||||
"songQueued": "노래 생성 대기 중...",
|
||||
"retryMessage": "시간 초과로 재생성 중... ({{count}}/{{max}})",
|
||||
"lyricGenerationFailed": "가사 생성 요청에 실패했습니다.",
|
||||
"lyricNotReceived": "가사를 받지 못했습니다.",
|
||||
"lyricGenerationError": "가사 생성에 실패했습니다. 다시 시도해주세요.",
|
||||
"songGenerationFailed": "음악 생성 요청에 실패했습니다.",
|
||||
"songIdMissing": "서버에서 song_id를 받지 못했습니다.",
|
||||
"musicGenerationFailed": "음악 생성에 실패했습니다.",
|
||||
"multipleRetryFailed": "여러 번 시도했지만 음악 생성에 실패했습니다. 다시 시도해주세요.",
|
||||
"musicGenerationError": "음악 생성 중 오류가 발생했습니다.",
|
||||
"songRegenerationError": "음악 재생성 중 오류가 발생했습니다."
|
||||
},
|
||||
"completion": {
|
||||
"back": "뒤로가기",
|
||||
"titleGenerating": "영상 생성 중",
|
||||
"titleError": "영상 생성 실패",
|
||||
"titleComplete": "콘텐츠 제작 완료",
|
||||
"imageAndVideo": "이미지 및 영상",
|
||||
"requestingGeneration": "영상 생성을 요청하고 있습니다...",
|
||||
"generatingVideo": "영상을 생성하고 있습니다...",
|
||||
"processingAfterRefresh": "영상을 처리하고 있습니다... (새로고침 후 복구됨)",
|
||||
"generationFailed": "영상 생성 요청에 실패했습니다.",
|
||||
"generationError": "영상 생성 중 오류가 발생했습니다.",
|
||||
"videoUrlMissing": "영상 URL을 받지 못했습니다.",
|
||||
"generationTimeout": "영상 생성 시간이 초과되었습니다. 다시 시도해주세요.",
|
||||
"retry": "다시 시도",
|
||||
"statusPlanned": "예약됨",
|
||||
"statusWaiting": "대기 중",
|
||||
"statusTranscribing": "트랜스크립션 중",
|
||||
"statusRendering": "렌더링 중",
|
||||
"statusSucceeded": "완료",
|
||||
"statusDefault": "처리 중...",
|
||||
"aiOptimization": "AI 최적화",
|
||||
"aiTagColorCorrection": "색상 보정",
|
||||
"aiTagDynamicSubtitle": "다이나믹 자막",
|
||||
"aiTagBeatSync": "비트 싱크",
|
||||
"aiTagSEOMeta": "SEO 메타 태그",
|
||||
"sharing": "공유",
|
||||
"connecting": "연결 중...",
|
||||
"authenticated": "인증됨",
|
||||
"disconnect": "해제",
|
||||
"connectAccount": "계정 연결",
|
||||
"comingSoon": "준비 중",
|
||||
"youtubeConnectFailed": "YouTube 연결에 실패했습니다.",
|
||||
"disconnectFailed": "연결 해제에 실패했습니다.",
|
||||
"deployToSocial": "소셜 채널에 배포",
|
||||
"downloadMp4": "MP4 파일 다운로드",
|
||||
"selectSocialChannel": "배포할 소셜 채널을 선택해주세요.",
|
||||
"videoNotReady": "영상이 아직 준비되지 않았습니다.",
|
||||
"youtubeUploadMessage": "YouTube 채널 \"{{channelName}}\"에 영상을 업로드합니다.\n\n(업로드 기능 준비 중)",
|
||||
"deployComingSoon": "선택한 소셜 채널에 배포 기능이 준비 중입니다.",
|
||||
"youtubeExpiredAlert": "YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "대시보드",
|
||||
"description": "실시간 마케팅 퍼포먼스를 확인하세요.",
|
||||
"lastUpdated": "마지막 업데이트:",
|
||||
"contentPerformance": "콘텐츠 성과",
|
||||
"metricImpressions": "총 노출수",
|
||||
"metricReach": "도달",
|
||||
"metricLikes": "좋아요",
|
||||
"metricComments": "댓글",
|
||||
"metricShares": "공유",
|
||||
"metricSaves": "저장",
|
||||
"metricEngagement": "참여율",
|
||||
"metricContent": "콘텐츠",
|
||||
"yearOverYear": "전년 대비 성장",
|
||||
"thisYear": "올해",
|
||||
"lastYear": "작년",
|
||||
"popularContent": "인기 콘텐츠",
|
||||
"audienceInsights": "오디언스 인사이트",
|
||||
"ageDistribution": "연령대 분포",
|
||||
"genderDistribution": "성별 분포",
|
||||
"male": "남성",
|
||||
"female": "여성",
|
||||
"topRegions": "상위 지역",
|
||||
"platformMetrics": "플랫폼별 지표",
|
||||
"months": {
|
||||
"jan": "1월",
|
||||
"feb": "2월",
|
||||
"mar": "3월",
|
||||
"apr": "4월",
|
||||
"may": "5월",
|
||||
"jun": "6월",
|
||||
"jul": "7월",
|
||||
"aug": "8월",
|
||||
"sep": "9월",
|
||||
"oct": "10월",
|
||||
"nov": "11월",
|
||||
"dec": "12월"
|
||||
},
|
||||
"youtubeMetrics": {
|
||||
"views": "조회수",
|
||||
"watchTime": "시청 시간",
|
||||
"watchTimeUnit": "시간",
|
||||
"avgViewDuration": "평균 시청 시간",
|
||||
"subscribers": "구독자",
|
||||
"newSubscribers": "신규 구독자",
|
||||
"engagement": "참여율",
|
||||
"ctr": "클릭률 (CTR)",
|
||||
"revenue": "예상 수익"
|
||||
},
|
||||
"instagramMetrics": {
|
||||
"reach": "도달",
|
||||
"impressions": "노출",
|
||||
"profileVisits": "프로필 방문",
|
||||
"followers": "팔로워",
|
||||
"newFollowers": "신규 팔로워",
|
||||
"storyViews": "스토리 조회",
|
||||
"reelPlays": "릴스 재생",
|
||||
"websiteClicks": "웹사이트 클릭"
|
||||
},
|
||||
"regions": {
|
||||
"seoul": "서울",
|
||||
"gyeonggi": "경기",
|
||||
"busan": "부산",
|
||||
"incheon": "인천",
|
||||
"daegu": "대구"
|
||||
},
|
||||
"topContentTitles": {
|
||||
"winterPromotion": "겨울 펜션 프로모션 영상",
|
||||
"stayIntroReel": "스테이 머뭄 소개 릴스",
|
||||
"newYearEvent": "신년 특가 이벤트 안내",
|
||||
"nightTimelapse": "펜션 야경 타임랩스"
|
||||
}
|
||||
},
|
||||
"myInfo": {
|
||||
"title": "내 정보",
|
||||
"tabBasic": "기본 정보",
|
||||
"tabPayment": "결제 정보",
|
||||
"tabBusiness": "내 비즈니스 & 소셜 채널 관리",
|
||||
"basicPlaceholder": "기본 정보 설정 기능이 준비 중입니다.",
|
||||
"paymentPlaceholder": "결제 정보 설정 기능이 준비 중입니다.",
|
||||
"myBusiness": "내 비즈니스",
|
||||
"noBusinessTitle": "아직 등록된 비즈니스가 없어요",
|
||||
"noBusinessDesc": "네이버 지도 URL을 입력하면, 영상 제작에 필요한 정보를 자동으로 불러와요",
|
||||
"naverMapUrlPlaceholder": "네이버 지도 URL 입력",
|
||||
"registerBusiness": "비즈니스 등록",
|
||||
"socialChannels": "소셜 채널",
|
||||
"youtubeConnecting": "연결 중...",
|
||||
"youtubeConnect": "YouTube 연결",
|
||||
"instagramConnect": "Instagram 연결",
|
||||
"connected": "연결됨",
|
||||
"disconnectAccount": "연결 해제",
|
||||
"loadingAccounts": "계정 정보를 불러오는 중...",
|
||||
"youtubeExpiredAlert": "YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다."
|
||||
},
|
||||
"ado2Contents": {
|
||||
"title": "ADO2 콘텐츠",
|
||||
"totalCount": "총 {{count}}개",
|
||||
"loading": "콘텐츠를 불러오는 중...",
|
||||
"loadFailed": "콘텐츠를 불러오는데 실패했습니다.",
|
||||
"retry": "다시 시도",
|
||||
"noContent": "생성된 콘텐츠가 없습니다.",
|
||||
"download": "다운로드",
|
||||
"downloadFailed": "다운로드에 실패했습니다.",
|
||||
"deleteFailed": "삭제에 실패했습니다.",
|
||||
"deleteConfirmTitle": "정말 콘텐츠를 삭제할까요?",
|
||||
"deleteConfirmDesc": "삭제한 파일은 복구할 수 없어요.",
|
||||
"cancel": "취소",
|
||||
"deleting": "삭제 중...",
|
||||
"delete": "삭제",
|
||||
"previous": "이전",
|
||||
"next": "다음",
|
||||
"uploadToSocial": "소셜 미디어에 업로드"
|
||||
},
|
||||
"businessSettings": {
|
||||
"title": "비즈니스 설정",
|
||||
"description": "펜션 정보와 YouTube 채널을 설정하여 자동 업로드를 활성화하세요",
|
||||
"sharing": "공유",
|
||||
"connect": "연결하기"
|
||||
},
|
||||
"analysis": {
|
||||
"back": "뒤로가기",
|
||||
"pageTitle": "브랜드 인텔리전스",
|
||||
"pageDescHighlight": "AI 데이터 분석",
|
||||
"defaultBrandName": "브랜드",
|
||||
"brandIdentity": "브랜드 정체성",
|
||||
"brandNameFallback": "브랜드명",
|
||||
"addressFallback": "주소 정보 없음",
|
||||
"locationAnalysis": "입지 특성 분석",
|
||||
"conceptScalability": "컨셉 확장성",
|
||||
"noInfo": "정보 없음",
|
||||
"marketPositioning": "시장 포지셔닝",
|
||||
"coreValue": "핵심 가치 (Core Value)",
|
||||
"categoryDefinition": "카테고리 정의",
|
||||
"targetPersona": "타겟 페르소나",
|
||||
"ageSuffix": "세",
|
||||
"sellingPoints": "주요 셀링 포인트 (USP)",
|
||||
"recommendedKeywords": "추천 타겟 키워드",
|
||||
"generateContent": "콘텐츠 생성",
|
||||
"pageDescBefore": "을 통해 도출된 ",
|
||||
"pageDescAfter": "의 핵심 전략입니다.",
|
||||
"loadingTitle": "브랜드 분석 중"
|
||||
},
|
||||
"common": {
|
||||
"back": "뒤로가기",
|
||||
"cancel": "취소",
|
||||
"confirm": "확인",
|
||||
"close": "닫기",
|
||||
"retry": "다시 시도",
|
||||
"loading": "로딩...",
|
||||
"required": "*",
|
||||
"unknown": "알 수 없음"
|
||||
},
|
||||
"app": {
|
||||
"loginProcessing": "로그인 처리 중...",
|
||||
"loginFailed": "로그인 처리에 실패했습니다. 다시 시도해주세요.",
|
||||
"kakaoLoginFailed": "카카오 로그인에 실패했습니다. 다시 시도해주세요.",
|
||||
"loginUrlFailed": "로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.",
|
||||
"invalidUrl": "유효하지 않은 URL입니다. 네이버 지도 URL을 입력해주세요.",
|
||||
"analysisError": "분석 중 오류가 발생했습니다. 다시 시도해주세요.",
|
||||
"autocompleteError": "업체 정보 조회에 실패했습니다.",
|
||||
"autocompleteGeneralError": "업체 정보 조회 중 오류가 발생했습니다. 다시 시도해주세요.",
|
||||
"pageComingSoon": "{{page}} 페이지 준비 중입니다."
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CrawlingResponse, TargetPersona, SellingPoint } from '../../types/api';
|
||||
|
||||
interface AnalysisResultSectionProps {
|
||||
|
|
@ -276,6 +277,7 @@ const RadarChart: React.FC<RadarChartProps> = ({ data, size = 280 }) => {
|
|||
};
|
||||
|
||||
const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, onGenerate, data }) => {
|
||||
const { t } = useTranslation();
|
||||
const { processed_info, marketing_analysis } = data;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [buttonPosition, setButtonPosition] = useState({ left: 0, width: 0 });
|
||||
|
|
@ -318,7 +320,7 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
|||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
<span>뒤로가기</span>
|
||||
<span>{t('analysis.back')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -327,9 +329,9 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
|||
<div className="bi-page-icon">
|
||||
<img src="/assets/images/star-icon.svg" alt="Star" className="bi-star-icon" />
|
||||
</div>
|
||||
<h1 className="bi-page-title">브랜드 인텔리전스</h1>
|
||||
<h1 className="bi-page-title">{t('analysis.pageTitle')}</h1>
|
||||
<p className="bi-page-desc">
|
||||
<span className="highlight">AI 데이터 분석</span>을 통해 도출된 {processed_info?.customer_name || '브랜드'}의 핵심 전략입니다.
|
||||
<span className="highlight">{t('analysis.pageDescHighlight')}</span>{t('analysis.pageDescBefore')}{processed_info?.customer_name || t('analysis.defaultBrandName')}{t('analysis.pageDescAfter')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -342,27 +344,27 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
|||
<div className="absolute top-0 left-0 w-1.5 h-full bg-gradient-to-b from-[#2dd4bf] to-[#AE72F9]"></div>
|
||||
|
||||
<div className="bi-section-label mb-4">
|
||||
브랜드 정체성
|
||||
{t('analysis.brandIdentity')}
|
||||
</div>
|
||||
|
||||
<h2 className="bi-brand-name">{processed_info?.customer_name || '브랜드명'}</h2>
|
||||
<h2 className="bi-brand-name">{processed_info?.customer_name || t('analysis.brandNameFallback')}</h2>
|
||||
|
||||
<div className="bi-location mb-6">
|
||||
<MapPinIcon />
|
||||
<div>
|
||||
<p>{processed_info?.detail_region_info || '주소 정보 없음'}</p>
|
||||
<p>{processed_info?.detail_region_info || t('analysis.addressFallback')}</p>
|
||||
<p style={{ opacity: 0.7, marginTop: '4px' }}>{processed_info?.region || ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 border-t border-white/5 pt-6">
|
||||
<AnimatedItem index={0} baseDelay={100}>
|
||||
<div className="bi-subsection-title">입지 특성 분석</div>
|
||||
<p className="bi-body-text">{brandIdentity?.location_feature_analysis || '정보 없음'}</p>
|
||||
<div className="bi-subsection-title">{t('analysis.locationAnalysis')}</div>
|
||||
<p className="bi-body-text">{brandIdentity?.location_feature_analysis || t('analysis.noInfo')}</p>
|
||||
</AnimatedItem>
|
||||
<AnimatedItem index={1} baseDelay={100}>
|
||||
<div className="bi-subsection-title">컨셉 확장성</div>
|
||||
<p className="bi-body-text">{brandIdentity?.concept_scalability || '정보 없음'}</p>
|
||||
<div className="bi-subsection-title">{t('analysis.conceptScalability')}</div>
|
||||
<p className="bi-body-text">{brandIdentity?.concept_scalability || t('analysis.noInfo')}</p>
|
||||
</AnimatedItem>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -370,20 +372,20 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
|||
{/* 시장 포지셔닝 카드 */}
|
||||
<div className="bi-card">
|
||||
<h3 className="bi-card-title">
|
||||
시장 포지셔닝
|
||||
{t('analysis.marketPositioning')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<AnimatedItem index={0} baseDelay={200}>
|
||||
<div className="bi-inner-box bi-inner-box-accent">
|
||||
<div className="bi-subsection-title">핵심 가치 (Core Value)</div>
|
||||
<div className="bi-value">{marketPositioning?.core_value || '정보 없음'}</div>
|
||||
<div className="bi-subsection-title">{t('analysis.coreValue')}</div>
|
||||
<div className="bi-value">{marketPositioning?.core_value || t('analysis.noInfo')}</div>
|
||||
</div>
|
||||
</AnimatedItem>
|
||||
<AnimatedItem index={1} baseDelay={200}>
|
||||
<div className="bi-inner-box">
|
||||
<div className="bi-subsection-title" style={{ color: '#6AB0B3' }}>카테고리 정의</div>
|
||||
<div className="bi-value">{marketPositioning?.category_definition || '정보 없음'}</div>
|
||||
<div className="bi-subsection-title" style={{ color: '#6AB0B3' }}>{t('analysis.categoryDefinition')}</div>
|
||||
<div className="bi-value">{marketPositioning?.category_definition || t('analysis.noInfo')}</div>
|
||||
</div>
|
||||
</AnimatedItem>
|
||||
</div>
|
||||
|
|
@ -392,16 +394,16 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
|||
{/* 타겟 페르소나 카드 */}
|
||||
<div className="bi-card">
|
||||
<h3 className="bi-card-title">
|
||||
타겟 페르소나
|
||||
{t('analysis.targetPersona')}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{targetPersonas.length === 0 && <p className="bi-body-text" style={{ color: '#6AB0B3' }}>정보 없음</p>}
|
||||
{targetPersonas.length === 0 && <p className="bi-body-text" style={{ color: '#6AB0B3' }}>{t('analysis.noInfo')}</p>}
|
||||
{targetPersonas.map((persona: TargetPersona, idx: number) => (
|
||||
<AnimatedItem key={idx} index={idx} baseDelay={300}>
|
||||
<div className="bi-inner-box">
|
||||
<div className="mb-4">
|
||||
<div className="bi-persona-name">{persona.persona}</div>
|
||||
<div className="bi-persona-age">{persona.age.min_age}~{persona.age.max_age}세</div>
|
||||
<div className="bi-persona-age">{persona.age.min_age}~{persona.age.max_age}{t('analysis.ageSuffix')}</div>
|
||||
</div>
|
||||
|
||||
{persona.favor_target.length > 0 && (
|
||||
|
|
@ -429,7 +431,7 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
|||
{/* 주요 셀링 포인트 카드 */}
|
||||
<div className="bi-card min-h-[500px] flex flex-col">
|
||||
<h3 className="bi-card-title mb-6">
|
||||
주요 셀링 포인트 (USP)
|
||||
{t('analysis.sellingPoints')}
|
||||
</h3>
|
||||
|
||||
{/* 레이더 차트 */}
|
||||
|
|
@ -450,7 +452,7 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
|||
|
||||
{/* 나머지 USP 리스트 */}
|
||||
<div className="space-y-4 flex-1">
|
||||
{sellingPoints.length === 0 && <p className="bi-body-text" style={{ color: '#6AB0B3' }}>정보 없음</p>}
|
||||
{sellingPoints.length === 0 && <p className="bi-body-text" style={{ color: '#6AB0B3' }}>{t('analysis.noInfo')}</p>}
|
||||
{sellingPoints
|
||||
.filter((usp: SellingPoint) => usp.english_category !== topUSP?.english_category)
|
||||
.map((usp: SellingPoint, idx: number) => (
|
||||
|
|
@ -469,9 +471,9 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
|||
{/* 추천 타겟 키워드 */}
|
||||
<div className="max-w-[1440px] mx-auto px-4 md:px-8 mt-10">
|
||||
<div className="bi-card">
|
||||
<h3 className="bi-card-title">추천 타겟 키워드</h3>
|
||||
<h3 className="bi-card-title">{t('analysis.recommendedKeywords')}</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{targetKeywords.length === 0 && <span className="bi-body-text" style={{ color: '#6AB0B3' }}>정보 없음</span>}
|
||||
{targetKeywords.length === 0 && <span className="bi-body-text" style={{ color: '#6AB0B3' }}>{t('analysis.noInfo')}</span>}
|
||||
{targetKeywords.map((keyword: string, idx: number) => (
|
||||
<span key={idx} className="bi-tag-outline"># {keyword}</span>
|
||||
))}
|
||||
|
|
@ -489,7 +491,7 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
|||
className="pointer-events-auto bg-brand-purple hover:bg-brand-purpleHover text-white font-bold py-3 px-12 rounded-full shadow-2xl shadow-brand-purple/40 transform hover:scale-105 transition-all duration-300 flex items-center gap-2"
|
||||
>
|
||||
<SparklesIcon className="w-5 h-5" />
|
||||
콘텐츠 생성
|
||||
{t('analysis.generateContent')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const LoadingSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="loading-container">
|
||||
<div className="loading-content">
|
||||
|
|
@ -12,7 +14,7 @@ const LoadingSection: React.FC = () => {
|
|||
|
||||
{/* Loading Spinner and Text */}
|
||||
<div className="loading-section">
|
||||
<h2 className="loading-title">브랜드 분석 중</h2>
|
||||
<h2 className="loading-title">{t('analysis.loadingTitle')}</h2>
|
||||
|
||||
<div className="loading-spinner-wrapper">
|
||||
<img
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getVideosList, deleteVideo } from '../../utils/api';
|
||||
import { VideoListItem } from '../../types/api';
|
||||
import SocialPostingModal from '../../components/SocialPostingModal';
|
||||
|
|
@ -9,6 +10,7 @@ interface ADO2ContentsPageProps {
|
|||
}
|
||||
|
||||
const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
||||
const { t } = useTranslation();
|
||||
const [videos, setVideos] = useState<VideoListItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -45,7 +47,7 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
|||
setHasPrev(response.has_prev);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch videos:', err);
|
||||
setError('콘텐츠를 불러오는데 실패했습니다.');
|
||||
setError(t('ado2Contents.loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -84,7 +86,7 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
|||
document.body.removeChild(a);
|
||||
} catch (err) {
|
||||
console.error('Download failed:', err);
|
||||
alert('다운로드에 실패했습니다.');
|
||||
alert(t('ado2Contents.downloadFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -126,7 +128,7 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
|||
setDeleteTargetId(null);
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err);
|
||||
alert('삭제에 실패했습니다.');
|
||||
alert(t('ado2Contents.deleteFailed'));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
|
|
@ -136,24 +138,24 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
|||
<div className="ado2-contents-page">
|
||||
{/* Header */}
|
||||
<div className="ado2-contents-header">
|
||||
<h1 className="ado2-contents-title">ADO2 콘텐츠</h1>
|
||||
<span className="ado2-contents-count">총 {total}개</span>
|
||||
<h1 className="ado2-contents-title">{t('ado2Contents.title')}</h1>
|
||||
<span className="ado2-contents-count">{t('ado2Contents.totalCount', { count: total })}</span>
|
||||
</div>
|
||||
|
||||
{/* Content Grid */}
|
||||
{loading ? (
|
||||
<div className="ado2-contents-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>콘텐츠를 불러오는 중...</p>
|
||||
<p>{t('ado2Contents.loading')}</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="ado2-contents-error">
|
||||
<p>{error}</p>
|
||||
<button onClick={fetchVideos} className="retry-btn">다시 시도</button>
|
||||
<button onClick={fetchVideos} className="retry-btn">{t('ado2Contents.retry')}</button>
|
||||
</div>
|
||||
) : videos.length === 0 ? (
|
||||
<div className="ado2-contents-empty">
|
||||
<p>생성된 콘텐츠가 없습니다.</p>
|
||||
<p>{t('ado2Contents.noContent')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -212,13 +214,13 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
|||
<path d="M10 3v10M10 13l-4-4M10 13l4-4"/>
|
||||
<path d="M3 15v2h14v-2"/>
|
||||
</svg>
|
||||
<span>다운로드</span>
|
||||
<span>{t('ado2Contents.download')}</span>
|
||||
</button>
|
||||
<button
|
||||
className="content-upload-btn"
|
||||
onClick={() => handleUploadClick(video)}
|
||||
disabled={!video.result_movie_url}
|
||||
title="소셜 미디어에 업로드"
|
||||
title={t('ado2Contents.uploadToSocial')}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M10 13V3M10 3l-4 4M10 3l4 4"/>
|
||||
|
|
@ -248,7 +250,7 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
|||
onClick={() => setPage(p => p - 1)}
|
||||
disabled={!hasPrev}
|
||||
>
|
||||
이전
|
||||
{t('ado2Contents.previous')}
|
||||
</button>
|
||||
<span className="pagination-info">{page} / {totalPages}</span>
|
||||
<button
|
||||
|
|
@ -256,7 +258,7 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
|||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={!hasNext}
|
||||
>
|
||||
다음
|
||||
{t('ado2Contents.next')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -266,22 +268,22 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
|||
{deleteModalOpen && (
|
||||
<div className="delete-modal-overlay" onClick={handleDeleteCancel}>
|
||||
<div className="delete-modal" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<h2 className="delete-modal-title">정말 콘텐츠를 삭제할까요?</h2>
|
||||
<p className="delete-modal-description">삭제한 파일은 복구할 수 없어요.</p>
|
||||
<h2 className="delete-modal-title">{t('ado2Contents.deleteConfirmTitle')}</h2>
|
||||
<p className="delete-modal-description">{t('ado2Contents.deleteConfirmDesc')}</p>
|
||||
<div className="delete-modal-actions">
|
||||
<button
|
||||
className="delete-modal-btn cancel"
|
||||
onClick={handleDeleteCancel}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
취소
|
||||
{t('ado2Contents.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="delete-modal-btn confirm"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? '삭제 중...' : '삭제'}
|
||||
{isDeleting ? t('ado2Contents.deleting') : t('ado2Contents.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ImageItem, ImageUrlItem } from '../../types/api';
|
||||
import { uploadImages } from '../../utils/api';
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
|||
onRemoveImage,
|
||||
onAddImages,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const imageListRef = useRef<HTMLDivElement>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
|
@ -65,7 +67,7 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
|||
}
|
||||
} catch (error) {
|
||||
console.error('Image upload failed:', error);
|
||||
setUploadError(error instanceof Error ? error.message : '이미지 업로드에 실패했습니다.');
|
||||
setUploadError(error instanceof Error ? error.message : t('assetManagement.uploadFailed'));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
|
|
@ -109,13 +111,13 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
|||
return (
|
||||
<main className="page-container">
|
||||
{/* Title */}
|
||||
<h1 className="asset-title">브랜드 에셋</h1>
|
||||
<h1 className="asset-title">{t('assetManagement.title')}</h1>
|
||||
|
||||
{/* Main Content Container */}
|
||||
<div className="asset-container">
|
||||
{/* Left Column - Selected Images */}
|
||||
<div className="asset-column asset-column-left">
|
||||
<h3 className="asset-section-title">선택된 이미지</h3>
|
||||
<h3 className="asset-section-title">{t('assetManagement.selectedImages')}</h3>
|
||||
<div
|
||||
ref={imageListRef}
|
||||
onWheel={handleImageListWheel}
|
||||
|
|
@ -127,10 +129,10 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
|||
<div key={i} className="asset-image-item">
|
||||
<img
|
||||
src={getImageSrc(item)}
|
||||
alt={`이미지 ${i + 1}`}
|
||||
alt={`${t('assetManagement.imageAlt')} ${i + 1}`}
|
||||
/>
|
||||
{item.type === 'file' && (
|
||||
<div className="asset-image-badge">업로드</div>
|
||||
<div className="asset-image-badge">{t('assetManagement.uploadBadge')}</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onRemoveImage(i)}
|
||||
|
|
@ -152,7 +154,7 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
|||
<div className="asset-column asset-column-right">
|
||||
{/* Image Upload Section */}
|
||||
<div className="asset-upload-section">
|
||||
<h3 className="asset-section-title">이미지 업로드</h3>
|
||||
<h3 className="asset-section-title">{t('assetManagement.imageUpload')}</h3>
|
||||
<div
|
||||
onClick={handleFileSelect}
|
||||
onDragOver={handleDragOver}
|
||||
|
|
@ -160,7 +162,9 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
|||
className="asset-upload-zone"
|
||||
>
|
||||
<p className="asset-upload-text">
|
||||
이미지를 드래그하여<br/>업로드
|
||||
{t('assetManagement.dragAndDrop').split('\n').map((line, i) => (
|
||||
<React.Fragment key={i}>{i > 0 && <br/>}{line}</React.Fragment>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
|
|
@ -175,7 +179,7 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
|||
|
||||
{/* Video Ratio Section */}
|
||||
<div className="asset-ratio-section">
|
||||
<h3 className="asset-section-title">영상 비율</h3>
|
||||
<h3 className="asset-section-title">{t('assetManagement.videoRatio')}</h3>
|
||||
<div className="asset-ratio-buttons">
|
||||
<button
|
||||
onClick={() => handleVideoRatioChange('vertical')}
|
||||
|
|
@ -210,7 +214,7 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
|
|||
disabled={imageList.length === 0 || isUploading}
|
||||
className="asset-next-button"
|
||||
>
|
||||
{isUploading ? '업로드 중...' : '다음 단계'}
|
||||
{isUploading ? t('assetManagement.uploading') : t('assetManagement.nextStep')}
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -1,31 +1,36 @@
|
|||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const SocialItem: React.FC<{ platform: string; icon: React.ReactNode; color: string }> = ({ platform, icon, color }) => (
|
||||
<div className="social-item">
|
||||
<div className="social-item-left">
|
||||
<div className="social-item-icon" style={{ backgroundColor: color }}>
|
||||
<div className="social-item-icon-inner">{icon}</div>
|
||||
const SocialItem: React.FC<{ platform: string; icon: React.ReactNode; color: string }> = ({ platform, icon, color }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="social-item">
|
||||
<div className="social-item-left">
|
||||
<div className="social-item-icon" style={{ backgroundColor: color }}>
|
||||
<div className="social-item-icon-inner">{icon}</div>
|
||||
</div>
|
||||
<span className="social-item-name">{platform}</span>
|
||||
</div>
|
||||
<span className="social-item-name">{platform}</span>
|
||||
<button className="social-item-connect">{t('businessSettings.connect')}</button>
|
||||
</div>
|
||||
<button className="social-item-connect">연결하기</button>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
const BusinessSettingsContent: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="settings-container">
|
||||
<div className="settings-header">
|
||||
<h1 className="settings-title">비즈니스 설정</h1>
|
||||
<h1 className="settings-title">{t('businessSettings.title')}</h1>
|
||||
<p className="settings-description">
|
||||
펜션 정보와 YouTube 채널을 설정하여 자동 업로드를 활성화하세요
|
||||
{t('businessSettings.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-card">
|
||||
<div className="settings-card-inner">
|
||||
<h2 className="settings-card-title">공유</h2>
|
||||
<h2 className="settings-card-title">{t('businessSettings.sharing')}</h2>
|
||||
|
||||
<div className="social-items">
|
||||
<SocialItem
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { generateVideo, waitForVideoComplete, getYouTubeConnectUrl, getSocialAccounts, disconnectSocialAccount, TokenExpiredError, handleSocialReconnect } from '../../utils/api';
|
||||
import { SocialAccount } from '../../types/api';
|
||||
|
||||
|
|
@ -30,6 +31,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
onVideoStatusChange,
|
||||
onVideoProgressChange
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedSocials, setSelectedSocials] = useState<string[]>([]);
|
||||
const [videoStatus, setVideoStatus] = useState<VideoStatus>('idle');
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||
|
|
@ -123,7 +125,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
|
||||
hasStartedGeneration.current = true;
|
||||
setVideoStatus('generating');
|
||||
setStatusMessage('영상 생성을 요청하고 있습니다...');
|
||||
setStatusMessage(t('completion.requestingGeneration'));
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
|
|
@ -134,11 +136,11 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
const videoResponse = await generateVideo(songTaskId, orientation);
|
||||
|
||||
if (!videoResponse.success) {
|
||||
throw new Error(videoResponse.error_message || '영상 생성 요청에 실패했습니다.');
|
||||
throw new Error(videoResponse.error_message || t('completion.generationFailed'));
|
||||
}
|
||||
|
||||
setVideoStatus('polling');
|
||||
setStatusMessage('영상을 생성하고 있습니다...');
|
||||
setStatusMessage(t('completion.generatingVideo'));
|
||||
// video/status API는 creatomate_render_id를 사용
|
||||
saveToStorage(videoResponse.creatomate_render_id, songTaskId, 'polling', null);
|
||||
|
||||
|
|
@ -147,7 +149,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
} catch (error) {
|
||||
console.error('Video generation failed:', error);
|
||||
setVideoStatus('error');
|
||||
setErrorMessage(error instanceof Error ? error.message : '영상 생성 중 오류가 발생했습니다.');
|
||||
setErrorMessage(error instanceof Error ? error.message : t('completion.generationError'));
|
||||
hasStartedGeneration.current = false;
|
||||
clearStorage();
|
||||
}
|
||||
|
|
@ -157,17 +159,17 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
const getStatusMessage = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'planned':
|
||||
return '예약됨';
|
||||
return t('completion.statusPlanned');
|
||||
case 'waiting':
|
||||
return '대기 중';
|
||||
return t('completion.statusWaiting');
|
||||
case 'transcribing':
|
||||
return '트랜스크립션 중';
|
||||
return t('completion.statusTranscribing');
|
||||
case 'rendering':
|
||||
return '렌더링 중';
|
||||
return t('completion.statusRendering');
|
||||
case 'succeeded':
|
||||
return '완료';
|
||||
return t('completion.statusSucceeded');
|
||||
default:
|
||||
return '처리 중...';
|
||||
return t('completion.statusDefault');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -209,7 +211,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
setStatusMessage('');
|
||||
saveToStorage(videoTaskId, currentSongTaskId, 'complete', videoUrlFromResponse);
|
||||
} else {
|
||||
throw new Error('영상 URL을 받지 못했습니다.');
|
||||
throw new Error(t('completion.videoUrlMissing'));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
|
|
@ -217,10 +219,10 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
|
||||
if (error instanceof Error && error.message === 'TIMEOUT') {
|
||||
setVideoStatus('error');
|
||||
setErrorMessage('영상 생성 시간이 초과되었습니다. 다시 시도해주세요.');
|
||||
setErrorMessage(t('completion.generationTimeout'));
|
||||
} else {
|
||||
setVideoStatus('error');
|
||||
setErrorMessage(error instanceof Error ? error.message : '영상 생성 중 오류가 발생했습니다.');
|
||||
setErrorMessage(error instanceof Error ? error.message : t('completion.generationError'));
|
||||
}
|
||||
|
||||
hasStartedGeneration.current = false;
|
||||
|
|
@ -255,7 +257,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
} else if (savedState.status === 'polling') {
|
||||
// 폴링 중이었던 경우 다시 폴링
|
||||
setVideoStatus('polling');
|
||||
setStatusMessage('영상을 처리하고 있습니다... (새로고침 후 복구됨)');
|
||||
setStatusMessage(t('completion.processingAfterRefresh'));
|
||||
hasStartedGeneration.current = true;
|
||||
pollVideoStatus(savedState.videoTaskId, savedState.songTaskId);
|
||||
}
|
||||
|
|
@ -318,7 +320,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
}
|
||||
} catch (error) {
|
||||
if (error instanceof TokenExpiredError) {
|
||||
alert('YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다.');
|
||||
alert(t('completion.youtubeExpiredAlert'));
|
||||
handleSocialReconnect(error.reconnectUrl);
|
||||
return;
|
||||
}
|
||||
|
|
@ -357,12 +359,12 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
window.location.href = response.auth_url;
|
||||
} catch (error) {
|
||||
if (error instanceof TokenExpiredError) {
|
||||
alert('YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다.');
|
||||
alert(t('completion.youtubeExpiredAlert'));
|
||||
handleSocialReconnect(error.reconnectUrl);
|
||||
return;
|
||||
}
|
||||
console.error('YouTube connect failed:', error);
|
||||
setYoutubeError('YouTube 연결에 실패했습니다.');
|
||||
setYoutubeError(t('completion.youtubeConnectFailed'));
|
||||
setIsYoutubeConnecting(false);
|
||||
}
|
||||
};
|
||||
|
|
@ -377,12 +379,12 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
setSelectedSocials(prev => prev.filter(s => s !== 'Youtube'));
|
||||
} catch (error) {
|
||||
if (error instanceof TokenExpiredError) {
|
||||
alert('YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다.');
|
||||
alert(t('completion.youtubeExpiredAlert'));
|
||||
handleSocialReconnect(error.reconnectUrl);
|
||||
return;
|
||||
}
|
||||
console.error('YouTube disconnect failed:', error);
|
||||
setYoutubeError('연결 해제에 실패했습니다.');
|
||||
setYoutubeError(t('completion.disconnectFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -430,24 +432,24 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
// 소셜 채널에 배포
|
||||
const handleDeploy = () => {
|
||||
if (selectedSocials.length === 0) {
|
||||
alert('배포할 소셜 채널을 선택해주세요.');
|
||||
alert(t('completion.selectSocialChannel'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!videoUrl) {
|
||||
alert('영상이 아직 준비되지 않았습니다.');
|
||||
alert(t('completion.videoNotReady'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 선택된 채널 중 YouTube가 있고 연결된 경우
|
||||
if (selectedSocials.includes('Youtube') && youtubeAccount) {
|
||||
// TODO: YouTube 업로드 API 호출
|
||||
alert(`YouTube 채널 "${youtubeAccount.display_name}"에 영상을 업로드합니다.\n\n(업로드 기능 준비 중)`);
|
||||
alert(t('completion.youtubeUploadMessage', { channelName: youtubeAccount.display_name }));
|
||||
return;
|
||||
}
|
||||
|
||||
// 다른 플랫폼 선택 시
|
||||
alert('선택한 소셜 채널에 배포 기능이 준비 중입니다.');
|
||||
alert(t('completion.deployComingSoon'));
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
|
|
@ -482,20 +484,20 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
<span>뒤로가기</span>
|
||||
<span>{t('completion.back')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="completion-title">
|
||||
{isLoading ? '영상 생성 중' : videoStatus === 'error' ? '영상 생성 실패' : '콘텐츠 제작 완료'}
|
||||
{isLoading ? t('completion.titleGenerating') : videoStatus === 'error' ? t('completion.titleError') : t('completion.titleComplete')}
|
||||
</h1>
|
||||
|
||||
{/* Main Content Container */}
|
||||
<div className="completion-container">
|
||||
{/* Left: Video Preview */}
|
||||
<div className="completion-column completion-column-left video-preview-card">
|
||||
<h3 className="asset-section-title">이미지 및 영상</h3>
|
||||
<h3 className="asset-section-title">{t('completion.imageAndVideo')}</h3>
|
||||
|
||||
<div className="completion-video-wrapper">
|
||||
<div className="video-container">
|
||||
|
|
@ -519,7 +521,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
</svg>
|
||||
<p className="text-gray-400 mb-4">{errorMessage}</p>
|
||||
<button onClick={handleRetry} className="btn-secondary">
|
||||
다시 시도
|
||||
{t('completion.retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : videoUrl ? (
|
||||
|
|
@ -561,12 +563,17 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
{/* AI Optimization Tags - only show when complete */}
|
||||
{videoStatus === 'complete' && (
|
||||
<div className="ai-optimization-section">
|
||||
<h3 className="ai-optimization-title">AI 최적화</h3>
|
||||
<h3 className="ai-optimization-title">{t('completion.aiOptimization')}</h3>
|
||||
<div className="ai-optimization-tags">
|
||||
{['색상 보정', '다이나믹 자막', '비트 싱크', 'SEO 메타 태그'].map(tag => (
|
||||
<div key={tag} className="ai-tag">
|
||||
{[
|
||||
{ key: 'colorCorrection', label: t('completion.aiTagColorCorrection') },
|
||||
{ key: 'dynamicSubtitle', label: t('completion.aiTagDynamicSubtitle') },
|
||||
{ key: 'beatSync', label: t('completion.aiTagBeatSync') },
|
||||
{ key: 'seoMeta', label: t('completion.aiTagSEOMeta') },
|
||||
].map(tag => (
|
||||
<div key={tag.key} className="ai-tag">
|
||||
<div className="ai-tag-dot"></div>
|
||||
<span className="ai-tag-text">{tag}</span>
|
||||
<span className="ai-tag-text">{tag.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -577,7 +584,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
{/* Right: Sharing */}
|
||||
<div className="completion-column completion-column-right sharing-card">
|
||||
<div className="sharing-content">
|
||||
<h3 className="asset-section-title">공유</h3>
|
||||
<h3 className="asset-section-title">{t('completion.sharing')}</h3>
|
||||
|
||||
<div className="social-list-new">
|
||||
{socials.map(social => {
|
||||
|
|
@ -616,14 +623,14 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
{social.isConnecting ? (
|
||||
<span className="completion-social-status connecting">연결 중...</span>
|
||||
<span className="completion-social-status connecting">{t('completion.connecting')}</span>
|
||||
) : social.connected ? (
|
||||
<div className="completion-social-actions">
|
||||
<span className="completion-social-status connected">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
인증됨
|
||||
{t('completion.authenticated')}
|
||||
</span>
|
||||
{isYoutube && (
|
||||
<button
|
||||
|
|
@ -633,13 +640,13 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
}}
|
||||
className="completion-social-disconnect"
|
||||
>
|
||||
해제
|
||||
{t('completion.disconnect')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="completion-social-status not-connected">
|
||||
{isYoutube ? '계정 연결' : '준비 중'}
|
||||
{isYoutube ? t('completion.connectAccount') : t('completion.comingSoon')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -657,7 +664,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
disabled={selectedSocials.length === 0 || videoStatus !== 'complete'}
|
||||
className="btn-completion-deploy"
|
||||
>
|
||||
소셜 채널에 배포
|
||||
{t('completion.deployToSocial')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
|
@ -665,7 +672,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
disabled={videoStatus !== 'complete' || !videoUrl}
|
||||
className="btn-completion-download"
|
||||
>
|
||||
MP4 파일 다운로드
|
||||
{t('completion.downloadMp4')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// =====================================================
|
||||
// Types
|
||||
|
|
@ -263,6 +264,7 @@ const formatNumber = (num: number): string => {
|
|||
};
|
||||
|
||||
const YearOverYearChart: React.FC<{ data: MonthlyData[] }> = ({ data }) => {
|
||||
const { t } = useTranslation();
|
||||
const [tooltip, setTooltip] = useState<TooltipData | null>(null);
|
||||
const [isAnimated, setIsAnimated] = useState(false);
|
||||
|
||||
|
|
@ -420,12 +422,12 @@ const YearOverYearChart: React.FC<{ data: MonthlyData[] }> = ({ data }) => {
|
|||
<div className="chart-tooltip-title">{tooltip.month}</div>
|
||||
<div className="chart-tooltip-row">
|
||||
<span className="chart-tooltip-dot mint"></span>
|
||||
<span className="chart-tooltip-label">올해</span>
|
||||
<span className="chart-tooltip-label">{t('dashboard.thisYear')}</span>
|
||||
<span className="chart-tooltip-value">{formatNumber(tooltip.thisYear)}</span>
|
||||
</div>
|
||||
<div className="chart-tooltip-row">
|
||||
<span className="chart-tooltip-dot purple"></span>
|
||||
<span className="chart-tooltip-label">작년</span>
|
||||
<span className="chart-tooltip-label">{t('dashboard.lastYear')}</span>
|
||||
<span className="chart-tooltip-value">{formatNumber(tooltip.lastYear)}</span>
|
||||
</div>
|
||||
<div className="chart-tooltip-change">
|
||||
|
|
@ -517,6 +519,7 @@ const AudienceBarChart: React.FC<{ data: { label: string; percentage: number }[]
|
|||
};
|
||||
|
||||
const GenderChart: React.FC<{ male: number; female: number; delay?: number }> = ({ male, female, delay = 0 }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isAnimated, setIsAnimated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -535,8 +538,8 @@ const GenderChart: React.FC<{ male: number; female: number; delay?: number }> =
|
|||
</div>
|
||||
</div>
|
||||
<div className="gender-chart-labels">
|
||||
<span className="gender-label male">남성</span>
|
||||
<span className="gender-label female">여성</span>
|
||||
<span className="gender-label male">{t('dashboard.male')}</span>
|
||||
<span className="gender-label female">{t('dashboard.female')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -547,9 +550,97 @@ const GenderChart: React.FC<{ male: number; female: number; delay?: number }> =
|
|||
// =====================================================
|
||||
|
||||
const DashboardContent: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<'youtube' | 'instagram'>('youtube');
|
||||
|
||||
const currentPlatformData = PLATFORM_DATA.find(p => p.platform === selectedPlatform);
|
||||
// Translated content metrics
|
||||
const contentMetrics: ContentMetric[] = [
|
||||
{ id: 'impressions', label: t('dashboard.metricImpressions'), labelEn: 'IMPRESSIONS', value: '2.4M', trend: 12.5, trendDirection: 'up' },
|
||||
{ id: 'reach', label: t('dashboard.metricReach'), labelEn: 'REACH', value: '1.8M', trend: 9.2, trendDirection: 'up' },
|
||||
{ id: 'likes', label: t('dashboard.metricLikes'), labelEn: 'LIKES', value: '158.2K', trend: 8.3, trendDirection: 'up' },
|
||||
{ id: 'comments', label: t('dashboard.metricComments'), labelEn: 'COMMENTS', value: '24.9K', trend: 2.1, trendDirection: 'down' },
|
||||
{ id: 'shares', label: t('dashboard.metricShares'), labelEn: 'SHARES', value: '8.4K', trend: 15.7, trendDirection: 'up' },
|
||||
{ id: 'saves', label: t('dashboard.metricSaves'), labelEn: 'SAVES', value: '12.3K', trend: 22.4, trendDirection: 'up' },
|
||||
{ id: 'engagement', label: t('dashboard.metricEngagement'), labelEn: 'ENGAGEMENT', value: '4.8%', trend: 0.5, trendDirection: 'up' },
|
||||
{ id: 'content', label: t('dashboard.metricContent'), labelEn: 'CONTENT', value: '127', trend: 4, trendDirection: 'up' },
|
||||
];
|
||||
|
||||
// Translated monthly data
|
||||
const monthlyData: MonthlyData[] = [
|
||||
{ month: t('dashboard.months.jan'), thisYear: 180000, lastYear: 145000 },
|
||||
{ month: t('dashboard.months.feb'), thisYear: 195000, lastYear: 158000 },
|
||||
{ month: t('dashboard.months.mar'), thisYear: 210000, lastYear: 172000 },
|
||||
{ month: t('dashboard.months.apr'), thisYear: 185000, lastYear: 168000 },
|
||||
{ month: t('dashboard.months.may'), thisYear: 240000, lastYear: 195000 },
|
||||
{ month: t('dashboard.months.jun'), thisYear: 275000, lastYear: 210000 },
|
||||
{ month: t('dashboard.months.jul'), thisYear: 320000, lastYear: 235000 },
|
||||
{ month: t('dashboard.months.aug'), thisYear: 295000, lastYear: 248000 },
|
||||
{ month: t('dashboard.months.sep'), thisYear: 310000, lastYear: 262000 },
|
||||
{ month: t('dashboard.months.oct'), thisYear: 285000, lastYear: 255000 },
|
||||
{ month: t('dashboard.months.nov'), thisYear: 340000, lastYear: 278000 },
|
||||
{ month: t('dashboard.months.dec'), thisYear: 380000, lastYear: 295000 },
|
||||
];
|
||||
|
||||
// Translated platform data
|
||||
const platformData: PlatformData[] = [
|
||||
{
|
||||
platform: 'youtube',
|
||||
displayName: 'YouTube',
|
||||
metrics: [
|
||||
{ id: 'views', label: t('dashboard.youtubeMetrics.views'), value: '1.25M', trend: 18.2, trendDirection: 'up' },
|
||||
{ id: 'watchTime', label: t('dashboard.youtubeMetrics.watchTime'), value: '4,820', unit: t('dashboard.youtubeMetrics.watchTimeUnit'), trend: 12.5, trendDirection: 'up' },
|
||||
{ id: 'avgViewDuration', label: t('dashboard.youtubeMetrics.avgViewDuration'), value: '3:24', trend: 5.2, trendDirection: 'up' },
|
||||
{ id: 'subscribers', label: t('dashboard.youtubeMetrics.subscribers'), value: '45.2K', trend: 5.8, trendDirection: 'up' },
|
||||
{ id: 'newSubscribers', label: t('dashboard.youtubeMetrics.newSubscribers'), value: '1.2K', trend: 12.3, trendDirection: 'up' },
|
||||
{ id: 'engagement', label: t('dashboard.youtubeMetrics.engagement'), value: '4.8', unit: '%', trend: 0.3, trendDirection: 'up' },
|
||||
{ id: 'ctr', label: t('dashboard.youtubeMetrics.ctr'), value: '6.2', unit: '%', trend: 1.1, trendDirection: 'up' },
|
||||
{ id: 'revenue', label: t('dashboard.youtubeMetrics.revenue'), value: '₩2.4M', trend: 8.5, trendDirection: 'up' },
|
||||
],
|
||||
},
|
||||
{
|
||||
platform: 'instagram',
|
||||
displayName: 'Instagram',
|
||||
metrics: [
|
||||
{ id: 'reach', label: t('dashboard.instagramMetrics.reach'), value: '892K', trend: 22.4, trendDirection: 'up' },
|
||||
{ id: 'impressions', label: t('dashboard.instagramMetrics.impressions'), value: '1.58M', trend: 15.1, trendDirection: 'up' },
|
||||
{ id: 'profileVisits', label: t('dashboard.instagramMetrics.profileVisits'), value: '28.4K', trend: 18.7, trendDirection: 'up' },
|
||||
{ id: 'followers', label: t('dashboard.instagramMetrics.followers'), value: '28.5K', trend: 8.2, trendDirection: 'up' },
|
||||
{ id: 'newFollowers', label: t('dashboard.instagramMetrics.newFollowers'), value: '892', trend: 15.6, trendDirection: 'up' },
|
||||
{ id: 'storyViews', label: t('dashboard.instagramMetrics.storyViews'), value: '156K', trend: 3.2, trendDirection: 'down' },
|
||||
{ id: 'reelPlays', label: t('dashboard.instagramMetrics.reelPlays'), value: '423K', trend: 45.2, trendDirection: 'up' },
|
||||
{ id: 'websiteClicks', label: t('dashboard.instagramMetrics.websiteClicks'), value: '3.2K', trend: 11.8, trendDirection: 'up' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Translated top content
|
||||
const topContent: TopContent[] = [
|
||||
{ id: '1', title: t('dashboard.topContentTitles.winterPromotion'), thumbnail: 'https://picsum.photos/seed/content1/120/68', platform: 'youtube', views: '125.4K', engagement: '8.2%', date: '2025.01.15' },
|
||||
{ id: '2', title: t('dashboard.topContentTitles.stayIntroReel'), thumbnail: 'https://picsum.photos/seed/content2/120/68', platform: 'instagram', views: '89.2K', engagement: '12.5%', date: '2025.01.22' },
|
||||
{ id: '3', title: t('dashboard.topContentTitles.newYearEvent'), thumbnail: 'https://picsum.photos/seed/content3/120/68', platform: 'youtube', views: '67.8K', engagement: '6.4%', date: '2025.01.08' },
|
||||
{ id: '4', title: t('dashboard.topContentTitles.nightTimelapse'), thumbnail: 'https://picsum.photos/seed/content4/120/68', platform: 'instagram', views: '54.3K', engagement: '15.8%', date: '2025.01.28' },
|
||||
];
|
||||
|
||||
// Translated audience data
|
||||
const audienceData: AudienceData = {
|
||||
ageGroups: [
|
||||
{ label: '18-24', percentage: 12 },
|
||||
{ label: '25-34', percentage: 35 },
|
||||
{ label: '35-44', percentage: 28 },
|
||||
{ label: '45-54', percentage: 18 },
|
||||
{ label: '55+', percentage: 7 },
|
||||
],
|
||||
gender: { male: 42, female: 58 },
|
||||
topRegions: [
|
||||
{ region: t('dashboard.regions.seoul'), percentage: 32 },
|
||||
{ region: t('dashboard.regions.gyeonggi'), percentage: 24 },
|
||||
{ region: t('dashboard.regions.busan'), percentage: 12 },
|
||||
{ region: t('dashboard.regions.incheon'), percentage: 8 },
|
||||
{ region: t('dashboard.regions.daegu'), percentage: 6 },
|
||||
],
|
||||
};
|
||||
|
||||
const currentPlatformData = platformData.find(p => p.platform === selectedPlatform);
|
||||
const lastUpdated = new Date().toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
|
|
@ -564,18 +655,18 @@ const DashboardContent: React.FC = () => {
|
|||
<AnimatedSection delay={0}>
|
||||
<div className="dashboard-header-row">
|
||||
<div>
|
||||
<h1 className="dashboard-title">대시보드</h1>
|
||||
<p className="dashboard-description">실시간 마케팅 퍼포먼스를 확인하세요.</p>
|
||||
<h1 className="dashboard-title">{t('dashboard.title')}</h1>
|
||||
<p className="dashboard-description">{t('dashboard.description')}</p>
|
||||
</div>
|
||||
<span className="dashboard-last-updated">마지막 업데이트: {lastUpdated}</span>
|
||||
<span className="dashboard-last-updated">{t('dashboard.lastUpdated')} {lastUpdated}</span>
|
||||
</div>
|
||||
</AnimatedSection>
|
||||
|
||||
{/* Content Performance Section */}
|
||||
<AnimatedSection delay={100} className="dashboard-section">
|
||||
<h2 className="dashboard-section-title">콘텐츠 성과</h2>
|
||||
<h2 className="dashboard-section-title">{t('dashboard.contentPerformance')}</h2>
|
||||
<div className="stats-grid-8">
|
||||
{CONTENT_METRICS.map((metric, index) => (
|
||||
{contentMetrics.map((metric, index) => (
|
||||
<AnimatedItem key={metric.id} index={index} baseDelay={200}>
|
||||
<StatCard {...metric} />
|
||||
</AnimatedItem>
|
||||
|
|
@ -589,23 +680,23 @@ const DashboardContent: React.FC = () => {
|
|||
<AnimatedSection delay={600}>
|
||||
<div className="yoy-chart-card">
|
||||
<div className="yoy-chart-header">
|
||||
<h2 className="dashboard-section-title">전년 대비 성장</h2>
|
||||
<h2 className="dashboard-section-title">{t('dashboard.yearOverYear')}</h2>
|
||||
<div className="chart-legend-dual">
|
||||
<div className="chart-legend-item">
|
||||
<span className="chart-legend-line solid"></span>
|
||||
<span className="chart-legend-text">올해</span>
|
||||
<span className="chart-legend-text">{t('dashboard.thisYear')}</span>
|
||||
</div>
|
||||
<div className="chart-legend-item">
|
||||
<span className="chart-legend-line dashed"></span>
|
||||
<span className="chart-legend-text">작년</span>
|
||||
<span className="chart-legend-text">{t('dashboard.lastYear')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="yoy-chart-container">
|
||||
<YearOverYearChart data={MONTHLY_DATA} />
|
||||
<YearOverYearChart data={monthlyData} />
|
||||
</div>
|
||||
<div className="yoy-chart-xaxis">
|
||||
{MONTHLY_DATA.map(d => (
|
||||
{monthlyData.map(d => (
|
||||
<span key={d.month}>{d.month}</span>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -615,9 +706,9 @@ const DashboardContent: React.FC = () => {
|
|||
{/* Top Content Section */}
|
||||
<AnimatedSection delay={700}>
|
||||
<div className="top-content-card">
|
||||
<h2 className="dashboard-section-title">인기 콘텐츠</h2>
|
||||
<h2 className="dashboard-section-title">{t('dashboard.popularContent')}</h2>
|
||||
<div className="top-content-list">
|
||||
{TOP_CONTENT.map((content, index) => (
|
||||
{topContent.map((content, index) => (
|
||||
<AnimatedItem key={content.id} index={index} baseDelay={800}>
|
||||
<TopContentItem {...content} />
|
||||
</AnimatedItem>
|
||||
|
|
@ -629,24 +720,24 @@ const DashboardContent: React.FC = () => {
|
|||
|
||||
{/* Audience Insights Section */}
|
||||
<AnimatedSection delay={1000} className="audience-section">
|
||||
<h2 className="dashboard-section-title">오디언스 인사이트</h2>
|
||||
<h2 className="dashboard-section-title">{t('dashboard.audienceInsights')}</h2>
|
||||
<div className="audience-cards">
|
||||
<AnimatedItem index={0} baseDelay={1100}>
|
||||
<div className="audience-card">
|
||||
<h3 className="audience-card-title">연령대 분포</h3>
|
||||
<AudienceBarChart data={AUDIENCE_DATA.ageGroups} delay={1200} />
|
||||
<h3 className="audience-card-title">{t('dashboard.ageDistribution')}</h3>
|
||||
<AudienceBarChart data={audienceData.ageGroups} delay={1200} />
|
||||
</div>
|
||||
</AnimatedItem>
|
||||
<AnimatedItem index={1} baseDelay={1100}>
|
||||
<div className="audience-card">
|
||||
<h3 className="audience-card-title">성별 분포</h3>
|
||||
<GenderChart male={AUDIENCE_DATA.gender.male} female={AUDIENCE_DATA.gender.female} delay={1300} />
|
||||
<h3 className="audience-card-title">{t('dashboard.genderDistribution')}</h3>
|
||||
<GenderChart male={audienceData.gender.male} female={audienceData.gender.female} delay={1300} />
|
||||
</div>
|
||||
</AnimatedItem>
|
||||
<AnimatedItem index={2} baseDelay={1100}>
|
||||
<div className="audience-card">
|
||||
<h3 className="audience-card-title">상위 지역</h3>
|
||||
<AudienceBarChart data={AUDIENCE_DATA.topRegions.map(r => ({ label: r.region, percentage: r.percentage }))} delay={1400} />
|
||||
<h3 className="audience-card-title">{t('dashboard.topRegions')}</h3>
|
||||
<AudienceBarChart data={audienceData.topRegions.map(r => ({ label: r.region, percentage: r.percentage }))} delay={1400} />
|
||||
</div>
|
||||
</AnimatedItem>
|
||||
</div>
|
||||
|
|
@ -656,7 +747,7 @@ const DashboardContent: React.FC = () => {
|
|||
<AnimatedSection delay={1300}>
|
||||
<div className="platform-section-card">
|
||||
<div className="platform-section-header">
|
||||
<h2 className="dashboard-section-title">플랫폼별 지표</h2>
|
||||
<h2 className="dashboard-section-title">{t('dashboard.platformMetrics')}</h2>
|
||||
<div className="platform-tabs">
|
||||
<button
|
||||
className={`platform-tab ${selectedPlatform === 'youtube' ? 'active' : ''}`}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Sidebar from '../../components/Sidebar';
|
||||
import AssetManagementContent from './AssetManagementContent';
|
||||
import SoundStudioContent from './SoundStudioContent';
|
||||
|
|
@ -64,6 +65,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||
businessInfo,
|
||||
initialAnalysisData
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// localStorage에서 저장된 상태 복원
|
||||
const savedActiveItem = localStorage.getItem(ACTIVE_ITEM_KEY);
|
||||
const savedWizardStep = localStorage.getItem(WIZARD_STEP_KEY);
|
||||
|
|
@ -212,7 +214,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||
goToWizardStep(0); // 브랜드 분석 결과로
|
||||
} catch (err) {
|
||||
console.error('Autocomplete error:', err);
|
||||
setAnalysisError(err instanceof Error ? err.message : '업체 정보 조회에 실패했습니다.');
|
||||
setAnalysisError(err instanceof Error ? err.message : t('app.autocompleteError'));
|
||||
goToWizardStep(-2); // URL 입력으로 돌아가기
|
||||
}
|
||||
};
|
||||
|
|
@ -252,7 +254,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||
goToWizardStep(0); // 브랜드 분석 결과로
|
||||
} catch (err) {
|
||||
console.error('Crawling failed:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : '분석 중 오류가 발생했습니다. 다시 시도해주세요.';
|
||||
const errorMessage = err instanceof Error ? err.message : t('app.analysisError');
|
||||
setAnalysisError(errorMessage);
|
||||
goToWizardStep(-2); // URL 입력으로 돌아가기
|
||||
}
|
||||
|
|
@ -403,7 +405,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||
default:
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-500 font-light">
|
||||
{activeItem} 페이지 준비 중입니다.
|
||||
{t('app.pageComingSoon', { page: activeItem })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getSocialAccounts, getYouTubeConnectUrl, disconnectSocialAccount, TokenExpiredError, handleSocialReconnect } from '../../utils/api';
|
||||
import { SocialAccount } from '../../types/api';
|
||||
|
||||
type TabType = 'basic' | 'payment' | 'business';
|
||||
|
||||
const MyInfoContent: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('business');
|
||||
const [businessUrl, setBusinessUrl] = useState('');
|
||||
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
|
||||
|
|
@ -24,7 +26,7 @@ const MyInfoContent: React.FC = () => {
|
|||
setSocialAccounts(response.accounts || []);
|
||||
} catch (error) {
|
||||
if (error instanceof TokenExpiredError) {
|
||||
alert('YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다.');
|
||||
alert(t('myInfo.youtubeExpiredAlert'));
|
||||
handleSocialReconnect(error.reconnectUrl);
|
||||
return;
|
||||
}
|
||||
|
|
@ -42,7 +44,7 @@ const MyInfoContent: React.FC = () => {
|
|||
window.location.href = response.auth_url;
|
||||
} catch (error) {
|
||||
if (error instanceof TokenExpiredError) {
|
||||
alert('YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다.');
|
||||
alert(t('myInfo.youtubeExpiredAlert'));
|
||||
handleSocialReconnect(error.reconnectUrl);
|
||||
return;
|
||||
}
|
||||
|
|
@ -58,7 +60,7 @@ const MyInfoContent: React.FC = () => {
|
|||
setSocialAccounts(prev => prev.filter(acc => acc.id !== accountId));
|
||||
} catch (error) {
|
||||
if (error instanceof TokenExpiredError) {
|
||||
alert('YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다.');
|
||||
alert(t('myInfo.youtubeExpiredAlert'));
|
||||
handleSocialReconnect(error.reconnectUrl);
|
||||
return;
|
||||
}
|
||||
|
|
@ -76,14 +78,14 @@ const MyInfoContent: React.FC = () => {
|
|||
const hasConnectedAccounts = youtubeAccounts.length > 0 || instagramAccounts.length > 0;
|
||||
|
||||
const tabs = [
|
||||
{ id: 'basic' as TabType, label: '기본 정보' },
|
||||
{ id: 'payment' as TabType, label: '결제 정보' },
|
||||
{ id: 'business' as TabType, label: '내 비즈니스 & 소셜 채널 관리' },
|
||||
{ id: 'basic' as TabType, label: t('myInfo.tabBasic') },
|
||||
{ id: 'payment' as TabType, label: t('myInfo.tabPayment') },
|
||||
{ id: 'business' as TabType, label: t('myInfo.tabBusiness') },
|
||||
];
|
||||
|
||||
return (
|
||||
<main className="myinfo-page">
|
||||
<h1 className="myinfo-title">내 정보</h1>
|
||||
<h1 className="myinfo-title">{t('myInfo.title')}</h1>
|
||||
|
||||
{/* 탭 네비게이션 */}
|
||||
<div className="myinfo-tabs">
|
||||
|
|
@ -102,13 +104,13 @@ const MyInfoContent: React.FC = () => {
|
|||
<div className="myinfo-content">
|
||||
{activeTab === 'basic' && (
|
||||
<div className="myinfo-section">
|
||||
<p className="myinfo-placeholder">기본 정보 설정 기능이 준비 중입니다.</p>
|
||||
<p className="myinfo-placeholder">{t('myInfo.basicPlaceholder')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'payment' && (
|
||||
<div className="myinfo-section">
|
||||
<p className="myinfo-placeholder">결제 정보 설정 기능이 준비 중입니다.</p>
|
||||
<p className="myinfo-placeholder">{t('myInfo.paymentPlaceholder')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -116,22 +118,22 @@ const MyInfoContent: React.FC = () => {
|
|||
<>
|
||||
{/* 내 비즈니스 섹션 */}
|
||||
<div className="myinfo-section">
|
||||
<h2 className="myinfo-section-title">내 비즈니스</h2>
|
||||
<h2 className="myinfo-section-title">{t('myInfo.myBusiness')}</h2>
|
||||
<div className="myinfo-business-card">
|
||||
<h3 className="myinfo-business-empty-title">아직 등록된 비즈니스가 없어요</h3>
|
||||
<h3 className="myinfo-business-empty-title">{t('myInfo.noBusinessTitle')}</h3>
|
||||
<p className="myinfo-business-empty-desc">
|
||||
네이버 지도 URL을 입력하면, 영상 제작에 필요한 정보를 자동으로 불러와요
|
||||
{t('myInfo.noBusinessDesc')}
|
||||
</p>
|
||||
<div className="myinfo-business-input-row">
|
||||
<input
|
||||
type="text"
|
||||
value={businessUrl}
|
||||
onChange={(e) => setBusinessUrl(e.target.value)}
|
||||
placeholder="네이버 지도 URL 입력"
|
||||
placeholder={t('myInfo.naverMapUrlPlaceholder')}
|
||||
className="myinfo-business-input"
|
||||
/>
|
||||
<button className="myinfo-business-submit">
|
||||
비즈니스 등록
|
||||
{t('myInfo.registerBusiness')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -139,7 +141,7 @@ const MyInfoContent: React.FC = () => {
|
|||
|
||||
{/* 소셜 채널 섹션 */}
|
||||
<div className="myinfo-section">
|
||||
<h2 className="myinfo-section-title">소셜 채널</h2>
|
||||
<h2 className="myinfo-section-title">{t('myInfo.socialChannels')}</h2>
|
||||
|
||||
{/* 연결 버튼들 (가로 배치) */}
|
||||
<div className="myinfo-social-buttons">
|
||||
|
|
@ -149,7 +151,7 @@ const MyInfoContent: React.FC = () => {
|
|||
className={`myinfo-social-btn ${youtubeAccounts.length > 0 ? 'connected' : ''}`}
|
||||
>
|
||||
<img src="/assets/images/social-youtube.png" alt="YouTube" className="myinfo-social-btn-icon" />
|
||||
<span>{isConnecting === 'youtube' ? '연결 중...' : 'YouTube 연결'}</span>
|
||||
<span>{isConnecting === 'youtube' ? t('myInfo.youtubeConnecting') : t('myInfo.youtubeConnect')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
|
@ -157,7 +159,7 @@ const MyInfoContent: React.FC = () => {
|
|||
className="myinfo-social-btn disabled"
|
||||
>
|
||||
<img src="/assets/images/social-instagram.png" alt="Instagram" className="myinfo-social-btn-icon" />
|
||||
<span>Instagram 연결</span>
|
||||
<span>{t('myInfo.instagramConnect')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -189,13 +191,13 @@ const MyInfoContent: React.FC = () => {
|
|||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
연결됨
|
||||
{t('myInfo.connected')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDisconnectAccount(account.id)}
|
||||
className="myinfo-connected-disconnect"
|
||||
>
|
||||
연결 해제
|
||||
{t('myInfo.disconnectAccount')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -226,13 +228,13 @@ const MyInfoContent: React.FC = () => {
|
|||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
연결됨
|
||||
{t('myInfo.connected')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDisconnectAccount(account.id)}
|
||||
className="myinfo-connected-disconnect"
|
||||
>
|
||||
연결 해제
|
||||
{t('myInfo.disconnectAccount')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -241,7 +243,7 @@ const MyInfoContent: React.FC = () => {
|
|||
)}
|
||||
|
||||
{isLoadingAccounts && (
|
||||
<p className="myinfo-loading">계정 정보를 불러오는 중...</p>
|
||||
<p className="myinfo-loading">{t('myInfo.loadingAccounts')}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { generateLyric, waitForLyricComplete, generateSong, waitForSongComplete } from '../../utils/api';
|
||||
import { LANGUAGE_MAP } from '../../types/api';
|
||||
|
||||
|
|
@ -39,6 +40,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
videoGenerationStatus = 'idle',
|
||||
videoGenerationProgress = 0
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedType, setSelectedType] = useState('보컬');
|
||||
const [selectedLang, setSelectedLang] = useState('한국어');
|
||||
const [selectedGenre, setSelectedGenre] = useState('자동 선택');
|
||||
|
|
@ -94,15 +96,15 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
songId,
|
||||
(pollStatus: string) => {
|
||||
if (pollStatus === 'streaming') {
|
||||
setStatusMessage('노래를 생성하고 있습니다...');
|
||||
setStatusMessage(t('soundStudio.generatingSong'));
|
||||
} else if (pollStatus === 'queued') {
|
||||
setStatusMessage('노래 생성 대기 중...');
|
||||
setStatusMessage(t('soundStudio.songQueued'));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!statusResponse.success) {
|
||||
throw new Error(statusResponse.error_message || '음악 생성에 실패했습니다.');
|
||||
throw new Error(statusResponse.error_message || t('soundStudio.musicGenerationFailed'));
|
||||
}
|
||||
|
||||
// song_result_url을 사용하여 재생
|
||||
|
|
@ -120,16 +122,16 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
if (currentRetryCount < MAX_RETRY_COUNT) {
|
||||
const newRetryCount = currentRetryCount + 1;
|
||||
setRetryCount(newRetryCount);
|
||||
setStatusMessage(`시간 초과로 재생성 중... (${newRetryCount}/${MAX_RETRY_COUNT})`);
|
||||
setStatusMessage(t('soundStudio.retryMessage', { count: newRetryCount, max: MAX_RETRY_COUNT }));
|
||||
await regenerateSongOnly(currentLyrics, newRetryCount);
|
||||
} else {
|
||||
setStatus('error');
|
||||
setErrorMessage('여러 번 시도했지만 음악 생성에 실패했습니다. 다시 시도해주세요.');
|
||||
setErrorMessage(t('soundStudio.multipleRetryFailed'));
|
||||
setRetryCount(0);
|
||||
}
|
||||
} else {
|
||||
setStatus('error');
|
||||
setErrorMessage(error instanceof Error ? error.message : '음악 생성 중 오류가 발생했습니다.');
|
||||
setErrorMessage(error instanceof Error ? error.message : t('soundStudio.musicGenerationError'));
|
||||
setRetryCount(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -158,7 +160,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
});
|
||||
|
||||
if (!songResponse.success) {
|
||||
throw new Error(songResponse.error_message || '음악 생성 요청에 실패했습니다.');
|
||||
throw new Error(songResponse.error_message || t('soundStudio.songGenerationFailed'));
|
||||
}
|
||||
|
||||
await resumePolling(songResponse.task_id, songResponse.song_id, currentLyrics, currentRetryCount);
|
||||
|
|
@ -166,7 +168,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
} catch (error) {
|
||||
console.error('Song regeneration failed:', error);
|
||||
setStatus('error');
|
||||
setErrorMessage(error instanceof Error ? error.message : '음악 재생성 중 오류가 발생했습니다.');
|
||||
setErrorMessage(error instanceof Error ? error.message : t('soundStudio.songRegenerationError'));
|
||||
setRetryCount(0);
|
||||
}
|
||||
};
|
||||
|
|
@ -256,18 +258,18 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
|
||||
const handleGenerateMusic = async () => {
|
||||
if (!businessInfo) {
|
||||
setErrorMessage('비즈니스 정보가 없습니다. 다시 시도해주세요.');
|
||||
setErrorMessage(t('soundStudio.noBusinessInfo'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!imageTaskId) {
|
||||
setErrorMessage('이미지 업로드 정보가 없습니다. 이전 단계로 돌아가 다시 시도해주세요.');
|
||||
setErrorMessage(t('soundStudio.noImageUploadInfo'));
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('generating_lyric');
|
||||
setErrorMessage(null);
|
||||
setStatusMessage('가사를 생성하고 있습니다...');
|
||||
setStatusMessage(t('soundStudio.generatingLyrics'));
|
||||
|
||||
try {
|
||||
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
|
||||
|
|
@ -282,33 +284,33 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
});
|
||||
|
||||
if (!lyricResponse.success || !lyricResponse.task_id) {
|
||||
throw new Error(lyricResponse.error_message || '가사 생성 요청에 실패했습니다.');
|
||||
throw new Error(lyricResponse.error_message || t('soundStudio.lyricGenerationFailed'));
|
||||
}
|
||||
|
||||
// 2. 가사 생성 상태 폴링 → 완료 시 상세 조회
|
||||
setStatusMessage('가사를 생성하고 있습니다...');
|
||||
setStatusMessage(t('soundStudio.generatingLyrics'));
|
||||
const lyricDetailResponse = await waitForLyricComplete(
|
||||
lyricResponse.task_id,
|
||||
(status: string) => {
|
||||
if (status === 'processing') {
|
||||
setStatusMessage('가사를 생성하고 있습니다...');
|
||||
setStatusMessage(t('soundStudio.generatingLyrics'));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!lyricDetailResponse.lyric_result) {
|
||||
throw new Error('가사를 받지 못했습니다.');
|
||||
throw new Error(t('soundStudio.lyricNotReceived'));
|
||||
}
|
||||
|
||||
// "I'm sorry" 체크
|
||||
if (lyricDetailResponse.lyric_result.includes("I'm sorry")) {
|
||||
throw new Error('가사 생성에 실패했습니다. 다시 시도해주세요.');
|
||||
throw new Error(t('soundStudio.lyricGenerationError'));
|
||||
}
|
||||
|
||||
setLyrics(lyricDetailResponse.lyric_result);
|
||||
|
||||
setStatus('generating_song');
|
||||
setStatusMessage('노래를 생성하고 있습니다...');
|
||||
setStatusMessage(t('soundStudio.generatingSong'));
|
||||
|
||||
const genreMap: Record<string, string> = {
|
||||
'자동 선택': 'pop',
|
||||
|
|
@ -328,7 +330,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
});
|
||||
|
||||
if (!songResponse.success) {
|
||||
throw new Error(songResponse.error_message || '음악 생성 요청에 실패했습니다.');
|
||||
throw new Error(songResponse.error_message || t('soundStudio.songGenerationFailed'));
|
||||
}
|
||||
|
||||
// 디버깅: songResponse 확인
|
||||
|
|
@ -337,18 +339,18 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
console.log('task_id:', songResponse.task_id);
|
||||
|
||||
if (!songResponse.song_id) {
|
||||
throw new Error('서버에서 song_id를 받지 못했습니다.');
|
||||
throw new Error(t('soundStudio.songIdMissing'));
|
||||
}
|
||||
|
||||
setStatus('polling');
|
||||
setStatusMessage('노래를 생성하고 있습니다...');
|
||||
setStatusMessage(t('soundStudio.generatingSong'));
|
||||
|
||||
await resumePolling(songResponse.task_id, songResponse.song_id, lyricDetailResponse.lyric_result, 0);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Music generation failed:', error);
|
||||
setStatus('error');
|
||||
setErrorMessage(error instanceof Error ? error.message : '음악 생성 중 오류가 발생했습니다.');
|
||||
setErrorMessage(error instanceof Error ? error.message : t('soundStudio.musicGenerationError'));
|
||||
setRetryCount(0);
|
||||
}
|
||||
};
|
||||
|
|
@ -378,34 +380,38 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
뒤로가기
|
||||
{t('soundStudio.back')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Page Title */}
|
||||
<h1 className="sound-studio-title">사운드 스튜디오</h1>
|
||||
<h1 className="sound-studio-title">{t('soundStudio.title')}</h1>
|
||||
|
||||
{/* Main Content - Two Column Layout */}
|
||||
<div className="sound-studio-container">
|
||||
<div className="sound-studio-columns">
|
||||
{/* Left Column - Sound Settings */}
|
||||
<div className="sound-column">
|
||||
<h3 className="column-title">사운드</h3>
|
||||
<h3 className="column-title">{t('soundStudio.soundColumn')}</h3>
|
||||
|
||||
{/* Sound Type Selection */}
|
||||
<div className="sound-studio-section">
|
||||
<label className="input-label">AI 사운드 유형 선택</label>
|
||||
<label className="input-label">{t('soundStudio.soundTypeLabel')}</label>
|
||||
<div className="sound-type-grid">
|
||||
{['보컬', '배경음악', '성우 내레이션'].map(type => {
|
||||
const isDisabled = type === '성우 내레이션' || isGenerating;
|
||||
{[
|
||||
{ key: '보컬', label: t('soundStudio.soundTypeVocal') },
|
||||
{ key: '배경음악', label: t('soundStudio.soundTypeBGM') },
|
||||
{ key: '성우 내레이션', label: t('soundStudio.soundTypeNarration') },
|
||||
].map(({ key, label }) => {
|
||||
const isDisabled = key === '성우 내레이션' || isGenerating;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => !isDisabled && setSelectedType(type)}
|
||||
key={key}
|
||||
onClick={() => !isDisabled && setSelectedType(key)}
|
||||
disabled={isDisabled}
|
||||
className={`sound-type-btn ${selectedType === type ? 'active' : ''} ${type === '성우 내레이션' ? 'permanently-disabled' : ''}`}
|
||||
className={`sound-type-btn ${selectedType === key ? 'active' : ''} ${key === '성우 내레이션' ? 'permanently-disabled' : ''}`}
|
||||
>
|
||||
{type}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -414,17 +420,21 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
|
||||
{/* Genre Selection */}
|
||||
<div className="sound-studio-section">
|
||||
<label className="input-label">장르 선택</label>
|
||||
<label className="input-label">{t('soundStudio.genreLabel')}</label>
|
||||
<div className="genre-grid">
|
||||
<div className="genre-row">
|
||||
{['자동 선택', 'K-POP', '발라드'].map(genre => (
|
||||
{[
|
||||
{ key: '자동 선택', label: t('soundStudio.genreAuto') },
|
||||
{ key: 'K-POP', label: 'K-POP' },
|
||||
{ key: '발라드', label: t('soundStudio.genreBallad') },
|
||||
].map(({ key, label }) => (
|
||||
<button
|
||||
key={genre}
|
||||
onClick={() => setSelectedGenre(genre)}
|
||||
key={key}
|
||||
onClick={() => setSelectedGenre(key)}
|
||||
disabled={isGenerating}
|
||||
className={`genre-btn ${selectedGenre === genre ? 'active' : ''}`}
|
||||
className={`genre-btn ${selectedGenre === key ? 'active' : ''}`}
|
||||
>
|
||||
{genre}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -458,7 +468,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
|
||||
{/* Language Selection */}
|
||||
<div className="sound-studio-section">
|
||||
<label className="input-label">언어</label>
|
||||
<label className="input-label">{t('soundStudio.languageLabel')}</label>
|
||||
<div className="language-selector-wrapper" ref={languageDropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsLanguageDropdownOpen(!isLanguageDropdownOpen)}
|
||||
|
|
@ -499,8 +509,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
{/* Right Column - Lyrics */}
|
||||
<div className="lyrics-column">
|
||||
<div className="lyrics-header">
|
||||
<h3 className="column-title">가사</h3>
|
||||
<p className="lyrics-subtitle">가사 영역을 선택해서 수정 가능해요</p>
|
||||
<h3 className="column-title">{t('soundStudio.lyricsColumn')}</h3>
|
||||
<p className="lyrics-subtitle">{t('soundStudio.lyricsHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Audio Player */}
|
||||
|
|
@ -545,11 +555,11 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
value={lyrics}
|
||||
onChange={(e) => setLyrics(e.target.value)}
|
||||
className="lyrics-textarea"
|
||||
placeholder="사운드 생성 시 가사 표시됩니다."
|
||||
placeholder={t('soundStudio.lyricsPlaceholder')}
|
||||
/>
|
||||
) : (
|
||||
<div className="lyrics-placeholder">
|
||||
사운드 생성 시 가사 표시됩니다.
|
||||
{t('soundStudio.lyricsPlaceholder')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -568,10 +578,10 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
|
||||
</svg>
|
||||
생성 중...
|
||||
{t('soundStudio.generating')}
|
||||
</>
|
||||
) : (
|
||||
'사운드 생성'
|
||||
t('soundStudio.generateButton')
|
||||
)}
|
||||
</button>
|
||||
|
||||
|
|
@ -607,7 +617,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
>
|
||||
{videoGenerationStatus === 'generating' ? (
|
||||
<>
|
||||
<span className="video-gen-text">영상 생성 중</span>
|
||||
<span className="video-gen-text">{t('soundStudio.videoGenerating')}</span>
|
||||
<div className="video-gen-progress-bar">
|
||||
<div
|
||||
className="video-gen-progress-fill"
|
||||
|
|
@ -616,7 +626,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
</div>
|
||||
</>
|
||||
) : (
|
||||
'영상 생성하기'
|
||||
t('soundStudio.generateVideo')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../../utils/api';
|
||||
import { CrawlingResponse } from '../../types/api';
|
||||
|
||||
|
|
@ -16,6 +17,7 @@ interface UrlInputContentProps {
|
|||
}
|
||||
|
||||
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onTestData, error }) => {
|
||||
const { t } = useTranslation();
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [searchType, setSearchType] = useState<SearchType>('url');
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
|
@ -45,19 +47,19 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
|
|||
|
||||
const searchTypeOptions = [
|
||||
{ value: 'url' as SearchType, label: 'URL' },
|
||||
{ value: 'name' as SearchType, label: '업체명' },
|
||||
{ value: 'name' as SearchType, label: t('urlInput.searchTypeBusinessName') },
|
||||
];
|
||||
|
||||
const getPlaceholder = () => {
|
||||
return searchType === 'url'
|
||||
? 'https://www.castad.com'
|
||||
: '업체명을 입력하세요';
|
||||
: t('urlInput.placeholderBusinessName');
|
||||
};
|
||||
|
||||
const getGuideText = () => {
|
||||
return searchType === 'url'
|
||||
? 'URL에서 가져온 정보로 영상이 자동 생성됩니다.'
|
||||
: '업체명으로 검색하여 정보를 가져옵니다.';
|
||||
? t('urlInput.guideUrl')
|
||||
: t('urlInput.guideBusinessName');
|
||||
};
|
||||
|
||||
// 업체명 검색 시 자동완성 (디바운스 적용)
|
||||
|
|
@ -222,7 +224,7 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
|
|||
{showAutocomplete && searchType === 'name' && (
|
||||
<div className="url-input-autocomplete-dropdown">
|
||||
{isAutocompleteLoading ? (
|
||||
<div className="url-input-autocomplete-loading">검색 중...</div>
|
||||
<div className="url-input-autocomplete-loading">{t('urlInput.searching')}</div>
|
||||
) : (
|
||||
autocompleteResults.map((item, index) => (
|
||||
<button
|
||||
|
|
@ -245,7 +247,7 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
|
|||
|
||||
{/* 검색 버튼 */}
|
||||
<button type="submit" className="url-input-button">
|
||||
검색하기
|
||||
{t('urlInput.searchButton')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -268,7 +270,7 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
|
|||
disabled={isLoadingTest}
|
||||
className="test-data-button"
|
||||
>
|
||||
{isLoadingTest ? '로딩 중...' : '테스트 데이터'}
|
||||
{isLoadingTest ? t('urlInput.testDataLoading') : t('urlInput.testData')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Footer from '../../components/Footer';
|
||||
|
||||
interface DisplaySectionProps {
|
||||
|
|
@ -7,6 +8,7 @@ interface DisplaySectionProps {
|
|||
}
|
||||
|
||||
const DisplaySection: React.FC<DisplaySectionProps> = ({ onStartClick }) => {
|
||||
const { t } = useTranslation();
|
||||
// YouTube Shorts 영상 ID들
|
||||
const videos = [
|
||||
{ id: 1, videoId: 'OZJ8X4P82OA' },
|
||||
|
|
@ -38,7 +40,7 @@ const DisplaySection: React.FC<DisplaySectionProps> = ({ onStartClick }) => {
|
|||
|
||||
{/* Action Button */}
|
||||
<button onClick={onStartClick} className="display-button">
|
||||
시작하기
|
||||
{t('landing.display.startButton')}
|
||||
</button>
|
||||
</div>
|
||||
<Footer />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../../utils/api';
|
||||
import { CrawlingResponse } from '../../types/api';
|
||||
|
||||
|
|
@ -57,6 +58,7 @@ const orbConfigs: OrbConfig[] = [
|
|||
];
|
||||
|
||||
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onTestData, onNext, error: externalError, scrollProgress = 0 }) => {
|
||||
const { t } = useTranslation();
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [searchType, setSearchType] = useState<SearchType>('url');
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
|
@ -73,7 +75,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
|||
onTestData(data);
|
||||
} catch (error) {
|
||||
console.error('테스트 데이터 로드 실패:', error);
|
||||
setLocalError('테스트 데이터를 불러오는데 실패했습니다.');
|
||||
setLocalError(t('landing.hero.testDataLoadFailed'));
|
||||
} finally {
|
||||
setIsLoadingTest(false);
|
||||
}
|
||||
|
|
@ -92,7 +94,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
|||
|
||||
const searchTypeOptions = [
|
||||
{ value: 'url' as SearchType, label: 'URL' },
|
||||
{ value: 'name' as SearchType, label: '업체명' },
|
||||
{ value: 'name' as SearchType, label: t('landing.hero.searchTypeBusinessName') },
|
||||
];
|
||||
|
||||
// 드롭다운 외부 클릭 감지
|
||||
|
|
@ -258,23 +260,23 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
|||
const getPlaceholder = () => {
|
||||
return searchType === 'url'
|
||||
? 'https://www.castad.com'
|
||||
: '업체명을 입력하세요';
|
||||
: t('landing.hero.placeholderBusinessName');
|
||||
};
|
||||
|
||||
const getGuideText = () => {
|
||||
return searchType === 'url'
|
||||
? 'URL에서 가져온 정보로 영상이 자동 생성됩니다.'
|
||||
: '업체명으로 검색하여 정보를 가져옵니다.';
|
||||
? t('landing.hero.guideUrl')
|
||||
: t('landing.hero.guideBusinessName');
|
||||
};
|
||||
|
||||
const handleStart = () => {
|
||||
if (!inputValue.trim()) {
|
||||
setLocalError(searchType === 'url' ? 'URL을 입력해주세요.' : '업체명을 입력해주세요.');
|
||||
setLocalError(searchType === 'url' ? t('landing.hero.errorUrlRequired') : t('landing.hero.errorNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchType === 'url' && !isValidUrl(inputValue.trim())) {
|
||||
setLocalError('올바른 URL 형식이 아닙니다. (예: https://example.com)');
|
||||
setLocalError(t('landing.hero.errorInvalidUrl'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -404,7 +406,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
|||
{showAutocomplete && searchType === 'name' && (
|
||||
<div className="hero-autocomplete-dropdown">
|
||||
{isAutocompleteLoading ? (
|
||||
<div className="hero-autocomplete-loading">검색 중...</div>
|
||||
<div className="hero-autocomplete-loading">{t('landing.hero.searching')}</div>
|
||||
) : (
|
||||
autocompleteResults.map((item, index) => (
|
||||
<button
|
||||
|
|
@ -446,14 +448,14 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
|||
)}
|
||||
|
||||
<button onClick={handleStart} className="hero-button">
|
||||
브랜드 분석
|
||||
{t('landing.hero.analyzeButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Indicator */}
|
||||
<button onClick={onNext} className="scroll-indicator">
|
||||
<span className="scroll-indicator-text">스크롤하여 더 보기</span>
|
||||
<span className="scroll-indicator-text">{t('landing.hero.scrollMore')}</span>
|
||||
<div className="scroll-indicator-icon">
|
||||
<svg
|
||||
width="24"
|
||||
|
|
@ -477,7 +479,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
|||
disabled={isLoadingTest}
|
||||
className="test-data-button"
|
||||
>
|
||||
{isLoadingTest ? '로딩 중...' : '테스트 데이터'}
|
||||
{isLoadingTest ? t('landing.hero.testDataLoading') : t('landing.hero.testData')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface WelcomeSectionProps {
|
||||
onStartClick?: () => void;
|
||||
|
|
@ -7,25 +8,26 @@ interface WelcomeSectionProps {
|
|||
}
|
||||
|
||||
const WelcomeSection: React.FC<WelcomeSectionProps> = () => {
|
||||
const { t } = useTranslation();
|
||||
const features = [
|
||||
{
|
||||
id: 1,
|
||||
title: '비즈니스 핵심 정보 분석',
|
||||
description: '홈페이지, 네이버 지도, 블로그 등의\nURL을 입력하세요',
|
||||
title: t('landing.welcome.feature1Title'),
|
||||
description: t('landing.welcome.feature1Desc'),
|
||||
iconBg: '#9BCACC',
|
||||
icon: '/assets/images/icon-analysis.svg'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '홍보 콘텐츠 자동 제작',
|
||||
description: '분석된 정보를 바탕으로\n비즈니스에 맞는 음악, 자막, 노래, 영상을\n자동으로 제작해요',
|
||||
title: t('landing.welcome.feature2Title'),
|
||||
description: t('landing.welcome.feature2Desc'),
|
||||
iconBg: '#DFC7FD',
|
||||
icon: '/assets/images/icon-content.svg'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '멀티채널 자동 배포',
|
||||
description: '완성된 영상은 다운로드하거나\n바로 SNS에 업로드 할 수 있어요',
|
||||
title: t('landing.welcome.feature3Title'),
|
||||
description: t('landing.welcome.feature3Desc'),
|
||||
iconBg: '#D4FDF3',
|
||||
icon: '/assets/images/icon-deploy.svg'
|
||||
},
|
||||
|
|
@ -41,8 +43,8 @@ const WelcomeSection: React.FC<WelcomeSectionProps> = () => {
|
|||
|
||||
{/* Header */}
|
||||
<div className="welcome-header">
|
||||
<h2 className="welcome-title">ADO2.AI에 오신 것을 환영합니다.</h2>
|
||||
<p className="welcome-subtitle">분석, 제작, 배포까지 콘텐츠 마케팅의 전과정을 자동화</p>
|
||||
<h2 className="welcome-title">{t('landing.welcome.title')}</h2>
|
||||
<p className="welcome-subtitle">{t('landing.welcome.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Feature Cards */}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getKakaoLoginUrl, kakaoCallback } from '../../utils/api';
|
||||
|
||||
interface LoginSectionProps {
|
||||
|
|
@ -8,6 +9,7 @@ interface LoginSectionProps {
|
|||
}
|
||||
|
||||
const LoginSection: React.FC<LoginSectionProps> = ({ onBack, onLogin }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -37,7 +39,7 @@ const LoginSection: React.FC<LoginSectionProps> = ({ onBack, onLogin }) => {
|
|||
onLogin();
|
||||
} catch (err) {
|
||||
console.error('Kakao callback failed:', err);
|
||||
setError('카카오 로그인에 실패했습니다. 다시 시도해주세요.');
|
||||
setError(t('login.kakaoLoginFailed'));
|
||||
|
||||
// URL에서 code 파라미터 제거
|
||||
const url = new URL(window.location.href);
|
||||
|
|
@ -59,7 +61,7 @@ const LoginSection: React.FC<LoginSectionProps> = ({ onBack, onLogin }) => {
|
|||
window.location.href = response.auth_url;
|
||||
} catch (err) {
|
||||
console.error('Failed to get Kakao login URL:', err);
|
||||
setError('로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.');
|
||||
setError(t('login.loginUrlFailed'));
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
|
@ -69,7 +71,7 @@ const LoginSection: React.FC<LoginSectionProps> = ({ onBack, onLogin }) => {
|
|||
{/* Back Button */}
|
||||
<button onClick={onBack} className="login-back-btn" disabled={isLoading}>
|
||||
<img src="/assets/images/icon-back.svg" alt="Back" />
|
||||
<span>뒤로가기</span>
|
||||
<span>{t('login.back')}</span>
|
||||
</button>
|
||||
|
||||
<div className="login-content">
|
||||
|
|
@ -91,7 +93,7 @@ const LoginSection: React.FC<LoginSectionProps> = ({ onBack, onLogin }) => {
|
|||
className="btn-kakao"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? '로그인 중...' : '카카오로 시작하기'}
|
||||
{isLoading ? t('login.loggingIn') : t('login.kakaoStart')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue