All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m58s
Zone de dépôt : le contour s'allume en doré et la carte s'agrandit quand on la survole avec un fichier (l'information existait, elle n'était jamais affichée). Or lisible : nouveau jeton « or encre » (#8B6F47) pour les textes, l'or clair reste aux traits, fonds et au mode sombre ; 52 textes dorés convertis, pastille comprises ; plus aucun texte sous 10 px (8,5 px → 11 px dans le sélecteur de moteurs). Tarifs : le choix redevient binaire — les trois abonnements payants en grandes cartes, le gratuit en ligne discrète au-dessus, le sur mesure en ligne dorée dessous. Le tableau comparatif complet reste dessous. Accueil : le mur de six cartes génériques est remplacé par un vrai avant/après — une page « Cahier des charges — Traitement d'air » à côté de sa traduction anglaise, structure identique au mot près (terme technique en gras des deux côtés), sceau « Même mise en page, mot pour mot » entre les deux, et trois preuves précises (SmartArt reconstruits, séries de graphiques traduites, tables des matières régénérées) à la place des promesses vagues.
1000 lines
46 KiB
TypeScript
1000 lines
46 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import Link from "next/link";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import {
|
|
Check, CheckCircle2, X, Zap, Building2, Crown, Sparkles, ArrowRight,
|
|
ArrowLeft, ChevronLeft, Star, Shield, Rocket, Users, Headphones, Lock,
|
|
Globe, Clock, ChevronDown, ChevronUp, Cpu, BarChart3, Infinity,
|
|
FileText, Layers, Brain, BadgeCheck, Gauge, Activity,
|
|
} from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { cn } from "@/lib/utils";
|
|
import { API_BASE } from "@/lib/config";
|
|
import { ANNUAL_DISCOUNT_PERCENT } from "@/lib/pricing";
|
|
import { useI18n } from "@/lib/i18n";
|
|
|
|
// Enterprise contact — single place to update the sales address.
|
|
const SUPPORT_EMAIL = "contact@wordly.art";
|
|
|
|
/* ─────────────────────────────────────────────
|
|
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)
|
|
Names, descriptions, features, badges use i18n keys
|
|
resolved via t() in the render layer.
|
|
───────────────────────────────────────────── */
|
|
const STATIC_PLANS: Plan[] = [
|
|
{
|
|
id: "free",
|
|
name: "pricing.plans.free.name",
|
|
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: [
|
|
"pricing.plans.free.feat1",
|
|
"pricing.plans.free.feat2",
|
|
"pricing.plans.free.feat3",
|
|
"pricing.plans.free.feat4",
|
|
"pricing.plans.free.feat5",
|
|
],
|
|
ai_translation: false,
|
|
api_access: false,
|
|
priority_processing: false,
|
|
popular: false,
|
|
description: "pricing.plans.free.description",
|
|
},
|
|
{
|
|
id: "starter",
|
|
name: "pricing.plans.starter.name",
|
|
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"],
|
|
features: [
|
|
"pricing.plans.starter.feat1",
|
|
"pricing.plans.starter.feat2",
|
|
"pricing.plans.starter.feat3",
|
|
"pricing.plans.starter.feat4",
|
|
"pricing.plans.starter.feat5",
|
|
"pricing.plans.starter.feat6",
|
|
],
|
|
ai_translation: false,
|
|
api_access: false,
|
|
priority_processing: false,
|
|
popular: false,
|
|
description: "pricing.plans.starter.description",
|
|
},
|
|
{
|
|
id: "pro",
|
|
name: "pricing.plans.pro.name",
|
|
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", "openrouter"],
|
|
features: [
|
|
"pricing.plans.pro.feat1",
|
|
"pricing.plans.pro.feat2",
|
|
"pricing.plans.pro.feat3",
|
|
"pricing.plans.pro.feat4",
|
|
"pricing.plans.pro.feat5",
|
|
"pricing.plans.pro.feat6",
|
|
"pricing.plans.pro.feat7",
|
|
"pricing.plans.pro.feat8",
|
|
],
|
|
ai_translation: true,
|
|
ai_tier: "essential",
|
|
api_access: false,
|
|
priority_processing: true,
|
|
popular: true,
|
|
highlight: "pricing.plans.pro.highlight",
|
|
description: "pricing.plans.pro.description",
|
|
badge: "pricing.plans.pro.badge",
|
|
},
|
|
{
|
|
id: "business",
|
|
name: "pricing.plans.business.name",
|
|
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", "openrouter", "openrouter_premium", "openai"],
|
|
features: [
|
|
"pricing.plans.business.feat1",
|
|
"pricing.plans.business.feat2",
|
|
"pricing.plans.business.feat3",
|
|
"pricing.plans.business.feat4",
|
|
"pricing.plans.business.feat5",
|
|
"pricing.plans.business.feat6",
|
|
"pricing.plans.business.feat7",
|
|
"pricing.plans.business.feat8",
|
|
"pricing.plans.business.feat9",
|
|
"pricing.plans.business.feat10",
|
|
],
|
|
ai_translation: true,
|
|
ai_tier: "premium",
|
|
api_access: true,
|
|
priority_processing: true,
|
|
team_seats: 5,
|
|
popular: false,
|
|
description: "pricing.plans.business.description",
|
|
},
|
|
{
|
|
id: "enterprise",
|
|
name: "pricing.plans.enterprise.name",
|
|
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: [
|
|
"pricing.plans.enterprise.feat1",
|
|
"pricing.plans.enterprise.feat2",
|
|
"pricing.plans.enterprise.feat3",
|
|
"pricing.plans.enterprise.feat4",
|
|
"pricing.plans.enterprise.feat5",
|
|
"pricing.plans.enterprise.feat6",
|
|
"pricing.plans.enterprise.feat7",
|
|
"pricing.plans.enterprise.feat8",
|
|
],
|
|
ai_translation: true,
|
|
ai_tier: "custom",
|
|
api_access: true,
|
|
priority_processing: true,
|
|
team_seats: -1,
|
|
popular: false,
|
|
description: "pricing.plans.enterprise.description",
|
|
badge: "pricing.plans.enterprise.badge",
|
|
},
|
|
];
|
|
|
|
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 — editorial design
|
|
───────────────────────────────────────────── */
|
|
const PLAN_ICONS: Record<string, any> = {
|
|
free: Star,
|
|
starter: Zap,
|
|
pro: Crown,
|
|
business: Globe,
|
|
enterprise: Shield,
|
|
};
|
|
|
|
/** Avoids flash of static prices before the API responds on refresh. */
|
|
function PricingDataSkeleton() {
|
|
return (
|
|
<>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-6">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className="rounded-[24px] border border-black/[0.08] bg-white overflow-hidden animate-pulse"
|
|
>
|
|
<div className="h-48 bg-muted/60" />
|
|
<div className="p-8 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-10 bg-muted rounded-2xl 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: "pricing.faq.q1", a: "pricing.faq.a1" },
|
|
{ q: "pricing.faq.q2", a: "pricing.faq.a2" },
|
|
{ q: "pricing.faq.q3", a: "pricing.faq.a3" },
|
|
{ q: "pricing.faq.q4", a: "pricing.faq.a4" },
|
|
{ q: "pricing.faq.q5", a: "pricing.faq.a5" },
|
|
{ q: "pricing.faq.q6", a: "pricing.faq.a6" },
|
|
{ q: "pricing.faq.q7", a: "pricing.faq.a7" },
|
|
];
|
|
|
|
/* ─────────────────────────────────────────────
|
|
Main component
|
|
───────────────────────────────────────────── */
|
|
export default function PricingPage() {
|
|
const { t } = useI18n();
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const planFromUrl = searchParams.get("plan");
|
|
const billingFromUrl = searchParams.get("billing");
|
|
const [isYearly, setIsYearly] = useState(billingFromUrl === "yearly");
|
|
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 [loadingCreditIdx, setLoadingCreditIdx] = useState<number | null>(null);
|
|
const [toastMsg, setToastMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
|
const [annualDiscountPercent, setAnnualDiscountPercent] = useState(ANNUAL_DISCOUNT_PERCENT);
|
|
/** Until false: don't show STATIC_PLANS (avoids flash of stale prices on refresh). */
|
|
const [pricingLoaded, setPricingLoaded] = useState(false);
|
|
/** Plan awaiting explicit confirmation before any Stripe redirect. */
|
|
const [confirmPlan, setConfirmPlan] = useState<Plan | null>(null);
|
|
|
|
useEffect(() => {
|
|
// Fetch live plans — backend returns prices set by admin (no browser cache)
|
|
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] Cannot load /api/v1/auth/plans — showing default prices.", 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(() => {});
|
|
}
|
|
}, []);
|
|
|
|
// ?plan= in the URL pre-opens the confirmation dialog — never a silent checkout.
|
|
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) {
|
|
setConfirmPlan(targetPlan);
|
|
}
|
|
}
|
|
// 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:${SUPPORT_EMAIL}?subject=${encodeURIComponent(t('pricing.enterprise.subject'))}`;
|
|
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",
|
|
}),
|
|
});
|
|
|
|
// Safe JSON parse — backend may return plain text on 500
|
|
let data: any = {};
|
|
try { data = await res.json(); } catch { /* ignore */ }
|
|
|
|
const url = data.data?.url ?? data.url;
|
|
const backendMessage = data.message ?? data.error ?? data.data?.error;
|
|
|
|
if (!res.ok) {
|
|
throw new Error(backendMessage || t('pricing.error.server', { status: res.status }));
|
|
}
|
|
|
|
if (url) {
|
|
window.location.replace(url);
|
|
} else {
|
|
// Demo mode: Stripe not yet configured
|
|
setToastMsg({
|
|
type: 'ok',
|
|
text: t('pricing.toast.demo', { planId }),
|
|
});
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : t('pricing.toast.networkError');
|
|
setToastMsg({ type: 'err', text: message });
|
|
} finally {
|
|
setLoadingPlanId(null);
|
|
}
|
|
};
|
|
|
|
const handleBuyCredits = async (packageIndex: number) => {
|
|
const token = localStorage.getItem("token");
|
|
if (!token) {
|
|
router.push(`/auth/login?redirect=/pricing`);
|
|
return;
|
|
}
|
|
setLoadingCreditIdx(packageIndex);
|
|
setToastMsg(null);
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/v1/auth/create-credits-checkout`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ package_index: packageIndex }),
|
|
});
|
|
|
|
// Safe JSON parse
|
|
let data: any = {};
|
|
try { data = await res.json(); } catch { /* ignore */ }
|
|
|
|
const url = data.data?.url ?? data.url;
|
|
if (!res.ok) {
|
|
throw new Error(data.message ?? data.error ?? t('pricing.error.server', { status: res.status }));
|
|
}
|
|
if (url) {
|
|
window.location.replace(url);
|
|
} else {
|
|
setToastMsg({ type: 'ok', text: t('pricing.toast.demo', { planId: 'credits' }) });
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : t('pricing.toast.networkError');
|
|
setToastMsg({ type: 'err', text: message });
|
|
} finally {
|
|
setLoadingCreditIdx(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background text-foreground">
|
|
{/* ── Top navigation — breadcrumb bar ── */}
|
|
<div className="max-w-[1400px] mx-auto px-4 pt-4">
|
|
<div className="flex justify-between items-center mb-20">
|
|
<button
|
|
onClick={() => router.back()}
|
|
className="flex items-center gap-3 text-[11px] font-bold uppercase tracking-[0.4em] text-foreground/30 hover:text-foreground transition-all group"
|
|
>
|
|
<ChevronLeft size={16} className="group-hover:-translate-x-1 transition-transform" />
|
|
{t('pricing.nav.back')}
|
|
</button>
|
|
|
|
<div className="flex gap-2 p-1.5 bg-muted rounded-full border border-black/5 shadow-inner">
|
|
<Link
|
|
href={isLoggedIn ? "/dashboard" : "/"}
|
|
className="px-6 py-2 rounded-full text-[11px] font-bold uppercase tracking-wider text-foreground/60 hover:text-foreground transition-all"
|
|
>
|
|
{t('pricing.dashboard')}
|
|
</Link>
|
|
{isLoggedIn && (
|
|
<Link
|
|
href="/dashboard/profile"
|
|
className="px-6 py-2 bg-white rounded-full text-[9px] font-bold uppercase tracking-wider text-foreground shadow-sm border border-black/5"
|
|
>
|
|
{t('pricing.nav.mySubscription')}
|
|
</Link>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Checkout confirmation — no Stripe redirect without an explicit confirm ── */}
|
|
<Dialog open={!!confirmPlan} onOpenChange={(open) => { if (!open) setConfirmPlan(null); }}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{t('pricing.confirm.title')}</DialogTitle>
|
|
<DialogDescription>{t('pricing.confirm.subtitle')}</DialogDescription>
|
|
</DialogHeader>
|
|
{confirmPlan && (
|
|
<div className="space-y-4 py-2">
|
|
<div className="flex items-center justify-between rounded-xl border border-border/60 px-4 py-3">
|
|
<span className="text-sm font-semibold">{t(confirmPlan.name)}</span>
|
|
<span className="text-lg font-bold">
|
|
{isYearly
|
|
? `${Number(confirmPlan.price_yearly.toFixed(2))} € / ${t('pricing.confirm.year')}`
|
|
: `${Number(confirmPlan.price_monthly.toFixed(2))} € / ${t('pricing.confirm.month')}`}
|
|
</span>
|
|
</div>
|
|
{isYearly && confirmPlan.price_yearly > 0 && (
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('pricing.confirm.monthlyEquivalent', { price: (confirmPlan.price_yearly / 12).toFixed(2) })}
|
|
</p>
|
|
)}
|
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
{t('pricing.confirm.secureNote')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
<DialogFooter className="gap-2">
|
|
<Button variant="outline" onClick={() => setConfirmPlan(null)}>
|
|
{t('pricing.confirm.cancel')}
|
|
</Button>
|
|
<Button
|
|
disabled={loadingPlanId !== null}
|
|
onClick={() => { if (confirmPlan) handleSubscribe(confirmPlan.id); }}
|
|
>
|
|
{loadingPlanId !== null ? t('pricing.card.processing') : t('pricing.confirm.cta')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* ── 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-emerald-50 border-emerald-200 text-emerald-700 dark:bg-emerald-950/50 dark:border-emerald-800 dark:text-emerald-300"
|
|
: "bg-red-50 border-red-200 text-red-700 dark:bg-red-950/50 dark:border-red-800 dark:text-red-300"
|
|
)}>
|
|
<span className="text-lg">{toastMsg.type === 'ok' ? t('pricing.okSymbol') : t('pricing.errSymbol')}</span>
|
|
<p className="text-sm flex-1">{toastMsg.text}</p>
|
|
<button onClick={() => setToastMsg(null)} aria-label={t('pricing.toast.close')} className="text-muted-foreground hover:text-foreground text-lg leading-none">×</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Header ── */}
|
|
<div className="max-w-[1400px] mx-auto px-4 text-center mb-20">
|
|
<span className="accent-pill mb-6 mx-auto flex w-fit items-center gap-2">
|
|
<span className="h-1.5 w-1.5 rounded-full bg-accent animate-pulse" aria-hidden="true" />
|
|
{t('pricing.header.badge')}
|
|
</span>
|
|
<h1 className="mb-6 text-4xl md:text-6xl font-serif font-medium tracking-tight leading-tight text-brand-dark dark:text-white">
|
|
{t('pricing.header.titleBase')}{" "}
|
|
<span className="italic text-accent">
|
|
{t('pricing.header.titleAccent')}
|
|
</span>
|
|
</h1>
|
|
<p className="text-brand-dark/60 dark:text-white/60 font-light text-lg max-w-2xl mx-auto leading-relaxed">
|
|
{t('pricing.header.subtitle')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* ── Monthly / Yearly toggle ── */}
|
|
<div className="flex items-center justify-center gap-10 mb-20">
|
|
<div className="flex p-1 bg-muted rounded-full border border-black/5 shadow-inner px-2">
|
|
<button
|
|
onClick={() => setIsYearly(false)}
|
|
className={`px-8 py-3 rounded-full text-[11px] font-bold uppercase tracking-wider transition-all ${!isYearly ? 'bg-brand-dark text-white shadow-xl dark:bg-brand-accent dark:text-brand-dark' : 'text-foreground/60 hover:text-foreground'}`}
|
|
>
|
|
{t('pricing.billing.monthly')}
|
|
</button>
|
|
<button
|
|
onClick={() => setIsYearly(true)}
|
|
className={`px-8 py-3 rounded-full text-[11px] font-bold uppercase tracking-wider transition-all ${isYearly ? 'bg-brand-dark text-white shadow-xl dark:bg-brand-accent dark:text-brand-dark' : 'text-foreground/60 hover:text-foreground'}`}
|
|
>
|
|
{t('pricing.billing.yearly')}
|
|
<span className={`ml-2 transition-colors ${isYearly ? 'text-accent' : 'text-accent/60'}`}>−{annualDiscountPercent} %</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Plan cards (skeleton until API responds) ── */}
|
|
<div className="max-w-[1400px] mx-auto px-4 pb-20">
|
|
{!pricingLoaded ? (
|
|
<PricingDataSkeleton />
|
|
) : (
|
|
<>
|
|
{/* Essai gratuit : une ligne discrète au-dessus du choix payant */}
|
|
<div className="mb-8 flex flex-col items-center justify-between gap-4 rounded-2xl border border-black/[0.06] bg-brand-muted/40 px-6 py-4 dark:border-white/[0.08] dark:bg-white/5 sm:flex-row">
|
|
<p className="text-sm font-light text-brand-dark/70 dark:text-white/70">
|
|
{t('pricing.freeBand.text')}
|
|
</p>
|
|
<Link
|
|
href="/auth/register"
|
|
className="shrink-0 rounded-xl border border-black/10 px-5 py-2 text-[11px] font-bold uppercase tracking-wider text-brand-dark/70 transition-colors hover:bg-black/5 dark:border-white/15 dark:text-white/70 dark:hover:bg-white/10"
|
|
>
|
|
{t('pricing.freeBand.cta')}
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="grid gap-6 md:grid-cols-3 items-stretch">
|
|
{plans.filter((p) => !['free', 'enterprise'].includes(p.id)).map((plan) => {
|
|
const Icon = PLAN_ICONS[plan.id] ?? Sparkles;
|
|
const price = displayPrice(plan);
|
|
const isCurrent = currentPlan === plan.id;
|
|
const isEnterprise = plan.id === "enterprise";
|
|
const isFree = plan.id === "free";
|
|
|
|
return (
|
|
<div
|
|
key={plan.id}
|
|
className={cn(
|
|
"flex flex-col bg-white dark:bg-card rounded-[24px] border border-black/[0.08] dark:border-border/40 overflow-hidden transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.08)] hover:-translate-y-2 group",
|
|
plan.popular && "border-accent/30 ring-4 ring-accent/5"
|
|
)}
|
|
>
|
|
{/* ── Header (editorial) ── */}
|
|
<div className="relative border-b border-black/[0.04] p-8 pb-6 dark:border-white/[0.06]">
|
|
{(plan.popular || isCurrent) && (
|
|
<div className="absolute top-5 right-5 flex gap-2">
|
|
{plan.badge && (
|
|
<span className="rounded-full border border-brand-accent/30 bg-brand-accent/10 px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-brand-goldink dark:text-brand-accent">
|
|
{t(plan.badge)}
|
|
</span>
|
|
)}
|
|
{isCurrent && (
|
|
<span className="flex items-center gap-1.5 rounded-full border border-black/10 bg-brand-muted px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-brand-dark/70 dark:border-white/10 dark:bg-white/10 dark:text-white/70">
|
|
<span className="h-1.5 w-1.5 rounded-full bg-brand-accent animate-pulse" /> {t('pricing.card.myPlan')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{!plan.popular && !isCurrent && plan.badge && (
|
|
<span className="absolute top-5 right-5 rounded-full border border-black/[0.06] bg-brand-muted px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-brand-dark/60 dark:border-white/[0.08] dark:bg-white/5 dark:text-white/60">
|
|
{t(plan.badge)}
|
|
</span>
|
|
)}
|
|
|
|
{/* Icon + plan name */}
|
|
<div className="mb-4 flex items-center gap-3">
|
|
<div className="flex size-10 items-center justify-center rounded-xl bg-brand-muted text-brand-goldink dark:text-brand-accent dark:bg-white/10">
|
|
<Icon size={18} />
|
|
</div>
|
|
<span className="text-xs font-bold uppercase tracking-[0.15em] text-brand-dark/70 dark:text-white/70">
|
|
{t(plan.name)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Price */}
|
|
<div className="flex items-baseline gap-2">
|
|
{isEnterprise ? (
|
|
<h3 className="text-3xl font-serif font-medium tracking-tight text-brand-dark dark:text-white">
|
|
{t('pricing.card.onRequest')}
|
|
</h3>
|
|
) : price === 0 ? (
|
|
<h3 className="text-3xl font-serif font-medium tracking-tight text-brand-dark dark:text-white">
|
|
{t('pricing.card.free')}
|
|
</h3>
|
|
) : (
|
|
<>
|
|
<h3 className="text-4xl font-serif font-medium tracking-tight text-brand-dark dark:text-white">{price} €</h3>
|
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-brand-dark/55 dark:text-white/55">{t('pricing.card.perMonth')}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Yearly billing note */}
|
|
{isYearly && plan.price_yearly > 0 && (
|
|
<div className="mt-1 text-[11px] text-brand-dark/55 dark:text-white/55">
|
|
{t('pricing.card.billedYearly', { price: plan.price_yearly.toFixed(2) })}
|
|
</div>
|
|
)}
|
|
|
|
{/* Description */}
|
|
<p className="mt-3 text-xs font-light leading-relaxed text-brand-dark/60 dark:text-white/60">
|
|
{t(plan.description || '')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* ── Content section ── */}
|
|
<div className="p-8 flex-1 flex flex-col">
|
|
{/* Metrics */}
|
|
<div className="space-y-2 mb-10">
|
|
<div className="flex justify-between items-center py-2 border-b border-black/[0.03] dark:border-border/20">
|
|
<div className="flex items-center gap-2">
|
|
<FileText size={12} className="text-foreground/40" />
|
|
<span className="text-[11px] font-semibold text-foreground/70">{t('pricing.card.documents')}</span>
|
|
</div>
|
|
<span className="text-[11px] font-bold text-foreground">
|
|
{plan.docs_per_month === -1 ? t('pricing.card.unlimited') : `${plan.docs_per_month} ${t('pricing.card.perMonthStat')}`}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between items-center py-2 border-b border-black/[0.03] dark:border-border/20">
|
|
<div className="flex items-center gap-2">
|
|
<Layers size={12} className="text-foreground/40" />
|
|
<span className="text-[11px] font-semibold text-foreground/70">{t('pricing.card.pagesMax')}</span>
|
|
</div>
|
|
<span className="text-[11px] font-bold text-foreground">
|
|
{plan.max_pages_per_doc === -1 ? t('pricing.card.unlimited') : `${plan.max_pages_per_doc} ${t('pricing.card.perDoc')}`}
|
|
</span>
|
|
</div>
|
|
{plan.ai_translation && (
|
|
<div className={cn(
|
|
"flex justify-between items-center py-3 px-3 rounded-lg mt-2",
|
|
plan.ai_tier === "essential" ? "bg-accent/5" :
|
|
plan.ai_tier === "premium" ? "bg-foreground/5" :
|
|
"bg-black/5 dark:bg-border/20"
|
|
)}>
|
|
<div className="flex items-center gap-2">
|
|
<Activity size={12} className={
|
|
plan.ai_tier === "essential" ? "text-accent/40" :
|
|
"text-foreground/40"
|
|
} />
|
|
<span className={cn(
|
|
"text-[10px] font-bold uppercase tracking-wider",
|
|
plan.ai_tier === "essential" ? "text-accent/60" :
|
|
"text-foreground/40"
|
|
)}>{t('pricing.card.aiTranslation')}</span>
|
|
</div>
|
|
<span className={cn(
|
|
"text-[10px] font-bold uppercase",
|
|
plan.ai_tier === "essential" ? "text-accent" :
|
|
plan.ai_tier === "premium" ? "text-foreground" :
|
|
"text-foreground"
|
|
)}>
|
|
{plan.ai_tier === "essential" ? t('pricing.card.aiEssential') :
|
|
plan.ai_tier === "premium" ? t('pricing.card.aiEssentialPremium') :
|
|
t('pricing.card.aiCustom')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Features list */}
|
|
<ul className="space-y-4 mb-12 flex-1">
|
|
{plan.features.map((feat, i) => (
|
|
<li key={i} className="flex items-start gap-3">
|
|
<div className="w-4 h-4 rounded-full bg-accent/10 flex items-center justify-center shrink-0 mt-0.5">
|
|
<CheckCircle2 size={10} className="text-accent" />
|
|
</div>
|
|
<span className="text-[11px] font-medium text-foreground/75 leading-normal">{t(feat)}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
{/* CTA */}
|
|
{isCurrent ? (
|
|
<Link
|
|
href="/dashboard/profile"
|
|
className="w-full py-4 rounded-2xl text-xs font-bold uppercase tracking-wider transition-all flex items-center justify-center gap-3 border shadow-sm hover:shadow-xl active:scale-95 bg-muted text-foreground border-black/5 hover:bg-foreground hover:text-white"
|
|
>
|
|
{t('pricing.card.managePlan')}
|
|
<ArrowRight size={14} className="opacity-40" />
|
|
</Link>
|
|
) : isFree && !currentPlan ? (
|
|
<Link
|
|
href="/auth/register"
|
|
className="w-full py-4 rounded-2xl text-xs font-bold uppercase tracking-wider transition-all flex items-center justify-center gap-3 border shadow-sm hover:shadow-xl active:scale-95 bg-foreground text-white border-transparent hover:bg-accent"
|
|
>
|
|
{t('pricing.card.startFree')}
|
|
<ArrowRight size={14} className="opacity-40" />
|
|
</Link>
|
|
) : (
|
|
<button
|
|
onClick={() => setConfirmPlan(plan)}
|
|
disabled={loadingPlanId !== null}
|
|
className={cn(
|
|
"w-full py-4 rounded-2xl text-xs font-bold uppercase tracking-wider transition-all flex items-center justify-center gap-3 border shadow-sm hover:shadow-xl active:scale-95",
|
|
plan.popular
|
|
? "bg-muted text-foreground border-black/5 hover:bg-foreground hover:text-white"
|
|
: "bg-foreground text-white border-transparent hover:bg-accent",
|
|
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>
|
|
{t('pricing.card.processing')}
|
|
</>
|
|
) : (
|
|
<>
|
|
{isEnterprise ? t('pricing.card.contactUs') : t('pricing.card.choosePlan')}
|
|
<ArrowRight size={14} className="opacity-40" />
|
|
</>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Sur mesure : une ligne discrète sous le choix payant */}
|
|
<div className="mt-8 flex flex-col items-center justify-between gap-4 rounded-2xl border border-brand-accent/25 bg-brand-accent/[0.06] px-6 py-4 dark:border-brand-accent/20 dark:bg-brand-accent/10 sm:flex-row">
|
|
<p className="text-sm font-light text-brand-dark/70 dark:text-white/70">
|
|
{t('pricing.enterpriseBand.text')}
|
|
</p>
|
|
<a
|
|
href={`mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent(t('pricing.enterprise.subject'))}`}
|
|
className="shrink-0 rounded-xl border border-brand-accent/40 px-5 py-2 text-[11px] font-bold uppercase tracking-wider text-brand-goldink transition-colors hover:bg-brand-accent/10 dark:text-brand-accent"
|
|
>
|
|
{t('pricing.enterpriseBand.cta')}
|
|
</a>
|
|
</div>
|
|
|
|
{/* ── Feature comparison table ── */}
|
|
<div className="mt-20">
|
|
<h2 className="text-3xl font-bold text-center mb-2">{t('pricing.comparison.title')}</h2>
|
|
<p className="text-muted-foreground text-center mb-10">{t('pricing.comparison.subtitle')}</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-start py-4 px-6 text-muted-foreground font-medium">{t('pricing.comparison.feature')}</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")}>
|
|
{t(p.name)}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{[
|
|
{ label: t('pricing.comparison.docsPerMonth'), vals: plans.slice(0,4).map(p => p.docs_per_month === -1 ? "∞" : String(p.docs_per_month)) },
|
|
{ label: t('pricing.comparison.pagesMaxPerDoc'), vals: plans.slice(0,4).map(p => p.max_pages_per_doc === -1 ? "∞" : String(p.max_pages_per_doc)) },
|
|
{ label: t('pricing.comparison.maxFileSize'), vals: plans.slice(0,4).map(p => p.max_file_size_mb === -1 ? "∞" : `${p.max_file_size_mb} ${t('pricing.comparison.mb')}`) },
|
|
{ label: t('pricing.comparison.googleTranslation'), vals: plans.slice(0,4).map(() => true) },
|
|
{ label: t('pricing.comparison.aiEssential'), vals: plans.slice(0,4).map(p => p.ai_translation && (p.ai_tier === "essential" || p.ai_tier === "premium" || p.ai_tier === "custom")) },
|
|
{ label: t('pricing.comparison.aiPremium'), vals: plans.slice(0,4).map(p => p.ai_translation && (p.ai_tier === "premium" || p.ai_tier === "custom")) },
|
|
{ label: t('pricing.comparison.apiAccess'), vals: plans.slice(0,4).map(p => p.api_access) },
|
|
{ label: t('pricing.comparison.priorityProcessing'), vals: plans.slice(0,4).map(p => p.priority_processing) },
|
|
{ label: t('pricing.comparison.support'), vals: [t('pricing.comparison.support.community'), t('pricing.comparison.support.email'), t('pricing.comparison.support.priority'), t('pricing.comparison.support.dedicated')] },
|
|
].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">{t('pricing.credits.title')}</h2>
|
|
<p className="text-muted-foreground text-center mb-8">
|
|
{t('pricing.credits.subtitle')}
|
|
<span className="text-muted-foreground/70"> {t('pricing.credits.perPage')}</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">
|
|
{t('pricing.credits.bestValue')}
|
|
</div>
|
|
)}
|
|
<div className="text-2xl font-bold text-foreground">{pkg.credits}</div>
|
|
<div className="text-muted-foreground text-xs mb-3">{t('pricing.credits.unit')}</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)} {t('pricing.credits.centsPerCredit')}</div>
|
|
<button
|
|
onClick={() => handleBuyCredits(i)}
|
|
disabled={loadingCreditIdx === i}
|
|
className="mt-3 w-full py-1.5 rounded-lg bg-muted hover:bg-muted/80 text-foreground text-xs transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{loadingCreditIdx === i ? '...' : t('pricing.credits.buy')}
|
|
</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: t('pricing.trust.encryption.title'), sub: t('pricing.trust.encryption.sub') },
|
|
{ icon: <Globe className="w-6 h-6 text-blue-500" />, title: t('pricing.trust.languages.title'), sub: t('pricing.trust.languages.sub') },
|
|
{ icon: <Gauge className="w-6 h-6 text-accent" />, title: t('pricing.trust.parallel.title'), sub: t('pricing.trust.parallel.sub') },
|
|
{ icon: <Clock className="w-6 h-6 text-amber-500" />, title: t('pricing.trust.availability.title'), sub: t('pricing.trust.availability.sub') },
|
|
].map((signal, 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">{signal.icon}</div>
|
|
<div className="font-semibold text-foreground text-sm mb-1">{signal.title}</div>
|
|
<div className="text-muted-foreground text-xs">{signal.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">{t('pricing.aiModels.title')}</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">{t('pricing.aiModels.essential.title')}</span>
|
|
<Badge className="ml-auto text-xs bg-blue-500/10 text-blue-600 border-blue-500/30 dark:text-blue-300">{t('pricing.aiModels.essential.plan')}</Badge>
|
|
</div>
|
|
<div className="text-sm text-muted-foreground mb-3">
|
|
{t('pricing.aiModels.essential.descPrefix')} <strong className="text-foreground">{t('pricing.aiModels.essential.modelName')}</strong> {t('pricing.aiModels.essential.descSuffix')}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2 text-xs">
|
|
<span className="px-2 py-1 bg-muted rounded">{t('pricing.aiModels.essential.context')}</span>
|
|
<span className="px-2 py-1 bg-muted rounded">$0.25/$0.38 per 1M</span>
|
|
<span className="px-2 py-1 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 rounded">{t('pricing.aiModels.essential.value')}</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">{t('pricing.aiModels.premium.title')}</span>
|
|
<Badge className="ml-auto text-xs bg-accent/10 text-accent border-accent/30">{t('pricing.aiModels.premium.plan')}</Badge>
|
|
</div>
|
|
<div className="text-sm text-muted-foreground mb-3">
|
|
{t('pricing.aiModels.premium.descPrefix')} <strong className="text-foreground">Claude Sonnet 4.6</strong> {t('pricing.aiModels.premium.descSuffix')}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2 text-xs">
|
|
<span className="px-2 py-1 bg-muted rounded">{t('pricing.aiModels.premium.context')}</span>
|
|
<span className="px-2 py-1 bg-muted rounded">$3.00/$15.00 per 1M</span>
|
|
<span className="px-2 py-1 bg-accent/10 text-accent rounded">{t('pricing.aiModels.premium.precision')}</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">{t('pricing.faq.title')}</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-start hover:bg-muted/20 transition-colors"
|
|
onClick={() => setOpenFAQ(openFAQ === i ? null : i)}
|
|
>
|
|
<span className="font-medium text-foreground">{t(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">
|
|
{t(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">{t('pricing.cta.title')}</h2>
|
|
<p className="text-muted-foreground mb-8 max-w-lg mx-auto">
|
|
{t('pricing.cta.subtitle')}
|
|
</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">
|
|
{t('pricing.cta.createAccount')}
|
|
<ArrowRight className="ms-2 w-5 h-5" />
|
|
</Button>
|
|
</Link>
|
|
<Link href="/auth/login">
|
|
<Button variant="outline" className="px-8 py-3 text-base">
|
|
{t('pricing.cta.login')}
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|