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>
This commit is contained in:
@@ -2,9 +2,9 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
Check, X, Zap, Building2, Crown, Sparkles, ArrowRight,
|
||||
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
|
||||
@@ -13,6 +13,8 @@ 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
|
||||
@@ -76,8 +78,8 @@ const STATIC_PLANS: Plan[] = [
|
||||
{
|
||||
id: "starter",
|
||||
name: "Starter",
|
||||
price_monthly: 7.99,
|
||||
price_yearly: 76.70,
|
||||
price_monthly: 9.00,
|
||||
price_yearly: 86.40,
|
||||
docs_per_month: 50,
|
||||
max_pages_per_doc: 50,
|
||||
max_file_size_mb: 10,
|
||||
@@ -100,8 +102,8 @@ const STATIC_PLANS: Plan[] = [
|
||||
{
|
||||
id: "pro",
|
||||
name: "Pro",
|
||||
price_monthly: 19.99,
|
||||
price_yearly: 191.90,
|
||||
price_monthly: 19.00,
|
||||
price_yearly: 182.40,
|
||||
docs_per_month: 200,
|
||||
max_pages_per_doc: 200,
|
||||
max_file_size_mb: 25,
|
||||
@@ -129,8 +131,8 @@ const STATIC_PLANS: Plan[] = [
|
||||
{
|
||||
id: "business",
|
||||
name: "Business",
|
||||
price_monthly: 49.99,
|
||||
price_yearly: 479.90,
|
||||
price_monthly: 49.00,
|
||||
price_yearly: 470.40,
|
||||
docs_per_month: 1000,
|
||||
max_pages_per_doc: 500,
|
||||
max_file_size_mb: 50,
|
||||
@@ -213,6 +215,47 @@ const PLAN_COLORS: Record<string, { gradient: string; border: string; badge: str
|
||||
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 ?",
|
||||
@@ -249,27 +292,53 @@ const FAQS = [
|
||||
───────────────────────────────────────────── */
|
||||
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
|
||||
fetch("/api/v1/auth/plans")
|
||||
.then((r) => r.json())
|
||||
.then((json) => {
|
||||
const data = json.data ?? json;
|
||||
if (Array.isArray(data.plans) && data.plans.length) setPlans(data.plans);
|
||||
if (Array.isArray(data.credit_packages)) setCredits(data.credit_packages);
|
||||
// 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();
|
||||
})
|
||||
.catch(() => {/* keep static fallback */});
|
||||
.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) {
|
||||
fetch("/api/v1/auth/me", { headers: { Authorization: `Bearer ${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;
|
||||
@@ -279,15 +348,27 @@ export default function PricingPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 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;
|
||||
return isYearly
|
||||
? (plan.price_yearly / 12).toFixed(2)
|
||||
: plan.price_monthly.toFixed(2);
|
||||
const price = isYearly ? (plan.price_yearly / 12) : plan.price_monthly;
|
||||
return Number.isInteger(price) ? price.toString() : price.toFixed(2);
|
||||
};
|
||||
|
||||
const handleSubscribe = (planId: string) => {
|
||||
const handleSubscribe = async (planId: string) => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) {
|
||||
router.push(`/auth/login?redirect=/pricing`);
|
||||
@@ -297,35 +378,113 @@ export default function PricingPage() {
|
||||
window.location.href = "mailto:contact@votre-domaine.com?subject=Offre Enterprise";
|
||||
return;
|
||||
}
|
||||
// Redirect to subscription management
|
||||
router.push(`/settings/subscription?plan=${planId}`);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const savingsPercent = Math.round((1 - (10 / 12)) * 100); // ~17 %
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 text-white">
|
||||
<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-violet-500/10 border border-violet-500/20 text-violet-300 text-sm font-medium mb-6">
|
||||
<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-white via-violet-200 to-violet-400 bg-clip-text text-transparent">
|
||||
<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-gray-400 max-w-2xl mx-auto mb-8">
|
||||
<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-gray-800/60 border border-gray-700/50 rounded-full p-1.5">
|
||||
<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-white text-gray-900 shadow" : "text-gray-400 hover:text-white"
|
||||
!isYearly ? "bg-foreground text-background shadow" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Mensuel
|
||||
@@ -334,19 +493,23 @@ export default function PricingPage() {
|
||||
onClick={() => setIsYearly(true)}
|
||||
className={cn(
|
||||
"px-5 py-2 rounded-full text-sm font-medium transition-all flex items-center gap-2",
|
||||
isYearly ? "bg-white text-gray-900 shadow" : "text-gray-400 hover:text-white"
|
||||
isYearly ? "bg-foreground text-background shadow" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Annuel
|
||||
<span className="bg-emerald-500 text-white text-xs px-1.5 py-0.5 rounded-full">
|
||||
−20 %
|
||||
<span className="bg-success text-success-foreground text-xs px-1.5 py-0.5 rounded-full">
|
||||
−{annualDiscountPercent} %
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Plan cards ── */}
|
||||
{/* ── 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;
|
||||
@@ -359,7 +522,7 @@ export default function PricingPage() {
|
||||
<div
|
||||
key={plan.id}
|
||||
className={cn(
|
||||
"relative flex flex-col rounded-2xl border bg-gray-900/60 backdrop-blur transition-all duration-300",
|
||||
"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"
|
||||
@@ -437,11 +600,11 @@ export default function PricingPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-700/40 pt-3 space-y-2">
|
||||
<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-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-gray-300 text-xs leading-snug">{feat}</span>
|
||||
<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>
|
||||
@@ -452,7 +615,7 @@ export default function PricingPage() {
|
||||
{isCurrent ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-emerald-600/50 text-emerald-400 hover:bg-emerald-600/10"
|
||||
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
|
||||
@@ -460,7 +623,7 @@ export default function PricingPage() {
|
||||
) : plan.id === "free" && !currentPlan ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-gray-600/50 text-gray-400 hover:bg-gray-700/30"
|
||||
className="w-full border-border text-muted-foreground hover:bg-muted/30"
|
||||
onClick={() => router.push("/auth/register")}
|
||||
>
|
||||
Commencer gratuitement
|
||||
@@ -468,13 +631,27 @@ export default function PricingPage() {
|
||||
) : (
|
||||
<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
|
||||
colors.button,
|
||||
loadingPlanId !== null && "opacity-70 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{isEnterprise ? "Nous contacter" : "Choisir ce forfait"}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
{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>
|
||||
@@ -486,15 +663,15 @@ export default function PricingPage() {
|
||||
{/* ── 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-gray-400 text-center mb-10">Tout ce qui est inclus dans chaque forfait</p>
|
||||
<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-gray-700/40">
|
||||
<div className="overflow-x-auto rounded-2xl border border-border/40">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-800/60 border-b border-gray-700/40">
|
||||
<th className="text-left py-4 px-6 text-gray-300 font-medium">Fonctionnalité</th>
|
||||
<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-violet-300" : "text-gray-300")}>
|
||||
<th key={p.id} className={cn("py-4 px-4 text-center font-medium", p.popular ? "text-accent" : "text-muted-foreground")}>
|
||||
{p.name}
|
||||
</th>
|
||||
))}
|
||||
@@ -513,16 +690,16 @@ export default function PricingPage() {
|
||||
{ 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-gray-700/20", i % 2 === 0 ? "bg-gray-800/20" : "")}>
|
||||
<td className="py-3 px-6 text-gray-300">{row.label}</td>
|
||||
<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-400 mx-auto" />
|
||||
: <X className="w-4 h-4 text-gray-600 mx-auto" />
|
||||
? <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-violet-200 font-medium" : "text-gray-300")}>
|
||||
<span className={cn("text-sm", plans.slice(0,4)[j]?.popular ? "text-accent font-medium" : "text-muted-foreground")}>
|
||||
{val}
|
||||
</span>
|
||||
)}
|
||||
@@ -538,9 +715,9 @@ export default function PricingPage() {
|
||||
{/* ── Credits ── */}
|
||||
<div className="mt-20">
|
||||
<h2 className="text-3xl font-bold text-center mb-2">Crédits supplémentaires</h2>
|
||||
<p className="text-gray-400 text-center mb-8">
|
||||
<p className="text-muted-foreground text-center mb-8">
|
||||
Besoin de plus ? Achetez des crédits à l'unité, sans abonnement.
|
||||
<span className="text-gray-500"> 1 crédit = 1 page traduite.</span>
|
||||
<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) => (
|
||||
@@ -549,80 +726,82 @@ export default function PricingPage() {
|
||||
className={cn(
|
||||
"relative p-5 rounded-2xl border text-center transition-all hover:scale-105",
|
||||
pkg.popular
|
||||
? "border-violet-500/50 bg-violet-500/10 shadow-violet-500/20 shadow-lg"
|
||||
: "border-gray-700/40 bg-gray-800/40"
|
||||
? "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-violet-600 text-white text-xs rounded-full font-bold">
|
||||
<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-white">{pkg.credits}</div>
|
||||
<div className="text-gray-400 text-xs mb-3">crédits</div>
|
||||
<div className="text-xl font-bold text-white">{pkg.price} €</div>
|
||||
<div className="text-gray-500 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-gray-700 hover:bg-gray-600 text-white text-xs transition-all">
|
||||
<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-400" />, title: "Chiffrement de bout en bout", sub: "TLS 1.3 + AES-256 au repos" },
|
||||
{ icon: <Globe className="w-6 h-6 text-blue-400" />, title: "130+ langues", sub: "Dont arabe, persan, hébreu (RTL)" },
|
||||
{ icon: <Gauge className="w-6 h-6 text-violet-400" />, title: "Traitement parallèle", sub: "IA multi-thread ultra-rapide" },
|
||||
{ icon: <Clock className="w-6 h-6 text-amber-400" />, title: "Disponible 24/7", sub: "Uptime garanti 99,9 %" },
|
||||
{ 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-gray-800/30 border border-gray-700/30">
|
||||
<div className="p-3 rounded-full bg-gray-700/50 mb-3">{t.icon}</div>
|
||||
<div className="font-semibold text-white text-sm mb-1">{t.title}</div>
|
||||
<div className="text-gray-500 text-xs">{t.sub}</div>
|
||||
<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-violet-900/20 to-gray-800/40 border border-violet-500/20">
|
||||
<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-violet-400" />
|
||||
<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-gray-800/50 border border-gray-700/40">
|
||||
<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-400" />
|
||||
<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-600/20 text-blue-300 border-blue-600/30">Forfait Pro</Badge>
|
||||
<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-gray-400 mb-3">
|
||||
Basée sur <strong className="text-white">DeepSeek V3.2</strong> — le modèle IA le plus rentable de 2026.
|
||||
<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-gray-700/50 rounded">163K tokens de contexte</span>
|
||||
<span className="px-2 py-1 bg-gray-700/50 rounded">$0.25/$0.38 per 1M</span>
|
||||
<span className="px-2 py-1 bg-emerald-600/20 text-emerald-300 rounded">Excellent rapport qualité/prix</span>
|
||||
<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-gray-800/50 border border-violet-700/40">
|
||||
<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-violet-400" />
|
||||
<Crown className="w-4 h-4 text-accent" />
|
||||
<span className="font-semibold">Traduction IA Premium</span>
|
||||
<Badge className="ml-auto text-xs bg-violet-600/20 text-violet-300 border-violet-600/30">Forfait Business</Badge>
|
||||
<Badge className="ml-auto text-xs bg-accent/10 text-accent border-accent/30">Forfait Business</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 mb-3">
|
||||
Basée sur <strong className="text-white">Claude 3.5 Haiku</strong> d'Anthropic — précis sur les documents
|
||||
<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-gray-700/50 rounded">200K tokens de contexte</span>
|
||||
<span className="px-2 py-1 bg-gray-700/50 rounded">$0.25/$1.25 per 1M</span>
|
||||
<span className="px-2 py-1 bg-violet-600/20 text-violet-300 rounded">Meilleure précision</span>
|
||||
<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>
|
||||
@@ -635,20 +814,20 @@ export default function PricingPage() {
|
||||
{FAQS.map((faq, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-xl border border-gray-700/40 bg-gray-800/30 overflow-hidden"
|
||||
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-gray-700/20 transition-colors"
|
||||
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-gray-200">{faq.q}</span>
|
||||
<span className="font-medium text-foreground">{faq.q}</span>
|
||||
{openFAQ === i
|
||||
? <ChevronUp className="w-5 h-5 text-gray-400 flex-shrink-0" />
|
||||
: <ChevronDown className="w-5 h-5 text-gray-400 flex-shrink-0" />
|
||||
? <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-gray-400 text-sm leading-relaxed border-t border-gray-700/30 pt-4">
|
||||
<div className="px-5 pb-5 text-muted-foreground text-sm leading-relaxed border-t border-border/30 pt-4">
|
||||
{faq.a}
|
||||
</div>
|
||||
)}
|
||||
@@ -658,20 +837,20 @@ export default function PricingPage() {
|
||||
</div>
|
||||
|
||||
{/* ── CTA bottom ── */}
|
||||
<div className="mt-20 text-center p-12 rounded-2xl bg-gradient-to-br from-violet-900/30 to-gray-800/40 border border-violet-500/20">
|
||||
<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-gray-400 mb-8 max-w-lg mx-auto">
|
||||
<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-violet-600 hover:bg-violet-500 text-white px-8 py-3 text-base font-semibold">
|
||||
<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="border-gray-600 text-gray-300 hover:bg-gray-700/30 px-8 py-3 text-base">
|
||||
<Button variant="outline" className="px-8 py-3 text-base">
|
||||
Se connecter
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -687,11 +866,11 @@ function Stat({ icon, label, value, highlight }: { icon: React.ReactNode; label:
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-lg text-xs",
|
||||
highlight ? "bg-violet-500/10 border border-violet-500/20" : "bg-gray-800/40 border border-gray-700/30"
|
||||
highlight ? "bg-accent/10 border border-accent/20" : "bg-muted/40 border border-border/30"
|
||||
)}>
|
||||
<span className={highlight ? "text-violet-400" : "text-gray-500"}>{icon}</span>
|
||||
<span className="text-gray-400">{label} :</span>
|
||||
<span className={cn("font-medium ml-auto", highlight ? "text-violet-300" : "text-gray-200")}>{value}</span>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user