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>
877 lines
38 KiB
TypeScript
877 lines
38 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect } from "react";
|
||
import Link from "next/link";
|
||
import { useRouter, useSearchParams } from "next/navigation";
|
||
import {
|
||
Check, X, Zap, Building2, Crown, Sparkles, ArrowRight, ArrowLeft,
|
||
Star, Shield, Rocket, Users, Headphones, Lock, Globe,
|
||
Clock, ChevronDown, ChevronUp, Cpu, BarChart3, Infinity,
|
||
FileText, Layers, Brain, BadgeCheck, 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 { cn } from "@/lib/utils";
|
||
import { API_BASE } from "@/lib/config";
|
||
import { ANNUAL_DISCOUNT_PERCENT } from "@/lib/pricing";
|
||
|
||
/* ─────────────────────────────────────────────
|
||
Types
|
||
───────────────────────────────────────────── */
|
||
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;
|
||
max_chars_per_month: number;
|
||
features: string[];
|
||
providers: string[];
|
||
ai_translation: boolean;
|
||
ai_tier?: string;
|
||
api_access: boolean;
|
||
priority_processing: boolean;
|
||
team_seats?: number;
|
||
popular?: boolean;
|
||
highlight?: string;
|
||
description?: string;
|
||
badge?: string;
|
||
}
|
||
|
||
interface CreditPackage {
|
||
credits: number;
|
||
price: number;
|
||
price_per_credit: number;
|
||
popular?: boolean;
|
||
}
|
||
|
||
/* ─────────────────────────────────────────────
|
||
Static plan data (fallback + SSR-friendly)
|
||
───────────────────────────────────────────── */
|
||
const STATIC_PLANS: Plan[] = [
|
||
{
|
||
id: "free",
|
||
name: "Gratuit",
|
||
price_monthly: 0,
|
||
price_yearly: 0,
|
||
docs_per_month: 5,
|
||
max_pages_per_doc: 15,
|
||
max_file_size_mb: 5,
|
||
max_chars_per_month: 50_000,
|
||
providers: ["google"],
|
||
features: [
|
||
"5 documents / mois",
|
||
"Jusqu'à 15 pages par document",
|
||
"Google Traduction inclus",
|
||
"Toutes les langues (130+)",
|
||
"Support communautaire",
|
||
],
|
||
ai_translation: false,
|
||
api_access: false,
|
||
priority_processing: false,
|
||
popular: false,
|
||
description: "Parfait pour découvrir l'application",
|
||
},
|
||
{
|
||
id: "starter",
|
||
name: "Starter",
|
||
price_monthly: 9.00,
|
||
price_yearly: 86.40,
|
||
docs_per_month: 50,
|
||
max_pages_per_doc: 50,
|
||
max_file_size_mb: 10,
|
||
max_chars_per_month: 500_000,
|
||
providers: ["google", "deepl"],
|
||
features: [
|
||
"50 documents / mois",
|
||
"Jusqu'à 50 pages par document",
|
||
"Google Traduction + DeepL",
|
||
"Fichiers jusqu'à 10 Mo",
|
||
"Support par e-mail",
|
||
"Historique 30 jours",
|
||
],
|
||
ai_translation: false,
|
||
api_access: false,
|
||
priority_processing: false,
|
||
popular: false,
|
||
description: "Pour les particuliers et petits projets",
|
||
},
|
||
{
|
||
id: "pro",
|
||
name: "Pro",
|
||
price_monthly: 19.00,
|
||
price_yearly: 182.40,
|
||
docs_per_month: 200,
|
||
max_pages_per_doc: 200,
|
||
max_file_size_mb: 25,
|
||
max_chars_per_month: 2_000_000,
|
||
providers: ["google", "deepl", "openrouter"],
|
||
features: [
|
||
"200 documents / mois",
|
||
"Jusqu'à 200 pages par document",
|
||
"Traduction IA Essentielle (DeepSeek V3.2)",
|
||
"Google Traduction + DeepL",
|
||
"Fichiers jusqu'à 25 Mo",
|
||
"Glossaires personnalisés",
|
||
"Support prioritaire",
|
||
"Historique 90 jours",
|
||
],
|
||
ai_translation: true,
|
||
ai_tier: "essential",
|
||
api_access: false,
|
||
priority_processing: true,
|
||
popular: true,
|
||
highlight: "Le plus populaire",
|
||
description: "Pour les professionnels et équipes en croissance",
|
||
badge: "POPULAIRE",
|
||
},
|
||
{
|
||
id: "business",
|
||
name: "Business",
|
||
price_monthly: 49.00,
|
||
price_yearly: 470.40,
|
||
docs_per_month: 1000,
|
||
max_pages_per_doc: 500,
|
||
max_file_size_mb: 50,
|
||
max_chars_per_month: 10_000_000,
|
||
providers: ["google", "deepl", "openrouter", "openrouter_premium", "openai"],
|
||
features: [
|
||
"1 000 documents / mois",
|
||
"Jusqu'à 500 pages par document",
|
||
"IA Essentielle + Premium (Claude Haiku)",
|
||
"Tous les fournisseurs de traduction",
|
||
"Fichiers jusqu'à 50 Mo",
|
||
"Accès API (10 000 appels/mois)",
|
||
"Webhooks de notification",
|
||
"Support dédié",
|
||
"Historique 1 an",
|
||
"Analytiques avancées",
|
||
],
|
||
ai_translation: true,
|
||
ai_tier: "premium",
|
||
api_access: true,
|
||
priority_processing: true,
|
||
team_seats: 5,
|
||
popular: false,
|
||
description: "Pour les équipes et organisations",
|
||
},
|
||
{
|
||
id: "enterprise",
|
||
name: "Entreprise",
|
||
price_monthly: -1,
|
||
price_yearly: -1,
|
||
docs_per_month: -1,
|
||
max_pages_per_doc: -1,
|
||
max_file_size_mb: -1,
|
||
max_chars_per_month: -1,
|
||
providers: ["all"],
|
||
features: [
|
||
"Documents illimités",
|
||
"Tous les modèles IA (GPT-5, Claude Opus 4.6…)",
|
||
"Déploiement on-premise ou cloud dédié",
|
||
"SLA 99,9 % garanti",
|
||
"Support 24/7 dédié",
|
||
"Marque blanche (white-label)",
|
||
"Équipes illimitées",
|
||
"Intégrations sur mesure",
|
||
],
|
||
ai_translation: true,
|
||
ai_tier: "custom",
|
||
api_access: true,
|
||
priority_processing: true,
|
||
team_seats: -1,
|
||
popular: false,
|
||
description: "Solutions sur mesure pour grandes organisations",
|
||
badge: "SUR DEVIS",
|
||
},
|
||
];
|
||
|
||
const STATIC_CREDITS: CreditPackage[] = [
|
||
{ credits: 50, price: 5, price_per_credit: 0.10 },
|
||
{ credits: 150, price: 12, price_per_credit: 0.08, popular: true },
|
||
{ credits: 500, price: 35, price_per_credit: 0.07 },
|
||
{ credits: 1000, price: 60, price_per_credit: 0.06 },
|
||
];
|
||
|
||
/* ─────────────────────────────────────────────
|
||
Visual config by plan
|
||
───────────────────────────────────────────── */
|
||
const PLAN_ICONS: Record<string, any> = {
|
||
free: Sparkles,
|
||
starter: Zap,
|
||
pro: Crown,
|
||
business: Building2,
|
||
enterprise: Rocket,
|
||
};
|
||
|
||
const PLAN_COLORS: Record<string, { gradient: string; border: string; badge: string; button: string }> = {
|
||
free: { gradient: "from-slate-600 to-slate-700", border: "border-slate-700/50", badge: "bg-slate-700", button: "bg-slate-700 hover:bg-slate-600" },
|
||
starter: { gradient: "from-blue-600 to-blue-700", border: "border-blue-700/50", badge: "bg-blue-600", button: "bg-blue-600 hover:bg-blue-500" },
|
||
pro: { gradient: "from-violet-600 to-violet-700", border: "border-violet-500/60",badge: "bg-violet-600", button: "bg-violet-600 hover:bg-violet-500" },
|
||
business: { gradient: "from-emerald-600 to-emerald-700",border:"border-emerald-700/50",badge:"bg-emerald-600", button: "bg-emerald-600 hover:bg-emerald-500" },
|
||
enterprise: { gradient: "from-amber-600 to-amber-700", border: "border-amber-700/50", badge: "bg-amber-600", button: "bg-amber-600 hover:bg-amber-500" },
|
||
};
|
||
|
||
/** Évite le flash des tarifs statiques (STATIC_PLANS) avant la réponse API au refresh. */
|
||
function PricingDataSkeleton() {
|
||
return (
|
||
<>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-5">
|
||
{Array.from({ length: 5 }).map((_, i) => (
|
||
<div
|
||
key={i}
|
||
className="rounded-2xl border border-border/40 bg-card overflow-hidden animate-pulse"
|
||
>
|
||
<div className="h-28 bg-muted/60" />
|
||
<div className="p-5 space-y-3">
|
||
<div className="h-4 bg-muted rounded w-3/4" />
|
||
<div className="h-4 bg-muted rounded w-1/2" />
|
||
<div className="h-4 bg-muted rounded w-full" />
|
||
<div className="h-4 bg-muted rounded w-5/6" />
|
||
<div className="h-9 bg-muted rounded-lg mt-4" />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="mt-20">
|
||
<div className="h-9 bg-muted rounded w-64 mx-auto mb-4 animate-pulse" />
|
||
<div className="h-4 bg-muted rounded w-96 max-w-full mx-auto mb-10 animate-pulse" />
|
||
<div className="overflow-x-auto rounded-2xl border border-border/40">
|
||
<div className="h-64 bg-muted/30 animate-pulse rounded-xl" />
|
||
</div>
|
||
</div>
|
||
<div className="mt-20">
|
||
<div className="h-9 bg-muted rounded w-72 mx-auto mb-4 animate-pulse" />
|
||
<div className="h-4 bg-muted rounded w-full max-w-lg mx-auto mb-8 animate-pulse" />
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-3xl mx-auto">
|
||
{Array.from({ length: 4 }).map((_, i) => (
|
||
<div key={i} className="h-36 rounded-2xl border border-border/40 bg-muted/20 animate-pulse" />
|
||
))}
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
const FAQS = [
|
||
{
|
||
q: "Puis-je changer de forfait à tout moment ?",
|
||
a: "Oui. Le passage à un forfait supérieur est immédiat et proratisé. La rétrogradation prend effet à la fin de la période en cours.",
|
||
},
|
||
{
|
||
q: "Qu'est-ce que la « Traduction IA Essentielle » ?",
|
||
a: "C'est notre moteur IA basé sur DeepSeek V3.2 via OpenRouter. Il comprend le contexte de vos documents, préserve la mise en page et gère les termes techniques bien mieux qu'une traduction classique.",
|
||
},
|
||
{
|
||
q: "Quelle est la différence entre IA Essentielle et IA Premium ?",
|
||
a: "L'IA Essentielle utilise DeepSeek V3.2 (excellent rapport qualité/prix). L'IA Premium utilise Claude 3.5 Haiku d'Anthropic, plus précis sur les documents juridiques, médicaux et techniques complexes.",
|
||
},
|
||
{
|
||
q: "Mes documents sont-ils conservés après traduction ?",
|
||
a: "Les fichiers traduits sont disponibles selon votre forfait (30 jours Starter, 90 jours Pro, 1 an Business). Ils sont chiffrés au repos et en transit.",
|
||
},
|
||
{
|
||
q: "Que se passe-t-il si je dépasse mon quota mensuel ?",
|
||
a: "Vous pouvez acheter des crédits supplémentaires à l'unité, ou upgrader votre forfait. Vous êtes notifié à 80 % d'utilisation.",
|
||
},
|
||
{
|
||
q: "Y a-t-il une version d'essai gratuite des forfaits payants ?",
|
||
a: "Le forfait Gratuit est permanent et sans carte bancaire. Pour les forfaits Pro et Business, contactez-nous pour un accès d'essai de 14 jours.",
|
||
},
|
||
{
|
||
q: "Quels formats de fichiers sont supportés ?",
|
||
a: "Word (.docx), Excel (.xlsx/.xls), PowerPoint (.pptx), et bientôt PDF. Tous les plans supportent les mêmes formats.",
|
||
},
|
||
];
|
||
|
||
/* ─────────────────────────────────────────────
|
||
Main component
|
||
───────────────────────────────────────────── */
|
||
export default function PricingPage() {
|
||
const router = useRouter();
|
||
const searchParams = useSearchParams();
|
||
const planFromUrl = searchParams.get("plan");
|
||
const [isYearly, setIsYearly] = useState(false);
|
||
const [plans, setPlans] = useState<Plan[]>(STATIC_PLANS);
|
||
const [credits, setCredits] = useState<CreditPackage[]>(STATIC_CREDITS);
|
||
const [currentPlan, setCurrentPlan] = useState<string | null>(null);
|
||
const [openFAQ, setOpenFAQ] = useState<number | null>(null);
|
||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||
const [loadingPlanId, setLoadingPlanId] = useState<string | null>(null);
|
||
const [toastMsg, setToastMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||
const [annualDiscountPercent, setAnnualDiscountPercent] = useState(ANNUAL_DISCOUNT_PERCENT);
|
||
/** Tant que false : on n’affiche pas STATIC_PLANS (évite le flash de faux prix au refresh). */
|
||
const [pricingLoaded, setPricingLoaded] = useState(false);
|
||
|
||
useEffect(() => {
|
||
// Fetch live plans — backend returns prices set by admin (pas de cache navigateur)
|
||
const plansUrl = `${API_BASE}/api/v1/auth/plans`;
|
||
fetch(plansUrl, { cache: "no-store" })
|
||
.then(async (r) => {
|
||
if (!r.ok) {
|
||
throw new Error(`HTTP ${r.status}`);
|
||
}
|
||
return r.json();
|
||
})
|
||
.then((json) => {
|
||
const d = json.data ?? json;
|
||
const meta = (json.meta ?? d.meta) as { annual_discount_percent?: number } | undefined;
|
||
if (typeof meta?.annual_discount_percent === "number") {
|
||
setAnnualDiscountPercent(meta.annual_discount_percent);
|
||
}
|
||
if (Array.isArray(d.plans) && d.plans.length) setPlans(d.plans);
|
||
if (Array.isArray(d.credit_packages)) setCredits(d.credit_packages);
|
||
})
|
||
.catch((err) => {
|
||
if (process.env.NODE_ENV === "development") {
|
||
console.warn("[pricing] Impossible de charger /api/v1/auth/plans — affichage des tarifs par défaut.", err);
|
||
}
|
||
})
|
||
.finally(() => {
|
||
setPricingLoaded(true);
|
||
});
|
||
|
||
// Fetch current user plan (if logged in)
|
||
const token = localStorage.getItem("token");
|
||
if (token) {
|
||
setIsLoggedIn(true);
|
||
fetch(`${API_BASE}/api/v1/auth/me`, { headers: { Authorization: `Bearer ${token}` } })
|
||
.then((r) => r.json())
|
||
.then((json) => {
|
||
const user = json.data ?? json;
|
||
if (user?.plan) setCurrentPlan(user.plan);
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
}, []);
|
||
|
||
// Auto-trigger checkout when ?plan= is in the URL and user is already logged in
|
||
useEffect(() => {
|
||
if (!pricingLoaded) return;
|
||
if (planFromUrl && isLoggedIn && plans.length > 0 && currentPlan !== null) {
|
||
const targetPlan = plans.find(p => p.id === planFromUrl);
|
||
if (targetPlan && targetPlan.price_monthly > 0 && currentPlan !== planFromUrl) {
|
||
// Directly initiate checkout — no more redirect to /settings/subscription
|
||
handleSubscribe(planFromUrl);
|
||
}
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [planFromUrl, isLoggedIn, plans, currentPlan, pricingLoaded]);
|
||
|
||
const displayPrice = (plan: Plan) => {
|
||
if (plan.price_monthly === -1) return null;
|
||
if (plan.price_monthly === 0) return 0;
|
||
const price = isYearly ? (plan.price_yearly / 12) : plan.price_monthly;
|
||
return Number.isInteger(price) ? price.toString() : price.toFixed(2);
|
||
};
|
||
|
||
const handleSubscribe = async (planId: string) => {
|
||
const token = localStorage.getItem("token");
|
||
if (!token) {
|
||
router.push(`/auth/login?redirect=/pricing`);
|
||
return;
|
||
}
|
||
if (planId === "enterprise") {
|
||
window.location.href = "mailto:contact@votre-domaine.com?subject=Offre Enterprise";
|
||
return;
|
||
}
|
||
if (planId === currentPlan) return;
|
||
|
||
setLoadingPlanId(planId);
|
||
setToastMsg(null);
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/v1/auth/create-checkout`, {
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
plan: planId,
|
||
billing_period: isYearly ? "yearly" : "monthly",
|
||
}),
|
||
});
|
||
const data = await res.json();
|
||
const url = data.data?.url ?? data.url;
|
||
const backendMessage = data.message ?? data.error ?? data.data?.error;
|
||
|
||
if (!res.ok) {
|
||
throw new Error(backendMessage || "Erreur lors de la création du paiement.");
|
||
}
|
||
|
||
if (url) {
|
||
window.location.replace(url);
|
||
} else {
|
||
// Demo mode: Stripe not yet configured
|
||
setToastMsg({
|
||
type: 'ok',
|
||
text: `Mode démo — Stripe n'est pas encore configuré. En production, vous seriez redirigé vers le paiement pour activer le forfait ${planId}.`,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : "Erreur réseau. Veuillez réessayer.";
|
||
setToastMsg({ type: 'err', text: message });
|
||
} finally {
|
||
setLoadingPlanId(null);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="min-h-screen bg-background text-foreground">
|
||
{/* ── Top navigation ── */}
|
||
<div className="sticky top-0 z-40 border-b border-border/60 bg-background/85 backdrop-blur">
|
||
<div className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between gap-3">
|
||
<button
|
||
onClick={() => router.back()}
|
||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||
>
|
||
<ArrowLeft className="w-4 h-4" />
|
||
Retour
|
||
</button>
|
||
<div className="flex items-center gap-2">
|
||
<Link
|
||
href={isLoggedIn ? "/dashboard" : "/"}
|
||
className="px-3 py-1.5 rounded-lg text-sm bg-secondary hover:bg-secondary/80 text-secondary-foreground transition-colors"
|
||
>
|
||
{isLoggedIn ? "Dashboard" : "Accueil"}
|
||
</Link>
|
||
{isLoggedIn && (
|
||
<Link
|
||
href="/settings/subscription"
|
||
className="px-3 py-1.5 rounded-lg text-sm bg-accent/80 hover:bg-accent text-accent-foreground transition-colors"
|
||
>
|
||
Mon abonnement
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Toast notification ── */}
|
||
{toastMsg && (
|
||
<div className={cn(
|
||
"fixed top-4 left-1/2 -translate-x-1/2 z-50 flex items-start gap-3 px-5 py-4 rounded-2xl shadow-2xl border max-w-lg w-full mx-4 backdrop-blur-sm",
|
||
toastMsg.type === 'ok'
|
||
? "bg-success/10 border-success/30 text-success"
|
||
: "bg-destructive/10 border-destructive/30 text-destructive"
|
||
)}>
|
||
<span className="text-lg">{toastMsg.type === 'ok' ? '✓' : '✕'}</span>
|
||
<p className="text-sm flex-1">{toastMsg.text}</p>
|
||
<button onClick={() => setToastMsg(null)} className="text-muted-foreground hover:text-foreground text-lg leading-none">✕</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Header ── */}
|
||
<div className="max-w-7xl mx-auto px-4 pt-20 pb-12 text-center">
|
||
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-accent/10 border border-accent/20 text-accent text-sm font-medium mb-6">
|
||
<Cpu className="w-4 h-4" />
|
||
Modèles IA mis à jour — Mars 2026
|
||
</div>
|
||
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-4 bg-gradient-to-r from-foreground via-accent/80 to-accent bg-clip-text text-transparent">
|
||
Un forfait pour chaque besoin
|
||
</h1>
|
||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto mb-8">
|
||
Traduisez vos documents Word, Excel et PowerPoint en conservant
|
||
la mise en page originale. Sans jamais saisir de clé API.
|
||
</p>
|
||
|
||
{/* Monthly / Yearly toggle */}
|
||
<div className="inline-flex items-center gap-3 bg-muted/60 border border-border/50 rounded-full p-1.5">
|
||
<button
|
||
onClick={() => setIsYearly(false)}
|
||
className={cn(
|
||
"px-5 py-2 rounded-full text-sm font-medium transition-all",
|
||
!isYearly ? "bg-foreground text-background shadow" : "text-muted-foreground hover:text-foreground"
|
||
)}
|
||
>
|
||
Mensuel
|
||
</button>
|
||
<button
|
||
onClick={() => setIsYearly(true)}
|
||
className={cn(
|
||
"px-5 py-2 rounded-full text-sm font-medium transition-all flex items-center gap-2",
|
||
isYearly ? "bg-foreground text-background shadow" : "text-muted-foreground hover:text-foreground"
|
||
)}
|
||
>
|
||
Annuel
|
||
<span className="bg-success text-success-foreground text-xs px-1.5 py-0.5 rounded-full">
|
||
−{annualDiscountPercent} %
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Plan cards (+ comparaison + crédits) : squelette jusqu’à l’API pour éviter le flash des tarifs statiques */}
|
||
<div className="max-w-7xl mx-auto px-4 pb-20">
|
||
{!pricingLoaded ? (
|
||
<PricingDataSkeleton />
|
||
) : (
|
||
<>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-5">
|
||
{plans.map((plan) => {
|
||
const Icon = PLAN_ICONS[plan.id] ?? Sparkles;
|
||
const colors = PLAN_COLORS[plan.id] ?? PLAN_COLORS.starter;
|
||
const price = displayPrice(plan);
|
||
const isCurrent = currentPlan === plan.id;
|
||
const isEnterprise = plan.id === "enterprise";
|
||
|
||
return (
|
||
<div
|
||
key={plan.id}
|
||
className={cn(
|
||
"relative flex flex-col rounded-2xl border bg-card backdrop-blur transition-all duration-300",
|
||
"hover:scale-[1.02] hover:shadow-2xl",
|
||
colors.border,
|
||
plan.popular && "ring-2 ring-violet-500/50 shadow-violet-500/20 shadow-xl"
|
||
)}
|
||
>
|
||
{/* Popular badge */}
|
||
{plan.badge && (
|
||
<div className={cn(
|
||
"absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full text-xs font-bold text-white",
|
||
colors.badge
|
||
)}>
|
||
{plan.badge}
|
||
</div>
|
||
)}
|
||
|
||
{isCurrent && (
|
||
<div className="absolute -top-3 right-4 px-3 py-1 rounded-full text-xs font-bold bg-emerald-600 text-white flex items-center gap-1">
|
||
<BadgeCheck className="w-3 h-3" /> Mon forfait
|
||
</div>
|
||
)}
|
||
|
||
{/* Header */}
|
||
<div className={cn("p-5 rounded-t-2xl bg-gradient-to-br", colors.gradient)}>
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<div className="p-1.5 bg-white/10 rounded-lg">
|
||
<Icon className="w-5 h-5 text-white" />
|
||
</div>
|
||
<span className="font-bold text-white">{plan.name}</span>
|
||
</div>
|
||
|
||
{isEnterprise ? (
|
||
<div className="text-3xl font-bold text-white">Sur devis</div>
|
||
) : price === 0 ? (
|
||
<div className="text-3xl font-bold text-white">Gratuit</div>
|
||
) : (
|
||
<div className="flex items-end gap-1">
|
||
<span className="text-3xl font-bold text-white">{price} €</span>
|
||
<span className="text-white/70 text-sm pb-1">/mois</span>
|
||
</div>
|
||
)}
|
||
|
||
{isYearly && plan.price_yearly > 0 && (
|
||
<div className="text-white/70 text-xs mt-1">
|
||
Facturé {plan.price_yearly.toFixed(2)} € / an
|
||
</div>
|
||
)}
|
||
|
||
<p className="text-white/60 text-xs mt-2">{plan.description}</p>
|
||
</div>
|
||
|
||
{/* Features */}
|
||
<div className="flex-1 p-5 space-y-2.5">
|
||
{/* Key stats */}
|
||
<div className="grid grid-cols-1 gap-2 mb-3">
|
||
<Stat
|
||
icon={<FileText className="w-3.5 h-3.5" />}
|
||
label="Documents"
|
||
value={plan.docs_per_month === -1 ? "Illimité" : `${plan.docs_per_month} / mois`}
|
||
/>
|
||
<Stat
|
||
icon={<Layers className="w-3.5 h-3.5" />}
|
||
label="Pages max"
|
||
value={plan.max_pages_per_doc === -1 ? "Illimité" : `${plan.max_pages_per_doc} p / doc`}
|
||
/>
|
||
{plan.ai_translation && (
|
||
<Stat
|
||
icon={<Brain className="w-3.5 h-3.5 text-violet-400" />}
|
||
label="Traduction IA"
|
||
value={
|
||
plan.ai_tier === "essential" ? "Essentielle" :
|
||
plan.ai_tier === "premium" ? "Essentielle + Premium" : "Sur mesure"
|
||
}
|
||
highlight
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<div className="border-t border-border/40 pt-3 space-y-2">
|
||
{plan.features.map((feat, i) => (
|
||
<div key={i} className="flex items-start gap-2">
|
||
<Check className="w-4 h-4 text-emerald-500 flex-shrink-0 mt-0.5" />
|
||
<span className="text-muted-foreground text-xs leading-snug">{feat}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* CTA */}
|
||
<div className="p-5 pt-0">
|
||
{isCurrent ? (
|
||
<Button
|
||
variant="outline"
|
||
className="w-full border-success/50 text-success hover:bg-success/10"
|
||
onClick={() => router.push("/settings/subscription")}
|
||
>
|
||
<BadgeCheck className="w-4 h-4 mr-1" /> Gérer mon forfait
|
||
</Button>
|
||
) : plan.id === "free" && !currentPlan ? (
|
||
<Button
|
||
variant="outline"
|
||
className="w-full border-border text-muted-foreground hover:bg-muted/30"
|
||
onClick={() => router.push("/auth/register")}
|
||
>
|
||
Commencer gratuitement
|
||
</Button>
|
||
) : (
|
||
<button
|
||
onClick={() => handleSubscribe(plan.id)}
|
||
disabled={loadingPlanId !== null}
|
||
className={cn(
|
||
"w-full py-2.5 px-4 rounded-xl text-sm font-semibold text-white transition-all flex items-center justify-center gap-2",
|
||
colors.button,
|
||
loadingPlanId !== null && "opacity-70 cursor-not-allowed"
|
||
)}
|
||
>
|
||
{loadingPlanId === plan.id ? (
|
||
<>
|
||
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||
</svg>
|
||
Traitement…
|
||
</>
|
||
) : (
|
||
<>
|
||
{isEnterprise ? "Nous contacter" : "Choisir ce forfait"}
|
||
<ArrowRight className="w-4 h-4" />
|
||
</>
|
||
)}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* ── Feature comparison table ── */}
|
||
<div className="mt-20">
|
||
<h2 className="text-3xl font-bold text-center mb-2">Comparaison détaillée</h2>
|
||
<p className="text-muted-foreground text-center mb-10">Tout ce qui est inclus dans chaque forfait</p>
|
||
|
||
<div className="overflow-x-auto rounded-2xl border border-border/40">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="bg-muted/60 border-b border-border/40">
|
||
<th className="text-left py-4 px-6 text-muted-foreground font-medium">Fonctionnalité</th>
|
||
{plans.slice(0, 4).map((p) => (
|
||
<th key={p.id} className={cn("py-4 px-4 text-center font-medium", p.popular ? "text-accent" : "text-muted-foreground")}>
|
||
{p.name}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{[
|
||
{ label: "Documents / mois", vals: plans.slice(0,4).map(p => p.docs_per_month === -1 ? "∞" : String(p.docs_per_month)) },
|
||
{ label: "Pages max / document", vals: plans.slice(0,4).map(p => p.max_pages_per_doc === -1 ? "∞" : String(p.max_pages_per_doc)) },
|
||
{ label: "Taille max fichier", vals: plans.slice(0,4).map(p => p.max_file_size_mb === -1 ? "∞" : `${p.max_file_size_mb} Mo`) },
|
||
{ label: "Google Traduction", vals: plans.slice(0,4).map(() => true) },
|
||
{ label: "DeepL", vals: plans.slice(0,4).map(p => p.providers.includes("deepl") || p.providers.includes("all")) },
|
||
{ label: "Traduction IA Essentielle", vals: plans.slice(0,4).map(p => p.ai_translation && (p.ai_tier === "essential" || p.ai_tier === "premium" || p.ai_tier === "custom")) },
|
||
{ label: "Traduction IA Premium", vals: plans.slice(0,4).map(p => p.ai_translation && (p.ai_tier === "premium" || p.ai_tier === "custom")) },
|
||
{ label: "Accès API", vals: plans.slice(0,4).map(p => p.api_access) },
|
||
{ label: "Traitement prioritaire", vals: plans.slice(0,4).map(p => p.priority_processing) },
|
||
{ label: "Support", vals: ["Communauté", "E-mail", "Prioritaire", "Dédié"] },
|
||
].map((row, i) => (
|
||
<tr key={i} className={cn("border-b border-border/20", i % 2 === 0 ? "bg-muted/20" : "")}>
|
||
<td className="py-3 px-6 text-muted-foreground">{row.label}</td>
|
||
{row.vals.map((val, j) => (
|
||
<td key={j} className="py-3 px-4 text-center">
|
||
{typeof val === "boolean" ? (
|
||
val
|
||
? <Check className="w-4 h-4 text-emerald-500 mx-auto" />
|
||
: <X className="w-4 h-4 text-muted-foreground/40 mx-auto" />
|
||
) : (
|
||
<span className={cn("text-sm", plans.slice(0,4)[j]?.popular ? "text-accent font-medium" : "text-muted-foreground")}>
|
||
{val}
|
||
</span>
|
||
)}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Credits ── */}
|
||
<div className="mt-20">
|
||
<h2 className="text-3xl font-bold text-center mb-2">Crédits supplémentaires</h2>
|
||
<p className="text-muted-foreground text-center mb-8">
|
||
Besoin de plus ? Achetez des crédits à l'unité, sans abonnement.
|
||
<span className="text-muted-foreground/70"> 1 crédit = 1 page traduite.</span>
|
||
</p>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-3xl mx-auto">
|
||
{credits.map((pkg, i) => (
|
||
<div
|
||
key={i}
|
||
className={cn(
|
||
"relative p-5 rounded-2xl border text-center transition-all hover:scale-105",
|
||
pkg.popular
|
||
? "border-accent/50 bg-accent/10 shadow-accent/20 shadow-lg"
|
||
: "border-border/40 bg-card"
|
||
)}
|
||
>
|
||
{pkg.popular && (
|
||
<div className="absolute -top-2.5 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-accent text-accent-foreground text-xs rounded-full font-bold">
|
||
Le meilleur rapport
|
||
</div>
|
||
)}
|
||
<div className="text-2xl font-bold text-foreground">{pkg.credits}</div>
|
||
<div className="text-muted-foreground text-xs mb-3">crédits</div>
|
||
<div className="text-xl font-bold text-foreground">{pkg.price} €</div>
|
||
<div className="text-muted-foreground text-xs">{(pkg.price_per_credit * 100).toFixed(0)} cts / crédit</div>
|
||
<button className="mt-3 w-full py-1.5 rounded-lg bg-muted hover:bg-muted/80 text-foreground text-xs transition-all">
|
||
Acheter
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* ── Trust signals ── */}
|
||
<div className="mt-20 grid grid-cols-2 md:grid-cols-4 gap-6">
|
||
{[
|
||
{ icon: <Shield className="w-6 h-6 text-emerald-500" />, title: "Chiffrement de bout en bout", sub: "TLS 1.3 + AES-256 au repos" },
|
||
{ icon: <Globe className="w-6 h-6 text-blue-500" />, title: "130+ langues", sub: "Dont arabe, persan, hébreu (RTL)" },
|
||
{ icon: <Gauge className="w-6 h-6 text-accent" />, title: "Traitement parallèle", sub: "IA multi-thread ultra-rapide" },
|
||
{ icon: <Clock className="w-6 h-6 text-amber-500" />, title: "Disponible 24/7", sub: "Uptime garanti 99,9 %" },
|
||
].map((t, i) => (
|
||
<div key={i} className="flex flex-col items-center text-center p-6 rounded-2xl bg-card border border-border/30">
|
||
<div className="p-3 rounded-full bg-muted/50 mb-3">{t.icon}</div>
|
||
<div className="font-semibold text-foreground text-sm mb-1">{t.title}</div>
|
||
<div className="text-muted-foreground text-xs">{t.sub}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* ── AI Models info ── */}
|
||
<div className="mt-20 p-8 rounded-2xl bg-gradient-to-br from-accent/10 to-card border border-accent/20">
|
||
<div className="flex items-center gap-3 mb-6">
|
||
<Brain className="w-6 h-6 text-accent" />
|
||
<h2 className="text-2xl font-bold">Nos modèles IA — Mars 2026</h2>
|
||
</div>
|
||
<div className="grid md:grid-cols-2 gap-6">
|
||
<div className="p-5 rounded-xl bg-card border border-border/40">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<Zap className="w-4 h-4 text-blue-500" />
|
||
<span className="font-semibold">Traduction IA Essentielle</span>
|
||
<Badge className="ml-auto text-xs bg-blue-500/10 text-blue-600 border-blue-500/30 dark:text-blue-300">Forfait Pro</Badge>
|
||
</div>
|
||
<div className="text-sm text-muted-foreground mb-3">
|
||
Basée sur <strong className="text-foreground">DeepSeek V3.2</strong> — le modèle IA le plus rentable de 2026.
|
||
Qualité comparable aux modèles frontier à 1/50ème du coût.
|
||
</div>
|
||
<div className="flex flex-wrap gap-2 text-xs">
|
||
<span className="px-2 py-1 bg-muted rounded">163K tokens de contexte</span>
|
||
<span className="px-2 py-1 bg-muted rounded">$0.25/$0.38 per 1M</span>
|
||
<span className="px-2 py-1 bg-success/10 text-success rounded">Excellent rapport qualité/prix</span>
|
||
</div>
|
||
</div>
|
||
<div className="p-5 rounded-xl bg-card border border-accent/30">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<Crown className="w-4 h-4 text-accent" />
|
||
<span className="font-semibold">Traduction IA Premium</span>
|
||
<Badge className="ml-auto text-xs bg-accent/10 text-accent border-accent/30">Forfait Business</Badge>
|
||
</div>
|
||
<div className="text-sm text-muted-foreground mb-3">
|
||
Basée sur <strong className="text-foreground">Claude 3.5 Haiku</strong> d'Anthropic — précis sur les documents
|
||
juridiques, médicaux et techniques complexes.
|
||
</div>
|
||
<div className="flex flex-wrap gap-2 text-xs">
|
||
<span className="px-2 py-1 bg-muted rounded">200K tokens de contexte</span>
|
||
<span className="px-2 py-1 bg-muted rounded">$0.25/$1.25 per 1M</span>
|
||
<span className="px-2 py-1 bg-accent/10 text-accent rounded">Meilleure précision</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── FAQ ── */}
|
||
<div className="mt-20 max-w-3xl mx-auto">
|
||
<h2 className="text-3xl font-bold text-center mb-10">Questions fréquentes</h2>
|
||
<div className="space-y-3">
|
||
{FAQS.map((faq, i) => (
|
||
<div
|
||
key={i}
|
||
className="rounded-xl border border-border/40 bg-card overflow-hidden"
|
||
>
|
||
<button
|
||
className="w-full flex items-center justify-between p-5 text-left hover:bg-muted/20 transition-colors"
|
||
onClick={() => setOpenFAQ(openFAQ === i ? null : i)}
|
||
>
|
||
<span className="font-medium text-foreground">{faq.q}</span>
|
||
{openFAQ === i
|
||
? <ChevronUp className="w-5 h-5 text-muted-foreground flex-shrink-0" />
|
||
: <ChevronDown className="w-5 h-5 text-muted-foreground flex-shrink-0" />
|
||
}
|
||
</button>
|
||
{openFAQ === i && (
|
||
<div className="px-5 pb-5 text-muted-foreground text-sm leading-relaxed border-t border-border/30 pt-4">
|
||
{faq.a}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── CTA bottom ── */}
|
||
<div className="mt-20 text-center p-12 rounded-2xl bg-gradient-to-br from-accent/10 to-card border border-accent/20">
|
||
<h2 className="text-3xl font-bold mb-3">Prêt à commencer ?</h2>
|
||
<p className="text-muted-foreground mb-8 max-w-lg mx-auto">
|
||
Commencez gratuitement, sans carte bancaire. Passez à un forfait supérieur quand vous en avez besoin.
|
||
</p>
|
||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||
<Link href="/auth/register">
|
||
<Button className="bg-accent hover:bg-accent/90 text-accent-foreground px-8 py-3 text-base font-semibold">
|
||
Créer un compte gratuit
|
||
<ArrowRight className="ml-2 w-5 h-5" />
|
||
</Button>
|
||
</Link>
|
||
<Link href="/auth/login">
|
||
<Button variant="outline" className="px-8 py-3 text-base">
|
||
Se connecter
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ── Sub-components ── */
|
||
function Stat({ icon, label, value, highlight }: { icon: React.ReactNode; label: string; value: string; highlight?: boolean }) {
|
||
return (
|
||
<div className={cn(
|
||
"flex items-center gap-2 px-3 py-2 rounded-lg text-xs",
|
||
highlight ? "bg-accent/10 border border-accent/20" : "bg-muted/40 border border-border/30"
|
||
)}>
|
||
<span className={highlight ? "text-accent" : "text-muted-foreground"}>{icon}</span>
|
||
<span className="text-muted-foreground">{label} :</span>
|
||
<span className={cn("font-medium ml-auto", highlight ? "text-accent" : "text-foreground")}>{value}</span>
|
||
</div>
|
||
);
|
||
}
|