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

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:
2026-08-30 22:42:29 +02:00
parent 52748ee653
commit 67365918ae
35 changed files with 398 additions and 1572 deletions

View File

@@ -2,30 +2,24 @@
import { useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
Menu,
X,
LogOut
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useUser } from './useUser';
import { useLogout } from './useLogout';
import { baseNavItems } from './constants';
import { DashboardNavLinks } from './DashboardNavLinks';
import { getInitials, translateTier } from './utils';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { useI18n } from '@/lib/i18n';
export function DashboardHeader() {
const [mobileOpen, setMobileOpen] = useState(false);
const pathname = usePathname();
const { data: user, isLoading } = useUser();
const { logout } = useLogout();
const { t } = useI18n();
const isPro = ['pro', 'business', 'enterprise'].includes(user?.tier ?? '');
const navItems = isPro ? baseNavItems : baseNavItems.filter(item => !item.proOnly);
return (
<>
<header className="flex h-20 shrink-0 items-center justify-between border-b border-black/5 dark:border-white/5 bg-white/50 dark:bg-[#141414]/50 backdrop-blur-md px-6 lg:px-8">
@@ -97,25 +91,7 @@ export function DashboardHeader() {
{mobileOpen && (
<div className="border-b border-black/5 dark:border-white/5 bg-white dark:bg-[#141414] px-6 py-4 lg:hidden">
<nav className="flex flex-col gap-1">
{navItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileOpen(false)}
className={cn(
'flex items-center gap-4 rounded-2xl px-6 py-4 text-[11px] font-black uppercase tracking-[0.2em] transition-all duration-200',
isActive
? 'bg-brand-dark text-white shadow-xl'
: 'text-brand-dark/65 dark:text-white/65 hover:bg-brand-muted dark:hover:bg-[#1f1f1f]'
)}
>
<item.icon size={18} className="shrink-0" />
{t(item.labelKey)}
</Link>
);
})}
<DashboardNavLinks onNavigate={() => setMobileOpen(false)} />
<div className="my-3 h-px bg-black/5 dark:bg-white/5" />

View File

@@ -0,0 +1,42 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import { useUser } from './useUser';
import { baseNavItems } from './constants';
import { useI18n } from '@/lib/i18n';
/** Single source of dashboard navigation — used by the sidebar and the mobile drawer. */
export function DashboardNavLinks({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname();
const { data: user } = useUser();
const { t } = useI18n();
const isPro = ['pro', 'business', 'enterprise'].includes(user?.tier ?? '');
const navItems = isPro ? baseNavItems : baseNavItems.filter(item => !item.proOnly);
return (
<>
{navItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
onClick={onNavigate}
className={cn(
'flex items-center gap-4 rounded-2xl px-6 py-4 text-[11px] font-black uppercase tracking-[0.2em] transition-all duration-200',
isActive
? 'bg-brand-dark text-white shadow-xl dark:bg-brand-accent dark:text-brand-dark'
: 'text-brand-dark/65 dark:text-white/65 hover:bg-brand-muted dark:hover:bg-[#1f1f1f]'
)}
>
<item.icon size={18} className="shrink-0" />
{t(item.labelKey)}
</Link>
);
})}
</>
);
}

View File

@@ -1,24 +1,19 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { LogOut } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useUser } from './useUser';
import { useLogout } from './useLogout';
import { baseNavItems } from './constants';
import { DashboardNavLinks } from './DashboardNavLinks';
import { getInitials, translateTier } from './utils';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { useI18n } from '@/lib/i18n';
export function DashboardSidebar() {
const pathname = usePathname();
const { data: user, isLoading } = useUser();
const { logout } = useLogout();
const { t } = useI18n();
const isPro = ['pro', 'business', 'enterprise'].includes(user?.tier ?? '');
const navItems = isPro ? baseNavItems : baseNavItems.filter(item => !item.proOnly);
return (
<aside className="hidden w-72 shrink-0 border-r border-black/5 dark:border-white/5 bg-white dark:bg-[#141414] lg:flex lg:flex-col">
@@ -35,24 +30,7 @@ export function DashboardSidebar() {
{/* Navigation */}
<nav className="flex-1 overflow-y-auto px-3 py-2">
<div className="flex flex-col gap-1">
{navItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-4 rounded-2xl px-6 py-4 text-[11px] font-black uppercase tracking-[0.2em] transition-all duration-200',
isActive
? 'bg-brand-dark text-white shadow-xl'
: 'text-brand-dark/65 dark:text-white/65 hover:bg-brand-muted dark:hover:bg-[#1f1f1f]'
)}
>
<item.icon size={18} className="shrink-0" />
{t(item.labelKey)}
</Link>
);
})}
<DashboardNavLinks />
</div>
</nav>

View File

@@ -43,6 +43,17 @@ interface RebuildResponse {
data: { job_id: string; rebuilt: boolean; segments_applied: number; download_url: string };
}
/** Map raw backend errors to something the reviewer can act on. */
function friendlyReviewError(raw: string | undefined, t: (k: string) => string, fallbackKey: string): string | undefined {
if (!raw) return undefined;
const r = raw.toLowerCase();
if (r.includes('401') || r.includes('unauthorized') || r.includes('403')) return t('reviews.error.sessionExpired');
if (r.includes('404') || r.includes('not found')) return t('reviews.error.notFound');
if (r.includes('429') || r.includes('rate')) return t('reviews.error.rateLimited');
if (r.includes('500') || r.includes('server')) return t('reviews.error.server');
return raw || t(fallbackKey);
}
function authHeaders(): Record<string, string> {
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
const headers: Record<string, string> = {};
@@ -130,7 +141,7 @@ export default function ReviewPage() {
} catch (err) {
notify.error({
title: t('reviews.error.title'),
description: err instanceof Error ? err.message : t('reviews.error.update'),
description: friendlyReviewError(err instanceof Error ? err.message : undefined, t, 'reviews.error.update'),
});
} finally {
setSavingId(null);
@@ -174,7 +185,7 @@ export default function ReviewPage() {
} catch (err) {
notify.error({
title: t('reviews.rebuildFailed'),
description: err instanceof Error ? err.message : undefined,
description: friendlyReviewError(err instanceof Error ? err.message : undefined, t, 'reviews.error.update'),
});
} finally {
setIsRebuilding(false);
@@ -191,7 +202,7 @@ export default function ReviewPage() {
} catch (err) {
notify.error({
title: t('reviews.xliffExportFailed'),
description: err instanceof Error ? err.message : undefined,
description: friendlyReviewError(err instanceof Error ? err.message : undefined, t, 'reviews.error.update'),
});
}
};
@@ -223,7 +234,7 @@ export default function ReviewPage() {
} catch (err) {
notify.error({
title: t('reviews.xliffImportFailed'),
description: err instanceof Error ? err.message : undefined,
description: friendlyReviewError(err instanceof Error ? err.message : undefined, t, 'reviews.error.update'),
});
} finally {
setIsImporting(false);
@@ -256,6 +267,9 @@ export default function ReviewPage() {
edited: counts.edited,
})}
</p>
<p className="max-w-xl text-xs font-light text-brand-dark/50 dark:text-white/50">
{t('reviews.xliffHint')}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant={approveArmed ? 'default' : 'outline'} size="sm" onClick={approveAll} disabled={counts.pending === 0}>

View File

@@ -12,7 +12,7 @@ import {
} from 'lucide-react';
import { useFileUpload } from './useFileUpload';
import { useTranslationConfig } from './useTranslationConfig';
import { useTranslationSubmit, getRecentJobs, type RecentJob } from './useTranslationSubmit';
import { useTranslationSubmit, getRecentJobs, fetchServerHistory, type RecentJob } from './useTranslationSubmit';
import LanguageSelector from './LanguageSelector';
import { ProviderSelector } from './ProviderSelector';
import { GlossarySelector } from './GlossarySelector';
@@ -122,9 +122,14 @@ export default function TranslatePage() {
return () => { if (timerRef.current) clearInterval(timerRef.current); };
}, [submit.status, submit.isSubmitting]);
// Recent jobs (client-side history) — loaded on mount, refreshed when a job completes
// History: server list first (survives any device), localStorage as fallback
useEffect(() => {
setRecentJobs(getRecentJobs());
let cancelled = false;
fetchServerHistory().then((server) => {
if (cancelled) return;
setRecentJobs(server.length > 0 ? server : getRecentJobs());
});
return () => { cancelled = true; };
}, [submit.status]);
const handleTranslate = async () => {
@@ -141,6 +146,17 @@ export default function TranslatePage() {
await handleTranslate();
};
const handleCancel = async () => {
const ok = await submit.cancelJob();
if (ok) {
submit.reset();
setElapsed(0);
showError({ title: t('translate.cancelledTitle'), description: t('translate.cancelledDesc') });
} else {
showError({ title: t('translate.cancelFailedTitle'), description: t('translate.cancelFailedDesc') });
}
};
const handleNewTranslation = () => { submit.reset(); upload.removeFile(); setElapsed(0); setRecentJobs(getRecentJobs()); };
const handleDownload = async (jobId: string = submit.jobId ?? '') => {
if (!jobId) return;
@@ -220,7 +236,7 @@ export default function TranslatePage() {
{showProcessing ? (
<>
<span className="accent-pill mb-4 block w-fit italic">{t('translate.header.processing')}</span>
<h1 className="text-4xl md:text-5xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
<h1 className="text-3xl md:text-4xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
<SplitTitle base={t('translate.header.aiActiveTitle')} accent={t('translate.header.aiActiveAccent')} />
</h1>
<p className="text-brand-dark/50 dark:text-white/50 text-sm font-light leading-relaxed">
@@ -230,7 +246,7 @@ export default function TranslatePage() {
) : showComplete ? (
<>
<span className="accent-pill mb-4 block w-fit italic">{t('translate.header.completed')}</span>
<h1 className="text-4xl md:text-5xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
<h1 className="text-3xl md:text-4xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
<SplitTitle base={t('translate.header.completedTitleBase')} accent={t('translate.header.completedTitleAccent')} />
</h1>
<p className="text-brand-dark/50 dark:text-white/50 text-sm font-light leading-relaxed truncate max-w-xl">
@@ -240,7 +256,7 @@ export default function TranslatePage() {
) : (
<>
<span className="accent-pill mb-4 block w-fit">{t('translate.header.workspace')}</span>
<h1 className="text-4xl md:text-5xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
<h1 className="text-3xl md:text-4xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
<SplitTitle base={t('translate.header.translateDocBase')} accent={t('translate.header.translateDocAccent')} />
</h1>
<p className="text-brand-dark/50 dark:text-white/50 text-sm font-light leading-relaxed">
@@ -596,7 +612,8 @@ export default function TranslatePage() {
</div>
)}
{/* Glossary selector */}
{/* Glossary selector — Pro only; hidden entirely for free users */}
{config.isPro && (
<GlossarySelector
sourceLang={config.sourceLang}
targetLang={config.targetLang}
@@ -606,8 +623,10 @@ export default function TranslatePage() {
onChange={config.setGlossaryId}
disabled={submit.isSubmitting}
/>
)}
{/* Translate Images */}
{/* Translate Images — LLM mode only; hidden for free users */}
{config.isPro && (
<div className="bg-brand-muted/30 dark:bg-white/[0.02] border border-black/[0.03] dark:border-white/[0.03] p-4 rounded-xl space-y-3">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
@@ -639,6 +658,8 @@ export default function TranslatePage() {
)}
</div>
)}
{/* PDF mode selector */}
{isPdf && (
<div className="space-y-2 text-left">
@@ -741,11 +762,17 @@ export default function TranslatePage() {
</div>
<button
onClick={handleNewTranslation}
title={t('translate.leaveScreenHint')}
className="w-full mt-8 py-3.5 border border-black/10 dark:border-white/10 text-brand-dark/50 dark:text-white/50 rounded-2xl text-[10px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-2 hover:bg-brand-muted/40 dark:hover:bg-white/5 hover:text-brand-dark dark:hover:text-white transition-all cursor-pointer"
onClick={handleCancel}
className="w-full mt-8 py-3.5 border border-red-200 dark:border-red-900/40 text-red-500 rounded-2xl text-[10px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-2 hover:bg-red-50 dark:hover:bg-red-950/30 transition-all cursor-pointer"
>
<X size={13} />
{t('translate.cancelAction')}
</button>
<button
onClick={handleNewTranslation}
title={t('translate.leaveScreenHint')}
className="w-full mt-2 py-2.5 text-[10px] font-bold uppercase tracking-[0.2em] text-brand-dark/50 dark:text-white/50 hover:text-brand-dark dark:hover:text-white transition-colors cursor-pointer"
>
{t('translate.leaveScreen')}
</button>
</div>

View File

@@ -101,6 +101,8 @@ export interface TranslationStatusResponse {
export interface UseTranslationSubmitReturn {
submitTranslation: (file: File, config: TranslationConfig) => Promise<void>;
/** Ask the backend to cancel the running job. */
cancelJob: () => Promise<boolean>;
jobId: string | null;
status: TranslationStatus;
progress: number;

View File

@@ -22,6 +22,30 @@ interface StoredJob { jobId: string; fileName: string | null; savedAt: number }
export interface RecentJob { jobId: string; fileName: string; completedAt: number }
/** Server history (last jobs, newest first) — empty when offline/unauthenticated. */
export async function fetchServerHistory(perPage = 6): Promise<RecentJob[]> {
if (typeof window === 'undefined') return [];
const token = localStorage.getItem('token');
if (!token) return [];
try {
const res = await fetch(`${API_BASE}/api/v1/translations?per_page=${perPage}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return [];
const json = await res.json();
return (json.data ?? []).map((j: {
id: string; file_name?: string | null;
completed_at?: string | null; created_at?: string | null;
}) => ({
jobId: j.id,
fileName: j.file_name ?? '',
completedAt: Date.parse(j.completed_at ?? j.created_at ?? '') || Date.now(),
}));
} catch {
return [];
}
}
export function getRecentJobs(): RecentJob[] {
if (typeof window === 'undefined') return [];
try {
@@ -235,6 +259,28 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
// NOTE: Don't set isSubmitting(false) here - let polling handle the transition
}, [startPolling]);
/** Ask the backend to cancel the current job. Returns true on success. */
const cancelJob = useCallback(async (): Promise<boolean> => {
const id = jobId;
if (!id) return false;
try {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}/api/v1/translations/${id}/cancel`, {
method: 'POST',
headers,
});
if (!res.ok) return false;
stopPolling();
persistActiveJob(null);
setIsSubmitting(false);
return true;
} catch {
return false;
}
}, [jobId, stopPolling]);
const reset = useCallback(() => {
stopPolling();
persistActiveJob(null);
@@ -285,6 +331,7 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
return {
submitTranslation,
cancelJob,
jobId,
status,
progress,

View File

@@ -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'}`}>&minus;{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} &euro;</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} &euro;</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",

View File

@@ -143,29 +143,6 @@ const Hero = () => {
referrerPolicy="no-referrer"
/>
<div className="absolute inset-0 bg-brand-dark/5 dark:bg-black/20" />
<div className="absolute top-8 right-8 w-64 bg-white/90 dark:bg-[#1a1a1a]/90 backdrop-blur-xl border border-black/5 dark:border-white/10 p-6 rounded-2xl shadow-2xl text-left hidden md:block">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-full bg-brand-accent/20 flex items-center justify-center text-brand-accent">
<Zap size={16} />
</div>
<span className="text-[10px] font-bold uppercase tracking-widest text-brand-dark dark:text-white">Context Engine</span>
</div>
<p className="text-xs text-brand-dark/70 dark:text-white/70 leading-relaxed font-medium">{t('landing.hero.contextEngine')}</p>
</div>
<div className="absolute bottom-8 left-8 w-64 bg-brand-dark dark:bg-[#0a0a0a] text-white p-6 rounded-2xl shadow-2xl text-left hidden md:block">
<div className="flex items-center gap-3 mb-4">
<Activity size={16} className="text-brand-accent" />
<span className="text-[10px] font-bold uppercase tracking-widest text-brand-accent">{t('landing.hero.liveAnalysis')}</span>
</div>
<div className="flex items-center gap-3">
{[1, 2, 3].map(i => (
<div key={i} className="w-6 h-6 rounded-full border-2 border-brand-dark dark:border-[#0a0a0a] bg-brand-muted dark:bg-[#1f1f1f] text-[8px] flex items-center justify-center font-bold text-brand-dark dark:text-white">JD</div>
))}
<span className="text-[10px] ml-2 text-white/60">+12 {t('landing.hero.termsDetected')}</span>
</div>
</div>
</div>
</div>
</motion.div>

View File

@@ -34,5 +34,10 @@
"reviews.titleAccent": "review",
"reviews.approveAllConfirm": "Confirm ({count})",
"reviews.action.approveShort": "OK",
"reviews.action.resetShort": "Re-review"
"reviews.action.resetShort": "Re-review",
"reviews.xliffHint": "XLIFF is the standard exchange format for translation reviews — export the segments, correct them in your own tool or send them to a reviewer, then import them back.",
"reviews.error.sessionExpired": "Your session has expired. Log in again and reload this page.",
"reviews.error.notFound": "This job no longer exists. It may have been cleaned up.",
"reviews.error.rateLimited": "Too many requests — wait a few seconds and retry.",
"reviews.error.server": "The server could not process this. Try again in a moment."
}

View File

@@ -62,8 +62,8 @@
"translate.chooseTargetLang": "Please choose a target language",
"translate.pleaseLoadFile": "Please upload a file first",
"translate.contextEngineActive": "Contextual engine active",
"translate.phase1": "Phase 1: Initialisation",
"translate.phase2": "Phase 2: Contextual reconstruction",
"translate.phase1": "Step 1: Analysing the document",
"translate.phase2": "Step 2: Translating & rebuilding layout",
"translate.stat.time": "time",
"translate.download": "Download",
"translate.newTranslation": "+ New translation",
@@ -96,5 +96,10 @@
"translate.downloadFailed": "The download failed. Please try again.",
"translate.recent.title": "Recent translations",
"translate.recent.review": "Review",
"translate.upload.ariaDropzone": "Upload a document: drag and drop, or press Enter to browse files"
"translate.upload.ariaDropzone": "Upload a document: drag and drop, or press Enter to browse files",
"translate.cancelAction": "Cancel translation",
"translate.cancelledTitle": "Translation cancelled",
"translate.cancelledDesc": "The job was stopped and the reserved document slot released.",
"translate.cancelFailedTitle": "Could not cancel",
"translate.cancelFailedDesc": "The job may have already finished. The display will update shortly."
}

View File

@@ -34,5 +34,10 @@
"reviews.titleAccent": "traduction",
"reviews.approveAllConfirm": "Confirmer ({count})",
"reviews.action.approveShort": "OK",
"reviews.action.resetShort": "À relire"
"reviews.action.resetShort": "À relire",
"reviews.xliffHint": "XLIFF est le format d'échange standard des relectures — exportez les segments, corrigez-les dans votre outil ou envoyez-les à un réviseur, puis réimportez-les.",
"reviews.error.sessionExpired": "Votre session a expiré. Reconnectez-vous puis rechargez cette page.",
"reviews.error.notFound": "Ce job n'existe plus. Il a peut-être été nettoyé.",
"reviews.error.rateLimited": "Trop de requêtes — attendez quelques secondes et réessayez.",
"reviews.error.server": "Le serveur n'a pas pu traiter la demande. Réessayez dans un instant."
}

View File

@@ -62,8 +62,8 @@
"translate.chooseTargetLang": "Veuillez choisir une langue cible",
"translate.pleaseLoadFile": "Veuillez charger un fichier d'abord",
"translate.contextEngineActive": "Moteur contextuel actif",
"translate.phase1": "Phase 1 : Initialisation",
"translate.phase2": "Phase 2 : Reconstruction contextuelle",
"translate.phase1": "Étape 1 : Analyse du document",
"translate.phase2": "Étape 2 : Traduction et reconstruction de la mise en page",
"translate.stat.time": "temps",
"translate.download": "Télécharger",
"translate.newTranslation": "+ Nouvelle traduction",
@@ -96,5 +96,10 @@
"translate.downloadFailed": "Le téléchargement a échoué. Réessayez.",
"translate.recent.title": "Traductions récentes",
"translate.recent.review": "Relire",
"translate.upload.ariaDropzone": "Déposer un document : glisser-déposer ou appuyer sur Entrée pour parcourir"
"translate.upload.ariaDropzone": "Déposer un document : glisser-déposer ou appuyer sur Entrée pour parcourir",
"translate.cancelAction": "Annuler la traduction",
"translate.cancelledTitle": "Traduction annulée",
"translate.cancelledDesc": "Le job a été arrêté et le document réservé a été libéré.",
"translate.cancelFailedTitle": "Annulation impossible",
"translate.cancelFailedDesc": "Le job est peut-être déjà terminé. L'affichage se mettra à jour sous peu."
}