Files
Wyview/components/StatsCharts.tsx

278 lines
13 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import {
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Area,
AreaChart,
} from "recharts";
import { TrendingUp, TrendingDown, Minus, Lock } from "lucide-react";
type Period = "7d" | "30d" | "90d" | "all";
type Metric = "followers" | "likes" | "videoCount";
interface Snapshot {
createdAt: string;
followers: number;
likes: number;
videoCount: number;
}
interface StatsChartProps {
plan: "free" | "pro" | "elite" | "team";
}
const PERIOD_CONFIG: { value: Period; label: string; requiredPlan: "free" | "pro" | "elite" | "team" }[] = [
{ value: "7d", label: "7J", requiredPlan: "free" },
{ value: "30d", label: "30J", requiredPlan: "pro" },
{ value: "90d", label: "90J", requiredPlan: "pro" },
{ value: "all", label: "TOUT", requiredPlan: "elite"},
];
const PLAN_RANK: Record<string, number> = { free: 0, pro: 1, elite: 2, team: 3 };
const METRICS: { value: Metric; label: string; color: string; gradientId: string }[] = [
{ value: "followers", label: "Followers", color: "#c084fc", gradientId: "gradFollowers" },
{ value: "likes", label: "Likes", color: "#f472b6", gradientId: "gradLikes" },
{ value: "videoCount", label: "Vidéos", color: "#60a5fa", gradientId: "gradVideos" },
];
function canAccess(plan: string, required: string) {
return PLAN_RANK[plan] >= PLAN_RANK[required];
}
function formatValue(value: number): string {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
return value.toLocaleString("fr-FR");
}
function formatDate(dateStr: string, period: Period): string {
const d = new Date(dateStr);
if (period === "7d") return d.toLocaleDateString("fr-FR", { weekday: "short", day: "numeric" });
if (period === "all") return d.toLocaleDateString("fr-FR", { month: "short", year: "2-digit" });
return d.toLocaleDateString("fr-FR", { day: "numeric", month: "short" });
}
function getDelta(snapshots: Snapshot[], metric: Metric) {
if (snapshots.length < 2) return null;
const first = snapshots[0][metric];
const last = snapshots[snapshots.length - 1][metric];
const diff = last - first;
const pct = first > 0 ? ((diff / first) * 100).toFixed(1) : null;
return { diff, pct };
}
function CustomTooltip({ active, payload, label, period }: any) {
if (!active || !payload?.length) return null;
return (
<div className="border rounded-sm px-3 py-2 font-mono text-[10px]"
style={{ background: "var(--surface)", borderColor: "var(--border)" }}>
<div className="mb-1 tracking-widest uppercase" style={{ color: "var(--text-secondary)" }}>
{formatDate(label, period)}
</div>
{payload.map((p: any) => (
<div key={p.dataKey} className="flex items-center gap-2" style={{ color: p.color }}>
<span className="uppercase tracking-widest">{p.name}</span>
<span className="ml-auto font-bold">{formatValue(p.value)}</span>
</div>
))}
</div>
);
}
export default function StatsChart({ plan }: StatsChartProps) {
const [period, setPeriod] = useState<Period>("7d");
const [metric, setMetric] = useState<Metric>("followers");
const [snapshots, setSnapshots] = useState<Snapshot[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!canAccess(plan, PERIOD_CONFIG.find(p => p.value === period)!.requiredPlan)) return;
setLoading(true);
setError(null);
fetch(`/api/tiktok/snapshots?period=${period}`)
.then(r => r.json())
.then(d => {
if (d.error) throw new Error(d.error);
setSnapshots(d);
})
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, [period, plan]);
const metricConfig = METRICS.find(m => m.value === metric)!;
const delta = getDelta(snapshots, metric);
const chartData = snapshots.map(s => ({ ...s, date: s.createdAt }));
return (
<div className="border rounded-sm" style={{ background: "var(--surface)", borderColor: "var(--border)" }}>
{/* Header */}
<div className="flex items-center justify-between px-4 pt-4 pb-3 border-b" style={{ borderColor: "var(--border)" }}>
<div>
<div className="text-[9px] font-mono tracking-[0.3em] text-pink-400/70 uppercase mb-0.5">Évolution</div>
<h2 className="text-sm font-black tracking-widest uppercase" style={{ color: "var(--text-primary)" }}>
Statistiques
</h2>
</div>
{/* Period selector */}
<div className="flex items-center gap-1">
{PERIOD_CONFIG.map(p => {
const accessible = canAccess(plan, p.requiredPlan);
const active = period === p.value;
return (
<button
key={p.value}
onClick={() => accessible && setPeriod(p.value)}
disabled={!accessible}
className={`
relative flex items-center gap-1 px-2.5 py-1 text-[9px] font-mono tracking-widest uppercase rounded-sm transition-all
${active
? "bg-pink-500/15 border border-pink-500/40 text-pink-300"
: accessible
? "border border-transparent hover:border-white/10"
: "border border-transparent cursor-not-allowed opacity-30"
}
`}
style={!active && accessible ? { color: "var(--text-secondary)" } : undefined}
>
{!accessible && <Lock size={7} className="opacity-50" />}
{p.label}
</button>
);
})}
</div>
</div>
{/* Metric selector */}
<div className="flex items-center gap-3 px-4 py-3 border-b" style={{ borderColor: "var(--border)" }}>
{METRICS.map(m => (
<button
key={m.value}
onClick={() => setMetric(m.value)}
className={`flex items-center gap-1.5 text-[9px] font-mono tracking-widest uppercase transition-all pb-0.5 ${
metric === m.value ? "border-b-2" : ""
}`}
style={metric === m.value
? { color: m.color, borderColor: m.color }
: { color: "var(--text-secondary)" }}
>
<span
className="w-1.5 h-1.5 rounded-full"
style={{ background: metric === m.value ? m.color : "var(--border)" }}
/>
{m.label}
</button>
))}
{/* Delta badge */}
{delta && !loading && (
<div className="ml-auto flex items-center gap-1.5 text-[9px] font-mono">
{Number(delta.diff) > 0 ? (
<TrendingUp size={11} className="text-emerald-400" />
) : Number(delta.diff) < 0 ? (
<TrendingDown size={11} className="text-red-400" />
) : (
<Minus size={11} style={{ color: "var(--text-secondary)" }} />
)}
<span style={{ color: Number(delta.diff) > 0 ? "#34d399" : Number(delta.diff) < 0 ? "#f87171" : "var(--text-secondary)" }}>
{Number(delta.diff) > 0 ? "+" : ""}{formatValue(delta.diff)}
{delta.pct && ` (${Number(delta.diff) > 0 ? "+" : ""}${delta.pct}%)`}
</span>
<span className="tracking-widest uppercase" style={{ color: "var(--text-secondary)" }}>sur la période</span>
</div>
)}
</div>
<div className="px-2 pt-4 pb-2" style={{ height: 240 }}>
{loading ? (
<div className="flex items-center justify-center h-full gap-2 font-mono text-[10px] tracking-widest uppercase animate-pulse"
style={{ color: "var(--text-secondary)" }}>
Chargement...
</div>
) : error ? (
<div className="flex items-center justify-center h-full font-mono text-[10px] tracking-widest uppercase text-red-400/60">
{error}
</div>
) : snapshots.length < 2 ? (
<div className="flex flex-col items-center justify-center h-full gap-2">
<span className="font-mono text-[10px] tracking-widest uppercase" style={{ color: "var(--text-secondary)" }}>
Pas encore assez de données
</span>
<span className="font-mono text-[9px] tracking-widest uppercase" style={{ color: "var(--text-secondary)", opacity: 0.4 }}>
Les snapshots s'accumulent toutes les heures
</span>
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id={metricConfig.gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={metricConfig.color} stopOpacity={0.15} />
<stop offset="95%" stopColor={metricConfig.color} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="2 4"
stroke="var(--border)"
strokeOpacity={0.5}
vertical={false}
/>
<XAxis
dataKey="date"
tickFormatter={d => formatDate(d, period)}
tick={{ fill: "var(--text-secondary)", fontSize: 9, fontFamily: "monospace" }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
tickFormatter={formatValue}
tick={{ fill: "var(--text-secondary)", fontSize: 9, fontFamily: "monospace" }}
tickLine={false}
axisLine={false}
width={40}
/>
<Tooltip
content={<CustomTooltip period={period} />}
cursor={{ stroke: "var(--border)", strokeWidth: 1 }}
/>
<Area
type="monotone"
dataKey={metric}
name={metricConfig.label}
stroke={metricConfig.color}
strokeWidth={1.5}
fill={`url(#${metricConfig.gradientId})`}
dot={false}
activeDot={{ r: 3, fill: metricConfig.color, strokeWidth: 0 }}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
{!canAccess(plan, PERIOD_CONFIG.find(p => p.value === period)!.requiredPlan) && (
<div className="mx-4 mb-4 px-4 py-3 border border-pink-500/20 rounded-sm bg-pink-500/5 flex items-center justify-between">
<span className="font-mono text-[10px] tracking-widest text-pink-400/60 uppercase">
<Lock size={9} className="inline mr-1.5 mb-0.5" />
Disponible en plan Pro
</span>
<a
href="/pricing"
className="font-mono text-[9px] tracking-widest uppercase text-pink-300 hover:text-pink-200 transition-colors border border-pink-500/30 hover:border-pink-500/60 px-2.5 py-1 rounded-sm"
>
Upgrader
</a>
</div>
)}
</div>
);
}