Files
sundynix-radio-admin/src/pages/Dashboard/index.tsx
T
2026-03-30 16:26:42 +08:00

710 lines
40 KiB
TypeScript

import { useState, useEffect } from 'react';
import {
Mic2,
Disc3,
Users,
Activity,
BarChart3,
PieChart,
TrendingUp,
ArrowRight,
Headphones,
ShoppingCart,
CreditCard,
RefreshCw,
UserCheck,
UserX,
Filter,
Crown,
Coins
} from 'lucide-react';
import { motion } from 'framer-motion';
import { AnimatedCounter } from '@/components/AnimatedCounter.tsx';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend
} from 'recharts';
import {
getListeningTrendApi,
getSubscriptionTrendApi,
getRenewalTrendApi,
getSubscriberStatsApi,
getContentQualityApi,
getBusinessConversionApi,
getPreferenceApi,
getUserStickinessApi,
getChannelListApi,
getProgramListApi,
getVipStatsApi
} from '@/api/radio.ts';
import type { RadioChannel } from '@/types/radio.ts';
// ─── Color Palette ───
const COLORS = {
listen: '#0f172a', // Slate-900 (primary)
sub: '#3b82f6', // Blue-500
renew: '#10b981', // Emerald-500
muted: '#9ca3af', // Gray-400
chart: ['#0f172a', '#3b82f6', '#10b981', '#f59e0b', '#6366f1'],
};
export default function Dashboard() {
const [loading, setLoading] = useState(true);
// Filter states
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [channelId, setChannelId] = useState('');
const [channelOptions, setChannelOptions] = useState<RadioChannel[]>([]);
// Core stats
const [statsData, setStatsData] = useState<{
activeSubscribers: number;
totalSubscribers: number;
expiredSubscribers: number;
channelCount: number;
programCount: number;
}>({
activeSubscribers: 0,
totalSubscribers: 0,
expiredSubscribers: 0,
channelCount: 0,
programCount: 0,
});
const [vipStats, setVipStats] = useState<{
activeVipUsers: number;
vipRevenue: number;
newVipOrders: number;
}>({
activeVipUsers: 0,
vipRevenue: 0,
newVipOrders: 0,
});
// Unified Trend Data
const [trendData, setTrendData] = useState<{ date: string; listen: number; sub: number; renew: number }[]>([]);
// Quality / Preference / Funnel / Retention
const [qualityData, setQualityData] = useState<{ programId: string; title: string; playCount: number; avgCompletion: number }[]>([]);
const [preferenceData, setPreferenceData] = useState<{ categoryId: string; categoryName: string; revenue: number; share: number }[]>([]);
const [funnelData, setFunnelData] = useState<{ listenUsers: number; orderUsers: number; payUsers: number; ltv: number } | null>(null);
const [retentionData, setRetentionData] = useState<{ date: string; newUsers: number; retention: number[] }[]>([]);
const [subscriberTrend, setSubscriberTrend] = useState<{ date: string; count: number }[]>([]);
// Initial load: Fetch channels for filter
useEffect(() => {
getChannelListApi({ pageSize: 100 })
.then(res => {
if (res && res.list) setChannelOptions(res.list);
})
.catch(console.error);
}, []);
useEffect(() => {
fetchDashboardData();
fetchTrendData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [startDate, endDate, channelId]);
const fetchDashboardData = async () => {
setLoading(true);
try {
const commonParams = {
...(startDate ? { startDate } : {}),
...(endDate ? { endDate } : {}),
...(channelId ? { channelId } : {}),
};
const dateOnlyParams = {
...(startDate ? { startDate } : {}),
...(endDate ? { endDate } : {}),
};
const channelOnlyParams = {
...(channelId ? { channelId } : {}),
};
const [subStats, conversion, preference, channels, programs, quality, stickiness, vipData] =
await Promise.all([
getSubscriberStatsApi(commonParams),
getBusinessConversionApi(commonParams),
getPreferenceApi(), // NO PARAMS
getChannelListApi({ pageSize: 1 }),
getProgramListApi({ pageSize: 1 }),
getContentQualityApi(channelOnlyParams),
getUserStickinessApi(dateOnlyParams),
getVipStatsApi(dateOnlyParams)
]);
setStatsData({
activeSubscribers: subStats.activeSubscribers || 0,
totalSubscribers: subStats.totalSubscribers || 0,
expiredSubscribers: subStats.expiredSubscribers || 0,
channelCount: channels.total || 0,
programCount: programs.total || 0,
});
setSubscriberTrend(subStats.activeTrend || []);
setFunnelData(conversion);
setPreferenceData(preference.list || []);
setQualityData(quality || []);
setRetentionData(stickiness || []);
setVipStats({
activeVipUsers: vipData.activeVipUsers || 0,
vipRevenue: vipData.vipRevenue || 0,
newVipOrders: vipData.newVipOrders || 0,
});
} catch (error) {
console.error('Fetch dashboard error:', error);
} finally {
setLoading(false);
}
};
const fetchTrendData = async () => {
try {
const commonParams = {
...(startDate ? { startDate } : {}),
...(endDate ? { endDate } : {}),
...(channelId ? { channelId } : {}),
};
const [listenRes, subRes, renewRes] = await Promise.all([
getListeningTrendApi(commonParams),
getSubscriptionTrendApi(commonParams),
getRenewalTrendApi(commonParams)
]);
// Merge trend arrays grouping by date
const mergedMap = new Map<string, { date: string; listen: number; sub: number; renew: number }>();
const addData = (arr: { date: string; count: number }[], key: 'listen' | 'sub' | 'renew') => {
if (!arr) return;
arr.forEach(item => {
const existing = mergedMap.get(item.date) || { date: item.date, listen: 0, sub: 0, renew: 0 };
existing[key] = item.count;
mergedMap.set(item.date, existing);
});
};
addData(listenRes.trend, 'listen');
addData(subRes.trend, 'sub');
addData(renewRes.trend, 'renew');
const finalTrend = Array.from(mergedMap.values()).sort((a, b) => a.date.localeCompare(b.date));
setTrendData(finalTrend);
} catch (error) {
console.error('Fetch trend error:', error);
}
};
// ─── Funnel steps ───
const funnelSteps = funnelData
? [
{ label: '活跃收听', value: funnelData.listenUsers || 0, icon: Headphones, color: COLORS.chart[0] },
{ label: '尝试下单', value: funnelData.orderUsers || 0, icon: ShoppingCart, color: COLORS.chart[1] },
{ label: '支付成功', value: funnelData.payUsers || 0, icon: CreditCard, color: COLORS.chart[2] },
]
: [];
const funnelMax = funnelSteps.length > 0 ? Math.max(...funnelSteps.map((s) => s.value), 1) : 1;
// ─── Stats cards ───
const stats = [
{
name: '有效订阅',
value: statsData.activeSubscribers,
icon: UserCheck,
subLabel: '当前活跃付费用户',
color: COLORS.sub,
trend: statsData.totalSubscribers > 0
? `${statsData.totalSubscribers}`
: undefined,
},
{
name: '已过期订阅',
value: statsData.expiredSubscribers,
icon: UserX,
subLabel: '可触达召回用户',
color: COLORS.muted,
},
{
name: '活跃频道',
value: statsData.channelCount,
icon: Mic2,
subLabel: '在线广播节点',
color: COLORS.listen,
},
{
name: '节目总数',
value: statsData.programCount,
icon: Disc3,
subLabel: '内容资产储备',
color: COLORS.renew,
},
{
name: 'VIP 用户',
value: vipStats.activeVipUsers,
icon: Crown,
subLabel: '当前生效的尊享会员',
color: '#f59e0b',
trend: vipStats.newVipOrders > 0 ? `+${vipStats.newVipOrders} 订单` : undefined,
},
{
name: 'VIP 营收',
value: `¥${(vipStats.vipRevenue / 100).toFixed(2)}`,
icon: Coins,
subLabel: '区间内会员特权变现',
color: '#0f172a',
},
];
// ─── Formatted date helper ───
// Handles format "2026-03-07T00:00:00+08:00" -> "03-07"
const fmtDate = (val: string) => {
if (!val) return '';
const justDate = val.split('T')[0];
return justDate.split('-').slice(1).join('-');
};
return (
<div className="space-y-8 pb-20 overflow-x-hidden min-h-screen text-gray-900 font-sans relative">
{/* ═══ Header ═══ */}
<header className="relative z-10 flex flex-col xl:flex-row xl:items-end justify-between gap-6 pb-6 border-b border-border">
<div>
<h1 className="text-3xl md:text-4xl font-bold tracking-tight"></h1>
<p className="text-sm text-muted-foreground mt-1"></p>
</div>
{/* ═══ Filters ═══ */}
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center bg-card border border-border rounded-xl px-3 py-1.5 shadow-sm focus-within:ring-2 ring-primary/20 transition-all">
<span className="text-[13px] font-medium text-muted-foreground mr-2 whitespace-nowrap">:</span>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="bg-transparent text-[13px] font-medium text-foreground outline-none border-none custom-date-input"
/>
<span className="text-muted-foreground mx-1 text-xs">-</span>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="bg-transparent text-[13px] font-medium text-foreground outline-none border-none custom-date-input"
/>
</div>
<div className="flex items-center bg-card border border-border rounded-xl px-3 py-1.5 shadow-sm focus-within:ring-2 ring-primary/20 transition-all">
<span className="text-[13px] font-medium text-muted-foreground mr-2 whitespace-nowrap">:</span>
<select
value={channelId}
onChange={(e) => setChannelId(e.target.value)}
className="bg-transparent text-[13px] font-medium text-foreground outline-none border-none appearance-none pr-6 relative cursor-pointer min-w-[100px]"
style={{ backgroundImage: `url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e")`, backgroundRepeat: 'no-repeat', backgroundPosition: 'right center', backgroundSize: '14px' }}
>
<option value=""></option>
{channelOptions.map(ch => (
<option key={ch.id} value={ch.id}>{ch.name || `Channel ${ch.id}`}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<motion.button
whileTap={{ scale: 0.95 }}
onClick={() => { setStartDate(''); setEndDate(''); setChannelId(''); }}
className="p-1.5 rounded-lg bg-muted border border-border shadow-sm hover:bg-muted/80 transition-colors flex items-center justify-center h-8 w-8"
title="重置过滤"
>
<Filter className="w-4 h-4 text-muted-foreground" />
</motion.button>
<motion.button
whileHover={{ rotate: 180 }}
transition={{ duration: 0.4 }}
onClick={() => { fetchDashboardData(); fetchTrendData(); }}
className="p-1.5 rounded-lg bg-card border border-border shadow-sm hover:bg-muted transition-colors flex items-center justify-center h-8 w-8"
title="刷新数据"
>
<RefreshCw className="w-4 h-4 text-muted-foreground" />
</motion.button>
</div>
</div>
</header>
{/* ═══ Stats Overview ═══ */}
<section className="relative z-10 grid gap-5 grid-cols-2 md:grid-cols-3 xl:grid-cols-6">
{stats.map((stat, idx) => (
<motion.div
key={stat.name}
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.08 }}
className="bg-white rounded-[2rem] p-6 border border-gray-100/50 shadow-soft shadow-hover-spring flex flex-col gap-3 group relative overflow-hidden"
>
<div className="flex justify-between items-start">
<p className="text-xs font-semibold text-muted-foreground">{stat.name}</p>
<div className="p-2 rounded-lg bg-muted/60 border border-border group-hover:scale-110 transition-transform">
<stat.icon className="w-4 h-4" style={{ color: stat.color }} />
</div>
</div>
<p className="text-2xl md:text-3xl font-bold text-gray-900 leading-none">
{loading ? '—' : (
<AnimatedCounter
value={stat.value}
format={typeof stat.value === 'string' && stat.value.startsWith('¥') ? 'currency' : 'number'}
/>
)}
</p>
<div className="flex justify-between items-center">
<span className="text-[11px] text-muted-foreground">{stat.subLabel}</span>
{stat.trend && (
<span className="text-[10px] font-semibold text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
{stat.trend}
</span>
)}
</div>
</motion.div>
))}
</section>
{/* ═══ Main Trend Chart + Sidebar ═══ */}
<section className="relative z-10 grid grid-cols-1 xl:grid-cols-12 gap-6">
{/* Multiplex Trend Chart */}
<motion.div
className="xl:col-span-8"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
>
<div className="bg-white rounded-[2rem] p-8 border-glass shadow-soft flex flex-col gap-4">
{/* Chart header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-lg font-bold text-foreground"></h2>
<p className="text-xs text-muted-foreground mt-0.5">
</p>
</div>
</div>
{/* Chart area */}
<div className="w-full h-[320px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={trendData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="listenGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={COLORS.listen} stopOpacity={0.15} />
<stop offset="95%" stopColor={COLORS.listen} stopOpacity={0} />
</linearGradient>
<linearGradient id="subGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={COLORS.sub} stopOpacity={0.15} />
<stop offset="95%" stopColor={COLORS.sub} stopOpacity={0} />
</linearGradient>
<linearGradient id="renewGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={COLORS.renew} stopOpacity={0.15} />
<stop offset="95%" stopColor={COLORS.renew} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="4 4" vertical={false} stroke="var(--border)" opacity={0.4} />
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{ fill: 'var(--muted-foreground)', fontSize: 11 }}
dy={12}
tickFormatter={fmtDate}
minTickGap={30}
/>
<YAxis hide />
<Tooltip
cursor={{ stroke: 'var(--border)', strokeDasharray: '4 4' }}
content={({ active, payload, label }) =>
active && payload?.length ? (
<div className="px-4 py-3 rounded-xl bg-popover border border-border shadow-xl text-xs flex flex-col gap-2 min-w-[140px]">
<p className="font-semibold text-muted-foreground border-b border-border/50 pb-2">{fmtDate(label as string)} </p>
{payload.map(p => (
<div key={p.dataKey} className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: p.color }} />
<span className="text-muted-foreground">{p.name}</span>
</div>
<span className="font-bold text-foreground ml-4">{p.value}</span>
</div>
))}
</div>
) : null
}
/>
<Legend iconType="circle" wrapperStyle={{ fontSize: '11px', paddingTop: '10px' }} />
<Area type="monotone" dataKey="listen" name="收听量" stroke={COLORS.listen} strokeWidth={2.5} fill="url(#listenGrad)" activeDot={{ r: 5, fill: 'var(--background)', stroke: COLORS.listen, strokeWidth: 2.5 }} />
<Area type="monotone" dataKey="sub" name="新增订阅" stroke={COLORS.sub} strokeWidth={2.5} fill="url(#subGrad)" activeDot={{ r: 5, fill: 'var(--background)', stroke: COLORS.sub, strokeWidth: 2.5 }} />
<Area type="monotone" dataKey="renew" name="续费订单" stroke={COLORS.renew} strokeWidth={2.5} fill="url(#renewGrad)" activeDot={{ r: 5, fill: 'var(--background)', stroke: COLORS.renew, strokeWidth: 2.5 }} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</motion.div>
{/* ─── Conversion Funnel Side Card ─── */}
<motion.div
className="xl:col-span-4"
initial={{ opacity: 0, x: 16 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.15 }}
>
<div className="bg-white rounded-[2rem] p-8 border-glass shadow-soft h-full flex flex-col">
<div className="flex items-center justify-between mb-6 pb-4 border-b border-border">
<h3 className="text-sm font-bold text-foreground"></h3>
<TrendingUp className="w-4 h-4 text-muted-foreground" />
</div>
{/* LTV highlight */}
{funnelData && (
<div className="mb-6 rounded-2xl bg-gray-50/50 border border-gray-100 p-5 text-center shadow-inner group transition-all duration-300 hover:bg-white hover:shadow-soft">
<p className="text-xs text-gray-500 mb-2 transition-colors group-hover:text-gray-900"> (LTV)</p>
<p className="text-3xl font-black" style={{ color: COLORS.listen }}>
<AnimatedCounter value={funnelData.ltv / 100} format="currency" />
</p>
</div>
)}
{/* Funnel bars */}
<div className="space-y-4 flex-1">
{funnelSteps.map((step, i) => {
const pct = funnelMax > 0 ? (step.value / funnelMax) * 100 : 0;
const convRate =
i > 0 && funnelSteps[i - 1].value > 0
? ((step.value / funnelSteps[i - 1].value) * 100).toFixed(1)
: null;
return (
<div key={step.label} className="space-y-2">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<step.icon className="w-4 h-4" style={{ color: step.color }} />
<span className="text-xs font-bold text-foreground">{step.label}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-gray-900"><AnimatedCounter value={step.value}/></span>
{convRate && (
<span className="text-[10px] font-semibold text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full border border-gray-100">
<AnimatedCounter value={convRate} format="percent" />
</span>
)}
</div>
</div>
<div className="w-full bg-muted rounded-full h-2.5 overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
transition={{ duration: 1, delay: 0.2 + i * 0.15 }}
className="h-full rounded-full"
style={{ backgroundColor: step.color }}
/>
</div>
{i < funnelSteps.length - 1 && (
<div className="flex justify-center">
<ArrowRight className="w-3 h-3 text-border rotate-90" />
</div>
)}
</div>
);
})}
</div>
</div>
</motion.div>
</section>
{/* ═══ Subscriber Active Trend (mini chart) + Content Quality ═══ */}
<section className="relative z-10 grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Mini subscriber trend */}
<div className="bg-card rounded-2xl p-6 border border-border shadow-sm">
<div className="flex items-center justify-between mb-4 pb-3 border-b border-border">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-secondary" />
<h3 className="text-sm font-bold text-foreground"></h3>
</div>
<span className="text-xs text-muted-foreground">
<span className="font-bold text-foreground">{statsData.activeSubscribers}</span>
</span>
</div>
<div className="w-full h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={subscriberTrend} margin={{ top: 5, right: 5, left: -25, bottom: 0 }}>
<defs>
<linearGradient id="subTrendGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={COLORS.sub} stopOpacity={0.2} />
<stop offset="95%" stopColor={COLORS.sub} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--border)" opacity={0.3} />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }} dy={8} tickFormatter={fmtDate} minTickGap={40} />
<YAxis hide />
<Tooltip
content={({ active, payload, label }) =>
active && payload?.length ? (
<div className="px-3 py-2 rounded-lg bg-popover border border-border shadow-md text-xs">
<p className="text-muted-foreground">{fmtDate(label as string)}</p>
<p className="font-bold text-foreground" style={{ color: COLORS.sub }}>{payload[0].value} </p>
</div>
) : null
}
/>
<Area type="monotone" dataKey="count" stroke={COLORS.sub} strokeWidth={2} fill="url(#subTrendGrad)" animationDuration={1000} activeDot={{ r: 4, fill: 'var(--background)', stroke: COLORS.sub, strokeWidth: 2 }} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
{/* Content Quality */}
<div className="bg-card rounded-2xl p-6 border border-border shadow-sm">
<div className="flex items-center justify-between mb-4 pb-3 border-b border-border">
<div className="flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-listen" />
<h3 className="text-sm font-bold text-foreground"> TOP5</h3>
</div>
</div>
<div className="space-y-5">
{qualityData.slice(0, 5).map((item, i) => {
const pct = (item.avgCompletion * 100);
return (
<div key={item.programId} className="space-y-1.5">
<div className="flex justify-between items-baseline">
<span className="text-xs font-medium text-foreground truncate pr-4 flex-1">{item.title}</span>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[11px] text-muted-foreground">{item.playCount} </span>
<span className="text-xs font-bold" style={{ color: pct >= 60 ? COLORS.sub : COLORS.muted }}>
{pct.toFixed(1)}%
</span>
</div>
</div>
<div className="w-full bg-muted rounded-full h-2 overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
className="h-full rounded-full"
style={{ backgroundColor: pct >= 60 ? COLORS.sub : COLORS.listen }}
transition={{ duration: 1, delay: i * 0.08 }}
/>
</div>
</div>
);
})}
</div>
</div>
</section>
{/* ═══ Bottom: Preference + Retention ═══ */}
<section className="relative z-10 grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Revenue by Category */}
<div className="bg-card rounded-2xl p-6 border border-border shadow-sm">
<div className="flex items-center justify-between mb-5 pb-3 border-b border-border">
<div className="flex items-center gap-2">
<PieChart className="w-4 h-4 text-primary" />
<h3 className="text-sm font-bold text-foreground"></h3>
</div>
<span className="text-xs text-muted-foreground"></span>
</div>
<div className="space-y-5">
{preferenceData.slice(0, 5).map((item, idx) => (
<div key={item.categoryId} className="flex items-center gap-4">
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-xs font-bold text-white shrink-0"
style={{ backgroundColor: COLORS.chart[idx % COLORS.chart.length] }}>
{idx + 1}
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-center mb-1">
<span className="text-sm font-semibold text-foreground truncate">{item.categoryName}</span>
<span className="text-sm font-bold" style={{ color: COLORS.chart[idx % COLORS.chart.length] }}>
¥{(item.revenue / 100).toLocaleString()}
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 bg-muted rounded-full h-1.5 overflow-hidden">
<motion.div
className="h-full rounded-full"
style={{ backgroundColor: COLORS.chart[idx % COLORS.chart.length] }}
initial={{ width: 0 }}
animate={{ width: `${item.share * 100}%` }}
transition={{ duration: 0.8, delay: 0.2 + idx * 0.08 }}
/>
</div>
<span className="text-[11px] font-bold text-muted-foreground w-12 text-right shrink-0">
{(item.share * 100).toFixed(1)}%
</span>
</div>
</div>
</div>
))}
</div>
</div>
{/* User Retention (real data from getUserStickinessApi) */}
<div className="bg-card rounded-2xl p-6 border border-border shadow-sm overflow-hidden">
<div className="flex items-center justify-between mb-5 pb-3 border-b border-border">
<div className="flex items-center gap-2">
<Activity className="w-4 h-4 text-secondary" />
<h3 className="text-sm font-bold text-foreground"></h3>
</div>
</div>
<div className="w-full overflow-x-auto custom-scrollbar">
<table className="w-full text-left border-collapse min-w-[420px]">
<thead>
<tr>
<th className="text-[11px] font-bold text-muted-foreground py-2.5 px-2 border-b border-border"></th>
<th className="text-[11px] font-bold text-muted-foreground text-center py-2.5 px-2 border-b border-border"></th>
<th className="text-[11px] font-bold text-muted-foreground text-center py-2.5 px-2 border-b border-border"></th>
<th className="text-[11px] font-bold text-muted-foreground text-center py-2.5 px-2 border-b border-border">3</th>
<th className="text-[11px] font-bold text-muted-foreground text-center py-2.5 px-2 border-b border-border">7</th>
<th className="text-[11px] font-bold text-muted-foreground text-center py-2.5 px-2 border-b border-border">30</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{retentionData.slice(0, 7).map((row) => (
<tr key={row.date} className="hover:bg-muted/30 transition-colors">
<td className="py-3 px-2 text-xs font-medium text-muted-foreground whitespace-nowrap">{fmtDate(row.date)}</td>
<td className="py-3 px-2 text-center text-xs font-bold text-foreground">{row.newUsers}</td>
{(row.retention || []).map((rate: number, idx: number) => {
const pct = (rate * 100);
const bgOpacity = Math.max(0.06, rate * 0.5);
return (
<td key={idx} className="py-2 px-1 text-center">
<div
className="w-full h-7 rounded-md flex items-center justify-center text-[11px] font-bold transition-colors"
style={{
backgroundColor: `rgba(106, 127, 106, ${bgOpacity})`,
color: pct > 20 ? COLORS.sub : COLORS.muted,
}}
>
{pct.toFixed(0)}%
</div>
</td>
);
})}
{/* Fill empty if less than 4 retention values */}
{(row.retention || []).length < 4 &&
Array.from({ length: 4 - (row.retention || []).length }).map((_, idx) => (
<td key={`empty-${idx}`} className="py-2 px-1 text-center">
<div className="w-full h-7 rounded-md bg-muted/30 flex items-center justify-center text-[11px] text-muted-foreground">
</div>
</td>
))}
</tr>
))}
{retentionData.length === 0 && (
<tr>
<td colSpan={6} className="py-8 text-center text-xs text-muted-foreground"></td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</section>
</div>
);
}