feat(ui,api): wave 3 — editorial pricing, real cancel, server history, DeepL purge
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m36s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m36s
Pricing: full editorial redesign — serif card headers with accent pills
replace the colored font-black blocks, tone sweep across toggle/metrics/
features/CTAs, PLAN_COLORS removed; one design system app-wide.
Translate: decorative titles one step down (CTA hierarchy restored);
glossary and image-translation blocks hidden entirely for free users
(progressive disclosure — three controls for free).
Reviews: XLIFF hint line explains the exchange format; backend errors
routed through a friendly mapper (session/not-found/rate-limit/server).
Landing: fabricated hero UI cards (fake 'Context Engine' overlay)
removed — the photo no longer promises screens that don't exist.
Nav: single DashboardNavLinks component shared by sidebar and mobile
drawer (was duplicated markup).
API: GET /api/v1/translations (user job history, paginated; completed
jobs retained 24h) and POST /api/v1/translations/{id}/cancel —
cooperative cancellation with worker checkpoints before dispatch and
before finalisation, reserved quota released immediately. Translate
monitor now offers a real 'Cancel translation' next to 'Back to start';
recent-jobs list reads server history first, localStorage fallback.
DeepL purge (backend): provider module, registry registration, config
attrs/defaults, dispatch branch, admin settings schema + test branch,
legacy availability block, validation rules, plan provider lists,
error-code mappings, MCP enums, translator prompt mention, related
tests updated/removed. Fallback resolver skips unknown providers, so
stale chains containing 'deepl' degrade gracefully.
Verified: backend 110 tests passed; frontend build exit 0, vitest 9/9,
0 missing i18n keys, eslint 63 errors (vs 64 at HEAD).
This commit is contained in:
@@ -215,14 +215,6 @@ const PLAN_ICONS: Record<string, any> = {
|
||||
enterprise: Shield,
|
||||
};
|
||||
|
||||
const PLAN_COLORS: Record<string, { header: string; iconColor: string; nameColor: string }> = {
|
||||
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" },
|
||||
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 (
|
||||
@@ -460,7 +452,7 @@ export default function PricingPage() {
|
||||
<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"
|
||||
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')}
|
||||
@@ -476,7 +468,7 @@ export default function PricingPage() {
|
||||
{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"
|
||||
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>
|
||||
@@ -562,13 +554,13 @@ export default function PricingPage() {
|
||||
<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'}`}
|
||||
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-[10px] font-black uppercase tracking-widest transition-all ${isYearly ? 'bg-foreground text-white shadow-xl' : 'text-foreground/60 hover:text-foreground'}`}
|
||||
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>
|
||||
@@ -585,7 +577,6 @@ export default function PricingPage() {
|
||||
<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";
|
||||
@@ -599,43 +590,34 @@ export default function PricingPage() {
|
||||
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">
|
||||
{/* ── 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="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">
|
||||
<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-accent">
|
||||
{t(plan.badge)}
|
||||
</span>
|
||||
)}
|
||||
{isCurrent && (
|
||||
<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 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.badge && !plan.popular && (
|
||||
<div className="absolute top-0 right-0 p-3">
|
||||
<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>
|
||||
)}
|
||||
{/* "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-[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>
|
||||
{!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="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)}>
|
||||
<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-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>
|
||||
@@ -643,30 +625,30 @@ export default function PricingPage() {
|
||||
{/* Price */}
|
||||
<div className="flex items-baseline gap-2">
|
||||
{isEnterprise ? (
|
||||
<h3 className={cn("text-3xl font-black uppercase tracking-tighter", isFree ? "text-foreground" : "text-white")}>
|
||||
<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-black uppercase tracking-tighter text-foreground">
|
||||
<h3 className="text-3xl font-serif font-medium tracking-tight text-brand-dark dark:text-white">
|
||||
{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/70">{t('pricing.card.perMonth')}</span>
|
||||
<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="text-white/70 text-[10px] mt-1">
|
||||
<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={cn("text-[10px] font-medium uppercase mt-3 tracking-widest leading-relaxed", isFree ? "text-foreground/40" : "text-white/60")}>
|
||||
<p className="mt-3 text-xs font-light leading-relaxed text-brand-dark/60 dark:text-white/60">
|
||||
{t(plan.description || '')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -678,18 +660,18 @@ export default function PricingPage() {
|
||||
<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-bold uppercase tracking-wider text-foreground/60">{t('pricing.card.documents')}</span>
|
||||
<span className="text-[11px] font-semibold text-foreground/70">{t('pricing.card.documents')}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-black uppercase text-foreground">
|
||||
<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-bold uppercase tracking-wider text-foreground/60">{t('pricing.card.pagesMax')}</span>
|
||||
<span className="text-[11px] font-semibold text-foreground/70">{t('pricing.card.pagesMax')}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-black uppercase text-foreground">
|
||||
<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>
|
||||
@@ -706,13 +688,13 @@ export default function PricingPage() {
|
||||
"text-foreground/40"
|
||||
} />
|
||||
<span className={cn(
|
||||
"text-[9px] font-black uppercase tracking-widest",
|
||||
"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-[9px] font-black uppercase",
|
||||
"text-[10px] font-bold uppercase",
|
||||
plan.ai_tier === "essential" ? "text-accent" :
|
||||
plan.ai_tier === "premium" ? "text-foreground" :
|
||||
"text-foreground"
|
||||
@@ -732,7 +714,7 @@ export default function PricingPage() {
|
||||
<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>
|
||||
<span className="text-[11px] font-medium text-foreground/75 leading-normal">{t(feat)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -741,7 +723,7 @@ export default function PricingPage() {
|
||||
{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"
|
||||
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" />
|
||||
@@ -749,7 +731,7 @@ export default function PricingPage() {
|
||||
) : 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"
|
||||
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" />
|
||||
@@ -759,7 +741,7 @@ export default function PricingPage() {
|
||||
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",
|
||||
"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",
|
||||
|
||||
Reference in New Issue
Block a user