검색히스토리 추가 및 번역 수정
parent
29cee13da7
commit
044fd21b2d
38
index.css
38
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
20
src/App.tsx
20
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<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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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": "로그인 처리에 실패했습니다. 다시 시도해주세요.",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
import React, { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CrawlingResponse, TargetPersona } from '../../types/api';
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue