Files
office_translator/frontend/src/app/(app)/settings/subscription/page.tsx
Sepehr Ramezani 26bd096a06 feat: production deployment - full update with providers, admin, glossaries, pricing, tests
Major changes across backend, frontend, infrastructure:
- Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud)
- Admin panel: user management, pricing, settings
- Glossary system with CSV import/export
- Subscription and tier quota management
- Security hardening (rate limiting, API key auth, path traversal fixes)
- Docker compose for dev, prod, and IONOS deployment
- Alembic migrations for new tables
- Frontend: dashboard, pricing page, landing page, i18n (en/fr)
- Test suite and verification scripts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-25 15:01:47 +02:00

695 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { API_BASE } from "@/lib/config";
import {
Crown, Zap, Sparkles, Building2, Rocket, BadgeCheck,
ArrowRight, AlertTriangle, CheckCircle2, XCircle,
BarChart3, FileText, Layers, Brain, CreditCard,
RefreshCw, ExternalLink, ChevronRight, Info,
TrendingUp, Calendar, Gauge
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
/* ─────────────────────────────────────────────
Types
───────────────────────────────────────────── */
interface UserInfo {
id: string;
email: string;
name: string;
plan: string;
subscription_status: string;
docs_translated_this_month: number;
pages_translated_this_month: number;
api_calls_this_month: number;
extra_credits: number;
subscription_ends_at?: string;
cancel_at_period_end?: boolean;
}
interface UsageLimits {
plan: string;
docs_used: number;
docs_limit: number;
pages_used: number;
pages_limit: number;
api_calls_used: number;
api_calls_limit: number;
can_translate: boolean;
upgrade_required: boolean;
extra_credits: number;
}
interface Plan {
id: string;
name: string;
price_monthly: number;
price_yearly: number;
docs_per_month: number;
max_pages_per_doc: number;
max_file_size_mb: number;
features: string[];
ai_translation: boolean;
ai_tier?: string;
api_access: boolean;
priority_processing: boolean;
team_seats?: number;
popular?: boolean;
badge?: string;
description?: string;
}
/* ─────────────────────────────────────────────
Helpers
───────────────────────────────────────────── */
const PLAN_ICONS: Record<string, any> = {
free: Sparkles, starter: Zap, pro: Crown, business: Building2, enterprise: Rocket,
};
const PLAN_COLORS: Record<string, string> = {
free: "from-slate-600 to-slate-700",
starter: "from-blue-600 to-blue-700",
pro: "from-violet-600 to-violet-700",
business: "from-emerald-600 to-emerald-700",
enterprise: "from-amber-600 to-amber-700",
};
const PLAN_LABELS: Record<string, string> = {
free: "Gratuit", starter: "Starter", pro: "Pro",
business: "Business", enterprise: "Entreprise",
};
function pct(used: number, limit: number) {
if (limit === -1) return 0;
return Math.min(100, Math.round((used / limit) * 100));
}
function fmtLimit(val: number) {
return val === -1 ? "Illimité" : String(val);
}
function UsageBar({
label, used, limit, icon,
}: { label: string; used: number; limit: number; icon: React.ReactNode }) {
const p = pct(used, limit);
const isUnlimited = limit === -1;
return (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
{icon}
{label}
</div>
<span className={cn(
"font-mono text-xs",
isUnlimited ? "text-emerald-500" :
p >= 90 ? "text-destructive" : p >= 70 ? "text-warning" : "text-muted-foreground"
)}>
{isUnlimited ? "∞" : `${used} / ${limit}`}
</span>
</div>
{!isUnlimited && (
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all duration-700",
p >= 90 ? "bg-destructive" : p >= 70 ? "bg-warning" : "bg-accent"
)}
style={{ width: `${p}%` }}
/>
</div>
)}
</div>
);
}
/* ─────────────────────────────────────────────
Main component
───────────────────────────────────────────── */
export default function SubscriptionPage() {
const router = useRouter();
const searchParams = useSearchParams();
const targetPlan = searchParams.get("plan");
const [user, setUser] = useState<UserInfo | null>(null);
const [usage, setUsage] = useState<UsageLimits | null>(null);
const [plans, setPlans] = useState<Plan[]>([]);
const [isYearly, setIsYearly] = useState(false);
const [loadingPortal, setLoadingPortal] = useState(false);
const [cancelConfirm, setCancelConfirm] = useState(false);
const [statusMsg, setStatusMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
const [loading, setLoading] = useState(true);
const [isProcessingCheckout, setIsProcessingCheckout] = useState(false);
const token = typeof window !== "undefined" ? localStorage.getItem("token") : null;
const authHeaders = { Authorization: `Bearer ${token}` };
const fetchData = useCallback(async () => {
if (!token) { router.push("/auth/login?redirect=/settings/subscription"); return; }
try {
const [meRes, usageRes, plansRes] = await Promise.all([
fetch(`${API_BASE}/api/v1/auth/me`, { headers: authHeaders }),
fetch(`${API_BASE}/api/v1/auth/usage`, { headers: authHeaders }),
fetch(`${API_BASE}/api/v1/auth/plans`),
]);
if (meRes.ok) {
const j = await meRes.json();
setUser(j.data ?? j);
}
if (usageRes.ok) {
const j = await usageRes.json();
setUsage(j.data ?? j);
}
if (plansRes.ok) {
const j = await plansRes.json();
const d = j.data ?? j;
if (Array.isArray(d.plans)) setPlans(d.plans);
}
} catch {
// ignore
} finally {
setLoading(false);
}
}, [token]);
useEffect(() => { fetchData(); }, [fetchData]);
const handleBillingPortal = async () => {
setLoadingPortal(true);
try {
const res = await fetch(`${API_BASE}/api/v1/auth/billing-portal`, { headers: authHeaders });
const j = await res.json();
const url = j.data?.url ?? j.url;
if (url) window.open(url, "_blank");
else setStatusMsg({ type: "err", text: "Portail de facturation non disponible pour le moment." });
} catch {
setStatusMsg({ type: "err", text: "Impossible d'accéder au portail de facturation." });
} finally {
setLoadingPortal(false);
}
};
const handleCancel = async () => {
if (!cancelConfirm) { setCancelConfirm(true); return; }
try {
const res = await fetch(`${API_BASE}/api/v1/auth/cancel-subscription`, {
method: "POST",
headers: authHeaders,
});
if (res.ok) {
setStatusMsg({ type: "ok", text: "Abonnement annulé. Vous conservez l'accès jusqu'à la fin de la période en cours." });
setCancelConfirm(false);
fetchData();
} else {
setStatusMsg({ type: "err", text: "Erreur lors de l'annulation. Réessayez ou contactez le support." });
}
} catch {
setStatusMsg({ type: "err", text: "Erreur réseau." });
}
};
const handleSubscribe = async (planId: string) => {
if (planId === "enterprise") {
window.location.href = "mailto:contact@votre-domaine.com?subject=Offre Enterprise";
return;
}
setStatusMsg({
type: "ok",
text: `Création de votre session de paiement pour le forfait ${PLAN_LABELS[planId] ?? planId}`,
});
try {
const res = await fetch(`${API_BASE}/api/v1/auth/create-checkout`, {
method: "POST",
headers: { ...authHeaders, "Content-Type": "application/json" },
body: JSON.stringify({ plan: planId, billing_period: isYearly ? "yearly" : "monthly" }),
});
const data = await res.json();
if (!res.ok) {
const msg = data.message ?? data.error ?? "Erreur lors de la création de la session de paiement.";
setStatusMsg({ type: "err", text: msg });
return;
}
const url = data.data?.url ?? data.url;
if (url) {
window.location.replace(url);
} else {
await handleBillingPortal();
}
} catch {
setStatusMsg({ type: "err", text: "Erreur réseau lors de la création de la session de paiement." });
} finally {
setIsProcessingCheckout(false);
}
};
// Handle targetPlan from URL query param automatically (MUST be after handleSubscribe declaration)
useEffect(() => {
if (targetPlan && plans.length > 0 && !loading && !isProcessingCheckout) {
const p = plans.find(plan => plan.id === targetPlan);
if (p && user?.plan !== targetPlan) {
setIsProcessingCheckout(true);
// Clean URL to prevent infinite re-triggering
router.replace("/settings/subscription", { scroll: false });
handleSubscribe(targetPlan);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [targetPlan, plans, loading, user]);
const handleCreditsCheckout = async (packageIndex: number) => {
setStatusMsg({
type: "ok",
text: "Préparation de l'achat de crédits...",
});
try {
const res = await fetch(`${API_BASE}/api/v1/auth/create-credits-checkout`, {
method: "POST",
headers: { ...authHeaders, "Content-Type": "application/json" },
body: JSON.stringify({ package_index: packageIndex }),
});
const data = await res.json();
const url = data.data?.url ?? data.url;
if (url) {
window.location.replace(url);
} else {
setTimeout(() => handleBillingPortal(), 1000);
}
} catch {
setStatusMsg({ type: "err", text: "Erreur lors de la création de la session de paiement." });
}
};
if (loading || isProcessingCheckout) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<RefreshCw className="w-8 h-8 text-violet-400 animate-spin" />
</div>
);
}
const currentPlanId = user?.plan ?? "free";
const currentPlanLabel = PLAN_LABELS[currentPlanId] ?? currentPlanId;
const Icon = PLAN_ICONS[currentPlanId] ?? Sparkles;
const gradient = PLAN_COLORS[currentPlanId] ?? PLAN_COLORS.free;
const currentPlanData = plans.find((p) => p.id === currentPlanId);
const otherPlans = plans.filter((p) => p.id !== currentPlanId);
const upgradePlans = otherPlans.filter((p) => {
const order = ["free", "starter", "pro", "business", "enterprise"];
return order.indexOf(p.id) > order.indexOf(currentPlanId);
});
const downgradePlans = otherPlans.filter((p) => {
const order = ["free", "starter", "pro", "business", "enterprise"];
return order.indexOf(p.id) < order.indexOf(currentPlanId);
});
return (
<div className="max-w-4xl mx-auto px-4 py-10 space-y-8">
<div>
<h1 className="text-3xl font-bold text-foreground">Mon abonnement</h1>
<p className="text-muted-foreground mt-1">Gérez votre forfait, votre usage et votre facturation.</p>
</div>
{/* Status message */}
{statusMsg && (
<div className={cn(
"flex items-start gap-3 p-4 rounded-xl border",
statusMsg.type === "ok"
? "bg-success/10 border-success/30 text-success"
: "bg-destructive/10 border-destructive/30 text-destructive"
)}>
{statusMsg.type === "ok"
? <CheckCircle2 className="w-5 h-5 flex-shrink-0 mt-0.5" />
: <XCircle className="w-5 h-5 flex-shrink-0 mt-0.5" />}
<span className="text-sm">{statusMsg.text}</span>
<button className="ml-auto text-muted-foreground hover:text-foreground" onClick={() => setStatusMsg(null)}></button>
</div>
)}
{/* ── Current plan card ── */}
<div className={cn("rounded-2xl p-1 bg-gradient-to-br", gradient)}>
<div className="bg-card/95 rounded-xl p-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className={cn("p-3 rounded-xl bg-gradient-to-br", gradient)}>
<Icon className="w-6 h-6 text-white" />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-foreground">Forfait {currentPlanLabel}</h2>
{user?.subscription_status && (
<Badge className={cn(
"text-xs",
user.subscription_status === "active" ? "bg-success/10 text-success border-success/30" :
user.subscription_status === "trialing" ? "bg-primary/10 text-primary border-primary/30" :
"bg-warning/10 text-warning border-warning/30"
)}>
{user.subscription_status === "active" ? "Actif" :
user.subscription_status === "trialing" ? "Essai" :
user.subscription_status === "canceled" ? "Annulé" :
user.subscription_status}
</Badge>
)}
</div>
{user?.subscription_ends_at && (
<p className="text-muted-foreground text-sm mt-0.5">
<Calendar className="w-3.5 h-3.5 inline mr-1" />
{user.cancel_at_period_end ? "Expire le " : "Renouvellement le "}
{new Date(user.subscription_ends_at).toLocaleDateString("fr-FR", { day: "2-digit", month: "long", year: "numeric" })}
</p>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
{currentPlanId !== "free" && (
<Button
variant="outline"
onClick={handleBillingPortal}
disabled={loadingPortal}
>
{loadingPortal ? <RefreshCw className="w-4 h-4 animate-spin mr-1" /> : <CreditCard className="w-4 h-4 mr-1" />}
Portail de facturation
<ExternalLink className="w-3.5 h-3.5 ml-1" />
</Button>
)}
{upgradePlans.length > 0 && (
<Button
className="bg-accent hover:bg-accent/90 text-accent-foreground"
onClick={() => router.push("/pricing")}
>
<TrendingUp className="w-4 h-4 mr-1" /> Upgrader
</Button>
)}
</div>
</div>
</div>
</div>
{/* ── Usage this month ── */}
{usage && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-accent" />
Utilisation ce mois
<span className="ml-auto text-xs text-muted-foreground font-normal">Remise à zéro chaque 1er du mois</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
<UsageBar
label="Documents traduits"
used={usage.docs_used}
limit={usage.docs_limit}
icon={<FileText className="w-4 h-4 text-muted-foreground" />}
/>
<UsageBar
label="Pages traduites"
used={usage.pages_used}
limit={usage.pages_limit}
icon={<Layers className="w-4 h-4 text-muted-foreground" />}
/>
{usage.api_calls_limit !== 0 && (
<UsageBar
label="Appels API"
used={usage.api_calls_used}
limit={usage.api_calls_limit}
icon={<Gauge className="w-4 h-4 text-muted-foreground" />}
/>
)}
{usage.extra_credits > 0 && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-warning/10 border border-warning/20 text-sm">
<Info className="w-4 h-4 text-warning flex-shrink-0" />
<span className="text-warning">
{usage.extra_credits} crédit{usage.extra_credits > 1 ? "s" : ""} supplémentaire{usage.extra_credits > 1 ? "s" : ""} disponible{usage.extra_credits > 1 ? "s" : ""}
</span>
</div>
)}
{usage.upgrade_required && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-destructive/10 border border-destructive/20 text-sm">
<AlertTriangle className="w-4 h-4 text-destructive flex-shrink-0" />
<span className="text-destructive">
Quota atteint. Achetez des crédits ou upgradez votre forfait pour continuer.
</span>
<Button size="sm" className="ml-auto bg-destructive hover:bg-destructive/90 text-destructive-foreground flex-shrink-0" onClick={() => router.push("/pricing")}>
Upgrader
</Button>
</div>
)}
</CardContent>
</Card>
)}
{/* ── Plan features recap ── */}
{currentPlanData && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<BadgeCheck className="w-5 h-5 text-success" />
Inclus dans votre forfait
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid sm:grid-cols-2 gap-2">
{currentPlanData.features.map((f, i) => (
<div key={i} className="flex items-start gap-2 text-sm">
<CheckCircle2 className="w-4 h-4 text-success flex-shrink-0 mt-0.5" />
<span className="text-muted-foreground">{f}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* ── Upgrade options ── */}
{upgradePlans.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-accent" />
Passer à un forfait supérieur
</h3>
{/* Billing toggle */}
<div className="inline-flex items-center gap-2 bg-muted/60 border border-border/50 rounded-full p-1 mb-4">
<button
onClick={() => setIsYearly(false)}
className={cn("px-4 py-1.5 rounded-full text-xs font-medium transition-all", !isYearly ? "bg-foreground text-background" : "text-muted-foreground")}
>Mensuel</button>
<button
onClick={() => setIsYearly(true)}
className={cn("px-4 py-1.5 rounded-full text-xs font-medium transition-all flex items-center gap-1.5", isYearly ? "bg-foreground text-background" : "text-muted-foreground")}
>
Annuel
<span className="bg-success text-success-foreground text-xs px-1.5 py-0.5 rounded-full">20 %</span>
</button>
</div>
<div className="grid sm:grid-cols-2 gap-4">
{upgradePlans.map((plan) => {
const PIcon = PLAN_ICONS[plan.id] ?? Zap;
const grad = PLAN_COLORS[plan.id] ?? PLAN_COLORS.starter;
const price = plan.price_monthly === -1
? null
: isYearly
? (plan.price_yearly / 12).toFixed(2)
: plan.price_monthly.toFixed(2);
return (
<div
key={plan.id}
className={cn(
"relative rounded-2xl border bg-card overflow-hidden",
plan.popular ? "border-accent/50" : "border-border/40"
)}
>
{plan.badge && (
<div className="absolute top-3 right-3">
<Badge className="bg-accent text-accent-foreground text-xs">{plan.badge}</Badge>
</div>
)}
<div className={cn("p-4 bg-gradient-to-br", grad)}>
<div className="flex items-center gap-2">
<PIcon className="w-5 h-5 text-white" />
<span className="font-bold text-white">{plan.name}</span>
</div>
<div className="mt-2 flex items-end gap-1">
{price === null ? (
<span className="text-2xl font-bold text-white">Sur devis</span>
) : (
<>
<span className="text-2xl font-bold text-white">{price} </span>
<span className="text-white/70 text-sm pb-0.5">/mois</span>
</>
)}
</div>
</div>
<div className="p-4 space-y-2">
{plan.features.slice(0, 4).map((f, i) => (
<div key={i} className="flex items-start gap-2 text-xs text-muted-foreground">
<CheckCircle2 className="w-3.5 h-3.5 text-success flex-shrink-0 mt-0.5" />
{f}
</div>
))}
{plan.features.length > 4 && (
<p className="text-xs text-muted-foreground">+{plan.features.length - 4} autres avantages</p>
)}
<button
onClick={() => handleSubscribe(plan.id)}
className={cn(
"mt-3 w-full py-2 rounded-xl text-sm font-semibold text-white flex items-center justify-center gap-2 transition-all",
`bg-gradient-to-r ${grad} hover:opacity-90`
)}
>
Passer au forfait {plan.name} <ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
);
})}
</div>
</div>
)}
{/* ── Buy credits ── */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<CreditCard className="w-5 h-5 text-warning" />
Crédits supplémentaires
</CardTitle>
<p className="text-muted-foreground text-sm">1 crédit = 1 page traduite. Utilisables sans expiration.</p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ credits: 50, price: 5 },
{ credits: 150, price: 12, popular: true },
{ credits: 500, price: 35 },
{ credits: 1000, price: 60 },
].map((pkg, i) => (
<div
key={i}
className={cn(
"relative p-4 rounded-xl border text-center",
pkg.popular
? "border-warning/50 bg-warning/10"
: "border-border/40 bg-muted/20"
)}
>
{pkg.popular && (
<div className="absolute -top-2 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-warning text-warning-foreground text-xs rounded-full font-bold whitespace-nowrap">
Meilleure valeur
</div>
)}
<div className="text-xl font-bold text-foreground">{pkg.credits}</div>
<div className="text-muted-foreground text-xs mb-2">crédits</div>
<div className="text-lg font-bold text-foreground">{pkg.price} </div>
<div className="text-muted-foreground text-xs mb-3">{((pkg.price / pkg.credits) * 100).toFixed(0)} cts/crédit</div>
<button
onClick={() => handleCreditsCheckout(i)}
className="w-full py-1.5 rounded-lg bg-muted hover:bg-muted/80 text-foreground text-xs transition-all"
>
Acheter
</button>
</div>
))}
</div>
</CardContent>
</Card>
{/* ── Downgrade / Cancel ── */}
{currentPlanId !== "free" && (
<Card className="border-destructive/20">
<CardHeader className="pb-3">
<CardTitle className="text-lg text-destructive flex items-center gap-2">
<AlertTriangle className="w-5 h-5" />
Zone de danger
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{downgradePlans.length > 0 && (
<div>
<p className="text-sm text-muted-foreground mb-2">Rétrograder vers un forfait inférieur :</p>
<div className="flex flex-wrap gap-2">
{downgradePlans.map((p) => (
<button
key={p.id}
onClick={() => handleSubscribe(p.id)}
className="px-4 py-2 rounded-lg bg-muted hover:bg-secondary text-muted-foreground text-sm border border-border/50 transition-all"
>
Passer à {p.name}
</button>
))}
</div>
</div>
)}
<div className="border-t border-border/30 pt-4">
{!cancelConfirm ? (
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground">Annuler mon abonnement</p>
<p className="text-xs text-muted-foreground mt-0.5">Vous conservez l'accès jusqu'à la fin de la période payée.</p>
</div>
<Button
variant="outline"
className="border-destructive/50 text-destructive hover:bg-destructive/10"
onClick={() => setCancelConfirm(true)}
>
Annuler l'abonnement
</Button>
</div>
) : (
<div className="p-4 rounded-xl bg-destructive/10 border border-destructive/30 space-y-3">
<p className="text-sm text-destructive font-medium">
⚠️ Confirmer l'annulation ?
</p>
<p className="text-xs text-muted-foreground">
Votre abonnement sera annulé et vous reviendrez au forfait Gratuit à la fin de la période en cours.
Vos documents traduits resteront accessibles pendant 30 jours.
</p>
<div className="flex gap-2">
<Button
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
onClick={handleCancel}
>
Oui, annuler mon abonnement
</Button>
<Button
variant="outline"
onClick={() => setCancelConfirm(false)}
>
Non, conserver
</Button>
</div>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Link to full pricing */}
<div className="text-center">
<button
onClick={() => router.push("/pricing")}
className="text-accent hover:text-accent/80 text-sm flex items-center gap-1 mx-auto"
>
Voir tous les forfaits en détail <ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
);
}