feat (TikTok): add a mockup of the graph of likes / views
This commit is contained in:
274
components/StatsCharts.tsx
Normal file
274
components/StatsCharts.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
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="bg-[#0f0f13] border border-white/10 rounded-sm px-3 py-2 font-mono text-[10px]">
|
||||
<div className="text-white/40 mb-1 tracking-widest uppercase">{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 text-white/30 hover:text-white/60 hover:border-white/10"
|
||||
: "border border-transparent text-white/15 cursor-not-allowed"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{!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"
|
||||
: "text-white/30 hover:text-white/60"
|
||||
}`}
|
||||
style={metric === m.value ? { color: m.color, borderColor: m.color } : {}}
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{ background: metric === m.value ? m.color : "rgba(255,255,255,0.15)" }}
|
||||
/>
|
||||
{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} className="text-white/30" />
|
||||
)}
|
||||
<span style={{ color: Number(delta.diff) > 0 ? "#34d399" : Number(delta.diff) < 0 ? "#f87171" : "rgba(255,255,255,0.3)" }}>
|
||||
{Number(delta.diff) > 0 ? "+" : ""}{formatValue(delta.diff)}
|
||||
{delta.pct && ` (${Number(delta.diff) > 0 ? "+" : ""}${delta.pct}%)`}
|
||||
</span>
|
||||
<span className="text-white/20 tracking-widest uppercase">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 text-white/20 uppercase">
|
||||
<span className="animate-pulse">Chargement...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center h-full font-mono text-[10px] tracking-widest text-red-400/50 uppercase">
|
||||
{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 text-white/20 uppercase">
|
||||
Pas encore assez de données
|
||||
</span>
|
||||
<span className="font-mono text-[9px] text-white/10 uppercase tracking-widest">
|
||||
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="rgba(255,255,255,0.04)"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={d => formatDate(d, period)}
|
||||
tick={{ fill: "rgba(255,255,255,0.2)", fontSize: 9, fontFamily: "monospace" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={formatValue}
|
||||
tick={{ fill: "rgba(255,255,255,0.2)", fontSize: 9, fontFamily: "monospace" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={40}
|
||||
/>
|
||||
<Tooltip
|
||||
content={<CustomTooltip period={period} />}
|
||||
cursor={{ stroke: "rgba(255,255,255,0.06)", 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user