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:
@@ -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" />
|
||||
|
||||
|
||||
42
frontend/src/app/dashboard/DashboardNavLinks.tsx
Normal file
42
frontend/src/app/dashboard/DashboardNavLinks.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user