feat: production deployment - full update with providers, admin, glossaries, pricing, tests

Major changes across backend, frontend, infrastructure:
- Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud)
- Admin panel: user management, pricing, settings
- Glossary system with CSV import/export
- Subscription and tier quota management
- Security hardening (rate limiting, API key auth, path traversal fixes)
- Docker compose for dev, prod, and IONOS deployment
- Alembic migrations for new tables
- Frontend: dashboard, pricing page, landing page, i18n (en/fr)
- Test suite and verification scripts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sepehr Ramezani
2026-04-25 15:01:47 +02:00
parent 2ba4fedfc8
commit 26bd096a06
1178 changed files with 136435 additions and 3047 deletions

View File

@@ -18,15 +18,18 @@ import { Separator } from '@/components/ui/separator';
import { useUser } from './useUser';
import { useLogout } from './useLogout';
import { getNavItems } from './constants';
import { getInitials } from './utils';
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 navItems = getNavItems(user?.tier === 'pro');
const navItems = getNavItems(['pro', 'business', 'enterprise'].includes(user?.tier ?? ''));
return (
<>
@@ -37,7 +40,7 @@ export function DashboardHeader() {
size="icon"
className="lg:hidden"
onClick={() => setMobileOpen(!mobileOpen)}
aria-label="Toggle menu"
aria-label={t('dashboard.header.toggleMenu')}
>
{mobileOpen ? <X className="size-4" /> : <Menu className="size-4" />}
</Button>
@@ -47,35 +50,42 @@ export function DashboardHeader() {
<div className="flex size-6 items-center justify-center rounded-md bg-foreground">
<Languages className="size-3 text-background" />
</div>
<span className="text-sm font-semibold text-foreground">Office Translator</span>
<span className="text-sm font-semibold text-foreground">{t('auth.brandName')}</span>
</div>
{/* Page title - desktop */}
<div className="hidden items-center gap-3 lg:flex">
<h1 className="text-sm font-semibold text-foreground">Dashboard</h1>
<h1 className="text-sm font-semibold text-foreground">{t('dashboard.header.title')}</h1>
<Separator orientation="vertical" className="h-4" />
<span className="text-sm text-muted-foreground">Manage your API and translation settings</span>
<span className="text-sm text-muted-foreground">{t('dashboard.header.subtitle')}</span>
</div>
{/* Right side */}
{!isLoading && user && (
<div className="flex items-center gap-3">
<Badge
variant="secondary"
className={cn(
'border border-accent/20',
user.tier === 'pro' ? 'bg-accent/10 text-accent' : 'bg-muted text-muted-foreground'
)}
>
{user.tier === 'pro' ? 'Pro Plan' : 'Free Plan'}
</Badge>
<Avatar className="size-8">
<AvatarFallback className="bg-accent text-accent-foreground text-xs font-semibold">
{getInitials(user.name)}
</AvatarFallback>
</Avatar>
</div>
)}
<div className="flex items-center gap-2">
<ThemeToggle />
{!isLoading && user && (
<>
<Badge
variant="secondary"
className={cn(
'border border-accent/20',
user.tier === 'free' || !user.tier
? 'bg-muted text-muted-foreground'
: 'bg-accent/10 text-accent'
)}
>
{translateTier(t, user.tier)}
</Badge>
<Link href="/dashboard/profile" title={t('dashboard.header.profileTitle')}>
<Avatar className="size-8 cursor-pointer ring-2 ring-transparent hover:ring-accent/40 transition-all">
<AvatarFallback className="bg-accent text-accent-foreground text-xs font-semibold">
{getInitials(user.name)}
</AvatarFallback>
</Avatar>
</Link>
</>
)}
</div>
</header>
{/* Mobile navigation drawer */}
@@ -97,13 +107,17 @@ export function DashboardHeader() {
)}
>
<item.icon className="size-4 shrink-0" />
{item.label}
{t(item.labelKey)}
</Link>
);
})}
<Separator className="my-2" />
{!isLoading && user && (
<div className="flex items-center gap-3 px-3 py-2">
<Link
href="/dashboard/profile"
onClick={() => setMobileOpen(false)}
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-secondary/60 transition-colors"
>
<Avatar className="size-8">
<AvatarFallback className="bg-accent text-accent-foreground text-xs font-semibold">
{getInitials(user.name)}
@@ -111,31 +125,31 @@ export function DashboardHeader() {
</Avatar>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-foreground">{user.name}</span>
<Badge
variant="secondary"
<Badge
variant="secondary"
className={cn(
'text-xs w-fit',
user.tier === 'pro' && 'border border-accent/20 bg-accent/10 text-accent'
)}
>
{user.tier === 'pro' ? 'Pro' : 'Free'}
{translateTier(t, user.tier)}
</Badge>
</div>
</div>
</Link>
)}
<button
onClick={logout}
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-secondary/60 hover:text-foreground"
>
<LogOut className="size-4 shrink-0" />
Sign out
{t('dashboard.sidebar.signOut')}
</button>
<Link
href="/"
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-secondary/60 hover:text-foreground"
>
<ChevronLeft className="size-4 shrink-0" />
Back to home
{t('dashboard.sidebar.backHome')}
</Link>
</nav>
</div>

View File

@@ -11,14 +11,17 @@ import { Button } from '@/components/ui/button';
import { useUser } from './useUser';
import { useLogout } from './useLogout';
import { getNavItems } from './constants';
import { getInitials } from './utils';
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 navItems = getNavItems(user?.tier === 'pro');
const navItems = getNavItems(['pro', 'business', 'enterprise'].includes(user?.tier ?? ''));
return (
<aside className="hidden w-64 shrink-0 border-r border-border bg-card lg:flex lg:flex-col">
@@ -27,9 +30,7 @@ export function DashboardSidebar() {
<div className="flex size-7 items-center justify-center rounded-md bg-foreground">
<Languages className="size-3.5 text-background" />
</div>
<span className="text-sm font-semibold tracking-tight text-foreground">
Office Translator
</span>
<span className="text-sm font-semibold tracking-tight text-foreground">{t('auth.brandName')}</span>
</div>
<Separator />
@@ -50,7 +51,7 @@ export function DashboardSidebar() {
)}
>
<item.icon className="size-4 shrink-0" />
{item.label}
{t(item.labelKey)}
</Link>
);
})}
@@ -60,32 +61,36 @@ export function DashboardSidebar() {
{/* User section */}
{!isLoading && user && (
<div className="flex items-center gap-3 px-5 py-4">
<Avatar className="size-8">
<div className="flex items-center gap-2.5 px-4 py-3">
<Avatar className="size-8 shrink-0">
<AvatarFallback className="bg-accent text-accent-foreground text-xs font-semibold">
{getInitials(user.name)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-none text-foreground">{user.name}</span>
<span className="text-xs leading-none text-muted-foreground">{user.email}</span>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium leading-none text-foreground">{user.name}</span>
<span className="truncate text-xs leading-none text-muted-foreground">{user.email}</span>
<Badge
variant="secondary"
className={cn(
'mt-0.5 w-fit text-xs',
user.tier !== 'free' && user.tier && 'border border-accent/20 bg-accent/10 text-accent'
)}
>
{translateTier(t, user.tier)}
</Badge>
</div>
<Badge
variant="secondary"
className={cn(
'ml-auto text-xs',
user.tier === 'pro' && 'border border-accent/20 bg-accent/10 text-accent'
)}
>
{user.tier === 'pro' ? 'Pro' : 'Free'}
</Badge>
</div>
)}
<Separator />
{/* Logout */}
<div className="px-3 py-3">
{/* Actions */}
<div className="px-3 py-3 space-y-1">
<div className="flex items-center justify-between px-3 py-1.5">
<span className="text-xs text-muted-foreground">{t('dashboard.sidebar.theme')}</span>
<ThemeToggle />
</div>
<Button
variant="ghost"
size="sm"
@@ -93,16 +98,12 @@ export function DashboardSidebar() {
onClick={logout}
>
<LogOut className="size-3.5" />
Sign out
{t('dashboard.sidebar.signOut')}
</Button>
</div>
{/* Back to homepage */}
<div className="px-3 py-3">
<Button variant="ghost" size="sm" className="w-full justify-start gap-2 text-muted-foreground" asChild>
<Link href="/">
<ChevronLeft className="size-3.5" />
Back to home
{t('dashboard.sidebar.backHome')}
</Link>
</Button>
</div>

View File

@@ -1,23 +1,23 @@
import { LayoutDashboard, FileText, Key, BookText, type LucideIcon } from 'lucide-react';
import { FileText, Key, BookText, User, type LucideIcon } from 'lucide-react';
export interface NavItem {
label: string;
labelKey: string;
href: string;
icon: LucideIcon;
proOnly?: boolean;
}
export const baseNavItems: NavItem[] = [
{ label: 'Overview', href: '/dashboard', icon: LayoutDashboard },
{ label: 'Translate', href: '/dashboard/translate', icon: FileText },
{ label: 'API Keys', href: '/dashboard/api-keys', icon: Key },
{ labelKey: 'dashboard.nav.translate', href: '/dashboard/translate', icon: FileText },
{ labelKey: 'dashboard.nav.profile', href: '/dashboard/profile', icon: User },
{ labelKey: 'dashboard.nav.apiKeys', href: '/dashboard/api-keys', icon: Key },
];
export const proNavItem: NavItem = {
label: 'Glossaries',
href: '/dashboard/glossaries',
export const proNavItem: NavItem = {
labelKey: 'dashboard.nav.glossaries',
href: '/dashboard/glossaries',
icon: BookText,
proOnly: true
proOnly: true,
};
export function getNavItems(isPro: boolean): NavItem[] {

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useCallback } from 'react';
import { useState, useCallback, useRef } from 'react';
import {
Dialog,
DialogContent,
@@ -12,94 +12,473 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { TermEditor } from './TermEditor';
import { parseFileToTerms } from './csvUtils';
import { useGlossaryTemplates } from './useGlossaries';
import type { GlossaryTermInput } from './types';
import type { GlossaryTemplate } from './useGlossaries';
import { useI18n } from '@/lib/i18n';
import {
Upload,
FileText,
BookOpen,
PenLine,
CheckCircle2,
AlertCircle,
Loader2,
X,
Scale,
Cpu,
TrendingUp,
HeartPulse,
Megaphone,
Users,
FlaskConical,
ShoppingCart,
} from 'lucide-react';
import { cn } from '@/lib/utils';
interface CreateGlossaryDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreate: (data: { name: string; terms: GlossaryTermInput[] }) => Promise<void>;
onImportTemplate: (templateId: string, name?: string) => Promise<void>;
isCreating: boolean;
isImportingTemplate: boolean;
}
const TEMPLATE_ICONS: Record<string, React.ReactNode> = {
legal: <Scale className="size-5" />,
technology: <Cpu className="size-5" />,
finance: <TrendingUp className="size-5" />,
medical: <HeartPulse className="size-5" />,
marketing: <Megaphone className="size-5" />,
hr: <Users className="size-5" />,
scientific: <FlaskConical className="size-5" />,
ecommerce: <ShoppingCart className="size-5" />,
};
const TEMPLATE_COLORS: Record<string, string> = {
legal: 'bg-purple-50 text-purple-700 border-purple-200 hover:bg-purple-100',
technology: 'bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100',
finance: 'bg-green-50 text-green-700 border-green-200 hover:bg-green-100',
medical: 'bg-red-50 text-red-700 border-red-200 hover:bg-red-100',
marketing: 'bg-orange-50 text-orange-700 border-orange-200 hover:bg-orange-100',
hr: 'bg-teal-50 text-teal-700 border-teal-200 hover:bg-teal-100',
scientific: 'bg-indigo-50 text-indigo-700 border-indigo-200 hover:bg-indigo-100',
ecommerce: 'bg-pink-50 text-pink-700 border-pink-200 hover:bg-pink-100',
};
type FileStatus = 'idle' | 'parsing' | 'success' | 'error';
const MAX_FILE_SIZE_MB = 5;
function TemplateCard({
template,
onSelect,
isLoading,
termsLabel,
}: {
template: GlossaryTemplate;
onSelect: (t: GlossaryTemplate) => void;
isLoading: boolean;
termsLabel: string;
}) {
const icon = TEMPLATE_ICONS[template.id] ?? <BookOpen className="size-5" />;
const colorClass = TEMPLATE_COLORS[template.id] ?? 'bg-muted text-foreground border-border hover:bg-muted/80';
return (
<button
type="button"
onClick={() => onSelect(template)}
disabled={isLoading}
className={cn(
'flex flex-col gap-2 rounded-lg border p-3 text-left transition-colors disabled:opacity-50 disabled:cursor-not-allowed',
colorClass
)}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{icon}
<span className="text-sm font-medium leading-tight">
{template.name.split(' - ')[0]}
</span>
</div>
<Badge variant="secondary" className="shrink-0 text-xs font-normal">
{template.terms_count} {termsLabel}
</Badge>
</div>
<p className="text-xs opacity-75 line-clamp-2">{template.description}</p>
</button>
);
}
function FileUploadZone({
onTermsParsed,
disabled,
}: {
onTermsParsed: (terms: GlossaryTermInput[], filename: string) => void;
disabled: boolean;
}) {
const { t } = useI18n();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [status, setStatus] = useState<FileStatus>('idle');
const [errorMsg, setErrorMsg] = useState('');
const [parsedFile, setParsedFile] = useState<{ name: string; count: number } | null>(null);
const processFile = useCallback(async (file: File) => {
const ext = file.name.split('.').pop()?.toLowerCase();
const allowed = ['csv', 'xlsx', 'xls', 'ods', 'txt', 'tsv'];
if (!ext || !allowed.includes(ext)) {
setStatus('error');
setErrorMsg(t('glossaries.dialog.errorFormat'));
return;
}
if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) {
setStatus('error');
setErrorMsg(t('glossaries.dialog.errorSize', { max: String(MAX_FILE_SIZE_MB) }));
return;
}
setStatus('parsing');
setErrorMsg('');
try {
const terms = await parseFileToTerms(file);
if (terms.length === 0) {
setStatus('error');
setErrorMsg(t('glossaries.dialog.errorEmpty'));
return;
}
setStatus('success');
setParsedFile({ name: file.name, count: terms.length });
onTermsParsed(terms, file.name);
} catch {
setStatus('error');
setErrorMsg(t('glossaries.dialog.errorRead'));
}
}, [onTermsParsed, t]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) processFile(file);
}, [processFile]);
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) processFile(file);
e.target.value = '';
}, [processFile]);
const reset = () => {
setStatus('idle');
setParsedFile(null);
setErrorMsg('');
};
return (
<div className="space-y-3">
<div
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onClick={() => !disabled && fileInputRef.current?.click()}
className={cn(
'relative flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-8 text-center transition-colors cursor-pointer',
isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/30',
disabled && 'opacity-50 cursor-not-allowed',
status === 'success' && 'border-green-400 bg-green-50',
status === 'error' && 'border-destructive/50 bg-destructive/5'
)}
>
<input
ref={fileInputRef}
type="file"
accept=".csv,.xlsx,.xls,.ods,.txt,.tsv"
className="hidden"
onChange={handleFileChange}
disabled={disabled}
/>
{status === 'parsing' && (
<>
<Loader2 className="size-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">{t('glossaries.dialog.parsing')}</p>
</>
)}
{status === 'success' && parsedFile && (
<>
<CheckCircle2 className="size-8 text-green-600" />
<div>
<p className="text-sm font-medium text-green-700">
{t('glossaries.dialog.termsImported', { count: String(parsedFile.count) })}
</p>
<p className="text-xs text-muted-foreground mt-0.5">{parsedFile.name}</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); reset(); }}
className="gap-1.5 text-xs"
>
<X className="size-3.5" /> {t('glossaries.dialog.changeFile')}
</Button>
</>
)}
{status === 'error' && (
<>
<AlertCircle className="size-8 text-destructive" />
<div>
<p className="text-sm font-medium text-destructive">{errorMsg}</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); reset(); }}
className="gap-1.5 text-xs"
>
<X className="size-3.5" /> {t('glossaries.dialog.retry')}
</Button>
</>
)}
{status === 'idle' && (
<>
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<Upload className="size-6 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium">{t('glossaries.dialog.dropTitle')}</p>
<p className="text-xs text-muted-foreground mt-1">{t('glossaries.dialog.dropOr')}</p>
</div>
<p className="text-xs text-muted-foreground">{t('glossaries.dialog.dropFormats')}</p>
</>
)}
</div>
<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground space-y-1">
<p className="font-medium">{t('glossaries.dialog.formatTitle')}</p>
<p>{t('glossaries.dialog.formatDesc')}</p>
<div className="font-mono bg-background rounded border px-2 py-1 mt-1">
<div className="text-muted-foreground">source,target</div>
<div>server,server</div>
<div>database,database</div>
</div>
<p className="mt-1">{t('glossaries.dialog.formatNote')}</p>
</div>
</div>
);
}
export function CreateGlossaryDialog({
open,
onOpenChange,
onCreate,
onImportTemplate,
isCreating,
isImportingTemplate,
}: CreateGlossaryDialogProps) {
const { t } = useI18n();
const [activeTab, setActiveTab] = useState<'templates' | 'file' | 'manual'>('templates');
const [name, setName] = useState('');
const [nameAutoFilled, setNameAutoFilled] = useState(false);
const [terms, setTerms] = useState<GlossaryTermInput[]>([{ source: '', target: '' }]);
const [fileTerms, setFileTerms] = useState<GlossaryTermInput[]>([]);
const [selectedTemplate, setSelectedTemplate] = useState<GlossaryTemplate | null>(null);
const handleCreate = useCallback(async () => {
if (!name.trim()) return;
const validTerms = terms.filter(t => t.source.trim() && t.target.trim());
await onCreate({
name: name.trim(),
terms: validTerms,
});
const { templates, isLoading: isLoadingTemplates } = useGlossaryTemplates();
const isProcessing = isCreating || isImportingTemplate;
const reset = useCallback(() => {
setName('');
setNameAutoFilled(false);
setTerms([{ source: '', target: '' }]);
}, [name, terms, onCreate]);
setFileTerms([]);
setSelectedTemplate(null);
setActiveTab('templates');
}, []);
const handleOpenChange = useCallback((newOpen: boolean) => {
if (!newOpen) {
setName('');
setTerms([{ source: '', target: '' }]);
}
if (!newOpen) reset();
onOpenChange(newOpen);
}, [onOpenChange]);
}, [onOpenChange, reset]);
const validTermsCount = terms.filter(t => t.source.trim() && t.target.trim()).length;
const handleTemplateSelect = useCallback((template: GlossaryTemplate) => {
setSelectedTemplate(template);
if (!name || nameAutoFilled) {
setName(template.name.split(' - ')[0]);
setNameAutoFilled(true);
}
}, [name, nameAutoFilled]);
const handleFileTermsParsed = useCallback((parsed: GlossaryTermInput[], filename: string) => {
setFileTerms(parsed);
if (!name || nameAutoFilled) {
const baseName = filename.replace(/\.[^.]+$/, '').replace(/[_-]/g, ' ');
setName(baseName);
setNameAutoFilled(true);
}
}, [name, nameAutoFilled]);
const handleSubmit = useCallback(async () => {
if (!name.trim()) return;
if (activeTab === 'templates' && selectedTemplate) {
await onImportTemplate(selectedTemplate.id, name.trim());
reset();
return;
}
const termsToSave = activeTab === 'file'
? fileTerms
: terms.filter(t => t.source.trim() && t.target.trim());
await onCreate({ name: name.trim(), terms: termsToSave });
reset();
}, [activeTab, selectedTemplate, name, fileTerms, terms, onCreate, onImportTemplate, reset]);
const canSubmit = (() => {
if (!name.trim() || isProcessing) return false;
if (activeTab === 'templates') return !!selectedTemplate;
if (activeTab === 'file') return fileTerms.length > 0;
return terms.some(t => t.source.trim() && t.target.trim());
})();
const submitLabel = (() => {
if (isProcessing) {
return activeTab === 'templates'
? t('glossaries.dialog.importing')
: t('glossaries.dialog.creating');
}
if (activeTab === 'templates') {
return selectedTemplate
? t('glossaries.dialog.importBtn', { count: String(selectedTemplate.terms_count) })
: t('glossaries.dialog.selectPrompt');
}
if (activeTab === 'file') {
return fileTerms.length > 0
? t('glossaries.dialog.importBtn', { count: String(fileTerms.length) })
: t('glossaries.dialog.dropTitle');
}
const count = terms.filter(t => t.source.trim() && t.target.trim()).length;
return count > 0
? t('glossaries.dialog.createBtn', { count: String(count) })
: t('glossaries.dialog.createEmpty');
})();
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create New Glossary</DialogTitle>
<DialogDescription>
Create a glossary with custom terminology for your translations.
</DialogDescription>
<DialogContent className="sm:max-w-2xl max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader className="shrink-0">
<DialogTitle>{t('glossaries.dialog.title')}</DialogTitle>
<DialogDescription>{t('glossaries.dialog.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
<div className="space-y-2">
<Label htmlFor="glossary-name">Glossary Name</Label>
<Input
id="glossary-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Technical Terms FR-EN"
disabled={isCreating}
/>
</div>
<div className="shrink-0 space-y-2 pt-2">
<Label htmlFor="glossary-name">{t('glossaries.dialog.nameLabel')}</Label>
<Input
id="glossary-name"
value={name}
onChange={(e) => { setName(e.target.value); setNameAutoFilled(false); }}
placeholder={t('glossaries.dialog.namePlaceholder')}
disabled={isProcessing}
/>
</div>
<div className="space-y-2">
<Label>Terms ({validTermsCount} valid)</Label>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as typeof activeTab)}
className="flex flex-col flex-1 min-h-0 mt-4"
>
<TabsList className="shrink-0 w-full grid grid-cols-3">
<TabsTrigger value="templates" className="gap-1.5 text-xs">
<BookOpen className="size-3.5" />
{t('glossaries.dialog.tabTemplates')}
</TabsTrigger>
<TabsTrigger value="file" className="gap-1.5 text-xs">
<FileText className="size-3.5" />
{t('glossaries.dialog.tabFile')}
</TabsTrigger>
<TabsTrigger value="manual" className="gap-1.5 text-xs">
<PenLine className="size-3.5" />
{t('glossaries.dialog.tabManual')}
</TabsTrigger>
</TabsList>
<TabsContent value="templates" className="flex-1 overflow-y-auto mt-4 space-y-3">
{isLoadingTemplates ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : (
<>
<p className="text-xs text-muted-foreground">
{t('glossaries.dialog.templatesDesc')}
</p>
<div className="grid gap-2 sm:grid-cols-2">
{templates.map((template) => (
<div
key={template.id}
className={cn(
'relative',
selectedTemplate?.id === template.id && 'ring-2 ring-primary ring-offset-1 rounded-lg'
)}
>
{selectedTemplate?.id === template.id && (
<CheckCircle2 className="absolute -top-1.5 -right-1.5 size-4 text-primary bg-background rounded-full z-10" />
)}
<TemplateCard
template={template}
onSelect={handleTemplateSelect}
isLoading={isProcessing}
termsLabel={t('glossaries.dialog.terms')}
/>
</div>
))}
</div>
{templates.length === 0 && !isLoadingTemplates && (
<p className="text-sm text-muted-foreground text-center py-8">
{t('glossaries.dialog.templatesEmpty')}
</p>
)}
</>
)}
</TabsContent>
<TabsContent value="file" className="flex-1 overflow-y-auto mt-4">
<FileUploadZone
onTermsParsed={handleFileTermsParsed}
disabled={isProcessing}
/>
</TabsContent>
<TabsContent value="manual" className="flex-1 overflow-y-auto mt-4">
<TermEditor
terms={terms}
onChange={setTerms}
disabled={isCreating}
disabled={isProcessing}
/>
</div>
</div>
</TabsContent>
</Tabs>
<DialogFooter>
<DialogFooter className="shrink-0 pt-4 border-t mt-2">
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isCreating}
disabled={isProcessing}
>
Cancel
{t('glossaries.dialog.cancel')}
</Button>
<Button
onClick={handleCreate}
disabled={isCreating || !name.trim()}
>
{isCreating ? 'Creating...' : 'Create Glossary'}
<Button onClick={handleSubmit} disabled={!canSubmit}>
{isProcessing && <Loader2 className="size-3.5 animate-spin mr-1.5" />}
{submitLabel}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { BookText, Pencil, Trash2 } from 'lucide-react';
import type { GlossaryListItem } from './types';
import { useI18n } from '@/lib/i18n';
interface GlossaryCardProps {
glossary: GlossaryListItem;
@@ -20,6 +21,7 @@ export const GlossaryCard = memo(function GlossaryCard({
onDelete,
isDeleting = false,
}: GlossaryCardProps) {
const { t, locale } = useI18n();
const handleEdit = useCallback(() => {
onEdit(glossary.id);
}, [glossary.id, onEdit]);
@@ -28,7 +30,7 @@ export const GlossaryCard = memo(function GlossaryCard({
onDelete(glossary.id, glossary.name);
}, [glossary.id, glossary.name, onDelete]);
const formattedDate = new Date(glossary.created_at).toLocaleDateString('en-US', {
const formattedDate = new Date(glossary.created_at).toLocaleDateString(locale === 'fr' ? 'fr-FR' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
@@ -46,10 +48,10 @@ export const GlossaryCard = memo(function GlossaryCard({
<h3 className="font-medium text-foreground truncate">{glossary.name}</h3>
<div className="flex items-center gap-2 mt-1">
<Badge variant="secondary" className="text-xs">
{glossary.terms_count} {glossary.terms_count === 1 ? 'term' : 'terms'}
{glossary.terms_count} {glossary.terms_count === 1 ? t('glossaries.card.term') : t('glossaries.card.terms')}
</Badge>
<span className="text-xs text-muted-foreground">
Created {formattedDate}
{t('glossaries.card.created')} {formattedDate}
</span>
</div>
</div>

View File

@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
import { ArrowRight, Plus, Trash2 } from 'lucide-react';
import type { GlossaryTermInput, GlossaryTermInputWithId } from './types';
import { MAX_TERMS_PER_GLOSSARY, generateTermId } from './types';
import { useI18n } from '@/lib/i18n';
interface TermEditorProps {
terms: GlossaryTermInput[];
@@ -25,6 +26,7 @@ export const TermEditor = memo(function TermEditor({
onChange,
disabled = false,
}: TermEditorProps) {
const { t } = useI18n();
// Generate stable keys for current terms
const termKeys = useMemo(() => {
return terms.map((term, index) => getTermKey(term, index));
@@ -51,11 +53,11 @@ export const TermEditor = memo(function TermEditor({
<div className="space-y-3">
<div className="mb-2 grid grid-cols-[1fr_32px_1fr_36px] items-center gap-2 px-1">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Source Term
{t('glossaries.termEditor.sourceTerm')}
</span>
<span />
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Target Translation
{t('glossaries.termEditor.targetTranslation')}
</span>
<span />
</div>
@@ -107,12 +109,12 @@ export const TermEditor = memo(function TermEditor({
className="mt-3 gap-1.5 border-dashed"
>
<Plus className="size-3.5" />
Add Term
{t('glossaries.termEditor.addTerm')}
</Button>
{maxTermsReached && (
<p className="text-xs text-amber-600">
Maximum {MAX_TERMS_PER_GLOSSARY} terms per glossary reached.
{t('glossaries.termEditor.maxReached', { max: String(MAX_TERMS_PER_GLOSSARY) })}
</p>
)}
</div>

View File

@@ -50,6 +50,47 @@ export function parseCsvToTerms(csvText: string): GlossaryTermInput[] {
return terms;
}
export async function parseXlsxToTerms(file: File): Promise<GlossaryTermInput[]> {
const { read, utils } = await import('xlsx');
const buffer = await file.arrayBuffer();
const workbook = read(buffer, { type: 'array' });
const firstSheet = workbook.Sheets[workbook.SheetNames[0]];
if (!firstSheet) return [];
const rows: string[][] = utils.sheet_to_json(firstSheet, { header: 1, defval: '' });
if (rows.length === 0) return [];
// Detect header row
const firstRow = rows[0].map(c => String(c).toLowerCase().trim());
const hasHeader =
firstRow.some(c => c.includes('source') || c === 'src') &&
firstRow.some(c => c.includes('target') || c === 'tgt' || c.includes('traduction') || c.includes('cible'));
const dataRows = hasHeader ? rows.slice(1) : rows;
const terms: GlossaryTermInput[] = [];
for (const row of dataRows) {
const source = String(row[0] ?? '').trim();
const target = String(row[1] ?? '').trim();
if (source && target) {
terms.push({ source, target });
}
}
return terms;
}
export async function parseFileToTerms(file: File): Promise<GlossaryTermInput[]> {
const ext = file.name.split('.').pop()?.toLowerCase();
if (ext === 'xlsx' || ext === 'xls' || ext === 'ods') {
return parseXlsxToTerms(file);
}
// CSV / TSV / TXT
const text = await file.text();
return parseCsvToTerms(text);
}
function parseCsvLine(line: string): string[] {
const result: string[] = [];
let current = '';

View File

@@ -6,6 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useUser } from '@/app/dashboard/useUser';
import { useI18n } from '@/lib/i18n';
import { useGlossaries, useGlossary } from './useGlossaries';
import type { Glossary, GlossaryTermInput, GlossaryListItem } from './types';
import { ProUpgradePrompt } from './ProUpgradePrompt';
@@ -16,6 +17,7 @@ import { DeleteGlossaryDialog } from './DeleteGlossaryDialog';
import { useToast } from '@/components/ui/toast';
export default function GlossariesPage() {
const { t } = useI18n();
const { data: user, isLoading: isLoadingUser } = useUser();
const {
glossaries,
@@ -24,9 +26,11 @@ export default function GlossariesPage() {
isCreating,
isUpdating,
isDeleting,
isImportingTemplate,
createGlossary,
updateGlossary,
deleteGlossary,
importTemplate,
} = useGlossaries();
const { toast } = useToast();
@@ -62,14 +66,34 @@ export default function GlossariesPage() {
await createGlossary(data);
setCreateDialogOpen(false);
toast({
title: 'Glossary created',
description: `"${data.name}" has been created successfully.`,
title: t('glossaries.toast.created'),
description: t('glossaries.toast.createdDesc', { name: data.name }),
});
} catch (error) {
toast({
variant: 'destructive',
title: 'Error',
description: 'Failed to create glossary. Please try again.',
title: t('glossaries.toast.error'),
description: t('glossaries.toast.errorCreate'),
});
throw error;
}
};
const handleImportTemplate = async (templateId: string, name?: string) => {
try {
await importTemplate(templateId, name);
setCreateDialogOpen(false);
toast({
title: t('glossaries.toast.imported'),
description: name
? t('glossaries.toast.importedDesc', { name })
: t('glossaries.toast.importedDesc', { name: templateId }),
});
} catch (error) {
toast({
variant: 'destructive',
title: t('glossaries.toast.error'),
description: t('glossaries.toast.errorImport'),
});
throw error;
}
@@ -81,14 +105,14 @@ export default function GlossariesPage() {
setEditDialogOpen(false);
setSelectedGlossary(null);
toast({
title: 'Glossary updated',
description: `"${data.name}" has been updated successfully.`,
title: t('glossaries.toast.updated'),
description: t('glossaries.toast.updatedDesc', { name: data.name }),
});
} catch (error) {
toast({
variant: 'destructive',
title: 'Error',
description: 'Failed to update glossary. Please try again.',
title: t('glossaries.toast.error'),
description: t('glossaries.toast.errorUpdate'),
});
throw error;
}
@@ -101,14 +125,14 @@ export default function GlossariesPage() {
setDeleteDialogOpen(false);
setGlossaryToDelete(null);
toast({
title: 'Glossary deleted',
description: 'The glossary has been deleted successfully.',
title: t('glossaries.toast.deleted'),
description: t('glossaries.toast.deletedDesc'),
});
} catch (error) {
toast({
variant: 'destructive',
title: 'Error',
description: 'Failed to delete glossary. Please try again.',
title: t('glossaries.toast.error'),
description: t('glossaries.toast.errorDelete'),
});
}
};
@@ -131,10 +155,8 @@ export default function GlossariesPage() {
return (
<div className="space-y-6 p-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Glossaries</h1>
<p className="text-muted-foreground">
Manage custom terminology for your LLM translations.
</p>
<h1 className="text-2xl font-semibold tracking-tight">{t('glossaries.title')}</h1>
<p className="text-muted-foreground">{t('glossaries.description')}</p>
</div>
<Card>
@@ -144,8 +166,8 @@ export default function GlossariesPage() {
<BookText className="size-4 text-accent" />
</div>
<div>
<CardTitle className="text-base">Your Glossaries</CardTitle>
<CardDescription>Create and manage glossaries for consistent translations</CardDescription>
<CardTitle className="text-base">{t('glossaries.yourGlossaries')}</CardTitle>
<CardDescription>{t('glossaries.yourGlossariesDesc')}</CardDescription>
</div>
</div>
</CardHeader>
@@ -154,11 +176,9 @@ export default function GlossariesPage() {
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">
{total} glossarie{total !== 1 ? 's' : ''}
</p>
<p className="text-xs text-muted-foreground">
Define term pairs to customize your LLM translations
{t(total !== 1 ? 'glossaries.count_other' : 'glossaries.count_one', { count: String(total) })}
</p>
<p className="text-xs text-muted-foreground">{t('glossaries.defineTerms')}</p>
</div>
<Button
onClick={() => setCreateDialogOpen(true)}
@@ -166,17 +186,15 @@ export default function GlossariesPage() {
className="gap-1.5"
>
<Plus className="size-3.5" />
Create New Glossary
{t('glossaries.createNew')}
</Button>
</div>
{glossaries.length === 0 ? (
<div className="text-center py-8">
<BookText className="size-12 mx-auto text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">No glossaries yet</p>
<p className="text-sm text-muted-foreground/80">
Create your first glossary to customize translations
</p>
<p className="text-muted-foreground">{t('glossaries.empty')}</p>
<p className="text-sm text-muted-foreground/80">{t('glossaries.emptyDesc')}</p>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
@@ -198,15 +216,11 @@ export default function GlossariesPage() {
<Card className="border-border/50">
<CardHeader>
<CardTitle className="text-sm">About Glossaries</CardTitle>
<CardTitle className="text-sm">{t('glossaries.aboutTitle')}</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground space-y-2">
<p>
Glossaries let you define custom terminology for your translations. When using LLM translation modes, your terms will be applied to ensure consistent translations.
</p>
<p>
<strong>Format:</strong> Each term has a source (original) and target (translation) pair.
</p>
<p>{t('glossaries.aboutDesc')}</p>
<p><strong>{t('glossaries.aboutFormat')}</strong></p>
</CardContent>
</Card>
@@ -214,7 +228,9 @@ export default function GlossariesPage() {
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onCreate={handleCreateGlossary}
onImportTemplate={handleImportTemplate}
isCreating={isCreating}
isImportingTemplate={isImportingTemplate}
/>
{editDialogOpen && (fullGlossary || !isLoadingGlossaryDetail) && (

View File

@@ -22,6 +22,21 @@ export type GlossaryErrorCode =
| 'INVALID_GLOSSARY_ID'
| 'UNAUTHORIZED';
export interface GlossaryTemplate {
id: string;
name: string;
description: string;
source_lang: string;
target_lang: string;
terms_count: number;
file: string;
}
export interface GlossaryTemplatesResponse {
data: GlossaryTemplate[];
meta: { total: number };
}
export interface GlossaryError {
status: number;
code: GlossaryErrorCode;
@@ -103,6 +118,24 @@ export function useGlossaries(options: UseGlossariesOptions = {}) {
return deleteMutation.mutateAsync(id);
};
const importTemplateMutation = useMutation({
mutationFn: async ({ templateId, name }: { templateId: string; name?: string }): Promise<Glossary> => {
const params = new URLSearchParams({ template_id: templateId });
if (name) params.set('name', name);
const response = await apiClient.post<GlossaryDetailResponse>(
`/api/v1/glossaries/import?${params.toString()}`
);
return response.data.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
},
});
const importTemplate = async (templateId: string, name?: string) => {
return importTemplateMutation.mutateAsync({ templateId, name });
};
const parseError = (error: Error | null): GlossaryError | null => {
if (!error) return null;
@@ -145,9 +178,30 @@ export function useGlossaries(options: UseGlossariesOptions = {}) {
createError: createMutation.error,
updateError: updateMutation.error,
deleteError: deleteMutation.error,
isImportingTemplate: importTemplateMutation.isPending,
importTemplateError: importTemplateMutation.error,
parseCreateError: () => parseError(createMutation.error),
parseUpdateError: () => parseError(updateMutation.error),
parseDeleteError: () => parseError(deleteMutation.error),
importTemplate,
};
}
export function useGlossaryTemplates() {
const { data, isLoading, error } = useQuery<GlossaryTemplatesResponse, ApiClientError>({
queryKey: ['glossary-templates'],
queryFn: async () => {
const response = await apiClient.get<GlossaryTemplatesResponse>('/api/v1/glossaries/templates/list');
return response.data;
},
staleTime: 5 * 60 * 1000, // templates rarely change
retry: 1,
});
return {
templates: data?.data ?? [],
isLoading,
error,
};
}

View File

@@ -1,102 +1,79 @@
'use client';
import Link from 'next/link';
import { FileText, Key, BookText, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Loader2 } from 'lucide-react';
import { useUser } from './useUser';
import { API_BASE } from '@/lib/config';
/**
* /dashboard — point d'entrée après connexion.
*
* Unique rôle : synchroniser le paiement Stripe si `session_id` est présent
* dans l'URL (retour depuis Stripe Checkout), puis rediriger vers /dashboard/translate.
*/
export default function DashboardPage() {
const { data: user, isLoading } = useUser();
const router = useRouter();
const searchParams = useSearchParams();
const checkoutSessionId = searchParams.get('session_id');
const [syncError, setSyncError] = useState<string | null>(null);
const { refetch } = useUser();
if (isLoading) {
useEffect(() => {
if (!checkoutSessionId) {
router.replace('/dashboard/translate');
return;
}
const token = localStorage.getItem('token');
if (!token) {
router.replace('/dashboard/translate');
return;
}
let cancelled = false;
const runSync = async () => {
setSyncError(null);
try {
const res = await fetch(
`${API_BASE}/api/v1/auth/checkout/sync?session_id=${encodeURIComponent(checkoutSessionId)}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!cancelled) {
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
setSyncError(errData.message || 'Erreur lors de la synchronisation du paiement.');
} else {
await refetch();
router.replace('/dashboard/translate');
}
}
} catch {
if (!cancelled) setSyncError('Erreur réseau. Veuillez rafraîchir la page.');
}
};
runSync();
return () => { cancelled = true; };
}, [checkoutSessionId, refetch, router]);
if (syncError) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-4 border-muted border-t-foreground"></div>
<div className="flex flex-col items-center justify-center gap-4 py-20">
<p className="text-sm text-destructive">{syncError}</p>
<button
onClick={() => router.replace('/dashboard/translate')}
className="text-xs text-muted-foreground underline"
>
Continuer vers la traduction
</button>
</div>
);
}
const firstName = user?.name?.split(' ')[0] || 'User';
return (
<div className="mx-auto max-w-5xl px-4 py-6 lg:px-8 lg:py-8">
{/* Page heading */}
<div className="mb-6">
<h2 className="text-xl font-semibold tracking-tight text-foreground">
Welcome back, {firstName}!
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Monitor your usage, manage API keys, and configure translation preferences.
</p>
</div>
{/* Quick actions */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Link href="/dashboard/translate">
<Card className="cursor-pointer transition-colors hover:bg-secondary/50">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Translate Document</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<CardDescription>
Upload and translate Excel, Word, or PowerPoint files
</CardDescription>
</CardContent>
</Card>
</Link>
<Link href="/dashboard/api-keys">
<Card className="cursor-pointer transition-colors hover:bg-secondary/50">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">API Keys</CardTitle>
<Key className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<CardDescription>
Manage your API keys for automation
</CardDescription>
</CardContent>
</Card>
</Link>
{user?.tier === 'pro' && (
<Link href="/dashboard/glossaries">
<Card className="cursor-pointer transition-colors hover:bg-secondary/50">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Glossaries</CardTitle>
<BookText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<CardDescription>
Create custom terminology for translations
</CardDescription>
</CardContent>
</Card>
</Link>
)}
</div>
{/* Plan info */}
{user?.tier === 'free' && (
<Card className="mt-6 border-primary/50 bg-primary/5">
<CardContent className="flex items-center justify-between pt-6">
<div>
<p className="text-sm font-medium text-foreground">Upgrade to Pro</p>
<p className="text-xs text-muted-foreground">
Get unlimited translations, API access, and custom glossaries
</p>
</div>
<Link href="/pricing">
<Button variant="premium" size="sm" className="gap-1">
View Plans
<ChevronRight className="h-4 w-4" />
</Button>
</Link>
</CardContent>
</Card>
)}
<div className="flex items-center justify-center py-20">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}

View File

@@ -0,0 +1,497 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { API_BASE } from '@/lib/config';
import { useI18n, type Locale } from '@/lib/i18n';
import {
User, Mail, Calendar, Crown, Zap, Sparkles, Building2, Rocket,
FileText, Layers, CreditCard, TrendingUp, AlertTriangle,
CheckCircle2, XCircle, RefreshCw, ExternalLink, ArrowRight,
BadgeCheck, ShieldAlert, Info, Globe,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
/* ─────────────── helpers ─────────────── */
const PLAN_ICONS: Record<string, React.ElementType> = {
free: Sparkles, starter: Zap, pro: Crown, business: Building2, enterprise: Rocket,
};
const PLAN_COLORS: Record<string, { badge: string; gradient: string; ring: string }> = {
free: { badge: 'bg-muted text-muted-foreground border border-border', gradient: 'from-slate-600 to-slate-700', ring: 'ring-slate-500/30' },
starter: { badge: 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200', gradient: 'from-blue-600 to-blue-700', ring: 'ring-blue-500/30' },
pro: { badge: 'bg-violet-100 text-violet-700 dark:bg-violet-900/50 dark:text-violet-200', gradient: 'from-violet-600 to-violet-700', ring: 'ring-violet-500/30' },
business: { badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-200', gradient: 'from-emerald-600 to-emerald-700', ring: 'ring-emerald-500/30' },
enterprise: { badge: 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-200', gradient: 'from-amber-600 to-amber-700', ring: 'ring-amber-500/30' },
};
const PLAN_LABELS: Record<string, string> = {
free: 'Gratuit', starter: 'Starter', pro: 'Pro', business: 'Business', enterprise: 'Entreprise',
};
const PLAN_PRICES: Record<string, number> = {
free: 0, starter: 9, pro: 19, business: 49,
};
function getInitials(name?: string) {
if (!name) return '??';
return name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase();
}
function pct(used: number, limit: number) {
if (limit === -1 || limit === 0) return 0;
return Math.min(100, Math.round((used / limit) * 100));
}
function fmtLimit(val: number) {
return val === -1 ? '∞' : String(val);
}
function nextResetDate() {
const now = new Date();
const next = new Date(now.getFullYear(), now.getMonth() + 1, 1);
return next.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
}
interface UsageBarProps {
label: string;
used: number;
limit: number;
icon: React.ReactNode;
}
function UsageBar({ label, used, limit, icon }: UsageBarProps) {
const p = pct(used, limit);
const isUnlimited = limit === -1;
return (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 text-muted-foreground">{icon} {label}</div>
<span className={cn(
'font-mono text-xs font-medium',
isUnlimited ? 'text-emerald-400' :
p >= 90 ? 'text-red-400' : p >= 70 ? 'text-amber-400' : 'text-muted-foreground'
)}>
{isUnlimited ? '∞' : `${used} / ${limit}`}
</span>
</div>
{!isUnlimited && (
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-700',
p >= 90 ? 'bg-red-500' : p >= 70 ? 'bg-amber-500' : 'bg-accent'
)}
style={{ width: `${p}%` }}
/>
</div>
)}
</div>
);
}
const UI_LANGUAGES: { value: Locale; label: string; flag: string }[] = [
{ value: 'fr', label: 'Français', flag: '🇫🇷' },
{ value: 'en', label: 'English', flag: '🇬🇧' },
];
/* ─────────────── Main component ─────────────── */
export default function ProfilePage() {
const router = useRouter();
const { locale, setLocale } = useI18n();
const [user, setUser] = useState<any>(null);
const [usage, setUsage] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [cancelConfirm, setCancelConfirm] = useState(false);
const [statusMsg, setStatusMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [loadingPortal, setLoadingPortal] = useState(false);
const [loadingCancel, setLoadingCancel] = useState(false);
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
const authHeaders = { Authorization: `Bearer ${token}` };
const fetchData = useCallback(async () => {
if (!token) { router.push('/auth/login?redirect=/dashboard/profile'); return; }
try {
const [meRes, usageRes] = await Promise.all([
fetch(`${API_BASE}/api/v1/auth/me`, { headers: authHeaders }),
fetch(`${API_BASE}/api/v1/auth/usage`, { headers: authHeaders }),
]);
if (meRes.ok) { const j = await meRes.json(); setUser(j.data ?? j); }
if (usageRes.ok) { const j = await usageRes.json(); setUsage(j.data ?? j); }
} catch {
// ignore
} finally {
setLoading(false);
}
}, [token]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { fetchData(); }, [fetchData]);
const handleBillingPortal = async () => {
setLoadingPortal(true);
try {
const res = await fetch(`${API_BASE}/api/v1/auth/billing-portal`, { headers: authHeaders });
const j = await res.json();
const url = j.data?.url ?? j.url;
if (url) window.open(url, '_blank');
else setStatusMsg({ type: 'err', text: 'Portail de facturation non disponible (Stripe non configuré).' });
} catch {
setStatusMsg({ type: 'err', text: 'Erreur lors de l\'accès au portail.' });
} finally {
setLoadingPortal(false);
}
};
const handleCancel = async () => {
if (!cancelConfirm) { setCancelConfirm(true); return; }
setLoadingCancel(true);
try {
const res = await fetch(`${API_BASE}/api/v1/auth/cancel-subscription`, {
method: 'POST', headers: authHeaders,
});
if (res.ok) {
setStatusMsg({ type: 'ok', text: 'Abonnement annulé. Vous conservez l\'accès jusqu\'à la fin de la période en cours.' });
setCancelConfirm(false);
fetchData();
} else {
setStatusMsg({ type: 'err', text: 'Erreur lors de l\'annulation. Contactez le support.' });
}
} catch {
setStatusMsg({ type: 'err', text: 'Erreur réseau.' });
} finally {
setLoadingCancel(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<RefreshCw className="w-8 h-8 text-accent animate-spin" />
</div>
);
}
const planId = user?.plan ?? user?.tier ?? 'free';
const planLabel = PLAN_LABELS[planId] ?? planId;
const planColors = PLAN_COLORS[planId] ?? PLAN_COLORS.free;
const PlanIcon = PLAN_ICONS[planId] ?? Sparkles;
const planPrice = PLAN_PRICES[planId];
const isFreePlan = planId === 'free';
const isCanceling = user?.cancel_at_period_end === true;
const docsUsed = usage?.docs_used ?? user?.docs_translated_this_month ?? 0;
const docsLimit = usage?.docs_limit ?? user?.plan_limits?.docs_per_month ?? 5;
const pagesUsed = usage?.pages_used ?? user?.pages_translated_this_month ?? 0;
const pagesLimit = usage?.pages_limit ?? user?.plan_limits?.max_pages_per_doc ?? 50;
const extraCredits = usage?.extra_credits ?? user?.extra_credits ?? 0;
return (
<div className="flex h-full flex-col overflow-y-auto">
<div className="flex flex-1 flex-col gap-6 p-6 lg:p-8">
{/* ── Page title ── */}
<div>
<h1 className="text-2xl font-bold text-foreground">Mon Profil</h1>
<p className="text-sm text-muted-foreground mt-1">Gérez votre compte, votre abonnement et votre utilisation.</p>
</div>
{/* ── Status message ── */}
{statusMsg && (
<div className={cn(
'flex items-start gap-3 p-4 rounded-xl border text-sm',
statusMsg.type === 'ok'
? 'bg-success/10 border-success/30 text-success'
: 'bg-destructive/10 border-destructive/30 text-destructive'
)}>
{statusMsg.type === 'ok'
? <CheckCircle2 className="w-4 h-4 flex-shrink-0 mt-0.5" />
: <XCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />}
<span className="flex-1">{statusMsg.text}</span>
<button onClick={() => setStatusMsg(null)} className="text-muted-foreground hover:text-foreground"></button>
</div>
)}
{/* ── Two-column grid on desktop ── */}
<div className="grid gap-6 lg:grid-cols-2 lg:items-start">
{/* ── LEFT column ── */}
<div className="flex flex-col gap-6">
{/* Identity card */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className={cn(
'flex shrink-0 items-center justify-center w-16 h-16 rounded-2xl text-white font-bold text-xl ring-4',
`bg-gradient-to-br ${planColors.gradient}`,
planColors.ring
)}>
{getInitials(user?.name)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h2 className="text-lg font-semibold text-foreground truncate">{user?.name || 'Utilisateur'}</h2>
<Badge className={cn('text-xs font-medium', planColors.badge)}>
<PlanIcon className="w-3 h-3 mr-1" />
{planLabel}
</Badge>
</div>
<div className="flex items-center gap-1.5 mt-1 text-sm text-muted-foreground">
<Mail className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{user?.email}</span>
</div>
{user?.created_at && (
<div className="flex items-center gap-1.5 mt-0.5 text-xs text-muted-foreground">
<Calendar className="w-3 h-3 shrink-0" />
<span>Membre depuis le {new Date(user.created_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* Subscription card */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<CreditCard className="w-4 h-4 text-accent" />
Mon abonnement
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={cn('p-2.5 rounded-xl bg-gradient-to-br', planColors.gradient)}>
<PlanIcon className="w-5 h-5 text-white" />
</div>
<div>
<p className="font-semibold text-foreground">Forfait {planLabel}</p>
<p className="text-sm text-muted-foreground">
{isFreePlan ? 'Gratuit' : `${planPrice} €/mois`}
</p>
</div>
</div>
{!isFreePlan && (
<Badge variant="secondary" className={cn(
'text-xs',
isCanceling ? 'text-warning border-warning/30 bg-warning/10' :
user?.subscription_status === 'active' ? 'text-success border-success/30 bg-success/10' :
'text-destructive border-destructive/30 bg-destructive/10'
)}>
{isCanceling ? 'Annulation en cours' :
user?.subscription_status === 'active' ? 'Actif' :
user?.subscription_status ?? 'Inconnu'}
</Badge>
)}
</div>
{!isFreePlan && user?.subscription_ends_at && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-secondary/40 text-sm">
<Info className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="text-muted-foreground">
{isCanceling
? `Accès jusqu'au ${new Date(user.subscription_ends_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}`
: `Renouvellement le ${new Date(user.subscription_ends_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}`}
</span>
</div>
)}
<Separator />
<div className="flex flex-wrap gap-2">
<Button asChild size="sm" className="gap-2">
<Link href="/pricing">
<TrendingUp className="w-3.5 h-3.5" />
{isFreePlan ? 'Passer à un forfait payant' : 'Changer de forfait'}
</Link>
</Button>
{!isFreePlan && (
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={handleBillingPortal}
disabled={loadingPortal}
>
{loadingPortal
? <RefreshCw className="w-3.5 h-3.5 animate-spin" />
: <ExternalLink className="w-3.5 h-3.5" />}
Gérer la facturation
</Button>
)}
</div>
</CardContent>
</Card>
{/* Danger zone */}
{!isFreePlan && !isCanceling && (
<Card className="border-red-900/30">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2 text-red-400">
<ShieldAlert className="w-4 h-4" />
Zone danger
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
L'annulation prend effet à la fin de votre période de facturation en cours. Vous conservez l'accès jusqu'à cette date.
</p>
{cancelConfirm && (
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/30 text-sm text-destructive">
⚠️ Êtes-vous sûr(e) ? Cette action ne peut pas être annulée une fois la période terminée.
</div>
)}
<div className="flex gap-2">
<Button
variant="destructive"
size="sm"
onClick={handleCancel}
disabled={loadingCancel}
className="gap-2"
>
{loadingCancel && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
{cancelConfirm ? "Confirmer l'annulation" : 'Annuler mon abonnement'}
</Button>
{cancelConfirm && (
<Button variant="outline" size="sm" onClick={() => setCancelConfirm(false)}>
Non, garder
</Button>
)}
</div>
</CardContent>
</Card>
)}
</div>
{/* ── RIGHT column ── */}
<div className="flex flex-col gap-6">
{/* Usage card */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<BadgeCheck className="w-4 h-4 text-accent" />
Utilisation ce mois-ci
<span className="ml-auto text-xs text-muted-foreground font-normal">
Réinitialisation le {nextResetDate()}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<UsageBar
label="Documents traduits"
used={docsUsed}
limit={docsLimit}
icon={<FileText className="w-4 h-4" />}
/>
<UsageBar
label="Pages traduites"
used={pagesUsed}
limit={pagesLimit}
icon={<Layers className="w-4 h-4" />}
/>
{extraCredits > 0 && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20 text-sm">
<Info className="w-4 h-4 text-amber-400 flex-shrink-0" />
<span className="text-amber-300">
{extraCredits} crédit{extraCredits > 1 ? 's' : ''} supplémentaire{extraCredits > 1 ? 's' : ''} disponible{extraCredits > 1 ? 's' : ''}
</span>
</div>
)}
{usage?.upgrade_required && (
<div className="flex items-start gap-3 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-sm">
<AlertTriangle className="w-4 h-4 text-red-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-red-300 font-medium">Quota atteint</p>
<p className="text-red-400 text-xs mt-0.5">Passez à un forfait supérieur pour continuer à traduire.</p>
</div>
<Button asChild size="sm" className="bg-red-600 hover:bg-red-500 flex-shrink-0">
<Link href="/pricing"><ArrowRight className="w-3.5 h-3.5" /></Link>
</Button>
</div>
)}
{isFreePlan && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-accent/10 border border-accent/20 text-sm">
<Zap className="w-4 h-4 text-accent flex-shrink-0" />
<span className="text-accent/90 flex-1">Débloquez plus de traductions avec un forfait payant.</span>
<Button asChild size="sm" variant="outline" className="border-accent/30 text-accent hover:bg-accent/10 flex-shrink-0">
<Link href="/pricing">Voir les forfaits <ArrowRight className="w-3.5 h-3.5 ml-1" /></Link>
</Button>
</div>
)}
</CardContent>
</Card>
{/* Features included */}
{user?.plan_limits?.features?.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
Inclus dans votre forfait
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-2">
{user.plan_limits.features.map((f: string, i: number) => (
<div key={i} className="flex items-start gap-2 text-sm">
<CheckCircle2 className="w-4 h-4 text-emerald-400 flex-shrink-0 mt-0.5" />
<span className="text-muted-foreground">{f}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Preferences */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Globe className="w-4 h-4 text-accent" />
Préférences
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-foreground">Langue de l'interface</p>
<p className="text-xs text-muted-foreground mt-0.5">Choisissez la langue d'affichage</p>
</div>
<div className="flex gap-2 shrink-0">
{UI_LANGUAGES.map((lang) => (
<button
key={lang.value}
onClick={() => setLocale(lang.value)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
locale === lang.value
? 'bg-accent/10 border-accent/40 text-accent'
: 'bg-transparent border-border text-muted-foreground hover:text-foreground hover:border-border/80'
)}
>
<span>{lang.flag}</span>
<span>{lang.label}</span>
</button>
))}
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,8 +1,9 @@
'use client';
import { useRef } from 'react';
import { Upload } from 'lucide-react';
import { Upload, FileSpreadsheet, FileText, Presentation } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import type { UseFileUploadReturn } from './types';
interface FileDropZoneProps {
@@ -11,40 +12,72 @@ interface FileDropZoneProps {
export function FileDropZone({ upload }: FileDropZoneProps) {
const inputRef = useRef<HTMLInputElement>(null);
const { t } = useI18n();
const handleClick = () => {
inputRef.current?.click();
};
const handleClick = () => inputRef.current?.click();
return (
<div
role="button"
tabIndex={0}
aria-label={t('dashboard.translate.dropzone.uploadAria')}
className={cn(
'relative flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed px-6 py-10 transition-colors cursor-pointer',
'relative flex flex-col items-center justify-center gap-6 rounded-2xl border-2 border-dashed',
'min-h-[320px] cursor-pointer select-none transition-all duration-200 px-8 py-12',
upload.isDragOver
? 'border-primary bg-primary/5'
: 'border-border bg-muted/30 hover:border-muted-foreground/30'
? 'border-primary bg-primary/8 scale-[1.01]'
: 'border-border bg-muted/20 hover:border-primary/50 hover:bg-muted/40'
)}
onDragOver={upload.handleDragOver}
onDragLeave={upload.handleDragLeave}
onDrop={upload.handleDrop}
onClick={handleClick}
onKeyDown={(e) => e.key === 'Enter' || e.key === ' ' ? handleClick() : undefined}
>
<div className="flex size-12 items-center justify-center rounded-xl bg-secondary">
<Upload className="size-5 text-muted-foreground" />
{/* Icon */}
<div className={cn(
'flex size-20 items-center justify-center rounded-2xl transition-colors',
upload.isDragOver ? 'bg-primary/15' : 'bg-secondary'
)}>
<Upload className={cn(
'size-9 transition-colors',
upload.isDragOver ? 'text-primary' : 'text-muted-foreground'
)} />
</div>
<div className="flex flex-col items-center gap-1">
<p className="text-sm font-medium text-foreground">
Drag & drop your .xlsx, .docx, or .pptx file here
{/* Text */}
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-lg font-semibold text-foreground">
{t('dashboard.translate.dropzone.title')}
</p>
<p className="text-sm text-muted-foreground">
{t('dashboard.translate.dropzone.subtitle')}
</p>
<p className="text-xs text-muted-foreground">or click to browse</p>
</div>
{/* Format badges */}
<div className="flex flex-wrap items-center justify-center gap-3">
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
<FileSpreadsheet className="size-3.5 text-green-500" />
Excel (.xlsx)
</span>
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
<FileText className="size-3.5 text-blue-500" />
Word (.docx)
</span>
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
<Presentation className="size-3.5 text-orange-500" />
PowerPoint (.pptx)
</span>
</div>
<input
ref={inputRef}
type="file"
accept=".xlsx,.docx,.pptx"
className="hidden"
onChange={upload.handleFileSelect}
aria-label="Upload file"
aria-label={t('dashboard.translate.dropzone.uploadAria')}
/>
</div>
);

View File

@@ -8,6 +8,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useI18n } from '@/lib/i18n';
import type { Language } from './types';
interface LanguageSelectorProps {
@@ -29,36 +30,36 @@ export function LanguageSelector({
onSourceChange,
onTargetChange,
}: LanguageSelectorProps) {
const { t } = useI18n();
return (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-3">
{error && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertCircle className="size-3.5" />
<span>Failed to load languages: {error}</span>
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertCircle className="size-3.5 shrink-0" />
<span>{t('dashboard.translate.language.loadErrorPrefix')} {error}</span>
</div>
)}
<div className="flex items-center gap-3">
<div className="flex items-end gap-3">
{/* Source language */}
<div className="flex flex-1 flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Source Language
<label className="text-sm font-medium text-foreground">
{t('dashboard.translate.language.source')}
</label>
<Select
value={sourceLang}
onValueChange={onSourceChange}
disabled={isLoading}
>
<SelectTrigger className="w-full">
<Select value={sourceLang} onValueChange={onSourceChange} disabled={isLoading}>
<SelectTrigger className="h-11 w-full">
{isLoading ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
<span className="text-muted-foreground">Loading...</span>
<span>{t('dashboard.translate.language.loading')}</span>
</div>
) : (
<SelectValue placeholder="Auto-detect" />
<SelectValue placeholder={t('dashboard.translate.language.autoDetect')} />
)}
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto-detect</SelectItem>
<SelectItem value="auto">{t('dashboard.translate.language.autoDetect')}</SelectItem>
{languages.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.name}
@@ -68,25 +69,25 @@ export function LanguageSelector({
</Select>
</div>
<ArrowRight className="mt-5 size-4 shrink-0 text-muted-foreground" />
{/* Arrow — flips in RTL via rtl: variant */}
<div className="mb-2 flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<ArrowRight className="size-4 rtl:rotate-180" />
</div>
{/* Target language */}
<div className="flex flex-1 flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Target Language
<label className="text-sm font-medium text-foreground">
{t('dashboard.translate.language.target')}
</label>
<Select
value={targetLang}
onValueChange={onTargetChange}
disabled={isLoading}
>
<SelectTrigger className="w-full">
<Select value={targetLang} onValueChange={onTargetChange} disabled={isLoading}>
<SelectTrigger className="h-11 w-full">
{isLoading ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
<span className="text-muted-foreground">Loading...</span>
<span>{t('dashboard.translate.language.loading')}</span>
</div>
) : (
<SelectValue placeholder="Select language" />
<SelectValue placeholder={t('dashboard.translate.language.selectPlaceholder')} />
)}
</SelectTrigger>
<SelectContent>

View File

@@ -1,7 +1,8 @@
'use client';
import { Loader2, CheckCircle2, Lock } from 'lucide-react';
import { Loader2, CheckCircle2, Lock, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import type { Provider, AvailableProvider } from './types';
interface ProviderSelectorProps {
@@ -19,20 +20,21 @@ export function ProviderSelector({
isLoadingProviders,
isPro,
}: ProviderSelectorProps) {
const { t } = useI18n();
if (isLoadingProviders) {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
<span>Loading providers</span>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>{t('dashboard.translate.provider.loading')}</span>
</div>
);
}
if (availableProviders.length === 0) {
return (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
No providers are configured. Ask your administrator to enable at least one in the
admin settings.
<p className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700 dark:border-amber-800/50 dark:bg-amber-950/30 dark:text-amber-400">
{t('dashboard.translate.provider.noneConfigured')}
</p>
);
}
@@ -49,60 +51,86 @@ export function ProviderSelector({
disabled={locked}
onClick={() => !locked && onProviderChange(p.id)}
className={cn(
'flex w-full items-center justify-between rounded-lg border px-3 py-2.5 text-left text-sm transition-colors',
'flex w-full items-center gap-3 rounded-xl border px-4 py-3 text-start transition-all duration-150',
isSelected
? 'border-primary bg-primary/5 text-primary'
? 'border-primary bg-primary/8 ring-1 ring-primary/30'
: locked
? 'cursor-not-allowed border-border/40 bg-muted/30 text-muted-foreground'
: 'border-border/60 bg-background hover:border-primary/40 hover:bg-muted/40'
? 'cursor-not-allowed border-border/40 bg-muted/20 opacity-60'
: 'border-border/60 bg-background hover:border-primary/40 hover:bg-muted/30 active:scale-[0.99]'
)}
>
<div className="flex flex-col">
<span className="font-medium leading-tight">{p.label}</span>
<span className="text-xs text-muted-foreground">{p.description}</span>
{/* Selection indicator */}
<div className={cn(
'flex size-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors',
isSelected ? 'border-primary bg-primary' : 'border-border/60'
)}>
{isSelected && <div className="size-2 rounded-full bg-primary-foreground" />}
</div>
{/* Label + description */}
<div className="flex flex-1 flex-col gap-0.5 min-w-0">
<span className={cn(
'text-sm font-medium leading-tight',
isSelected ? 'text-primary' : locked ? 'text-muted-foreground' : 'text-foreground'
)}>
{p.label}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{p.description}
</span>
{p.mode === 'llm' && p.model && (
<span className="mt-0.5 text-[10px] font-mono text-muted-foreground/80" title="Modèle configuré par l'admin">
Modèle : {p.model}
<span className="mt-0.5 text-[10px] font-mono text-muted-foreground/70">
{t('dashboard.translate.provider.modelTitle')} {p.model}
</span>
)}
</div>
{/* Right badge */}
{locked ? (
<Lock className="size-3.5 shrink-0 text-muted-foreground/60" />
<Lock className="size-4 shrink-0 text-muted-foreground/50" />
) : isSelected ? (
<CheckCircle2 className="size-4 shrink-0 text-primary" />
<CheckCircle2 className="size-5 shrink-0 text-primary" />
) : null}
</button>
);
};
return (
<div className="space-y-3">
<p className="text-xs font-medium text-muted-foreground">Translation Provider</p>
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-foreground">
{t('dashboard.translate.provider.sectionTitle')}
</p>
{/* Classic providers — available to everyone */}
{/* Classic providers */}
{classicProviders.length > 0 && (
<div className="space-y-1.5">
<div className="flex flex-col gap-2">
{classicProviders.map((p) => renderCard(p, false))}
</div>
)}
{/* LLM providers — Pro only */}
{llmProviders.length > 0 && (
<div className="space-y-1.5">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-border/50" />
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
LLM · Context-Aware {!isPro && '· Pro'}
<span className="flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<Sparkles className="size-3" />
{t('dashboard.translate.provider.llmDivider')}
{!isPro && (
<span className="ms-1 rounded bg-primary/10 px-1 py-0.5 text-primary">
{t('dashboard.translate.provider.llmDividerPro')}
</span>
)}
</span>
<div className="h-px flex-1 bg-border/50" />
</div>
{llmProviders.map((p) => renderCard(p, !isPro))}
{!isPro && (
<p className="text-xs text-muted-foreground">
<a href="/pricing" className="text-primary hover:underline">
Upgrade to Pro
<p className="text-xs text-muted-foreground text-center">
<a href="/pricing" className="text-primary hover:underline font-medium">
{t('dashboard.translate.provider.upgrade')}
</a>{' '}
to use LLM-powered translation.
{t('dashboard.translate.provider.upgradeSuffix')}
</p>
)}
</div>

View File

@@ -1,10 +1,11 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { CheckCircle, Download, Plus, Loader2 } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { CheckCircle2, Download, Plus, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useNotification } from '@/components/ui/notification';
import { useI18n } from '@/lib/i18n';
import { API_BASE } from '@/lib/config';
interface TranslationCompleteProps {
jobId: string;
@@ -12,8 +13,6 @@ interface TranslationCompleteProps {
onNewTranslation: () => void;
}
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
export function TranslationComplete({
jobId,
fileName,
@@ -21,133 +20,126 @@ export function TranslationComplete({
}: TranslationCompleteProps) {
const [isDownloading, setIsDownloading] = useState(false);
const { success, error } = useNotification();
const { t } = useI18n();
const blobUrlRef = useRef<string | null>(null);
const handleDownload = async () => {
setIsDownloading(true);
try {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
if (token) headers['Authorization'] = `Bearer ${token}`;
const response = await fetch(`${API_BASE}/api/v1/download/${jobId}`, { headers });
if (!response.ok) {
let errorMessage = 'Download failed';
let msg = t('dashboard.translate.complete.toastFailDesc');
try {
const errorData = await response.json();
errorMessage = errorData.message || errorData.error || errorMessage;
} catch {
// Response not JSON
}
throw new Error(errorMessage);
const body = await response.json();
msg = body.message || body.error || msg;
} catch { /* not JSON */ }
throw new Error(msg);
}
const contentDisposition = response.headers.get('Content-Disposition');
let downloadFilename = 'translated_document';
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename\*?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']+)/i);
if (filenameMatch && filenameMatch[1]) {
downloadFilename = filenameMatch[1];
}
const match = contentDisposition.match(/filename\*?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']+)/i);
if (match?.[1]) downloadFilename = match[1];
} else if (fileName) {
const ext = fileName.split('.').pop() || '';
const baseName = fileName.replace(/\.[^.]+$/, '');
downloadFilename = `${baseName}_translated.${ext}`;
const base = fileName.replace(/\.[^.]+$/, '');
downloadFilename = `${base}_translated.${ext}`;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
blobUrlRef.current = url;
const a = document.createElement('a');
a.href = url;
a.download = downloadFilename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
if (blobUrlRef.current) { URL.revokeObjectURL(blobUrlRef.current); blobUrlRef.current = null; }
}, 1000);
success({
title: 'Download Complete',
description: `${downloadFilename} has been downloaded successfully.`,
title: t('dashboard.translate.complete.toastOkTitle'),
description: t('dashboard.translate.complete.toastOkDesc', { name: downloadFilename }),
});
} catch (err) {
error({
title: 'Download Failed',
description: err instanceof Error ? err.message : 'Failed to download the translated file.',
title: t('dashboard.translate.complete.toastFailTitle'),
description: err instanceof Error ? err.message : t('dashboard.translate.complete.toastFailDesc'),
});
} finally {
setIsDownloading(false);
setTimeout(() => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
if (blobUrlRef.current) { URL.revokeObjectURL(blobUrlRef.current); blobUrlRef.current = null; }
}, 5000);
}
};
useEffect(() => {
return () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
if (blobUrlRef.current) { URL.revokeObjectURL(blobUrlRef.current); blobUrlRef.current = null; }
};
}, []);
return (
<Card className="border-success/40 bg-gradient-to-br from-success/10 to-success/5 overflow-hidden">
<CardContent className="p-6 text-center">
<div className="w-14 h-14 mx-auto mb-4 rounded-full bg-success/20 flex items-center justify-center">
<CheckCircle className="w-8 h-8 text-success" />
</div>
<h3 className="text-lg font-semibold mb-2">Translation Complete!</h3>
<p className="text-sm text-muted-foreground mb-5">
{fileName ? `"${fileName}" has been translated successfully.` : 'Your document has been translated successfully.'}
<div className="flex w-full max-w-md flex-col items-center gap-8 rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
{/* Success icon */}
<div className="flex size-20 items-center justify-center rounded-full bg-green-500/15">
<CheckCircle2 className="size-10 text-green-500" />
</div>
{/* Text */}
<div className="flex flex-col gap-2">
<h3 className="text-xl font-bold text-foreground">
{t('dashboard.translate.complete.title')}
</h3>
<p className="text-sm text-muted-foreground">
{fileName
? t('dashboard.translate.complete.descNamed', { name: fileName })
: t('dashboard.translate.complete.descGeneric')}
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button
onClick={handleDownload}
disabled={isDownloading}
className="gap-2"
>
{isDownloading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Downloading...
</>
) : (
<>
<Download className="w-4 h-4" />
Download Translated File
</>
)}
</Button>
<Button
variant="outline"
onClick={onNewTranslation}
className="gap-2"
>
<Plus className="w-4 h-4" />
New Translation
</Button>
</div>
</CardContent>
</Card>
</div>
{/* Actions — stacked column so text is never cut */}
<div className="flex w-full flex-col gap-3">
<Button
size="lg"
className="h-12 w-full gap-2 text-base font-semibold"
onClick={handleDownload}
disabled={isDownloading}
>
{isDownloading ? (
<>
<Loader2 className="size-5 animate-spin" />
{t('dashboard.translate.complete.downloading')}
</>
) : (
<>
<Download className="size-5" />
{t('dashboard.translate.complete.download')}
</>
)}
</Button>
<Button
variant="outline"
size="lg"
className="h-11 w-full gap-2"
onClick={onNewTranslation}
>
<Plus className="size-4" />
{t('dashboard.translate.complete.newTranslation')}
</Button>
</div>
</div>
);
}

View File

@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from 'react';
import { AlertTriangle, Loader2, Clock, WifiOff } from 'lucide-react';
import { Progress } from '@/components/ui/progress';
import { useI18n } from '@/lib/i18n';
interface TranslationProgressProps {
progress: number;
@@ -14,15 +15,6 @@ interface TranslationProgressProps {
isCompleted?: boolean;
}
function formatTimeRemaining(seconds: number | null): string {
if (seconds === null || seconds <= 0) return '';
if (seconds < 60) return `${seconds}s remaining`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (remainingSeconds === 0) return `${minutes} min remaining`;
return `${minutes}m ${remainingSeconds}s remaining`;
}
export function TranslationProgress({
progress,
currentStep,
@@ -32,8 +24,7 @@ export function TranslationProgress({
isUploading = false,
isCompleted = false,
}: TranslationProgressProps) {
// Disable CSS transition on the very first render so that when progress
// resets from a previous job's 100% → 0%, there is no visible backward sweep.
const { t } = useI18n();
const [animate, setAnimate] = useState(false);
const prevProgressRef = useRef(progress);
@@ -41,25 +32,40 @@ export function TranslationProgress({
if (progress > 0) {
setAnimate(true);
} else if (progress === 0) {
// Momentarily cut the transition to snap to 0, then re-enable.
setAnimate(false);
const t = setTimeout(() => setAnimate(true), 50);
return () => clearTimeout(t);
const tid = setTimeout(() => setAnimate(true), 50);
return () => clearTimeout(tid);
}
prevProgressRef.current = progress;
}, [progress]);
function formatTimeRemaining(seconds: number | null): string {
if (seconds === null || seconds <= 0) return '';
if (seconds < 60)
return t('dashboard.translate.progress.timeSeconds', { seconds: String(seconds) });
const minutes = Math.floor(seconds / 60);
const remaining = seconds % 60;
if (remaining === 0)
return t('dashboard.translate.progress.timeMinutes', { minutes: String(minutes) });
return t('dashboard.translate.progress.timeMixed', {
minutes: String(minutes),
seconds: String(remaining),
});
}
if (error) {
return (
<div
className="rounded-lg bg-destructive/10 border border-destructive/30 p-4"
<div
className="rounded-xl bg-destructive/10 border border-destructive/20 p-5"
role="alert"
aria-live="assertive"
>
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" aria-hidden="true" />
<AlertTriangle className="size-5 text-destructive shrink-0 mt-0.5" aria-hidden />
<div>
<p className="text-sm font-medium text-destructive mb-1">Translation Failed</p>
<p className="text-sm font-semibold text-destructive mb-1">
{t('dashboard.translate.progress.failedTitle')}
</p>
<p className="text-sm text-destructive/80">{error}</p>
</div>
</div>
@@ -68,40 +74,48 @@ export function TranslationProgress({
}
const timeRemaining = formatTimeRemaining(estimatedRemaining);
// Only show "Connection lost" when polling was active and then stopped —
// never during the initial upload phase.
const showConnectionLost = !isPolling && !isCompleted && !isUploading;
return (
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
{currentStep || 'Processing...'}
</span>
<span className="text-primary font-medium tabular-nums" aria-live="polite">
{Math.round(progress)}%
</span>
<div className="flex flex-col items-center gap-6 py-4">
{/* Animated spinner */}
<div className="flex size-16 items-center justify-center rounded-full bg-primary/10">
<Loader2 className="size-8 animate-spin text-primary" aria-hidden />
</div>
<Progress
value={progress}
animate={animate}
className="h-2"
aria-label="Translation progress"
aria-valuenow={Math.round(progress)}
aria-valuemin={0}
aria-valuemax={100}
/>
{/* Status text */}
<div className="flex flex-col items-center gap-1 text-center">
<p className="text-base font-medium text-foreground">
{currentStep || t('dashboard.translate.progress.processingFallback')}
</p>
{timeRemaining && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock className="size-3.5" aria-hidden />
{timeRemaining}
</p>
)}
</div>
{/* Progress bar + percentage */}
<div className="w-full max-w-md space-y-2">
<Progress
value={progress}
animate={animate}
className="h-3 rounded-full"
aria-label={t('dashboard.translate.progress.ariaProgress')}
aria-valuenow={Math.round(progress)}
aria-valuemin={0}
aria-valuemax={100}
/>
<p className="text-end text-sm font-semibold tabular-nums text-primary" aria-live="polite">
{Math.round(progress)} %
</p>
</div>
{showConnectionLost && (
<div className="flex items-center gap-2 text-xs text-amber-600">
<WifiOff className="h-3 w-3" aria-hidden="true" />
<span>Connection lost. Retrying...</span>
</div>
)}
{timeRemaining && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3 w-3" aria-hidden="true" />
<span>{timeRemaining}</span>
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-400">
<WifiOff className="size-3.5" aria-hidden />
<span>{t('dashboard.translate.progress.connectionLost')}</span>
</div>
)}
</div>

View File

@@ -1,11 +1,20 @@
'use client';
import { useEffect, useRef } from 'react';
import { Languages, ShieldCheck, Clock, ArrowRight, RotateCcw, Loader2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import {
ShieldCheck,
Clock,
ArrowRight,
RotateCcw,
Loader2,
FileSpreadsheet,
FileText,
Presentation,
Upload,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { FileDropZone } from './FileDropZone';
import { FilePreview } from './FilePreview';
import { useFileUpload } from './useFileUpload';
import { useTranslationConfig } from './useTranslationConfig';
import { useTranslationSubmit } from './useTranslationSubmit';
@@ -14,187 +23,290 @@ import { ProviderSelector } from './ProviderSelector';
import { TranslationProgress } from './TranslationProgress';
import { TranslationComplete } from './TranslationComplete';
import { useNotification } from '@/components/ui/notification';
import { useI18n } from '@/lib/i18n';
/* ── helpers ─────────────────────────────────────────────────────── */
const FILE_ICONS: Record<string, React.ElementType> = {
xlsx: FileSpreadsheet,
docx: FileText,
pptx: Presentation,
};
const FILE_COLORS: Record<string, string> = {
xlsx: 'text-green-500',
docx: 'text-blue-500',
pptx: 'text-orange-500',
};
function fmt(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(1)} MB`;
}
/* ── Compact file strip ──────────────────────────────────────────── */
function FileStrip({
file,
onRemove,
onReplace,
}: {
file: File;
onRemove: () => void;
onReplace: () => void;
}) {
const { t } = useI18n();
const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
const FileIcon = FILE_ICONS[ext] ?? FileText;
const color = FILE_COLORS[ext] ?? 'text-muted-foreground';
return (
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
<FileIcon className={`size-5 shrink-0 ${color}`} />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-semibold text-foreground">{file.name}</span>
<span className="text-xs text-muted-foreground">{fmt(file.size)} · .{ext.toUpperCase()}</span>
</div>
<button
type="button"
onClick={onReplace}
className="flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground transition hover:bg-secondary hover:text-foreground"
>
<Upload className="size-3.5" />
{t('dashboard.translate.dropzone.replaceFile')}
</button>
<button
type="button"
aria-label="Remove"
onClick={onRemove}
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-secondary hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
);
}
/* ── Trust row ───────────────────────────────────────────────────── */
function TrustRow() {
const { t } = useI18n();
return (
<div className="flex flex-wrap items-center justify-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<ShieldCheck className="size-3.5" />
{t('dashboard.translate.trust.zeroRetention')}
</span>
<span className="h-3 w-px bg-border" aria-hidden />
<span className="flex items-center gap-1.5">
<Clock className="size-3.5" />
{t('dashboard.translate.trust.deletedAfter')}
</span>
</div>
);
}
/* ── Page ────────────────────────────────────────────────────────── */
export default function TranslatePage() {
const upload = useFileUpload();
const config = useTranslationConfig(!!upload.file);
const submit = useTranslationSubmit();
const { error: showError } = useNotification();
const { t } = useI18n();
const lastErrorRef = useRef<string | null>(null);
const replaceInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (submit.error && submit.error !== lastErrorRef.current) {
lastErrorRef.current = submit.error;
showError({
title: t('dashboard.translate.errorNotificationTitle'),
description: submit.error,
});
}
}, [submit.error, showError, t]);
const handleTranslate = async () => {
if (!upload.file || !config.isConfigValid) return;
await submit.submitTranslation(upload.file, config.getConfig());
};
useEffect(() => {
if (submit.error && submit.error !== lastErrorRef.current) {
lastErrorRef.current = submit.error;
showError({
title: 'Translation Error',
description: submit.error,
});
}
}, [submit.error, showError]);
const handleNewTranslation = () => {
submit.reset();
upload.removeFile();
};
const isConfiguring = upload.file && submit.status === 'idle' && !submit.isSubmitting;
const isProcessing = (submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed';
const isConfiguring = !!upload.file && submit.status === 'idle' && !submit.isSubmitting;
const isProcessing =
(submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed';
const isCompleted = submit.status === 'completed';
const isFailed = submit.status === 'failed';
return (
<div className="mx-auto max-w-xl px-4 py-6 lg:px-8">
<Card className="border-border/70 shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Languages className="size-5" />
Office Translator
</CardTitle>
<CardDescription>
Upload an Excel, Word, or PowerPoint file to translate
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{upload.file && !isProcessing && !isCompleted && (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-success/40 bg-success/5 px-6 py-4">
<FilePreview file={upload.file} onRemove={upload.removeFile} />
</div>
)}
{!upload.file && !isProcessing && !isCompleted && (
<FileDropZone upload={upload} />
)}
{upload.error && !isProcessing && !isCompleted && (
<p className="text-sm text-destructive">{upload.error}</p>
)}
{isConfiguring && (
<>
<div className="my-2 flex items-center gap-2">
<div className="h-px flex-1 bg-border" />
<span className="text-xs text-muted-foreground">Configuration</span>
<div className="h-px flex-1 bg-border" />
</div>
<LanguageSelector
sourceLang={config.sourceLang}
targetLang={config.targetLang}
languages={config.languages}
isLoading={config.isLoadingLanguages}
error={config.languagesError}
onSourceChange={config.setSourceLang}
onTargetChange={config.setTargetLang}
/>
<ProviderSelector
provider={config.provider}
onProviderChange={config.setProvider}
availableProviders={config.availableProviders}
isLoadingProviders={config.isLoadingProviders}
isPro={config.isPro}
/>
<Button
size="lg"
className="w-full text-sm font-semibold"
disabled={!config.isConfigValid || submit.isSubmitting}
onClick={handleTranslate}
>
{submit.isSubmitting ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Uploading...
</>
) : (
<>
Translate Document
<ArrowRight className="ml-2 size-4" />
</>
)}
</Button>
</>
)}
{isProcessing && !isCompleted && (
<>
<div className="flex items-center justify-between text-sm text-muted-foreground mb-2">
<span>File: {submit.fileName || upload.file?.name}</span>
</div>
<TranslationProgress
progress={submit.progress}
currentStep={submit.currentStep || (submit.isSubmitting ? 'Uploading file...' : 'Starting translation...')}
estimatedRemaining={submit.estimatedRemaining}
error={null}
isPolling={submit.isPolling}
isUploading={submit.isSubmitting}
isCompleted={false}
/>
<Button
variant="outline"
size="sm"
onClick={handleNewTranslation}
className="w-full mt-2"
>
<RotateCcw className="mr-2 size-4" />
Cancel
</Button>
</>
)}
{isCompleted && submit.jobId && (
<TranslationComplete
jobId={submit.jobId}
fileName={submit.fileName}
onNewTranslation={handleNewTranslation}
/>
)}
{isFailed && (
<>
<TranslationProgress
progress={submit.progress}
currentStep={submit.currentStep}
estimatedRemaining={submit.estimatedRemaining}
error={submit.error}
isPolling={false}
isCompleted={false}
/>
<Button
variant="outline"
onClick={handleNewTranslation}
className="w-full mt-2"
>
<RotateCcw className="mr-2 size-4" />
Try Again
</Button>
</>
)}
{!upload.file && !isProcessing && !isCompleted && !isFailed && (
<p className="text-xs text-muted-foreground">
Supported formats: Excel (.xlsx), Word (.docx), PowerPoint (.pptx)
</p>
)}
</CardContent>
</Card>
<div className="flex items-center justify-center gap-4 mt-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<ShieldCheck className="size-3.5" />
<span>Zero Data Retention</span>
/* ── STATE: No file ─────────────────────────────────────────────── */
if (!upload.file && !isProcessing && !isCompleted && !isFailed) {
return (
<div className="flex h-full flex-col gap-6 overflow-y-auto p-6 lg:p-8">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
{t('dashboard.translate.pageTitle')}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{t('dashboard.translate.pageSubtitle')}
</p>
</div>
<div className="h-3 w-px bg-border" />
<div className="flex items-center gap-1.5">
<Clock className="size-3.5" />
<span>Files deleted after 60 min</span>
<div className="flex flex-1 flex-col">
<FileDropZone upload={upload} />
{upload.error && <p className="mt-2 text-sm text-destructive">{upload.error}</p>}
</div>
<TrustRow />
</div>
);
}
/* ── STATE: Configure — single column, full width ───────────────── */
if (isConfiguring) {
return (
<div className="flex h-full flex-col overflow-y-auto">
{/* Scrollable content */}
<div className="flex flex-1 flex-col gap-6 p-6 lg:p-8">
{/* Page title */}
<div>
<h1 className="text-xl font-bold tracking-tight text-foreground">
{t('dashboard.translate.pageTitle')}
</h1>
</div>
{/* File strip */}
<FileStrip
file={upload.file!}
onRemove={upload.removeFile}
onReplace={() => replaceInputRef.current?.click()}
/>
<input
ref={replaceInputRef}
type="file"
accept=".xlsx,.docx,.pptx"
className="hidden"
onChange={upload.handleFileSelect}
/>
{upload.error && <p className="text-sm text-destructive">{upload.error}</p>}
{/* Language selector — full width */}
<LanguageSelector
sourceLang={config.sourceLang}
targetLang={config.targetLang}
languages={config.languages}
isLoading={config.isLoadingLanguages}
error={config.languagesError}
onSourceChange={config.setSourceLang}
onTargetChange={config.setTargetLang}
/>
{/* Provider selector — full width */}
<ProviderSelector
provider={config.provider}
onProviderChange={config.setProvider}
availableProviders={config.availableProviders}
isLoadingProviders={config.isLoadingProviders}
isPro={config.isPro}
/>
</div>
{/* Sticky bottom bar: button + trust */}
<div className="shrink-0 border-t border-border/50 bg-background px-6 py-4 lg:px-8">
<Button
size="lg"
className="h-13 w-full gap-2 text-base font-semibold"
disabled={!config.isConfigValid || submit.isSubmitting}
onClick={handleTranslate}
>
{submit.isSubmitting ? (
<>
<Loader2 className="size-5 animate-spin" />
{t('dashboard.translate.actions.uploading')}
</>
) : (
<>
{t('dashboard.translate.actions.translate')}
<ArrowRight className="size-5 rtl:rotate-180" />
</>
)}
</Button>
<div className="mt-3">
<TrustRow />
</div>
</div>
</div>
</div>
);
);
}
/* ── STATE: Processing ──────────────────────────────────────────── */
if (isProcessing) {
return (
<div className="flex h-full flex-col items-center justify-center gap-6 overflow-y-auto p-6">
{(submit.fileName || upload.file?.name) && (
<p className="text-sm text-muted-foreground">
{t('dashboard.translate.actions.filePrefix')}{' '}
<span className="font-medium text-foreground">
{submit.fileName || upload.file?.name}
</span>
</p>
)}
<div className="w-full max-w-md">
<TranslationProgress
progress={submit.progress}
currentStep={
submit.currentStep ||
(submit.isSubmitting
? t('dashboard.translate.steps.uploading')
: t('dashboard.translate.steps.starting'))
}
estimatedRemaining={submit.estimatedRemaining}
error={null}
isPolling={submit.isPolling}
isUploading={submit.isSubmitting}
isCompleted={false}
/>
</div>
<Button variant="outline" size="lg" onClick={handleNewTranslation} className="gap-2">
<RotateCcw className="size-4" />
{t('dashboard.translate.actions.cancel')}
</Button>
</div>
);
}
/* ── STATE: Complete ────────────────────────────────────────────── */
if (isCompleted && submit.jobId) {
return (
<div className="flex h-full items-center justify-center overflow-y-auto p-6">
<TranslationComplete
jobId={submit.jobId}
fileName={submit.fileName}
onNewTranslation={handleNewTranslation}
/>
</div>
);
}
/* ── STATE: Failed ──────────────────────────────────────────────── */
if (isFailed) {
return (
<div className="flex h-full flex-col items-center justify-center gap-6 overflow-y-auto p-6">
<div className="w-full max-w-md">
<TranslationProgress
progress={submit.progress}
currentStep={submit.currentStep}
estimatedRemaining={submit.estimatedRemaining}
error={submit.error}
isPolling={false}
isCompleted={false}
/>
</div>
<Button variant="outline" size="lg" onClick={handleNewTranslation} className="gap-2">
<RotateCcw className="size-4" />
{t('dashboard.translate.actions.tryAgain')}
</Button>
</div>
);
}
return null;
}

View File

@@ -9,9 +9,7 @@ import type {
TranslationConfig,
AvailableProvider,
} from './types';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
import { API_BASE } from '@/lib/config';
/** Fallback when API fails — Google is always available server-side */
const FALLBACK_PROVIDERS: AvailableProvider[] = [

View File

@@ -8,8 +8,7 @@ import type {
TranslationSubmitResponse,
TranslationStatusResponse
} from './types';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
import { API_BASE } from '@/lib/config';
const POLLING_INTERVAL_MS = 2000;
const MAX_POLLING_FAILURES = 3;
@@ -58,6 +57,7 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
if (!response.ok) {
if (response.status === 404) {
stopPolling();
setIsSubmitting(false);
setStatus('failed');
setError('Translation job not found');
return;
@@ -81,6 +81,7 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
if (job.status === 'completed' || job.status === 'failed') {
stopPolling();
setIsSubmitting(false);
if (job.status === 'failed') {
setError(job.error_message || 'Translation failed');
}
@@ -92,6 +93,7 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
if (pollingFailuresRef.current >= MAX_POLLING_FAILURES) {
stopPolling();
setIsSubmitting(false);
setStatus('failed');
setError('Lost connection to translation service. Please check your internet connection and try again.');
}

View File

@@ -1,7 +1,30 @@
export interface PlanLimits {
docs_per_month: number;
max_pages_per_doc: number;
max_file_size_mb: number;
features: string[];
api_access: boolean;
providers: string[];
}
export interface User {
id: string;
email: string;
name: string;
tier: 'free' | 'pro';
avatar_url?: string;
// Plan information
tier: string; // alias for plan (legacy)
plan: string; // 'free' | 'starter' | 'pro' | 'business' | 'enterprise'
subscription_status: string; // 'active' | 'canceled' | 'past_due' | 'trialing'
subscription_ends_at?: string;
cancel_at_period_end?: boolean;
// Usage stats (current month)
docs_translated_this_month: number;
pages_translated_this_month: number;
api_calls_this_month: number;
extra_credits: number;
// Plan limits
plan_limits?: PlanLimits;
// Account
created_at: string;
}

View File

@@ -17,3 +17,13 @@ export function getInitials(name: string): string {
.toUpperCase()
.slice(0, 2);
}
/** Libellé de plan localisé (clés `dashboard.tier.*` dans les messages). */
export function translateTier(
t: (key: string) => string,
tier: string | undefined
): string {
const key = `dashboard.tier.${tier ?? 'free'}`;
const label = t(key);
return label === key ? tier ?? t('dashboard.tier.free') : label;
}