All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m51s
P0 a11y: keyboard-accessible dropzone (role/tabIndex/Enter-Space), ARIA combobox + listbox pattern for language selector, role=switch on glossary toggle, role=status live region on notifications, aria-labels on password toggles, visible-on-focus close buttons, RTL logical positioning (start-*). Trust: third-party Memento promo removed from translate page, sidebar and all 13 locales; fabricated stats (99.9%, Turbo, computed layout-integrity bars) replaced with real measurements incl. API estimated remaining time; silent download failure now surfaces an error notification. Honesty: fake 100-byte file injections removed (format chips are now informational); cancel-that-doesn't renamed 'Back to start' with hint; Enterprise contact placeholder replaced with contact@wordly.art. i18n: t() no longer returns raw keys (empty string + defaultValue support, ~30 dead || fallbacks now work); ~170 new keys EN+FR across new reviews/ teams namespaces, glossaries context tab, translate monitor, settings, services, pricing, landing, fileUploader; split-key italic titles replace lastIndexOf() surgery (zh/ja-safe); key-audit script added 0 missing. Flow: active job persisted across refresh with polling resume (24h TTL); client-side recent-jobs history with review links; review page linked from complete state; settings/services added to dashboard nav; Business/ Enterprise regain glossary access (tier gate unified). Typeset (sober-tool direction): 7.5-9px labels raised to 10-12px, /30 opacity to /45-/55, uppercase tracking reduced, trust footer legible, country flags removed from language switcher, localized dates. Cleanup: 5 orphaned translate components, dead site header/footer, fossil tailwind.config.js, PipelineStepper, duplicate pill+H1 titles, two-step confirm for cache clear, dead landing footer links. Verified: next build exit 0, vitest 9/9, eslint 64 errors = HEAD (no regression, -3 warnings), detector 4 -> 3 findings.
948 lines
44 KiB
TypeScript
948 lines
44 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 { 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", "deepl"],
|
|
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", "deepl", "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", "deepl", "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,
|
|
};
|
|
|
|
const PLAN_COLORS: Record<string, { header: string; iconColor: string; nameColor: string }> = {
|
|
free: { header: "bg-muted", iconColor: "text-foreground/20", nameColor: "text-foreground/40" },
|
|
starter: { header: "bg-foreground", iconColor: "text-white/20", nameColor: "text-white/50" },
|
|
pro: { header: "bg-accent", iconColor: "text-white/30", nameColor: "text-white/50" },
|
|
business: { header: "bg-foreground", iconColor: "text-accent/40", nameColor: "text-white/50" },
|
|
enterprise: { header: "bg-[#252525]", iconColor: "text-white/10", nameColor: "text-white/50" },
|
|
};
|
|
|
|
/** 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);
|
|
|
|
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(() => {});
|
|
}
|
|
}, []);
|
|
|
|
// 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
|
|
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:${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-[10px] font-black 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-[9px] font-black uppercase tracking-widest text-foreground/40 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-black uppercase tracking-widest text-foreground shadow-sm border border-black/5"
|
|
>
|
|
{t('pricing.nav.mySubscription')}
|
|
</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-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="inline-flex items-center gap-2 px-4 py-1.5 rounded-full border border-black/5 mb-6">
|
|
<div className="w-2 h-2 bg-accent rounded-full animate-pulse" />
|
|
<span className="text-[10px] font-black tracking-[0.3em] opacity-40">{t('pricing.header.badge')}</span>
|
|
</span>
|
|
<h1 className="text-5xl md:text-7xl font-black uppercase tracking-tighter mb-6 leading-none">
|
|
{t('pricing.header.titleBase')}{" "}
|
|
<span className="text-accent">
|
|
{t('pricing.header.titleAccent')}
|
|
</span>
|
|
</h1>
|
|
<p className="text-foreground/40 font-medium text-xl 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-[10px] font-black uppercase tracking-widest transition-all ${!isYearly ? 'bg-foreground text-white shadow-xl' : 'text-foreground/60 hover:text-foreground'}`}
|
|
>
|
|
{t('pricing.billing.monthly')}
|
|
</button>
|
|
<button
|
|
onClick={() => setIsYearly(true)}
|
|
className={`px-8 py-3 rounded-full text-[10px] font-black uppercase tracking-widest transition-all ${isYearly ? 'bg-foreground text-white shadow-xl' : '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 />
|
|
) : (
|
|
<>
|
|
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-6 items-stretch">
|
|
{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";
|
|
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 section ── */}
|
|
<div className={cn("p-8 text-white relative h-48 flex flex-col justify-end", colors.header)}>
|
|
{/* Badges for popular/current plan */}
|
|
{plan.popular && (
|
|
<div className="absolute top-0 right-0 p-3 flex gap-2">
|
|
{plan.badge && (
|
|
<span className="bg-foreground/20 backdrop-blur-md text-white text-[8px] font-black uppercase tracking-widest px-3 py-1 rounded-full border border-white/20 shadow-lg">
|
|
{t(plan.badge)}
|
|
</span>
|
|
)}
|
|
{isCurrent && (
|
|
<span className="bg-foreground/40 backdrop-blur-md text-white text-[8px] font-black uppercase tracking-widest px-3 py-1 rounded-full border border-white/20 shadow-lg flex items-center gap-1">
|
|
<div className="w-1.5 h-1.5 bg-accent rounded-full animate-pulse" /> {t('pricing.card.myPlan')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{plan.badge && !plan.popular && (
|
|
<div className="absolute top-0 right-0 p-3">
|
|
<span className="bg-white/10 backdrop-blur-md text-white text-[8px] font-black uppercase tracking-widest px-3 py-1 rounded-full border border-white/10">
|
|
{t(plan.badge)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{/* "My Plan" badge for current non-popular plan */}
|
|
{isCurrent && !plan.popular && (
|
|
<div className="absolute top-0 right-0 p-3">
|
|
<span className="bg-foreground/40 backdrop-blur-md text-white text-[8px] font-black uppercase tracking-widest px-3 py-1 rounded-full border border-white/20 shadow-lg flex items-center gap-1">
|
|
<div className="w-1.5 h-1.5 bg-accent rounded-full animate-pulse" /> {t('pricing.card.myPlan')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Icon + plan name */}
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<Icon size={20} className={colors.iconColor} />
|
|
<span className={cn("text-sm font-black uppercase tracking-widest", isFree ? "text-foreground/40" : colors.nameColor)}>
|
|
{t(plan.name)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Price */}
|
|
<div className="flex items-baseline gap-2">
|
|
{isEnterprise ? (
|
|
<h3 className={cn("text-3xl font-black uppercase tracking-tighter", isFree ? "text-foreground" : "text-white")}>
|
|
{t('pricing.card.onRequest')}
|
|
</h3>
|
|
) : price === 0 ? (
|
|
<h3 className="text-3xl font-black uppercase tracking-tighter text-foreground">
|
|
{t('pricing.card.free')}
|
|
</h3>
|
|
) : (
|
|
<>
|
|
<h3 className="text-3xl font-black uppercase tracking-tighter text-white">{price} €</h3>
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-white/40">{t('pricing.card.perMonth')}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Yearly billing note */}
|
|
{isYearly && plan.price_yearly > 0 && (
|
|
<div className="text-white/70 text-[10px] mt-1">
|
|
{t('pricing.card.billedYearly', { price: plan.price_yearly.toFixed(2) })}
|
|
</div>
|
|
)}
|
|
|
|
{/* Description */}
|
|
<p className={cn("text-[10px] font-medium uppercase mt-3 tracking-widest leading-relaxed", isFree ? "text-foreground/40" : "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/20" />
|
|
<span className="text-[9px] font-black uppercase tracking-widest text-foreground/40">{t('pricing.card.documents')}</span>
|
|
</div>
|
|
<span className="text-[10px] font-black uppercase 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/20" />
|
|
<span className="text-[9px] font-black uppercase tracking-widest text-foreground/40">{t('pricing.card.pagesMax')}</span>
|
|
</div>
|
|
<span className="text-[10px] font-black uppercase 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/20"
|
|
} />
|
|
<span className={cn(
|
|
"text-[9px] font-black uppercase tracking-widest",
|
|
plan.ai_tier === "essential" ? "text-accent/60" :
|
|
"text-foreground/40"
|
|
)}>{t('pricing.card.aiTranslation')}</span>
|
|
</div>
|
|
<span className={cn(
|
|
"text-[9px] font-black 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-[10px] font-bold text-foreground/60 leading-normal">{t(feat)}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
{/* CTA */}
|
|
{isCurrent ? (
|
|
<Link
|
|
href="/dashboard/profile"
|
|
className="w-full py-4 rounded-2xl text-[11px] font-black uppercase tracking-widest 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-[11px] font-black uppercase tracking-widest 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={() => handleSubscribe(plan.id)}
|
|
disabled={loadingPlanId !== null}
|
|
className={cn(
|
|
"w-full py-4 rounded-2xl text-[11px] font-black uppercase tracking-widest 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>
|
|
|
|
{/* ── 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.deepl'), vals: plans.slice(0,4).map(p => p.providers.includes("deepl") || p.providers.includes("all")) },
|
|
{ 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>
|
|
);
|
|
}
|