diff --git a/index.css b/index.css index 31a5adb..23927f6 100644 --- a/index.css +++ b/index.css @@ -3932,7 +3932,7 @@ flex-direction: column; align-items: center; text-align: center; - max-width: 500px; + max-width: 400px; width: 100%; } @@ -4128,10 +4128,10 @@ .url-input-autocomplete-dropdown { position: absolute; - top: calc(100% + 8px); + top: calc(100% + 4px); left: 0; right: 0; - background-color: #002224; + background-color: #01393B; border: 1px solid rgba(148, 251, 224, 0.2); border-radius: 12px; max-height: 300px; @@ -4247,7 +4247,7 @@ display: flex; flex-direction: column; align-items: center; - z-index: 10; + z-index: 20; position: relative; margin-top: 50px; } @@ -4355,6 +4355,7 @@ gap: 10px; border-radius: 999px; background: var(--Color-white, #FFF); + position: relative; } .hero-input-wrapper.focused { @@ -4446,7 +4447,7 @@ .hero-dropdown-menu { position: absolute; - top: calc(100% + 8px); + top: calc(100% + 4px); left: 0; min-width: 100px; background-color: #ffffff; @@ -4484,15 +4485,14 @@ /* Hero Autocomplete */ .hero-input-container { flex: 1; - position: relative; min-width: 0; } .hero-autocomplete-dropdown { position: absolute; - top: calc(100% + 8px); - left: -24px; - right: -60px; + top: calc(100% + 4px); + left: 90px; + right: 0; background-color: #ffffff; border: 1px solid #E5E7EB; border-radius: 12px; @@ -7710,6 +7710,10 @@ border-color: var(--color-mint); } +.sound-type-btn.active:hover:not(:disabled) { + border-color: var(--color-mint); +} + .sound-type-btn:disabled { opacity: 0.5; cursor: not-allowed; @@ -8297,8 +8301,20 @@ } @media (min-width: 768px) { - .asset-sticky-footer { - left: 240px; + body:has(.sidebar.expanded) .asset-sticky-footer { + left: 15rem; + right: 0; + width: auto; + display: flex; + justify-content: center; + } + + body:has(.sidebar.collapsed) .asset-sticky-footer { + left: 5rem; + right: 0; + width: auto; + display: flex; + justify-content: center; } } diff --git a/src/App.tsx b/src/App.tsx index ff628aa..279fe8d 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import SocialConnectSuccess from './pages/Social/SocialConnectSuccess'; import SocialConnectError from './pages/Social/SocialConnectError'; import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback'; import { crawlUrl, autocomplete, kakaoCallback, isLoggedIn, saveTokens, AutocompleteRequest } from './utils/api'; +import { saveSearchHistory } from './components/SearchHistory/useSearchHistory'; import { CrawlingResponse } from './types/api'; type ViewMode = 'landing' | 'loading' | 'analysis' | 'login' | 'generation_flow'; @@ -96,6 +97,7 @@ const App: React.FC = () => { parseSavedAnalysisData() ); const [error, setError] = useState(null); + const [isAnalysisComplete, setIsAnalysisComplete] = useState(false); const [scrollProgress, setScrollProgress] = useState(0); const [isProcessingCallback, setIsProcessingCallback] = useState(false); @@ -256,6 +258,7 @@ const App: React.FC = () => { if (!url.trim()) return; setViewMode('loading'); + setIsAnalysisComplete(false); setError(null); try { @@ -268,7 +271,8 @@ const App: React.FC = () => { setAnalysisData(data); localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data)); - setViewMode('analysis'); + saveSearchHistory({ type: 'url', value: url }); + setIsAnalysisComplete(true); } catch (err) { console.error('Crawling failed:', err); const errorMessage = err instanceof Error ? err.message : t('app.analysisError'); @@ -280,6 +284,7 @@ const App: React.FC = () => { // 업체명 자동완성으로 분석 시작 const handleAutocomplete = async (request: AutocompleteRequest) => { setViewMode('loading'); + setIsAnalysisComplete(false); setError(null); try { @@ -292,10 +297,12 @@ const App: React.FC = () => { setAnalysisData(data); localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data)); - setViewMode('analysis'); + saveSearchHistory({ type: 'name', value: request.title.replace(/<[^>]*>/g, ''), address: request.address, roadAddress: request.roadAddress }); + setIsAnalysisComplete(true); } catch (err) { console.error('Autocomplete failed:', err); - const errorMessage = err instanceof Error ? err.message : t('app.autocompleteGeneralError'); + const is404 = err instanceof Error && err.message.includes('status: 404'); + const errorMessage = is404 ? t('app.autocompleteError') : (err instanceof Error ? err.message : t('app.autocompleteGeneralError')); setError(errorMessage); setViewMode('landing'); } @@ -395,7 +402,12 @@ const App: React.FC = () => { } if (viewMode === 'loading') { - return ; + return ( + setViewMode('analysis')} + /> + ); } if (viewMode === 'analysis' && analysisData) { diff --git a/src/components/SearchHistory/SearchHistoryDropdown.tsx b/src/components/SearchHistory/SearchHistoryDropdown.tsx new file mode 100644 index 0000000..3829e5e --- /dev/null +++ b/src/components/SearchHistory/SearchHistoryDropdown.tsx @@ -0,0 +1,58 @@ +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 = ({ + 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 ( +
+ {items.map((item, index) => ( +
+ + +
+ ))} +
+ ); +}; + +export default SearchHistoryDropdown; diff --git a/src/components/SearchHistory/useSearchHistory.ts b/src/components/SearchHistory/useSearchHistory.ts new file mode 100644 index 0000000..2e5ead2 --- /dev/null +++ b/src/components/SearchHistory/useSearchHistory.ts @@ -0,0 +1,56 @@ +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([]); + 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 }; +}; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 9cab585..08ea9ed 100755 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -85,7 +85,7 @@ const Sidebar: React.FC = ({ activeItem, onNavigate, onHome, userI { id: '새 프로젝트 만들기', label: t('sidebar.newProject'), disabled: false, icon: }, { id: 'ADO2 콘텐츠', label: t('sidebar.ado2Contents'), disabled: false, icon: }, { id: '내 콘텐츠', label: t('sidebar.myContents'), disabled: true, icon: }, - { id: '콘텐츠 캘린더', label: '콘텐츠 캘린더', disabled: false, icon: }, + { id: '콘텐츠 캘린더', label: t('contentCalendar.title'), disabled: false, icon: }, { id: '내 정보', label: t('sidebar.myInfo'), disabled: false, icon: }, ]; @@ -200,12 +200,12 @@ const Sidebar: React.FC = ({ activeItem, onNavigate, onHome, userI target="_blank" rel="noopener noreferrer" className={`sidebar-inquiry-btn ${isCollapsed ? 'collapsed' : ''}`} - title="고객의견" + title={t('sidebar.inquiry')} > - {!isCollapsed && 고객의견} + {!isCollapsed && {t('sidebar.inquiry')}} diff --git a/src/locales/en.json b/src/locales/en.json index 4b955ea..35e726b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -17,7 +17,8 @@ "tutorialRestart": "Restart Tutorial", "tutorial": "Tutorial", "tutorialOn": "Enable Tutorial", - "tutorialOff": "Disable Tutorial" + "tutorialOff": "Disable Tutorial", + "inquiry": "Feedback" }, "tutorial": { "skip": "Skip", @@ -37,7 +38,7 @@ "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." }, + "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." }, @@ -290,7 +291,17 @@ "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." + "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...", + "downloading": "Downloading...", + "download": "Download", + "uploadToSocial": "Upload to Social" }, "dashboard": { "title": "Dashboard", @@ -363,7 +374,38 @@ "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", @@ -444,6 +486,36 @@ "required": "*", "unknown": "Unknown" }, + "contentCalendar": { + "title": "Content Calendar", + "tabs": { + "all": "All", + "completed": "Completed", + "scheduled": "Scheduled", + "failed": "Failed" + }, + "status": { + "completed": "Completed", + "scheduled": "Scheduled", + "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}}" + }, "app": { "loginProcessing": "Processing login...", "loginFailed": "Login processing failed. Please try again.", diff --git a/src/locales/ko.json b/src/locales/ko.json index cfa2ab0..35b8cb4 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -17,7 +17,8 @@ "tutorialRestart": "튜토리얼 다시 보기", "tutorial": "튜토리얼", "tutorialOn": "튜토리얼 켜기", - "tutorialOff": "튜토리얼 끄기" + "tutorialOff": "튜토리얼 끄기", + "inquiry": "고객의견" }, "tutorial": { "skip": "건너뛰기", @@ -37,7 +38,7 @@ "next": { "title": "다음 단계로", "desc": "설정이 완료되면 다음 단계로 진행하세요." } }, "sound": { - "genre": { "title": "장르 선택", "desc": "영상에 어울리는 음악 장르를 선택하세요." }, + "genre": { "title": "장르 선택", "desc": "영상에 어울리는 음악 장르를 선택하세요.", "note": "배경음악은 이후 오픈 예정입니다." }, "language": { "title": "언어 선택", "desc": "음악의 언어를 선택할 수 있어요. \n이미 선택된 한국어로 진행해볼까요?" }, "generate": { "title": "사운드 생성", "desc": "버튼을 클릭하면 AI가 가사와 음악을 생성해요."}, "lyrics": { "title": "가사 생성 완료", "desc": "AI가 선택한 언어로 가사를 만들었어요.\n생성된 가사를 확인하세요." }, @@ -290,7 +291,17 @@ "videoNotReady": "영상이 아직 준비되지 않았습니다.", "youtubeUploadMessage": "YouTube 채널 \"{{channelName}}\"에 영상을 업로드합니다.\n\n(업로드 기능 준비 중)", "deployComingSoon": "선택한 소셜 채널에 배포 기능이 준비 중입니다.", - "youtubeExpiredAlert": "YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다." + "youtubeExpiredAlert": "YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다.", + "contentComplete": "콘텐츠 제작 완료", + "contentCompleteDesc": "AI 분석 및 편집을 통해 최적화된 콘텐츠가 완성되었습니다", + "contentInfo": "파일명", + "genre": "장르", + "resolution": "규격", + "lyrics": "가사", + "sampleLyrics": "가사를 불러오는 중...", + "downloading": "다운로드 중...", + "download": "다운로드", + "uploadToSocial": "소셜 채널 업로드" }, "dashboard": { "title": "대시보드", @@ -363,7 +374,38 @@ "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": "내 정보", @@ -444,6 +486,36 @@ "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}}" + }, "app": { "loginProcessing": "로그인 처리 중...", "loginFailed": "로그인 처리에 실패했습니다. 다시 시도해주세요.", diff --git a/src/pages/Analysis/AnalysisResultSection.tsx b/src/pages/Analysis/AnalysisResultSection.tsx index 3087787..7c82373 100755 --- a/src/pages/Analysis/AnalysisResultSection.tsx +++ b/src/pages/Analysis/AnalysisResultSection.tsx @@ -1,4 +1,3 @@ - import React, { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { CrawlingResponse, TargetPersona } from '../../types/api'; diff --git a/src/pages/Analysis/LoadingSection.tsx b/src/pages/Analysis/LoadingSection.tsx index d411eb0..a71722c 100755 --- a/src/pages/Analysis/LoadingSection.tsx +++ b/src/pages/Analysis/LoadingSection.tsx @@ -1,26 +1,43 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -const LoadingSection: React.FC = () => { +interface LoadingSectionProps { + onComplete?: () => void; + isComplete?: boolean; +} + +const LoadingSection: React.FC = ({ onComplete, isComplete }) => { const { t } = useTranslation(); const [progress, setProgress] = useState(0); + const intervalRef = useRef | null>(null); useEffect(() => { - const interval = setInterval(() => { + intervalRef.current = setInterval(() => { setProgress(prev => { if (prev >= 100) { - clearInterval(interval); + if (intervalRef.current) clearInterval(intervalRef.current); return 100; } - // 느리게 올라가다가 90% 이후 거의 멈춤 - const increment = prev < 50 ? 1 : prev < 85 ? 0.5 : 0.1; + const increment = prev < 70 ? 0.5 : prev < 90 ? 0.2 : 0.1; return Math.min(prev + increment, 100); }); }, 100); - return () => clearInterval(interval); + 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]); + return (
diff --git a/src/pages/Dashboard/CompletionContent.tsx b/src/pages/Dashboard/CompletionContent.tsx index a676b5c..ce1133d 100755 --- a/src/pages/Dashboard/CompletionContent.tsx +++ b/src/pages/Dashboard/CompletionContent.tsx @@ -40,7 +40,9 @@ const CompletionContent: React.FC = ({ const [errorMessage, setErrorMessage] = useState(null); const [statusMessage, setStatusMessage] = useState(''); const [renderProgress, setRenderProgress] = useState(0); + const [displayProgress, setDisplayProgress] = useState(0); const hasStartedGeneration = useRef(false); + const displayIntervalRef = useRef | null>(null); const tutorial = useTutorial(); @@ -54,7 +56,7 @@ const CompletionContent: React.FC = ({ } else if (isComplete && !tutorial.isActive && !tutorial.hasSeen(TUTORIAL_KEYS.COMPLETION)) { tutorial.startTutorial(TUTORIAL_KEYS.COMPLETION); } - }, [videoStatus, tutorial]); + }, [videoStatus, tutorial, tutorial.isActive]); // 소셜 미디어 포스팅 모달 const [showSocialModal, setShowSocialModal] = useState(false); @@ -83,6 +85,31 @@ const CompletionContent: React.FC = ({ } }, [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, @@ -254,6 +281,7 @@ const CompletionContent: React.FC = ({ setVideoUrl(completeVideo.videoUrl); if (completeVideo.videoDbId) setVideoDbId(completeVideo.videoDbId); setVideoStatus('complete'); + setShowComplete(true); hasStartedGeneration.current = true; return; } @@ -265,6 +293,7 @@ const CompletionContent: React.FC = ({ setVideoUrl(savedState.videoUrl); if (savedState.videoDbId) setVideoDbId(savedState.videoDbId); setVideoStatus('complete'); + setShowComplete(true); hasStartedGeneration.current = true; } else if (savedState.status === 'polling') { setVideoStatus('polling'); @@ -296,15 +325,32 @@ const CompletionContent: React.FC = ({ } }, []); - const handleDownload = () => { - if (videoUrl) { + 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 link = document.createElement('a'); link.href = videoUrl; - link.download = 'castad_video.mp4'; + link.download = getFileName(); link.target = '_blank'; document.body.appendChild(link); link.click(); document.body.removeChild(link); + } finally { + setIsDownloading(false); } }; @@ -321,11 +367,20 @@ const CompletionContent: React.FC = ({ setVideoStatus('idle'); setVideoUrl(null); setErrorMessage(null); + setShowComplete(false); clearStorage(); startVideoGeneration(); }; - const isLoading = videoStatus === 'generating' || videoStatus === 'polling'; + 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 getVideoResolution = () => { @@ -351,8 +406,8 @@ const CompletionContent: React.FC = ({
-

{t('completion.contentComplete', { defaultValue: '콘텐츠 제작 완료' })}

-

{t('completion.contentCompleteDesc', { defaultValue: 'AI 분석 및 편집을 통해 최적화된 콘텐츠가 완성되었습니다' })}

+

{t('completion.contentComplete')}

+

{t('completion.contentCompleteDesc')}

@@ -373,10 +428,10 @@ const CompletionContent: React.FC = ({
- {renderProgress}% + {Math.floor(displayProgress)}%
) : videoStatus === 'error' ? ( @@ -406,7 +461,7 @@ const CompletionContent: React.FC = ({ {/* 오른쪽: 콘텐츠 정보 */}
- {t('completion.contentInfo', { defaultValue: '파일명' })} + {t('completion.contentInfo')}
@@ -415,17 +470,17 @@ const CompletionContent: React.FC = ({
- {t('completion.genre', { defaultValue: '장르' })} : {songCompletionData?.genre || 'K-POP'} + {t('completion.genre')} : {songCompletionData?.genre || 'K-POP'}
- {t('completion.resolution', { defaultValue: '규격' })} : {getVideoResolution()} + {t('completion.resolution')} : {getVideoResolution()}
- {t('completion.lyrics', { defaultValue: '가사' })} + {t('completion.lyrics')}

- {songCompletionData?.lyrics || t('completion.sampleLyrics', { defaultValue: '가사를 불러오는 중...' })} + {songCompletionData?.lyrics || t('completion.sampleLyrics')}

@@ -434,17 +489,17 @@ const CompletionContent: React.FC = ({
diff --git a/src/pages/Dashboard/ContentCalendarContent.tsx b/src/pages/Dashboard/ContentCalendarContent.tsx index 7e4b20c..522afd7 100644 --- a/src/pages/Dashboard/ContentCalendarContent.tsx +++ b/src/pages/Dashboard/ContentCalendarContent.tsx @@ -1,5 +1,6 @@ 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'; @@ -53,13 +54,13 @@ const statusColor = (status: UploadStatus) => { } }; -// 상태 라벨 -const statusLabel = (status: UploadStatus) => { +// 상태 라벨 (컴포넌트 내부에서 t()로 처리) +const statusLabelKey = (status: UploadStatus) => { switch (status) { - case 'completed': return '완료'; + case 'completed': return 'contentCalendar.status.completed'; case 'scheduled': - case 'pending': return '예약'; - case 'failed': return '실패'; + case 'pending': return 'contentCalendar.status.scheduled'; + case 'failed': return 'contentCalendar.status.failed'; default: return status; } }; @@ -69,6 +70,7 @@ interface ContentCalendarContentProps { } const ContentCalendarContent: React.FC = ({ onNavigate }) => { + const { t } = useTranslation(); const today = new Date(); const [year, setYear] = useState(today.getFullYear()); const [month, setMonth] = useState(today.getMonth()); @@ -203,7 +205,7 @@ const ContentCalendarContent: React.FC = ({ onNavig // 취소 const handleCancel = async (uploadId: number) => { - if (!window.confirm('예약을 취소하시겠습니까?')) return; + if (!window.confirm(t('contentCalendar.cancelConfirm'))) return; // 낙관적 업데이트: 먼저 UI에서 제거 setAllItems(prev => prev.filter(i => i.upload_id !== uploadId)); setPanelItems(prev => prev.filter(i => i.upload_id !== uploadId)); @@ -212,7 +214,7 @@ const ContentCalendarContent: React.FC = ({ onNavig } catch { // 실패 시 다시 불러오기 refreshAll(); - alert('취소에 실패했습니다.'); + alert(t('contentCalendar.cancelFailed')); } }; @@ -222,7 +224,7 @@ const ContentCalendarContent: React.FC = ({ onNavig await retryUpload(uploadId); refreshAll(); } catch { - alert('재시도에 실패했습니다.'); + alert(t('contentCalendar.retryFailed')); } }; @@ -234,10 +236,11 @@ const ContentCalendarContent: React.FC = ({ 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('-'); - return `${parseInt(m)}월 ${parseInt(d)}`; + const months = t('contentCalendar.months', { returnObjects: true }) as string[]; + return t('contentCalendar.monthDay', { month: months[parseInt(m) - 1], day: parseInt(d) }); }; const buildCalendarDays = (): CalendarDay[] => { @@ -266,9 +269,15 @@ const ContentCalendarContent: React.FC = ({ onNavig }; const calendarDays = buildCalendarDays(); - const monthNames = ['1월','2월','3월','4월','5월','6월','7월','8월','9월','10월','11월','12월']; - const dayLabels = ['일','월','화','수','목','금','토']; + const monthNames = t('contentCalendar.months', { returnObjects: true }) as string[]; + const dayLabels = t('contentCalendar.days', { returnObjects: true }) as string[]; const tabs: TabType[] = ['전체','완료','예약','실패']; + const tabLabels: Record = { + '전체': t('contentCalendar.tabs.all'), + '완료': t('contentCalendar.tabs.completed'), + '예약': t('contentCalendar.tabs.scheduled'), + '실패': t('contentCalendar.tabs.failed'), + }; // ── 캘린더 그리드 ────────────────────────────────────────────── const renderCalendarGrid = () => { @@ -388,7 +397,7 @@ const ContentCalendarContent: React.FC = ({ onNavig
- 완료 {summary.completed} + {t('contentCalendar.completedCount', { count: summary.completed })}
)} @@ -396,7 +405,7 @@ const ContentCalendarContent: React.FC = ({ onNavig
- 예약 {summary.scheduled} + {t('contentCalendar.scheduledCount', { count: summary.scheduled })}
)} @@ -404,7 +413,7 @@ const ContentCalendarContent: React.FC = ({ onNavig
- 실패 {summary.failed} + {t('contentCalendar.failedCount', { count: summary.failed })}
)} @@ -432,7 +441,7 @@ const ContentCalendarContent: React.FC = ({ onNavig letterSpacing: '-0.096px', lineHeight: '22px', whiteSpace: 'nowrap', }} > - {tab} + {tabLabels[tab]} ))}
@@ -442,9 +451,9 @@ const ContentCalendarContent: React.FC = ({ onNavig const renderStats = () => (
{[ - { label: '예약', value: totalStats.scheduled }, - { label: '완료', value: totalStats.completed }, - { label: '실패', value: totalStats.failed }, + { label: t('contentCalendar.tabs.scheduled'), value: totalStats.scheduled }, + { label: t('contentCalendar.tabs.completed'), value: totalStats.completed }, + { label: t('contentCalendar.tabs.failed'), value: totalStats.failed }, ].map((item, idx) => ( {idx > 0 &&
} @@ -483,7 +492,7 @@ const ContentCalendarContent: React.FC = ({ onNavig color: '#e5f1f2', letterSpacing: '-0.096px', lineHeight: '22px', margin: 0, whiteSpace: 'nowrap', }}> - {year}년 {monthNames[month]} + {t('contentCalendar.yearMonth', { year, month: monthNames[month] })}

)} {isFailed && ( @@ -588,7 +597,7 @@ const ContentCalendarContent: React.FC = ({ onNavig color: '#ffffff', cursor: 'pointer', }} > - 재시도 + {t('contentCalendar.retry')} )}
@@ -602,7 +611,7 @@ const ContentCalendarContent: React.FC = ({ onNavig if (panelLoading) { return (
- 불러오는 중... + {t('contentCalendar.loading')}
); } @@ -617,13 +626,13 @@ const ContentCalendarContent: React.FC = ({ onNavig fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 16, color: '#e5f1f2', letterSpacing: '-0.096px', lineHeight: '22px', margin: 0, }}> - 최근 결과 없음 + {t('contentCalendar.noResults')}

- 제작한 콘텐츠를 소셜 채널에 업로드해 보세요 + {t('contentCalendar.noResultsDesc')}

); @@ -681,7 +690,7 @@ const ContentCalendarContent: React.FC = ({ onNavig fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30, color: '#ffffff', letterSpacing: '-0.18px', lineHeight: 1.3, margin: 0, }}> - 콘텐츠 캘린더 + {t('contentCalendar.title')}

@@ -746,7 +755,7 @@ const ContentCalendarContent: React.FC = ({ onNavig fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30, color: '#ffffff', letterSpacing: '-0.18px', lineHeight: 1.3, margin: 0, }}> - 콘텐츠 캘린더 + {t('contentCalendar.title')}

@@ -774,7 +783,7 @@ const ContentCalendarContent: React.FC = ({ onNavig
{loading ? (
- 불러오는 중... + {t('contentCalendar.loading')}
) : renderCalendarGrid()} diff --git a/src/pages/Dashboard/DashboardContent.tsx b/src/pages/Dashboard/DashboardContent.tsx index 056e6f1..37ed99e 100755 --- a/src/pages/Dashboard/DashboardContent.tsx +++ b/src/pages/Dashboard/DashboardContent.tsx @@ -81,29 +81,29 @@ interface ConnectedAccount { // ===================================================== const MOCK_CONTENT_METRICS: ContentMetric[] = [ - { 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' }, + { 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' }, ]; const MOCK_MONTHLY_DATA: MonthlyData[] = [ - { 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 }, + { 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 }, ]; const MOCK_DAILY_DATA: DailyData[] = Array.from({ length: 30 }, (_, i) => { @@ -201,18 +201,16 @@ const formatNumber = (num: number): string => { return num.toString(); }; -// unit에 따라 value를 표시용 문자열로 변환 (언어별 suffix는 호출부에서 처리) -const formatValue = (value: number, unit: string): string => { - if (unit === 'hours') return value.toFixed(1) + '시간'; - if (unit === 'minutes') return Math.round(value) + '분'; +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; return formatNumber(Math.round(value)); }; -// unit에 따라 trend 절댓값을 표시용 문자열로 변환 -const formatTrend = (trend: number, unit: string): string => { +const formatTrend = (trend: number, unit: string, unitHours: string, unitMinutes: string): string => { const abs = Math.abs(trend); - if (unit === 'hours') return abs.toFixed(1) + '시간'; - if (unit === 'minutes') return Math.round(abs) + '분'; + if (unit === 'hours') return abs.toFixed(1) + unitHours; + if (unit === 'minutes') return Math.round(abs) + unitMinutes; return formatNumber(Math.round(abs)); }; @@ -239,19 +237,22 @@ const TrendIcon: React.FC<{ direction: 'up' | 'down' }> = ({ direction }) => ( ); const StatCard: React.FC = ({ 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 (
- {label} -

{formatValue(value, unit)}

+ {t(label)} +

{formatValue(value, unit, unitHours, unitMinutes)}

{isNeutral ? ( ) : ( - {isUp ? '+' : '-'}{formatTrend(trend, unit)} + {isUp ? '+' : '-'}{formatTrend(trend, unit, unitHours, unitMinutes)} )}
@@ -276,11 +277,12 @@ const YearOverYearChart: React.FC<{ currentLabel: string; previousLabel: string; mode: 'day' | 'month'; -}> = ({ data, currentLabel, previousLabel, mode }) => { + noDataLabel: string; +}> = ({ data, currentLabel, previousLabel, mode, noDataLabel }) => { if (data.length === 0) { return (
- 이 기간에 데이터가 없습니다. + {noDataLabel}
); } @@ -362,14 +364,14 @@ const YearOverYearChart: React.FC<{ // Icon Components // ===================================================== -const YouTubeIcon: React.FC<{ className?: string }> = ({ className }) => ( - +const YouTubeIcon: React.FC<{ className?: string; style?: React.CSSProperties }> = ({ className, style }) => ( + ); -const InstagramIcon: React.FC<{ className?: string }> = ({ className }) => ( - +const InstagramIcon: React.FC<{ className?: string; style?: React.CSSProperties }> = ({ className, style }) => ( + ); @@ -573,7 +575,7 @@ const DashboardContent: React.FC = ({ onNavigate }) => { return; } if (errorData.code === 'YOUTUBE_API_FAILED') { - setError({ code: 'YOUTUBE_API_FAILED', message: 'YouTube Analytics API 호출에 실패했습니다.' }); + setError({ code: 'YOUTUBE_API_FAILED', message: t('dashboard.youtubeApiFailed') }); setDashboardData(null); return; } @@ -606,7 +608,7 @@ const DashboardContent: React.FC = ({ onNavigate }) => { if (isLoading) { return (
-
{t('데이터 로딩 중...')}
+
{t('dashboard.dataLoading')}
); } @@ -614,12 +616,13 @@ const DashboardContent: React.FC = ({ 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 contentMetrics = (useReal && dashboardData?.contentMetrics?.length) ? dashboardData.contentMetrics : MOCK_CONTENT_METRICS; + const hasRealContentMetrics = useReal && !!dashboardData?.contentMetrics?.slice(0, -1).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 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; @@ -631,15 +634,15 @@ const DashboardContent: React.FC = ({ 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: d.month, current: d.thisYear, previous: d.lastYear }))) + : MOCK_MONTHLY_DATA.map((d: MonthlyData) => ({ label: t(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' ? '올해' : '이번달'; - const chartPreviousLabel = mode === 'month' ? '작년' : '지난달'; - const chartSectionTitle = mode === 'month' ? t('dashboard.yearOverYear') : '전월 대비 성장'; + 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 lastUpdated = new Date().toLocaleDateString('ko-KR', { year: 'numeric', @@ -657,8 +660,24 @@ const DashboardContent: React.FC = ({ onNavigate }) => {
-

아직 업로드된 영상이 없습니다.

-

ADO2에서 영상을 업로드하면 실제 통계가 표시됩니다.

+

{t('dashboard.noUploadsTitle')}

+

{t('dashboard.noUploadsDesc')}

+
+
+ + )} + + {/* 48시간 지연 안내 배너 */} + {dashboardData && !isEmptyState && !hasRealContentMetrics && !showMockData && ( +
+
+ + + + +
+

{t('dashboard.dataDelayTitle')}

+

{t('dashboard.dataDelayDesc')}

@@ -679,7 +698,7 @@ const DashboardContent: React.FC = ({ 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')} )} {error.code === 'YOUTUBE_NOT_CONNECTED' && ( @@ -687,7 +706,7 @@ const DashboardContent: React.FC = ({ 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')} )} {(error.code === 'YOUTUBE_API_FAILED' || error.code === 'DASHBOARD_DATA_ERROR') && ( @@ -695,7 +714,7 @@ const DashboardContent: React.FC = ({ 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')} )} @@ -738,7 +757,7 @@ const DashboardContent: React.FC = ({ onNavigate }) => { ) : ( - 계정을 선택하세요 + {t('dashboard.selectAccount')} ); })()} @@ -800,20 +819,20 @@ const DashboardContent: React.FC = ({ onNavigate }) => {

{t('dashboard.contentPerformance')}

-

ADO2에서 업로드한 최근 30개의 영상 통계가 표시됩니다.

+

{t('dashboard.recentVideosDesc')}

@@ -850,6 +869,7 @@ const DashboardContent: React.FC = ({ onNavigate }) => { currentLabel={chartCurrentLabel} previousLabel={chartPreviousLabel} mode={mode} + noDataLabel={t('dashboard.noDataInPeriod')} /> @@ -874,7 +894,7 @@ const DashboardContent: React.FC = ({ onNavigate }) => {

{t('dashboard.audienceInsights')}

-

선택한 채널의 통계가 표시됩니다.

+

{t('dashboard.channelStatsDesc')}

@@ -907,7 +927,7 @@ const DashboardContent: React.FC = ({ onNavigate }) => { padding: '20px 28px', }}>

- 누적 데이터가 부족하여 실제 시청자 정보가 없습니다. + {t('dashboard.noAudienceData')}

@@ -943,7 +963,7 @@ const DashboardContent: React.FC = ({ onNavigate }) => { : <> } - {showMockData ? 'Sample 숨기기' : 'Sample 보기'} + {showMockData ? t('dashboard.sampleHide') : t('dashboard.sampleShow')} ); diff --git a/src/pages/Dashboard/GenerationFlow.tsx b/src/pages/Dashboard/GenerationFlow.tsx index 05859ed..86103a2 100755 --- a/src/pages/Dashboard/GenerationFlow.tsx +++ b/src/pages/Dashboard/GenerationFlow.tsx @@ -24,6 +24,7 @@ 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'; @@ -114,6 +115,7 @@ const GenerationFlow: React.FC = ({ const [videoGenerationProgress, setVideoGenerationProgress] = useState(0); const [analysisData, setAnalysisData] = useState(parseAnalysisData()); const [analysisError, setAnalysisError] = useState(null); + const [isAnalysisComplete, setIsAnalysisComplete] = useState(false); const [userInfo, setUserInfo] = useState(null); const tutorial = useTutorial(); @@ -208,6 +210,7 @@ const GenerationFlow: React.FC = ({ // 업체명 자동완성으로 분석 시작 const handleAutocomplete = async (request: AutocompleteRequest) => { goToWizardStep(-1); // 로딩 상태로 + setIsAnalysisComplete(false); setAnalysisError(null); try { @@ -215,11 +218,11 @@ const GenerationFlow: React.FC = ({ 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 || ''; @@ -229,7 +232,8 @@ const GenerationFlow: React.FC = ({ setAnalysisData(data); localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data)); - goToWizardStep(0); // 브랜드 분석 결과로 + saveSearchHistory({ type: 'name', value: request.title.replace(/<[^>]*>/g, ''), address: request.address, roadAddress: request.roadAddress }); + setIsAnalysisComplete(true); } catch (err) { console.error('Autocomplete error:', err); setAnalysisError(err instanceof Error ? err.message : t('app.autocompleteError')); @@ -275,6 +279,7 @@ const GenerationFlow: React.FC = ({ if (!url.trim()) return; goToWizardStep(-1); // 로딩 상태로 + setIsAnalysisComplete(false); setAnalysisError(null); try { @@ -282,11 +287,11 @@ const GenerationFlow: React.FC = ({ 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 || ''; @@ -296,7 +301,8 @@ const GenerationFlow: React.FC = ({ setAnalysisData(data); localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data)); - goToWizardStep(0); // 브랜드 분석 결과로 + saveSearchHistory({ type: 'url', value: url }); + setIsAnalysisComplete(true); } catch (err) { console.error('Crawling failed:', err); const errorMessage = err instanceof Error ? err.message : t('app.analysisError'); @@ -384,7 +390,12 @@ const GenerationFlow: React.FC = ({ ); case -1: // 로딩 단계 - return ; + return ( + goToWizardStep(0)} + /> + ); case 0: // 브랜드 분석 결과 단계 if (!analysisData) { diff --git a/src/pages/Dashboard/SoundStudioContent.tsx b/src/pages/Dashboard/SoundStudioContent.tsx index 3fb6ac4..fd87a52 100755 --- a/src/pages/Dashboard/SoundStudioContent.tsx +++ b/src/pages/Dashboard/SoundStudioContent.tsx @@ -409,14 +409,15 @@ const SoundStudioContent: React.FC = ({
{[ - { key: '보컬', label: t('soundStudio.soundTypeVocal') }, - { key: '배경음악', label: t('soundStudio.soundTypeBGM') }, - ].map(({ key, label }) => ( + { key: '보컬', label: t('soundStudio.soundTypeVocal'), disabled: false }, + { key: '배경음악', label: t('soundStudio.soundTypeBGM'), disabled: true }, + ].map(({ key, label, disabled }) => ( @@ -586,7 +587,7 @@ const SoundStudioContent: React.FC = ({ {lyrics ? (