o2o-castad-frontend/src/pages/Dashboard/DashboardContent.tsx

894 lines
38 KiB
TypeScript
Executable File

import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { authenticatedFetch, API_URL } from '../../utils/api';
import {
ResponsiveContainer, ComposedChart, Area, Line,
XAxis, CartesianGrid, Tooltip,
} from 'recharts';
// =====================================================
// Types
// =====================================================
interface ContentMetric {
id: string;
label: string;
value: number; // 원시 숫자값 (단위: unit 참조)
unit: string; // "count" | "hours" | "minutes"
trend: number;
trendDirection: 'up' | 'down' | '-';
}
interface MonthlyData {
month: string;
thisYear: number;
lastYear: number;
}
interface DailyData {
date: string;
thisPeriod: number;
lastPeriod: number;
}
// interface PlatformMetric { // 미사용 — platform_data 기능 예정
// id: string;
// label: string;
// value: string;
// unit?: string;
// trend: number;
// trendDirection: 'up' | 'down';
// }
// interface PlatformData { // 미사용 — platform_data 기능 예정
// platform: 'youtube' | 'instagram';
// displayName: string;
// metrics: PlatformMetric[];
// }
interface TopContent {
id: string;
title: string;
thumbnail: string;
platform: 'youtube' | 'instagram';
views: number; // 원시 정수 (포맷팅은 formatNumber로 처리)
engagement: string;
date: string;
}
interface AudienceData {
ageGroups: { label: string; percentage: number }[];
gender: { male: number; female: number };
topRegions: { region: string; percentage: number }[];
}
interface DashboardResponse {
contentMetrics: ContentMetric[];
monthlyData: MonthlyData[];
dailyData: DailyData[];
topContent: TopContent[];
audienceData: AudienceData;
hasUploads: boolean; // 업로드 영상 존재 여부 (false 시 mock 데이터 + 안내 메시지 표시)
// platformData: PlatformData[]; // 미사용
}
interface ConnectedAccount {
id: number;
platform: string;
platformUserId: string;
platformUsername?: string | null;
channelTitle?: string | null;
connectedAt: string;
isActive: boolean;
}
// =====================================================
// Mock Data (연동 전 샘플 데이터)
// =====================================================
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' },
];
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 },
];
const MOCK_DAILY_DATA: DailyData[] = Array.from({ length: 30 }, (_, i) => {
const month = i <= 11 ? 1 : 2;
const day = i <= 11 ? i + 20 : i - 11;
const thisPeriod = [820, 910, 780, 1020, 890, 1100, 950, 870, 1010, 980, 1120, 1050, 930, 860, 1080, 1150, 970, 1030, 800, 990, 1180, 1070, 920, 880, 1040, 1110, 960, 1000, 850, 940][i];
const lastPeriod = [680, 720, 650, 800, 700, 800, 770, 710, 800, 790, 900, 860, 760, 700, 870, 920, 790, 840, 690, 800, 940, 870, 700, 720, 850, 890, 780, 810, 690, 760][i];
return { date: `${month}/${day}`, thisPeriod, lastPeriod };
});
const MOCK_TOP_CONTENT: TopContent[] = [
{ id: '1', title: '겨울 펜션 프로모션 영상', thumbnail: 'https://picsum.photos/seed/content1/120/68', platform: 'youtube', views: 125400, engagement: '8.2%', date: '2025.01.15' },
{ id: '2', title: '스테이 머뭄 소개 릴스', thumbnail: 'https://picsum.photos/seed/content2/120/68', platform: 'instagram', views: 89200, engagement: '12.5%', date: '2025.01.22' },
{ id: '3', title: '신년 특가 이벤트 안내', thumbnail: 'https://picsum.photos/seed/content3/120/68', platform: 'youtube', views: 67800, engagement: '6.4%', date: '2025.01.08' },
{ id: '4', title: '펜션 야경 타임랩스', thumbnail: 'https://picsum.photos/seed/content4/120/68', platform: 'instagram', views: 54300, engagement: '15.8%', date: '2025.01.28' },
];
const MOCK_AUDIENCE_DATA: AudienceData = {
ageGroups: [
{ label: '18-24', percentage: 12 },
{ label: '25-34', percentage: 35 },
{ label: '35-44', percentage: 28 },
{ label: '45-54', percentage: 18 },
{ label: '55+', percentage: 7 },
],
gender: { male: 42, female: 58 },
topRegions: [
{ region: '서울', percentage: 32 },
{ region: '경기', percentage: 24 },
{ region: '부산', percentage: 12 },
{ region: '인천', percentage: 8 },
{ region: '대구', percentage: 6 },
],
};
// =====================================================
// Animation Components
// =====================================================
interface AnimatedItemProps {
children: React.ReactNode;
index: number;
baseDelay?: number;
className?: string;
}
const AnimatedItem: React.FC<AnimatedItemProps> = ({ children, index, baseDelay = 0, className = '' }) => {
const [isVisible, setIsVisible] = useState(false);
const delay = baseDelay + index * 80;
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), delay);
return () => clearTimeout(timer);
}, [delay]);
return (
<div
className={`transition-all duration-500 ${
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4 pointer-events-none'
} ${className}`}
>
{children}
</div>
);
};
interface AnimatedSectionProps {
children: React.ReactNode;
delay?: number;
className?: string;
}
const AnimatedSection: React.FC<AnimatedSectionProps> = ({ children, delay = 0, className = '' }) => {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), delay);
return () => clearTimeout(timer);
}, [delay]);
return (
<div
className={`transition-all duration-600 ${
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-6 pointer-events-none'
} ${className}`}
>
{children}
</div>
);
};
const formatNumber = (num: number): string => {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
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) + '분';
return formatNumber(Math.round(value));
};
// unit에 따라 trend 절댓값을 표시용 문자열로 변환
const formatTrend = (trend: number, unit: string): string => {
const abs = Math.abs(trend);
if (unit === 'hours') return abs.toFixed(1) + '시간';
if (unit === 'minutes') return Math.round(abs) + '분';
return formatNumber(Math.round(abs));
};
// =====================================================
// UI Components
// =====================================================
const TrendIcon: React.FC<{ direction: 'up' | 'down' }> = ({ direction }) => (
<svg
className="stat-trend-icon"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{direction === 'up' ? (
<path d="M6 9V3M3 5l3-3 3 3" />
) : (
<path d="M6 3v6M3 7l3 3 3-3" />
)}
</svg>
);
const StatCard: React.FC<ContentMetric> = ({ label, value, unit, trend, trendDirection }) => {
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>
<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)}
</span>
)}
</div>
</div>
);
};
// =====================================================
// Chart Component with Tooltip
// =====================================================
// 차트에 전달하는 통합 데이터 포인트 타입
// MonthlyData / DailyData 모두 이 형식으로 변환하여 사용
interface ChartDataPoint {
label: string; // X축 레이블 (월별: "1월", 일별: "1/20")
current: number; // 현재 기간 (thisYear 또는 thisPeriod)
previous: number; // 이전 기간 (lastYear 또는 lastPeriod)
}
const YearOverYearChart: React.FC<{
data: ChartDataPoint[];
currentLabel: string;
previousLabel: string;
mode: 'day' | 'month';
}> = ({ data, currentLabel, previousLabel, mode }) => {
if (data.length === 0) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '200px', opacity: 0.4 }}>
<span> .</span>
</div>
);
}
const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: { dataKey: string; value: number }[]; label?: string }) => {
if (!active || !payload?.length) return null;
const current = payload.find((p: { dataKey: string; value: number }) => p.dataKey === 'current')?.value ?? 0;
const previous = payload.find((p: { dataKey: string; value: number }) => p.dataKey === 'previous')?.value ?? 0;
const isEqual = current === previous;
const isUp = current > previous;
const diff = Math.abs(current - previous);
const changeClass = isEqual ? ' neutral' : isUp ? '' : ' down';
return (
<div className="chart-tooltip">
<div className="chart-tooltip-title">{label}</div>
<div className="chart-tooltip-row">
<span className="chart-tooltip-dot mint"></span>
<span className="chart-tooltip-label">{currentLabel}</span>
<span className="chart-tooltip-value">{formatNumber(current)}</span>
</div>
<div className="chart-tooltip-row">
<span className="chart-tooltip-dot purple"></span>
<span className="chart-tooltip-label">{previousLabel}</span>
<span className="chart-tooltip-value">{formatNumber(previous)}</span>
</div>
<div className={`chart-tooltip-change${changeClass}`}>
{isEqual ? '─' : `${isUp ? '↑' : '↓'} ${formatNumber(diff)}`}
</div>
</div>
);
};
return (
<div className="yoy-chart-wrapper">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data} margin={{ top: 20, right: 20, bottom: 0, left: 20 }}>
<defs>
<linearGradient id="thisYearGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#a6ffea" stopOpacity="0.25" />
<stop offset="100%" stopColor="#a6ffea" stopOpacity="0" />
</linearGradient>
</defs>
<CartesianGrid stroke="white" strokeOpacity={0.05} vertical={false} />
<XAxis
dataKey="label"
tick={{ fill: 'rgba(255,255,255,0.4)', fontSize: 12 }}
axisLine={false}
tickLine={false}
interval={mode === 'day' ? 1 : 0}
/>
<Tooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="current"
stroke="#a6ffea"
strokeWidth={3}
fill="url(#thisYearGradient)"
dot={{ fill: '#a6ffea', r: 4, filter: 'drop-shadow(0 0 6px rgba(166,255,234,0.6))' }}
activeDot={{ r: 6, fill: '#a6ffea' }}
isAnimationActive={true}
/>
<Line
type="monotone"
dataKey="previous"
stroke="#a682ff"
strokeWidth={2}
strokeDasharray="6 4"
dot={false}
opacity={0.7}
isAnimationActive={true}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
);
};
// =====================================================
// Icon Components
// =====================================================
const YouTubeIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className || "platform-tab-icon"} viewBox="0 0 24 24" fill="currentColor">
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
);
const InstagramIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg className={className || "platform-tab-icon"} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z" />
</svg>
);
// =====================================================
// Content Components
// =====================================================
const TopContentItem: React.FC<TopContent> = ({ title, thumbnail, platform, views, engagement, date }) => (
<div className="top-content-item">
<div className="top-content-thumbnail">
<img src={thumbnail} alt={title} />
<div className="top-content-platform-badge">
{platform === 'youtube' ? <YouTubeIcon className="top-content-platform-icon" /> : <InstagramIcon className="top-content-platform-icon" />}
</div>
</div>
<div className="top-content-info">
<h4 className="top-content-title">{title}</h4>
<div className="top-content-stats">
<span className="top-content-stat">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<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>
{formatNumber(views)}
</span>
<span className="top-content-stat">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
{engagement}
</span>
</div>
<span className="top-content-date">{date}</span>
</div>
</div>
);
const AudienceBarChart: React.FC<{ data: { label: string; percentage: number }[]; delay?: number }> = ({ data, delay = 0 }) => {
const [isAnimated, setIsAnimated] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setIsAnimated(true), delay);
return () => clearTimeout(timer);
}, [delay]);
return (
<div className="audience-bar-chart">
{data.map((item, index) => (
<div key={item.label} className="audience-bar-item">
<span className="audience-bar-label">{item.label}</span>
<div className="audience-bar-track">
<div
className="audience-bar-fill"
style={{
width: isAnimated ? `${item.percentage}%` : '0%',
transitionDelay: `${index * 100}ms`,
}}
/>
</div>
<span className="audience-bar-value">{item.percentage}%</span>
</div>
))}
</div>
);
};
const GenderChart: React.FC<{ male: number; female: number; delay?: number }> = ({ male, female, delay = 0 }) => {
const { t } = useTranslation();
const [isAnimated, setIsAnimated] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setIsAnimated(true), delay);
return () => clearTimeout(timer);
}, [delay]);
return (
<div className="gender-chart">
<div className="gender-chart-bars">
<div className="gender-bar male" style={{ width: isAnimated ? `${male}%` : '0%' }}>
<span>{male}%</span>
</div>
<div className="gender-bar female" style={{ width: isAnimated ? `${female}%` : '0%' }}>
<span>{female}%</span>
</div>
</div>
<div className="gender-chart-labels">
<span className="gender-label male">{t('dashboard.male')}</span>
<span className="gender-label female">{t('dashboard.female')}</span>
</div>
</div>
);
};
// =====================================================
// Main Component
// =====================================================
interface DashboardContentProps {
onNavigate?: (id: string) => void;
}
const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
const { t } = useTranslation();
const [mode, setMode] = useState<'day' | 'month'>('month');
const [dashboardData, setDashboardData] = useState<DashboardResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 계정 관련 state
const [accounts, setAccounts] = useState<ConnectedAccount[]>([]);
const [selectedAccountId, setSelectedAccountId] = useState<number | null>(() => {
const stored = localStorage.getItem('castad_selected_account_id');
return stored ? Number(stored) : null;
});
const [accountsLoaded, setAccountsLoaded] = useState(false);
const [accountDropdownOpen, setAccountDropdownOpen] = useState(false);
const accountDropdownRef = React.useRef<HTMLDivElement>(null);
// 드롭다운 외부 클릭 시 닫기
useEffect(() => {
if (!accountDropdownOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (accountDropdownRef.current && !accountDropdownRef.current.contains(e.target as Node)) {
setAccountDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [accountDropdownOpen]);
// 연결된 YouTube 계정 목록 fetch (마운트 시 1회)
useEffect(() => {
const fetchAccounts = async () => {
try {
const response = await authenticatedFetch(`${API_URL}/dashboard/accounts`, { method: 'GET' });
if (!response.ok) return;
const data = await response.json();
// snake_case(Python 백엔드) / camelCase 모두 처리
const list: ConnectedAccount[] = (data.accounts ?? []).map((acc: Record<string, unknown>) => ({
id: acc.id as number,
platform: acc.platform as string,
platformUserId: (acc.platformUserId ?? acc.platform_user_id ?? null) as string | null,
platformUsername: (acc.platformUsername ?? acc.platform_username ?? null) as string | null,
channelTitle: (acc.channelTitle ?? acc.channel_title ?? null) as string | null,
connectedAt: (acc.connectedAt ?? acc.connected_at ?? '') as string,
isActive: (acc.isActive ?? acc.is_active ?? true) as boolean,
}));
setAccounts(list);
// localStorage 값이 현재 계정 목록에 없으면 초기화
const storedId = localStorage.getItem('castad_selected_account_id');
if (storedId && !list.some((a: ConnectedAccount) => a.id === Number(storedId))) {
localStorage.removeItem('castad_selected_account_id');
setSelectedAccountId(null);
}
if (list.length === 1) {
setSelectedAccountId(list[0].id);
localStorage.setItem('castad_selected_account_id', String(list[0].id));
}
} catch (err) {
console.error('Failed to fetch accounts:', err);
} finally {
setAccountsLoaded(true);
}
};
fetchAccounts();
}, []);
useEffect(() => {
if (!accountsLoaded) return;
if (accounts.length > 1 && selectedAccountId === null) {
setIsLoading(false); // 계정 선택 전 로딩 종료 → 드롭다운 표시
return;
}
const fetchDashboardData = async () => {
try {
setIsLoading(true);
setError(null);
const params = new URLSearchParams({ mode });
// selectedAccountId로 계정을 찾아 platformUserId를 API 파라미터로 전달
if (selectedAccountId !== null) {
const selectedAccount = accounts.find((a: ConnectedAccount) => a.id === selectedAccountId);
if (selectedAccount?.platformUserId) {
params.set('platform_user_id', selectedAccount.platformUserId);
}
}
const response = await authenticatedFetch(`${API_URL}/dashboard/stats?${params}`, { method: 'GET' });
if (!response.ok) {
const errorData = await response.json();
if (errorData.code === 'YOUTUBE_NOT_CONNECTED') {
setError('YouTube 계정을 연동하여 데이터를 확인하세요.');
setDashboardData(null);
return;
}
if (errorData.code === 'YOUTUBE_ACCOUNT_SELECTION_REQUIRED') {
setDashboardData(null);
return;
}
throw new Error(errorData.detail || `API Error: ${response.status}`);
}
const data: DashboardResponse = await response.json();
setDashboardData(data);
setError(null);
} catch (err) {
console.error('Dashboard API Error:', err);
setError(err instanceof Error ? err.message : 'Unknown error');
setDashboardData(null);
} finally {
setIsLoading(false);
}
};
fetchDashboardData();
}, [mode, selectedAccountId, accountsLoaded]);
if (isLoading) {
return (
<div className="flex justify-center items-center h-screen">
<div className="text-lg">{t('데이터 로딩 중...')}</div>
</div>
);
}
// hasUploads === false: 업로드 영상 없음 → 전체 mock 데이터 표시 + 안내 배너
const isEmptyState = dashboardData?.hasUploads === false;
// API 데이터 우선 사용, 없거나 영상 없음(isEmptyState) 시 Mock 데이터로 폴백
const contentMetrics = (!isEmptyState && dashboardData?.contentMetrics?.length) ? dashboardData.contentMetrics : MOCK_CONTENT_METRICS;
const topContent = (!isEmptyState && dashboardData?.topContent?.length) ? dashboardData.topContent : MOCK_TOP_CONTENT;
const hasRealAudienceData = !isEmptyState && !!dashboardData?.audienceData?.ageGroups?.length;
const audienceData = hasRealAudienceData ? dashboardData!.audienceData : MOCK_AUDIENCE_DATA;
// mode별 차트 데이터를 ChartDataPoint 통합 형식으로 변환
const chartData: ChartDataPoint[] = mode === 'month'
? ((!isEmptyState && 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 })))
: ((!isEmptyState && 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 lastUpdated = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
return (
<div className="dashboard-container">
{/* 업로드 영상 없음 배너 */}
{isEmptyState && (
<div className="mb-4 p-4 border-l-4 rounded" style={{ background: 'rgba(166,255,234,0.06)', borderColor: '#a6ffea' }}>
<div className="flex items-center gap-3">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#a6ffea" strokeWidth="2" style={{ flexShrink: 0 }}>
<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', fontWeight: 600, marginBottom: '2px' }}> .</p>
<p style={{ color: 'rgba(255,255,255,0.5)', fontSize: '13px' }}>ADO2 . .</p>
</div>
</div>
</div>
)}
{/* 에러 배너 */}
{error && (
<div className="mb-4 p-4 bg-yellow-50 border-l-4 border-yellow-400 text-yellow-800 rounded">
<div className="flex items-center justify-between">
<div className="flex items-center">
<svg className="w-5 h-5 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<div>
<p className="font-semibold"> . .</p>
</div>
</div>
{error.includes('YouTube') && (
<button
onClick={() => onNavigate?.('내 정보')}
className="ml-4 px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 whitespace-nowrap"
>
</button>
)}
</div>
</div>
)}
{/* Header */}
<AnimatedSection delay={0} className="relative z-10">
<div className="dashboard-header-row">
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<h1 className="dashboard-title">{t('dashboard.title')}</h1>
{/* 계정 드롭다운: 연결된 계정이 2개 이상일 때만 표시 */}
{accounts.length > 1 && (
<div ref={accountDropdownRef} style={{ position: 'relative', zIndex: 11 }}>
{/* 트리거 버튼 */}
<button
onClick={() => setAccountDropdownOpen(!accountDropdownOpen)}
style={{
display: 'flex', alignItems: 'center', gap: '6px',
background: '#001a1c',
border: '1px solid rgba(255,255,255,0.15)',
borderRadius: '8px',
color: 'rgba(255,255,255,0.85)',
padding: '5px 10px',
fontSize: '13px',
cursor: 'pointer',
minWidth: '160px',
}}
>
{(() => {
const acc = accounts.find((a: ConnectedAccount) => a.id === selectedAccountId);
return acc ? (
<>
{acc.platform === 'youtube'
? <YouTubeIcon style={{ width: 14, height: 14, color: '#ff4444', flexShrink: 0 }} />
: <InstagramIcon style={{ width: 14, height: 14, color: '#e1306c', flexShrink: 0 }} />}
<span style={{ flex: 1, textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{acc.channelTitle ?? acc.platformUsername ?? String(acc.id)}
</span>
</>
) : (
<span style={{ flex: 1, color: 'rgba(255,255,255,0.4)' }}> </span>
);
})()}
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.5)" strokeWidth="2" style={{ flexShrink: 0, marginLeft: 'auto' }}>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{/* 드롭다운 목록 */}
{accountDropdownOpen && (
<div style={{
position: 'absolute', top: 'calc(100% + 4px)', left: 0,
zIndex: 9999,
background: '#001a1c',
border: '1px solid rgba(255,255,255,0.15)',
borderRadius: '8px',
overflow: 'hidden',
minWidth: '100%',
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
}}>
{accounts.map((acc: ConnectedAccount) => (
<div
key={acc.id}
onClick={() => {
setSelectedAccountId(acc.id);
localStorage.setItem('castad_selected_account_id', String(acc.id));
setAccountDropdownOpen(false);
}}
style={{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '8px 12px',
cursor: 'pointer',
fontSize: '13px',
background: acc.id === selectedAccountId ? 'rgba(255,255,255,0.08)' : 'transparent',
color: 'rgba(255,255,255,0.85)',
}}
onMouseEnter={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'rgba(255,255,255,0.1)'; }}
onMouseLeave={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = acc.id === selectedAccountId ? 'rgba(255,255,255,0.08)' : 'transparent'; }}
>
{acc.platform === 'youtube'
? <YouTubeIcon style={{ width: 14, height: 14, color: '#ff4444', flexShrink: 0 }} />
: <InstagramIcon style={{ width: 14, height: 14, color: '#e1306c', flexShrink: 0 }} />}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{acc.channelTitle ?? acc.platformUsername ?? acc.platformUserId}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
<p className="dashboard-description">{t('dashboard.description')}</p>
</div>
<span className="dashboard-last-updated">{t('dashboard.lastUpdated')} {lastUpdated}</span>
</div>
</AnimatedSection>
{/* Content Performance Section */}
<AnimatedSection delay={100} className="dashboard-section">
<div className="flex items-center justify-between mb-4">
<h2 className="dashboard-section-title" style={{ marginBottom: 0 }}>{t('dashboard.contentPerformance')}</h2>
<div className="mode-toggle">
<button
className={`mode-btn ${mode === 'month' ? 'active' : ''}`}
onClick={() => setMode('month')}
>
</button>
<button
className={`mode-btn ${mode === 'day' ? 'active' : ''}`}
onClick={() => setMode('day')}
>
</button>
</div>
</div>
<div className="stats-grid-8">
{contentMetrics.map((metric: ContentMetric, index: number) => (
<AnimatedItem key={metric.id} index={index} baseDelay={200}>
<StatCard {...metric} />
</AnimatedItem>
))}
</div>
</AnimatedSection>
{/* Two Column Layout */}
<div className="dashboard-two-column">
{/* 추이 차트 섹션 (mode=month: 연간, mode=day: 일별) */}
<AnimatedSection delay={600}>
<div className="yoy-chart-card">
<div className="yoy-chart-header">
<h2 className="dashboard-section-title">{chartSectionTitle}</h2>
<div className="chart-legend-dual">
<div className="chart-legend-item">
<span className="chart-legend-line solid"></span>
<span className="chart-legend-text">{chartCurrentLabel}</span>
</div>
<div className="chart-legend-item">
<span className="chart-legend-line dashed"></span>
<span className="chart-legend-text">{chartPreviousLabel}</span>
</div>
</div>
</div>
<div className="yoy-chart-container">
<YearOverYearChart
data={chartData}
currentLabel={chartCurrentLabel}
previousLabel={chartPreviousLabel}
mode={mode}
/>
</div>
</div>
</AnimatedSection>
{/* Top Content Section */}
<AnimatedSection delay={700}>
<div className="top-content-card">
<h2 className="dashboard-section-title">{t('dashboard.popularContent')}</h2>
<div className="top-content-list">
{topContent.map((content, index) => (
<AnimatedItem key={content.id} index={index} baseDelay={800}>
<TopContentItem {...content} />
</AnimatedItem>
))}
</div>
</div>
</AnimatedSection>
</div>
{/* Audience Insights Section */}
<AnimatedSection delay={1000} className="audience-section">
<h2 className="dashboard-section-title">{t('dashboard.audienceInsights')}</h2>
{!isEmptyState && !hasRealAudienceData && (
<div className="mb-4 p-3 border-l-4 rounded" style={{ background: 'rgba(255,200,0,0.06)', borderColor: 'rgba(255,200,0,0.5)' }}>
<div className="flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="rgba(255,200,0,0.8)" strokeWidth="2" style={{ flexShrink: 0 }}>
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p style={{ color: 'rgba(255,255,255,0.5)', fontSize: '13px' }}>
. .
</p>
</div>
</div>
)}
{audienceData ? (
<div className="audience-cards">
<AnimatedItem index={0} baseDelay={1100}>
<div className="audience-card">
<h3 className="audience-card-title">{t('dashboard.ageDistribution')}</h3>
<AudienceBarChart data={audienceData.ageGroups} delay={1200} />
</div>
</AnimatedItem>
<AnimatedItem index={1} baseDelay={1100}>
<div className="audience-card">
<h3 className="audience-card-title">{t('dashboard.genderDistribution')}</h3>
<GenderChart male={audienceData.gender.male} female={audienceData.gender.female} delay={1300} />
</div>
</AnimatedItem>
<AnimatedItem index={2} baseDelay={1100}>
<div className="audience-card">
<h3 className="audience-card-title">{t('dashboard.topRegions')}</h3>
<AudienceBarChart data={audienceData.topRegions.map(r => ({ label: r.region, percentage: r.percentage }))} delay={1400} />
</div>
</AnimatedItem>
</div>
) : (
<div style={{ textAlign: 'center', padding: '32px', opacity: 0.4 }}>
<span>{t('dashboard.noData') || '이 기간에 데이터가 없습니다.'}</span>
</div>
)}
</AnimatedSection>
</div>
);
};
export default DashboardContent;