278 lines
13 KiB
TypeScript
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>
|
|
);
|
|
} |