로컬라이제이션 적용 (영어 / 한국어 )

main
hbyang 2026-02-10 14:39:58 +09:00
parent 29fdb7e65c
commit ad6f9e09a1
30 changed files with 1474 additions and 309 deletions

View File

@ -9,7 +9,8 @@
"Bash(python3:*)",
"mcp__figma__get_figma_data",
"mcp__figma__download_figma_images",
"Bash(npx tsc:*)"
"Bash(npx tsc:*)",
"Bash(grep:*)"
]
}
}

View File

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

102
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

22
src/i18n/index.ts Normal file
View File

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

View File

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

371
src/locales/en.json Normal file
View File

@ -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."
}
}

371
src/locales/ko.json Normal file
View File

@ -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}} 페이지 준비 중입니다."
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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' : ''}`}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 */}

View File

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