fix(ui): wave 2 from critique re-run — AA contrast, unified PageHeader, checkout confirm
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m46s

Nav: teams/settings/services back out of the nav until product-ready (user
decision); teams page was already half-finished (UUID member display is a
backend limitation).

Payment: explicit confirmation dialog (plan, amount, billing period, Stripe
note) before any redirect; ?plan= URL now pre-opens the dialog instead of
triggering a silent checkout.

Contrast AA sweep (71 replacements): functional micro-labels raised to
>=60-65% opacity and >=10px across sidebar, header, pricing, landing;
decorative all-caps 'interface' label removed.

Design unification: new PageHeader component (accent pill + serif
base/accent title) applied to settings, services, reviews, teams; pricing
header returned to the editorial voice (serif + accent pill); dead
GlossaryCard deleted.

i18n residuals: suggestion chips, 'Standard' provider label, notification
close, model-combobox strings extracted (+broken bg-surface/border-border-
subtle tokens fixed); ~45 new keys EN+FR; 13 dead keys removed; zero
missing keys verified.

Reviews: icon-only row actions now carry visible text; 'Approve all' is
two-step armed-confirm.

Accelerators: Ctrl/Cmd+Enter submits; arrow-key navigation in the language
combobox; recent-jobs history cap 8->20 with per-job download; source=target
config rejected; empty 'Master Quality' badge removed.

Cookie consent: reopenable via footer link, emoji replaced with drawn icon.

Verified: build exit 0, vitest 9/9, eslint 64 errors = previous level,
0 missing i18n keys.
This commit is contained in:
2026-08-30 22:02:35 +02:00
parent 50047ea8a2
commit 111f3cb69d
41 changed files with 470 additions and 280 deletions

View File

@@ -11,6 +11,9 @@ import {
} 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";
@@ -213,7 +216,7 @@ const PLAN_ICONS: Record<string, any> = {
};
const PLAN_COLORS: Record<string, { header: string; iconColor: string; nameColor: string }> = {
free: { header: "bg-muted", iconColor: "text-foreground/20", nameColor: "text-foreground/40" },
free: { header: "bg-muted", iconColor: "text-foreground/40", 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" },
@@ -292,6 +295,8 @@ export default function PricingPage() {
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)
@@ -335,14 +340,13 @@ export default function PricingPage() {
}
}, []);
// Auto-trigger checkout when ?plan= is in the URL and user is already logged in
// ?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) {
// Directly initiate checkout
handleSubscribe(planFromUrl);
setConfirmPlan(targetPlan);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -465,7 +469,7 @@ export default function PricingPage() {
<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"
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>
@@ -481,6 +485,47 @@ export default function PricingPage() {
</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(
@@ -497,17 +542,17 @@ export default function PricingPage() {
{/* ── 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 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="text-5xl md:text-7xl font-black uppercase tracking-tighter mb-6 leading-none">
<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="text-accent">
<span className="italic text-accent">
{t('pricing.header.titleAccent')}
</span>
</h1>
<p className="text-foreground/40 font-medium text-xl max-w-2xl mx-auto leading-relaxed">
<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>
@@ -560,12 +605,12 @@ export default function PricingPage() {
{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">
<span className="bg-foreground/20 backdrop-blur-md text-white text-[10px] font-black uppercase tracking-wider 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">
<span className="bg-foreground/40 backdrop-blur-md text-white text-[10px] font-black uppercase tracking-wider 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>
)}
@@ -573,7 +618,7 @@ export default function PricingPage() {
)}
{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">
<span className="bg-white/10 backdrop-blur-md text-white text-[10px] font-black uppercase tracking-wider px-3 py-1 rounded-full border border-white/10">
{t(plan.badge)}
</span>
</div>
@@ -581,7 +626,7 @@ export default function PricingPage() {
{/* "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">
<span className="bg-foreground/40 backdrop-blur-md text-white text-[10px] font-black uppercase tracking-wider 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>
@@ -608,7 +653,7 @@ export default function PricingPage() {
) : (
<>
<h3 className="text-3xl font-black uppercase tracking-tighter text-white">{price} &euro;</h3>
<span className="text-[10px] font-bold uppercase tracking-widest text-white/40">{t('pricing.card.perMonth')}</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-white/70">{t('pricing.card.perMonth')}</span>
</>
)}
</div>
@@ -632,8 +677,8 @@ export default function PricingPage() {
<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>
<FileText size={12} className="text-foreground/40" />
<span className="text-[11px] font-bold uppercase tracking-wider text-foreground/60">{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')}`}
@@ -641,8 +686,8 @@ export default function PricingPage() {
</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>
<Layers size={12} className="text-foreground/40" />
<span className="text-[11px] font-bold uppercase tracking-wider text-foreground/60">{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')}`}
@@ -658,7 +703,7 @@ export default function PricingPage() {
<div className="flex items-center gap-2">
<Activity size={12} className={
plan.ai_tier === "essential" ? "text-accent/40" :
"text-foreground/20"
"text-foreground/40"
} />
<span className={cn(
"text-[9px] font-black uppercase tracking-widest",
@@ -711,7 +756,7 @@ export default function PricingPage() {
</Link>
) : (
<button
onClick={() => handleSubscribe(plan.id)}
onClick={() => setConfirmPlan(plan)}
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",