All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m51s
- Gamme officielle : Essentielle (Pro) = deepseek-v4-flash, glm-5.3-flash, minimax-m3 ; Premium (Business) = claude-sonnet-5, deepseek-v4-pro, glm-5.3 - Routage reel : le modele employe suit le palier IA du forfait (reglages admin > defaut du plan), garde anti-croisement de palier - Nouvelle page admin « Modeles & abonnements » (matrice forfaits/paliers, modele par defaut, catalogue OpenRouter) - Statistiques enrichies : revenus (30 j + total), MRR estime, credits, liste d'attente, paliers utilises - Nouvelle page admin « Marketing » : audiences avec compteurs, apercu, envoi test obligatoire, historique, desabonnement public + en-tetes List-Unsubscribe ; .gitignore pour les donnees d'execution - Interface (accueil + tarifs) alignee sur la gamme, 13 langues completes - Tests : routage par palier (fonction + tache), marketing, revenus en base
160 lines
5.5 KiB
TypeScript
160 lines
5.5 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { Euro, TrendingUp, Users, Cpu, Loader2 } from "lucide-react";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { useTranslationStore } from "@/lib/store";
|
|
import { API_BASE } from "@/lib/config";
|
|
import { useI18n } from "@/lib/i18n";
|
|
import type { AdminStatsResponse } from "./types";
|
|
|
|
const getToken = () => useTranslationStore.getState().settings.adminToken ?? "";
|
|
|
|
function formatMoney(value: number, currency: string): string {
|
|
const currencyLabel = currency === "EUR" ? "€" : currency === "USD" ? "$" : currency;
|
|
return `${value.toLocaleString("fr-FR", { maximumFractionDigits: 2 })} ${currencyLabel}`;
|
|
}
|
|
|
|
/**
|
|
* Cartes business (revenus, MRR estimé, crédits, liste d'attente, paliers IA)
|
|
* alimentées par GET /api/v1/admin/stats (champs revenue, mrr_estimated,
|
|
* waitlist_count, ai_tier_usage).
|
|
*/
|
|
export function RevenueOverview() {
|
|
const { t } = useI18n();
|
|
const [stats, setStats] = useState<AdminStatsResponse | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const load = async () => {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/api/v1/admin/stats`, {
|
|
headers: { Authorization: `Bearer ${getToken()}` },
|
|
});
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
const json = await response.json();
|
|
if (!cancelled) {
|
|
setStats(json.data ?? json);
|
|
setError(null);
|
|
}
|
|
} catch (e) {
|
|
if (!cancelled) setError(e instanceof Error ? e.message : "Erreur");
|
|
} finally {
|
|
if (!cancelled) setIsLoading(false);
|
|
}
|
|
};
|
|
load();
|
|
const interval = setInterval(load, 30000);
|
|
return () => {
|
|
cancelled = true;
|
|
clearInterval(interval);
|
|
};
|
|
}, []);
|
|
|
|
if (isLoading && !stats) {
|
|
return (
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<Card key={i}>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="h-8 w-20 animate-pulse rounded bg-muted" />
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !stats) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="flex items-center gap-2 py-4 text-sm text-red-500">
|
|
<Cpu className="size-4" /> {t("admin.stats.unavailable", { error: error ?? "" })}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const currency = stats.revenue?.currency ?? "EUR";
|
|
const tierTotal =
|
|
(stats.ai_tier_usage?.essential ?? 0) +
|
|
(stats.ai_tier_usage?.premium ?? 0) +
|
|
(stats.ai_tier_usage?.classic ?? 0) +
|
|
(stats.ai_tier_usage?.other ?? 0);
|
|
|
|
const cards = [
|
|
{
|
|
title: t("admin.stats.revenue30"),
|
|
icon: <Euro className="h-4 w-4 text-muted-foreground" />,
|
|
value: formatMoney(stats.revenue?.collected_30d ?? 0, currency),
|
|
sub: t("admin.stats.payments30", { count: stats.revenue?.payments_30d ?? 0 }),
|
|
},
|
|
{
|
|
title: t("admin.stats.revenueTotal"),
|
|
icon: <TrendingUp className="h-4 w-4 text-muted-foreground" />,
|
|
value: formatMoney(stats.revenue?.collected_total ?? 0, currency),
|
|
sub: t("admin.stats.includingCredits", {
|
|
amount: formatMoney(stats.revenue?.credits_purchased ?? 0, currency),
|
|
}),
|
|
},
|
|
{
|
|
title: t("admin.stats.mrr"),
|
|
icon: <TrendingUp className="h-4 w-4 text-muted-foreground" />,
|
|
value: formatMoney(stats.mrr_estimated?.total ?? 0, currency),
|
|
sub:
|
|
Object.entries(stats.mrr_estimated?.by_plan ?? {})
|
|
.map(([plan, mrr]) => `${plan} : ${formatMoney(mrr, currency)}`)
|
|
.join(" · ") || "—",
|
|
},
|
|
{
|
|
title: t("admin.stats.waitlist"),
|
|
icon: <Users className="h-4 w-4 text-muted-foreground" />,
|
|
value: String(stats.waitlist_count ?? 0),
|
|
sub: t("admin.stats.waitlistSub"),
|
|
},
|
|
{
|
|
title: t("admin.stats.aiTiers"),
|
|
icon: <Cpu className="h-4 w-4 text-muted-foreground" />,
|
|
value: `${stats.ai_tier_usage?.essential ?? 0} / ${stats.ai_tier_usage?.premium ?? 0}`,
|
|
sub: t("admin.stats.tiersSub", {
|
|
classic: stats.ai_tier_usage?.classic ?? 0,
|
|
other: stats.ai_tier_usage?.other ?? 0,
|
|
}),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
|
{cards.map((c) => (
|
|
<Card key={c.title}>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">{c.title}</CardTitle>
|
|
{c.icon}
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{c.value}</div>
|
|
<p className="truncate text-xs text-muted-foreground" title={c.sub}>
|
|
{c.sub}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
{tierTotal === 0 && (
|
|
<p className="mt-2 text-xs text-muted-foreground">{t("admin.stats.noTranslationsYet")}</p>
|
|
)}
|
|
{isLoading && (
|
|
<p className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
<Loader2 className="size-3 animate-spin" /> {t("admin.stats.refreshing")}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|