342 lines
16 KiB
TypeScript
342 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { Music2, Link2, Unlink, Loader2, AlertTriangle, Eye, EyeOff } from "lucide-react";
|
|
import { useEffect, useState } from "react";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
|
|
import StatCard from "@/components/StatCard";
|
|
import StatsChart from "@/components/StatsCharts";
|
|
|
|
interface TikTokStats {
|
|
followers: number;
|
|
likes: number;
|
|
videoCount: number;
|
|
views: number;
|
|
displayName: string;
|
|
avatarUrl: string;
|
|
plan: "free" | "pro" | "elite" | "team";
|
|
}
|
|
|
|
type CardId = "followers" | "likes" | "videos" | "views" | "ratio";
|
|
|
|
const defaultVisibleCards: Record<CardId, boolean> = {
|
|
followers: true,
|
|
likes: true,
|
|
videos: true,
|
|
views: true,
|
|
ratio: true,
|
|
};
|
|
|
|
const visibleCardsStorageKey = "tiktok.visibleCards";
|
|
|
|
function parseVisibleCards(value: string | null): Record<CardId, boolean> {
|
|
if (!value) return defaultVisibleCards;
|
|
|
|
try {
|
|
const parsed = JSON.parse(value) as Partial<Record<CardId, unknown>>;
|
|
return {
|
|
followers: typeof parsed.followers === "boolean" ? parsed.followers : defaultVisibleCards.followers,
|
|
likes: typeof parsed.likes === "boolean" ? parsed.likes : defaultVisibleCards.likes,
|
|
videos: typeof parsed.videos === "boolean" ? parsed.videos : defaultVisibleCards.videos,
|
|
views: typeof parsed.views === "boolean" ? parsed.views : defaultVisibleCards.views,
|
|
ratio: typeof parsed.ratio === "boolean" ? parsed.ratio : defaultVisibleCards.ratio,
|
|
};
|
|
} catch {
|
|
return defaultVisibleCards;
|
|
}
|
|
}
|
|
|
|
export default function TikTokPage() {
|
|
const [stats, setStats] = useState<TikTokStats | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [disconnecting, setDisconnecting] = useState(false);
|
|
const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);
|
|
const [visibleCards, setVisibleCards] = useState<Record<CardId, boolean>>(defaultVisibleCards);
|
|
const [visibleCardsHydrated, setVisibleCardsHydrated] = useState(false);
|
|
const searchParams = useSearchParams();
|
|
const router = useRouter();
|
|
|
|
async function loadStats() {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const res = await fetch("/api/tiktok/stats");
|
|
if (res.status === 404) {
|
|
setStats(null);
|
|
} else if (!res.ok) {
|
|
const data = await res.json();
|
|
setError(data.error ?? "Erreur inconnue");
|
|
} else {
|
|
const data = await res.json();
|
|
setStats(data);
|
|
}
|
|
} catch {
|
|
setError("Erreur réseau");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
const persisted = parseVisibleCards(window.localStorage.getItem(visibleCardsStorageKey));
|
|
setVisibleCards(persisted);
|
|
setVisibleCardsHydrated(true);
|
|
loadStats();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!visibleCardsHydrated) return;
|
|
window.localStorage.setItem(visibleCardsStorageKey, JSON.stringify(visibleCards));
|
|
}, [visibleCards, visibleCardsHydrated]);
|
|
|
|
async function handleDisconnect() {
|
|
if (disconnecting) return;
|
|
setDisconnecting(true);
|
|
try {
|
|
await fetch("/api/tiktok/disconnect", { method: "POST" });
|
|
setStats(null);
|
|
setShowDisconnectConfirm(false);
|
|
router.replace("/tiktok");
|
|
} finally {
|
|
setDisconnecting(false);
|
|
}
|
|
}
|
|
|
|
const urlError = searchParams.get("error");
|
|
|
|
const cards = stats
|
|
? [
|
|
{
|
|
id: "followers" as const,
|
|
label: "Followers",
|
|
value: stats.followers.toLocaleString("fr-FR"),
|
|
sub: "Abonnés totaux",
|
|
accent: "purple" as const,
|
|
},
|
|
{
|
|
id: "likes" as const,
|
|
label: "Likes totaux",
|
|
value: stats.likes.toLocaleString("fr-FR"),
|
|
sub: "Cumul likes",
|
|
accent: "red" as const,
|
|
},
|
|
{
|
|
id: "videos" as const,
|
|
label: "Vidéos publiées",
|
|
value: stats.videoCount.toLocaleString("fr-FR"),
|
|
sub: "Vidéos au total",
|
|
accent: "blue" as const,
|
|
},
|
|
{
|
|
id: "views" as const,
|
|
label: "Vues vidéos",
|
|
value: stats.views.toLocaleString("fr-FR"),
|
|
sub: "Vues totales des vidéos",
|
|
accent: "green" as const,
|
|
},
|
|
{
|
|
id: "ratio" as const,
|
|
label: "Ratio likes/vidéo",
|
|
value: stats.videoCount > 0 ? Math.round(stats.likes / stats.videoCount).toLocaleString("fr-FR") : "—",
|
|
sub: "Moy. likes par vidéo",
|
|
accent: "gold" as const,
|
|
},
|
|
]
|
|
: [];
|
|
|
|
const shownCards = cards.filter((card) => visibleCards[card.id]);
|
|
const hiddenCards = cards.filter((card) => !visibleCards[card.id]);
|
|
|
|
return (
|
|
<div>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-8">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 rounded-sm bg-pink-500/10 border border-pink-500/20">
|
|
<Music2 size={18} className="text-pink-400" />
|
|
</div>
|
|
<div>
|
|
<div className="text-[9px] font-mono tracking-[0.3em] text-pink-400/70 uppercase mb-0.5">Plateforme</div>
|
|
<h1 className="text-2xl font-black tracking-widest uppercase" style={{ color: "var(--text-primary)" }}>TikTok</h1>
|
|
</div>
|
|
</div>
|
|
|
|
{stats && !loading && (
|
|
<button
|
|
onClick={() => setShowDisconnectConfirm(true)}
|
|
disabled={disconnecting}
|
|
className="flex items-center gap-2 px-4 py-2 text-[10px] font-mono tracking-widest uppercase border border-red-500/30 text-red-400/70 hover:text-red-400 hover:border-red-500/60 rounded-sm transition-colors disabled:opacity-50"
|
|
>
|
|
{disconnecting ? <Loader2 size={12} className="animate-spin" /> : <Unlink size={12} />}
|
|
Déconnecter
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Erreur URL */}
|
|
{urlError && (
|
|
<div className="flex items-center gap-2 mb-6 px-4 py-3 bg-red-500/5 border border-red-500/20 rounded-sm text-red-400 text-xs font-mono">
|
|
<AlertTriangle size={14} />
|
|
{urlError === "access_denied" && "Accès refusé par TikTok."}
|
|
{urlError === "token_exchange" && "Erreur lors de l'échange du token."}
|
|
{urlError === "session_error" && "Erreur de session."}
|
|
{!["access_denied", "token_exchange", "session_error"].includes(urlError) && `Erreur : ${urlError}`}
|
|
</div>
|
|
)}
|
|
|
|
{/* Chargement */}
|
|
{loading && (
|
|
<div className="flex items-center justify-center min-h-[200px] gap-3 font-mono text-xs" style={{ color: "var(--text-secondary)" }}>
|
|
<Loader2 size={16} className="animate-spin text-pink-400/60" />
|
|
Chargement...
|
|
</div>
|
|
)}
|
|
|
|
{/* Connecté : stats */}
|
|
{!loading && stats && (
|
|
<>
|
|
{stats.displayName && (
|
|
<div className="flex items-center gap-3 mb-6 px-4 py-3 bg-pink-500/8 border border-pink-500/25 rounded-sm">
|
|
{stats.avatarUrl && (
|
|
<img src={stats.avatarUrl} alt="avatar" className="w-8 h-8 rounded-full object-cover border border-pink-500/30" />
|
|
)}
|
|
<span className="text-pink-300 font-mono text-sm font-bold">{stats.displayName}</span>
|
|
<span className="text-[9px] font-mono tracking-widest text-pink-400/50 uppercase ml-auto">Connecté</span>
|
|
</div>
|
|
)}
|
|
|
|
{hiddenCards.length > 0 && (
|
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
|
{hiddenCards.map((card) => (
|
|
<button
|
|
key={card.id}
|
|
type="button"
|
|
onClick={() => setVisibleCards((prev) => ({ ...prev, [card.id]: true }))}
|
|
className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-mono tracking-widest uppercase border rounded-sm transition-colors hover:text-pink-300"
|
|
style={{ borderColor: "var(--border)", color: "var(--text-secondary)" }}
|
|
aria-label={`Afficher la carte ${card.label}`}
|
|
>
|
|
<Eye size={12} />
|
|
{card.label}
|
|
</button>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={() => setVisibleCards(defaultVisibleCards)}
|
|
className="px-2 py-1 text-[10px] font-mono tracking-widest uppercase border rounded-sm transition-colors hover:text-pink-300"
|
|
style={{ borderColor: "var(--border)", color: "var(--text-secondary)" }}
|
|
>
|
|
Tout afficher
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{shownCards.length > 0 ? (
|
|
<div id="tiktok-stat-cards" className="grid grid-cols-2 xl:grid-cols-5 gap-4 mb-8">
|
|
{shownCards.map((card) => (
|
|
<div key={card.id} className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setVisibleCards((prev) => ({ ...prev, [card.id]: false }))}
|
|
className="absolute top-2 right-2 z-10 p-1 rounded-sm border transition-colors"
|
|
style={{ borderColor: "var(--border)", color: "var(--text-secondary)", background: "var(--surface)" }}
|
|
aria-label={`Masquer la carte ${card.label}`}
|
|
>
|
|
<EyeOff size={12} />
|
|
</button>
|
|
<StatCard
|
|
label={card.label}
|
|
value={card.value}
|
|
sub={card.sub}
|
|
accent={card.accent}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="border rounded-sm p-6 mb-8 text-center" style={{ background: "var(--surface)", borderColor: "var(--border)" }}>
|
|
<p className="font-mono text-xs tracking-widest uppercase mb-3" style={{ color: "var(--text-secondary)" }}>
|
|
Toutes les cartes sont masquées
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => setVisibleCards(defaultVisibleCards)}
|
|
className="px-3 py-2 text-[10px] font-mono tracking-widest uppercase border rounded-sm transition-colors hover:text-pink-300"
|
|
style={{ borderColor: "var(--border)", color: "var(--text-secondary)" }}
|
|
>
|
|
Afficher toutes les cartes
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Graph */}
|
|
<StatsChart plan={stats.plan ?? "free"} />
|
|
</>
|
|
)}
|
|
|
|
{!loading && !stats && !error && (
|
|
<div className="border rounded-sm p-12 flex flex-col items-center justify-center gap-5 min-h-[240px]"
|
|
style={{ background: "var(--surface)", borderColor: "var(--border)" }}>
|
|
<Music2 size={36} className="text-pink-400/30" />
|
|
<p className="font-mono text-xs tracking-widest text-center uppercase" style={{ color: "var(--text-secondary)" }}>
|
|
Connectez votre compte TikTok<br />pour afficher vos statistiques
|
|
</p>
|
|
<a
|
|
href="/api/tiktok/connect"
|
|
className="flex items-center gap-2 px-5 py-2.5 text-[10px] font-mono tracking-widest uppercase bg-pink-500/10 border border-pink-500/40 text-pink-300 hover:bg-pink-500/20 hover:border-pink-500/60 rounded-sm transition-colors"
|
|
>
|
|
<Link2 size={13} />
|
|
Connecter TikTok
|
|
</a>
|
|
</div>
|
|
)}
|
|
|
|
{!loading && error && (
|
|
<div className="border border-red-500/25 rounded-sm p-8 flex flex-col items-center justify-center gap-3 min-h-[200px]"
|
|
style={{ background: "var(--surface)" }}>
|
|
<AlertTriangle size={28} className="text-red-400/60" />
|
|
<p className="text-red-400/80 font-mono text-xs tracking-widest text-center uppercase">{error}</p>
|
|
<button onClick={loadStats} className="text-[10px] font-mono tracking-widest uppercase transition-colors hover:text-pink-400"
|
|
style={{ color: "var(--text-secondary)" }}>
|
|
Réessayer
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{showDisconnectConfirm && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4">
|
|
<div className="w-full max-w-md rounded-sm border p-5" style={{ background: "var(--surface)", borderColor: "var(--border)" }}>
|
|
<div className="flex items-center gap-2 mb-3 text-red-400">
|
|
<AlertTriangle size={16} />
|
|
<p className="text-xs font-mono tracking-widest uppercase">Confirmer la déconnexion</p>
|
|
</div>
|
|
<p className="text-sm mb-5" style={{ color: "var(--text-secondary)" }}>
|
|
Êtes-vous sûr de vouloir déconnecter votre compte TikTok ?
|
|
</p>
|
|
<div className="flex items-center justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowDisconnectConfirm(false)}
|
|
disabled={disconnecting}
|
|
className="px-3 py-2 text-[10px] font-mono tracking-widest uppercase border rounded-sm transition-colors disabled:opacity-50"
|
|
style={{ borderColor: "var(--border)", color: "var(--text-secondary)" }}
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleDisconnect}
|
|
disabled={disconnecting}
|
|
className="flex items-center gap-2 px-3 py-2 text-[10px] font-mono tracking-widest uppercase border border-red-500/40 text-red-300 hover:text-red-200 hover:border-red-500/70 rounded-sm transition-colors disabled:opacity-50"
|
|
>
|
|
{disconnecting ? <Loader2 size={12} className="animate-spin" /> : <Unlink size={12} />}
|
|
Confirmer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |