feat: revue de code, doc CODE_REVIEW, forfaits 2026, traduction LLM, providers avec modèle
Made-with: Cursor
This commit is contained in:
630
frontend/src/app/(app)/settings/subscription/page.tsx
Normal file
630
frontend/src/app/(app)/settings/subscription/page.tsx
Normal file
@@ -0,0 +1,630 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
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-gray-300">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<span className={cn(
|
||||
"font-mono text-xs",
|
||||
isUnlimited ? "text-emerald-400" :
|
||||
p >= 90 ? "text-red-400" : p >= 70 ? "text-amber-400" : "text-gray-400"
|
||||
)}>
|
||||
{isUnlimited ? "∞" : `${used} / ${limit}`}
|
||||
</span>
|
||||
</div>
|
||||
{!isUnlimited && (
|
||||
<div className="h-1.5 bg-gray-700/50 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all duration-700",
|
||||
p >= 90 ? "bg-red-500" : p >= 70 ? "bg-amber-500" : "bg-violet-500"
|
||||
)}
|
||||
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 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/v1/auth/me", { headers: authHeaders }),
|
||||
fetch("/api/v1/auth/usage", { headers: authHeaders }),
|
||||
fetch("/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/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/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 = (planId: string) => {
|
||||
if (planId === "enterprise") {
|
||||
window.location.href = "mailto:contact@votre-domaine.com?subject=Offre Enterprise";
|
||||
return;
|
||||
}
|
||||
// In a real app, this would initiate a Stripe Checkout session
|
||||
// For now, redirect to billing portal or show info
|
||||
setStatusMsg({
|
||||
type: "ok",
|
||||
text: `Redirection vers Stripe pour activer le forfait ${PLAN_LABELS[planId] ?? planId}…`,
|
||||
});
|
||||
setTimeout(() => handleBillingPortal(), 1000);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
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-white">Mon abonnement</h1>
|
||||
<p className="text-gray-400 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-emerald-900/20 border-emerald-600/30 text-emerald-300"
|
||||
: "bg-red-900/20 border-red-600/30 text-red-300"
|
||||
)}>
|
||||
{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-gray-500 hover:text-white" onClick={() => setStatusMsg(null)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Current plan card ── */}
|
||||
<div className={cn("rounded-2xl p-1 bg-gradient-to-br", gradient)}>
|
||||
<div className="bg-gray-900/90 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-white">Forfait {currentPlanLabel}</h2>
|
||||
{user?.subscription_status && (
|
||||
<Badge className={cn(
|
||||
"text-xs",
|
||||
user.subscription_status === "active" ? "bg-emerald-600/20 text-emerald-300 border-emerald-600/30" :
|
||||
user.subscription_status === "trialing" ? "bg-blue-600/20 text-blue-300 border-blue-600/30" :
|
||||
"bg-amber-600/20 text-amber-300 border-amber-600/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-gray-400 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"
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/30"
|
||||
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-violet-600 hover:bg-violet-500"
|
||||
onClick={() => router.push("/pricing")}
|
||||
>
|
||||
<TrendingUp className="w-4 h-4 mr-1" /> Upgrader
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Usage this month ── */}
|
||||
{usage && (
|
||||
<Card className="bg-gray-900/60 border-gray-700/40">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-violet-400" />
|
||||
Utilisation ce mois
|
||||
<span className="ml-auto text-xs text-gray-500 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-gray-400" />}
|
||||
/>
|
||||
<UsageBar
|
||||
label="Pages traduites"
|
||||
used={usage.pages_used}
|
||||
limit={usage.pages_limit}
|
||||
icon={<Layers className="w-4 h-4 text-gray-400" />}
|
||||
/>
|
||||
{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-gray-400" />}
|
||||
/>
|
||||
)}
|
||||
{usage.extra_credits > 0 && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-sm">
|
||||
<Info className="w-4 h-4 text-amber-400 flex-shrink-0" />
|
||||
<span className="text-amber-300">
|
||||
{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-red-500/10 border border-red-500/20 text-sm">
|
||||
<AlertTriangle className="w-4 h-4 text-red-400 flex-shrink-0" />
|
||||
<span className="text-red-300">
|
||||
Quota atteint. Achetez des crédits ou upgradez votre forfait pour continuer.
|
||||
</span>
|
||||
<Button size="sm" className="ml-auto bg-red-600 hover:bg-red-500 flex-shrink-0" onClick={() => router.push("/pricing")}>
|
||||
Upgrader
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Plan features recap ── */}
|
||||
{currentPlanData && (
|
||||
<Card className="bg-gray-900/60 border-gray-700/40">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<BadgeCheck className="w-5 h-5 text-emerald-400" />
|
||||
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-emerald-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-gray-300">{f}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Upgrade options ── */}
|
||||
{upgradePlans.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-violet-400" />
|
||||
Passer à un forfait supérieur
|
||||
</h3>
|
||||
|
||||
{/* Billing toggle */}
|
||||
<div className="inline-flex items-center gap-2 bg-gray-800/60 border border-gray-700/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-white text-gray-900" : "text-gray-400")}
|
||||
>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-white text-gray-900" : "text-gray-400")}
|
||||
>
|
||||
Annuel
|
||||
<span className="bg-emerald-500 text-white 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-gray-900/60 overflow-hidden",
|
||||
plan.popular ? "border-violet-500/50" : "border-gray-700/40"
|
||||
)}
|
||||
>
|
||||
{plan.badge && (
|
||||
<div className="absolute top-3 right-3">
|
||||
<Badge className="bg-violet-600 text-white 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-gray-300">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-400 flex-shrink-0 mt-0.5" />
|
||||
{f}
|
||||
</div>
|
||||
))}
|
||||
{plan.features.length > 4 && (
|
||||
<p className="text-xs text-gray-500">+{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 className="bg-gray-900/60 border-gray-700/40">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<CreditCard className="w-5 h-5 text-amber-400" />
|
||||
Crédits supplémentaires
|
||||
</CardTitle>
|
||||
<p className="text-gray-400 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-amber-500/50 bg-amber-500/10"
|
||||
: "border-gray-700/40 bg-gray-800/30"
|
||||
)}
|
||||
>
|
||||
{pkg.popular && (
|
||||
<div className="absolute -top-2 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-amber-600 text-white text-xs rounded-full font-bold whitespace-nowrap">
|
||||
Meilleure valeur
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xl font-bold text-white">{pkg.credits}</div>
|
||||
<div className="text-gray-400 text-xs mb-2">crédits</div>
|
||||
<div className="text-lg font-bold text-white">{pkg.price} €</div>
|
||||
<div className="text-gray-500 text-xs mb-3">{((pkg.price / pkg.credits) * 100).toFixed(0)} cts/crédit</div>
|
||||
<button
|
||||
onClick={handleBillingPortal}
|
||||
className="w-full py-1.5 rounded-lg bg-gray-700 hover:bg-gray-600 text-white text-xs transition-all"
|
||||
>
|
||||
Acheter
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Downgrade / Cancel ── */}
|
||||
{currentPlanId !== "free" && (
|
||||
<Card className="bg-gray-900/60 border-gray-700/40">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg text-red-400 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-gray-400 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-gray-800 hover:bg-gray-700 text-gray-300 text-sm border border-gray-700/50 transition-all"
|
||||
>
|
||||
Passer à {p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-gray-700/30 pt-4">
|
||||
{!cancelConfirm ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">Annuler mon abonnement</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Vous conservez l'accès jusqu'à la fin de la période payée.</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-red-700/50 text-red-400 hover:bg-red-900/20"
|
||||
onClick={() => setCancelConfirm(true)}
|
||||
>
|
||||
Annuler l'abonnement
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 rounded-xl bg-red-900/20 border border-red-600/30 space-y-3">
|
||||
<p className="text-sm text-red-300 font-medium">
|
||||
⚠️ Confirmer l'annulation ?
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
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-red-600 hover:bg-red-500 text-white"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Oui, annuler mon abonnement
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-gray-600 text-gray-300"
|
||||
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-violet-400 hover:text-violet-300 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user