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
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:
@@ -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} €</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",
|
||||
|
||||
Reference in New Issue
Block a user