Compare commits

..

No commits in common. "main" and "feature-dashboard" have entirely different histories.

38 changed files with 680 additions and 6645 deletions

2091
index.css

File diff suppressed because it is too large Load Diff

View File

@ -4,15 +4,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>ADO2</title>
<!-- ① 페이지 설명 -->
<meta name="description" content="ADO2는 ㈜에이아이오투오의 AI 기반 마케팅 자동화 플랫폼입니다. 콘텐츠 제작부터 채널 배포까지 자동으로 처리합니다." />
<!-- ② Open Graph 제목 -->
<meta property="og:title" content="ADO2 - AI 마케팅 자동화 플랫폼" />
<!-- ③ Open Graph 설명 -->
<meta property="og:description" content="ADO2는 ㈜에이아이오투오의 AI 기반 마케팅 자동화 플랫폼입니다. 콘텐츠 제작부터 채널 배포까지 자동으로 처리합니다." />
<meta property="og:url" content="https://demo.castad.net/" />
<meta property="og:type" content="website" />
<title>CASTAD</title>
<link rel="icon" type="image/svg+xml" href="/favicon_32.svg" sizes="32x32">
<link rel="icon" type="image/svg+xml" href="/favicon_48.svg" sizes="48x48">
<script src="https://cdn.tailwindcss.com"></script>
@ -36,7 +28,6 @@
}
}
</script>
<script src="https://t1.kakaocdn.net/kakao_js_sdk/2.7.4/kakao.min.js" crossorigin="anonymous"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=Playfair+Display:ital,wght@0,700;1,700&display=swap" rel="stylesheet">

View File

@ -1,92 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>개인정보처리방침 - ADO2</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; line-height: 1.8; color: #333; max-width: 800px; margin: 0 auto; padding: 40px 20px; }
h1 { font-size: 28px; border-bottom: 2px solid #eee; padding-bottom: 12px; margin-bottom: 30px; }
h2 { font-size: 19px; margin-top: 40px; color: #111; }
p, li { font-size: 15px; color: #555; margin-bottom: 10px; }
ul { padding-left: 20px; }
.google-policy { background: #f0f7ff; border-left: 4px solid #4285f4; padding: 16px 20px; margin: 24px 0; border-radius: 4px; }
.google-policy p { color: #1a1a2e; margin: 0; }
.updated { color: #888; font-size: 14px; margin-bottom: 30px; }
</style>
</head>
<body>
<h1>개인정보처리방침 (Privacy Policy)</h1>
<p class="updated"><strong>시행일:</strong> 2026년 5월 7일 &nbsp;|&nbsp; <strong>최종 수정일:</strong> 2026년 5월 27일</p>
<p>㈜에이아이오투오(이하 "회사")는 AI 마케팅 자동화 서비스 ADO2(이하 "서비스")를 제공함에 있어 사용자의 개인정보를 중요시하며, 「개인정보 보호법」 등 관련 법령을 성실히 준수합니다.</p>
<h2>1. 수집하는 개인정보 항목 및 수집 방법</h2>
<p>회사는 서비스 제공을 위해 아래와 같은 개인정보를 수집합니다.</p>
<ul>
<li><strong>카카오 로그인을 통한 수집:</strong> 이름, 이메일 주소, 프로필 사진 — 서비스 회원 가입 및 로그인에 사용</li>
<li><strong>Google 사용자 프로필 :</strong> YouTube 채널 연동 시 연결된 구글 계정의 이름 및 프로필 사진 확인 — 채널 소유자 식별 목적으로만 사용</li>
<li><strong>YouTube Data API :</strong> 채널 정보, 동영상 목록, 재생목록 등 YouTube 계정 데이터 (읽기 전용)</li>
<li><strong>YouTube Data API :</strong> AI가 생성한 콘텐츠를 YouTube에 업로드하기 위한 동영상 관리 권한</li>
<li><strong>YouTube Analytics API :</strong> 채널 및 동영상의 조회수, 시청 시간 등 성과 지표 데이터 (읽기 전용)</li>
<li><strong>서비스 이용 과정에서 자동 수집:</strong> 접속 IP, 쿠키, 서비스 이용 기록</li>
</ul>
<h2>2. Google 사용자 데이터의 수집 및 이용 목적</h2>
<p>회사가 접근하는 Google 사용자 데이터는 아래 목적으로만 사용되며, 명시된 목적 외에는 사용하지 않습니다.</p>
<ul>
<li><strong>Google 사용자 프로필:</strong> YouTube 채널 연동 과정에서 연결 대상 구글 계정을 식별하는 용도로만 사용하며, 서비스 로그인에는 사용되지 않습니다.</li>
<li><strong>YouTube 계정 데이터 (읽기):</strong> 기존 채널 정보·동영상 현황을 분석하여 AI 콘텐츠 전략 수립에 활용합니다.</li>
<li><strong>YouTube 동영상 업로드:</strong> AI가 생성한 영상을 사용자의 YouTube 채널에 업로드합니다. 업로드는 반드시 사용자의 명시적 요청에 의해서만 실행됩니다.</li>
<li><strong>YouTube 분석 데이터:</strong> 채널 성과 지표를 분석하여 AI 마케팅 전략 수립 및 콘텐츠 최적화에 활용합니다.</li>
</ul>
<div class="google-policy">
<p><strong>[Google API 서비스 사용자 데이터 정책 준수]</strong><br><br>
㈜에이아이오투오가 운영하는 ADO2 서비스가 Google API로부터 수신한 정보의 사용 및 타 앱으로의 전송은,
<a href="https://developers.google.com/terms/api-services-user-data-policy" target="_blank">Google API 서비스 사용자 데이터 정책</a>
제한적 사용(Limited Use) 요건을 포함한 모든 정책을 엄격히 준수합니다.<br><br>
특히, Google API로부터 수신한 데이터는 <strong>AI·ML 모델 학습에 사용되지 않으며</strong>, 사용자가 명시적으로 요청한 서비스 기능 제공 목적 외에는 사용·전송·공유되지 않습니다.</p>
</div>
<h2>3. Google 사용자 데이터의 제3자 공유</h2>
<p>회사는 Google API로부터 수신한 사용자 데이터를 아래 경우를 제외하고 어떠한 제3자에게도 판매하거나 공유하지 않습니다.</p>
<ul>
<li><strong>AI 콘텐츠 생성 처리:</strong> 서비스 제공에 필수적인 AI 생성 기능 수행을 위해 처리 서버로 전달될 수 있으며, 해당 처리는 서비스 제공 목적으로만 사용됩니다.</li>
<li><strong>법적 요구:</strong> 관련 법령에 의거한 수사기관 등의 적법한 요청이 있는 경우</li>
</ul>
<p>Google 사용자 데이터는 광고, 마케팅, 프로파일링 목적으로 사용되지 않습니다.</p>
<h2>4. Google 사용자 데이터의 저장 및 보안</h2>
<ul>
<li>Google OAuth 액세스 토큰 및 리프레시 토큰은 암호화된 상태로 보관됩니다.</li>
<li>Google API를 통해 읽어온 데이터는 서비스 기능 처리에 필요한 최소한의 시간 동안만 임시 보유하며, 처리 완료 후 삭제됩니다.</li>
<li>서버와의 모든 통신은 TLS(HTTPS)를 통해 암호화됩니다.</li>
<li>데이터 접근 권한은 서비스 운영에 필요한 최소 인원에게만 부여됩니다.</li>
</ul>
<h2>5. 개인정보의 보유 및 이용 기간 / 데이터 삭제</h2>
<p>원칙적으로 회원 탈퇴 또는 개인정보 수집·이용 목적이 달성된 후에는 해당 정보를 지체 없이 파기합니다. 단, 관련 법령에 따라 보존이 필요한 경우 해당 기간 동안 보관합니다.</p>
<ul>
<li>전자상거래 관련 기록: 5년 (전자상거래 등에서의 소비자보호에 관한 법률)</li>
<li>접속 로그 기록: 3개월 (통신비밀보호법)</li>
<li>Google 연동 토큰: 연동 해제 또는 회원 탈퇴 즉시 삭제</li>
</ul>
<p><strong>데이터 삭제 요청 방법:</strong> 아래 이메일(<a href="mailto:o2oteam@o2o.kr">o2oteam@o2o.kr</a>)로 요청하시면 <strong>영업일 기준 7일 이내</strong>에 처리 결과를 안내해 드립니다. Google 계정 연동 해제는 서비스 내 계정 설정에서 직접 처리하실 수 있으며, 해제 즉시 관련 토큰이 삭제됩니다.</p>
<h2>6. 정보주체의 권리 및 행사 방법</h2>
<p>사용자는 언제든지 자신의 개인정보에 대한 열람, 수정, 삭제, 처리 정지를 요청할 수 있습니다. 서비스 내 계정 설정에서 직접 처리하거나 아래 문의처로 연락해 주시기 바랍니다.</p>
<p>또한, <a href="https://myaccount.google.com/permissions" target="_blank">Google 계정 권한 관리 페이지</a>에서 ADO2 앱의 Google 데이터 접근 권한을 언제든지 직접 취소하실 수 있습니다.</p>
<h2>7. 개인정보 보호책임자 및 문의처</h2>
<p>개인정보 보호와 관련된 불만 처리 및 피해 구제에 관한 사항은 아래로 문의해 주시기 바랍니다.</p>
<ul>
<li><strong>회사명:</strong> ㈜에이아이오투오</li>
<li><strong>서비스명:</strong> ADO2</li>
<li><strong>이메일:</strong> <a href="mailto:o2oteam@o2o.kr">o2oteam@o2o.kr</a></li>
<li><strong>웹사이트:</strong> https://demo.castad.net</li>
</ul>
<p style="margin-top:40px; font-size:14px; color:#999;">본 방침은 2026년 5월 7일부터 시행됩니다. 최종 수정일: 2026년 5월 27일</p>
</body>
</html>

View File

@ -1,66 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>서비스 약관 - ADO2</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; line-height: 1.8; color: #333; max-width: 800px; margin: 0 auto; padding: 40px 20px; }
h1 { font-size: 28px; border-bottom: 2px solid #eee; padding-bottom: 12px; margin-bottom: 30px; }
h2 { font-size: 19px; margin-top: 40px; color: #111; }
p, li { font-size: 15px; color: #555; margin-bottom: 10px; }
ul { padding-left: 20px; }
.updated { color: #888; font-size: 14px; margin-bottom: 30px; }
</style>
</head>
<body>
<h1>서비스 이용약관 (Terms of Service)</h1>
<p class="updated"><strong>시행일:</strong> 2026년 5월 7일 &nbsp;|&nbsp; <strong>최종 수정일:</strong> 2026년 5월 21일</p>
<h2>제 1 조 (목적)</h2>
<p>본 약관은 ㈜에이아이오투오(이하 "회사")가 제공하는 AI 마케팅 자동화 서비스 ADO2(이하 "서비스")의 이용과 관련하여, 회사와 이용자(이하 "회원") 간의 권리, 의무 및 책임사항을 규정함을 목적으로 합니다.</p>
<h2>제 2 조 (용어의 정의)</h2>
<ul>
<li>"서비스"라 함은 회사가 제공하는 ADO2 AI 마케팅 자동화 플랫폼 및 관련 제반 기능을 의미합니다.</li>
<li>"회원"이라 함은 본 약관에 동의하고 회사와 이용계약을 체결하여 서비스를 이용하는 자를 말합니다.</li>
<li>"콘텐츠"라 함은 서비스 내에서 AI가 생성하거나 회원이 등록하는 텍스트, 이미지, 음악, 영상 등 일체의 자료를 말합니다.</li>
</ul>
<h2>제 3 조 (약관의 효력 및 변경)</h2>
<p>회사는 본 약관의 내용을 서비스 화면에 게시하며, 관련 법령을 위배하지 않는 범위에서 약관을 개정할 수 있습니다. 약관이 변경되는 경우 시행일 7일 전부터 공지합니다.</p>
<h2>제 4 조 (서비스의 제공 및 변경)</h2>
<p>회사는 AI 기반 마케팅 콘텐츠(가사, 이미지, 영상 등) 자동 생성, Google·YouTube 등 외부 플랫폼 연동, SNS 자동 배포 등의 서비스를 제공합니다. 운영상·기술상의 필요에 따라 서비스의 전부 또는 일부를 변경할 수 있습니다.</p>
<h2>제 5 조 (회원의 의무)</h2>
<ul>
<li>타인의 Google 계정 등 외부 서비스 계정을 무단으로 도용하여 서비스를 이용해서는 안 됩니다.</li>
<li>스팸 발송, API 한도 고의 초과, 허위 정보 등록 등 비정상적인 방법으로 서비스를 이용해서는 안 됩니다.</li>
<li>회사 또는 제3자의 지식재산권을 침해해서는 안 됩니다.</li>
</ul>
<h2>제 6 조 (외부 API 연동 및 데이터 활용)</h2>
<p>서비스는 Google, YouTube, Naver 등의 제3자 API를 활용하여 마케팅 자동화 기능을 제공합니다. 특히 YouTube 서비스 연동을 위해 YouTube Data API 및 YouTube Analytics API를 사용하며, 이를 통해 수집·처리되는 데이터는 <a href="/privacy.html">개인정보처리방침</a>에 따라 관리됩니다. 회원은 각 외부 서비스의 이용약관 및 정책을 준수할 의무가 있으며, 외부 API 제공사의 정책 변경으로 인한 서비스 제한에 대해 회사는 면책됩니다.</p>
<p>YouTube API 서비스 이용과 관련하여 <a href="https://www.youtube.com/t/terms" target="_blank">YouTube 이용약관</a><a href="https://policies.google.com/privacy" target="_blank">Google 개인정보처리방침</a>이 함께 적용됩니다.</p>
<h2>제 7 조 (AI 생성 콘텐츠의 권리)</h2>
<p>서비스 내에서 AI가 생성한 콘텐츠에 대한 권리 관계는 관련 법령 및 회사의 별도 정책에 따릅니다. 회원이 직접 입력한 정보(매장 URL, 상호명 등)를 기반으로 생성된 콘텐츠에 대한 책임은 회원에게 있습니다.</p>
<h2>제 8 조 (책임 제한)</h2>
<p>회사는 천재지변, 외부 플랫폼(Google, YouTube, Naver 등)의 장애, 통신 장애 등 불가항력으로 서비스를 제공할 수 없는 경우 책임이 면제됩니다.</p>
<h2>제 9 조 (준거법 및 재판관할)</h2>
<p>본 약관과 관련된 분쟁은 대한민국 법을 준거법으로 하며, 소송은 회사의 소재지를 관할하는 법원에 제소합니다.</p>
<h2>문의처</h2>
<ul>
<li><strong>회사명:</strong> ㈜에이아이오투오</li>
<li><strong>서비스명:</strong> ADO2</li>
<li><strong>이메일:</strong> o2oteam@o2o.kr</li>
<li><strong>웹사이트:</strong> https://demo.castad.net</li>
</ul>
<p style="margin-top:40px; font-size:14px; color:#999;">본 약관은 2026년 5월 7일부터 시행됩니다.</p>
</body>
</html>

View File

@ -12,11 +12,7 @@ import GenerationFlow from './pages/Dashboard/GenerationFlow';
import SocialConnectSuccess from './pages/Social/SocialConnectSuccess';
import SocialConnectError from './pages/Social/SocialConnectError';
import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback';
import ADO2ContentsPage from './pages/Dashboard/ADO2ContentsPage';
import VideoDetailPage from './components/VideoDetailPage';
import LoginPromptModal from './components/LoginPromptModal';
import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, AutocompleteRequest } from './utils/api';
import { saveSearchHistory } from './components/SearchHistory/useSearchHistory';
import { crawlUrl, autocomplete, kakaoCallback, isLoggedIn, saveTokens, AutocompleteRequest } from './utils/api';
import { CrawlingResponse } from './types/api';
type ViewMode = 'landing' | 'loading' | 'analysis' | 'login' | 'generation_flow';
@ -100,7 +96,6 @@ const App: React.FC = () => {
parseSavedAnalysisData()
);
const [error, setError] = useState<string | null>(null);
const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
const [scrollProgress, setScrollProgress] = useState(0);
const [isProcessingCallback, setIsProcessingCallback] = useState(false);
@ -147,12 +142,6 @@ const App: React.FC = () => {
localStorage.removeItem('castad_wizard_step');
localStorage.removeItem('castad_active_item');
}
const redirectPath = sessionStorage.getItem('castad_login_redirect');
sessionStorage.removeItem('castad_login_redirect');
if (redirectPath && redirectPath !== '/') {
window.location.href = redirectPath;
return;
}
setInitialTab('새 프로젝트 만들기');
setViewMode('generation_flow');
} catch (err) {
@ -267,7 +256,6 @@ const App: React.FC = () => {
if (!url.trim()) return;
setViewMode('loading');
setIsAnalysisComplete(false);
setError(null);
try {
@ -280,11 +268,11 @@ const App: React.FC = () => {
setAnalysisData(data);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
saveSearchHistory({ type: 'url', value: url });
setIsAnalysisComplete(true);
setViewMode('analysis');
} catch (err) {
console.error('Crawling failed:', err);
setError(t('app.analysisError'));
const errorMessage = err instanceof Error ? err.message : t('app.analysisError');
setError(errorMessage);
setViewMode('landing');
}
};
@ -292,7 +280,6 @@ const App: React.FC = () => {
// 업체명 자동완성으로 분석 시작
const handleAutocomplete = async (request: AutocompleteRequest) => {
setViewMode('loading');
setIsAnalysisComplete(false);
setError(null);
try {
@ -305,35 +292,11 @@ const App: React.FC = () => {
setAnalysisData(data);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
saveSearchHistory({ type: 'name', value: request.title.replace(/<[^>]*>/g, ''), address: request.address, roadAddress: request.roadAddress });
setIsAnalysisComplete(true);
setViewMode('analysis');
} catch (err) {
console.error('Autocomplete failed:', err);
setError(t('app.autocompleteError'));
setViewMode('landing');
}
};
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
const handleManualInput = async (businessName: string, address: string) => {
setViewMode('loading');
setIsAnalysisComplete(false);
setError(null);
try {
const data = await marketingAnalysis(businessName, address);
if (!validateCrawlingResponse(data)) {
throw new Error(t('app.autocompleteError'));
}
setAnalysisData(data);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
saveSearchHistory({ type: 'name', value: businessName, address, roadAddress: address });
setIsAnalysisComplete(true);
} catch (err) {
console.error('Marketing analysis failed:', err);
setError(t('app.autocompleteError'));
const errorMessage = err instanceof Error ? err.message : t('app.autocompleteGeneralError');
setError(errorMessage);
setViewMode('landing');
}
};
@ -420,13 +383,6 @@ const App: React.FC = () => {
return <SocialConnectError />;
}
// 영상 상세 페이지 (/video/{id}) — 인증 게이트는 VideoDetailPage 내부에서 처리
const videoDetailMatch = pathname.match(/^\/video\/([^/]+)$/);
if (videoDetailMatch) {
return <VideoDetailPage videoId={videoDetailMatch[1]} />;
}
// 카카오 콜백 처리 중 로딩 화면 표시
if (isProcessingCallback) {
return (
@ -439,12 +395,7 @@ const App: React.FC = () => {
}
if (viewMode === 'loading') {
return (
<LoadingSection
isComplete={isAnalysisComplete}
onComplete={() => setViewMode('analysis')}
/>
);
return <LoadingSection />;
}
if (viewMode === 'analysis' && analysisData) {
@ -486,7 +437,6 @@ const App: React.FC = () => {
<HeroSection
onAnalyze={handleStartAnalysis}
onAutocomplete={handleAutocomplete}
onManualInput={handleManualInput}
onTestData={handleTestData}
onNext={() => scrollToSection(1)}
error={error}

View File

@ -1,95 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
interface BusinessNameInputModalProps {
onClose: () => void;
onSubmit: (businessName: string, address: string) => void;
}
const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose, onSubmit }) => {
const { t } = useTranslation();
const [businessName, setBusinessName] = useState('');
const [address, setAddress] = useState('');
useEffect(() => {
document.body.style.overflow = 'hidden';
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = '';
document.removeEventListener('keydown', handleKeyDown);
};
}, [onClose]);
const isValid = businessName.trim().length > 0 && address.trim().length > 0;
const handleSubmit = () => {
if (!isValid) return;
onSubmit(businessName.trim(), address.trim());
onClose();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && isValid) handleSubmit();
};
return (
<div className="manual-modal-backdrop" onClick={onClose}>
<div className="manual-modal" onClick={e => e.stopPropagation()}>
<div className="manual-modal-header">
<span className="manual-modal-title">{t('landing.hero.manualModalTitle')}</span>
<button className="manual-modal-close" onClick={onClose}></button>
</div>
<div className="manual-modal-body">
<div className="manual-modal-field">
<label className="manual-modal-label">{t('landing.hero.manualLabelName')}</label>
<input
type="text"
className="manual-modal-input"
placeholder={t('landing.hero.manualPlaceholderName')}
value={businessName}
onChange={e => setBusinessName(e.target.value)}
onKeyDown={handleKeyDown}
maxLength={50}
autoFocus
/>
</div>
<div className="manual-modal-field">
<label className="manual-modal-label">{t('landing.hero.manualLabelAddress')}</label>
<input
type="text"
className="manual-modal-input"
placeholder={t('landing.hero.manualPlaceholderAddress')}
value={address}
onChange={e => setAddress(e.target.value)}
onKeyDown={handleKeyDown}
maxLength={100}
/>
</div>
<div className="manual-modal-actions">
<button type="button" className="manual-modal-cancel" onClick={onClose}>
{t('common.cancel')}
</button>
<button
type="button"
className="manual-modal-submit"
onClick={handleSubmit}
disabled={!isValid}
>
{t('landing.hero.analyzeButton')}
</button>
</div>
</div>
</div>
</div>
);
};
export default BusinessNameInputModal;

View File

@ -1,145 +0,0 @@
import React, { useState, useEffect } from 'react';
const REGIONS: { label: string; cities: string[] }[] = [
{
label: '특별시 / 광역시',
cities: ['서울시', '부산시', '대구시', '인천시', '광주시', '대전시', '울산시', '세종시'],
},
{
label: '경기도',
cities: [
'수원시', '성남시', '고양시', '용인시', '부천시', '안산시', '안양시', '남양주시',
'화성시', '평택시', '의정부시', '시흥시', '파주시', '김포시', '광주시', '광명시',
'군포시', '하남시', '오산시', '이천시', '안성시', '구리시', '양주시', '포천시',
'여주시', '동두천시', '과천시', '가평군', '양평군', '연천군',
],
},
{
label: '강원도',
cities: [
'춘천시', '원주시', '강릉시', '동해시', '태백시', '속초시', '삼척시',
'홍천군', '횡성군', '영월군', '평창군', '정선군', '철원군', '화천군',
'양구군', '인제군', '고성군', '양양군',
],
},
{
label: '충청북도',
cities: [
'청주시', '충주시', '제천시',
'보은군', '옥천군', '영동군', '증평군', '진천군', '괴산군', '음성군', '단양군',
],
},
{
label: '충청남도',
cities: [
'천안시', '공주시', '보령시', '아산시', '서산시', '논산시', '계룡시', '당진시',
'금산군', '부여군', '서천군', '청양군', '홍성군', '예산군', '태안군',
],
},
{
label: '전라북도',
cities: [
'전주시', '군산시', '익산시', '정읍시', '남원시', '김제시',
'완주군', '진안군', '무주군', '장수군', '임실군', '순창군', '고창군', '부안군',
],
},
{
label: '전라남도',
cities: [
'목포시', '여수시', '순천시', '나주시', '광양시',
'담양군', '곡성군', '구례군', '고흥군', '보성군', '화순군', '장흥군', '강진군',
'해남군', '영암군', '무안군', '함평군', '영광군', '장성군', '완도군', '진도군', '신안군',
],
},
{
label: '경상북도',
cities: [
'포항시', '경주시', '김천시', '안동시', '구미시', '영주시', '영천시', '상주시', '문경시', '경산시',
'의성군', '청송군', '영양군', '영덕군', '청도군', '고령군', '성주군', '칠곡군',
'예천군', '봉화군', '울진군', '울릉군',
],
},
{
label: '경상남도',
cities: [
'창원시', '진주시', '통영시', '사천시', '김해시', '밀양시', '거제시', '양산시',
'의령군', '함안군', '창녕군', '고성군', '남해군', '하동군', '산청군', '함양군', '거창군', '합천군',
],
},
{
label: '제주도',
cities: ['제주시', '서귀포시'],
},
];
interface CitySelectModalProps {
selected: string;
onSelect: (city: string) => void;
onClose: () => void;
}
const CitySelectModal: React.FC<CitySelectModalProps> = ({ selected, onSelect, onClose }) => {
const [activeRegion, setActiveRegion] = useState<string | null>(
() => REGIONS.find(r => r.cities.includes(selected))?.label ?? null
);
useEffect(() => {
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}, []);
const cities = REGIONS.find(r => r.label === activeRegion)?.cities ?? [];
const handleCityClick = (city: string) => {
onSelect(city === selected ? '' : city);
onClose();
};
return (
<div className="city-modal-backdrop" onClick={onClose}>
<div className="city-modal" onClick={e => e.stopPropagation()}>
<div className="city-modal-header">
<span className="city-modal-title">
{activeRegion ? (
<>
<button className="city-modal-back" onClick={() => setActiveRegion(null)}></button>
{activeRegion}
</>
) : '지역 선택'}
</span>
<button className="city-modal-close" onClick={onClose}></button>
</div>
{activeRegion === null ? (
<div className="city-modal-grid">
{REGIONS.map(r => (
<button
key={r.label}
className={`city-modal-region-item ${r.cities.includes(selected) ? 'has-selected' : ''}`}
onClick={() => setActiveRegion(r.label)}
>
<span>{r.label}</span>
{r.cities.includes(selected) && <span className="city-modal-region-badge">{selected}</span>}
<span className="city-modal-arrow"></span>
</button>
))}
</div>
) : (
<div className="city-modal-item-wrap">
{cities.map(city => (
<button
key={city}
className={`city-modal-item ${selected === city ? 'active' : ''}`}
onClick={() => handleCityClick(city)}
>
{city}
</button>
))}
</div>
)}
</div>
</div>
);
};
export default CitySelectModal;

View File

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

View File

@ -1,100 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { getKakaoLoginUrl } from '../utils/api';
interface LoginPromptModalProps {
onClose: () => void;
}
const LoginPromptModal: React.FC<LoginPromptModalProps> = ({ onClose }) => {
const { t } = useTranslation();
const handleLogin = async () => {
try {
sessionStorage.setItem('castad_login_redirect', window.location.pathname);
const response = await getKakaoLoginUrl();
window.location.href = response.auth_url;
} catch (err) {
console.error('Failed to get Kakao login URL:', err);
}
};
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(255, 255, 255, 0.25)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
}}
onClick={onClose}
>
<div
style={{
background: '#01282A',
border: '1px solid rgba(155, 202, 204, 0.2)',
borderRadius: '16px',
padding: '40px 32px',
width: '360px',
maxWidth: '90vw',
textAlign: 'center',
position: 'relative',
}}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={onClose}
aria-label="닫기"
style={{
position: 'absolute',
top: '16px',
right: '16px',
background: 'none',
border: 'none',
cursor: 'pointer',
color: '#9BCACC',
padding: '4px',
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<line x1="4" y1="4" x2="16" y2="16"/>
<line x1="16" y1="4" x2="4" y2="16"/>
</svg>
</button>
<h2 style={{ color: '#FFFFFF', fontSize: '20px', fontWeight: 600, marginBottom: '12px' }}>
{t('loginPrompt.title')}
</h2>
<button
onClick={handleLogin}
style={{
width: '50%',
margin: '0 auto',
padding: '14px',
background: '#FEE500',
color: '#3C1E1E',
border: 'none',
borderRadius: '8px',
fontSize: '15px',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M10 2C5.582 2 2 4.91 2 8.5c0 2.26 1.37 4.25 3.44 5.44L4.6 17.1a.3.3 0 0 0 .44.33l4.03-2.67c.3.03.62.04.93.04 4.418 0 8-2.91 8-6.5S14.418 2 10 2z" fill="#3C1E1E"/>
</svg>
{t('loginPrompt.loginBtn')}
</button>
</div>
</div>
);
};
export default LoginPromptModal;

View File

@ -1,58 +0,0 @@
import React from 'react';
import { SearchHistoryItem } from './useSearchHistory';
interface SearchHistoryDropdownProps {
items: SearchHistoryItem[];
onSelect: (item: SearchHistoryItem) => void;
onDelete: (e: React.MouseEvent, value: string) => void;
className?: string;
itemClassName?: string;
titleClassName?: string;
addressClassName?: string;
}
const SearchHistoryDropdown: React.FC<SearchHistoryDropdownProps> = ({
items,
onSelect,
onDelete,
className = 'url-input-autocomplete-dropdown',
itemClassName = 'url-input-autocomplete-item',
titleClassName = 'url-input-autocomplete-title',
addressClassName = 'url-input-autocomplete-address',
}) => {
if (items.length === 0) return null;
return (
<div className={className}>
{items.map((item, index) => (
<div
key={index}
className={itemClassName}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<button
type="button"
style={{ flex: 1, textAlign: 'left', background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
onMouseDown={(e) => { e.preventDefault(); onSelect(item); }}
>
<div className={titleClassName}>{item.value}</div>
{item.roadAddress && (
<div className={addressClassName}>{item.roadAddress}</div>
)}
</button>
<button
type="button"
onMouseDown={(e) => onDelete(e, item.value)}
style={{ color:'#737983', background: 'none', border: 'none', cursor: 'pointer', padding: '0 4px', opacity: 0.8, flexShrink: 0 }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
))}
</div>
);
};
export default SearchHistoryDropdown;

View File

@ -1,56 +0,0 @@
import { useState, useEffect } from 'react';
export const SEARCH_HISTORY_KEY = 'castad_search_history';
export const SEARCH_HISTORY_MAX = 5;
export interface SearchHistoryItem {
type: 'url' | 'name';
value: string;
address?: string;
roadAddress?: string;
}
export const saveSearchHistory = (item: SearchHistoryItem) => {
try {
const saved = localStorage.getItem(SEARCH_HISTORY_KEY);
const history: SearchHistoryItem[] = saved ? JSON.parse(saved) : [];
const filtered = history.filter(h => h.value !== item.value);
const updated = [item, ...filtered].slice(0, SEARCH_HISTORY_MAX);
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(updated));
} catch { /* ignore */ }
};
export const useSearchHistory = (searchType: 'url' | 'name') => {
const [history, setHistory] = useState<SearchHistoryItem[]>([]);
const [showHistory, setShowHistory] = useState(false);
useEffect(() => {
try {
const saved = localStorage.getItem(SEARCH_HISTORY_KEY);
if (saved) setHistory(JSON.parse(saved));
} catch { /* ignore */ }
}, []);
const filteredHistory = history.filter(h => h.type === searchType);
const deleteItem = (value: string) => {
const updated = history.filter(h => h.value !== value);
setHistory(updated);
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(updated));
if (updated.filter(h => h.type === searchType).length === 0) {
setShowHistory(false);
}
};
const openHistory = (inputValue: string) => {
if (!inputValue && filteredHistory.length > 0) setShowHistory(true);
};
const closeHistory = () => setShowHistory(false);
const hideOnInput = (value: string) => {
if (value) setShowHistory(false);
};
return { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem };
};

View File

@ -12,13 +12,11 @@ interface SidebarItemProps {
isCollapsed: boolean;
isDisabled?: boolean;
onClick?: () => void;
id?: string;
}
const SidebarItem: React.FC<SidebarItemProps> = ({ icon, label, isActive, isCollapsed, isDisabled, onClick, id }) => {
const SidebarItem: React.FC<SidebarItemProps> = ({ icon, label, isActive, isCollapsed, isDisabled, onClick }) => {
return (
<div
id={id}
onClick={isDisabled ? undefined : onClick}
className={`sidebar-item ${isActive ? 'active' : ''} ${isCollapsed ? 'collapsed' : ''} ${isDisabled ? 'disabled' : ''}`}
title={isCollapsed ? label : ""}
@ -37,13 +35,9 @@ interface SidebarProps {
onHome?: () => void;
userInfo?: UserMeResponse | null;
onLogout?: () => void;
credits?: number | null;
tutorialAvailable?: boolean;
tutorialEnabled?: boolean;
onToggleTutorial?: () => void;
}
const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userInfo, onLogout, credits, tutorialAvailable, tutorialEnabled, onToggleTutorial }) => {
const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userInfo, onLogout }) => {
const { t } = useTranslation();
const [isCollapsed, setIsCollapsed] = useState(false);
const [isMobileOpen, setIsMobileOpen] = useState(false);
@ -88,8 +82,8 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
{ 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: 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="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('contentCalendar.title'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></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: '콘텐츠 캘린더', disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></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> },
];
@ -144,13 +138,6 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
{menuItems.map(item => (
<SidebarItem
key={item.id}
id={
item.id === '내 정보'
? 'sidebar-my-info'
: item.id === 'ADO2 콘텐츠'
? 'sidebar-ado2-contents'
: undefined
}
icon={item.icon}
label={item.label}
isCollapsed={isCollapsed}
@ -162,24 +149,6 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
</div>
<div className="sidebar-footer">
{/* 모바일 전용 튜토리얼 토글 */}
{tutorialAvailable && (
<button
className={`sidebar-tutorial-btn ${tutorialEnabled ? 'active' : ''}`}
onClick={() => onToggleTutorial?.()}
title={tutorialEnabled ? t('sidebar.tutorialOff') : t('sidebar.tutorialOn')}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<circle cx="12" cy="12" r="10"/>
<path d="M12 8v4l3 3"/>
</svg>
<span className="sidebar-tutorial-label">{t('sidebar.tutorial')}</span>
<span className={`sidebar-tutorial-badge ${tutorialEnabled ? 'on' : 'off'}`}>
{tutorialEnabled ? 'ON' : 'OFF'}
</span>
</button>
)}
<div className="sidebar-language-switch">
<LanguageSwitch isCollapsed={isCollapsed} />
</div>
@ -202,9 +171,6 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
{!isCollapsed && (
<div className="flex-1 min-w-0">
<p className="profile-name">{userInfo?.nickname || t('sidebar.defaultUser')}</p>
{credits !== null && credits !== undefined && (
<p className="profile-credits">{t('sidebar.credits', { count: credits })}</p>
)}
</div>
)}
</div>
@ -225,12 +191,12 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
target="_blank"
rel="noopener noreferrer"
className={`sidebar-inquiry-btn ${isCollapsed ? 'collapsed' : ''}`}
title={t('sidebar.inquiry')}
title="고객의견"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
{!isCollapsed && <span>{t('sidebar.inquiry')}</span>}
{!isCollapsed && <span></span>}
</a>
</div>
</div>

View File

@ -4,15 +4,11 @@ import { useTranslation } from 'react-i18next';
import { getSocialAccounts, uploadToSocial, waitForUploadComplete, TokenExpiredError, handleSocialReconnect, getAutoSeoYoutube } from '../utils/api';
import { SocialAccount, VideoListItem, SocialUploadStatusResponse } from '../types/api';
import UploadProgressModal, { UploadStatus } from './UploadProgressModal';
import { useTutorial } from './Tutorial/useTutorial';
import { TUTORIAL_KEYS } from './Tutorial/tutorialSteps';
import TutorialOverlay from './Tutorial/TutorialOverlay';
interface SocialPostingModalProps {
isOpen: boolean;
onClose: () => void;
video: VideoListItem | null;
onGoToCalendar?: () => void;
}
type PrivacyType = 'public' | 'unlisted' | 'private';
@ -117,11 +113,9 @@ const MiniCalendar: React.FC<{
const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
isOpen,
onClose,
video,
onGoToCalendar,
video
}) => {
const { t } = useTranslation();
const tutorial = useTutorial();
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
const [selectedChannel, setSelectedChannel] = useState<string>('');
const [title, setTitle] = useState('');
@ -141,11 +135,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
const [videoMeta, setVideoMeta] = useState<{ width: number; height: number; duration: number } | null>(null);
const channelDropdownRef = useRef<HTMLDivElement>(null);
const privacyDropdownRef = useRef<HTMLDivElement>(null);
const hasBeenOpenedRef = useRef(false);
const loadedForTaskIdRef = useRef<string | null>(null);
const loadedAtRef = useRef<number>(0);
const seoCache = useRef<Map<string, { title: string; description: string; tags: string }>>(new Map());
const SEO_CACHE_TTL = 50 * 60 * 1000;
// Upload progress modal state
const [showUploadProgress, setShowUploadProgress] = useState(false);
@ -156,7 +145,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
// 업로드 정보 (모달이 닫힌 후에도 유지)
const [uploadVideoTitle, setUploadVideoTitle] = useState<string>('');
const [uploadChannelName, setUploadChannelName] = useState<string>('');
const [uploadIsScheduled, setUploadIsScheduled] = useState(false);
// 드롭다운 외부 클릭 시 닫기
useEffect(() => {
@ -183,56 +171,13 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
return () => { document.body.style.overflow = ''; };
}, [isOpen]);
// 모달 오픈/닫힘 시 튜토리얼 트리거
useEffect(() => {
if (isOpen) {
hasBeenOpenedRef.current = true;
if (!tutorial.hasSeen(TUTORIAL_KEYS.UPLOAD_MODAL)) {
const timer = setTimeout(() => {
tutorial.startTutorial(TUTORIAL_KEYS.UPLOAD_MODAL);
}, 400);
return () => clearTimeout(timer);
}
} else if (hasBeenOpenedRef.current && !tutorial.hasSeen(TUTORIAL_KEYS.FEEDBACK)) {
hasBeenOpenedRef.current = false;
tutorial.startTutorial(TUTORIAL_KEYS.FEEDBACK);
}
}, [isOpen]);
// SEO 생성 완료 시 UPLOAD_FORM 튜토리얼 트리거
useEffect(() => {
if (!isLoadingAutoDescription && isOpen && !tutorial.hasSeen(TUTORIAL_KEYS.UPLOAD_FORM)) {
const timer = setTimeout(() => {
tutorial.startTutorial(TUTORIAL_KEYS.UPLOAD_FORM);
}, 400);
return () => clearTimeout(timer);
}
}, [isLoadingAutoDescription]);
// 소셜 계정 로드
useEffect(() => {
if (!isOpen) return;
const now = Date.now();
loadSocialAccounts();
const taskId = video?.task_id ?? null;
const expired = now - loadedAtRef.current > SEO_CACHE_TTL;
if (taskId && (taskId !== loadedForTaskIdRef.current || expired)) {
loadedForTaskIdRef.current = taskId;
loadedAtRef.current = now;
if (isOpen) {
loadSocialAccounts();
loadAutocomplete();
} else if (taskId) {
const cached = seoCache.current.get(taskId);
if (cached) {
setTitle(cached.title);
setDescription(cached.description);
setTags(cached.tags);
}
}
}, [isOpen, video?.task_id]);
}, [isOpen, video]);
const loadSocialAccounts = async () => {
setIsLoadingAccounts(true);
@ -241,9 +186,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
const activeAccounts = response.accounts?.filter(acc => acc.is_active) || [];
setSocialAccounts(activeAccounts);
if (activeAccounts.length > 0) {
const lastUsed = localStorage.getItem('lastUsedSocialChannel');
const defaultAccount = activeAccounts.find(acc => acc.platform_user_id === lastUsed) || activeAccounts[0];
setSelectedChannel(defaultAccount.platform_user_id);
setSelectedChannel(activeAccounts[0].platform_user_id);
}
} catch (error) {
if (error instanceof TokenExpiredError) {
@ -273,11 +216,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
if (autoSeoResponse.title) setTitle(autoSeoResponse.title);
if (autoSeoResponse.description) setDescription(autoSeoResponse.description);
if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(','));
seoCache.current.set(video.task_id, {
title: autoSeoResponse.title || '',
description: autoSeoResponse.description || '',
tags: autoSeoResponse.keywords?.join(',') || '',
});
} catch (error) {
console.error('Failed to load autocomplete:', error);
// 실패해도 사용자에게 별도 알림 없이 조용히 처리
@ -329,7 +267,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
// 업로드 정보 저장 (모달이 닫힌 후에도 유지)
setUploadVideoTitle(title.trim());
setUploadChannelName(selectedAcc.display_name);
setUploadIsScheduled(publishTime === 'schedule');
setShowUploadProgress(true);
try {
@ -367,8 +304,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
throw new Error(uploadResponse.message || t('social.uploadStartFailed'));
}
localStorage.setItem('lastUsedSocialChannel', selectedAcc.platform_user_id);
if (publishTime === 'schedule') {
// 예약 업로드: 폴링 없이 바로 완료 처리
setUploadStatus('completed');
@ -431,7 +366,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
setUploadErrorMessage(undefined);
setUploadVideoTitle('');
setUploadChannelName('');
setUploadIsScheduled(false);
};
const handleClose = () => {
@ -460,27 +394,13 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
channelName={uploadChannelName || selectedAccount?.display_name || ''}
youtubeUrl={uploadYoutubeUrl}
errorMessage={uploadErrorMessage}
isScheduled={uploadIsScheduled}
onGoToCalendar={onGoToCalendar}
isScheduled={publishTime === 'schedule'}
/>
);
if (!isOpen || !video) {
return (
<>
{showUploadProgress && uploadProgressModalElement}
{tutorial.isActive && (
<TutorialOverlay
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/>
)}
</>
);
// Still render upload progress modal even when main modal is closed
return showUploadProgress ? uploadProgressModalElement : null;
}
return (
@ -556,7 +476,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
onClick={() => setIsChannelDropdownOpen(!isChannelDropdownOpen)}
>
<div className="social-posting-channel-selected">
{selectedAccount ? (
{selectedAccount && (
<>
<img
src={getPlatformIcon(selectedAccount.platform)}
@ -565,8 +485,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
/>
<span>{selectedAccount.display_name}</span>
</>
) : (
<span className="social-posting-channel-placeholder">{t('social.selectChannel')}</span>
)}
</div>
<svg className="social-posting-channel-arrow" viewBox="0 0 12 8" fill="none">
@ -604,60 +522,48 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
<label className="social-posting-label">
{t('social.postTitleLabel')} <span className="required">*</span>
</label>
<div className="seo-input-wrapper">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={isLoadingAutoDescription ? '' : t('social.postTitlePlaceholder')}
className="social-posting-input"
maxLength={100}
disabled={isLoadingAutoDescription}
/>
{isLoadingAutoDescription && (
<span className="seo-shimmer-text">{t('social.autoSeoTitle')}</span>
)}
</div>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={isLoadingAutoDescription ? t('social.autoSeoTitle') : t('social.postTitlePlaceholder')}
// placeholder={t('social.postTitlePlaceholder')}
className="social-posting-input"
maxLength={100}
disabled={isLoadingAutoDescription}
/>
<span className="social-posting-char-count">{title.length}/100</span>
</div>
{/* Description */}
<div className="social-posting-field">
<label className="social-posting-label">{t('social.postContentLabel')}</label>
<div className="seo-input-wrapper">
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={isLoadingAutoDescription ? '' : t('social.postContentPlaceholder')}
className="social-posting-textarea"
maxLength={5000}
rows={4}
disabled={isLoadingAutoDescription}
/>
{isLoadingAutoDescription && (
<span className="seo-shimmer-text">{t('social.autoSeoDescription')}</span>
)}
</div>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t(isLoadingAutoDescription ? t('social.autoSeoDescription') : t('social.postContentPlaceholder'))}
// placeholder={t('social.postContentPlaceholder')}
className="social-posting-textarea"
maxLength={5000}
rows={4}
disabled={isLoadingAutoDescription}
/>
<span className="social-posting-char-count">{description.length}/5,000</span>
</div>
{/* Tags */}
<div className="social-posting-field">
<label className="social-posting-label">{t('social.tagsLabel')}</label>
<div className="seo-input-wrapper">
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder={isLoadingAutoDescription ? '' : t('social.tagsPlaceholder')}
className="social-posting-input"
maxLength={500}
disabled={isLoadingAutoDescription}
/>
{isLoadingAutoDescription && (
<span className="seo-shimmer-text">{t('social.autoSeoTags')}</span>
)}
</div>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder={t(isLoadingAutoDescription ? t('social.autoSeoTags') : t('social.tagsPlaceholder'))}
// placeholder={t('social.tagsPlaceholder')}
className="social-posting-input"
maxLength={500}
disabled={isLoadingAutoDescription}
/>
<span className="social-posting-char-count">{tags.length}/500</span>
</div>
@ -855,16 +761,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
</div>
</div>
{uploadProgressModalElement}
{tutorial.isActive && (
<TutorialOverlay
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/>
)}
</>
);
};

View File

@ -1,310 +0,0 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { TutorialHint } from './tutorialSteps';
interface Rect {
top: number;
left: number;
width: number;
height: number;
bottom: number;
}
interface TooltipPos {
top: number;
left: number;
}
interface TutorialOverlayProps {
hints: TutorialHint[];
currentIndex: number;
onNext: () => void;
onPrev: () => void;
onSkip: () => void;
groupProgress?: { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null;
}
const PADDING = 8;
function getTargetRect(selector: string): Rect | null {
const els = Array.from(document.querySelectorAll(selector));
const el = els.find(e => {
const r = (e as HTMLElement).getBoundingClientRect();
return r.width > 0 && r.height > 0;
}) ?? els[0];
if (!el) return null;
const r = el.getBoundingClientRect();
return { top: r.top, left: r.left, width: r.width, height: r.height, bottom: r.bottom };
}
function getSpotlightRect(
rect: Rect,
padding: number,
override?: { top?: number; right?: number; bottom?: number; left?: number }
): Rect {
const pTop = override?.top ?? padding;
const pRight = override?.right ?? padding;
const pBottom = override?.bottom ?? padding;
const pLeft = override?.left ?? padding;
const left = Math.max(0, Math.floor(rect.left - pLeft));
const top = Math.max(0, Math.floor(rect.top - pTop));
const right = Math.min(window.innerWidth, Math.ceil(rect.left + rect.width + pRight));
const bottom = Math.min(window.innerHeight, Math.ceil(rect.top + rect.height + pBottom));
return {
top,
bottom,
left,
width: Math.max(0, right - left),
height: Math.max(0, bottom - top),
};
}
function calcTooltipPos(rect: Rect, position: TutorialHint['position'], tooltipW = 300, tooltipH = 160): TooltipPos {
switch (position) {
case 'bottom':
return {
top: Math.min(rect.top + rect.height + PADDING, window.innerHeight - tooltipH - 8),
left: Math.min(Math.max(rect.left + rect.width / 2 - tooltipW / 2, 8), window.innerWidth - tooltipW - 8),
};
case 'top':
return {
top: Math.max(rect.top - tooltipH - PADDING, 8),
left: Math.min(Math.max(rect.left + rect.width / 2 - tooltipW / 2, 8), window.innerWidth - tooltipW - 8),
};
case 'right':
return {
top: Math.min(Math.max(rect.top + rect.height / 2 - tooltipH / 2, 8), window.innerHeight - tooltipH - 8),
left: Math.min(rect.left + rect.width + PADDING, window.innerWidth - tooltipW - 8),
};
case 'left':
return {
top: Math.min(Math.max(rect.top + rect.height / 2 - tooltipH / 2, 8), window.innerHeight - tooltipH - 8),
left: Math.max(rect.left - tooltipW - PADDING, 8),
};
}
}
const TutorialOverlay: React.FC<TutorialOverlayProps> = ({
hints,
currentIndex,
onNext,
onPrev,
onSkip,
groupProgress,
}) => {
const { t } = useTranslation();
const [targetRect, setTargetRect] = useState<Rect | null>(null);
const tooltipRef = React.useRef<HTMLDivElement>(null);
const [tooltipSize, setTooltipSize] = useState({ w: 300, h: 160 });
const hint = hints[currentIndex];
const isLast = currentIndex === hints.length - 1;
// 그룹이 있으면 그룹의 마지막 키 + 마지막 힌트일 때만 완료, 그룹 없으면 기존대로
const isFinish = isLast && (groupProgress ? groupProgress.isLastKeyInGroup : true);
const updateRect = useCallback(() => {
if (!hint) return;
setTargetRect(getTargetRect(hint.targetSelector));
}, [hint]);
useEffect(() => {
updateRect();
window.addEventListener('resize', updateRect);
window.addEventListener('scroll', updateRect, true);
return () => {
window.removeEventListener('resize', updateRect);
window.removeEventListener('scroll', updateRect, true);
};
}, [updateRect]);
useEffect(() => {
if (!hint) return;
let retryTimer: number | undefined;
let rectTimer: number | undefined;
let cleanupTarget: (() => void) | undefined;
const bindToTarget = (el: HTMLElement) => {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
rectTimer = window.setTimeout(updateRect, 200);
const shouldClickAdvance = hint.clickToAdvance !== false;
if (shouldClickAdvance) {
el.style.cursor = 'pointer';
el.addEventListener('click', onNext);
cleanupTarget = () => {
el.style.cursor = '';
el.removeEventListener('click', onNext);
};
} else {
cleanupTarget = () => {};
}
};
const tryBind = (): boolean => {
const el = document.querySelector(hint.targetSelector) as HTMLElement | null;
if (!el) return false;
bindToTarget(el);
return true;
};
if (!tryBind()) {
retryTimer = window.setInterval(() => {
if (tryBind() && retryTimer) {
window.clearInterval(retryTimer);
retryTimer = undefined;
}
}, 80);
}
return () => {
if (retryTimer) window.clearInterval(retryTimer);
if (rectTimer) window.clearTimeout(rectTimer);
cleanupTarget?.();
};
}, [hint, updateRect, onNext]);
// 툴팁 DOM 크기 측정 — 힌트가 바뀔 때만 재측정
useEffect(() => {
const el = tooltipRef.current;
if (!el) return;
const { offsetWidth, offsetHeight } = el;
if (offsetWidth && offsetHeight) {
setTooltipSize(prev =>
prev.w === offsetWidth && prev.h === offsetHeight
? prev
: { w: offsetWidth, h: offsetHeight }
);
}
}, [currentIndex, hints]);
if (!hint) return null;
const isTargetVisible = targetRect
? targetRect.top + 60 < window.innerHeight && targetRect.bottom > 60
: true;
if (!isTargetVisible) return null;
const spotlightPadding = hint.spotlightPadding ?? PADDING;
const spotlightRect = (targetRect && !hint.noSpotlight) ? getSpotlightRect(targetRect, spotlightPadding, hint.spotlightPaddingOverride) : null;
// 툴팁은 spotlightRect 기준으로 배치해야 스포트라이트와 겹치지 않음
const tooltipPos: TooltipPos = (spotlightRect ?? targetRect)
? calcTooltipPos((spotlightRect ?? targetRect)!, hint.position, tooltipSize.w, tooltipSize.h)
: {
top: window.innerHeight / 2 - tooltipSize.h / 2,
left: window.innerWidth / 2 - tooltipSize.w / 2,
};
return (
<div className="tutorial-overlay-root">
{spotlightRect ? (
<>
<div
className="tutorial-overlay-blocker"
style={{ top: 0, left: 0, right: 0, height: spotlightRect.top }}
/>
<div
className="tutorial-overlay-blocker"
style={{
top: spotlightRect.top,
left: 0,
width: spotlightRect.left,
height: spotlightRect.height,
}}
/>
<div
className="tutorial-overlay-blocker"
style={{
top: spotlightRect.top,
left: spotlightRect.left + spotlightRect.width,
right: 0,
height: spotlightRect.height,
}}
/>
<div
className="tutorial-overlay-blocker"
style={{
top: spotlightRect.top + spotlightRect.height,
left: 0,
right: 0,
bottom: 0,
}}
/>
<div
className="tutorial-spotlight-ring"
style={{
top: spotlightRect.top,
left: spotlightRect.left,
width: spotlightRect.width,
height: spotlightRect.height,
}}
/>
</>
) : null}
<div
ref={tooltipRef}
className={`tutorial-tooltip${hint.variant === 'bubble' ? ` tutorial-tooltip-bubble tutorial-tooltip-bubble--${hint.position}` : ''}`}
style={{ top: tooltipPos.top, left: tooltipPos.left }}
onClick={(e) => e.stopPropagation()}
>
<p className="tutorial-tooltip-title">{t(hint.titleKey)}</p>
<p className="tutorial-tooltip-desc">{t(hint.descriptionKey)}</p>
{hint.noteKey && (
<p className="tutorial-tooltip-note">{t(hint.noteKey)}</p>
)}
<div className="tutorial-tooltip-footer">
<span className="tutorial-tooltip-counter">
{groupProgress ? groupProgress.groupOffset + 1 : currentIndex + 1} / {groupProgress ? groupProgress.groupTotal : hints.length}
</span>
<div className="tutorial-tooltip-actions">
<button className="tutorial-btn-skip" onClick={onSkip}>
{t('tutorial.skip')}
</button>
{currentIndex > 0 && (
<button className="tutorial-btn-prev" onClick={onPrev}>
{t('tutorial.prev')}
</button>
)}
{(hint.clickToAdvance !== true || isFinish) && (
<button className="tutorial-btn-next" onClick={onNext}>
{isFinish ? t('tutorial.finish') : t('tutorial.next')}
</button>
)}
</div>
</div>
</div>
</div>
);
};
export default TutorialOverlay;
interface TutorialRestartPopupProps {
onConfirm: () => void;
onCancel: () => void;
}
export const TutorialRestartPopup: React.FC<TutorialRestartPopupProps> = ({ onConfirm, onCancel }) => {
const { t } = useTranslation();
return (
<div className="tutorial-restart-backdrop">
<div className="tutorial-restart-popup" onClick={(e) => e.stopPropagation()}>
<p className="tutorial-restart-title">{t('tutorial.restart.title')}</p>
<p className="tutorial-restart-desc">{t('tutorial.restart.desc')}</p>
<div className="tutorial-restart-actions">
<button className="tutorial-restart-cancel" onClick={onCancel}>
{t('tutorial.restart.cancel')}
</button>
<button className="tutorial-restart-confirm" onClick={onConfirm}>
{t('tutorial.restart.confirm')}
</button>
</div>
</div>
</div>
);
};

View File

@ -1,377 +0,0 @@
export interface TutorialHint {
targetSelector: string;
titleKey: string;
descriptionKey: string;
position: 'top' | 'bottom' | 'left' | 'right';
noteKey?: string;
variant?: 'bubble';
clickToAdvance?: boolean;
advanceSelector?: string;
noSpotlight?: boolean;
spotlightPadding?: number;
spotlightPaddingOverride?: { top?: number; right?: number; bottom?: number; left?: number };
}
export interface TutorialStepDef {
key: string;
hints: TutorialHint[];
}
export const TUTORIAL_KEYS = {
LANDING: 'landing',
ANALYSIS: 'analysis',
ASSET: 'asset',
SOUND: 'sound',
SOUND_LYRICS: 'soundLyrics',
SOUND_AUDIO: 'soundAudio',
GENERATING:'generating',
COMPLETION: 'completion',
MY_INFO: 'myInfo',
ADO2_CONTENTS: 'ado2Contents',
UPLOAD_MODAL: 'uploadModal',
UPLOAD_FORM: 'uploadForm',
DASHBOARD: 'dashboard',
CONTENT_CALENDAR: 'contentCalendar',
FEEDBACK: 'feedback',
} as const;
// 같은 페이지에 속하는 튜토리얼 키 그룹 — 진행 카운터를 합산해서 표시
export const TUTORIAL_PAGE_GROUPS: string[][] = [
[TUTORIAL_KEYS.SOUND, TUTORIAL_KEYS.SOUND_LYRICS, TUTORIAL_KEYS.SOUND_AUDIO],
[TUTORIAL_KEYS.GENERATING, TUTORIAL_KEYS.COMPLETION],
[TUTORIAL_KEYS.UPLOAD_MODAL, TUTORIAL_KEYS.UPLOAD_FORM],
];
export const tutorialSteps: TutorialStepDef[] = [
{
key: TUTORIAL_KEYS.LANDING,
hints: [
{
targetSelector: '.hero-dropdown-trigger',
titleKey: 'tutorial.landing.dropdown.title',
descriptionKey: 'tutorial.landing.dropdown.desc',
position: 'top',
clickToAdvance: false,
noSpotlight: true,
variant: 'bubble',
},
{
targetSelector: '.hero-input-wrapper',
titleKey: 'tutorial.landing.field.title',
descriptionKey: 'tutorial.landing.field.desc',
position: 'top',
clickToAdvance: false,
noSpotlight: true,
variant: 'bubble',
},
{
targetSelector: '.hero-button',
titleKey: 'tutorial.landing.button.title',
descriptionKey: 'tutorial.landing.button.desc',
position: 'bottom',
clickToAdvance: true,
noSpotlight: true,
variant: 'bubble',
},
],
},
{
key: TUTORIAL_KEYS.ASSET,
hints: [
{
targetSelector: '.asset-column.asset-column-left',
titleKey: 'tutorial.asset.image.title',
descriptionKey: 'tutorial.asset.image.desc',
position: 'left',
clickToAdvance: false,
},
{
targetSelector: '.asset-upload-zone, .asset-mobile-upload-btn',
titleKey: 'tutorial.asset.upload.title',
descriptionKey: 'tutorial.asset.upload.desc',
position: 'bottom',
clickToAdvance: false,
},
{
targetSelector: '.asset-ratio-section',
titleKey: 'tutorial.asset.ratio.title',
descriptionKey: 'tutorial.asset.ratio.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.asset-next-button',
titleKey: 'tutorial.asset.next.title',
descriptionKey: 'tutorial.asset.next.desc',
position: 'top',
clickToAdvance: true,
},
],
},
{
key: TUTORIAL_KEYS.SOUND,
hints: [
{
targetSelector: '.genre-grid',
titleKey: 'tutorial.sound.genre.title',
descriptionKey: 'tutorial.sound.genre.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.language-grid',
titleKey: 'tutorial.sound.language.title',
descriptionKey: 'tutorial.sound.language.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.btn-generate-sound',
titleKey: 'tutorial.sound.generate.title',
descriptionKey: 'tutorial.sound.generate.desc',
position: 'top',
clickToAdvance: true,
},
],
},
{
key: TUTORIAL_KEYS.SOUND_LYRICS,
hints: [
{
targetSelector: '.lyrics-display',
titleKey: 'tutorial.sound.lyrics.title',
descriptionKey: 'tutorial.sound.lyrics.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.status-message-new',
titleKey: 'tutorial.sound.lyricsWait.title',
descriptionKey: 'tutorial.sound.lyricsWait.desc',
position: 'top',
clickToAdvance: false,
},
],
},
{
key: TUTORIAL_KEYS.SOUND_AUDIO,
hints: [
{
targetSelector: '.audio-player',
titleKey: 'tutorial.sound.audioPlayer.title',
descriptionKey: 'tutorial.sound.audioPlayer.desc',
position: 'bottom',
clickToAdvance: false,
},
{
targetSelector: '.btn-video-generate',
titleKey: 'tutorial.sound.video.title',
descriptionKey: 'tutorial.sound.video.desc',
position: 'top',
clickToAdvance: true,
},
],
},
{
key: TUTORIAL_KEYS.MY_INFO,
hints: [
{
targetSelector: '.youtube-connect-section',
titleKey: 'tutorial.myInfo.myInfo.title',
descriptionKey: 'tutorial.myInfo.myInfo.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.myinfo-social-btn',
titleKey: 'tutorial.myInfo.connect.title',
descriptionKey: 'tutorial.myInfo.connect.desc',
noteKey: 'tutorial.myInfo.connect.note',
position: 'top',
clickToAdvance: true,
},
{
targetSelector: '.myinfo-connected-accounts',
titleKey: 'tutorial.myInfo.connected.title',
descriptionKey: 'tutorial.myInfo.connected.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '#sidebar-ado2-contents',
titleKey: 'tutorial.myInfo.ado2.title',
descriptionKey: 'tutorial.myInfo.ado2.desc',
position: 'right',
clickToAdvance: true,
},
],
},
{
key: TUTORIAL_KEYS.ADO2_CONTENTS,
hints: [
{
targetSelector: '.ado2-content-card',
titleKey: 'tutorial.ado2.list.title',
descriptionKey: 'tutorial.ado2.list.desc',
position: 'right',
clickToAdvance: false,
},
{
targetSelector: '.content-upload-btn',
titleKey: 'tutorial.ado2.download.title',
descriptionKey: 'tutorial.ado2.download.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.content-delete-btn',
titleKey: 'tutorial.ado2.delete.title',
descriptionKey: 'tutorial.ado2.delete.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.content-download-btn',
titleKey: 'tutorial.ado2.upload.title',
descriptionKey: 'tutorial.ado2.upload.desc',
position: 'top',
clickToAdvance: true,
},
],
},
{
key: TUTORIAL_KEYS.GENERATING,
hints:[
{
targetSelector: '.comp2-info-section',
titleKey: 'tutorial.completion.contentInfo.title',
descriptionKey: 'tutorial.completion.contentInfo.desc',
position: 'left',
},
{
targetSelector: '.comp2-video-section',
titleKey: 'tutorial.completion.generating.title',
descriptionKey: 'tutorial.completion.generating.desc',
position: 'top',
}
]
},
{
key: TUTORIAL_KEYS.COMPLETION,
hints: [
{
targetSelector: '.comp2-video-section',
titleKey: 'tutorial.completion.completion.title',
descriptionKey: 'tutorial.completion.completion.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '#sidebar-my-info',
titleKey: 'tutorial.completion.myInfo.title',
descriptionKey: 'tutorial.completion.myInfo.desc',
position: 'right',
clickToAdvance: true,
},
],
},
{
key: TUTORIAL_KEYS.UPLOAD_MODAL,
hints: [
{
targetSelector: '.social-posting-content',
titleKey: 'tutorial.upload.seo.title',
descriptionKey: 'tutorial.upload.seo.desc',
position: 'right',
clickToAdvance: false,
},
],
},
{
key: TUTORIAL_KEYS.UPLOAD_FORM,
hints: [
{
targetSelector: '.social-posting-form',
titleKey: 'tutorial.upload.required.title',
descriptionKey: 'tutorial.upload.required.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.social-posting-radio-group',
titleKey: 'tutorial.upload.schedule.title',
descriptionKey: 'tutorial.upload.schedule.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.social-posting-btn:not(.cancel)',
titleKey: 'tutorial.upload.submit.title',
descriptionKey: 'tutorial.upload.submit.desc',
position: 'top',
clickToAdvance: true,
},
],
},
{
key: TUTORIAL_KEYS.DASHBOARD,
hints: [
{
targetSelector: '.stats-grid-8',
titleKey: 'tutorial.dashboard.metrics.title',
descriptionKey: 'tutorial.dashboard.metrics.desc',
position: 'bottom',
},
{
targetSelector: '.yoy-chart-card',
titleKey: 'tutorial.dashboard.chart.title',
descriptionKey: 'tutorial.dashboard.chart.desc',
position: 'top',
},
{
targetSelector: '.tutorial-center-anchor',
titleKey: 'tutorial.dashboard.more.title',
descriptionKey: 'tutorial.dashboard.more.desc',
position: 'bottom',
clickToAdvance: false,
},
],
},
{
key: TUTORIAL_KEYS.CONTENT_CALENDAR,
hints: [
{
targetSelector: '.calendar-grid-area',
titleKey: 'tutorial.contentCalendar.grid.title',
descriptionKey: 'tutorial.contentCalendar.grid.desc',
position: 'top',
clickToAdvance: false,
},
{
targetSelector: '.calendar-side-panel',
titleKey: 'tutorial.contentCalendar.panel.title',
descriptionKey: 'tutorial.contentCalendar.panel.desc',
position: 'left',
clickToAdvance: false,
},
],
},
{
key: TUTORIAL_KEYS.FEEDBACK,
hints: [
{
targetSelector: '.tutorial-center-anchor',
titleKey: 'tutorial.feedback.complete.title',
descriptionKey: 'tutorial.feedback.complete.desc',
position: 'bottom',
clickToAdvance: false,
},
{
targetSelector: '.sidebar-inquiry-btn',
titleKey: 'tutorial.feedback.title',
descriptionKey: 'tutorial.feedback.desc',
position: 'right',
},
],
},
];

View File

@ -1,210 +0,0 @@
import React, { useState, useCallback } from 'react';
import { tutorialSteps, TutorialHint, TUTORIAL_PAGE_GROUPS } from './tutorialSteps';
// 현재 키가 속한 그룹의 전체 힌트 수와 현재까지의 offset 반환
function getGroupProgress(key: string, currentIndex: number): { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null {
const group = TUTORIAL_PAGE_GROUPS.find(g => g.includes(key));
if (!group) return null;
let offset = 0;
let total = 0;
for (const k of group) {
const step = tutorialSteps.find(s => s.key === k);
const count = step?.hints.length ?? 0;
if (k === key) offset = total;
total += count;
}
const isLastKeyInGroup = group[group.length - 1] === key;
return { groupTotal: total, groupOffset: offset + currentIndex, isLastKeyInGroup };
}
const SEEN_KEY = 'ado2_tutorial_seen';
const PROGRESS_KEY = 'ado2_tutorial_progress';
const ENABLED_KEY = 'ado2_tutorial_enabled';
// 전역 단일 활성 튜토리얼 관리 — 새 튜토리얼 시작 시 이전 것을 skip 처리
let globalSkip: (() => void) | null = null;
function getSeenKeys(): string[] {
try {
return JSON.parse(localStorage.getItem(SEEN_KEY) || '[]');
} catch {
return [];
}
}
function markSeen(key: string) {
const seen = getSeenKeys();
if (!seen.includes(key)) {
localStorage.setItem(SEEN_KEY, JSON.stringify([...seen, key]));
}
clearProgress(key);
}
function saveProgress(key: string, index: number) {
try {
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
progress[key] = index;
localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress));
} catch {}
}
function loadProgress(key: string): number {
try {
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
return progress[key] ?? 0;
} catch {
return 0;
}
}
function clearProgress(key: string) {
try {
const progress = JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}');
delete progress[key];
localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress));
} catch {}
}
interface UseTutorialReturn {
isActive: boolean;
isEnabled: boolean;
isRestartPopupVisible: boolean;
currentHintIndex: number;
hints: TutorialHint[];
tutorialKey: string | null;
groupProgress: { groupTotal: number; groupOffset: number; isLastKeyInGroup: boolean } | null;
startTutorial: (key: string, onComplete?: () => void, forceFromStart?: boolean) => void;
nextHint: () => void;
prevHint: () => void;
skipTutorial: () => void;
toggleTutorial: (currentKey: string | null) => void;
showRestartPopup: (key: string) => void;
confirmRestart: () => void;
cancelRestart: () => void;
hasSeen: (key: string) => boolean;
}
export function useTutorial(): UseTutorialReturn {
const [isActive, setIsActive] = useState(false);
const [isEnabled, setIsEnabled] = useState(() => localStorage.getItem(ENABLED_KEY) !== 'false');
const [isRestartPopupVisible, setIsRestartPopupVisible] = useState(false);
const [pendingRestartKey, setPendingRestartKey] = useState<string | null>(null);
const [currentHintIndex, setCurrentHintIndex] = useState(0);
const [hints, setHints] = useState<TutorialHint[]>([]);
const [tutorialKey, setTutorialKey] = useState<string | null>(null);
const onCompleteRef = React.useRef<(() => void) | undefined>(undefined);
const startTutorial = useCallback((key: string, onComplete?: () => void, forceFromStart?: boolean) => {
if (localStorage.getItem(ENABLED_KEY) === 'false') return;
const step = tutorialSteps.find(s => s.key === key);
if (!step || step.hints.length === 0) return;
// 다른 인스턴스에서 활성화된 튜토리얼이 있으면 skip 처리
globalSkip?.();
const savedIndex = forceFromStart ? 0 : loadProgress(key);
const resumeIndex = savedIndex < step.hints.length ? savedIndex : 0;
onCompleteRef.current = onComplete;
setHints(step.hints);
setTutorialKey(key);
setCurrentHintIndex(resumeIndex);
setIsActive(true);
// 이 인스턴스의 skip을 전역에 등록
globalSkip = () => {
if (key) saveProgress(key, resumeIndex);
setIsActive(false);
setCurrentHintIndex(0);
globalSkip = null;
};
}, []);
const nextHint = useCallback(() => {
setCurrentHintIndex(prev => {
if (prev < hints.length - 1) {
const next = prev + 1;
if (tutorialKey) saveProgress(tutorialKey, next);
return next;
}
// 마지막 힌트 완료 → seen 기록 + 진행 상태 삭제
setIsActive(false);
if (tutorialKey) markSeen(tutorialKey);
globalSkip = null; // 완료된 튜토리얼은 globalSkip 해제
onCompleteRef.current?.();
onCompleteRef.current = undefined;
return 0;
});
}, [hints.length, tutorialKey]);
const prevHint = useCallback(() => {
setCurrentHintIndex(prev => Math.max(0, prev - 1));
}, []);
// 건너뛰기: 현재 진행 인덱스 저장 후 오버레이 닫기 → 다음 방문 시 이어서 표시
const skipTutorial = useCallback(() => {
if (tutorialKey) saveProgress(tutorialKey, currentHintIndex);
setIsActive(false);
setCurrentHintIndex(0);
}, [tutorialKey, currentHintIndex]);
const toggleTutorial = useCallback((currentKey: string | null) => {
if (isEnabled) {
// off: 튜토리얼 중단 + 비활성화
setIsActive(false);
setIsEnabled(false);
localStorage.setItem(ENABLED_KEY, 'false');
} else {
// on: seen/progress 초기화 + 현재 화면 튜토리얼 시작
localStorage.removeItem(SEEN_KEY);
localStorage.removeItem(PROGRESS_KEY);
localStorage.setItem(ENABLED_KEY, 'true');
setIsEnabled(true);
if (currentKey) {
startTutorial(currentKey, undefined, true);
}
}
}, [isEnabled, startTutorial]);
// 튜토리얼 다시 보기: 팝업 표시만
const showRestartPopup = useCallback((key: string) => {
setPendingRestartKey(key);
setIsRestartPopupVisible(true);
}, []);
// 팝업에서 확인 → seen/progress 초기화 후 튜토리얼 시작
const confirmRestart = useCallback(() => {
setIsRestartPopupVisible(false);
if (pendingRestartKey) {
localStorage.removeItem(SEEN_KEY);
localStorage.removeItem(PROGRESS_KEY);
startTutorial(pendingRestartKey, undefined, true);
}
setPendingRestartKey(null);
}, [pendingRestartKey, startTutorial]);
// 팝업에서 취소
const cancelRestart = useCallback(() => {
setIsRestartPopupVisible(false);
setPendingRestartKey(null);
}, []);
const hasSeen = useCallback((key: string) => {
return getSeenKeys().includes(key);
}, []);
return {
isActive,
isEnabled,
isRestartPopupVisible,
currentHintIndex,
hints,
tutorialKey,
groupProgress: tutorialKey ? getGroupProgress(tutorialKey, currentHintIndex) : null,
startTutorial,
nextHint,
prevHint,
skipTutorial,
toggleTutorial,
showRestartPopup,
confirmRestart,
cancelRestart,
hasSeen,
};
}

View File

@ -14,7 +14,6 @@ interface UploadProgressModalProps {
youtubeUrl?: string;
errorMessage?: string;
isScheduled?: boolean;
onGoToCalendar?: () => void;
}
const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
@ -27,7 +26,6 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
youtubeUrl,
errorMessage,
isScheduled = false,
onGoToCalendar,
}) => {
const { t } = useTranslation();
if (!isOpen) return null;
@ -153,16 +151,9 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
{/* Footer */}
<div className="upload-progress-footer">
{canClose ? (
<>
<button className="upload-progress-btn primary" onClick={onClose}>
{status === 'completed' ? t('upload.confirm') : t('upload.close')}
</button>
{status === 'completed' && onGoToCalendar && (
<span className="upload-progress-calendar-link" onClick={() => { onClose(); onGoToCalendar(); }}>
{t('upload.goToCalendar')}
</span>
)}
</>
<button className="upload-progress-btn primary" onClick={onClose}>
{status === 'completed' ? t('upload.confirm') : t('upload.close')}
</button>
) : (
<p className="upload-progress-note">{t('upload.doNotClose')}</p>
)}

View File

@ -1,485 +0,0 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
getVideoById,
getVideoComments,
postVideoComment,
deleteComment,
toggleVideoLike,
isLoggedIn,
} from '../utils/api';
import { VideoDetailItem, CommentItem } from '../types/api';
import LoginPromptModal from './LoginPromptModal';
interface VideoDetailContentProps {
videoId: string;
isModal?: boolean;
onClose?: () => void;
}
const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModal = false, onClose }) => {
const { t } = useTranslation();
const authed = isLoggedIn();
const [video, setVideo] = useState<VideoDetailItem | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [likeCount, setLikeCount] = useState(0);
const [isLiked, setIsLiked] = useState(false);
const [copied, setCopied] = useState(false);
const [shareMenuOpen, setShareMenuOpen] = useState(false);
const [isLandscape, setIsLandscape] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false);
const [comments, setComments] = useState<CommentItem[]>([]);
const [commentsTotal, setCommentsTotal] = useState(0);
const [commentsPage, setCommentsPage] = useState(1);
const [commentsHasNext, setCommentsHasNext] = useState(false);
const [commentsLoading, setCommentsLoading] = useState(false);
const [commentInput, setCommentInput] = useState('');
const [commentSubmitting, setCommentSubmitting] = useState(false);
const commentTextareaRef = useRef<HTMLTextAreaElement>(null);
const [commentNickname, setCommentNickname] = useState('');
const [commentAvatarSeedIdx, setCommentAvatarSeedIdx] = useState(0);
// 고정 seed 목록: 브라우저가 캐싱하여 중복 요청 없음
const AVATAR_SEEDS = ['42', '77', '123', '256', '512', '888', '1024', '2048', '3141', '9999'];
const commentAvatarSeed = AVATAR_SEEDS[commentAvatarSeedIdx % AVATAR_SEEDS.length];
const handleChangeAvatar = useCallback(() => {
setCommentAvatarSeedIdx(prev => (prev + 1) % AVATAR_SEEDS.length);
}, []);
const fetchComments = useCallback(async (page: number, append = false) => {
setCommentsLoading(true);
try {
const res = await getVideoComments(videoId, page, 20);
const sorted = [...res.items].sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
setComments(prev => append ? [...prev, ...sorted] : sorted);
setCommentsTotal(res.total);
setCommentsHasNext(res.has_next);
setCommentsPage(page);
} catch (err) {
console.error('Failed to fetch comments:', err);
} finally {
setCommentsLoading(false);
}
}, [videoId]);
useEffect(() => {
const fetchVideo = async () => {
setLoading(true);
setError(null);
try {
const data = await getVideoById(videoId);
setVideo(data);
setLikeCount(data.like_count);
setIsLiked(data.is_liked_by_me);
} catch (err) {
console.error('Failed to fetch video:', err);
setError(t('ado2Contents.loadFailed'));
} finally {
setLoading(false);
}
};
fetchVideo();
fetchComments(1);
}, [videoId, fetchComments]);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`;
};
const formatCommentDate = (dateString: string) => {
const date = new Date(dateString);
return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`;
};
const shareUrl = `${window.location.origin}/video/${videoId}`;
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(shareUrl);
} catch {
// clipboard API 미지원 환경에서는 무시
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleKakaoShare = () => {
const kakao = window.Kakao;
if (kakao?.Share) {
kakao.Share.sendDefault({
objectType: 'feed',
content: {
title: video?.store_name ?? 'ADO2 영상',
description: `${video?.region ?? ''} · ADO2 AI 마케팅 영상`,
imageUrl: 'https://demo.castad.net/favicon_48.svg',
link: { mobileWebUrl: shareUrl, webUrl: shareUrl },
},
buttons: [{ title: '영상 보기', link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }],
});
} else if (navigator.share) {
navigator.share({ url: shareUrl }).catch(() => {});
} else {
handleCopyLink();
}
setShareMenuOpen(false);
};
const handleFacebookShare = () => {
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
setShareMenuOpen(false);
};
const handleTwitterShare = () => {
window.open(`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600');
setShareMenuOpen(false);
};
const shareMenuRef = React.useRef<HTMLDivElement>(null);
useEffect(() => {
if (!shareMenuOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (shareMenuRef.current && !shareMenuRef.current.contains(e.target as Node)) {
setShareMenuOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [shareMenuOpen]);
const likeDebounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const handleLike = () => {
if (!authed) { setShowLoginModal(true); return; }
// 1. UI 즉시 업데이트 (Optimistic)
setIsLiked(prev => !prev);
setLikeCount(prev => isLiked ? prev - 1 : prev + 1);
// 2. 기존 debounce 타이머 취소 후 재설정
if (likeDebounceRef.current) clearTimeout(likeDebounceRef.current);
likeDebounceRef.current = setTimeout(async () => {
const prevLiked = isLiked;
const prevCount = likeCount;
try {
await toggleVideoLike(videoId);
} catch (err) {
// 3. 실패 시 롤백
console.error('Failed to toggle like:', err);
setIsLiked(prevLiked);
setLikeCount(prevCount);
}
}, 500);
};
const handleHeaderAction = () => {
if (!isModal && !authed) { setShowLoginModal(true); return; }
onClose?.();
};
const handleCommentFocus = () => {
if (!authed) setShowLoginModal(true);
};
const handleCommentSubmit = async () => {
if (!authed) { setShowLoginModal(true); return; }
if (!commentInput.trim() || commentSubmitting) return;
setCommentSubmitting(true);
try {
await postVideoComment(videoId, commentInput.trim(), commentNickname);
setCommentInput('');
if (commentTextareaRef.current) {
commentTextareaRef.current.style.height = 'auto';
}
setCommentNickname('');
setCommentAvatarSeedIdx(prev => (prev + 1) % AVATAR_SEEDS.length);
await fetchComments(1);
} catch (err) {
console.error('Failed to post comment:', err);
} finally {
setCommentSubmitting(false);
}
};
const handleDeleteComment = async (commentId: number) => {
try {
await deleteComment(commentId);
await fetchComments(commentsPage);
} catch (err) {
console.error('Failed to delete comment:', err);
}
};
const renderCommentContent = (content: string | null, isDeleted: boolean) => {
if (isDeleted) return <span style={{ color: '#6B9EA0', fontStyle: 'italic' }}>( .)</span>;
return content;
};
return (
<div className={isModal ? 'video-detail-modal-content' : 'video-detail-page-content'}>
{/* 헤더 */}
<div className="video-detail-header">
{isModal ? (
<button className="video-detail-close-btn" onClick={handleHeaderAction} aria-label="닫기">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
) : (
<button className="video-detail-back-btn" onClick={handleHeaderAction}>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M13 4l-6 6 6 6"/>
</svg>
{t('sidebar.ado2Contents')}
</button>
)}
</div>
{loading ? (
<div className="ado2-contents-loading">
<div className="loading-spinner"></div>
<p>{t('ado2Contents.loading')}</p>
</div>
) : error ? (
<div className="ado2-contents-error"><p>{error}</p></div>
) : video ? (
<div className={`video-detail-content ${isLandscape ? 'landscape' : ''}`}>
<video
src={video.result_movie_url}
controls
autoPlay
controlsList="nodownload"
onContextMenu={(e) => e.preventDefault()}
className="video-detail-player"
onLoadedMetadata={(e) => {
const v = e.currentTarget;
setIsLandscape(v.videoWidth > v.videoHeight);
}}
/>
<div className="video-detail-info">
<h2 className="video-detail-store">{video.store_name}</h2>
<p className="video-detail-date">{formatDate(video.created_at)}</p>
{/* 좋아요 + 링크 복사 */}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
className={`video-detail-like-btn ${isLiked ? 'liked' : ''}`}
onClick={handleLike}
disabled={false}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill={isLiked ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
{likeCount}
</button>
<div style={{ position: 'relative' }} ref={shareMenuRef}>
<button
className="video-detail-copy-btn"
onClick={() => setShareMenuOpen(v => !v)}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg>
{copied ? '복사됨!' : '공유하기'}
</button>
{shareMenuOpen && (
<div className="video-detail-share-menu">
{/* 카카오톡 */}
<button className="video-detail-share-item" onClick={handleKakaoShare}>
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="20" height="20" rx="4" fill="#FEE500"/>
<path fillRule="evenodd" clipRule="evenodd" d="M10 3.5C6.134 3.5 3 6.01 3 9.1c0 1.98 1.2 3.72 3.01 4.76l-.74 2.75a.19.19 0 0 0 .28.21l3.37-2.23c.34.04.69.06 1.06.06 3.866 0 7-2.51 7-5.6S13.866 3.5 10 3.5z" fill="#3C1E1E"/>
</svg>
</button>
{/* 페이스북 */}
<button className="video-detail-share-item" onClick={handleFacebookShare}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="#1877F2">
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.235 2.686.235v2.97h-1.513c-1.491 0-1.956.93-1.956 1.887v2.268h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z"/>
</svg>
</button>
{/* X (트위터) */}
<button className="video-detail-share-item" onClick={handleTwitterShare}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
X ()
</button>
{/* URL 복사 */}
<button className="video-detail-share-item" onClick={() => { handleCopyLink(); setShareMenuOpen(false); }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
{copied ? '복사됨!' : 'URL 복사'}
</button>
</div>
)}
</div>
</div>
{/* 댓글 섹션 */}
<div className="video-detail-comments">
<div className="video-detail-comments-header">
<h3 className="video-detail-comments-title"></h3>
<span className="video-detail-comments-count">{commentsTotal}</span>
</div>
{/* 댓글 작성자 프로필 선택 */}
{authed && (
<div className="video-detail-comment-profile">
<img
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${commentAvatarSeed}`}
alt="아바타 변경"
className="video-detail-comment-avatar"
onClick={handleChangeAvatar}
style={{ cursor: 'pointer' }}
title="클릭하여 아바타 변경"
/>
<input
className="video-detail-nickname-input"
type="text"
placeholder="작성자 이름"
value={commentNickname}
onChange={(e) => setCommentNickname(e.target.value)}
maxLength={20}
/>
</div>
)}
<div className="video-detail-comment-input-wrap">
<textarea
ref={commentTextareaRef}
className="video-detail-comment-input"
placeholder={authed ? '댓글을 입력하세요...' : '로그인 후 댓글을 작성할 수 있습니다'}
maxLength={500}
rows={1}
value={commentInput}
onChange={(e) => {
setCommentInput(e.target.value);
e.target.style.height = 'auto';
e.target.style.height = `${e.target.scrollHeight}px`;
}}
onFocus={handleCommentFocus}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleCommentSubmit();
}
}}
disabled={!authed}
/>
<button
className="video-detail-comment-submit"
onClick={handleCommentSubmit}
disabled={!authed || !commentInput.trim() || commentSubmitting}
>
{commentSubmitting ? '작성 중' : '작성'}
</button>
</div>
{comments.length === 0 && !commentsLoading ? (
<p className="video-detail-comments-empty"> .</p>
) : (
<ul className="video-detail-comment-list">
{comments.map((c) => (
<li key={c.id} className="video-detail-comment-item">
<img
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${c.id}`}
alt="avatar"
className="video-detail-comment-avatar"
/>
<div className="video-detail-comment-body">
<span className="video-detail-comment-nickname">
{c.nickname || '익명'}
</span>
<p className="video-detail-comment-text">
{renderCommentContent(c.content, c.is_deleted)}
</p>
<div className="video-detail-comment-bottom">
<span className="video-detail-comment-date">{formatCommentDate(c.created_at)}</span>
{c.is_mine && !c.is_deleted && (
<button
className="video-detail-comment-delete"
onClick={() => handleDeleteComment(c.id)}
>
</button>
)}
</div>
{/* 대댓글 (읽기 전용) */}
{c.replies && c.replies.length > 0 && (
<ul className="video-detail-reply-list">
{c.replies.map((r) => (
<li key={r.id} className="video-detail-reply-item">
<img
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${r.id}`}
alt="avatar"
className="video-detail-comment-avatar small"
/>
<div className="video-detail-comment-body">
<span className="video-detail-comment-nickname">
{r.nickname || '익명'}
</span>
<p className="video-detail-comment-text">
{renderCommentContent(r.content, r.is_deleted)}
</p>
<div className="video-detail-comment-bottom">
<span className="video-detail-comment-date">{formatCommentDate(r.created_at)}</span>
{r.is_mine && !r.is_deleted && (
<button
className="video-detail-comment-delete"
onClick={() => handleDeleteComment(r.id)}
>
</button>
)}
</div>
</div>
</li>
))}
</ul>
)}
</div>
</li>
))}
</ul>
)}
{commentsHasNext && (
<button
className="video-detail-comments-more"
onClick={() => fetchComments(commentsPage + 1, true)}
disabled={commentsLoading}
>
{commentsLoading ? '불러오는 중...' : '댓글 더 보기'}
</button>
)}
</div>
</div>
</div>
) : null}
{showLoginModal && (
<LoginPromptModal onClose={() => setShowLoginModal(false)} />
)}
</div>
);
};
export default VideoDetailContent;

View File

@ -1,56 +0,0 @@
import React, { useEffect } from 'react';
import VideoDetailContent from './VideoDetailContent';
interface VideoDetailModalProps {
videoId: string;
onClose: () => void;
}
const VideoDetailModal: React.FC<VideoDetailModalProps> = ({ videoId, onClose }) => {
useEffect(() => {
// 모달 열릴 때 배경 스크롤 잠금
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = prev;
document.removeEventListener('keydown', handleKeyDown);
};
}, [onClose]);
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.85)',
zIndex: 2000,
display: 'flex',
overflowY: 'auto',
padding: '24px 16px',
}}
onClick={onClose}
>
<div
style={{
background: '#01282A',
borderRadius: '16px',
width: '100%',
maxWidth: '900px',
margin: 'auto',
position: 'relative',
}}
onClick={(e) => e.stopPropagation()}
>
<VideoDetailContent videoId={videoId} isModal={true} onClose={onClose} />
</div>
</div>
);
};
export default VideoDetailModal;

View File

@ -1,21 +0,0 @@
import React from 'react';
import VideoDetailContent from './VideoDetailContent';
interface VideoDetailPageProps {
videoId: string;
}
const VideoDetailPage: React.FC<VideoDetailPageProps> = ({ videoId }) => {
const handleBack = () => {
localStorage.setItem('castad_active_item', 'ADO2 콘텐츠');
window.location.href = '/';
};
return (
<div className="video-detail-page">
<VideoDetailContent videoId={videoId} isModal={false} onClose={handleBack} />
</div>
);
};
export default VideoDetailPage;

View File

@ -4,17 +4,6 @@ import ReactDOM from 'react-dom/client';
import './i18n';
import App from './App';
declare global {
interface Window {
Kakao: any;
}
}
const kakaoKey = import.meta.env.VITE_KAKAO_JS_KEY as string | undefined;
if (kakaoKey && window.Kakao && !window.Kakao.isInitialized()) {
window.Kakao.init(kakaoKey);
}
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");

View File

@ -12,95 +12,16 @@
"myContents": "My Contents",
"myInfo": "My Info",
"defaultUser": "User",
"credits": "Credits left: {{count}}",
"loggingOut": "Logging out...",
"logout": "Log Out",
"tutorialRestart": "Restart Tutorial",
"tutorial": "Tutorial",
"tutorialOn": "Enable Tutorial",
"tutorialOff": "Disable Tutorial",
"inquiry": "Feedback"
},
"tutorial": {
"skip": "Skip",
"next": "Next",
"prev": "Back",
"finish": "Done",
"landing": {
"intro": { "title": "Welcome to ADO2 Tutorial", "desc": "We'll guide you through ADO2 step by step." },
"dropdown": { "title": "Choose Search Type", "desc": "Select URL or business name from the dropdown." },
"field": { "title": "Enter Search Term", "desc": "For URL, paste a Naver Maps share URL.\nFor business name, type the name and select from the list." },
"button": { "title": "Start Brand Analysis", "desc": "Click the button to let AI start analyzing your brand." }
},
"asset": {
"image": { "title": "Image List", "desc": "Photos from Naver Place. Tap 'Show more' to see the rest, or X to remove any." },
"upload": { "title": "Add Images", "desc": "You can freely add more images." },
"ratio": { "title": "Select Video Ratio", "desc": "Choose the ratio for the video to be generated." },
"next": { "title": "Next Step", "desc": "Proceed to the next step when ready." }
},
"sound": {
"genre": { "title": "Select Genre", "desc": "Pick a music genre that fits your brand.", "note": "Background music is coming soon." },
"language": { "title": "Select Language", "desc": "You can choose the language for the sound.\nWant to continue with Korean?" },
"generate": { "title": "Generate Sound", "desc": "Click the button and AI will generate lyrics and music in your chosen genre and language." },
"lyrics": { "title": "Lyrics Complete", "desc": "AI wrote lyrics in your selected language.\nCheck the generated lyrics." },
"lyricsWait": { "title": "Generating Music", "desc": "AI is composing music based on the lyrics.\nPlease wait a moment." },
"audioPlayer": { "title": "Preview the Music", "desc": "Music generation is complete.\nPress play to listen to the generated music." },
"video": { "title": "Generate Video", "desc": "Click the button to start generating your video." }
},
"myInfo": {
"myInfo": { "title": "My Info", "desc": "In My Info, you can manage your social connections and view connected accounts." },
"connect": { "title": "Connect Now", "desc": "Click the YouTube connect button to go to the connection page.", "note": "Instagram connection is coming soon." },
"connected": { "title": "Connected Accounts", "desc": "Your linked social accounts appear here.\nCheck after connecting." },
"ado2": { "title": "ADO2 Contents", "desc": "You can now upload the generated video.\nClick to navigate." }
},
"ado2": {
"list": { "title": "Generated Videos", "desc": "View all AI-created videos here." },
"download": { "title": "Download", "desc": "Download the video to your device." },
"delete": { "title": "Delete", "desc": "Remove videos you no longer need." },
"upload": { "title": "Upload to Social Media", "desc": "Select a video and upload it to social media." }
},
"completion": {
"contentInfo": { "title": "Content Info", "desc": "Check the title, genre, resolution, and lyrics of the generated content." },
"generating": { "title": "Generating Video", "desc": "AI is creating your video.\nPlease wait a moment." },
"completion": { "title": "Video Complete!", "desc": "Your video is ready. Want to take a look?" },
"myInfo": { "title": "Connect Social Account", "desc": "To upload your video to YouTube, connect your social account in My Info. Click to go there." }
},
"upload": {
"seo": { "title": "Title & Description", "desc": "AI is generating the title and description for your video. Please wait a moment." },
"required": { "title": "Required Fields", "desc": "Fields marked with * are required.\nPlease check them before uploading." },
"schedule": { "title": "Schedule Upload", "desc": "Post now or schedule for a specific time." },
"submit": { "title": "Start Upload", "desc": "Click the Post button to start uploading." }
},
"dashboard": {
"metrics": { "title": "Key Metrics", "desc": "Check views, subscribers, and other stats for content uploaded via ADO2." },
"chart": { "title": "Growth Chart", "desc": "Track your channel's growth over time." },
"more": { "title": "More Analytics", "desc": "Even more statistics are available at a glance on the dashboard." }
},
"contentCalendar": {
"grid": { "title": "Content Calendar", "desc": "View your content schedule by date.\nWhy not select today?" },
"panel": { "title": "Content List", "desc": "Check the detailed content schedule here." }
},
"feedback": {
"complete": { "title": "Tutorial Complete 🎉", "desc": "You've completed the full flow from brand analysis to YouTube upload.\nTo replay the tutorial, click the button in the top right." },
"title": "Customer Feedback",
"desc": "Share any issues or suggestions to help us improve."
},
"restart": {
"title": "Restart Tutorial?",
"desc": "The tutorial will restart from the current screen.",
"confirm": "Start",
"cancel": "Cancel"
}
"logout": "Log Out"
},
"footer": {
"company":"O2O Inc.",
"businessNumber": "Business Registration No. : 620-87-00810 | CEO : Ahn Sungmin",
"headquarters": "HQ : Unicorn Lab Daegu A05, 5F, 111 Oksan-ro, Buk-gu, Daegu, Korea",
"researchCenter": "R&D : Rooms 504-505 (East), KT Pangyo Bldg, 32 Geumto-ro, Sujeong-gu, Seongnam-si, Gyeonggi-do, Korea",
"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",
"privacyPolicy": "Privacy Policy",
"termsOfService": "Terms of Service"
"email": "Email : o2oteam@o2o.kr"
},
"social": {
"title": "Social Media Posting",
@ -134,9 +55,9 @@
"invalidVideoInfo": "Video information is invalid. (missing video_id)",
"uploadStartFailed": "Failed to start upload.",
"uploadFailed": "Upload failed.",
"autoSeoTitle": "Auto-generating... (3060 sec)",
"autoSeoDescription": "Auto-generating... (3060 sec)",
"autoSeoTags": "Auto-generating... (3060 sec)"
"autoSeoTitle": "This will be automatically generated. please wait.",
"autoSeoDescription": "This will be automatically generated. please wait.",
"autoSeoTags": "This will be automatically generated. please wait."
},
"upload": {
"title": "YouTube Upload",
@ -152,14 +73,13 @@
"viewOnYoutube": "View on YouTube",
"confirm": "OK",
"close": "Close",
"doNotClose": "Upload is in progress. Do not close this window.",
"goToCalendar": "View in Calendar"
"doNotClose": "Upload is in progress. Do not close this window."
},
"landing": {
"hero": {
"searchTypeBusinessName": "Business Name",
"placeholderBusinessName": "Enter a business name",
"guideUrl": "Search for a place on Naver Maps, tap Share,\nthen paste the URL here.",
"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.",
@ -169,13 +89,7 @@
"testDataLoading": "Loading...",
"testData": "Test Data",
"testDataLoadFailed": "Failed to load test data.",
"searching": "Searching...",
"searchTypeManual": "Manual Input",
"manualModalTitle": "Enter Business Info",
"manualLabelName": "Business Name",
"manualLabelAddress": "Address",
"manualPlaceholderName": "Enter the business name",
"manualPlaceholderAddress": "Enter the address"
"searching": "Searching..."
},
"welcome": {
"title": "Welcome to ADO2.AI",
@ -183,7 +97,7 @@
"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,\nand videos tailored to your business",
"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"
},
@ -201,18 +115,12 @@
"urlInput": {
"searchTypeBusinessName": "Business Name",
"placeholderBusinessName": "Enter a business name",
"guideUrl": "Search for a place on Naver Maps, tap Share, then paste the URL here.",
"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",
"searchTypeManual": "Manual Input",
"manualModalTitle": "Enter Business Info",
"manualLabelName": "Business Name",
"manualLabelAddress": "Address",
"manualPlaceholderName": "Enter the business name",
"manualPlaceholderAddress": "Enter the address"
"testData": "Test Data"
},
"assetManagement": {
"title": "Brand Assets",
@ -220,7 +128,7 @@
"imageAlt": "Image",
"uploadBadge": "Uploaded",
"imageUpload": "Image Upload",
"dragAndDrop": "Drag & drop or\nclick to upload",
"dragAndDrop": "Drag and drop\nimages to upload",
"videoRatio": "Video Ratio",
"minImages": "Min. 5 images",
"youtubeShorts": "YouTube Shorts",
@ -228,7 +136,7 @@
"back": "Go Back",
"loadMore": "Load more",
"uploadFailed": "Image upload failed.",
"uploading": "Uploading... (30 sec 2 min)",
"uploading": "Uploading...",
"nextStep": "Next Step"
},
"soundStudio": {
@ -246,17 +154,14 @@
"lyricsColumn": "Lyrics",
"lyricsHint": "Select the lyrics area to edit",
"lyricsPlaceholder": "Lyrics will be displayed when sound is generated.",
"lyricsPlaceholderBGM": "Background music is generated without lyrics.",
"generateButton": "Generate Sound",
"regenerateButton": "Regenerate",
"regenerateHint": "Press the regenerate button to create new lyrics and music.",
"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...\n(3060 sec)",
"generatingSong": "Generating music...\n(12 min)",
"generatingLyrics": "Generating lyrics...",
"generatingSong": "Generating song...",
"songQueued": "Song generation queued...",
"retryMessage": "Regenerating due to timeout... ({{count}}/{{max}})",
"lyricGenerationFailed": "Lyrics generation request failed.",
@ -267,10 +172,7 @@
"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.",
"creditsRemaining": "{{count}} left",
"creditsExhausted": "Not enough credits.",
"chargeCredits": "Purchase credits"
"songRegenerationError": "An error occurred during music regeneration."
},
"completion": {
"back": "Go Back",
@ -278,8 +180,6 @@
"titleError": "Video Generation Failed",
"titleComplete": "Content Creation Complete",
"imageAndVideo": "Images & Video",
"checkingSubtitle": "Checking subtitle generation status...",
"waitingSubtitle": "Generating subtitles...",
"requestingGeneration": "Requesting video generation...",
"generatingVideo": "Generating video...",
"processingAfterRefresh": "Processing video... (recovered after refresh)",
@ -291,7 +191,7 @@
"statusPlanned": "Scheduled",
"statusWaiting": "Waiting",
"statusTranscribing": "Transcribing",
"statusRendering": "Rendering (23 min)",
"statusRendering": "Rendering",
"statusSucceeded": "Completed",
"statusDefault": "Processing...",
"aiOptimization": "AI Optimization",
@ -313,18 +213,7 @@
"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.",
"contentComplete": "Content Creation Complete",
"contentCompleteDesc": "Your optimized content has been created through AI analysis and editing",
"contentInfo": "File Name",
"genre": "Genre",
"resolution": "Resolution",
"lyrics": "Lyrics",
"sampleLyrics": "Loading lyrics...",
"noLyricsBGM": "Background music is generated without lyrics.",
"downloading": "Downloading...",
"download": "Download",
"uploadToSocial": "Upload to Social"
"youtubeExpiredAlert": "YouTube authentication has expired. Redirecting to reconnect page."
},
"dashboard": {
"title": "Dashboard",
@ -397,38 +286,7 @@
"stayIntroReel": "Stay Meomum Introduction Reel",
"newYearEvent": "New Year Special Event",
"nightTimelapse": "Pension Night View Timelapse"
},
"metricTotalViews": "Views",
"metricWatchTime": "Watch Time",
"metricAvgViewDuration": "Avg. Watch Duration",
"metricNewSubscribers": "New Subscribers",
"metricUploadedVideos": "Uploaded Videos",
"unitHours": "h",
"unitMinutes": "m",
"noDataInPeriod": "No data available for this period.",
"dataLoading": "Loading data...",
"noUploadsTitle": "No uploaded videos yet.",
"noUploadsDesc": "Upload videos via ADO2 to see real stats.",
"reconnectButton": "Reconnect →",
"connectButton": "Connect →",
"retryButton": "Retry →",
"selectAccount": "Select an account",
"modeAnnual": "Annual",
"modeMonthly": "Monthly",
"chartThisYear": "This Year",
"chartLastYear": "Last Year",
"chartThisMonth": "This Month",
"chartLastMonth": "Last Month",
"monthOverMonth": "Month-over-Month Growth",
"noAudienceData": "Not enough data to show real audience insights.",
"mockContentNotice": "The content shown below is sample data, not your actual content.",
"dataDelayTitle": "Data is being prepared",
"dataDelayDesc": "According to YouTube's policy, analytics data may take up to 48 hours to be reflected after a video is uploaded. Please check back later.",
"sampleHide": "Hide Sample",
"sampleShow": "Show Sample",
"recentVideosDesc": "Showing stats for the latest 30 videos uploaded via ADO2.",
"channelStatsDesc": "Showing stats for the selected channel.",
"youtubeApiFailed": "Failed to call YouTube Analytics API."
}
},
"myInfo": {
"title": "My Info",
@ -449,22 +307,7 @@
"connected": "Connected",
"disconnectAccount": "Disconnect",
"loadingAccounts": "Loading account information...",
"youtubeExpiredAlert": "YouTube authentication has expired. Redirecting to reconnect page.",
"creditsTitle": "Credit Balance",
"creditsLoading": "Loading...",
"creditsLabel": "Available Credits",
"creditsUnit": "credits",
"creditsDesc": "You can request a top-up from the admin if you run low on credits.",
"creditsChargeBtn": "Top Up",
"chargePopupTitle": "Credit Top-up Request",
"chargeAmountLabel": "Credits to Request",
"chargeNoteLabel": "Additional Notes",
"chargeNotePlaceholder": "Enter any message for the admin",
"chargeCancel": "Cancel",
"chargeSubmit": "Submit",
"chargeSubmitting": "Submitting...",
"chargeSuccess": "Your top-up request has been submitted!",
"chargeConfirm": "OK"
"youtubeExpiredAlert": "YouTube authentication has expired. Redirecting to reconnect page."
},
"ado2Contents": {
"title": "ADO2 Contents",
@ -483,14 +326,7 @@
"delete": "Delete",
"previous": "Previous",
"next": "Next",
"uploadToSocial": "Upload to social media",
"sortLatest": "Latest",
"sortOldest": "Oldest",
"sortLikes": "Most Liked",
"sortComments": "Most Commented",
"regionPlaceholder": "Select Region",
"searchPlaceholder": "Search by name",
"searchBtn": "Search"
"uploadToSocial": "Upload to social media"
},
"businessSettings": {
"title": "Business Settings",
@ -519,11 +355,7 @@
"generateContent": "Generate Content",
"pageDescBefore": " reveals ",
"pageDescAfter": "'s core strategy.",
"loadingTitle": "Analyzing Brand",
"loadingStep1": "Collecting brand information...",
"loadingStep2": "Analyzing collected data...",
"loadingStep3": "Deriving key strategies...",
"loadingStep4": "Organizing results..."
"loadingTitle": "Analyzing Brand"
},
"common": {
"back": "Go Back",
@ -535,48 +367,14 @@
"required": "*",
"unknown": "Unknown"
},
"contentCalendar": {
"title": "Content Calendar",
"tabs": {
"all": "All",
"completed": "Done",
"scheduled": "Planned",
"failed": "Failed"
},
"status": {
"completed": "Done",
"scheduled": "Planned",
"failed": "Failed"
},
"months": ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],
"days": ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],
"yearMonth": "{{month}} {{year}}",
"monthDay": "{{month}} {{day}}",
"loading": "Loading...",
"noResults": "No recent results",
"noResultsDesc": "Upload your content to social channels",
"ado2Contents": "ADO2 Contents",
"cancel": "Cancel",
"retry": "Retry",
"cancelConfirm": "Are you sure you want to cancel this reservation?",
"cancelFailed": "Failed to cancel.",
"retryFailed": "Failed to retry.",
"completedCount": "Done {{count}}",
"scheduledCount": "Sched {{count}}",
"failedCount": "Fail {{count}}"
},
"loginPrompt": {
"title": "Login Required",
"loginBtn": "Login with Kakao"
},
"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": "No results found. Please check your input and try again.",
"autocompleteError": "No results found. Please check your input and try again.",
"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."
}

View File

@ -12,95 +12,16 @@
"myContents": "내 콘텐츠",
"myInfo": "내 정보",
"defaultUser": "사용자",
"credits": "보유 크레딧: {{count}}",
"loggingOut": "로그아웃 중...",
"logout": "로그아웃",
"tutorialRestart": "튜토리얼 다시 보기",
"tutorial": "튜토리얼",
"tutorialOn": "튜토리얼 켜기",
"tutorialOff": "튜토리얼 끄기",
"inquiry": "고객의견"
},
"tutorial": {
"skip": "건너뛰기",
"next": "다음",
"prev": "이전",
"finish": "완료",
"landing": {
"intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요." },
"dropdown": { "title": "검색 방식 선택", "desc": "드롭다운에서 URL 또는 업체명 중 원하는 방식을 선택하세요." },
"field": { "title": "입력하기", "desc": "URL 방식이라면 네이버 지도 공유 URL,\n업체명 방식이라면 업체명을 입력하고 목록에서 선택하세요." },
"button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." }
},
"asset": {
"image": { "title": "이미지 목록", "desc": "네이버 Place에서 가져 온 사진이에요. \n더보기를 누르면 나머지 사진도 볼 수 있고 X를 눌러 삭제 할 수 있어요." },
"upload": { "title": "이미지 추가", "desc": "이미지를 자유롭게 추가 할 수 있어요." },
"ratio": { "title": "영상 비율 선택", "desc": "생성 할 영상의 비율을 선택하세요." },
"next": { "title": "다음 단계로", "desc": "설정이 완료되면 다음 단계로 진행하세요." }
},
"sound": {
"genre": { "title": "장르 선택", "desc": "영상에 어울리는 음악 장르를 선택하세요.", "note": "배경음악은 이후 오픈 예정입니다." },
"language": { "title": "언어 선택", "desc": "음악의 언어를 선택할 수 있어요. \n이미 선택된 한국어로 진행해볼까요?" },
"generate": { "title": "사운드 생성", "desc": "버튼을 클릭하면 AI가 가사와 음악을 생성해요."},
"lyrics": { "title": "가사 생성 완료", "desc": "AI가 선택한 언어로 가사를 만들었어요.\n생성된 가사를 확인하세요." },
"lyricsWait": { "title": "음악 생성 중", "desc": "가사를 바탕으로 AI가 음악을 만들고 있어요.\n잠시만 기다려 주세요." },
"audioPlayer": { "title": "음악 미리 듣기", "desc": "음악 생성이 완료되었어요.\n재생 버튼을 눌러 생성된 음악을 들어보세요." },
"video": { "title": "영상 생성", "desc": "버튼을 클릭해서 영상 생성을 시작하세요." }
},
"completion": {
"contentInfo": { "title": "콘텐츠 정보", "desc": "콘텐츠의 파일명, 장르, 규격, 가사를 확인하세요." },
"generating": { "title": "영상 제작 중", "desc": "AI가 영상을 만들고 있어요. \n잠시만 기다려 주세요." },
"completion": { "title": "영상 완성!", "desc": "영상 제작이 완료되었어요. \n영상을 확인해 볼까요?" },
"myInfo": { "title": "소셜 계정 연동", "desc": "영상을 유튜브에 업로드하려면 내 정보에서 소셜 계정을 연동해야 해요. \n클릭해서 이동하세요." }
},
"myInfo": {
"myInfo": { "title": "내 정보", "desc": "내 정보에서는 소셜 연결과 연결된 계정을 확인 할 수 있어요." },
"connect": { "title": "연결하기", "desc": "YouTube 연결 버튼을 누르면 연결 페이지로 이동합니다.", "note": "Instargram 연결은 오픈 예정입니다." },
"connected": { "title": "연결 계정", "desc": "연결된 소셜 계정 목록이에요. \n연결 후 여기서 확인할 수 있어요." },
"ado2": { "title": "ADO2 콘텐츠", "desc": "이제 생성된 영상을 업로드할 수 있어요. \n클릭해서 이동하세요." }
},
"ado2": {
"list": { "title": "생성된 영상 목록", "desc": "ADO2에서 만든 영상들을 확인할 수 있어요." },
"download": { "title": "다운로드", "desc": "영상을 다운로드 할 수 있어요." },
"delete": { "title": "삭제", "desc": "필요없는 영상을 삭제할 수 있어요." },
"upload": { "title": "소셜 업로드", "desc": "선택해서 소셜미디어에 업로드하세요." }
},
"upload": {
"seo": { "title": "제목 및 설명", "desc": "영상의 제목과 설명을 AI가 만들고 있어요. 잠시만 기다려 주세요." },
"required": { "title": "필수 항목", "desc": "영상을 업로드 하기 전 *는 필수 항목으로 \n반드시 확인해 주세요." },
"schedule": { "title": "업로드 예약", "desc": "지금 게시하거나 원하는 시간에 예약할 수 있어요." },
"submit": { "title": "업로드 시작", "desc": "게시 버튼을 눌러 업로드를 시작하세요." }
},
"dashboard": {
"metrics": { "title": "핵심 지표", "desc": "조회수, 구독자 등 ADO2로 업로드한 콘텐츠의 주요 통계를 확인하세요." },
"chart": { "title": "성장 추이 차트", "desc": "기간별 성장 추이를 그래프로 확인할 수 있어요." },
"more": { "title": "더 많은 통계", "desc": "그 외에도 다양한 통계를 대시보드에서 한눈에 확인할 수 있어요." }
},
"contentCalendar": {
"grid": { "title": "콘텐츠 캘린더", "desc": "날짜별로 콘텐츠 스케줄을 확인할 수 있어요. \n오늘 날짜를 선택해 볼까요?" },
"panel": { "title": "콘텐츠 목록", "desc": "자세한 콘텐츠 스케줄을 확인 할수 있어요." }
},
"feedback": {
"complete": { "title": "튜토리얼 완료 🎉", "desc": "유튜브 업로드까지 모든 과정을 완료했어요. \n튜토리얼을 다시보고 싶다면 우측 상단의 버튼을 눌러주세요." },
"title": "고객의견",
"desc": "서비스 이용 중 불편한 점이나 개선 의견을 보내주세요."
},
"restart": {
"title": "튜토리얼을 다시 시작할까요?",
"desc": "현재 화면부터 튜토리얼이 다시 시작됩니다.",
"confirm": "시작하기",
"cancel": "취소"
}
"logout": "로그아웃"
},
"footer": {
"company":"㈜에이아이오투오",
"businessNumber": "사업자 등록번호 : 620-87-00810 | 대표 : 안성민",
"headquarters": "본사 : 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호",
"researchCenter": "연구소 : 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)",
"headquarters": "본사 : 41593 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호",
"researchCenter": "연구소 : 13453 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)",
"phone": "전화 : 070-4260-8310 | 010-2755-6463",
"email": "이메일 : o2oteam@o2o.kr",
"privacyPolicy": "개인정보처리방침",
"termsOfService": "서비스 약관"
"email": "이메일 : o2oteam@o2o.kr"
},
"social": {
"title": "소셜 미디어 포스팅",
@ -134,9 +55,9 @@
"invalidVideoInfo": "영상 정보가 올바르지 않습니다. (video_id 누락)",
"uploadStartFailed": "업로드 시작에 실패했습니다.",
"uploadFailed": "업로드에 실패했습니다.",
"autoSeoTitle": "자동으로 작성중입니다. (30~60초 소요)",
"autoSeoDescription": "자동으로 작성중입니다. (30~60초 소요)",
"autoSeoTags": "자동으로 작성중입니다. (30~60초 소요)"
"autoSeoTitle": "자동으로 작성중입니다. 기다려주세요.",
"autoSeoDescription": "자동으로 작성중입니다. 기다려주세요.",
"autoSeoTags": "자동으로 작성중입니다. 기다려주세요."
},
"upload": {
"title": "YouTube 업로드",
@ -152,14 +73,13 @@
"viewOnYoutube": "YouTube에서 보기",
"confirm": "확인",
"close": "닫기",
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.",
"goToCalendar": "캘린더에서 확인"
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요."
},
"landing": {
"hero": {
"searchTypeBusinessName": "업체명",
"placeholderBusinessName": "업체명을 입력하세요.",
"guideUrl": "네이버지도에서 장소를 검색하고 공유 선택, \n나오는 URL을 붙여 넣어 주세요.",
"placeholderBusinessName": "업체명을 입력하세요",
"guideUrl": "URL에서 가져온 정보로 영상이 자동 생성됩니다.",
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
"errorUrlRequired": "URL을 입력해주세요.",
"errorNameRequired": "업체명을 입력해주세요.",
@ -169,21 +89,15 @@
"testDataLoading": "로딩 중...",
"testData": "테스트 데이터",
"testDataLoadFailed": "테스트 데이터를 불러오는데 실패했습니다.",
"searching": "검색 중...",
"searchTypeManual": "직접 입력",
"manualModalTitle": "업체 정보 입력",
"manualLabelName": "업체명",
"manualLabelAddress": "주소",
"manualPlaceholderName": "업체명을 입력하세요.",
"manualPlaceholderAddress": "주소를 입력하세요."
"searching": "검색 중..."
},
"welcome": {
"title": "ADO2.AI에 오신 것을 환영합니다.",
"subtitle": "분석, 제작, 배포까지 콘텐츠 마케팅의 전과정을 자동화",
"feature1Title": "비즈니스 핵심 정보 분석",
"feature1Desc": "홈페이지, 네이버 지도, 블로그 등의\nURL을 입력하세요.",
"feature1Desc": "홈페이지, 네이버 지도, 블로그 등의\nURL을 입력하세요",
"feature2Title": "홍보 콘텐츠 자동 제작",
"feature2Desc": "분석된 정보를 바탕으로\n비즈니스에 맞는 음악, 자막, 영상을\n자동으로 제작해요",
"feature2Desc": "분석된 정보를 바탕으로\n비즈니스에 맞는 음악, 자막, 노래, 영상을\n자동으로 제작해요",
"feature3Title": "멀티채널 자동 배포",
"feature3Desc": "완성된 영상은 다운로드하거나\n바로 SNS에 업로드 할 수 있어요"
},
@ -200,19 +114,13 @@
},
"urlInput": {
"searchTypeBusinessName": "업체명",
"placeholderBusinessName": "업체명을 입력하세요.",
"guideUrl": "네이버지도에서 장소를 검색하고 공유 선택, \n나오는 URL을 붙여 넣어 주세요.",
"placeholderBusinessName": "업체명을 입력하세요",
"guideUrl": "URL에서 가져온 정보로 영상이 자동 생성됩니다.",
"guideBusinessName": "업체명으로 검색하여 정보를 가져옵니다.",
"searchButton": "검색하기",
"searching": "검색 중...",
"testDataLoading": "로딩 중...",
"testData": "테스트 데이터",
"searchTypeManual": "직접 입력",
"manualModalTitle": "업체 정보 입력",
"manualLabelName": "업체명",
"manualLabelAddress": "주소",
"manualPlaceholderName": "업체명을 입력하세요.",
"manualPlaceholderAddress": "주소를 입력하세요."
"testData": "테스트 데이터"
},
"assetManagement": {
"title": "브랜드 에셋",
@ -220,7 +128,7 @@
"imageAlt": "이미지",
"uploadBadge": "업로드",
"imageUpload": "이미지 업로드",
"dragAndDrop": "이미지를 끌어다 놓거나\n클릭하여 업로드",
"dragAndDrop": "이미지를 드래그하여\n업로드",
"videoRatio": "영상 비율",
"minImages": "최소 5장",
"youtubeShorts": "유튜브 쇼츠",
@ -228,7 +136,7 @@
"back": "뒤로가기",
"loadMore": "더보기",
"uploadFailed": "이미지 업로드에 실패했습니다.",
"uploading": "업로드 중 (30초~2분 소요)",
"uploading": "업로드 중...",
"nextStep": "다음 단계"
},
"soundStudio": {
@ -241,23 +149,20 @@
"genreLabel": "장르 선택",
"genreAuto": "자동 선택",
"genreBallad": "발라드",
"languageLabel": "언어 선택",
"languageLabel": "언어",
"languageKorean": "한국어",
"lyricsColumn": "가사",
"lyricsHint": "가사 영역을 선택해서 수정 가능해요",
"lyricsPlaceholder": "사운드 생성 시 가사 표시됩니다.",
"lyricsPlaceholderBGM": "배경음악은 가사 없이 생성됩니다.",
"generateButton": "사운드 생성",
"regenerateButton": "재생성 하기",
"regenerateHint": "재생성 버튼을 누르면 가사와 음악을 다시 만들 수 있어요.",
"generating": "생성 중...",
"generateVideo": "영상 생성하기",
"videoGenerating": "영상 생성 중",
"noBusinessInfo": "비즈니스 정보가 없습니다. 다시 시도해주세요.",
"noImageUploadInfo": "이미지 업로드 정보가 없습니다. 이전 단계로 돌아가 다시 시도해주세요.",
"generatingLyrics": "가사를 생성하고 있습니다. (30~60초 소요)",
"generatingSong": "음악을 생성하고 있습니다. (1~2분 소요)",
"songQueued": "음악 생성 대기 중...",
"generatingLyrics": "가사를 생성하고 있습니다...",
"generatingSong": "노래를 생성하고 있습니다...",
"songQueued": "노래 생성 대기 중...",
"retryMessage": "시간 초과로 재생성 중... ({{count}}/{{max}})",
"lyricGenerationFailed": "가사 생성 요청에 실패했습니다.",
"lyricNotReceived": "가사를 받지 못했습니다.",
@ -267,10 +172,7 @@
"musicGenerationFailed": "음악 생성에 실패했습니다.",
"multipleRetryFailed": "여러 번 시도했지만 음악 생성에 실패했습니다. 다시 시도해주세요.",
"musicGenerationError": "음악 생성 중 오류가 발생했습니다.",
"songRegenerationError": "음악 재생성 중 오류가 발생했습니다.",
"creditsRemaining": "잔여 {{count}}",
"creditsExhausted": "크레딧이 부족합니다.",
"chargeCredits": "크레딧 충전하기"
"songRegenerationError": "음악 재생성 중 오류가 발생했습니다."
},
"completion": {
"back": "뒤로가기",
@ -278,8 +180,6 @@
"titleError": "영상 생성 실패",
"titleComplete": "콘텐츠 제작 완료",
"imageAndVideo": "이미지 및 영상",
"checkingSubtitle": "자막 생성 상태를 확인하고 있습니다...",
"waitingSubtitle": "자막을 생성하고 있습니다...",
"requestingGeneration": "영상 생성을 요청하고 있습니다...",
"generatingVideo": "영상을 생성하고 있습니다...",
"processingAfterRefresh": "영상을 처리하고 있습니다... (새로고침 후 복구됨)",
@ -291,7 +191,7 @@
"statusPlanned": "예약됨",
"statusWaiting": "대기 중",
"statusTranscribing": "트랜스크립션 중",
"statusRendering": "생성 중 (2~3분 소요)",
"statusRendering": "렌더링 중",
"statusSucceeded": "완료",
"statusDefault": "처리 중...",
"aiOptimization": "AI 최적화",
@ -313,18 +213,7 @@
"videoNotReady": "영상이 아직 준비되지 않았습니다.",
"youtubeUploadMessage": "YouTube 채널 \"{{channelName}}\"에 영상을 업로드합니다.\n\n(업로드 기능 준비 중)",
"deployComingSoon": "선택한 소셜 채널에 배포 기능이 준비 중입니다.",
"youtubeExpiredAlert": "YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다.",
"contentComplete": "콘텐츠 제작 완료",
"contentCompleteDesc": "AI 분석 및 편집을 통해 최적화된 콘텐츠가 완성되었습니다",
"contentInfo": "파일명",
"genre": "장르",
"resolution": "규격",
"lyrics": "가사",
"sampleLyrics": "가사를 불러오는 중...",
"noLyricsBGM": "배경음악은 가사 없이 생성됩니다.",
"downloading": "다운로드 중...",
"download": "다운로드",
"uploadToSocial": "소셜 채널 업로드"
"youtubeExpiredAlert": "YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다."
},
"dashboard": {
"title": "대시보드",
@ -397,38 +286,7 @@
"stayIntroReel": "스테이 머뭄 소개 릴스",
"newYearEvent": "신년 특가 이벤트 안내",
"nightTimelapse": "펜션 야경 타임랩스"
},
"metricTotalViews": "조회수",
"metricWatchTime": "시청시간",
"metricAvgViewDuration": "평균 시청시간",
"metricNewSubscribers": "신규 구독자",
"metricUploadedVideos": "업로드 영상",
"unitHours": "시간",
"unitMinutes": "분",
"noDataInPeriod": "이 기간에 데이터가 없습니다.",
"dataLoading": "데이터 로딩 중...",
"noUploadsTitle": "아직 업로드된 영상이 없습니다.",
"noUploadsDesc": "ADO2에서 영상을 업로드하면 실제 통계가 표시됩니다.",
"reconnectButton": "재연동하러 가기 →",
"connectButton": "연동하러 가기 →",
"retryButton": "재시도 →",
"selectAccount": "계정을 선택하세요",
"modeAnnual": "연간",
"modeMonthly": "월간",
"chartThisYear": "올해",
"chartLastYear": "작년",
"chartThisMonth": "이번달",
"chartLastMonth": "지난달",
"monthOverMonth": "전월 대비 성장",
"noAudienceData": "누적 데이터가 부족하여 실제 시청자 정보가 없습니다.",
"mockContentNotice": "현재 표시된 콘텐츠는 실제 데이터가 아닌 샘플 데이터입니다.",
"dataDelayTitle": "데이터 준비 중",
"dataDelayDesc": "유튜브 정책에 따라 분석 데이터는 영상 업로드 후 최대 48시간이 지나야 반영됩니다. 나중에 다시 확인해 주세요.",
"sampleHide": "Sample 숨기기",
"sampleShow": "Sample 보기",
"recentVideosDesc": "ADO2에서 업로드한 최근 30개의 영상 통계가 표시됩니다.",
"channelStatsDesc": "선택한 채널의 통계가 표시됩니다.",
"youtubeApiFailed": "YouTube Analytics API 호출에 실패했습니다."
}
},
"myInfo": {
"title": "내 정보",
@ -449,22 +307,7 @@
"connected": "연결됨",
"disconnectAccount": "연결 해제",
"loadingAccounts": "계정 정보를 불러오는 중...",
"youtubeExpiredAlert": "YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다.",
"creditsTitle": "크레딧 현황",
"creditsLoading": "로딩 중...",
"creditsLabel": "보유 크레딧",
"creditsUnit": "크레딧",
"creditsDesc": "크레딧이 부족하면 관리자에게 충전 요청할 수 있습니다.",
"creditsChargeBtn": "충전하기",
"chargePopupTitle": "크레딧 충전 요청",
"chargeAmountLabel": "충전 크레딧",
"chargeNoteLabel": "기타 내용",
"chargeNotePlaceholder": "관리자에게 전달할 내용을 입력해주세요",
"chargeCancel": "취소",
"chargeSubmit": "요청하기",
"chargeSubmitting": "요청 중...",
"chargeSuccess": "충전 요청이 완료 되었습니다!",
"chargeConfirm": "확인"
"youtubeExpiredAlert": "YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다."
},
"ado2Contents": {
"title": "ADO2 콘텐츠",
@ -483,14 +326,7 @@
"delete": "삭제",
"previous": "이전",
"next": "다음",
"uploadToSocial": "소셜 미디어에 업로드",
"sortLatest": "최신순",
"sortOldest": "오래된순",
"sortLikes": "좋아요 많은순",
"sortComments": "댓글 많은순",
"regionPlaceholder": "지역 선택",
"searchPlaceholder": "업체명 검색",
"searchBtn": "검색"
"uploadToSocial": "소셜 미디어에 업로드"
},
"businessSettings": {
"title": "비즈니스 설정",
@ -519,11 +355,7 @@
"generateContent": "콘텐츠 생성",
"pageDescBefore": "을 통해 도출된 ",
"pageDescAfter": "의 핵심 전략입니다.",
"loadingTitle": "브랜드 분석 중",
"loadingStep1": "브랜드 정보를 수집하는 중...",
"loadingStep2": "수집한 데이터를 분석하는 중...",
"loadingStep3": "핵심 전략을 도출하는 중...",
"loadingStep4": "결과를 정리하는 중..."
"loadingTitle": "브랜드 분석 중"
},
"common": {
"back": "뒤로가기",
@ -535,48 +367,14 @@
"required": "*",
"unknown": "알 수 없음"
},
"contentCalendar": {
"title": "콘텐츠 캘린더",
"tabs": {
"all": "전체",
"completed": "완료",
"scheduled": "예약",
"failed": "실패"
},
"status": {
"completed": "완료",
"scheduled": "예약",
"failed": "실패"
},
"months": ["1월","2월","3월","4월","5월","6월","7월","8월","9월","10월","11월","12월"],
"days": ["일","월","화","수","목","금","토"],
"yearMonth": "{{year}}년 {{month}}",
"monthDay": "{{month}} {{day}}일",
"loading": "불러오는 중...",
"noResults": "최근 결과 없음",
"noResultsDesc": "제작한 콘텐츠를 소셜 채널에 업로드해 보세요",
"ado2Contents": "ADO2 콘텐츠",
"cancel": "취소",
"retry": "재시도",
"cancelConfirm": "예약을 취소하시겠습니까?",
"cancelFailed": "취소에 실패했습니다.",
"retryFailed": "재시도에 실패했습니다.",
"completedCount": "완료 {{count}}",
"scheduledCount": "예약 {{count}}",
"failedCount": "실패 {{count}}"
},
"loginPrompt": {
"title": "로그인이 필요합니다.",
"loginBtn": "로그인"
},
"app": {
"loginProcessing": "로그인 처리 중...",
"loginFailed": "로그인 처리에 실패했습니다. 다시 시도해주세요.",
"kakaoLoginFailed": "카카오 로그인에 실패했습니다. 다시 시도해주세요.",
"loginUrlFailed": "로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.",
"invalidUrl": "유효하지 않은 URL입니다. 네이버 지도 URL을 입력해주세요.",
"analysisError": "검색 정보를 찾을 수 없습니다. 입력 정보를 다시 확인해주세요.",
"autocompleteError": "검색 정보를 찾을 수 없습니다. 입력 정보를 다시 확인해주세요.",
"analysisError": "분석 중 오류가 발생했습니다. 다시 시도해주세요.",
"autocompleteError": "업체 정보 조회에 실패했습니다.",
"autocompleteGeneralError": "업체 정보 조회 중 오류가 발생했습니다. 다시 시도해주세요.",
"pageComingSoon": "{{page}} 페이지 준비 중입니다."
}

View File

@ -1,10 +1,8 @@
import React, { useEffect } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { CrawlingResponse, TargetPersona } from '../../types/api';
import { GeometricChart } from './GeometricChart';
import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
interface AnalysisResultSectionProps {
onBack: () => void;
@ -14,19 +12,8 @@ interface AnalysisResultSectionProps {
const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, onGenerate, data }) => {
const { t } = useTranslation();
const tutorial = useTutorial();
const { processed_info, marketing_analysis } = data;
useEffect(() => {
const timer = setTimeout(() => {
if (!tutorial.hasSeen(TUTORIAL_KEYS.ANALYSIS)) {
tutorial.startTutorial(TUTORIAL_KEYS.ANALYSIS);
}
}, 600);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const brandIdentity = marketing_analysis?.brand_identity;
const marketPositioning = marketing_analysis?.market_positioning;
const targetPersonas = marketing_analysis?.target_persona || [];
@ -37,7 +24,6 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
const sortedSellingPoints = [...sellingPoints].sort((a, b) => b.score - a.score);
return (
<>
<div className="bi2-page">
{/* Header */}
<div className="bi2-header">
@ -189,18 +175,6 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
</button>
</div>
</div>
{tutorial.isActive && (
<TutorialOverlay
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/>
)}
</>
);
};

View File

@ -1,50 +1,9 @@
import React, { useEffect, useRef, useState } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
interface LoadingSectionProps {
onComplete?: () => void;
isComplete?: boolean;
}
const LoadingSection: React.FC<LoadingSectionProps> = ({ onComplete, isComplete }) => {
const LoadingSection: React.FC = () => {
const { t } = useTranslation();
const [progress, setProgress] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
intervalRef.current = setInterval(() => {
setProgress(prev => {
if (prev >= 100) {
if (intervalRef.current) clearInterval(intervalRef.current);
return 100;
}
const increment = prev < 70 ? 0.5 : prev < 90 ? 0.2 : 0.1;
return Math.min(prev + increment, 100);
});
}, 100);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, []);
useEffect(() => {
if (!isComplete) return;
if (intervalRef.current) clearInterval(intervalRef.current);
setProgress(100);
const timer = setTimeout(() => {
onComplete?.();
}, 400);
return () => clearTimeout(timer);
}, [isComplete]);
const getStepMessage = (p: number) => {
if (p < 30) return t('analysis.loadingStep1');
if (p < 50) return t('analysis.loadingStep2');
if (p < 80) return t('analysis.loadingStep3');
return t('analysis.loadingStep4');
};
return (
<div className="loading-container">
<div className="loading-content">
@ -64,17 +23,6 @@ const LoadingSection: React.FC<LoadingSectionProps> = ({ onComplete, isComplete
className="loading-spinner-icon"
/>
</div>
<div className="loading-progress-wrapper">
<div className="loading-progress-bar">
<div
className="loading-progress-fill"
style={{ width: `${progress}%` }}
/>
</div>
<span className="loading-progress-text">{Math.floor(progress)}%</span>
</div>
<p className="loading-step-message">{getStepMessage(progress)}</p>
</div>
</div>
</div>

View File

@ -1,127 +1,148 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { getAllVideos, isLoggedIn } from '../../utils/api';
import { getVideosList, deleteVideo } from '../../utils/api';
import { VideoListItem } from '../../types/api';
import LoginPromptModal from '../../components/LoginPromptModal';
import VideoDetailModal from '../../components/VideoDetailModal';
import CitySelectModal from '../../components/CitySelectModal';
import SocialPostingModal from '../../components/SocialPostingModal';
interface ADO2ContentsPageProps {
onBack?: () => void;
onBack: () => void;
}
const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
const { t } = useTranslation();
const authed = isLoggedIn();
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
const [videos, setVideos] = useState<VideoListItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(authed);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [hasNext, setHasNext] = useState(false);
const [hasPrev, setHasPrev] = useState(false);
const [totalPages, setTotalPages] = useState(1);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [uploadTargetVideo, setUploadTargetVideo] = useState<VideoListItem | null>(null);
const pageSize = 20;
const [sortBy, setSortBy] = useState<'created_at' | 'like_count' | 'comment_count'>('created_at');
const [order, setOrder] = useState<'desc' | 'asc'>('desc');
const [searchInput, setSearchInput] = useState('');
const [storeName, setStoreName] = useState('');
const [region, setRegion] = useState('');
const [showCityModal, setShowCityModal] = useState(false);
const pageSize = 12;
useEffect(() => {
if (!authed) return;
fetchVideos();
}, [page, sortBy, order, storeName, region]);
}, [page]);
const fetchVideos = async () => {
setLoading(true);
setError(null);
try {
const response = await getAllVideos(page, pageSize, sortBy, storeName, order, region);
setVideos(response.items);
const response = await getVideosList(page, pageSize);
console.log('[ADO2] API response:', response);
console.log('[ADO2] First video item:', response.items[0]);
// result_movie_url이 있는 비디오만 필터링 (빈/더미 데이터 제외)
const validVideos = response.items.filter(video => video.result_movie_url && video.result_movie_url.trim() !== '');
setVideos(validVideos);
setTotal(response.total);
setTotalPages(response.total_pages);
setHasNext(response.has_next);
setHasPrev(response.has_prev);
} catch (err) {
console.error('Failed to fetch all videos:', err);
console.error('Failed to fetch videos:', err);
setError(t('ado2Contents.loadFailed'));
} finally {
setLoading(false);
}
};
const handleCardClick = (videoId: number) => {
setSelectedVideoId(videoId);
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}.${month}.${day}`;
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}.${month}.${day}${hours}:${minutes}`;
};
const formatTitle = (storeName: string, dateString: string) => {
const date = new Date(dateString);
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${storeName} ${month}/${day} ${hours}:${minutes}`;
};
const handleDownload = async (videoUrl: string, storeName: string) => {
try {
const response = await fetch(videoUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${storeName}.mp4`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (err) {
console.error('Download failed:', err);
alert(t('ado2Contents.downloadFailed'));
}
};
const handleDeleteClick = (videoId: number) => {
setDeleteTargetId(videoId);
setDeleteModalOpen(true);
};
const handleUploadClick = (video: VideoListItem) => {
console.log('[ADO2] Upload clicked - video object:', video);
console.log('[ADO2] video.video_id:', video.video_id);
setUploadTargetVideo(video);
setUploadModalOpen(true);
};
const handleUploadModalClose = () => {
setUploadModalOpen(false);
setUploadTargetVideo(null);
};
const handleDeleteCancel = () => {
setDeleteModalOpen(false);
setDeleteTargetId(null);
};
const handleDeleteConfirm = async () => {
if (!deleteTargetId) return;
setIsDeleting(true);
try {
await deleteVideo(deleteTargetId);
// 삭제 성공 시 로컬 상태에서 즉시 제거 (UI 즉시 반영)
// fetchVideos()를 호출하지 않음 - 서버 캐시 또는 동기화 지연으로 인해
// 삭제된 항목이 다시 나타날 수 있기 때문
setVideos(prev => prev.filter(video => video.video_id !== deleteTargetId));
setTotal(prev => Math.max(0, prev - 1));
setDeleteModalOpen(false);
setDeleteTargetId(null);
} catch (err) {
console.error('Delete failed:', err);
alert(t('ado2Contents.deleteFailed'));
} finally {
setIsDeleting(false);
}
};
return (
<div className="ado2-contents-page">
{/* Header */}
<div className="ado2-contents-header">
<h1 className="ado2-contents-title">{t('sidebar.ado2Contents')}</h1>
<h1 className="ado2-contents-title">{t('ado2Contents.title')}</h1>
<span className="ado2-contents-count">{t('ado2Contents.totalCount', { count: total })}</span>
</div>
<div className="ado2-contents-filters">
<select
className="ado2-filter-select"
value={`${sortBy}__${order}`}
onChange={(e) => {
const [sb, ord] = e.target.value.split('__') as [typeof sortBy, typeof order];
setSortBy(sb);
setOrder(ord);
setPage(1);
}}
>
<option value="created_at__desc">{t('ado2Contents.sortLatest')}</option>
<option value="created_at__asc">{t('ado2Contents.sortOldest')}</option>
<option value="like_count__desc">{t('ado2Contents.sortLikes')}</option>
<option value="comment_count__desc">{t('ado2Contents.sortComments')}</option>
</select>
<button
type="button"
className={`ado2-region-pill ${region ? 'active' : ''}`}
onClick={() => setShowCityModal(true)}
>
{region || t('ado2Contents.regionPlaceholder')}
{region && (
<span className="ado2-region-clear" onClick={(e) => { e.stopPropagation(); setRegion(''); setPage(1); }}></span>
)}
</button>
<form
className="ado2-filter-search"
onSubmit={(e) => {
e.preventDefault();
setStoreName(searchInput);
setPage(1);
}}
>
<input
type="text"
className="ado2-filter-input"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder={t('ado2Contents.searchPlaceholder')}
/>
<button type="submit" className="ado2-filter-btn">
{t('ado2Contents.searchBtn')}
</button>
</form>
</div>
{/* Content Grid */}
{loading ? (
<div className="ado2-contents-loading">
<div className="loading-spinner"></div>
@ -140,64 +161,89 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
<>
<div className="ado2-contents-grid">
{videos.map((video) => (
<div
key={video.video_id}
className="ado2-content-card"
style={{ cursor: 'pointer' }}
onClick={() => handleCardClick(video.video_id)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && handleCardClick(video.video_id)}
>
<div className="content-card-thumbnail ado2-gallery-thumbnail-wrap">
{video.thumbnail_url ? (
<img
src={video.thumbnail_url}
alt={video.store_name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : video.result_movie_url ? (
<div key={video.task_id} className="ado2-content-card">
{/* Video Thumbnail */}
<div className="content-card-thumbnail">
{video.result_movie_url ? (
<video
src={video.result_movie_url}
className="content-video-preview"
preload="metadata"
muted
playsInline
onMouseEnter={(e) => e.currentTarget.play()}
onMouseLeave={(e) => {
e.currentTarget.pause();
e.currentTarget.currentTime = 0;
}}
/>
) : (
<div className="content-no-video" />
)}
{/* 호버 오버레이 */}
<div className="ado2-gallery-overlay">
<div className="ado2-gallery-play-btn">
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5,3 19,12 5,21"/>
<div className="content-no-video">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/>
<line x1="7" y1="2" x2="7" y2="22"/>
<line x1="17" y1="2" x2="17" y2="22"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<line x1="2" y1="7" x2="7" y2="7"/>
<line x1="2" y1="17" x2="7" y2="17"/>
<line x1="17" y1="17" x2="22" y2="17"/>
<line x1="17" y1="7" x2="22" y2="7"/>
</svg>
</div>
</div>
)}
</div>
{/* Card Info */}
<div className="content-card-info">
<div className="content-card-text">
<h3 className="content-card-title">{video.store_name}</h3>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<p className="content-card-date">{formatDate(video.created_at)}</p>
<span className="content-card-like">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
{video.like_count ?? 0}
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: '6px' }}>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
{video.comment_count ?? 0}
</span>
</div>
<h3 className="content-card-title">
{formatTitle(video.store_name, video.created_at)}
</h3>
<p className="content-card-date">
{formatDate(video.created_at)}
</p>
</div>
{/* Action Buttons */}
<div className="content-card-actions">
<button
className="content-download-btn"
onClick={() => handleUploadClick(video)}
disabled={!video.result_movie_url}
>
<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"/>
<path d="M3 15v2h14v-2"/>
</svg>
<span>{t('ado2Contents.uploadToSocial')}</span>
</button>
<button
className="content-upload-btn"
onClick={() => handleDownload(video.result_movie_url, video.store_name)}
disabled={!video.result_movie_url}
title={t('ado2Contents.download')}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 3v10M10 13l-4-4M10 13l4-4"/>
<path d="M3 15v2h14v-2"/>
</svg>
</button>
<button
className="content-delete-btn"
onClick={() => handleDeleteClick(video.video_id)}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/>
<line x1="8" y1="8" x2="8" y2="14"/>
<line x1="12" y1="8" x2="12" y2="14"/>
</svg>
</button>
</div>
</div>
</div>
))}
</div>
{/* Pagination - 항상 표시 */}
<div className="ado2-contents-pagination">
<button
className="pagination-btn"
@ -218,24 +264,38 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
</>
)}
{!authed && (
<LoginPromptModal onClose={() => { window.location.href = '/'; }} />
{/* 삭제 확인 모달 */}
{deleteModalOpen && (
<div className="delete-modal-overlay" onClick={handleDeleteCancel}>
<div className="delete-modal" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<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 ? t('ado2Contents.deleting') : t('ado2Contents.delete')}
</button>
</div>
</div>
</div>
)}
{selectedVideoId !== null && (
<VideoDetailModal
videoId={String(selectedVideoId)}
onClose={() => setSelectedVideoId(null)}
/>
)}
{showCityModal && (
<CitySelectModal
selected={region}
onSelect={(city) => { setRegion(city); setPage(1); }}
onClose={() => setShowCityModal(false)}
/>
)}
{/* 소셜 미디어 업로드 모달 */}
<SocialPostingModal
isOpen={uploadModalOpen}
onClose={handleUploadModalClose}
video={uploadTargetVideo}
/>
</div>
);
};

View File

@ -1,18 +1,14 @@
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { generateVideo, waitForVideoComplete, getSubtitleStatus, waitForSubtitleComplete } from '../../utils/api';
import { generateVideo, waitForVideoComplete } from '../../utils/api';
import SocialPostingModal from '../../components/SocialPostingModal';
import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
interface CompletionContentProps {
onBack: () => void;
songTaskId: string | null;
onVideoStatusChange?: (status: 'idle' | 'generating' | 'complete' | 'error') => void;
onVideoProgressChange?: (progress: number) => void;
onGoToCalendar?: () => void;
}
type VideoStatus = 'idle' | 'generating' | 'polling' | 'complete' | 'error';
@ -33,8 +29,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
onBack,
songTaskId,
onVideoStatusChange,
onVideoProgressChange,
onGoToCalendar,
onVideoProgressChange
}) => {
const { t } = useTranslation();
const [videoStatus, setVideoStatus] = useState<VideoStatus>('idle');
@ -42,25 +37,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState('');
const [renderProgress, setRenderProgress] = useState(0);
const [displayProgress, setDisplayProgress] = useState(0);
const hasStartedGeneration = useRef(false);
const displayIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const tutorial = useTutorial();
const tutorialIsActiveRef = useRef(tutorial.isActive);
tutorialIsActiveRef.current = tutorial.isActive;
// 영상 생성 중 튜토리얼 트리거 (생성 상태 안내 -> 콘텐츠 정보 -> 내 정보 이동)
useEffect(() => {
const isComplete = videoStatus === 'complete';
const isProcessing = videoStatus === 'generating' || videoStatus === 'polling';
if (isProcessing && !tutorialIsActiveRef.current && !tutorial.hasSeen(TUTORIAL_KEYS.GENERATING)) {
tutorial.startTutorial(TUTORIAL_KEYS.GENERATING);
} else if (isComplete && !tutorialIsActiveRef.current && !tutorial.hasSeen(TUTORIAL_KEYS.COMPLETION)) {
tutorial.startTutorial(TUTORIAL_KEYS.COMPLETION);
}
}, [videoStatus]);
// 소셜 미디어 포스팅 모달
const [showSocialModal, setShowSocialModal] = useState(false);
@ -89,31 +66,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
}
}, [renderProgress, onVideoProgressChange]);
useEffect(() => {
if (displayIntervalRef.current) clearInterval(displayIntervalRef.current);
if (renderProgress === 100) {
setDisplayProgress(100);
return;
}
// renderProgress에 도달한 후에도 99%까지 서서히 크리핑
const CREEP_MAX = 99;
displayIntervalRef.current = setInterval(() => {
setDisplayProgress(prev => {
const target = Math.max(prev, renderProgress);
if (prev >= CREEP_MAX) return prev;
const increment = prev < 70 ? 0.2 : prev < 90 ? 0.04 : 0.01;
return Math.min(prev + increment, Math.max(target, Math.min(prev + increment, CREEP_MAX)));
});
}, 100);
return () => {
if (displayIntervalRef.current) clearInterval(displayIntervalRef.current);
};
}, [renderProgress]);
const saveToStorage = (videoTaskId: string, currentSongTaskId: string, status: VideoStatus, url: string | null, dbId?: number) => {
const data: SavedVideoState = {
videoTaskId,
@ -174,19 +126,10 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
hasStartedGeneration.current = true;
setVideoStatus('generating');
setStatusMessage(t('completion.checkingSubtitle'));
setStatusMessage(t('completion.requestingGeneration'));
setErrorMessage(null);
try {
// 자막 완료 여부 확인 후 미완료면 폴링
const subtitleStatus = await getSubtitleStatus(songTaskId);
if (subtitleStatus.status !== 'completed') {
setStatusMessage(t('completion.waitingSubtitle'));
await waitForSubtitleComplete(songTaskId);
}
setStatusMessage(t('completion.requestingGeneration'));
const savedRatio = localStorage.getItem('castad_video_ratio');
const orientation = (savedRatio === 'horizontal' || savedRatio === 'vertical') ? savedRatio : 'vertical';
@ -294,7 +237,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
setVideoUrl(completeVideo.videoUrl);
if (completeVideo.videoDbId) setVideoDbId(completeVideo.videoDbId);
setVideoStatus('complete');
setShowComplete(true);
hasStartedGeneration.current = true;
return;
}
@ -306,7 +248,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
setVideoUrl(savedState.videoUrl);
if (savedState.videoDbId) setVideoDbId(savedState.videoDbId);
setVideoStatus('complete');
setShowComplete(true);
hasStartedGeneration.current = true;
} else if (savedState.status === 'polling') {
setVideoStatus('polling');
@ -338,32 +279,15 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
}
}, []);
const [isDownloading, setIsDownloading] = useState(false);
const handleDownload = async () => {
if (!videoUrl || isDownloading) return;
setIsDownloading(true);
try {
const response = await fetch(videoUrl);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = getFileName();
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
} catch {
const handleDownload = () => {
if (videoUrl) {
const link = document.createElement('a');
link.href = videoUrl;
link.download = getFileName();
link.download = 'castad_video.mp4';
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} finally {
setIsDownloading(false);
}
};
@ -380,20 +304,11 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
setVideoStatus('idle');
setVideoUrl(null);
setErrorMessage(null);
setShowComplete(false);
clearStorage();
startVideoGeneration();
};
const [showComplete, setShowComplete] = useState(false);
useEffect(() => {
if (displayProgress < 100) return;
const timer = setTimeout(() => setShowComplete(true), 500);
return () => clearTimeout(timer);
}, [displayProgress]);
const isLoading = videoStatus === 'generating' || videoStatus === 'polling' || (videoStatus === 'complete' && !showComplete);
const isLoading = videoStatus === 'generating' || videoStatus === 'polling';
// 비디오 해상도 계산
const getVideoResolution = () => {
@ -419,8 +334,8 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
</div>
<div className="comp2-title-row">
<h1 className="comp2-page-title">{t('completion.contentComplete')}</h1>
<p className="comp2-page-subtitle">{t('completion.contentCompleteDesc')}</p>
<h1 className="comp2-page-title">{t('completion.contentComplete', { defaultValue: '콘텐츠 제작 완료' })}</h1>
<p className="comp2-page-subtitle">{t('completion.contentCompleteDesc', { defaultValue: 'AI 분석 및 편집을 통해 최적화된 콘텐츠가 완성되었습니다' })}</p>
</div>
<div className="comp2-container">
@ -437,15 +352,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
</div>
</div>
<p className="comp2-loading-text">{statusMessage}</p>
<div className="loading-progress-wrapper">
<div className="loading-progress-bar">
<div
className="loading-progress-fill"
style={{ width: `${displayProgress}%` }}
/>
</div>
<span className="loading-progress-text">{Math.floor(displayProgress)}%</span>
</div>
</div>
) : videoStatus === 'error' ? (
<div className="comp2-video-error">
@ -474,7 +380,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
{/* 오른쪽: 콘텐츠 정보 */}
<div className="comp2-info-section">
<div className="comp2-info-header">
<span className="comp2-info-label">{t('completion.contentInfo')}</span>
<span className="comp2-info-label">{t('completion.contentInfo', { defaultValue: '콘텐츠 정보' })}</span>
</div>
<div className="comp2-info-content">
<div className="comp2-file-info">
@ -483,42 +389,18 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
</div>
<div className="comp2-meta-grid">
<div className="comp2-meta-item">
<span className="comp2-meta-label">{t('completion.genre')} : {songCompletionData?.genre || 'K-POP'}</span>
<span className="comp2-meta-label">{t('completion.genre', { defaultValue: '장르' })} : {songCompletionData?.genre || 'K-POP'}</span>
</div>
<div className="comp2-meta-divider"></div>
<div className="comp2-meta-item">
<span className="comp2-meta-label">{t('completion.resolution')} : {getVideoResolution()}</span>
<span className="comp2-meta-label">{t('completion.resolution', { defaultValue: '규격' })} : {getVideoResolution()}</span>
</div>
<div className="comp2-meta-divider"></div>
<div className="comp2-lyrics-section">
<span className="comp2-meta-label">{t('completion.lyrics')}</span>
{(() => {
if (!songCompletionData) {
return <p className="comp2-lyrics-text">{t('completion.sampleLyrics')}</p>;
}
if (!songCompletionData.lyrics) {
return <p className="comp2-lyrics-text">{t('completion.noLyricsBGM')}</p>;
}
const lines = songCompletionData.lyrics.split('\n').filter((l: string) => l.trim());
const outro = lines.slice(-1);
const body = lines.slice(0, -1);
const half = Math.ceil(body.length / 2);
const sections = [
{ tag: '[Verse]', lines: body.slice(0, half) },
{ tag: '[Chorus]', lines: body.slice(half) },
{ tag: '[Outro]', lines: outro },
].filter(s => s.lines.length > 0);
return (
<div className="comp2-lyrics-paragraphs">
{sections.map((section, i) => (
<div key={i} className="comp2-lyrics-para-section">
<span className="comp2-lyrics-tag">{section.tag}</span>
<p className="comp2-lyrics-text">{section.lines.join('\n')}</p>
</div>
))}
</div>
);
})()}
<span className="comp2-meta-label">{t('completion.lyrics', { defaultValue: '가사' })}</span>
<p className="comp2-lyrics-text">
{songCompletionData?.lyrics || t('completion.sampleLyrics', { defaultValue: '가사를 불러오는 중...' })}
</p>
</div>
</div>
</div>
@ -526,17 +408,17 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
<div className="comp2-buttons">
<button
onClick={handleDownload}
disabled={videoStatus !== 'complete' || !videoUrl || isDownloading}
disabled={videoStatus !== 'complete' || !videoUrl}
className="comp2-btn comp2-btn-secondary"
>
{isDownloading ? t('completion.downloading') : t('completion.download')}
{t('completion.download', { defaultValue: '다운로드' })}
</button>
<button
onClick={handleOpenSocialConnect}
disabled={videoStatus !== 'complete' || !videoDbId}
className="comp2-btn comp2-btn-primary"
>
{t('completion.uploadToSocial')}
{t('completion.uploadToSocial', { defaultValue: '소셜 채널 업로드' })}
</button>
</div>
</div>
@ -547,7 +429,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
<SocialPostingModal
isOpen={showSocialModal}
onClose={handleCloseSocialConnect}
onGoToCalendar={onGoToCalendar}
video={videoUrl && videoDbId ? {
video_id: videoDbId,
store_name: songCompletionData?.businessName || '',
@ -555,21 +436,8 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
task_id: songTaskId || '',
result_movie_url: videoUrl,
created_at: new Date().toISOString(),
like_count: 0,
comment_count: 0,
} : null}
/>
{tutorial.isActive && (
<TutorialOverlay
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/>
)}
</main>
);
};

View File

@ -1,6 +1,5 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { UploadHistoryItem } from '../../types/api';
import { getUploadHistory, cancelUpload, retryUpload } from '../../utils/api';
@ -54,13 +53,13 @@ const statusColor = (status: UploadStatus) => {
}
};
// 상태 라벨 (컴포넌트 내부에서 t()로 처리)
const statusLabelKey = (status: UploadStatus) => {
// 상태 라벨
const statusLabel = (status: UploadStatus) => {
switch (status) {
case 'completed': return 'contentCalendar.status.completed';
case 'completed': return '완료';
case 'scheduled':
case 'pending': return 'contentCalendar.status.scheduled';
case 'failed': return 'contentCalendar.status.failed';
case 'pending': return '예약';
case 'failed': return '실패';
default: return status;
}
};
@ -70,7 +69,6 @@ interface ContentCalendarContentProps {
}
const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavigate }) => {
const { t } = useTranslation();
const today = new Date();
const [year, setYear] = useState(today.getFullYear());
const [month, setMonth] = useState(today.getMonth());
@ -205,7 +203,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
// 취소
const handleCancel = async (uploadId: number) => {
if (!window.confirm(t('contentCalendar.cancelConfirm'))) return;
if (!window.confirm('예약을 취소하시겠습니까?')) return;
// 낙관적 업데이트: 먼저 UI에서 제거
setAllItems(prev => prev.filter(i => i.upload_id !== uploadId));
setPanelItems(prev => prev.filter(i => i.upload_id !== uploadId));
@ -214,7 +212,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
} catch {
// 실패 시 다시 불러오기
refreshAll();
alert(t('contentCalendar.cancelFailed'));
alert('취소에 실패했습니다.');
}
};
@ -224,7 +222,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
await retryUpload(uploadId);
refreshAll();
} catch {
alert(t('contentCalendar.retryFailed'));
alert('재시도에 실패했습니다.');
}
};
@ -236,11 +234,10 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
// 날짜 표시
// 날짜 표시 (M월 D)
const formatDateLabel = (key: string) => {
const [, m, d] = key.split('-');
const months = t('contentCalendar.months', { returnObjects: true }) as string[];
return t('contentCalendar.monthDay', { month: months[parseInt(m) - 1], day: parseInt(d) });
return `${parseInt(m)}${parseInt(d)}`;
};
const buildCalendarDays = (): CalendarDay[] => {
@ -269,15 +266,9 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
};
const calendarDays = buildCalendarDays();
const monthNames = t('contentCalendar.months', { returnObjects: true }) as string[];
const dayLabels = t('contentCalendar.days', { returnObjects: true }) as string[];
const monthNames = ['1월','2월','3월','4월','5월','6월','7월','8월','9월','10월','11월','12월'];
const dayLabels = ['일','월','화','수','목','금','토'];
const tabs: TabType[] = ['전체','완료','예약','실패'];
const tabLabels: Record<TabType, string> = {
'전체': t('contentCalendar.tabs.all'),
'완료': t('contentCalendar.tabs.completed'),
'예약': t('contentCalendar.tabs.scheduled'),
'실패': t('contentCalendar.tabs.failed'),
};
// ── 캘린더 그리드 ──────────────────────────────────────────────
const renderCalendarGrid = () => {
@ -397,7 +388,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#1ba64f', flexShrink: 0 }} />
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
{t('contentCalendar.completedCount', { count: summary.completed })}
{summary.completed}
</span>
</div>
)}
@ -405,7 +396,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#2563eb', flexShrink: 0 }} />
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
{t('contentCalendar.scheduledCount', { count: summary.scheduled })}
{summary.scheduled}
</span>
</div>
)}
@ -413,7 +404,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: '#e15252', flexShrink: 0 }} />
<span style={{ fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 11, color: '#01191a', lineHeight: 1, whiteSpace: 'nowrap' }}>
{t('contentCalendar.failedCount', { count: summary.failed })}
{summary.failed}
</span>
</div>
)}
@ -441,7 +432,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
letterSpacing: '-0.096px', lineHeight: '22px', whiteSpace: 'nowrap',
}}
>
{tabLabels[tab]}
{tab}
</button>
))}
</div>
@ -451,9 +442,9 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
const renderStats = () => (
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
{[
{ label: t('contentCalendar.tabs.scheduled'), value: totalStats.scheduled },
{ label: t('contentCalendar.tabs.completed'), value: totalStats.completed },
{ label: t('contentCalendar.tabs.failed'), value: totalStats.failed },
{ label: '예약', value: totalStats.scheduled },
{ label: '완료', value: totalStats.completed },
{ label: '실패', value: totalStats.failed },
].map((item, idx) => (
<React.Fragment key={item.label}>
{idx > 0 && <div style={{ width: 0, height: 14, borderLeft: '1px solid #379599' }} />}
@ -492,7 +483,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
color: '#e5f1f2', letterSpacing: '-0.096px', lineHeight: '22px',
margin: 0, whiteSpace: 'nowrap',
}}>
{t('contentCalendar.yearMonth', { year, month: monthNames[month] })}
{year} {monthNames[month]}
</p>
<button
onClick={handleNextMonth}
@ -538,7 +529,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 13,
color: statusColor(item.status as UploadStatus), lineHeight: 1,
}}>
{t(statusLabelKey(item.status as UploadStatus))}
{statusLabel(item.status as UploadStatus)}
</span>
<span style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 400, fontSize: 13,
@ -548,28 +539,19 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
</span>
</div>
{/* 채널 아이콘 + 채널명 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<PlatformIcon platform={item.platform} size={16} />
{/* 채널 아이콘 + 제목 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<PlatformIcon platform={item.platform} size={18} />
<span style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 12,
color: '#9bcacc', lineHeight: 1.4,
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
fontFamily: 'Pretendard, sans-serif', fontWeight: 600, fontSize: 14,
color: '#e5f1f2', lineHeight: 1.4,
overflow: 'hidden', display: '-webkit-box',
WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
}}>
{item.platform_username || item.platform_user_id || item.channel_name}
{item.title}
</span>
</div>
{/* 제목 */}
<span style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 600, fontSize: 14,
color: '#e5f1f2', lineHeight: 1.4,
overflow: 'hidden', display: '-webkit-box',
WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
}}>
{item.title}
</span>
{/* 실패 메시지 */}
{isFailed && item.error_message && (
<p style={{
@ -593,7 +575,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
color: '#9bcacc', cursor: 'pointer',
}}
>
{t('contentCalendar.cancel')}
</button>
)}
{isFailed && (
@ -606,7 +588,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
color: '#ffffff', cursor: 'pointer',
}}
>
{t('contentCalendar.retry')}
</button>
)}
</div>
@ -620,7 +602,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
if (panelLoading) {
return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ color: '#9bcacc', fontFamily: 'Pretendard, sans-serif', fontSize: 14 }}>{t('contentCalendar.loading')}</span>
<span style={{ color: '#9bcacc', fontFamily: 'Pretendard, sans-serif', fontSize: 14 }}> ...</span>
</div>
);
}
@ -635,13 +617,13 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 16,
color: '#e5f1f2', letterSpacing: '-0.096px', lineHeight: '22px', margin: 0,
}}>
{t('contentCalendar.noResults')}
</p>
<p style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 12,
color: '#9bcacc', letterSpacing: '-0.072px', lineHeight: 1, margin: 0,
}}>
{t('contentCalendar.noResultsDesc')}
</p>
</div>
<button
@ -653,7 +635,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
color: '#ffffff',
}}
>
{t('contentCalendar.ado2Contents')}
ADO2
</button>
</div>
);
@ -662,7 +644,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
<div
ref={panelRef}
className="calendar-panel-scroll"
style={{ flex: 1, overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 20, maxHeight: 700 }}
style={{ flex: 1, overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 20, maxHeight: 900 }}
>
{sortedDateKeys.map(dateKey => (
<div
@ -699,7 +681,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
color: '#ffffff', letterSpacing: '-0.18px', lineHeight: 1.3, margin: 0,
}}>
{t('contentCalendar.title')}
</p>
</div>
@ -754,7 +736,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
padding: '32px',
padding: '80px 32px',
width: '100%',
minWidth: 1400,
height: '100%', boxSizing: 'border-box',
@ -764,7 +746,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
color: '#ffffff', letterSpacing: '-0.18px', lineHeight: 1.3, margin: 0,
}}>
{t('contentCalendar.title')}
</p>
</div>
@ -776,7 +758,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
flex: 1, minHeight: 0,
}}>
{/* 캘린더 영역 */}
<div className="calendar-grid-area" style={{
<div style={{
gridColumn: '1 / span 8',
backgroundColor: '#01393b',
borderRadius: 20, padding: 16,
@ -792,13 +774,13 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
</div>
{loading ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ color: '#9bcacc', fontFamily: 'Pretendard, sans-serif', fontSize: 14 }}>{t('contentCalendar.loading')}</span>
<span style={{ color: '#9bcacc', fontFamily: 'Pretendard, sans-serif', fontSize: 14 }}> ...</span>
</div>
) : renderCalendarGrid()}
</div>
{/* 오른쪽 패널 */}
<div className="calendar-side-panel" style={{
<div style={{
gridColumn: '9 / span 1',
backgroundColor: '#01393b', borderRadius: 20,
display: 'flex', flexDirection: 'column', overflow: 'hidden',

View File

@ -81,29 +81,29 @@ interface ConnectedAccount {
// =====================================================
const MOCK_CONTENT_METRICS: ContentMetric[] = [
{ id: 'total-views', label: 'dashboard.metricTotalViews', value: 240000, unit: 'count', trend: 3800, trendDirection: 'up' },
{ id: 'total-watch-time', label: 'dashboard.metricWatchTime', value: 85.3, unit: 'hours', trend: 21.5, trendDirection: 'up' },
{ id: 'avg-view-duration',label: 'dashboard.metricAvgViewDuration', value: 41, unit: 'minutes', trend: 2.4, trendDirection: 'up' },
{ id: 'new-subscribers', label: 'dashboard.metricNewSubscribers', value: 483, unit: 'count', trend: 50, trendDirection: 'up' },
{ id: 'likes', label: 'dashboard.metricLikes', value: 15800, unit: 'count', trend: 180, trendDirection: 'up' },
{ id: 'comments', label: 'dashboard.metricComments', value: 2500, unit: 'count', trend: 50, trendDirection: 'down' },
{ id: 'shares', label: 'dashboard.metricShares', value: 840, unit: 'count', trend: 15, trendDirection: 'up' },
{ id: 'uploaded-videos', label: 'dashboard.metricUploadedVideos', value: 17, unit: 'count', trend: 4, trendDirection: 'up' },
{ id: 'total-views', label: '조회수', value: 240000, unit: 'count', trend: 3800, trendDirection: 'up' },
{ id: 'total-watch-time', label: '시청시간', value: 85.3, unit: 'hours', trend: 21.5, trendDirection: 'up' },
{ id: 'avg-view-duration',label: '평균 시청시간', value: 41, unit: 'minutes', trend: 2.4, trendDirection: 'up' },
{ id: 'new-subscribers', label: '신규 구독자', value: 483, unit: 'count', trend: 50, trendDirection: 'up' },
{ id: 'likes', label: '좋아요', value: 15800, unit: 'count', trend: 180, trendDirection: 'up' },
{ id: 'comments', label: '댓글', value: 2500, unit: 'count', trend: 50, trendDirection: 'down' },
{ id: 'shares', label: '공유', value: 840, unit: 'count', trend: 15, trendDirection: 'up' },
{ id: 'uploaded-videos', label: '업로드 영상', value: 17, unit: 'count', trend: 4, trendDirection: 'up' },
];
const MOCK_MONTHLY_DATA: MonthlyData[] = [
{ month: 'dashboard.months.jan', thisYear: 18000, lastYear: 14500 },
{ month: 'dashboard.months.feb', thisYear: 19500, lastYear: 15800 },
{ month: 'dashboard.months.mar', thisYear: 21000, lastYear: 17200 },
{ month: 'dashboard.months.apr', thisYear: 18500, lastYear: 16800 },
{ month: 'dashboard.months.may', thisYear: 24000, lastYear: 19500 },
{ month: 'dashboard.months.jun', thisYear: 27500, lastYear: 21000 },
{ month: 'dashboard.months.jul', thisYear: 32000, lastYear: 23500 },
{ month: 'dashboard.months.aug', thisYear: 29500, lastYear: 24800 },
{ month: 'dashboard.months.sep', thisYear: 31000, lastYear: 26200 },
{ month: 'dashboard.months.oct', thisYear: 28500, lastYear: 25500 },
{ month: 'dashboard.months.nov', thisYear: 34000, lastYear: 27800 },
{ month: 'dashboard.months.dec', thisYear: 38000, lastYear: 29500 },
{ month: '1월', thisYear: 18000, lastYear: 14500 },
{ month: '2월', thisYear: 19500, lastYear: 15800 },
{ month: '3월', thisYear: 21000, lastYear: 17200 },
{ month: '4월', thisYear: 18500, lastYear: 16800 },
{ month: '5월', thisYear: 24000, lastYear: 19500 },
{ month: '6월', thisYear: 27500, lastYear: 21000 },
{ month: '7월', thisYear: 32000, lastYear: 23500 },
{ month: '8월', thisYear: 29500, lastYear: 24800 },
{ month: '9월', thisYear: 31000, lastYear: 26200 },
{ month: '10월', thisYear: 28500, lastYear: 25500 },
{ month: '11월', thisYear: 34000, lastYear: 27800 },
{ month: '12월', thisYear: 38000, lastYear: 29500 },
];
const MOCK_DAILY_DATA: DailyData[] = Array.from({ length: 30 }, (_, i) => {
@ -201,16 +201,18 @@ const formatNumber = (num: number): string => {
return num.toString();
};
const formatValue = (value: number, unit: string, unitHours: string, unitMinutes: string): string => {
if (unit === 'hours') return value.toFixed(1) + unitHours;
if (unit === 'minutes') return Math.round(value) + unitMinutes;
// unit에 따라 value를 표시용 문자열로 변환 (언어별 suffix는 호출부에서 처리)
const formatValue = (value: number, unit: string): string => {
if (unit === 'hours') return value.toFixed(1) + '시간';
if (unit === 'minutes') return Math.round(value) + '분';
return formatNumber(Math.round(value));
};
const formatTrend = (trend: number, unit: string, unitHours: string, unitMinutes: string): string => {
// unit에 따라 trend 절댓값을 표시용 문자열로 변환
const formatTrend = (trend: number, unit: string): string => {
const abs = Math.abs(trend);
if (unit === 'hours') return abs.toFixed(1) + unitHours;
if (unit === 'minutes') return Math.round(abs) + unitMinutes;
if (unit === 'hours') return abs.toFixed(1) + '시간';
if (unit === 'minutes') return Math.round(abs) + '분';
return formatNumber(Math.round(abs));
};
@ -237,22 +239,19 @@ const TrendIcon: React.FC<{ direction: 'up' | 'down' }> = ({ direction }) => (
);
const StatCard: React.FC<ContentMetric> = ({ label, value, unit, trend, trendDirection }) => {
const { t } = useTranslation();
const unitHours = t('dashboard.unitHours');
const unitMinutes = t('dashboard.unitMinutes');
const isNeutral = trend === 0 || trendDirection === '-';
const isUp = trendDirection === 'up';
return (
<div className="stat-card">
<span className="stat-label">{t(label)}</span>
<h3 className="stat-value">{formatValue(value, unit, unitHours, unitMinutes)}</h3>
<span className="stat-label">{label}</span>
<h3 className="stat-value">{formatValue(value, unit)}</h3>
<div className="stat-trend-wrapper">
{isNeutral ? (
<span className="stat-trend neutral"></span>
) : (
<span className={`stat-trend ${isUp ? 'up' : 'down'}`}>
<TrendIcon direction={isUp ? 'up' : 'down'} />
{isUp ? '+' : '-'}{formatTrend(trend, unit, unitHours, unitMinutes)}
{isUp ? '+' : '-'}{formatTrend(trend, unit)}
</span>
)}
</div>
@ -277,12 +276,11 @@ const YearOverYearChart: React.FC<{
currentLabel: string;
previousLabel: string;
mode: 'day' | 'month';
noDataLabel: string;
}> = ({ data, currentLabel, previousLabel, mode, noDataLabel }) => {
}> = ({ data, currentLabel, previousLabel, mode }) => {
if (data.length === 0) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '200px', opacity: 0.4 }}>
<span>{noDataLabel}</span>
<span> .</span>
</div>
);
}
@ -364,14 +362,14 @@ const YearOverYearChart: React.FC<{
// Icon Components
// =====================================================
const YouTubeIcon: React.FC<{ className?: string; style?: React.CSSProperties }> = ({ className, style }) => (
<svg className={className || "platform-tab-icon"} style={style} viewBox="0 0 24 24" fill="currentColor">
const YouTubeIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className || "platform-tab-icon"} viewBox="0 0 24 24" fill="currentColor">
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
);
const InstagramIcon: React.FC<{ className?: string; style?: React.CSSProperties }> = ({ className, style }) => (
<svg className={className || "platform-tab-icon"} style={style} viewBox="0 0 24 24" fill="currentColor">
const InstagramIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className || "platform-tab-icon"} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z" />
</svg>
);
@ -575,7 +573,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
return;
}
if (errorData.code === 'YOUTUBE_API_FAILED') {
setError({ code: 'YOUTUBE_API_FAILED', message: t('dashboard.youtubeApiFailed') });
setError({ code: 'YOUTUBE_API_FAILED', message: 'YouTube Analytics API 호출에 실패했습니다.' });
setDashboardData(null);
return;
}
@ -608,7 +606,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
if (isLoading) {
return (
<div className="flex justify-center items-center h-screen">
<div className="text-lg">{t('dashboard.dataLoading')}</div>
<div className="text-lg">{t('데이터 로딩 중...')}</div>
</div>
);
}
@ -616,13 +614,12 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
// hasUploads === false이고 error 없음: 업로드 영상 없음 → 전체 mock 데이터 표시 + 안내 배너
const isEmptyState = dashboardData?.hasUploads === false && !dashboardData?.error;
// 블러 조건: 1)계정 미연결 2)업로드 영상 없음 3)데이터 없음 4)에러 있음
const isBlurred = accounts.length === 0 || isEmptyState || !dashboardData || !!error;
// showMockData=true면 전체 mock 강제, 아니면 API 우선 / isEmptyState 시 mock 폴백
const useReal = !showMockData && !isEmptyState;
const hasRealContentMetrics = useReal && !!dashboardData?.contentMetrics?.some((m: ContentMetric) => m.value > 0);
const contentMetrics = hasRealContentMetrics ? dashboardData!.contentMetrics : MOCK_CONTENT_METRICS;
// 블러 조건: 1)계정 미연결 2)업로드 영상 없음 3)데이터 없음 4)에러 있음 5)실제 지표 없음
const isBlurred = accounts.length === 0 || isEmptyState || !dashboardData || !!error || !hasRealContentMetrics;
const contentMetrics = (useReal && dashboardData?.contentMetrics?.length) ? dashboardData.contentMetrics : MOCK_CONTENT_METRICS;
const topContent = (useReal && dashboardData?.topContent?.length) ? dashboardData.topContent : MOCK_TOP_CONTENT;
const hasRealAgeGroups = useReal && !!dashboardData?.audienceData?.ageGroups?.some(g => g.percentage > 0);
const hasRealGender = useReal && ((dashboardData?.audienceData?.gender?.male ?? 0) + (dashboardData?.audienceData?.gender?.female ?? 0)) > 0;
@ -634,15 +631,15 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
const chartData: ChartDataPoint[] = mode === 'month'
? ((useReal && dashboardData?.monthlyData?.length)
? dashboardData.monthlyData.map((d: MonthlyData) => ({ label: d.month, current: d.thisYear, previous: d.lastYear }))
: MOCK_MONTHLY_DATA.map((d: MonthlyData) => ({ label: t(d.month), current: d.thisYear, previous: d.lastYear })))
: MOCK_MONTHLY_DATA.map((d: MonthlyData) => ({ label: d.month, current: d.thisYear, previous: d.lastYear })))
: ((useReal && dashboardData?.dailyData?.length)
? dashboardData.dailyData.map((d: DailyData) => ({ label: d.date, current: d.thisPeriod, previous: d.lastPeriod }))
: MOCK_DAILY_DATA.map((d: DailyData) => ({ label: d.date, current: d.thisPeriod, previous: d.lastPeriod })));
// mode별 차트 레이블
const chartCurrentLabel = mode === 'month' ? t('dashboard.chartThisYear') : t('dashboard.chartThisMonth');
const chartPreviousLabel = mode === 'month' ? t('dashboard.chartLastYear') : t('dashboard.chartLastMonth');
const chartSectionTitle = mode === 'month' ? t('dashboard.yearOverYear') : t('dashboard.monthOverMonth');
const chartCurrentLabel = mode === 'month' ? '올해' : '이번달';
const chartPreviousLabel = mode === 'month' ? '작년' : '지난달';
const chartSectionTitle = mode === 'month' ? t('dashboard.yearOverYear') : '전월 대비 성장';
const lastUpdated = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
@ -660,24 +657,8 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
<path d="M15 10l4.553-2.069A1 1 0 0121 8.87v6.26a1 1 0 01-1.447.899L15 14M3 8a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V8z" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<div>
<p style={{ color: '#a6ffea', fontSize: '20px', fontWeight: 600, marginBottom: '2px' }}>{t('dashboard.noUploadsTitle')}</p>
<p style={{ color: 'rgba(255,255,255,0.5)', fontSize: '16px' }}>{t('dashboard.noUploadsDesc')}</p>
</div>
</div>
</div>
)}
{/* 48시간 지연 안내 배너 */}
{dashboardData && !isEmptyState && !hasRealContentMetrics && !showMockData && (
<div className="mb-4 p-4 border-l-4 rounded" style={{ background: 'rgba(255,210,100,0.06)', borderColor: '#ffd264' }}>
<div className="flex items-center gap-3">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ffd264" strokeWidth="2" style={{ flexShrink: 0 }}>
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<div>
<p style={{ color: '#ffd264', fontSize: '16px', fontWeight: 600, marginBottom: '2px' }}>{t('dashboard.dataDelayTitle')}</p>
<p style={{ color: 'rgba(255,255,255,0.5)', fontSize: '14px' }}>{t('dashboard.dataDelayDesc')}</p>
<p style={{ color: '#a6ffea', fontSize: '20px', fontWeight: 600, marginBottom: '2px' }}> .</p>
<p style={{ color: 'rgba(255,255,255,0.5)', fontSize: '16px' }}>ADO2 .</p>
</div>
</div>
</div>
@ -698,7 +679,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
onClick={() => onNavigate?.('내 정보')}
className="ml-4 px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 whitespace-nowrap"
>
{t('dashboard.reconnectButton')}
</button>
)}
{error.code === 'YOUTUBE_NOT_CONNECTED' && (
@ -706,7 +687,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
onClick={() => onNavigate?.('내 정보')}
className="ml-4 px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 whitespace-nowrap"
>
{t('dashboard.connectButton')}
</button>
)}
{(error.code === 'YOUTUBE_API_FAILED' || error.code === 'DASHBOARD_DATA_ERROR') && (
@ -714,7 +695,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
onClick={() => { setError(null); setRetryTrigger((n: number) => n + 1); }}
className="ml-4 px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 whitespace-nowrap"
>
{t('dashboard.retryButton')}
</button>
)}
</div>
@ -757,7 +738,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
</span>
</>
) : (
<span style={{ flex: 1, color: 'rgba(255,255,255,0.4)' }}>{t('dashboard.selectAccount')}</span>
<span style={{ flex: 1, color: 'rgba(255,255,255,0.4)' }}> </span>
);
})()}
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.5)" strokeWidth="2" style={{ flexShrink: 0, marginLeft: 'auto' }}>
@ -819,20 +800,20 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="dashboard-section-title" style={{ marginBottom: '2px' }}>{t('dashboard.contentPerformance')}</h2>
<p style={{ fontSize: '14px', color: 'rgba(255,255,255,0.4)', margin: 0 }}>{t('dashboard.recentVideosDesc')}</p>
<p style={{ fontSize: '14px', color: 'rgba(255,255,255,0.4)', margin: 0 }}>ADO2 30 .</p>
</div>
<div className="mode-toggle">
<button
className={`mode-btn ${mode === 'month' ? 'active' : ''}`}
onClick={() => setMode('month')}
>
{t('dashboard.modeAnnual')}
</button>
<button
className={`mode-btn ${mode === 'day' ? 'active' : ''}`}
onClick={() => setMode('day')}
>
{t('dashboard.modeMonthly')}
</button>
</div>
</div>
@ -869,7 +850,6 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
currentLabel={chartCurrentLabel}
previousLabel={chartPreviousLabel}
mode={mode}
noDataLabel={t('dashboard.noDataInPeriod')}
/>
</div>
</div>
@ -894,7 +874,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
<AnimatedSection delay={1000} className="audience-section">
<div style={{ marginBottom: '16px' }}>
<h2 className="dashboard-section-title" style={{ marginBottom: '2px' }}>{t('dashboard.audienceInsights')}</h2>
<p style={{ fontSize: '14px', color: 'rgba(255,255,255,0.4)', margin: 0 }}>{t('dashboard.channelStatsDesc')}</p>
<p style={{ fontSize: '14px', color: 'rgba(255,255,255,0.4)', margin: 0 }}> .</p>
</div>
<div style={{ position: 'relative' }}>
<div className="audience-cards" style={!hasRealAudienceData && !showMockData ? { filter: 'blur(4px)', pointerEvents: 'none', userSelect: 'none' } : {}}>
@ -927,7 +907,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
padding: '20px 28px',
}}>
<p style={{ color: 'rgba(255,255,255,0.85)', fontSize: '16px', fontWeight: 500, margin: 0, textAlign: 'center' }}>
{t('dashboard.noAudienceData')}
.
</p>
</div>
</div>
@ -963,7 +943,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
: <><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></>
}
</svg>
{showMockData ? t('dashboard.sampleHide') : t('dashboard.sampleShow')}
{showMockData ? 'Sample 숨기기' : 'Sample 보기'}
</button>
</div>
);

View File

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Sidebar from '../../components/Sidebar';
import AssetManagementContent from './AssetManagementContent';
@ -9,23 +9,18 @@ import DashboardContent from './DashboardContent';
import BusinessSettingsContent from './BusinessSettingsContent';
import UrlInputContent from './UrlInputContent';
import ADO2ContentsPage from './ADO2ContentsPage';
import MyContentsPage from './MyContentsPage';
import MyInfoContent from './MyInfoContent';
import ContentCalendarContent from './ContentCalendarContent';
import LoadingSection from '../Analysis/LoadingSection';
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
import { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/api';
import { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens } from '../../utils/api';
import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay';
import { crawlUrl, autocomplete, AutocompleteRequest, getUserMe, clearTokens } from '../../utils/api';
const WIZARD_STEP_KEY = 'castad_wizard_step';
const ACTIVE_ITEM_KEY = 'castad_active_item';
const SONG_TASK_ID_KEY = 'castad_song_task_id';
const IMAGE_TASK_ID_KEY = 'castad_image_task_id';
const ANALYSIS_DATA_KEY = 'castad_analysis_data';
import { saveSearchHistory } from '../../components/SearchHistory/useSearchHistory';
// 다른 컴포넌트에서 사용하는 storage key들 (초기화용)
const SONG_GENERATION_KEY = 'castad_song_generation';
@ -116,35 +111,14 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
const [videoGenerationProgress, setVideoGenerationProgress] = useState(0);
const [analysisData, setAnalysisData] = useState<CrawlingResponse | null>(parseAnalysisData());
const [analysisError, setAnalysisError] = useState<string | null>(null);
const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
const [userInfo, setUserInfo] = useState<UserMeResponse | null>(null);
const [credits, setCredits] = useState<number | null>(null);
const [myInfoInitialTab, setMyInfoInitialTab] = useState<'basic' | 'payment' | 'business' | undefined>(undefined);
const tutorial = useTutorial();
const refreshCredits = useCallback(async () => {
try {
const { credits } = await getUserCredits();
setCredits(credits);
} catch (e) {
console.error('Failed to refresh credits:', e);
}
}, []);
// 사운드 스튜디오, 영상 완성 진입 시 크레딧 갱신
useEffect(() => {
if (wizardStep === 2 || wizardStep === 3) {
refreshCredits();
}
}, [wizardStep]);
// 로그인 직후 사용자 정보 + 크레딧 조회
// 로그인 직후 사용자 정보 조회
useEffect(() => {
const fetchUserInfo = async () => {
try {
const [data, creditsData] = await Promise.all([getUserMe(), getUserCredits()]);
const data = await getUserMe();
setUserInfo(data);
setCredits(creditsData.credits);
} catch (error) {
console.error('Failed to fetch user info:', error);
}
@ -230,7 +204,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
// 업체명 자동완성으로 분석 시작
const handleAutocomplete = async (request: AutocompleteRequest) => {
goToWizardStep(-1); // 로딩 상태로
setIsAnalysisComplete(false);
setAnalysisError(null);
try {
@ -238,11 +211,11 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
console.log('[Autocomplete] Response m_id:', data.m_id);
// 기본값 보장
// if (data.marketing_analysis) {
// data.marketing_analysis.tags = data.marketing_analysis.tags || [];
// data.marketing_analysis.facilities = data.marketing_analysis.facilities || [];
// data.marketing_analysis.report = data.marketing_analysis.report || '';
// }
if (data.marketing_analysis) {
data.marketing_analysis.tags = data.marketing_analysis.tags || [];
data.marketing_analysis.facilities = data.marketing_analysis.facilities || [];
data.marketing_analysis.report = data.marketing_analysis.report || '';
}
if (data.processed_info) {
data.processed_info.customer_name = data.processed_info.customer_name || '알 수 없음';
data.processed_info.region = data.processed_info.region || '';
@ -252,42 +225,14 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
setAnalysisData(data);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
saveSearchHistory({ type: 'name', value: request.title.replace(/<[^>]*>/g, ''), address: request.address, roadAddress: request.roadAddress });
setIsAnalysisComplete(true);
goToWizardStep(0); // 브랜드 분석 결과로
} catch (err) {
console.error('Autocomplete error:', err);
setAnalysisError(t('app.autocompleteError'));
setAnalysisError(err instanceof Error ? err.message : t('app.autocompleteError'));
goToWizardStep(-2); // URL 입력으로 돌아가기
}
};
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
const handleManualInput = async (businessName: string, address: string) => {
goToWizardStep(-1);
setIsAnalysisComplete(false);
setAnalysisError(null);
try {
const data = await marketingAnalysis(businessName, address);
if (data.processed_info) {
data.processed_info.customer_name = data.processed_info.customer_name || businessName;
data.processed_info.region = data.processed_info.region || '';
data.processed_info.detail_region_info = data.processed_info.detail_region_info || '';
}
data.image_list = data.image_list || [];
setAnalysisData(data);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
saveSearchHistory({ type: 'name', value: businessName, address, roadAddress: address });
setIsAnalysisComplete(true);
} catch (err) {
console.error('Marketing analysis error:', err);
setAnalysisError(t('app.autocompleteError'));
goToWizardStep(-2);
}
};
// 테스트용 랜덤 m_id 생성 (99 ~ 300)
const generateRandomMId = (): number => {
return Math.floor(Math.random() * (300 - 99 + 1)) + 99;
@ -326,7 +271,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
if (!url.trim()) return;
goToWizardStep(-1); // 로딩 상태로
setIsAnalysisComplete(false);
setAnalysisError(null);
try {
@ -334,11 +278,11 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
console.log('[Crawl] Response m_id:', data.m_id);
// 기본값 보장
// if (data.marketing_analysis) {
// data.marketing_analysis.tags = data.marketing_analysis.tags || [];
// data.marketing_analysis.facilities = data.marketing_analysis.facilities || [];
// data.marketing_analysis.report = data.marketing_analysis.report || '';
// }
if (data.marketing_analysis) {
data.marketing_analysis.tags = data.marketing_analysis.tags || [];
data.marketing_analysis.facilities = data.marketing_analysis.facilities || [];
data.marketing_analysis.report = data.marketing_analysis.report || '';
}
if (data.processed_info) {
data.processed_info.customer_name = data.processed_info.customer_name || '알 수 없음';
data.processed_info.region = data.processed_info.region || '';
@ -348,11 +292,11 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
setAnalysisData(data);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
saveSearchHistory({ type: 'url', value: url });
setIsAnalysisComplete(true);
goToWizardStep(0); // 브랜드 분석 결과로
} catch (err) {
console.error('Crawling failed:', err);
setAnalysisError(t('app.analysisError'));
const errorMessage = err instanceof Error ? err.message : t('app.analysisError');
setAnalysisError(errorMessage);
goToWizardStep(-2); // URL 입력으로 돌아가기
}
};
@ -372,44 +316,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
localStorage.setItem(ACTIVE_ITEM_KEY, activeItem);
}, [activeItem]);
// wizardStep 변경 시 튜토리얼 트리거 (사이드바 메뉴 화면에서는 제외)
const SIDEBAR_ITEMS = ['대시보드', 'ADO2 콘텐츠', '내 정보', '콘텐츠 캘린더'];
useEffect(() => {
if (tutorial.isActive) tutorial.skipTutorial();
if (SIDEBAR_ITEMS.includes(activeItem)) return;
const timer = setTimeout(() => {
if (wizardStep === 1 && !tutorial.hasSeen(TUTORIAL_KEYS.ASSET)) {
tutorial.startTutorial(TUTORIAL_KEYS.ASSET);
} else if (wizardStep === 2 && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND)) {
tutorial.startTutorial(TUTORIAL_KEYS.SOUND);
}
}, 600);
return () => clearTimeout(timer);
}, [wizardStep, activeItem]);
// activeItem 변경 시 튜토리얼 트리거
useEffect(() => {
if (tutorial.isActive) tutorial.skipTutorial();
if (activeItem === '내 정보' && !tutorial.hasSeen(TUTORIAL_KEYS.MY_INFO)) {
tutorial.startTutorial(TUTORIAL_KEYS.MY_INFO);
} else if (activeItem === '내 콘텐츠' && !tutorial.hasSeen(TUTORIAL_KEYS.ADO2_CONTENTS)) {
tutorial.startTutorial(TUTORIAL_KEYS.ADO2_CONTENTS);
} else if (activeItem === '대시보드' && !tutorial.hasSeen(TUTORIAL_KEYS.DASHBOARD)) {
tutorial.startTutorial(TUTORIAL_KEYS.DASHBOARD);
} else if (activeItem === '콘텐츠 캘린더' && !tutorial.hasSeen(TUTORIAL_KEYS.CONTENT_CALENDAR)) {
tutorial.startTutorial(TUTORIAL_KEYS.CONTENT_CALENDAR);
}
}, [activeItem]);
// 결제 탭으로 바로 이동하는 핸들러 (크레딧 충전하기 버튼용)
const handleGoToPayment = () => {
setMyInfoInitialTab('payment');
setActiveItem('내 정보');
};
// 네비게이션 핸들러 - "새 프로젝트 만들기" 클릭 시 기존 프로젝트 데이터 초기화
const handleNavigate = (item: string) => {
setMyInfoInitialTab(undefined);
if (item === '새 프로젝트 만들기') {
// 기존 프로젝트 데이터 초기화
clearAllProjectStorage();
@ -426,7 +334,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
setAnalysisError(null);
}
setActiveItem(item);
refreshCredits();
};
// 새 프로젝트 만들기 - 단계별 컨텐츠 렌더링
@ -438,19 +345,13 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
<UrlInputContent
onAnalyze={handleStartAnalysis}
onAutocomplete={handleAutocomplete}
onManualInput={handleManualInput}
onTestData={handleTestData}
error={analysisError}
/>
);
case -1:
// 로딩 단계
return (
<LoadingSection
isComplete={isAnalysisComplete}
onComplete={() => goToWizardStep(0)}
/>
);
return <LoadingSection />;
case 0:
// 브랜드 분석 결과 단계
if (!analysisData) {
@ -498,14 +399,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
mId={analysisData?.m_id ?? 0}
videoGenerationStatus={videoGenerationStatus}
videoGenerationProgress={videoGenerationProgress}
onGoToPayment={handleGoToPayment}
onStatusChange={(status: string) => {
if (status === 'generating_song' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_LYRICS)) {
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.SOUND_LYRICS), 400);
} else if (status === 'complete' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_AUDIO)) {
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.SOUND_AUDIO), 400);
}
}}
/>
);
case 3:
@ -523,7 +416,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
songTaskId={songTaskId}
onVideoStatusChange={setVideoGenerationStatus}
onVideoProgressChange={setVideoGenerationProgress}
onGoToCalendar={() => handleNavigate('콘텐츠 캘린더')}
/>
);
default:
@ -538,18 +430,15 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
case '비즈니스 설정':
return <BusinessSettingsContent />;
case 'ADO2 콘텐츠':
return <ADO2ContentsPage />;
case '내 콘텐츠':
return (
<MyContentsPage
<ADO2ContentsPage
onBack={() => setActiveItem('새 프로젝트 만들기')}
onNavigate={handleNavigate}
/>
);
case '콘텐츠 캘린더':
return <ContentCalendarContent onNavigate={handleNavigate} />;
case '내 정보':
return <MyInfoContent initialTab={myInfoInitialTab} />;
return <MyInfoContent />;
case '새 프로젝트 만들기':
// 브랜드 분석(0)과 로딩(-1)은 전체 화면으로 표시
if (wizardStep === 0 || wizardStep === -1) {
@ -575,69 +464,18 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
// 브랜드 분석(0)일 때는 전체 페이지 스크롤
const isBrandAnalysis = activeItem === '새 프로젝트 만들기' && wizardStep === 0;
// 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0), ADO2 콘텐츠, 내 정보
const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || activeItem === '내 콘텐츠' || activeItem === '내 정보' || activeItem === '콘텐츠 캘린더' || isBrandAnalysis;
// 현재 화면에 맞는 튜토리얼 키 반환 (없으면 null)
const getCurrentTutorialKey = (): string | null => {
if (activeItem === '내 정보') return TUTORIAL_KEYS.MY_INFO;
if (activeItem === '내 콘텐츠') return TUTORIAL_KEYS.ADO2_CONTENTS;
if (activeItem === '대시보드') return TUTORIAL_KEYS.DASHBOARD;
if (activeItem === '콘텐츠 캘린더') return TUTORIAL_KEYS.CONTENT_CALENDAR;
if (activeItem === '새 프로젝트 만들기') {
if (wizardStep === 1) return TUTORIAL_KEYS.ASSET;
if (wizardStep === 2) return TUTORIAL_KEYS.SOUND;
}
return null;
};
const tutorialUI = (
<>
{getCurrentTutorialKey() && (
<button
className={`tutorial-toggle-fab ${tutorial.isEnabled ? 'active' : ''}`}
onClick={() => tutorial.toggleTutorial(getCurrentTutorialKey())}
title={tutorial.isEnabled ? t('sidebar.tutorialOff') : t('sidebar.tutorialOn')}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<circle cx="12" cy="12" r="10"/>
<path d="M12 8v4l3 3"/>
</svg>
<span>{t('sidebar.tutorial')}</span>
<span className={`tutorial-toggle-badge ${tutorial.isEnabled ? 'on' : 'off'}`}>
{tutorial.isEnabled ? 'ON' : 'OFF'}
</span>
</button>
)}
{tutorial.isActive && (
<TutorialOverlay
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/>
)}
{tutorial.isRestartPopupVisible && (
<TutorialRestartPopup
onConfirm={tutorial.confirmRestart}
onCancel={tutorial.cancelRestart}
/>
)}
</>
);
const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || activeItem === '내 정보' || activeItem === '콘텐츠 캘린더' || isBrandAnalysis;
// 브랜드 분석일 때는 전체 화면 스크롤
if (isBrandAnalysis) {
return (
<div className="analysis-page-wrapper">
{showSidebar && (
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} tutorialAvailable={!!getCurrentTutorialKey()} tutorialEnabled={tutorial.isEnabled} onToggleTutorial={() => tutorial.toggleTutorial(getCurrentTutorialKey())} />
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} />
)}
<main className="analysis-page-main">
{renderContent()}
</main>
{tutorialUI}
</div>
);
}
@ -645,9 +483,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
return (
<div className={`flex w-full bg-[#002224] text-white ${needsScroll ? 'min-h-[100dvh]' : 'h-[100dvh] overflow-hidden'}`}>
{showSidebar && (
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} tutorialAvailable={!!getCurrentTutorialKey()} tutorialEnabled={tutorial.isEnabled} onToggleTutorial={() => tutorial.toggleTutorial(getCurrentTutorialKey())} />
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} />
)}
{tutorialUI}
<div className={`flex-1 relative pl-0 md:pl-0 ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'}`}>
{renderContent()}
</div>

View File

@ -1,399 +0,0 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { getVideosList, deleteVideo } from '../../utils/api';
import { VideoListItem } from '../../types/api';
import SocialPostingModal from '../../components/SocialPostingModal';
import VideoDetailModal from '../../components/VideoDetailModal';
import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
const VideoPreviewCard: React.FC<{ src: string; className?: string }> = ({ src, className }) => {
const videoRef = useRef<HTMLVideoElement>(null);
const isPlayingRef = useRef(false);
const pendingPlayRef = useRef(false);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
video.preload = 'auto';
} else {
video.preload = 'none';
}
},
{ threshold: 0.1 }
);
observer.observe(video);
return () => observer.disconnect();
}, []);
const safePlay = useCallback((video: HTMLVideoElement) => {
const playPromise = video.play();
if (playPromise !== undefined) {
playPromise
.then(() => { isPlayingRef.current = true; })
.catch((error) => {
if (error.name !== 'AbortError') console.error(error);
});
}
}, []);
const handleCanPlay = useCallback(() => {
if (!pendingPlayRef.current || !videoRef.current) return;
pendingPlayRef.current = false;
safePlay(videoRef.current);
}, [safePlay]);
const handleMouseEnter = useCallback(() => {
const video = videoRef.current;
if (!video) return;
if (video.readyState >= 3) {
safePlay(video);
} else {
pendingPlayRef.current = true;
}
}, [safePlay]);
const handleMouseLeave = useCallback(() => {
const video = videoRef.current;
if (!video) return;
pendingPlayRef.current = false;
if (isPlayingRef.current) {
video.pause();
video.currentTime = 0;
isPlayingRef.current = false;
}
}, []);
return (
<video
ref={videoRef}
src={src}
className={className}
muted
playsInline
preload="none"
onCanPlay={handleCanPlay}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
/>
);
};
interface MyContentsPageProps {
onBack?: () => void;
onNavigate?: (item: string) => void;
}
const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
const { t } = useTranslation();
const tutorial = useTutorial();
const [videos, setVideos] = useState<VideoListItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [hasNext, setHasNext] = useState(false);
const [hasPrev, setHasPrev] = useState(false);
const [totalPages, setTotalPages] = useState(1);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [uploadTargetVideo, setUploadTargetVideo] = useState<VideoListItem | null>(null);
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
const pageSize = 12;
useEffect(() => {
fetchVideos();
}, [page]);
useEffect(() => {
if (!tutorial.hasSeen(TUTORIAL_KEYS.ADO2_CONTENTS)) {
tutorial.startTutorial(TUTORIAL_KEYS.ADO2_CONTENTS);
}
}, []);
const fetchVideos = async () => {
setLoading(true);
setError(null);
try {
const response = await getVideosList(page, pageSize);
const validVideos = response.items.filter(video => video.result_movie_url && video.result_movie_url.trim() !== '');
setVideos(validVideos);
setTotal(response.total);
setTotalPages(response.total_pages);
setHasNext(response.has_next);
setHasPrev(response.has_prev);
} catch (err) {
console.error('Failed to fetch videos:', err);
setError(t('ado2Contents.loadFailed'));
} finally {
setLoading(false);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}.${month}.${day}${hours}:${minutes}`;
};
const formatTitle = (storeName: string, dateString: string) => {
const date = new Date(dateString);
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${storeName} ${month}/${day} ${hours}:${minutes}`;
};
const handleDownload = async (videoUrl: string, storeName: string) => {
try {
const response = await fetch(videoUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${storeName}.mp4`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (err) {
console.error('Download failed:', err);
alert(t('ado2Contents.downloadFailed'));
}
};
const handleDeleteClick = (videoId: number) => {
setDeleteTargetId(videoId);
setDeleteModalOpen(true);
};
const handleUploadClick = (video: VideoListItem) => {
setUploadTargetVideo(video);
setUploadModalOpen(true);
};
const handleUploadModalClose = () => {
setUploadModalOpen(false);
setUploadTargetVideo(null);
};
const handleDeleteCancel = () => {
setDeleteModalOpen(false);
setDeleteTargetId(null);
};
const handleDeleteConfirm = async () => {
if (!deleteTargetId) return;
setIsDeleting(true);
try {
await deleteVideo(deleteTargetId);
setVideos(prev => prev.filter(video => video.video_id !== deleteTargetId));
setTotal(prev => Math.max(0, prev - 1));
setDeleteModalOpen(false);
setDeleteTargetId(null);
} catch (err) {
console.error('Delete failed:', err);
alert(t('ado2Contents.deleteFailed'));
} finally {
setIsDeleting(false);
}
};
return (
<div className="ado2-contents-page">
{/* Header */}
<div className="ado2-contents-header">
<h1 className="ado2-contents-title">{t('sidebar.myContents')}</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>{t('ado2Contents.loading')}</p>
</div>
) : error ? (
<div className="ado2-contents-error">
<p>{error}</p>
<button onClick={fetchVideos} className="retry-btn">{t('ado2Contents.retry')}</button>
</div>
) : videos.length === 0 ? (
<div className="ado2-contents-empty">
<p>{t('ado2Contents.noContent')}</p>
</div>
) : (
<>
<div className="ado2-contents-grid">
{videos.map((video) => (
<div key={video.task_id} className="ado2-content-card">
{/* Video Thumbnail */}
<div
className="content-card-thumbnail"
style={{ cursor: 'pointer' }}
onClick={() => setSelectedVideoId(video.video_id)}
>
{video.result_movie_url ? (
<VideoPreviewCard
src={video.result_movie_url}
className="content-video-preview"
/>
) : (
<div className="content-no-video">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/>
<line x1="7" y1="2" x2="7" y2="22"/>
<line x1="17" y1="2" x2="17" y2="22"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<line x1="2" y1="7" x2="7" y2="7"/>
<line x1="2" y1="17" x2="7" y2="17"/>
<line x1="17" y1="17" x2="22" y2="17"/>
<line x1="17" y1="7" x2="22" y2="7"/>
</svg>
</div>
)}
</div>
{/* Card Info */}
<div className="content-card-info">
<div className="content-card-text">
<h3 className="content-card-title">
{formatTitle(video.store_name, video.created_at)}
</h3>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<p className="content-card-date">
{formatDate(video.created_at)}
</p>
<span className="content-card-like">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
{video.like_count ?? 0}
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: '6px' }}>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
{video.comment_count ?? 0}
</span>
</div>
</div>
{/* Action Buttons */}
<div className="content-card-actions">
<button
className="content-download-btn"
onClick={() => handleUploadClick(video)}
disabled={!video.result_movie_url}
>
<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"/>
<path d="M3 15v2h14v-2"/>
</svg>
<span>{t('ado2Contents.uploadToSocial')}</span>
</button>
<button
className="content-upload-btn"
onClick={() => handleDownload(video.result_movie_url, video.store_name)}
disabled={!video.result_movie_url}
title={t('ado2Contents.download')}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 3v10M10 13l-4-4M10 13l4-4"/>
<path d="M3 15v2h14v-2"/>
</svg>
</button>
<button
className="content-delete-btn"
onClick={() => handleDeleteClick(video.video_id)}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/>
<line x1="8" y1="8" x2="8" y2="14"/>
<line x1="12" y1="8" x2="12" y2="14"/>
</svg>
</button>
</div>
</div>
</div>
))}
</div>
{/* Pagination */}
<div className="ado2-contents-pagination">
<button
className="pagination-btn"
onClick={() => setPage(p => p - 1)}
disabled={!hasPrev}
>
{t('ado2Contents.previous')}
</button>
<span className="pagination-info">{page} / {totalPages}</span>
<button
className="pagination-btn"
onClick={() => setPage(p => p + 1)}
disabled={!hasNext}
>
{t('ado2Contents.next')}
</button>
</div>
</>
)}
{/* 삭제 확인 모달 */}
{deleteModalOpen && (
<div className="delete-modal-overlay" onClick={handleDeleteCancel}>
<div className="delete-modal" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<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 ? t('ado2Contents.deleting') : t('ado2Contents.delete')}
</button>
</div>
</div>
</div>
)}
{selectedVideoId !== null && (
<VideoDetailModal
videoId={String(selectedVideoId)}
onClose={() => setSelectedVideoId(null)}
/>
)}
{/* 소셜 미디어 업로드 모달 */}
<SocialPostingModal
isOpen={uploadModalOpen}
onClose={handleUploadModalClose}
video={uploadTargetVideo}
onGoToCalendar={onNavigate ? () => onNavigate('콘텐츠 캘린더') : undefined}
/>
</div>
);
};
export default MyContentsPage;

View File

@ -1,53 +1,18 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { getSocialAccounts, getYouTubeConnectUrl, disconnectSocialAccount, TokenExpiredError, handleSocialReconnect, getUserCredits, requestCreditCharge } from '../../utils/api';
import { getSocialAccounts, getYouTubeConnectUrl, disconnectSocialAccount, TokenExpiredError, handleSocialReconnect } from '../../utils/api';
import { SocialAccount } from '../../types/api';
type TabType = 'basic' | 'payment' | 'business';
interface MyInfoContentProps {
initialTab?: TabType;
}
const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
const MyInfoContent: React.FC = () => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<TabType>(initialTab || 'business');
const [activeTab, setActiveTab] = useState<TabType>('business');
const [businessUrl, setBusinessUrl] = useState('');
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
const [isLoadingAccounts, setIsLoadingAccounts] = useState(false);
const [isConnecting, setIsConnecting] = useState<string | null>(null);
const [credits, setCredits] = useState<number | null>(null);
const [isLoadingCredits, setIsLoadingCredits] = useState(false);
const [showChargePopup, setShowChargePopup] = useState(false);
const [chargeAmount, setChargeAmount] = useState('');
const [chargeNote, setChargeNote] = useState('');
const [isRequesting, setIsRequesting] = useState(false);
const [chargeSuccess, setChargeSuccess] = useState(false);
// initialTab 변경 시 탭 업데이트
useEffect(() => {
if (initialTab) setActiveTab(initialTab);
}, [initialTab]);
// 크레딧 로드
useEffect(() => {
if (activeTab === 'payment') {
loadCredits();
}
}, [activeTab]);
const loadCredits = async () => {
setIsLoadingCredits(true);
try {
const data = await getUserCredits();
setCredits(data.credits);
} catch (error) {
console.error('Failed to load credits:', error);
} finally {
setIsLoadingCredits(false);
}
};
// 소셜 계정 목록 로드
useEffect(() => {
@ -119,74 +84,6 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
];
return (
<>
{showChargePopup && (
<div className="myinfo-popup-overlay" onClick={() => { setShowChargePopup(false); setChargeSuccess(false); setChargeAmount(''); setChargeNote(''); }}>
<div className="myinfo-popup" onClick={e => e.stopPropagation()}>
{chargeSuccess ? (
<>
<p className="myinfo-popup-message">{t('myInfo.chargeSuccess')}</p>
<button className="myinfo-popup-close" onClick={() => { setShowChargePopup(false); setChargeSuccess(false); setChargeAmount(''); setChargeNote(''); }}>{t('myInfo.chargeConfirm')}</button>
</>
) : (
<>
<h3 className="myinfo-popup-title">{t('myInfo.chargePopupTitle')}</h3>
<div className="myinfo-popup-field">
<label className="myinfo-popup-label">{t('myInfo.chargeAmountLabel')}</label>
<div className="myinfo-popup-counter">
<button
className="myinfo-popup-counter-btn"
onClick={() => setChargeAmount(v => String(Math.max(1, Number(v || 0) - 1)))}
></button>
<input
type="number"
className="myinfo-popup-input myinfo-popup-input--center"
placeholder="0"
value={chargeAmount}
onChange={e => setChargeAmount(e.target.value.replace(/[^0-9]/g, ''))}
min={1}
/>
<button
className="myinfo-popup-counter-btn"
onClick={() => setChargeAmount(v => String(Number(v || 0) + 1))}
>+</button>
</div>
</div>
<div className="myinfo-popup-field">
<label className="myinfo-popup-label">{t('myInfo.chargeNoteLabel')}</label>
<textarea
className="myinfo-popup-textarea"
placeholder={t('myInfo.chargeNotePlaceholder')}
value={chargeNote}
onChange={e => setChargeNote(e.target.value)}
rows={3}
/>
</div>
<div className="myinfo-popup-actions">
<button className="myinfo-popup-cancel" onClick={() => { setShowChargePopup(false); setChargeAmount(''); setChargeNote(''); }}>{t('myInfo.chargeCancel')}</button>
<button
className="myinfo-popup-close"
disabled={!chargeAmount || isRequesting}
onClick={async () => {
setIsRequesting(true);
try {
await requestCreditCharge(Number(chargeAmount), chargeNote);
} catch (e) {
console.error('Charge request failed:', e);
} finally {
setIsRequesting(false);
setChargeSuccess(true);
}
}}
>
{isRequesting ? t('myInfo.chargeSubmitting') : t('myInfo.chargeSubmit')}
</button>
</div>
</>
)}
</div>
</div>
)}
<main className="myinfo-page">
<h1 className="myinfo-title">{t('myInfo.title')}</h1>
@ -213,28 +110,7 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
{activeTab === 'payment' && (
<div className="myinfo-section">
<h2 className="myinfo-section-title">{t('myInfo.creditsTitle')}</h2>
<div className="myinfo-credits-card">
{isLoadingCredits ? (
<p className="myinfo-placeholder">{t('myInfo.creditsLoading')}</p>
) : (
<>
<div className="myinfo-credits-row">
<span className="myinfo-credits-label">{t('myInfo.creditsLabel')}</span>
<span className="myinfo-credits-value">{credits !== null ? credits.toLocaleString() : '-'} {t('myInfo.creditsUnit')}</span>
</div>
<div className="myinfo-credits-row">
<p className="myinfo-credits-desc">{t('myInfo.creditsDesc')}</p>
<button
className="myinfo-credits-charge-btn"
onClick={() => setShowChargePopup(true)}
>
{t('myInfo.creditsChargeBtn')}
</button>
</div>
</>
)}
</div>
<p className="myinfo-placeholder">{t('myInfo.paymentPlaceholder')}</p>
</div>
)}
@ -374,7 +250,6 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
)}
</div>
</main>
</>
);
};

View File

@ -16,10 +16,8 @@ interface SoundStudioContentProps {
businessInfo?: BusinessInfo;
imageTaskId: string | null;
mId: number;
onStatusChange?: (status: string) => void;
videoGenerationStatus?: 'idle' | 'generating' | 'complete' | 'error';
videoGenerationProgress?: number;
onGoToPayment?: () => void;
}
type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polling' | 'complete' | 'error';
@ -41,10 +39,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
businessInfo,
imageTaskId,
mId,
onStatusChange,
videoGenerationStatus = 'idle',
videoGenerationProgress = 0,
onGoToPayment,
videoGenerationProgress = 0
}) => {
const { t } = useTranslation();
const [selectedType, setSelectedType] = useState('보컬');
@ -62,8 +58,11 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
const [statusMessage, setStatusMessage] = useState('');
const [retryCount, setRetryCount] = useState(0);
const [songTaskId, setSongTaskId] = useState<string | null>(null);
const [isLanguageDropdownOpen, setIsLanguageDropdownOpen] = useState(false);
const progressBarRef = useRef<HTMLDivElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const languageDropdownRef = useRef<HTMLDivElement>(null);
// 완료 데이터를 localStorage에 저장
const saveSongCompletionData = () => {
@ -77,6 +76,22 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
};
// Close language dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (languageDropdownRef.current && !languageDropdownRef.current.contains(event.target as Node)) {
setIsLanguageDropdownOpen(false);
}
};
if (isLanguageDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isLanguageDropdownOpen]);
// Auto-navigate to next page when video generation is complete
useEffect(() => {
if (videoGenerationStatus === 'complete' && songTaskId) {
@ -153,10 +168,10 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
'ROCK': 'rock',
};
const isInstrumental = selectedType === '배경음악';
const songResponse = await generateSong(imageTaskId, {
genre: genreMap[selectedGenre] || 'pop',
...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: currentLyrics }),
language,
lyrics: currentLyrics,
});
if (!songResponse.success) {
@ -276,7 +291,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
try {
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
const isInstrumental = selectedType === '배경음악';
// 1. 가사 생성 요청
console.log('[SoundStudio] Sending m_id:', mId);
@ -288,13 +302,37 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
region: businessInfo.region,
task_id: imageTaskId,
orientation,
...(isInstrumental && { instrumental: true }),
});
if (!lyricResponse.success || !lyricResponse.task_id) {
throw new Error(lyricResponse.error_message || t('soundStudio.lyricGenerationFailed'));
}
// 2. 가사 생성 상태 폴링 → 완료 시 상세 조회
setStatusMessage(t('soundStudio.generatingLyrics'));
const lyricDetailResponse = await waitForLyricComplete(
lyricResponse.task_id,
(status: string) => {
if (status === 'processing') {
setStatusMessage(t('soundStudio.generatingLyrics'));
}
}
);
if (!lyricDetailResponse.lyric_result) {
throw new Error(t('soundStudio.lyricNotReceived'));
}
// "I'm sorry" 체크
if (lyricDetailResponse.lyric_result.includes("I'm sorry")) {
throw new Error(t('soundStudio.lyricGenerationError'));
}
setLyrics(lyricDetailResponse.lyric_result);
setStatus('generating_song');
setStatusMessage(t('soundStudio.generatingSong'));
const genreMap: Record<string, string> = {
'자동 선택': 'pop',
'K-POP': 'kpop',
@ -306,39 +344,10 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
'ROCK': 'rock',
};
let songLyrics: string | undefined;
if (!isInstrumental) {
// 2. 가사 생성 상태 폴링 → 완료 시 상세 조회
setStatusMessage(t('soundStudio.generatingLyrics'));
const lyricDetailResponse = await waitForLyricComplete(
lyricResponse.task_id,
(status: string) => {
if (status === 'processing') {
setStatusMessage(t('soundStudio.generatingLyrics'));
}
}
);
if (!lyricDetailResponse.lyric_result) {
throw new Error(t('soundStudio.lyricNotReceived'));
}
// "I'm sorry" 체크
if (lyricDetailResponse.lyric_result.includes("I'm sorry")) {
throw new Error(t('soundStudio.lyricGenerationError'));
}
setLyrics(lyricDetailResponse.lyric_result);
songLyrics = lyricDetailResponse.lyric_result;
}
setStatus('generating_song');
setStatusMessage(t('soundStudio.generatingSong'));
const songResponse = await generateSong(imageTaskId, {
genre: genreMap[selectedGenre] || 'pop',
...(isInstrumental ? { instrumental: true, lyrics: '' } : { language, lyrics: songLyrics }),
language,
lyrics: lyricDetailResponse.lyric_result,
});
if (!songResponse.success) {
@ -357,7 +366,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
setStatus('polling');
setStatusMessage(t('soundStudio.generatingSong'));
await resumePolling(songResponse.task_id, songResponse.song_id, songLyrics ?? '', 0);
await resumePolling(songResponse.task_id, songResponse.song_id, lyricDetailResponse.lyric_result, 0);
} catch (error) {
console.error('Music generation failed:', error);
@ -380,23 +389,10 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
const isGenerating = status === 'generating_lyric' || status === 'generating_song' || status === 'polling';
// status 변경 시 부모에 알림
useEffect(() => {
onStatusChange?.(status);
}, [status]);
return (
<>
<main className="sound-studio-page">
{audioUrl && (
<audio
ref={audioRef}
src={audioUrl}
preload="metadata"
onLoadedMetadata={() => {
if (audioRef.current) setDuration(audioRef.current.duration);
}}
/>
<audio ref={audioRef} src={audioUrl} preload="metadata" />
)}
{/* Header */}
@ -424,15 +420,14 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
<label className="input-label">{t('soundStudio.soundTypeLabel')}</label>
<div className="sound-type-grid">
{[
{ key: '보컬', label: t('soundStudio.soundTypeVocal'), disabled: false },
{ key: '배경음악', label: t('soundStudio.soundTypeBGM'), disabled: false },
].map(({ key, label, disabled }) => (
{ key: '보컬', label: t('soundStudio.soundTypeVocal') },
{ key: '배경음악', label: t('soundStudio.soundTypeBGM') },
].map(({ key, label }) => (
<button
key={key}
onClick={() => !isGenerating && !disabled && setSelectedType(key)}
disabled={isGenerating || disabled}
onClick={() => !isGenerating && setSelectedType(key)}
disabled={isGenerating}
className={`sound-type-btn ${selectedType === key ? 'active' : ''}`}
title={disabled ? t('soundStudio.comingSoon', { defaultValue: '이후 오픈 예정입니다' }) : undefined}
>
{label}
</button>
@ -491,31 +486,39 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
{/* Language Selection */}
<div className="sound-studio-section">
<label className="input-label">{t('soundStudio.languageLabel')}</label>
<div className="genre-grid language-grid">
<div className="genre-row">
{['한국어', 'English', '中文'].map((lang) => (
<button
key={lang}
onClick={() => !isGenerating && selectedType !== '배경음악' && setSelectedLang(lang)}
disabled={isGenerating || selectedType === '배경음악'}
className={`genre-btn ${selectedLang === lang ? 'active' : ''}`}
>
<span>{LANGUAGE_FLAGS[lang]}</span> {lang}
</button>
))}
</div>
<div className="genre-row">
{['日本語', 'ไทย', 'Tiếng Việt'].map((lang) => (
<button
key={lang}
onClick={() => !isGenerating && selectedType !== '배경음악' && setSelectedLang(lang)}
disabled={isGenerating || selectedType === '배경음악'}
className={`genre-btn ${selectedLang === lang ? 'active' : ''}`}
>
<span>{LANGUAGE_FLAGS[lang]}</span> {lang}
</button>
))}
</div>
<div className="language-selector-wrapper" ref={languageDropdownRef}>
<button
onClick={() => setIsLanguageDropdownOpen(!isLanguageDropdownOpen)}
disabled={isGenerating}
className="language-selector"
>
<div className="language-display">
<span className="language-flag">{LANGUAGE_FLAGS[selectedLang]}</span>
<span className="language-name">{selectedLang}</span>
</div>
<img
src="/assets/images/icon-dropdown.svg"
alt=""
className={`language-dropdown-icon ${isLanguageDropdownOpen ? 'open' : ''}`}
/>
</button>
{isLanguageDropdownOpen && (
<div className="language-dropdown-menu">
{Object.keys(LANGUAGE_MAP).map((lang) => (
<button
key={lang}
onClick={() => {
setSelectedLang(lang);
setIsLanguageDropdownOpen(false);
}}
className={`language-dropdown-item ${selectedLang === lang ? 'active' : ''}`}
>
<span className="language-flag">{LANGUAGE_FLAGS[lang]}</span>
<span className="language-name">{lang}</span>
</button>
))}
</div>
)}
</div>
</div>
@ -528,16 +531,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
</svg>
{statusMessage}
</div>
) : status === 'complete' ? (
<>
<button
onClick={handleRegenerate}
className="btn-generate-sound"
>
{t('soundStudio.regenerateButton')}
</button>
<p className="regenerate-hint">{t('soundStudio.regenerateHint')}</p>
</>
) : (
<button
onClick={handleGenerateMusic}
@ -548,30 +541,18 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
</button>
)}
{errorMessage && (() => {
const isCreditsError = errorMessage.includes('credit');
return (
<div className="error-message-new">
{isCreditsError ? t('soundStudio.creditsExhausted') : errorMessage}
{isCreditsError && (
<a
href=""
className="charge-credits-link"
onClick={e => { e.preventDefault(); onGoToPayment?.(); }}
>
{t('soundStudio.chargeCredits')}
</a>
)}
</div>
);
})()}
{errorMessage && (
<div className="error-message-new">
{errorMessage}
</div>
)}
</div>
{/* Right Column - Lyrics */}
<div className="lyrics-column">
<div className="lyrics-header">
<h3 className="column-title">{t('soundStudio.lyricsColumn')}</h3>
{/* <p className="lyrics-subtitle">{t('soundStudio.lyricsHint')}</p> */}
<p className="lyrics-subtitle">{t('soundStudio.lyricsHint')}</p>
</div>
{/* Audio Player */}
@ -611,29 +592,16 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
{/* Lyrics Display */}
<div className="lyrics-display">
{lyrics ? (() => {
const lines = lyrics.split('\n').filter(l => l.trim());
const outro = lines.slice(-1);
const body = lines.slice(0, -1);
const half = Math.ceil(body.length / 2);
const sections = [
{ tag: '[Verse]', lines: body.slice(0, half) },
{ tag: '[Chorus]', lines: body.slice(half) },
{ tag: '[Outro]', lines: outro },
].filter(s => s.lines.length > 0);
return (
<div className="lyrics-paragraphs">
{sections.map((section, i) => (
<div key={i} className="lyrics-section">
<span className="lyrics-tag">{section.tag}</span>
<p className="lyrics-paragraph">{section.lines.join('\n')}</p>
</div>
))}
</div>
);
})() : (
{lyrics ? (
<textarea
value={lyrics}
onChange={(e) => setLyrics(e.target.value)}
className="lyrics-textarea"
placeholder={t('soundStudio.lyricsPlaceholder')}
/>
) : (
<div className="lyrics-placeholder">
{selectedType === '배경음악' ? t('soundStudio.lyricsPlaceholderBGM') : t('soundStudio.lyricsPlaceholder')}
{t('soundStudio.lyricsPlaceholder')}
</div>
)}
</div>
@ -675,8 +643,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
</button>
</div>
</main>
</>
);
};

View File

@ -1,12 +1,10 @@
import React, { useState, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../../utils/api';
import { CrawlingResponse } from '../../types/api';
import { useSearchHistory } from '../../components/SearchHistory/useSearchHistory';
import SearchHistoryDropdown from '../../components/SearchHistory/SearchHistoryDropdown';
import BusinessNameInputModal from '../../components/BusinessNameInputModal';
type SearchType = 'url' | 'name' | 'manual';
type SearchType = 'url' | 'name';
// 환경변수에서 테스트 모드 확인
const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
@ -14,39 +12,20 @@ const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
interface UrlInputContentProps {
onAnalyze: (value: string, type?: SearchType) => void;
onAutocomplete?: (data: AutocompleteRequest) => void;
onManualInput?: (businessName: string, address: string) => void;
onTestData?: (data: CrawlingResponse) => void;
error: string | null;
}
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onManualInput, onTestData, error }) => {
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onTestData, error }) => {
const { t, i18n } = useTranslation();
const [inputValue, setInputValue] = useState('');
const [searchType, setSearchType] = useState<SearchType>('name');
const [searchType, setSearchType] = useState<SearchType>('url');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [autocompleteResults, setAutocompleteResults] = useState<AccommodationSearchItem[]>([]);
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
const [showAutocomplete, setShowAutocomplete] = useState(false);
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
const [isLoadingTest, setIsLoadingTest] = useState(false);
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType === 'manual' ? 'name' : searchType);
const handleSelectHistory = (item: { type: 'url' | 'name'; value: string; address?: string; roadAddress?: string }) => {
closeHistory();
if (item.type === 'url') {
setSearchType('url');
setInputValue(item.value);
} else {
setSearchType('name');
setInputValue(item.value);
setSelectedItem({
title: item.value,
address: item.address || '',
roadAddress: item.roadAddress || '',
});
}
};
// 테스트 데이터 로드 핸들러
const handleTestData = async () => {
@ -70,12 +49,11 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
const searchTypeOptions = [
{ value: 'url' as SearchType, label: 'URL' },
{ value: 'name' as SearchType, label: t('urlInput.searchTypeBusinessName') },
// { value: 'manual' as SearchType, label: t('urlInput.searchTypeManual') },
];
const getPlaceholder = () => {
return searchType === 'url'
? 'https://naver.me/abcdef'
? 'https://www.castad.com'
: t('urlInput.placeholderBusinessName');
};
@ -118,11 +96,6 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
// 키보드 네비게이션 핸들러
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
return;
}
if (!showAutocomplete || autocompleteResults.length === 0) return;
switch (e.key) {
@ -138,6 +111,12 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
prev > 0 ? prev - 1 : autocompleteResults.length - 1
);
break;
case 'Enter':
if (highlightedIndex >= 0 && highlightedIndex < autocompleteResults.length) {
e.preventDefault();
handleSelectAutocomplete(autocompleteResults[highlightedIndex]);
}
break;
case 'Escape':
setShowAutocomplete(false);
setHighlightedIndex(-1);
@ -159,21 +138,16 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
// 폼 제출 처리
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
};
const handleAnalyzeClick = () => {
if (!inputValue.trim()) return;
if (searchType === 'name') {
if (selectedItem && onAutocomplete) {
const request: AutocompleteRequest = {
address: selectedItem.address,
roadAddress: selectedItem.roadAddress,
title: selectedItem.title,
};
onAutocomplete(request);
}
// selectedItem 없으면 아무것도 하지 않음 (드롭다운에서 선택 필요)
if (searchType === 'name' && selectedItem && onAutocomplete) {
// 업체명 검색인 경우 autocomplete API 호출
const request: AutocompleteRequest = {
address: selectedItem.address,
roadAddress: selectedItem.roadAddress,
title: selectedItem.title,
};
onAutocomplete(request);
} else {
// URL 검색인 경우 기존 로직
onAnalyze(inputValue.trim(), searchType);
@ -219,13 +193,8 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
type="button"
className={`url-input-dropdown-item ${searchType === option.value ? 'active' : ''}`}
onClick={() => {
if (option.value === 'manual') {
setIsManualModalOpen(true);
setIsDropdownOpen(false);
} else {
setSearchType(option.value);
setIsDropdownOpen(false);
}
setSearchType(option.value);
setIsDropdownOpen(false);
}}
>
{option.label}
@ -252,7 +221,6 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
}
setInputValue(value);
hideOnInput(value);
// 업체명 검색일 때 자동완성 검색 (디바운스)
if (searchType === 'name') {
@ -269,22 +237,11 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
if (searchType === 'name' && autocompleteResults.length > 0) {
setShowAutocomplete(true);
}
openHistory(inputValue);
}}
onBlur={() => setTimeout(() => closeHistory(), 150)}
placeholder={getPlaceholder()}
className="url-input-field"
/>
{/* 최근 검색 히스토리 */}
{showHistory && !showAutocomplete && (
<SearchHistoryDropdown
items={filteredHistory}
onSelect={handleSelectHistory}
onDelete={(e, value) => { e.preventDefault(); e.stopPropagation(); deleteItem(value); }}
/>
)}
{/* 자동완성 결과 */}
{showAutocomplete && searchType === 'name' && (
<div className="url-input-autocomplete-dropdown">
@ -309,23 +266,22 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
</div>
)}
</div>
{/* 검색 버튼 */}
<button type="submit" className="url-input-button">
{t('landing.hero.analyzeButton')}
</button>
</div>
{/* 안내 텍스트 */}
<p className="url-input-guide">
{getGuideText()}
</p>
{/* 에러 메시지 */}
{error && (
<p className="url-input-error">{error}</p>
)}
{/* 검색 버튼 */}
<button type="button" onClick={handleAnalyzeClick} className="url-input-button">
{t('landing.hero.analyzeButton')}
</button>
</form>
{/* 안내 텍스트 */}
<p className="url-input-guide">
{getGuideText()}
</p>
</div>
{/* 테스트 버튼 (VITE_IS_TESTPAGE=true일 때만 표시) */}
@ -338,13 +294,6 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
{isLoadingTest ? t('urlInput.testDataLoading') : t('urlInput.testData')}
</button>
)}
{isManualModalOpen && (
<BusinessNameInputModal
onClose={() => setIsManualModalOpen(false)}
onSubmit={(businessName, address) => { setIsManualModalOpen(false); onManualInput?.(businessName, address); }}
/>
)}
</div>
);
};

View File

@ -11,9 +11,9 @@ const DisplaySection: React.FC<DisplaySectionProps> = ({ onStartClick }) => {
const { t } = useTranslation();
// YouTube Shorts 영상 ID들
const videos = [
{ id: 1, videoId: 'M3iuPZ59X1I' },
{ id: 2, videoId: 'JxWQxELDHSs' },
{ id: 3, videoId: 'c2ZdwhaB7S4' },
{ id: 1, videoId: 'OZJ8X4P82OA' },
{ id: 2, videoId: 'hNzMO21O40c' },
{ id: 3, videoId: 'dM8_d6Aud68' },
];
return (

View File

@ -3,14 +3,8 @@ 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';
import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
import { useSearchHistory } from '../../components/SearchHistory/useSearchHistory';
import SearchHistoryDropdown from '../../components/SearchHistory/SearchHistoryDropdown';
import BusinessNameInputModal from '../../components/BusinessNameInputModal';
type SearchType = 'url' | 'name' | 'manual';
type SearchType = 'url' | 'name';
// 환경변수에서 테스트 모드 확인
const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
@ -18,7 +12,6 @@ const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
interface HeroSectionProps {
onAnalyze?: (value: string, type?: SearchType) => void;
onAutocomplete?: (data: AutocompleteRequest) => void;
onManualInput?: (businessName: string, address: string) => void;
onTestData?: (data: CrawlingResponse) => void;
onNext?: () => void;
error?: string | null;
@ -64,12 +57,11 @@ const orbConfigs: OrbConfig[] = [
{ id: 'orb-6', size: 450, initialX: 65, initialY: 70, color: 'radial-gradient(circle, rgba(180, 255, 235, 0.95) 15%, rgba(200, 160, 255, 0.8) 50%, rgba(94, 235, 195, 0.45) 100%)', minX: 45, maxX: 110, minY: 55, maxY: 110 },
];
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onManualInput, onTestData, onNext, error: externalError, scrollProgress = 0 }) => {
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onTestData, onNext, error: externalError, scrollProgress = 0 }) => {
const { t, i18n } = useTranslation();
const [inputValue, setInputValue] = useState('');
const [searchType, setSearchType] = useState<SearchType>('name');
const [searchType, setSearchType] = useState<SearchType>('url');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
const [localError, setLocalError] = useState('');
const [isLoadingTest, setIsLoadingTest] = useState(false);
@ -89,7 +81,6 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
setIsLoadingTest(false);
}
};
const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType === 'manual' ? 'name' : searchType);
const [isFocused, setIsFocused] = useState(false);
const [autocompleteResults, setAutocompleteResults] = useState<AccommodationSearchItem[]>([]);
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
@ -101,22 +92,10 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
const dropdownRef = useRef<HTMLDivElement>(null);
const autocompleteRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const tutorial = useTutorial();
// 첫 방문 시 랜딩 튜토리얼 시작
useEffect(() => {
if (!tutorial.hasSeen(TUTORIAL_KEYS.LANDING)) {
const timer = setTimeout(() => {
tutorial.startTutorial(TUTORIAL_KEYS.LANDING);
}, 800);
return () => clearTimeout(timer);
}
}, []);
const searchTypeOptions = [
{ value: 'url' as SearchType, label: 'URL' },
{ value: 'name' as SearchType, label: t('landing.hero.searchTypeBusinessName') },
// { value: 'manual' as SearchType, label: t('landing.hero.searchTypeManual') },
];
// 드롭다운 외부 클릭 감지
@ -195,8 +174,8 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
}
}
// Enter 키 입력 시 기본 동작 방지 (폼 제출 방지)
if (e.key === 'Enter') {
// 업체명 검색 모드에서 Enter 키 입력 시 기본 동작 방지 (폼 제출 방지)
if (e.key === 'Enter' && searchType === 'name') {
e.preventDefault();
}
};
@ -281,7 +260,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
const getPlaceholder = () => {
return searchType === 'url'
? 'https://naver.me/abcdef'
? 'https://www.castad.com'
: t('landing.hero.placeholderBusinessName');
};
@ -381,13 +360,8 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
type="button"
className={`hero-dropdown-item ${searchType === option.value ? 'active' : ''}`}
onClick={() => {
if (option.value === 'manual') {
setIsManualModalOpen(true);
setIsDropdownOpen(false);
} else {
setSearchType(option.value);
setIsDropdownOpen(false);
}
setSearchType(option.value);
setIsDropdownOpen(false);
}}
>
{option.label}
@ -413,8 +387,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
}
setInputValue(value);
setHighlightedIndex(-1);
hideOnInput(value);
setHighlightedIndex(-1); // 입력 시 하이라이트 초기화
if (localError) setLocalError('');
// 업체명 검색일 때 자동완성 검색 (디바운스)
@ -442,37 +415,13 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
if (searchType === 'name' && autocompleteResults.length > 0) {
setShowAutocomplete(true);
}
openHistory(inputValue);
}}
onBlur={() => { setIsFocused(false); setTimeout(() => closeHistory(), 150); }}
onBlur={() => setIsFocused(false)}
onKeyDown={handleKeyDown}
placeholder={getPlaceholder()}
className={`hero-input ${inputValue ? 'has-value' : ''}`}
/>
{/* 최근 검색 히스토리 */}
{showHistory && !showAutocomplete && (
<SearchHistoryDropdown
items={filteredHistory}
onSelect={(item) => {
closeHistory();
if (item.type === 'url') {
setSearchType('url');
setInputValue(item.value);
} else {
setSearchType('name');
setInputValue(item.value);
setSelectedItem({ title: item.value, address: item.address || '', roadAddress: item.roadAddress || '' });
}
}}
onDelete={(e, value) => { e.preventDefault(); e.stopPropagation(); deleteItem(value); }}
className="hero-autocomplete-dropdown"
itemClassName="hero-autocomplete-item"
titleClassName="hero-autocomplete-title"
addressClassName="hero-autocomplete-address"
/>
)}
{/* 자동완성 결과 */}
{showAutocomplete && searchType === 'name' && (
<div className="hero-autocomplete-dropdown">
@ -553,24 +502,6 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
{isLoadingTest ? t('landing.hero.testDataLoading') : t('landing.hero.testData')}
</button>
)}
{tutorial.isActive && (
<TutorialOverlay
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/>
)}
{isManualModalOpen && (
<BusinessNameInputModal
onClose={() => setIsManualModalOpen(false)}
onSubmit={(businessName, address) => { setIsManualModalOpen(false); onManualInput?.(businessName, address); }}
/>
)}
</div>
);
};

View File

@ -68,7 +68,6 @@ export interface LyricGenerateRequest {
region: string;
task_id: string;
orientation?: 'vertical' | 'horizontal';
instrumental?: boolean;
}
// 가사 생성 응답
@ -104,9 +103,8 @@ export interface LyricDetailResponse {
// 노래 생성 요청
export interface SongGenerateRequest {
genre: string;
language?: string;
lyrics?: string;
instrumental?: boolean;
language: string;
lyrics: string;
}
// 노래 생성 응답
@ -143,13 +141,6 @@ export interface SongDownloadResponse {
}
// 영상 생성 응답
// 자막 상태 확인 응답
export interface SubtitleStatusResponse {
task_id: string;
status: string; // 'pending' | 'processing' | 'completed' | 'failed' | 'error'
message: string;
}
export interface VideoGenerateResponse {
success: boolean;
task_id: string;
@ -263,33 +254,14 @@ export interface UserMeResponse {
created_at: string;
}
export interface UserCreditsResponse {
credits: number;
}
// 비디오 목록 아이템 (갤러리용)
// 비디오 목록 아이템
export interface VideoListItem {
video_id: number;
store_name: string;
region: string;
task_id: string;
result_movie_url: string;
thumbnail_url?: string;
created_at: string;
like_count: number;
comment_count: number;
is_liked_by_me?: boolean;
}
// 비디오 상세 아이템
export interface VideoDetailItem {
video_id: number;
result_movie_url: string;
store_name: string;
region: string;
created_at: string;
like_count: number;
is_liked_by_me: boolean;
}
// 비디오 목록 응답
@ -303,45 +275,6 @@ export interface VideosListResponse {
has_prev: boolean;
}
// 댓글 대댓글
export interface CommentReply {
id: number;
nickname: string;
content: string | null;
is_deleted: boolean;
is_mine: boolean;
created_at: string;
}
// 댓글 아이템
export interface CommentItem {
id: number;
nickname: string;
content: string | null;
is_deleted: boolean;
is_mine: boolean;
created_at: string;
replies: CommentReply[];
}
// 댓글 목록 응답
export interface CommentsResponse {
items: CommentItem[];
total: number;
page: number;
page_size: number;
total_pages: number;
has_next: boolean;
has_prev: boolean;
}
// 좋아요 토글 응답
export interface LikeToggleResponse {
video_id: number;
is_liked: boolean;
like_count: number;
}
// ============================================
// Social OAuth Types
// ============================================
@ -442,8 +375,6 @@ export interface UploadHistoryItem {
status: 'pending' | 'uploading' | 'completed' | 'failed' | 'scheduled' | 'cancelled';
title: string;
channel_name: string;
platform_user_id: string | null;
platform_username: string | null;
scheduled_at: string | null;
uploaded_at: string | null;
created_at: string;

View File

@ -8,7 +8,6 @@ import {
SongGenerateResponse,
SongStatusResponse,
SongDownloadResponse,
SubtitleStatusResponse,
VideoGenerateResponse,
VideoStatusResponse,
VideoDownloadResponse,
@ -29,11 +28,6 @@ import {
TokenExpiredErrorResponse,
YTAutoSeoRequest,
YTAutoSeoResponse,
UserCreditsResponse,
VideoDetailItem,
CommentsResponse,
CommentItem,
LikeToggleResponse,
} from '../types/api';
export const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
@ -167,8 +161,6 @@ export async function generateSong(taskId: string, request: SongGenerateRequest)
});
if (!response.ok) {
const errorBody = await response.json().catch(() => null);
console.error('[generateSong] 422 detail:', JSON.stringify(errorBody));
throw new Error(`HTTP error! status: ${response.status}`);
}
@ -250,52 +242,6 @@ export async function waitForSongComplete(
return poll();
}
// 자막 상태 확인 API
export async function getSubtitleStatus(taskId: string): Promise<SubtitleStatusResponse> {
const response = await authenticatedFetch(`${API_URL}/lyric/subtitle/status/${taskId}`, {
method: 'GET',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 자막 완료까지 폴링 (5초 간격, 10분 타임아웃)
const SUBTITLE_POLL_INTERVAL = 5000;
const SUBTITLE_POLL_TIMEOUT = 5 * 60 * 1000;
export async function waitForSubtitleComplete(
taskId: string,
onStatusChange?: (status: string) => void
): Promise<SubtitleStatusResponse> {
const startTime = Date.now();
const poll = async (): Promise<SubtitleStatusResponse> => {
if (Date.now() - startTime > SUBTITLE_POLL_TIMEOUT) {
throw new Error('TIMEOUT');
}
const response = await getSubtitleStatus(taskId);
onStatusChange?.(response.status);
if (response.status === 'completed') {
return response;
}
if (response.status === 'failed' || response.status === 'error') {
throw new Error(response.message || '자막 생성에 실패했습니다.');
}
await new Promise(resolve => setTimeout(resolve, SUBTITLE_POLL_INTERVAL));
return poll();
};
return poll();
}
// 영상 생성 API
export async function generateVideo(taskId: string, orientation: 'vertical' | 'horizontal' = 'vertical'): Promise<VideoGenerateResponse> {
const response = await authenticatedFetch(`${API_URL}/video/generate/${taskId}?orientation=${orientation}`, {
@ -377,99 +323,6 @@ export async function deleteVideo(videoId: number): Promise<void> {
}
}
// 전체 사용자 영상 목록 조회 API (ADO2 콘텐츠 갤러리용)
export async function getAllVideos(
page: number = 1,
pageSize: number = 20,
sortBy: 'created_at' | 'like_count' | 'comment_count' = 'created_at',
storeName: string = '',
order: 'desc' | 'asc' = 'desc',
region: string = '',
): Promise<VideosListResponse> {
const params = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
sort_by: sortBy,
order,
});
if (storeName.trim()) params.set('store_name', storeName.trim());
if (region.trim()) params.set('region', region.trim());
const response = await authenticatedFetch(`${API_URL}/video/all?${params.toString()}`, {
method: 'GET',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 단일 영상 상세 조회 API
export async function getVideoById(videoId: string): Promise<VideoDetailItem> {
const response = await authenticatedFetch(`${API_URL}/video/${videoId}`, {
method: 'GET',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 댓글 목록 조회
export async function getVideoComments(videoId: string, page: number = 1, pageSize: number = 20): Promise<CommentsResponse> {
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}?page=${page}&page_size=${pageSize}`, {
method: 'GET',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 댓글 작성
export async function postVideoComment(videoId: string, content: string, nickname?: string, parentId?: number): Promise<CommentItem> {
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, nickname: nickname || '익명', parent_id: parentId ?? null }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 댓글 삭제
export async function deleteComment(commentId: number): Promise<void> {
const response = await authenticatedFetch(`${API_URL}/comment/${commentId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
}
// 좋아요 토글
export async function toggleVideoLike(videoId: string): Promise<LikeToggleResponse> {
const response = await authenticatedFetch(`${API_URL}/video/${videoId}/like`, {
method: 'POST',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 이미지 업로드 API (multipart/form-data)
// 타임아웃: 5분 (많은 이미지 업로드 시 시간이 오래 걸릴 수 있음)
const IMAGE_UPLOAD_TIMEOUT = 5 * 60 * 1000;
@ -623,20 +476,14 @@ export async function authenticatedFetch(
let response = await fetch(url, { ...options, headers });
// 401 에러 시 에러 코드에 따라 처리
// 401 에러 시 토큰 갱신 시도
if (response.status === 401) {
const errorBody = await response.json().catch(() => null);
const errorCode = errorBody?.detail?.code;
if (errorCode !== 'TOKEN_EXPIRED') {
// INVALID_TOKEN 등 갱신으로 해결 불가한 경우 즉시 로그인 이동
redirectToLogin();
throw new Error(errorCode ?? 'Unauthorized');
}
try {
// 이미 갱신 중이면 기존 Promise 재사용 (중복 요청 방지)
// refreshPromise가 존재하면 재사용, 없으면 새로 생성
if (!refreshPromise) {
refreshPromise = refreshAccessToken().finally(() => {
// 성공/실패 상관없이 Promise 초기화 (다음 갱신을 위해)
refreshPromise = null;
});
}
@ -653,6 +500,7 @@ export async function authenticatedFetch(
response = await fetch(url, { ...options, headers: newHeaders });
} catch (refreshError) {
console.error('Token refresh failed:', refreshError);
// 토큰 갱신 실패 시 로그인 페이지로 리다이렉트
redirectToLogin();
throw refreshError;
}
@ -803,32 +651,6 @@ export async function getUserMe(): Promise<UserMeResponse> {
return response.json();
}
// 사용자 크레딧 조회
export async function getUserCredits(): Promise<UserCreditsResponse> {
const response = await authenticatedFetch(`${API_URL}/user/auth/me/credits`, {
method: 'GET',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 크레딧 충전 요청
export async function requestCreditCharge(amount: number, note: string): Promise<void> {
const response = await authenticatedFetch(`${API_URL}/user/credits/charge-requests`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requested_amount: amount, message: note }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
}
// 로그인 여부 확인
export function isLoggedIn(): boolean {
return !!getAccessToken();
@ -900,37 +722,6 @@ export async function autocomplete(request: AutocompleteRequest): Promise<Crawli
}
}
// 업체명·주소 직접 입력으로 마케팅 분석
export async function marketingAnalysis(storeName: string, address: string): Promise<CrawlingResponse> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT);
try {
const response = await authenticatedFetch(`${API_URL}/marketing`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ store_name: storeName, address }),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('요청 시간이 초과되었습니다. 다시 시도해주세요.');
}
throw error;
}
}
// ============================================
// Social OAuth TOKEN_EXPIRED 처리
// ============================================