검색히스토리 추가 및 번역 수정

feature-credit
김성경 2026-04-24 15:50:05 +09:00
parent 29cee13da7
commit 044fd21b2d
16 changed files with 629 additions and 171 deletions

View File

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

View File

@ -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<string | null>(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 <LoadingSection />;
return (
<LoadingSection
isComplete={isAnalysisComplete}
onComplete={() => setViewMode('analysis')}
/>
);
}
if (viewMode === 'analysis' && analysisData) {

View File

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

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

@ -85,7 +85,7 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
{ id: '새 프로젝트 만들기', label: t('sidebar.newProject'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> },
{ id: 'ADO2 콘텐츠', label: t('sidebar.ado2Contents'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> },
{ id: '내 콘텐츠', label: t('sidebar.myContents'), disabled: true, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> },
{ id: '콘텐츠 캘린더', label: '콘텐츠 캘린더', 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('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.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> },
];
@ -200,12 +200,12 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
target="_blank"
rel="noopener noreferrer"
className={`sidebar-inquiry-btn ${isCollapsed ? 'collapsed' : ''}`}
title="고객의견"
title={t('sidebar.inquiry')}
>
<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></span>}
{!isCollapsed && <span>{t('sidebar.inquiry')}</span>}
</a>
</div>
</div>

View File

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

View File

@ -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": "로그인 처리에 실패했습니다. 다시 시도해주세요.",

View File

@ -1,4 +1,3 @@
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { CrawlingResponse, TargetPersona } from '../../types/api';

View File

@ -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<LoadingSectionProps> = ({ onComplete, isComplete }) => {
const { t } = useTranslation();
const [progress, setProgress] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | 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 (
<div className="loading-container">
<div className="loading-content">

View File

@ -40,7 +40,9 @@ 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();
@ -54,7 +56,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
} 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<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,
@ -254,6 +281,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
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<CompletionContentProps> = ({
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<CompletionContentProps> = ({
}
}, []);
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<CompletionContentProps> = ({
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<CompletionContentProps> = ({
</div>
<div className="comp2-title-row">
<h1 className="comp2-page-title">{t('completion.contentComplete', { defaultValue: '콘텐츠 제작 완료' })}</h1>
<p className="comp2-page-subtitle">{t('completion.contentCompleteDesc', { defaultValue: 'AI 분석 및 편집을 통해 최적화된 콘텐츠가 완성되었습니다' })}</p>
<h1 className="comp2-page-title">{t('completion.contentComplete')}</h1>
<p className="comp2-page-subtitle">{t('completion.contentCompleteDesc')}</p>
</div>
<div className="comp2-container">
@ -373,10 +428,10 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
<div className="loading-progress-bar">
<div
className="loading-progress-fill"
style={{ width: `${renderProgress}%` }}
style={{ width: `${displayProgress}%` }}
/>
</div>
<span className="loading-progress-text">{renderProgress}%</span>
<span className="loading-progress-text">{Math.floor(displayProgress)}%</span>
</div>
</div>
) : videoStatus === 'error' ? (
@ -406,7 +461,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
{/* 오른쪽: 콘텐츠 정보 */}
<div className="comp2-info-section">
<div className="comp2-info-header">
<span className="comp2-info-label">{t('completion.contentInfo', { defaultValue: '파일명' })}</span>
<span className="comp2-info-label">{t('completion.contentInfo')}</span>
</div>
<div className="comp2-info-content">
<div className="comp2-file-info">
@ -415,17 +470,17 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
</div>
<div className="comp2-meta-grid">
<div className="comp2-meta-item">
<span className="comp2-meta-label">{t('completion.genre', { defaultValue: '장르' })} : {songCompletionData?.genre || 'K-POP'}</span>
<span className="comp2-meta-label">{t('completion.genre')} : {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', { defaultValue: '규격' })} : {getVideoResolution()}</span>
<span className="comp2-meta-label">{t('completion.resolution')} : {getVideoResolution()}</span>
</div>
<div className="comp2-meta-divider"></div>
<div className="comp2-lyrics-section">
<span className="comp2-meta-label">{t('completion.lyrics', { defaultValue: '가사' })}</span>
<span className="comp2-meta-label">{t('completion.lyrics')}</span>
<p className="comp2-lyrics-text">
{songCompletionData?.lyrics || t('completion.sampleLyrics', { defaultValue: '가사를 불러오는 중...' })}
{songCompletionData?.lyrics || t('completion.sampleLyrics')}
</p>
</div>
</div>
@ -434,17 +489,17 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
<div className="comp2-buttons">
<button
onClick={handleDownload}
disabled={videoStatus !== 'complete' || !videoUrl}
disabled={videoStatus !== 'complete' || !videoUrl || isDownloading}
className="comp2-btn comp2-btn-secondary"
>
{t('completion.download', { defaultValue: '다운로드' })}
{isDownloading ? t('completion.downloading') : t('completion.download')}
</button>
<button
onClick={handleOpenSocialConnect}
disabled={videoStatus !== 'complete' || !videoDbId}
className="comp2-btn comp2-btn-primary"
>
{t('completion.uploadToSocial', { defaultValue: '소셜 채널 업로드' })}
{t('completion.uploadToSocial')}
</button>
</div>
</div>

View File

@ -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<ContentCalendarContentProps> = ({ 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<ContentCalendarContentProps> = ({ 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<ContentCalendarContentProps> = ({ onNavig
} catch {
// 실패 시 다시 불러오기
refreshAll();
alert('취소에 실패했습니다.');
alert(t('contentCalendar.cancelFailed'));
}
};
@ -222,7 +224,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
await retryUpload(uploadId);
refreshAll();
} catch {
alert('재시도에 실패했습니다.');
alert(t('contentCalendar.retryFailed'));
}
};
@ -234,10 +236,11 @@ 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('-');
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<ContentCalendarContentProps> = ({ 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<TabType, string> = {
'전체': 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<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' }}>
{summary.completed}
{t('contentCalendar.completedCount', { count: summary.completed })}
</span>
</div>
)}
@ -396,7 +405,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' }}>
{summary.scheduled}
{t('contentCalendar.scheduledCount', { count: summary.scheduled })}
</span>
</div>
)}
@ -404,7 +413,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' }}>
{summary.failed}
{t('contentCalendar.failedCount', { count: summary.failed })}
</span>
</div>
)}
@ -432,7 +441,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
letterSpacing: '-0.096px', lineHeight: '22px', whiteSpace: 'nowrap',
}}
>
{tab}
{tabLabels[tab]}
</button>
))}
</div>
@ -442,9 +451,9 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
const renderStats = () => (
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
{[
{ 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) => (
<React.Fragment key={item.label}>
{idx > 0 && <div style={{ width: 0, height: 14, borderLeft: '1px solid #379599' }} />}
@ -483,7 +492,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
color: '#e5f1f2', letterSpacing: '-0.096px', lineHeight: '22px',
margin: 0, whiteSpace: 'nowrap',
}}>
{year} {monthNames[month]}
{t('contentCalendar.yearMonth', { year, month: monthNames[month] })}
</p>
<button
onClick={handleNextMonth}
@ -529,7 +538,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 13,
color: statusColor(item.status as UploadStatus), lineHeight: 1,
}}>
{statusLabel(item.status as UploadStatus)}
{t(statusLabelKey(item.status as UploadStatus))}
</span>
<span style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 400, fontSize: 13,
@ -575,7 +584,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
color: '#9bcacc', cursor: 'pointer',
}}
>
{t('contentCalendar.cancel')}
</button>
)}
{isFailed && (
@ -588,7 +597,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
color: '#ffffff', cursor: 'pointer',
}}
>
{t('contentCalendar.retry')}
</button>
)}
</div>
@ -602,7 +611,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 }}> ...</span>
<span style={{ color: '#9bcacc', fontFamily: 'Pretendard, sans-serif', fontSize: 14 }}>{t('contentCalendar.loading')}</span>
</div>
);
}
@ -617,13 +626,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
@ -635,7 +644,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
color: '#ffffff',
}}
>
ADO2
{t('contentCalendar.ado2Contents')}
</button>
</div>
);
@ -681,7 +690,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>
@ -746,7 +755,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>
@ -774,7 +783,7 @@ 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 }}> ...</span>
<span style={{ color: '#9bcacc', fontFamily: 'Pretendard, sans-serif', fontSize: 14 }}>{t('contentCalendar.loading')}</span>
</div>
) : renderCalendarGrid()}
</div>

View File

@ -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<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">{label}</span>
<h3 className="stat-value">{formatValue(value, unit)}</h3>
<span className="stat-label">{t(label)}</span>
<h3 className="stat-value">{formatValue(value, unit, unitHours, unitMinutes)}</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)}
{isUp ? '+' : '-'}{formatTrend(trend, unit, unitHours, unitMinutes)}
</span>
)}
</div>
@ -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 (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '200px', opacity: 0.4 }}>
<span> .</span>
<span>{noDataLabel}</span>
</div>
);
}
@ -362,14 +364,14 @@ const YearOverYearChart: React.FC<{
// Icon Components
// =====================================================
const YouTubeIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className || "platform-tab-icon"} viewBox="0 0 24 24" fill="currentColor">
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">
<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 }> = ({ className }) => (
<svg className={className || "platform-tab-icon"} viewBox="0 0 24 24" fill="currentColor">
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">
<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>
);
@ -573,7 +575,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ 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<DashboardContentProps> = ({ onNavigate }) => {
if (isLoading) {
return (
<div className="flex justify-center items-center h-screen">
<div className="text-lg">{t('데이터 로딩 중...')}</div>
<div className="text-lg">{t('dashboard.dataLoading')}</div>
</div>
);
}
@ -614,12 +616,13 @@ 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 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<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: 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<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' }}> .</p>
<p style={{ color: 'rgba(255,255,255,0.5)', fontSize: '16px' }}>ADO2 .</p>
<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>
</div>
</div>
</div>
@ -679,7 +698,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' && (
@ -687,7 +706,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') && (
@ -695,7 +714,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>
@ -738,7 +757,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
</span>
</>
) : (
<span style={{ flex: 1, color: 'rgba(255,255,255,0.4)' }}> </span>
<span style={{ flex: 1, color: 'rgba(255,255,255,0.4)' }}>{t('dashboard.selectAccount')}</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' }}>
@ -800,20 +819,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 }}>ADO2 30 .</p>
<p style={{ fontSize: '14px', color: 'rgba(255,255,255,0.4)', margin: 0 }}>{t('dashboard.recentVideosDesc')}</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>
@ -850,6 +869,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
currentLabel={chartCurrentLabel}
previousLabel={chartPreviousLabel}
mode={mode}
noDataLabel={t('dashboard.noDataInPeriod')}
/>
</div>
</div>
@ -874,7 +894,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 }}> .</p>
<p style={{ fontSize: '14px', color: 'rgba(255,255,255,0.4)', margin: 0 }}>{t('dashboard.channelStatsDesc')}</p>
</div>
<div style={{ position: 'relative' }}>
<div className="audience-cards" style={!hasRealAudienceData && !showMockData ? { filter: 'blur(4px)', pointerEvents: 'none', userSelect: 'none' } : {}}>
@ -907,7 +927,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>
@ -943,7 +963,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 ? 'Sample 숨기기' : 'Sample 보기'}
{showMockData ? t('dashboard.sampleHide') : t('dashboard.sampleShow')}
</button>
</div>
);

View File

@ -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<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 tutorial = useTutorial();
@ -208,6 +210,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
// 업체명 자동완성으로 분석 시작
const handleAutocomplete = async (request: AutocompleteRequest) => {
goToWizardStep(-1); // 로딩 상태로
setIsAnalysisComplete(false);
setAnalysisError(null);
try {
@ -215,11 +218,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 || '';
@ -229,7 +232,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
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<GenerationFlowProps> = ({
if (!url.trim()) return;
goToWizardStep(-1); // 로딩 상태로
setIsAnalysisComplete(false);
setAnalysisError(null);
try {
@ -282,11 +287,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 || '';
@ -296,7 +301,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
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<GenerationFlowProps> = ({
);
case -1:
// 로딩 단계
return <LoadingSection />;
return (
<LoadingSection
isComplete={isAnalysisComplete}
onComplete={() => goToWizardStep(0)}
/>
);
case 0:
// 브랜드 분석 결과 단계
if (!analysisData) {

View File

@ -409,14 +409,15 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
<label className="input-label">{t('soundStudio.soundTypeLabel')}</label>
<div className="sound-type-grid">
{[
{ 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 }) => (
<button
key={key}
onClick={() => !isGenerating && setSelectedType(key)}
disabled={isGenerating}
onClick={() => !isGenerating && !disabled && setSelectedType(key)}
disabled={isGenerating || disabled}
className={`sound-type-btn ${selectedType === key ? 'active' : ''}`}
title={disabled ? t('soundStudio.comingSoon', { defaultValue: '이후 오픈 예정입니다' }) : undefined}
>
{label}
</button>
@ -586,7 +587,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
{lyrics ? (
<textarea
value={lyrics}
onChange={(e) => setLyrics(e.target.value)}
readOnly
className="lyrics-textarea"
placeholder={t('soundStudio.lyricsPlaceholder')}
/>

View File

@ -1,8 +1,9 @@
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';
type SearchType = 'url' | 'name';
@ -26,6 +27,23 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
const [showAutocomplete, setShowAutocomplete] = useState(false);
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
const [isLoadingTest, setIsLoadingTest] = useState(false);
const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(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 () => {
@ -96,6 +114,11 @@ 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) {
@ -111,12 +134,6 @@ 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);
@ -138,6 +155,9 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
// 폼 제출 처리
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
};
const handleAnalyzeClick = () => {
if (!inputValue.trim()) return;
if (searchType === 'name' && selectedItem && onAutocomplete) {
@ -221,6 +241,7 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
}
setInputValue(value);
hideOnInput(value);
// 업체명 검색일 때 자동완성 검색 (디바운스)
if (searchType === 'name') {
@ -237,11 +258,22 @@ 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">
@ -279,7 +311,7 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
)}
{/* 검색 버튼 */}
<button type="submit" className="url-input-button">
<button type="button" onClick={handleAnalyzeClick} className="url-input-button">
{t('landing.hero.analyzeButton')}
</button>
</form>

View File

@ -6,6 +6,8 @@ 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';
type SearchType = 'url' | 'name';
@ -84,6 +86,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
setIsLoadingTest(false);
}
};
const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType);
const [isFocused, setIsFocused] = useState(false);
const [autocompleteResults, setAutocompleteResults] = useState<AccommodationSearchItem[]>([]);
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
@ -188,8 +191,8 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
}
}
// 업체명 검색 모드에서 Enter 키 입력 시 기본 동작 방지 (폼 제출 방지)
if (e.key === 'Enter' && searchType === 'name') {
// Enter 키 입력 시 기본 동작 방지 (폼 제출 방지)
if (e.key === 'Enter') {
e.preventDefault();
}
};
@ -401,7 +404,8 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
}
setInputValue(value);
setHighlightedIndex(-1); // 입력 시 하이라이트 초기화
setHighlightedIndex(-1);
hideOnInput(value);
if (localError) setLocalError('');
// 업체명 검색일 때 자동완성 검색 (디바운스)
@ -429,13 +433,37 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
if (searchType === 'name' && autocompleteResults.length > 0) {
setShowAutocomplete(true);
}
openHistory(inputValue);
}}
onBlur={() => setIsFocused(false)}
onBlur={() => { setIsFocused(false); setTimeout(() => closeHistory(), 150); }}
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">