feat: rewrite all dashboard views with editorial design
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m29s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m29s
Translate page: editorial dropzone (rounded-[40px]), config panel, progress stepper, download button, Momento promo banner. Profile page: pill tabs, dark subscription card, usage bars, language grid. Context page: preset grid (rounded-[32px]), instruction/glossary textareas. API keys page: editorial-card with production key display. Glossaries page: editorial-card grid with hover lift. Pricing page: 5-column grid, colored headers, plan badges. All pages use editorial design system: accent-pill, editorial-card, premium-button, brand colors, dark mode support. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Zap, Plus, AlertCircle } from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Zap, Plus, AlertCircle, Clock } from 'lucide-react';
|
||||
import { useUser } from '@/app/dashboard/useUser';
|
||||
import { useApiKeys } from './useApiKeys';
|
||||
import { MAX_API_KEYS, type ApiKey } from './types';
|
||||
@@ -16,6 +12,7 @@ import { RevokeKeyDialog } from './RevokeKeyDialog';
|
||||
import { WebhookSnippet } from './WebhookSnippet';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function ApiKeysPage() {
|
||||
const { t } = useI18n();
|
||||
@@ -122,8 +119,8 @@ export default function ApiKeysPage() {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-4 border-muted border-t-foreground mx-auto"></div>
|
||||
<p className="text-sm text-muted-foreground">{t('apiKeys.loading')}</p>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-4 border-brand-muted border-t-brand-accent mx-auto"></div>
|
||||
<p className="text-[9px] font-black text-brand-dark/40 dark:text-white/40 uppercase tracking-widest">{t('apiKeys.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -134,70 +131,94 @@ export default function ApiKeysPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('apiKeys.title')}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{t('apiKeys.subtitle')}
|
||||
</p>
|
||||
<div className="max-w-4xl mx-auto w-full p-6 lg:p-8 space-y-8">
|
||||
{/* ── Editorial Header ───────────────────────────────────── */}
|
||||
<div className="mb-4">
|
||||
<span className="accent-pill mb-4 block w-fit">{t('apiKeys.sectionTitle')}</span>
|
||||
<h1 className="text-5xl font-black uppercase tracking-tighter mb-4 leading-none text-brand-dark dark:text-white">
|
||||
{t('apiKeys.title')}
|
||||
</h1>
|
||||
<p className="text-brand-dark/40 dark:text-white/40 font-medium">{t('apiKeys.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* API Error */}
|
||||
{apiError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{apiError}</AlertDescription>
|
||||
</Alert>
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl border border-red-200 dark:border-red-500/30 bg-red-50 dark:bg-red-500/10 text-sm text-red-600 dark:text-red-400">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span className="flex-1">{apiError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-accent/10">
|
||||
<Zap className="size-4 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{t('apiKeys.sectionTitle')}</CardTitle>
|
||||
<CardDescription>{t('apiKeys.sectionDesc')}</CardDescription>
|
||||
</div>
|
||||
{/* ── Main Card ──────────────────────────────────────────── */}
|
||||
<div className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial">
|
||||
{/* Section header */}
|
||||
<div className="flex items-center gap-4 mb-10 text-brand-accent">
|
||||
<div className="w-12 h-12 bg-brand-muted dark:bg-white/10 rounded-2xl flex items-center justify-center text-brand-accent">
|
||||
<Zap size={24} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t('apiKeys.keysUsed', { total, max: MAX_API_KEYS })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{maxKeysReached ? (
|
||||
<span className="text-amber-600">{t('apiKeys.maxReached')}</span>
|
||||
) : (
|
||||
t(MAX_API_KEYS - total !== 1 ? 'apiKeys.canGeneratePlural' : 'apiKeys.canGenerate', { count: MAX_API_KEYS - total })
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setGenerateDialogOpen(true)}
|
||||
disabled={maxKeysReached || isGenerating}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
{t('apiKeys.generateNew')}
|
||||
</Button>
|
||||
<div>
|
||||
<h3 className="text-[11px] font-black uppercase tracking-[0.3em] text-brand-dark dark:text-white">
|
||||
{t('apiKeys.sectionTitle')}
|
||||
</h3>
|
||||
<p className="text-[9px] text-brand-dark/30 dark:text-white/30 font-bold uppercase tracking-widest mt-1">
|
||||
{t('apiKeys.sectionDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ApiKeyTable
|
||||
keys={keys}
|
||||
onRevoke={handleRevokeClick}
|
||||
isRevoking={isRevoking}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Key count + generate button */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<p className="text-[11px] font-black uppercase tracking-widest text-brand-dark dark:text-white">
|
||||
{t('apiKeys.keysUsed', { total, max: MAX_API_KEYS })}
|
||||
</p>
|
||||
<p className="text-[9px] text-brand-dark/30 dark:text-white/30 font-black uppercase tracking-widest mt-1">
|
||||
{maxKeysReached ? (
|
||||
<span className="text-amber-600 dark:text-amber-400">{t('apiKeys.maxReached')}</span>
|
||||
) : (
|
||||
t(MAX_API_KEYS - total !== 1 ? 'apiKeys.canGeneratePlural' : 'apiKeys.canGenerate', { count: MAX_API_KEYS - total })
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setGenerateDialogOpen(true)}
|
||||
disabled={maxKeysReached || isGenerating}
|
||||
className="premium-button px-8 py-3 text-[9px] uppercase tracking-widest !rounded-xl flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t('apiKeys.generateNew')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
{/* Production key display area */}
|
||||
<div className="flex items-center justify-between p-8 bg-brand-muted dark:bg-white/5 rounded-3xl border border-black/5 dark:border-white/10 mb-8">
|
||||
<div>
|
||||
<p className="text-[9px] text-brand-dark/40 dark:text-white/40 font-black uppercase tracking-[0.3em] mb-2">
|
||||
{t('apiKeys.sectionTitle')}
|
||||
</p>
|
||||
<p className="text-sm font-mono text-brand-dark dark:text-white">
|
||||
{keys.length > 0 ? `${keys[0].key_prefix}************************************` : 'No keys generated'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button className="p-3 bg-white dark:bg-white/10 rounded-xl text-brand-dark/40 dark:text-white/40 hover:text-brand-dark dark:hover:text-white border border-black/5 dark:border-white/10 transition-all">
|
||||
<Clock size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key table */}
|
||||
<ApiKeyTable
|
||||
keys={keys}
|
||||
onRevoke={handleRevokeClick}
|
||||
isRevoking={isRevoking}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Webhook snippet */}
|
||||
<WebhookSnippet />
|
||||
|
||||
{/* Dialogs */}
|
||||
<GenerateKeyDialog
|
||||
open={generateDialogOpen}
|
||||
onOpenChange={setGenerateDialogOpen}
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Zap, Save, Brain, BookOpen, Trash2, Crown, Loader2,
|
||||
Wrench, HardHat, Monitor, Scale, Stethoscope, BarChart3,
|
||||
Megaphone, Car, MessageSquare, Database,
|
||||
} from 'lucide-react';
|
||||
import { useTranslationStore } from '@/lib/store';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { Save, Loader2, Brain, BookOpen, Sparkles, Trash2, Zap, Crown, Lock } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const PRESETS = [
|
||||
{ key: 'hvac', icon: '🔧', title: 'HVAC / Génie climatique', desc: 'Thermique, ventilation, climatisation' },
|
||||
{ key: 'construction', icon: '🏗️', title: 'BTP / Construction', desc: 'Gros œuvre, second œuvre, normes' },
|
||||
{ key: 'it', icon: '💻', title: 'IT / Logiciel', desc: 'Développement, infrastructure, DevOps' },
|
||||
{ key: 'legal', icon: '⚖️', title: 'Juridique / Contrats', desc: 'Droit des affaires, contentieux' },
|
||||
{ key: 'medical', icon: '🏥', title: 'Médical / Santé', desc: 'Pharmacologie, chirurgie, diagnostic' },
|
||||
{ key: 'finance', icon: '📊', title: 'Finance / Comptabilité', desc: 'IFRS, bilans, fiscalité' },
|
||||
{ key: 'marketing', icon: '📢', title: 'Marketing / Publicité', desc: 'Digital, branding, analytics' },
|
||||
{ key: 'automotive', icon: '🚗', title: 'Automobile', desc: 'Motorisation, ADAS, homologation' },
|
||||
{ key: 'hvac', title: 'HVAC / Génie climatique', desc: 'Thermique, ventilation, climatisation', icon: Wrench },
|
||||
{ key: 'construction', title: 'BTP / Construction', desc: 'Gros œuvre, second œuvre, normes', icon: HardHat },
|
||||
{ key: 'it', title: 'IT / Logiciel', desc: 'Développement, infrastructure, DevOps', icon: Monitor },
|
||||
{ key: 'legal', title: 'Juridique / Contrats', desc: 'Droit des affaires, contentieux', icon: Scale },
|
||||
{ key: 'medical', title: 'Médical / Santé', desc: 'Pharmacologie, chirurgie, diagnostic', icon: Stethoscope },
|
||||
{ key: 'finance', title: 'Finance / Comptabilité', desc: 'IFRS, bilans, fiscalité', icon: BarChart3 },
|
||||
{ key: 'marketing', title: 'Marketing / Publicité', desc: 'Digital, branding, analytics', icon: Megaphone },
|
||||
{ key: 'automotive', title: 'Automobile', desc: 'Motorisation, ADAS, homologation', icon: Car },
|
||||
];
|
||||
|
||||
export default function ContextGlossaryPage() {
|
||||
@@ -88,107 +89,137 @@ export default function ContextGlossaryPage() {
|
||||
if (!isPro) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-6 p-6">
|
||||
<div className="flex items-center justify-center w-20 h-20 rounded-2xl bg-violet-100 dark:bg-violet-900/30">
|
||||
<Crown className="w-10 h-10 text-violet-600 dark:text-violet-400" />
|
||||
<div className="w-20 h-20 bg-brand-dark dark:bg-white/10 rounded-[32px] flex items-center justify-center text-brand-accent shadow-2xl">
|
||||
<Crown className="w-10 h-10" />
|
||||
</div>
|
||||
<div className="text-center space-y-2 max-w-md">
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('context.proTitle')}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{t('context.proDesc')}
|
||||
</p>
|
||||
<h1 className="text-3xl font-black uppercase tracking-tighter text-brand-dark dark:text-white">{t('context.proTitle')}</h1>
|
||||
<p className="text-brand-dark/40 dark:text-white/40 font-medium">{t('context.proDesc')}</p>
|
||||
</div>
|
||||
<Button asChild size="lg">
|
||||
<Link href="/pricing">
|
||||
<Crown className="w-4 h-4 me-2" /> {t('context.viewPlans')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Link href="/pricing">
|
||||
<button className="premium-button px-10 py-4 text-[11px] uppercase tracking-widest !rounded-2xl flex items-center gap-2">
|
||||
<Crown size={14} /> {t('context.viewPlans')}
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 lg:p-8 max-w-3xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('context.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('context.subtitle')}
|
||||
</p>
|
||||
<div className="max-w-5xl mx-auto w-full p-6 lg:p-8">
|
||||
{/* ── Editorial Header ───────────────────────────────────── */}
|
||||
<div className="mb-12">
|
||||
<span className="accent-pill mb-4 block w-fit italic">{t('context.presets.title')}</span>
|
||||
<h1 className="text-5xl font-black uppercase tracking-tighter mb-4 leading-none text-brand-dark dark:text-white">
|
||||
{t('context.title')}
|
||||
</h1>
|
||||
<p className="text-brand-dark/40 dark:text-white/40 font-medium max-w-2xl">{t('context.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Presets */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-accent" /> {t('context.presets.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('context.presets.desc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
{PRESETS.map(p => (
|
||||
<button
|
||||
key={p.key}
|
||||
onClick={() => handleApplyPreset(p.key)}
|
||||
className="flex flex-col items-center gap-1 p-3 rounded-xl border border-border text-center transition-colors hover:border-primary/40 hover:bg-primary/5"
|
||||
>
|
||||
<span className="text-2xl">{p.icon}</span>
|
||||
<span className="text-xs font-medium text-foreground leading-tight">{p.title}</span>
|
||||
<span className="text-[10px] text-muted-foreground leading-tight">{p.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="space-y-12">
|
||||
{/* ── Professional Presets ──────────────────────────────── */}
|
||||
<section className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial">
|
||||
<div className="flex items-center gap-4 mb-8 text-brand-accent">
|
||||
<Zap size={20} />
|
||||
<h3 className="text-[11px] font-black uppercase tracking-[0.3em] text-brand-dark dark:text-white">
|
||||
{t('context.presets.title')}
|
||||
</h3>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p className="text-sm text-brand-dark/40 dark:text-white/40 mb-12 font-medium">{t('context.presets.desc')}</p>
|
||||
|
||||
{/* System Prompt */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Brain className="w-4 h-4 text-primary" /> {t('context.instructions.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('context.instructions.desc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Textarea
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{PRESETS.map((p) => {
|
||||
const Icon = p.icon;
|
||||
return (
|
||||
<button
|
||||
key={p.key}
|
||||
onClick={() => handleApplyPreset(p.key)}
|
||||
className="p-6 bg-brand-muted dark:bg-white/5 hover:bg-brand-dark dark:hover:bg-brand-dark group transition-all rounded-[32px] text-center border border-black/5 dark:border-white/10 hover:shadow-2xl hover:-translate-y-1"
|
||||
>
|
||||
<div className="flex justify-center mb-4 text-brand-dark group-hover:text-brand-accent group-hover:scale-125 transition-all">
|
||||
<Icon size={24} />
|
||||
</div>
|
||||
<h4 className="text-[11px] font-black uppercase tracking-tight text-brand-dark dark:text-white group-hover:text-white mb-2">
|
||||
{p.title}
|
||||
</h4>
|
||||
<p className="text-[8px] text-brand-dark/30 dark:text-white/30 group-hover:text-white/40 font-bold uppercase tracking-widest leading-relaxed">
|
||||
{p.desc}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Context Instructions ──────────────────────────────── */}
|
||||
<section className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial">
|
||||
<div className="flex items-center gap-4 mb-8 text-brand-accent">
|
||||
<MessageSquare size={20} />
|
||||
<h3 className="text-[11px] font-black uppercase tracking-[0.3em] text-brand-dark dark:text-white">
|
||||
{t('context.instructions.title')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-brand-dark/40 dark:text-white/40 mb-10 font-medium">{t('context.instructions.desc')}</p>
|
||||
<textarea
|
||||
value={localSettings.systemPrompt}
|
||||
onChange={e => setLocalSettings({ ...localSettings, systemPrompt: e.target.value })}
|
||||
placeholder={t('context.instructions.placeholder')}
|
||||
className="min-h-[140px] resize-y"
|
||||
className="w-full h-48 p-8 bg-brand-muted dark:bg-white/5 rounded-[32px] border border-black/5 dark:border-white/10 text-sm focus:ring-2 focus:ring-brand-accent/20 focus:border-brand-accent/30 transition-all outline-none resize-y"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex justify-end mt-6 gap-4">
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="px-8 py-3 bg-brand-muted dark:bg-white/5 text-brand-dark/40 dark:text-white/40 rounded-xl text-[9px] font-black uppercase tracking-widest hover:text-brand-dark dark:hover:text-white transition-all"
|
||||
>
|
||||
<Trash2 size={12} className="inline mr-2" />{t('context.clearAll')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="premium-button px-10 py-3 text-[9px] uppercase tracking-widest !rounded-xl flex items-center gap-3 disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
{isSaving ? t('context.saving') : t('context.save')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Glossary */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-emerald-500" /> {t('context.glossary.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('context.glossary.desc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Textarea
|
||||
{/* ── Technical Glossary ────────────────────────────────── */}
|
||||
<section className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial">
|
||||
<div className="flex items-center gap-4 mb-8 text-brand-accent">
|
||||
<Database size={20} />
|
||||
<h3 className="text-[11px] font-black uppercase tracking-[0.3em] text-brand-dark dark:text-white">
|
||||
{t('context.glossary.title')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-brand-dark/40 dark:text-white/40 mb-10 font-medium">{t('context.glossary.desc')}</p>
|
||||
<textarea
|
||||
value={localSettings.glossary}
|
||||
onChange={e => setLocalSettings({ ...localSettings, glossary: e.target.value })}
|
||||
placeholder={"pression statique=static pressure\nrécupérateur de chaleur=heat recovery unit"}
|
||||
className="min-h-[250px] resize-y font-mono text-sm"
|
||||
className="w-full h-64 p-8 bg-brand-muted dark:bg-white/5 rounded-[32px] border border-black/5 dark:border-white/10 text-sm font-mono focus:ring-2 focus:ring-brand-accent/20 focus:border-brand-accent/30 transition-all outline-none resize-y"
|
||||
/>
|
||||
{localSettings.glossary && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{localSettings.glossary.split('\n').filter(l => l.includes('=')).length} {t('context.glossary.terms')}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button variant="ghost" onClick={handleClear} className="text-destructive hover:text-destructive/80 hover:bg-destructive/10">
|
||||
<Trash2 className="me-1.5 h-4 w-4" /> {t('context.clearAll')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? <><Loader2 className="me-1.5 h-4 w-4 animate-spin" />{t('context.saving')}</> : <><Save className="me-1.5 h-4 w-4" />{t('context.save')}</>}
|
||||
</Button>
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mt-6 gap-4">
|
||||
<span className="text-[9px] text-brand-dark/20 dark:text-white/20 font-black uppercase tracking-widest">
|
||||
{localSettings.glossary
|
||||
? `${localSettings.glossary.split('\n').filter(l => l.includes('=')).length} ${t('context.glossary.terms')}`
|
||||
: `0 ${t('context.glossary.terms')}`}
|
||||
</span>
|
||||
<div className="flex gap-4">
|
||||
<button className="px-8 py-3 bg-brand-muted dark:bg-white/5 text-brand-dark/40 dark:text-white/40 rounded-xl text-[9px] font-black uppercase tracking-widest hover:text-brand-dark dark:hover:text-white transition-all">
|
||||
{t('context.clearAll')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="premium-button px-10 py-3 text-[9px] uppercase tracking-widest !rounded-xl flex items-center gap-3 disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
{isSaving ? t('context.saving') : t('context.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { BookText, Plus } from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { BookText, Plus, Library, Calendar, Hash } from 'lucide-react';
|
||||
import { useUser } from '@/app/dashboard/useUser';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useI18n, formatDate } from '@/lib/i18n';
|
||||
import { useGlossaries, useGlossary } from './useGlossaries';
|
||||
import type { Glossary, GlossaryTermInput, GlossaryListItem } from './types';
|
||||
import { ProUpgradePrompt } from './ProUpgradePrompt';
|
||||
@@ -15,6 +12,7 @@ import { CreateGlossaryDialog } from './CreateGlossaryDialog';
|
||||
import { EditGlossaryDialog } from './EditGlossaryDialog';
|
||||
import { DeleteGlossaryDialog } from './DeleteGlossaryDialog';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function GlossariesPage() {
|
||||
const { t } = useI18n();
|
||||
@@ -141,8 +139,8 @@ export default function GlossariesPage() {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-4 border-muted border-t-foreground mx-auto"></div>
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-4 border-brand-muted border-t-brand-accent mx-auto"></div>
|
||||
<p className="text-[9px] font-black text-brand-dark/40 dark:text-white/40 uppercase tracking-widest">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -153,77 +151,93 @@ export default function GlossariesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('glossaries.title')}</h1>
|
||||
<p className="text-muted-foreground">{t('glossaries.description')}</p>
|
||||
<div className="max-w-6xl mx-auto w-full p-6 lg:p-8">
|
||||
{/* ── Editorial Header ───────────────────────────────────── */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end mb-12 gap-6">
|
||||
<div>
|
||||
<span className="accent-pill mb-4 block w-fit">{t('glossaries.yourGlossaries')}</span>
|
||||
<h1 className="text-5xl font-black uppercase tracking-tighter mb-4 leading-none text-brand-dark dark:text-white">
|
||||
{t('glossaries.title')}
|
||||
</h1>
|
||||
<p className="text-brand-dark/40 dark:text-white/40 font-medium">{t('glossaries.description')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
disabled={isCreating}
|
||||
className="premium-button px-10 py-4 text-[11px] uppercase tracking-widest !rounded-2xl flex items-center gap-2 disabled:opacity-50 shrink-0"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t('glossaries.createNew')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-accent/10">
|
||||
<BookText className="size-4 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{t('glossaries.yourGlossaries')}</CardTitle>
|
||||
<CardDescription>{t('glossaries.yourGlossariesDesc')}</CardDescription>
|
||||
</div>
|
||||
{/* ── Glossary Grid ──────────────────────────────────────── */}
|
||||
{glossaries.length === 0 ? (
|
||||
<div className="editorial-card p-16 bg-white dark:bg-[#141414] border-none shadow-editorial text-center">
|
||||
<div className="w-16 h-16 bg-brand-muted dark:bg-white/10 rounded-2xl flex items-center justify-center text-brand-accent mx-auto mb-6">
|
||||
<Library size={32} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<p className="text-xl font-black uppercase tracking-tight text-brand-dark dark:text-white mb-2">{t('glossaries.empty')}</p>
|
||||
<p className="text-[10px] text-brand-dark/30 dark:text-white/30 font-bold uppercase tracking-widest">{t('glossaries.emptyDesc')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{glossaries.map((glossary: GlossaryListItem) => {
|
||||
const termCount = glossary.terms_count ?? 0;
|
||||
return (
|
||||
<div
|
||||
key={glossary.id}
|
||||
className="editorial-card p-8 bg-white dark:bg-[#141414] border-none shadow-editorial group hover:-translate-y-2 transition-all cursor-pointer"
|
||||
onClick={() => handleEditClick(glossary.id)}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-10">
|
||||
<div className="w-12 h-12 bg-brand-muted dark:bg-white/10 rounded-2xl flex items-center justify-center text-brand-accent group-hover:bg-brand-dark group-hover:text-white transition-all">
|
||||
<Library size={24} />
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteClick(glossary.id, glossary.name);
|
||||
}}
|
||||
className="text-[8px] bg-red-500/10 text-red-500 px-3 py-1 rounded-full font-black uppercase tracking-widest hover:bg-red-500 hover:text-white transition-all"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<h3 className="text-2xl font-black uppercase tracking-tight mb-2 text-brand-dark dark:text-white truncate">
|
||||
{glossary.name}
|
||||
</h3>
|
||||
<div className="flex justify-between items-center pt-8 border-t border-black/5 dark:border-white/10">
|
||||
<span className="text-[9px] text-brand-dark/30 dark:text-white/30 font-black uppercase tracking-widest flex items-center gap-1.5">
|
||||
<Hash size={10} />
|
||||
{termCount} {t('glossaries.defineTerms')}
|
||||
</span>
|
||||
<span className="text-[9px] text-brand-dark/30 dark:text-white/30 font-black uppercase tracking-widest flex items-center gap-1.5">
|
||||
<Calendar size={10} />
|
||||
{new Date(glossary.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{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)}
|
||||
disabled={isCreating}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
{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">{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">
|
||||
{glossaries.map((glossary: GlossaryListItem) => (
|
||||
<GlossaryCard
|
||||
key={glossary.id}
|
||||
glossary={glossary}
|
||||
onEdit={handleEditClick}
|
||||
onDelete={handleDeleteClick}
|
||||
isDeleting={isDeleting && glossaryToDelete?.id === glossary.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Card className="border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{t('glossaries.aboutTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground space-y-2">
|
||||
<p>{t('glossaries.aboutDesc')}</p>
|
||||
<p><strong>{t('glossaries.aboutFormat')}</strong></p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* ── About section ──────────────────────────────────────── */}
|
||||
<div className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial mt-12">
|
||||
<div className="flex items-center gap-4 text-brand-accent mb-8">
|
||||
<BookText size={20} />
|
||||
<span className="text-[11px] font-black uppercase tracking-[0.3em] text-brand-dark dark:text-white">
|
||||
{t('glossaries.aboutTitle')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-brand-dark/40 dark:text-white/40 font-medium mb-4">{t('glossaries.aboutDesc')}</p>
|
||||
<p className="text-[10px] font-bold uppercase tracking-tight text-brand-dark/60 dark:text-white/60">
|
||||
{t('glossaries.aboutFormat')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Dialogs */}
|
||||
<CreateGlossaryDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
FileText, Layers, CreditCard, TrendingUp, AlertTriangle,
|
||||
CheckCircle2, XCircle, RefreshCw, ExternalLink, ArrowRight,
|
||||
BadgeCheck, ShieldAlert, Info, Globe, Settings, Palette, Trash2, Loader2,
|
||||
Activity, ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -29,13 +30,6 @@ import { cn } from '@/lib/utils';
|
||||
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: 'profile.plan.free', starter: 'profile.plan.starter', pro: 'profile.plan.pro', business: 'profile.plan.business', enterprise: 'profile.plan.enterprise',
|
||||
};
|
||||
@@ -56,30 +50,6 @@ function nextResetDate(locale: Locale) {
|
||||
return formatDate(next, locale, { day: 'numeric', month: 'long' });
|
||||
}
|
||||
|
||||
function UsageBar({ label, used, limit, icon }: { label: string; used: number; limit: number; icon: React.ReactNode }) {
|
||||
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-600 dark:text-emerald-400' :
|
||||
p >= 90 ? 'text-red-600 dark:text-red-400' : p >= 70 ? 'text-amber-600 dark: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-primary')} style={{ width: `${p}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const UI_LANGUAGES: { value: Locale; label: string; flag: string }[] = [
|
||||
{ value: 'en', label: 'English', flag: '🇬🇧' },
|
||||
{ value: 'fr', label: 'Français', flag: '🇫🇷' },
|
||||
@@ -180,14 +150,13 @@ export default function ProfilePage() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<RefreshCw className="w-8 h-8 text-primary animate-spin" />
|
||||
<RefreshCw className="w-8 h-8 text-brand-accent animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const planId = user?.plan ?? user?.tier ?? 'free';
|
||||
const planLabel = t(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';
|
||||
@@ -199,14 +168,23 @@ export default function ProfilePage() {
|
||||
const pagesLimit = usage?.pages_limit ?? user?.plan_limits?.max_pages_per_doc ?? 50;
|
||||
const extraCredits = usage?.extra_credits ?? user?.extra_credits ?? 0;
|
||||
|
||||
const docsPct = pct(docsUsed, docsLimit);
|
||||
const pagesPct = pct(pagesUsed, pagesLimit);
|
||||
|
||||
// Tab state for editorial pill toggle
|
||||
const [activeTab, setActiveTab] = useState('account');
|
||||
|
||||
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 max-w-3xl">
|
||||
<div className="flex flex-1 flex-col gap-8 p-6 lg:p-8 max-w-4xl mx-auto w-full">
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('profile.header.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t('profile.header.subtitle')}</p>
|
||||
{/* ── Editorial Header ───────────────────────────────────── */}
|
||||
<div className="mb-4">
|
||||
<span className="accent-pill mb-4 block w-fit">{t('profile.header.title')}</span>
|
||||
<h1 className="text-5xl font-black uppercase tracking-tighter mb-4 leading-none text-brand-dark dark:text-white">
|
||||
{t('profile.header.title')}
|
||||
</h1>
|
||||
<p className="text-brand-dark/40 dark:text-white/40 font-medium">{t('profile.header.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Status message */}
|
||||
@@ -223,273 +201,401 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="account" className="w-full">
|
||||
<TabsList className="w-full justify-start">
|
||||
<TabsTrigger value="account" className="gap-1.5"><User className="size-3.5" /> {t('profile.tabs.account')}</TabsTrigger>
|
||||
<TabsTrigger value="subscription" className="gap-1.5"><CreditCard className="size-3.5" /> {t('profile.tabs.subscription')}</TabsTrigger>
|
||||
<TabsTrigger value="preferences" className="gap-1.5"><Settings className="size-3.5" /> {t('profile.tabs.preferences')}</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* ── Editorial Pill Tabs ────────────────────────────────── */}
|
||||
<div className="flex gap-2 p-2 bg-brand-muted dark:bg-[#141414] rounded-2xl w-fit border border-black/5 dark:border-white/10">
|
||||
{[
|
||||
{ key: 'account', label: t('profile.tabs.account'), icon: User },
|
||||
{ key: 'subscription', label: t('profile.tabs.subscription'), icon: CreditCard },
|
||||
{ key: 'preferences', label: t('profile.tabs.preferences'), icon: Settings },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={cn(
|
||||
'px-10 py-3 rounded-xl text-[11px] font-black uppercase tracking-widest transition-all flex items-center gap-2',
|
||||
activeTab === tab.key
|
||||
? 'bg-brand-dark text-white dark:bg-white dark:text-brand-dark shadow-xl'
|
||||
: 'text-brand-dark/30 dark:text-white/30 hover:text-brand-dark dark:hover:text-white'
|
||||
)}
|
||||
>
|
||||
<tab.icon size={14} />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Tab: Account ────────────────────────────────────── */}
|
||||
<TabsContent value="account" className="space-y-6 pt-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center justify-center w-20 h-20 rounded-2xl text-white font-bold text-2xl ring-4',
|
||||
`bg-gradient-to-br ${planColors.gradient}`, planColors.ring
|
||||
)}>
|
||||
{getInitials(user?.name)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h2 className="text-xl font-semibold text-foreground truncate">{user?.name || t('profile.account.user')}</h2>
|
||||
<Badge className={cn('text-xs font-medium', planColors.badge)}>
|
||||
<PlanIcon className="w-3 h-3 me-1" />{planLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 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 text-xs text-muted-foreground">
|
||||
<Calendar className="w-3 h-3 shrink-0" />
|
||||
<span>{t('profile.account.memberSince')} {formatDate(new Date(user.created_at), locale)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* ── Tab: Account ────────────────────────────────────── */}
|
||||
{activeTab === 'account' && (
|
||||
<div className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial flex flex-col sm:flex-row items-center gap-8 lg:gap-10">
|
||||
<div className="w-24 h-24 bg-brand-dark dark:bg-white/10 rounded-[32px] flex items-center justify-center text-white dark:text-white text-4xl font-black shadow-2xl shrink-0">
|
||||
{getInitials(user?.name)}
|
||||
</div>
|
||||
<div className="flex-1 text-center sm:text-left">
|
||||
<div className="flex items-center gap-4 mb-3 flex-wrap justify-center sm:justify-start">
|
||||
<h2 className="text-2xl lg:text-3xl font-black uppercase tracking-tight text-brand-dark dark:text-white">
|
||||
{user?.name || t('profile.account.user')}
|
||||
</h2>
|
||||
<span className="accent-pill !px-3 !py-1 text-[9px] flex items-center gap-1.5">
|
||||
<PlanIcon size={12} />
|
||||
{planLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] font-bold uppercase tracking-widest text-brand-dark/40 dark:text-white/40 mb-3 flex items-center gap-2 justify-center sm:justify-start">
|
||||
<Globe size={12} className="text-brand-accent" />
|
||||
{user?.email}
|
||||
</p>
|
||||
{user?.created_at && (
|
||||
<p className="text-[9px] text-brand-dark/20 dark:text-white/20 font-black uppercase tracking-widest">
|
||||
{t('profile.account.memberSince')} {formatDate(new Date(user.created_at), locale)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Tab: Subscription ───────────────────────────────── */}
|
||||
{activeTab === 'subscription' && (
|
||||
<div className="space-y-8">
|
||||
{/* Plan card - dark editorial */}
|
||||
<div className="editorial-card !bg-brand-dark p-10 lg:p-12 flex flex-col md:flex-row items-start md:items-center justify-between text-white border-none shadow-2xl relative overflow-hidden gap-6">
|
||||
{/* Decorative element */}
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-brand-accent/10 rounded-bl-full pointer-events-none" />
|
||||
|
||||
<div className="flex items-center gap-8 relative z-10">
|
||||
<div className="w-16 h-16 bg-white/10 backdrop-blur-xl rounded-2xl flex items-center justify-center text-brand-accent border border-white/10 shadow-inner shrink-0">
|
||||
<PlanIcon size={28} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Tab: Subscription ───────────────────────────────── */}
|
||||
<TabsContent value="subscription" className="space-y-6 pt-4">
|
||||
|
||||
{/* Plan card */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn('p-3 rounded-xl bg-gradient-to-br', planColors.gradient)}>
|
||||
<PlanIcon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-foreground text-lg">{t('profile.plan.label')} {planLabel}</p>
|
||||
<p className="text-sm text-muted-foreground">{isFreePlan ? t('profile.plan.free') : t('profile.plan.pricePerMonth', { price: planPrice })}</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'
|
||||
<div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h3 className="text-2xl font-black uppercase tracking-tight text-white">
|
||||
{t('profile.plan.label')} {planLabel}
|
||||
</h3>
|
||||
<span className={cn(
|
||||
'px-3 py-1 rounded-full text-[9px] font-black uppercase tracking-widest border shadow-sm',
|
||||
isCanceling
|
||||
? 'bg-amber-500/20 text-amber-300 border-amber-500/30'
|
||||
: user?.subscription_status === 'active' || isFreePlan
|
||||
? 'bg-brand-accent/20 text-brand-accent border-brand-accent/30'
|
||||
: 'bg-red-500/20 text-red-300 border-red-500/30'
|
||||
)}>
|
||||
{isCanceling ? t('profile.subscription.canceling') : user?.subscription_status === 'active' ? t('profile.subscription.active') : user?.subscription_status ?? t('profile.subscription.unknown')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isFreePlan && user?.subscription_ends_at && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted text-sm">
|
||||
<Info className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">
|
||||
{isCanceling
|
||||
? `${t('profile.subscription.accessUntil')} ${formatDate(new Date(user.subscription_ends_at), locale)}`
|
||||
: `${t('profile.subscription.renewalOn')} ${formatDate(new Date(user.subscription_ends_at), locale)}`}
|
||||
{isCanceling ? t('profile.subscription.canceling') : user?.subscription_status === 'active' ? t('profile.subscription.active') : isFreePlan ? t('profile.subscription.active') : user?.subscription_status ?? t('profile.subscription.unknown')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild size="sm"><Link href="/pricing"><TrendingUp className="w-3.5 h-3.5 me-1.5" />{isFreePlan ? t('profile.subscription.upgradePlan') : t('profile.subscription.changePlan')}</Link></Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Usage */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<BadgeCheck className="w-4 h-4 text-primary" />
|
||||
{t('profile.usage.title')}
|
||||
<span className="ms-auto text-xs text-muted-foreground font-normal">{t('profile.usage.resetOn')} {nextResetDate(locale)}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<UsageBar label={t('profile.usage.documents')} used={docsUsed} limit={docsLimit} icon={<FileText className="w-4 h-4" />} />
|
||||
<UsageBar label={t('profile.usage.pages')} 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-50 border border-amber-200 dark:bg-amber-500/10 dark:border-amber-500/20 text-sm">
|
||||
<Info className="w-4 h-4 text-amber-600 dark:text-amber-400 shrink-0" />
|
||||
<span className="text-amber-700 dark:text-amber-300">{extraCredits} {extraCredits > 1 ? t('profile.usage.extraCreditsPlural') : t('profile.usage.extraCredits')}</span>
|
||||
</div>
|
||||
)}
|
||||
{usage?.upgrade_required && (
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-red-50 border border-red-200 dark:bg-red-500/10 dark:border-red-500/20 text-sm">
|
||||
<AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-red-700 dark:text-red-300 font-medium">{t('profile.usage.quotaReached')}</p>
|
||||
<p className="text-red-600 dark:text-red-400 text-xs mt-0.5">{t('profile.usage.quotaReachedDesc')}</p>
|
||||
</div>
|
||||
<Button asChild size="sm" className="bg-red-600 hover:bg-red-500 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-primary/10 border border-primary/20 text-sm">
|
||||
<Zap className="w-4 h-4 text-primary shrink-0" />
|
||||
<span className="text-primary flex-1">{t('profile.usage.unlockMore')}</span>
|
||||
<Button asChild size="sm" variant="outline" className="border-primary/30 text-primary hover:bg-primary/10 shrink-0">
|
||||
<Link href="/pricing">{t('profile.usage.viewPlans')} <ArrowRight className="w-3.5 h-3.5 ms-1" /></Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Features */}
|
||||
{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-600 dark:text-emerald-400" />
|
||||
{t('profile.usage.includedInPlan')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 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-600 dark:text-emerald-400 shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground">{f.includes('.') ? t(f) : f}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Danger zone - only show if user has a real Stripe subscription */}
|
||||
{!isFreePlan && !isCanceling && user?.stripe_subscription_id && (
|
||||
<Card className="border-red-900/30">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2 text-red-600 dark:text-red-400">
|
||||
<ShieldAlert className="w-4 h-4" /> {t('profile.danger.title')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('profile.danger.description')}
|
||||
<p className="text-white/60 text-[10px] font-black uppercase tracking-widest mt-1">
|
||||
{isFreePlan ? t('profile.plan.free') : t('profile.plan.pricePerMonth', { price: planPrice })}
|
||||
</p>
|
||||
{cancelConfirm && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/30 text-sm text-destructive">
|
||||
⚠️ {t('profile.danger.confirm')}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="destructive" size="sm" onClick={handleCancel} disabled={loadingCancel}>
|
||||
{loadingCancel && <RefreshCw className="w-3.5 h-3.5 me-1.5 animate-spin" />}
|
||||
{cancelConfirm ? t('profile.danger.confirmCancel') : t('profile.danger.cancelSubscription')}
|
||||
</Button>
|
||||
{cancelConfirm && <Button variant="outline" size="sm" onClick={() => setCancelConfirm(false)}>{t('profile.danger.keep')}</Button>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isFreePlan && (
|
||||
<button
|
||||
onClick={handleBillingPortal}
|
||||
disabled={loadingPortal}
|
||||
className="relative z-10 px-10 py-4 bg-white text-brand-dark rounded-xl font-black text-[10px] uppercase tracking-widest shadow-xl hover:scale-105 transition-all shrink-0 disabled:opacity-50"
|
||||
>
|
||||
{loadingPortal ? <RefreshCw className="w-4 h-4 animate-spin" /> : t('profile.subscription.changePlan')}
|
||||
</button>
|
||||
)}
|
||||
{isFreePlan && (
|
||||
<Link href="/pricing">
|
||||
<button className="relative z-10 px-10 py-4 bg-white text-brand-dark rounded-xl font-black text-[10px] uppercase tracking-widest shadow-xl hover:scale-105 transition-all shrink-0">
|
||||
{t('profile.subscription.upgradePlan')}
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subscription ends info */}
|
||||
{!isFreePlan && user?.subscription_ends_at && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-brand-muted dark:bg-white/5 text-sm">
|
||||
<Info className="w-4 h-4 text-brand-dark/40 dark:text-white/40 shrink-0" />
|
||||
<span className="text-brand-dark/60 dark:text-white/60">
|
||||
{isCanceling
|
||||
? `${t('profile.subscription.accessUntil')} ${formatDate(new Date(user.subscription_ends_at), locale)}`
|
||||
: `${t('profile.subscription.renewalOn')} ${formatDate(new Date(user.subscription_ends_at), locale)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Tab: Preferences ────────────────────────────────── */}
|
||||
<TabsContent value="preferences" className="space-y-6 pt-4">
|
||||
{/* Usage metrics */}
|
||||
<div className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial">
|
||||
<div className="flex justify-between items-center mb-12">
|
||||
<div className="flex items-center gap-4 text-brand-accent">
|
||||
<Activity size={20} />
|
||||
<span className="text-[11px] font-black uppercase tracking-[0.3em] text-brand-dark dark:text-white">
|
||||
{t('profile.usage.title')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[9px] font-black text-brand-dark/20 dark:text-white/20 uppercase tracking-widest border-b border-black/5 dark:border-white/10 pb-1">
|
||||
{t('profile.usage.resetOn')} {nextResetDate(locale)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Interface language */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-primary" /> {t('profile.prefs.interfaceLang')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{UI_LANGUAGES.map((lang) => (
|
||||
<button
|
||||
key={lang.value}
|
||||
onClick={() => setLocale(lang.value)}
|
||||
<div className="space-y-16">
|
||||
{/* Documents bar */}
|
||||
<div>
|
||||
<div className="flex justify-between text-[10px] font-black uppercase tracking-widest mb-4">
|
||||
<span className="flex items-center gap-3 text-brand-dark/40 dark:text-white/40">
|
||||
<FileText size={16} className="text-brand-accent" /> {t('profile.usage.documents')}
|
||||
</span>
|
||||
<span className="text-brand-dark dark:text-white font-black">{docsUsed} / {fmtLimit(docsLimit)}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-brand-muted dark:bg-white/10 rounded-full overflow-hidden p-0.5 border border-black/5 dark:border-white/10">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium border transition-colors',
|
||||
locale === lang.value
|
||||
? 'bg-primary/10 border-primary/40 text-primary'
|
||||
: 'bg-transparent border-border text-muted-foreground hover:text-foreground hover:border-border/80'
|
||||
'h-full rounded-full transition-all duration-700',
|
||||
docsPct >= 90 ? 'bg-red-500 shadow-[0_0_10px_rgba(239,68,68,0.5)]' :
|
||||
docsPct >= 70 ? 'bg-amber-500 shadow-[0_0_10px_rgba(245,158,11,0.5)]' :
|
||||
'bg-brand-accent shadow-[0_0_10px_rgba(197,161,122,0.5)]'
|
||||
)}
|
||||
>
|
||||
<span>{lang.flag}</span><span>{lang.label}</span>
|
||||
style={{ width: `${docsPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pages bar */}
|
||||
<div>
|
||||
<div className="flex justify-between text-[10px] font-black uppercase tracking-widest mb-4">
|
||||
<span className="flex items-center gap-3 text-brand-dark/40 dark:text-white/40">
|
||||
<Layers size={16} className="text-brand-accent" /> {t('profile.usage.pages')}
|
||||
</span>
|
||||
<span className="text-brand-dark dark:text-white font-black">{pagesUsed} / {fmtLimit(pagesLimit)}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-brand-muted dark:bg-white/10 rounded-full overflow-hidden p-0.5 border border-black/5 dark:border-white/10">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-700',
|
||||
pagesPct >= 90 ? 'bg-red-500 shadow-[0_0_10px_rgba(239,68,68,0.5)]' :
|
||||
pagesPct >= 70 ? 'bg-amber-500 shadow-[0_0_10px_rgba(245,158,11,0.5)]' :
|
||||
'bg-brand-accent shadow-[0_0_10px_rgba(197,161,122,0.5)]'
|
||||
)}
|
||||
style={{ width: `${pagesPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Extra credits */}
|
||||
{extraCredits > 0 && (
|
||||
<div className="mt-12 flex items-center gap-3 p-4 rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 text-sm">
|
||||
<Info className="w-4 h-4 text-amber-600 dark:text-amber-400 shrink-0" />
|
||||
<span className="text-amber-700 dark:text-amber-300">
|
||||
{extraCredits} {extraCredits > 1 ? t('profile.usage.extraCreditsPlural') : t('profile.usage.extraCredits')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quota reached warning */}
|
||||
{usage?.upgrade_required && (
|
||||
<div className="mt-8 flex items-start gap-3 p-4 rounded-xl bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 text-sm">
|
||||
<AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-red-700 dark:text-red-300 font-medium">{t('profile.usage.quotaReached')}</p>
|
||||
<p className="text-red-600 dark:text-red-400 text-xs mt-0.5">{t('profile.usage.quotaReachedDesc')}</p>
|
||||
</div>
|
||||
<Link href="/pricing">
|
||||
<button className="px-4 py-2 bg-red-600 text-white rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-red-500 transition-all shrink-0">
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upgrade CTA */}
|
||||
{isFreePlan && (
|
||||
<div className="mt-12 p-8 bg-brand-muted dark:bg-white/5 rounded-[32px] border border-black/5 dark:border-white/10 flex flex-col sm:flex-row items-center justify-between gap-4 group">
|
||||
<div className="flex items-center gap-6 text-brand-dark dark:text-white">
|
||||
<div className="w-12 h-12 bg-white dark:bg-white/10 rounded-2xl flex items-center justify-center text-brand-accent shadow-sm group-hover:rotate-12 transition-transform">
|
||||
<Zap size={24} />
|
||||
</div>
|
||||
<p className="text-[11px] font-black uppercase tracking-tight max-w-[200px] leading-relaxed">
|
||||
{t('profile.usage.unlockMore')}
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/pricing">
|
||||
<button className="px-8 py-3 bg-brand-dark dark:bg-brand-accent text-white dark:text-brand-dark rounded-xl text-[9px] font-black uppercase tracking-widest shadow-lg hover:bg-brand-accent hover:text-brand-dark transition-all">
|
||||
{t('profile.usage.viewPlans')}
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Features grid */}
|
||||
{user?.plan_limits?.features?.length > 0 && (
|
||||
<div className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial">
|
||||
<div className="flex items-center gap-4 text-brand-accent mb-12">
|
||||
<CheckCircle2 size={20} />
|
||||
<span className="text-[11px] font-black uppercase tracking-[0.3em] text-brand-dark dark:text-white">
|
||||
{t('profile.usage.includedInPlan')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-y-6 gap-x-12">
|
||||
{user.plan_limits.features.map((f: string, i: number) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<div className="w-5 h-5 rounded-full border border-brand-accent/30 flex items-center justify-center bg-brand-accent/5 shrink-0">
|
||||
<CheckCircle2 size={12} className="text-brand-accent" />
|
||||
</div>
|
||||
<span className="text-[10px] font-bold uppercase tracking-tight text-brand-dark/60 dark:text-white/60">
|
||||
{f.includes('.') ? t(f) : f}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-3">{t('profile.prefs.interfaceLangDesc')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Danger zone */}
|
||||
{!isFreePlan && !isCanceling && user?.stripe_subscription_id && (
|
||||
<div className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial border-l-4 border-l-red-500">
|
||||
<div className="flex items-center gap-4 text-red-500 mb-8">
|
||||
<ShieldAlert size={20} />
|
||||
<span className="text-[11px] font-black uppercase tracking-[0.3em] text-brand-dark dark:text-white">
|
||||
{t('profile.danger.title')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-brand-dark/40 dark:text-white/40 mb-6">{t('profile.danger.description')}</p>
|
||||
{cancelConfirm && (
|
||||
<div className="p-4 rounded-xl bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/30 text-sm text-red-600 dark:text-red-400 mb-4">
|
||||
⚠️ {t('profile.danger.confirm')}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={loadingCancel}
|
||||
className="px-8 py-3 bg-red-600 text-white rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-red-500 transition-all disabled:opacity-50"
|
||||
>
|
||||
{loadingCancel && <RefreshCw className="w-3.5 h-3.5 me-1.5 animate-spin inline" />}
|
||||
{cancelConfirm ? t('profile.danger.confirmCancel') : t('profile.danger.cancelSubscription')}
|
||||
</button>
|
||||
{cancelConfirm && (
|
||||
<button
|
||||
onClick={() => setCancelConfirm(false)}
|
||||
className="px-8 py-3 bg-brand-muted dark:bg-white/10 text-brand-dark/40 dark:text-white/40 rounded-xl text-[9px] font-black uppercase tracking-widest hover:text-brand-dark dark:hover:text-white transition-all"
|
||||
>
|
||||
{t('profile.danger.keep')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Tab: Preferences ────────────────────────────────── */}
|
||||
{activeTab === 'preferences' && (
|
||||
<div className="space-y-8">
|
||||
{/* Interface language */}
|
||||
<div className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial">
|
||||
<div className="flex items-center gap-5 mb-12">
|
||||
<div className="w-12 h-12 bg-brand-muted dark:bg-white/10 rounded-2xl flex items-center justify-center text-brand-accent">
|
||||
<Globe size={24} />
|
||||
</div>
|
||||
<h3 className="text-2xl font-black uppercase tracking-tight text-brand-dark dark:text-white">
|
||||
{t('profile.prefs.interfaceLang')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{UI_LANGUAGES.map((lang) => (
|
||||
<button
|
||||
key={lang.value}
|
||||
onClick={() => setLocale(lang.value)}
|
||||
className={cn(
|
||||
'px-6 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest border transition-all',
|
||||
locale === lang.value
|
||||
? 'bg-brand-dark dark:bg-white border-brand-dark dark:border-white text-white dark:text-brand-dark shadow-xl'
|
||||
: 'bg-white dark:bg-white/5 border-black/5 dark:border-white/10 text-brand-dark/30 dark:text-white/30 hover:border-brand-accent/30 hover:text-brand-dark dark:hover:text-white'
|
||||
)}
|
||||
>
|
||||
<span className="mr-1.5">{lang.flag}</span> {lang.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-12 text-[9px] text-brand-dark/20 dark:text-white/20 font-black uppercase tracking-widest italic border-t border-black/5 dark:border-white/10 pt-6">
|
||||
{t('profile.prefs.interfaceLangDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Default target language */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-primary" /> {t('profile.prefs.defaultTargetLang')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Select value={defaultLanguage} onValueChange={setDefaultLanguage}>
|
||||
<SelectTrigger><SelectValue placeholder={t('profile.prefs.selectLanguage')} /></SelectTrigger>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
{languages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
<span className="flex items-center gap-2"><span>{lang.flag}</span><span>{lang.name}</span></span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{t('profile.prefs.defaultTargetLangDesc')}</p>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={handleSavePrefs}>
|
||||
<CheckCircle2 className="w-3.5 h-3.5 me-1.5" /> {t('profile.prefs.save')}
|
||||
</Button>
|
||||
<div className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial flex flex-col md:flex-row items-start md:items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="w-14 h-14 bg-brand-muted dark:bg-white/10 rounded-2xl flex items-center justify-center text-brand-accent shrink-0">
|
||||
<FileText size={28} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div>
|
||||
<h3 className="text-2xl font-black uppercase tracking-tight text-brand-dark dark:text-white">
|
||||
{t('profile.prefs.defaultTargetLang')}
|
||||
</h3>
|
||||
<p className="text-brand-dark/30 dark:text-white/30 text-[10px] font-black uppercase tracking-widest mt-2 leading-relaxed">
|
||||
{t('profile.prefs.defaultTargetLangDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="relative">
|
||||
<Select value={defaultLanguage} onValueChange={setDefaultLanguage}>
|
||||
<SelectTrigger className="px-8 py-4 bg-brand-muted dark:bg-white/10 rounded-2xl text-[10px] font-black uppercase tracking-widest border border-black/5 dark:border-white/10 w-[200px]">
|
||||
<SelectValue placeholder={t('profile.prefs.selectLanguage')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
{languages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
<span className="flex items-center gap-2"><span>{lang.flag}</span><span>{lang.name}</span></span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<button onClick={handleSavePrefs} className="premium-button px-10 py-4 text-[10px] uppercase tracking-widest !rounded-2xl">
|
||||
{t('profile.prefs.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Palette className="w-4 h-4 text-primary" /> {t('profile.prefs.theme')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{t('profile.prefs.themeDesc')}</p>
|
||||
<ThemeToggle />
|
||||
<div className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial flex items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="w-14 h-14 bg-brand-muted dark:bg-white/10 rounded-2xl flex items-center justify-center text-brand-accent shrink-0">
|
||||
<Palette size={28} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div>
|
||||
<h3 className="text-2xl font-black uppercase tracking-tight text-brand-dark dark:text-white">
|
||||
{t('profile.prefs.theme')}
|
||||
</h3>
|
||||
<p className="text-brand-dark/30 dark:text-white/30 text-[10px] font-black uppercase tracking-widest mt-2 leading-relaxed">
|
||||
{t('profile.prefs.themeDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* Cache */}
|
||||
<Card className="border-border/60">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Trash2 className="w-4 h-4 text-muted-foreground" /> {t('profile.prefs.cache')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-muted-foreground">{t('profile.prefs.cacheDesc')}</p>
|
||||
<Button onClick={handleClearCache} disabled={isClearing} variant="outline" size="sm" className="shrink-0">
|
||||
{isClearing ? <><Loader2 className="me-1.5 h-3.5 w-3.5 animate-spin" />{t('profile.prefs.clearing')}</> : <><Trash2 className="me-1.5 h-3.5 w-3.5" />{t('profile.prefs.clearCache')}</>}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial flex items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="w-14 h-14 bg-brand-muted dark:bg-white/10 rounded-2xl flex items-center justify-center text-brand-accent shrink-0">
|
||||
<Trash2 size={28} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-2xl font-black uppercase tracking-tight text-brand-dark dark:text-white">
|
||||
{t('profile.prefs.cache')}
|
||||
</h3>
|
||||
<p className="text-brand-dark/30 dark:text-white/30 text-[10px] font-black uppercase tracking-widest mt-2 leading-relaxed">
|
||||
{t('profile.prefs.cacheDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClearCache}
|
||||
disabled={isClearing}
|
||||
className="px-8 py-3 bg-brand-muted dark:bg-white/10 text-brand-dark/40 dark:text-white/40 rounded-xl text-[9px] font-black uppercase tracking-widest hover:text-brand-dark dark:hover:text-white transition-all disabled:opacity-50"
|
||||
>
|
||||
{isClearing ? <><Loader2 className="me-1.5 h-3.5 w-3.5 animate-spin inline" />{t('profile.prefs.clearing')}</> : <><Trash2 className="me-1.5 h-3.5 w-3.5 inline" />{t('profile.prefs.clearCache')}</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,13 +4,10 @@ import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import {
|
||||
ShieldCheck, Clock, ArrowRight, RotateCcw, Loader2,
|
||||
FileSpreadsheet, FileText, Presentation, Upload, X,
|
||||
Zap, Brain, CheckCircle2, ArrowLeftRight,
|
||||
Search, Languages, Wrench, Activity, Gauge, Timer,
|
||||
Download, Plus, TrendingUp, AlertTriangle, FileType,
|
||||
Zap, CheckCircle2,
|
||||
Search, Languages, Wrench, Activity, Timer,
|
||||
Download, AlertTriangle, FileType,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { FileDropZone } from './FileDropZone';
|
||||
import { useFileUpload } from './useFileUpload';
|
||||
import { useTranslationConfig } from './useTranslationConfig';
|
||||
import { useTranslationSubmit } from './useTranslationSubmit';
|
||||
@@ -76,6 +73,7 @@ export default function TranslatePage() {
|
||||
const { t } = useI18n();
|
||||
const lastErrorRef = useRef<string | null>(null);
|
||||
const replaceInputRef = useRef<HTMLInputElement>(null);
|
||||
const dropzoneInputRef = useRef<HTMLInputElement>(null);
|
||||
const [pdfMode, setPdfMode] = useState<'layout' | 'text_only'>('layout');
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@@ -152,381 +150,471 @@ export default function TranslatePage() {
|
||||
const qualityLabel = useMemo(() => getQualityLabel(t, config.provider), [t, config.provider]);
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════ */
|
||||
/* UNIFIED LAYOUT — always the same grid */
|
||||
/* EDITORIAL LAYOUT */
|
||||
/* ═══════════════════════════════════════════════════════════════ */
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-6 overflow-y-auto p-6 lg:p-8">
|
||||
{/* Header */}
|
||||
<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="min-h-full p-6 lg:p-8 dark:bg-[#0a0a0a]">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
|
||||
{/* Grid: LEFT (2/3) + RIGHT (1/3) — always present */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
{/* LEFT CARD (2/3) — content swaps based on state */}
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
<div className="lg:col-span-2 flex flex-col gap-0 rounded-2xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
|
||||
{/* ── UPLOAD STATE: Dropzone ─────────────────────────── */}
|
||||
{showUpload && (
|
||||
{/* ── HEADER ────────────────────────────────────────────── */}
|
||||
<div className="mb-12">
|
||||
{showProcessing ? (
|
||||
<>
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">{t('dashboard.translate.sourceDocument')}</h2>
|
||||
</div>
|
||||
<div className="flex-1 px-6 pb-6">
|
||||
<FileDropZone upload={upload} />
|
||||
{upload.error && <p className="mt-2 text-sm text-destructive">{upload.error}</p>}
|
||||
</div>
|
||||
<span className="accent-pill mb-4 block w-fit italic">{t('landing.translate.processing')}</span>
|
||||
<h1 className="text-5xl font-black uppercase tracking-tighter mb-4 leading-none text-brand-dark dark:text-white">
|
||||
{t('landing.translate.aiAnalysis')}
|
||||
</h1>
|
||||
<p className="text-brand-dark/40 font-medium dark:text-white/40">
|
||||
{t('landing.translate.preservingLayout')}
|
||||
</p>
|
||||
</>
|
||||
) : showComplete ? (
|
||||
<>
|
||||
<span className="accent-pill mb-4 block w-fit italic">{t('dashboard.translate.completed')}</span>
|
||||
<h1 className="text-5xl font-black uppercase tracking-tighter mb-4 leading-none text-brand-dark dark:text-white">
|
||||
{t('dashboard.translate.completed')}
|
||||
</h1>
|
||||
<p className="text-brand-dark/40 font-medium dark:text-white/40">
|
||||
{submit.fileName}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="accent-pill mb-4 block w-fit">{t('landing.translate.newProject')}</span>
|
||||
<h1 className="text-5xl font-black uppercase tracking-tighter mb-4 leading-none text-brand-dark dark:text-white">
|
||||
{t('landing.translate.title')}
|
||||
</h1>
|
||||
<p className="text-brand-dark/40 font-medium dark:text-white/40">
|
||||
{t('landing.translate.subtitle')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── CONFIGURING STATE: File strip ──────────────────── */}
|
||||
{showConfiguring && (
|
||||
<>
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">{t('dashboard.translate.sourceDocument')}</h2>
|
||||
{/* ── GRID: 8/4 SPLIT ───────────────────────────────────── */}
|
||||
<div className="grid lg:grid-cols-12 gap-12">
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
{/* LEFT (8 cols) — content swaps based on state */}
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
<div className="lg:col-span-8">
|
||||
|
||||
{/* ── UPLOAD STATE: Editorial Dropzone ──────────────── */}
|
||||
{showUpload && (
|
||||
<div
|
||||
className="bg-white border-2 border-dashed border-brand-accent/20 rounded-[40px] p-20 flex flex-col items-center justify-center text-center group cursor-pointer hover:border-brand-accent transition-all shadow-editorial dark:bg-[#141414] dark:border-white/10"
|
||||
onDragOver={upload.handleDragOver}
|
||||
onDragLeave={upload.handleDragLeave}
|
||||
onDrop={upload.handleDrop}
|
||||
onClick={() => dropzoneInputRef.current?.click()}
|
||||
>
|
||||
<div className="w-20 h-20 bg-brand-muted rounded-3xl flex items-center justify-center text-brand-accent group-hover:scale-110 group-hover:bg-brand-dark group-hover:text-white transition-all mb-8 shadow-sm dark:bg-white/10">
|
||||
<Upload size={32} />
|
||||
</div>
|
||||
<h3 className="text-2xl font-black uppercase tracking-tight mb-4 text-brand-dark dark:text-white">
|
||||
{t('landing.translate.dropHere')}
|
||||
</h3>
|
||||
<p className="text-sm text-brand-dark/40 mb-12 font-medium dark:text-white/40">
|
||||
{t('landing.translate.supportedFormats')}
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
{[
|
||||
{ label: 'Word', icon: <FileText size={12} className="text-blue-500" /> },
|
||||
{ label: 'Excel', icon: <FileSpreadsheet size={12} className="text-green-500" /> },
|
||||
{ label: 'Slides', icon: <Presentation size={12} className="text-orange-500" /> },
|
||||
{ label: 'PDF', icon: <FileType size={12} className="text-red-500" /> },
|
||||
].map(f => (
|
||||
<span key={f.label} className="flex items-center gap-3 px-4 py-2 bg-brand-muted rounded-xl text-[10px] font-black uppercase tracking-widest text-brand-dark/60 border border-transparent hover:border-brand-accent/30 transition-all dark:bg-white/10 dark:text-white/60">
|
||||
{f.icon} {f.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Hidden file input for click-to-upload */}
|
||||
<input
|
||||
ref={dropzoneInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.docx,.pptx,.pdf"
|
||||
className="hidden"
|
||||
onChange={upload.handleFileSelect}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-6 pb-6">
|
||||
)}
|
||||
|
||||
{/* ── CONFIGURING STATE: File strip ─────────────────── */}
|
||||
{showConfiguring && (
|
||||
<div className="editorial-card p-10 bg-white border-none shadow-editorial dark:bg-[#141414]">
|
||||
<h4 className="text-[10px] font-black uppercase tracking-[0.3em] mb-10 text-brand-dark/30 border-b border-black/5 pb-6 dark:text-white/30 dark:border-white/5">
|
||||
{t('landing.translate.sourceDocument')}
|
||||
</h4>
|
||||
<FileStrip file={upload.file!} onRemove={upload.removeFile} onReplace={() => replaceInputRef.current?.click()} t={t} />
|
||||
<input ref={replaceInputRef} type="file" accept=".xlsx,.docx,.pptx,.pdf" className="hidden" onChange={upload.handleFileSelect} />
|
||||
{upload.error && <p className="mt-2 text-sm text-destructive">{upload.error}</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* ── PROCESSING STATE: Rich progress ────────────────── */}
|
||||
{showProcessing && (
|
||||
<div className="flex flex-col">
|
||||
{/* Header band */}
|
||||
<div className="border-b border-border bg-gradient-to-r from-primary/5 via-primary/8 to-primary/3 px-6 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Loader2 className="size-5 animate-spin text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-foreground leading-tight">{t('dashboard.translate.translating')}</h2>
|
||||
{(submit.fileName || upload.file?.name) && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate max-w-[300px]">
|
||||
{submit.fileName || upload.file?.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* ── PROCESSING STATE: Rich progress ───────────────── */}
|
||||
{showProcessing && (
|
||||
<div className="editorial-card p-16 h-full border-none shadow-editorial bg-white dark:bg-[#141414]">
|
||||
<div className="flex items-center gap-6 mb-20">
|
||||
<div className="w-16 h-16 bg-brand-muted rounded-2xl flex items-center justify-center text-brand-accent border border-brand-accent/10 animate-pulse dark:bg-white/10 dark:border-white/10">
|
||||
<Activity size={32} />
|
||||
</div>
|
||||
{submit.estimatedRemaining != null && submit.estimatedRemaining > 0 && (
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1.5 text-xs font-semibold text-primary">
|
||||
<Clock className="size-3.5" />
|
||||
~{submit.estimatedRemaining}s
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5 p-6">
|
||||
{/* Pipeline stepper */}
|
||||
<PipelineStepper activeIdx={activeStepIdx} t={t} />
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm font-medium text-foreground animate-pulse truncate">
|
||||
{submit.currentStep || (submit.isSubmitting ? t('dashboard.translate.steps.uploading') : t('dashboard.translate.steps.starting'))}
|
||||
</p>
|
||||
<p className="text-2xl font-black tabular-nums tracking-tight text-primary shrink-0">
|
||||
{Math.round(submit.progress)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-3 w-full overflow-hidden rounded-full bg-primary/10">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-primary via-primary/80 to-primary transition-all duration-700 ease-out"
|
||||
style={{ width: `${Math.max(0, submit.progress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live stats */}
|
||||
<div className="grid grid-cols-4 gap-2.5">
|
||||
<StatBox icon={<FileText className="size-4" />} value={`${Math.round(submit.progress)}%`} label={t('dashboard.translate.segments')} />
|
||||
<StatBox icon={<Activity className="size-4" />} value={t('dashboard.translate.translating')} label={t('dashboard.translate.characters')} />
|
||||
<StatBox icon={<Gauge className="size-4" />} value="—" label={t('dashboard.translate.segPerMin')} />
|
||||
<StatBox icon={<Timer className="size-4" />} value={formatElapsed(elapsed)} label={t('dashboard.translate.elapsed')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── COMPLETE STATE: Success ────────────────────────── */}
|
||||
{showComplete && (
|
||||
<div className="flex flex-col">
|
||||
{/* Success header */}
|
||||
<div className="border-b border-emerald-200/50 bg-gradient-to-r from-emerald-500/8 via-emerald-500/5 to-transparent px-6 py-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-emerald-500 shadow-lg shadow-emerald-500/20">
|
||||
<CheckCircle2 className="size-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-foreground leading-tight">{t('dashboard.translate.completed')}</h2>
|
||||
{submit.fileName && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate max-w-[260px]">{submit.fileName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700 dark:border-emerald-800/50 dark:bg-emerald-950/30 dark:text-emerald-400">
|
||||
<TrendingUp className="size-3" />{qualityLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5 p-6">
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="lg" className="h-11 gap-2 flex-1 font-semibold" onClick={handleDownload}>
|
||||
<Download className="size-4" />{t('dashboard.translate.complete.download')}
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" className="h-11 gap-2 flex-1" onClick={handleNewTranslation}>
|
||||
<Plus className="size-4" />{t('dashboard.translate.complete.newTranslation')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── FAILED STATE ───────────────────────────────────── */}
|
||||
{showFailed && (
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<div className="rounded-xl bg-destructive/10 border border-destructive/20 p-4" role="alert">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="size-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-destructive mb-1">{t('dashboard.translate.progress.failedTitle')}</p>
|
||||
<p className="text-sm text-destructive/80">{submit.error}</p>
|
||||
<h3 className="text-2xl font-black uppercase tracking-tight mb-2 text-brand-dark dark:text-white">
|
||||
{t('dashboard.translate.translating')}
|
||||
</h3>
|
||||
<p className="text-[10px] text-brand-dark/30 font-black uppercase tracking-widest dark:text-white/30">
|
||||
{submit.fileName || upload.file?.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Line with step icons */}
|
||||
<div className="relative h-2 bg-brand-muted rounded-full mb-24 dark:bg-white/10">
|
||||
<div className="absolute top-1/2 left-0 w-full -translate-y-1/2 flex justify-between px-2">
|
||||
{PIPELINE_ICONS.map((Icon, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'w-12 h-12 rounded-2xl border-4 border-white dark:border-[#141414] shadow-xl flex items-center justify-center z-10 transition-all duration-500',
|
||||
submit.progress > (i * 25)
|
||||
? 'bg-brand-dark text-white scale-110 dark:bg-brand-accent'
|
||||
: 'bg-brand-muted text-brand-dark/20 dark:bg-white/10 dark:text-white/20'
|
||||
)}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-brand-accent shadow-[0_0_20px_rgba(197,161,122,0.4)] transition-all duration-700 rounded-full"
|
||||
style={{ width: `${Math.max(0, submit.progress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-end mb-8">
|
||||
<span className="text-[10px] font-black text-brand-dark/30 uppercase tracking-[0.3em] dark:text-white/30">
|
||||
{activeStepIdx < 2 ? t('dashboard.translate.steps.uploading') : t('dashboard.translate.steps.starting')}
|
||||
</span>
|
||||
<span className="text-7xl font-black text-brand-dark dark:text-white">
|
||||
{Math.round(submit.progress)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-6 pt-12 border-t border-black/5 dark:border-white/5">
|
||||
<StatBox icon={<FileText size={18} />} value={`${Math.round(submit.progress)}%`} label={t('dashboard.translate.segments')} />
|
||||
<StatBox icon={<Zap size={18} />} value="99.9%" label={t('dashboard.translate.quality')} />
|
||||
<StatBox icon={<Clock size={18} />} value="Turbo" label={t('dashboard.translate.segPerMin')} />
|
||||
<StatBox icon={<Activity size={18} />} value={formatElapsed(elapsed)} label={t('dashboard.translate.elapsed')} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── COMPLETE STATE: Success with download ─────────── */}
|
||||
{showComplete && (
|
||||
<div className="editorial-card p-16 h-full border-none shadow-editorial bg-white dark:bg-[#141414]">
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="p-8 bg-brand-accent/5 border border-brand-accent/10 rounded-[32px] flex items-center justify-between mb-16 shadow-inner dark:bg-brand-accent/10 dark:border-brand-accent/20">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="w-14 h-14 bg-brand-accent rounded-full flex items-center justify-center text-white shadow-xl">
|
||||
<CheckCircle2 size={28} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[13px] font-black uppercase tracking-[0.1em] text-brand-dark dark:text-white">
|
||||
{t('dashboard.translate.completed')}
|
||||
</p>
|
||||
<p className="text-[10px] text-brand-dark/40 font-bold uppercase mt-1 tracking-widest dark:text-white/40">
|
||||
{submit.fileName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-5 py-2 bg-white dark:bg-[#1a1a1a] rounded-full text-[9px] font-black uppercase tracking-widest text-brand-accent border border-brand-accent/20 shadow-sm">
|
||||
{qualityLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center justify-center py-20 bg-brand-muted/20 rounded-[40px] border border-black/5 dark:bg-white/5 dark:border-white/5">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="premium-button px-24 py-6 text-xl !rounded-full flex items-center gap-6 mb-8 group"
|
||||
>
|
||||
<Download size={28} className="group-hover:translate-y-1 transition-transform" />
|
||||
{t('dashboard.translate.complete.download')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNewTranslation}
|
||||
className="text-[10px] font-black uppercase tracking-[0.3em] text-brand-dark/20 hover:text-brand-dark transition-colors dark:text-white/20 dark:hover:text-white"
|
||||
>
|
||||
+ {t('dashboard.translate.complete.newTranslation')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(submit.fileName || upload.file?.name) && (
|
||||
<FileStrip file={upload.file!} onRemove={upload.removeFile} onReplace={() => replaceInputRef.current?.click()} t={t} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
{/* RIGHT CARD (1/3) — Config / Monitor / Summary */}
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
<div className="flex flex-col gap-0 rounded-2xl border border-border bg-card shadow-sm">
|
||||
|
||||
{/* ── CONFIG (upload / configuring / failed) ──────────── */}
|
||||
{(showUpload || showConfiguring || showFailed) && (
|
||||
<>
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">{t('dashboard.translate.configuration')}</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-5 px-6">
|
||||
<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}
|
||||
/>
|
||||
|
||||
{/* PDF mode selector — only shown for PDF files */}
|
||||
{isPdf && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{t('dashboard.translate.pdfMode.title')}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPdfMode('layout')}
|
||||
className={cn(
|
||||
'flex flex-col items-start rounded-lg border-2 p-3 text-start transition-all',
|
||||
pdfMode === 'layout'
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
||||
: 'border-border hover:border-primary/40'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<FileText className="size-4 text-primary" />
|
||||
{t('dashboard.translate.pdfMode.preserveLayout')}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">
|
||||
{t('dashboard.translate.pdfMode.preserveLayoutDesc')}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPdfMode('text_only')}
|
||||
className={cn(
|
||||
'flex flex-col items-start rounded-lg border-2 p-3 text-start transition-all',
|
||||
pdfMode === 'text_only'
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
||||
: 'border-border hover:border-primary/40'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Languages className="size-4 text-primary" />
|
||||
{t('dashboard.translate.pdfMode.textOnly')}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">
|
||||
{t('dashboard.translate.pdfMode.textOnlyDesc')}
|
||||
</p>
|
||||
</button>
|
||||
{/* ── FAILED STATE ───────────────────────────────────── */}
|
||||
{showFailed && (
|
||||
<div className="editorial-card p-10 bg-white border-none shadow-editorial dark:bg-[#141414]">
|
||||
<div className="rounded-[24px] bg-destructive/10 border border-destructive/20 p-6" role="alert">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="size-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-destructive mb-1">{t('dashboard.translate.progress.failedTitle')}</p>
|
||||
<p className="text-sm text-destructive/80">{submit.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(submit.fileName || upload.file?.name) && upload.file && (
|
||||
<div className="mt-6">
|
||||
<FileStrip file={upload.file} onRemove={upload.removeFile} onReplace={() => replaceInputRef.current?.click()} t={t} />
|
||||
<input ref={replaceInputRef} type="file" accept=".xlsx,.docx,.pptx,.pdf" className="hidden" onChange={upload.handleFileSelect} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with button */}
|
||||
<div className="px-6 py-4 border-t border-border/50">
|
||||
<Button
|
||||
size="lg" className="h-12 w-full gap-2 text-base font-semibold"
|
||||
disabled={!config.isConfigValid || submit.isSubmitting || !upload.file}
|
||||
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 flex 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>
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
{/* RIGHT (4 cols) — Config / Monitor / Summary */}
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
<div className="lg:col-span-4">
|
||||
|
||||
{/* ── CONFIG (upload / configuring / failed) ──────────── */}
|
||||
{(showUpload || showConfiguring || showFailed) && (
|
||||
<div className="space-y-8">
|
||||
<div className="editorial-card p-10 bg-white border-none shadow-editorial dark:bg-[#141414]">
|
||||
<h4 className="text-[10px] font-black uppercase tracking-[0.3em] mb-10 text-brand-dark/30 border-b border-black/5 pb-6 dark:text-white/30 dark:border-white/5">
|
||||
{t('landing.translate.configuration')}
|
||||
</h4>
|
||||
|
||||
<div className="space-y-8">
|
||||
<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}
|
||||
/>
|
||||
|
||||
{/* PDF mode selector */}
|
||||
{isPdf && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-[9px] font-black text-brand-dark/40 uppercase tracking-[0.2em] block mb-3 dark:text-white/40">
|
||||
{t('dashboard.translate.pdfMode.title')}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPdfMode('layout')}
|
||||
className={cn(
|
||||
'flex flex-col items-start rounded-2xl border-2 p-3 text-start transition-all',
|
||||
pdfMode === 'layout'
|
||||
? 'border-brand-accent bg-brand-accent/5'
|
||||
: 'border-black/5 bg-brand-muted/30 hover:border-brand-accent/20 dark:border-white/10 dark:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[11px] font-black uppercase tracking-tight text-brand-dark dark:text-white">
|
||||
<FileText className="size-4 text-brand-accent" />
|
||||
{t('dashboard.translate.pdfMode.preserveLayout')}
|
||||
</div>
|
||||
<p className="mt-1 text-[9px] text-brand-dark/50 font-bold uppercase tracking-widest leading-relaxed dark:text-white/40">
|
||||
{t('dashboard.translate.pdfMode.preserveLayoutDesc')}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPdfMode('text_only')}
|
||||
className={cn(
|
||||
'flex flex-col items-start rounded-2xl border-2 p-3 text-start transition-all',
|
||||
pdfMode === 'text_only'
|
||||
? 'border-brand-accent bg-brand-accent/5'
|
||||
: 'border-black/5 bg-brand-muted/30 hover:border-brand-accent/20 dark:border-white/10 dark:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[11px] font-black uppercase tracking-tight text-brand-dark dark:text-white">
|
||||
<Languages className="size-4 text-brand-accent" />
|
||||
{t('dashboard.translate.pdfMode.textOnly')}
|
||||
</div>
|
||||
<p className="mt-1 text-[9px] text-brand-dark/50 font-bold uppercase tracking-widest leading-relaxed dark:text-white/40">
|
||||
{t('dashboard.translate.pdfMode.textOnlyDesc')}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
disabled={!config.isConfigValid || submit.isSubmitting || !upload.file}
|
||||
onClick={handleTranslate}
|
||||
className={cn(
|
||||
'premium-button w-full py-6 text-[11px] uppercase tracking-[0.3em] flex items-center justify-center gap-3 !rounded-2xl',
|
||||
(!config.isConfigValid || submit.isSubmitting || !upload.file) && 'opacity-40 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{submit.isSubmitting ? (
|
||||
<><Loader2 className="size-5 animate-spin" />{t('dashboard.translate.actions.uploading')}</>
|
||||
) : (
|
||||
<>{t('landing.translate.startTranslation')}<ArrowRight size={18} className="text-brand-accent" /></>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between px-6 text-[9px] font-black uppercase tracking-[0.2em] text-brand-dark/20 dark:text-white/20">
|
||||
<span className="flex items-center gap-2">
|
||||
<ShieldCheck size={12} /> {t('landing.translate.zeroRetention')}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Clock size={12} /> {t('landing.translate.filesDeleted')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* ── MONITOR (processing) ────────────────────────────── */}
|
||||
{showProcessing && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 border-b border-border px-6 py-4">
|
||||
<div className="size-2 animate-pulse rounded-full bg-primary" />
|
||||
<h3 className="text-sm font-semibold text-foreground">{t('dashboard.translate.liveMonitor')}</h3>
|
||||
</div>
|
||||
{/* ── MONITOR (processing) ────────────────────────────── */}
|
||||
{showProcessing && (
|
||||
<div className="editorial-card p-10 bg-white border-none shadow-editorial h-full dark:bg-[#141414]">
|
||||
<h4 className="text-[10px] font-black uppercase tracking-[0.4em] mb-12 flex items-center gap-3 text-brand-dark/30 dark:text-white/30">
|
||||
<div className="w-2 h-2 bg-brand-accent rounded-full animate-ping" />
|
||||
{t('dashboard.translate.liveMonitor')}
|
||||
</h4>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 p-6">
|
||||
{/* File summary */}
|
||||
{(submit.fileName || upload.file?.name) && (
|
||||
<div className="flex items-center gap-3 rounded-xl bg-primary/5 border border-primary/10 p-3">
|
||||
{(() => {
|
||||
const name = submit.fileName || upload.file?.name || '';
|
||||
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||
const FileIcon = FILE_ICONS[ext] ?? FileText;
|
||||
const color = FILE_COLORS[ext] ?? 'text-muted-foreground';
|
||||
return <FileIcon className={`size-6 shrink-0 ${color}`} />;
|
||||
})()}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-foreground">{submit.fileName || upload.file?.name}</p>
|
||||
{upload.file && <p className="text-[11px] text-muted-foreground">{fmt(upload.file.size)}</p>}
|
||||
<div className="p-6 bg-brand-muted rounded-[32px] mb-10 flex items-center gap-5 border border-black/5 dark:bg-white/5 dark:border-white/5">
|
||||
<div className="w-12 h-12 bg-white rounded-2xl flex items-center justify-center text-brand-accent shadow-sm dark:bg-[#1a1a1a]">
|
||||
{(() => {
|
||||
const name = submit.fileName || upload.file?.name || '';
|
||||
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||
const FileIcon = FILE_ICONS[ext] ?? FileText;
|
||||
return <FileIcon size={24} />;
|
||||
})()}
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-[11px] font-black uppercase tracking-tight truncate text-brand-dark dark:text-white">
|
||||
{submit.fileName || upload.file?.name}
|
||||
</p>
|
||||
<p className="text-[9px] text-brand-dark/40 font-bold uppercase tracking-widest mt-1 dark:text-white/40">
|
||||
{upload.file ? `${fmt(upload.file.size)} ` : ''}{(submit.fileName || upload.file?.name || '').split('.').pop()?.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config summary */}
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.language.source')}</span>
|
||||
<span className="text-xs font-semibold text-foreground bg-muted px-2 py-0.5 rounded">{srcLangName}</span>
|
||||
<div className="space-y-8 mb-16 px-2">
|
||||
<div className="flex justify-between items-center text-[10px] font-black uppercase tracking-[0.4em] text-brand-dark/30 dark:text-white/30">
|
||||
<span>{t('dashboard.translate.language.source')}</span>
|
||||
<span className="text-brand-dark dark:text-white">{srcLangName.toUpperCase()}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.language.target')}</span>
|
||||
<span className="text-xs font-semibold text-primary bg-primary/10 px-2 py-0.5 rounded">{tgtLangName}</span>
|
||||
<div className="flex justify-between items-center text-[10px] font-black uppercase tracking-[0.4em] text-brand-dark/30 dark:text-white/30">
|
||||
<span>{t('dashboard.translate.language.target')}</span>
|
||||
<span className="text-brand-accent">{tgtLangName.toUpperCase()}</span>
|
||||
</div>
|
||||
{currentProvider && (
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.engine')}</span>
|
||||
<span className="text-xs font-semibold text-foreground bg-muted px-2 py-0.5 rounded">{currentProvider.label}</span>
|
||||
<div className="flex justify-between items-center text-[10px] font-black uppercase tracking-[0.4em] text-brand-dark/30 dark:text-white/30">
|
||||
<span>{t('dashboard.translate.engine')}</span>
|
||||
<span className="text-brand-dark dark:text-white">{currentProvider.label.toUpperCase()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quality indicator */}
|
||||
<div className="rounded-xl border border-border bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{t('dashboard.translate.quality')}</span>
|
||||
<span className="text-xs font-bold text-emerald-600">{qualityLabel}</span>
|
||||
{/* Quality progress */}
|
||||
<div className="pt-10 border-t border-black/10 dark:border-white/10">
|
||||
<div className="flex justify-between text-[10px] font-black uppercase tracking-[0.4em] mb-4">
|
||||
<span className="text-brand-dark/30 dark:text-white/30">{t('dashboard.translate.quality')}</span>
|
||||
<span className="text-brand-accent">{qualityLabel}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<div className="h-2 bg-brand-muted rounded-full overflow-hidden p-0.5 dark:bg-white/10">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600 transition-all duration-700"
|
||||
className="h-full bg-brand-accent rounded-full transition-all duration-700"
|
||||
style={{ width: `${Math.min(95, 40 + submit.progress * 0.55)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cancel */}
|
||||
<div className="px-6 py-4 border-t border-border/50">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2 border-destructive/20 text-destructive hover:bg-destructive hover:text-white hover:border-destructive"
|
||||
<button
|
||||
onClick={handleNewTranslation}
|
||||
className="w-full mt-16 py-5 border border-red-50 text-red-500 rounded-2xl text-[10px] font-black uppercase tracking-[0.3em] flex items-center justify-center gap-3 hover:bg-red-50 transition-all dark:border-red-900/30 dark:text-red-400 dark:hover:bg-red-950/30"
|
||||
>
|
||||
<RotateCcw className="size-4" />{t('dashboard.translate.cancel')}
|
||||
</Button>
|
||||
<RotateCcw size={16} /> {t('dashboard.translate.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* ── SUMMARY (complete) ──────────────────────────────── */}
|
||||
{showComplete && (
|
||||
<>
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||||
<CheckCircle2 className="size-4 text-emerald-500" />{t('dashboard.translate.summary')}
|
||||
</h3>
|
||||
</div>
|
||||
{/* ── SUMMARY (complete) ──────────────────────────────── */}
|
||||
{showComplete && (
|
||||
<div className="editorial-card p-10 bg-white border-none shadow-editorial h-full dark:bg-[#141414]">
|
||||
<h4 className="text-[10px] font-black uppercase tracking-[0.4em] mb-12 flex items-center gap-3 text-brand-dark/30 dark:text-white/30">
|
||||
<CheckCircle2 size={14} className="text-emerald-500" />
|
||||
{t('dashboard.translate.summary')}
|
||||
</h4>
|
||||
|
||||
<div className="flex-1 p-6 space-y-2.5">
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.language.source')}</span>
|
||||
<span className="text-xs font-semibold text-foreground bg-muted px-2 py-0.5 rounded">{srcLangName}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.language.target')}</span>
|
||||
<span className="text-xs font-semibold text-primary bg-primary/10 px-2 py-0.5 rounded">{tgtLangName}</span>
|
||||
</div>
|
||||
{currentProvider && (
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.engine')}</span>
|
||||
<span className="text-xs font-semibold text-foreground bg-muted px-2 py-0.5 rounded">{currentProvider.label}</span>
|
||||
<div className="space-y-8 mb-16 px-2">
|
||||
<div className="flex justify-between items-center text-[10px] font-black uppercase tracking-[0.4em] text-brand-dark/30 dark:text-white/30">
|
||||
<span>{t('dashboard.translate.language.source')}</span>
|
||||
<span className="text-brand-dark dark:text-white">{srcLangName.toUpperCase()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.quality')}</span>
|
||||
<span className="text-xs font-bold text-emerald-600">{qualityLabel}</span>
|
||||
<div className="flex justify-between items-center text-[10px] font-black uppercase tracking-[0.4em] text-brand-dark/30 dark:text-white/30">
|
||||
<span>{t('dashboard.translate.language.target')}</span>
|
||||
<span className="text-brand-accent">{tgtLangName.toUpperCase()}</span>
|
||||
</div>
|
||||
{currentProvider && (
|
||||
<div className="flex justify-between items-center text-[10px] font-black uppercase tracking-[0.4em] text-brand-dark/30 dark:text-white/30">
|
||||
<span>{t('dashboard.translate.engine')}</span>
|
||||
<span className="text-brand-dark dark:text-white">{currentProvider.label.toUpperCase()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality bar */}
|
||||
<div className="px-6 pb-6">
|
||||
<div className="rounded-xl border border-border bg-muted/20 p-3">
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<div className="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600" style={{ width: '95%' }} />
|
||||
<div className="pt-10 border-t border-black/10 dark:border-white/10">
|
||||
<div className="flex justify-between text-[10px] font-black uppercase tracking-[0.4em] mb-4">
|
||||
<span className="text-brand-dark/30 dark:text-white/30">{t('dashboard.translate.quality')}</span>
|
||||
<span className="text-brand-accent">{qualityLabel}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-brand-muted rounded-full overflow-hidden p-0.5 dark:bg-white/10">
|
||||
<div className="h-full bg-brand-accent rounded-full" style={{ width: '95%' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── MOMENTO PROMO BANNER ──────────────────────────────── */}
|
||||
{(showUpload || showConfiguring || showFailed) && (
|
||||
<div className="mt-20 editorial-card p-12 bg-white border-none shadow-editorial flex flex-col md:flex-row items-center gap-10 group overflow-hidden relative dark:bg-[#141414]">
|
||||
<div className="absolute -right-20 -top-20 w-64 h-64 bg-brand-accent/5 rounded-full blur-3xl group-hover:bg-brand-accent/10 transition-colors" />
|
||||
|
||||
<div className="w-24 h-24 bg-brand-dark rounded-[32px] flex items-center justify-center text-white text-5xl font-black shadow-2xl shrink-0 group-hover:rotate-12 transition-transform duration-500 dark:bg-brand-accent dark:text-brand-dark">
|
||||
M
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<span className="accent-pill !px-3 !py-1 text-[9px] italic">{t('memento.title')}</span>
|
||||
<h3 className="text-2xl font-black uppercase tracking-tight text-brand-dark dark:text-white">{t('memento.title')}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-brand-dark/40 font-medium leading-relaxed max-w-2xl dark:text-white/40">
|
||||
{t('memento.slogan')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 shrink-0 w-full md:w-auto">
|
||||
<button className="premium-button px-10 py-4 text-[11px] uppercase tracking-widest !rounded-2xl">
|
||||
{t('memento.ctaFree')}
|
||||
</button>
|
||||
<button className="px-10 py-4 border border-black/5 bg-brand-muted text-brand-dark/40 rounded-2xl text-[10px] font-black uppercase tracking-widest hover:text-brand-dark transition-all dark:border-white/10 dark:bg-white/5 dark:text-white/40 dark:hover:text-white">
|
||||
{t('memento.ctaMore')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -540,16 +628,16 @@ function FileStrip({ file, onRemove, onReplace, t }: { file: File; onRemove: ()
|
||||
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">
|
||||
<div className="flex items-center gap-3 rounded-[24px] border border-black/5 bg-brand-muted/30 px-4 py-3 dark:border-white/5 dark:bg-white/5">
|
||||
<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>
|
||||
<span className="truncate text-[11px] font-black uppercase tracking-tight text-brand-dark dark:text-white">{file.name}</span>
|
||||
<span className="text-[9px] text-brand-dark/40 font-bold uppercase tracking-widest dark:text-white/40">{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">
|
||||
<button type="button" onClick={onReplace} className="flex shrink-0 items-center gap-1 rounded-xl px-2 py-1 text-[10px] font-black uppercase tracking-widest text-brand-dark/30 transition hover:bg-brand-muted hover:text-brand-dark dark:text-white/30 dark:hover:bg-white/10 dark:hover:text-white">
|
||||
<Upload className="size-3.5" />{t('dashboard.translate.replace')}
|
||||
</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">
|
||||
<button type="button" aria-label="Remove" onClick={onRemove} className="flex size-7 shrink-0 items-center justify-center rounded-xl text-brand-dark/20 transition hover:bg-brand-muted hover:text-brand-dark dark:text-white/20 dark:hover:bg-white/10 dark:hover:text-white">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -594,10 +682,10 @@ function PipelineStepper({ activeIdx, t }: { activeIdx: number; t: (key: string)
|
||||
/** Small stat box */
|
||||
function StatBox({ icon, value, label }: { icon: React.ReactNode; value: string; label: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 rounded-xl border border-border bg-muted/20 p-2.5 text-center">
|
||||
<div className="text-primary">{icon}</div>
|
||||
<p className="text-sm font-bold tabular-nums text-foreground leading-none">{value}</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{label}</p>
|
||||
<div className="p-6 bg-brand-muted/30 rounded-3xl text-center border border-transparent hover:border-brand-accent/10 transition-all dark:bg-white/5 dark:border-white/5">
|
||||
<div className="text-brand-accent flex justify-center mb-4">{icon}</div>
|
||||
<p className="text-[12px] font-black text-brand-dark mb-1 uppercase tracking-tight dark:text-white">{value}</p>
|
||||
<p className="text-[8px] font-black text-brand-dark/30 uppercase tracking-[0.2em] dark:text-white/30">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,14 +4,13 @@ import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
Check, X, Zap, Building2, Crown, Sparkles, ArrowRight, ArrowLeft,
|
||||
Star, Shield, Rocket, Users, Headphones, Lock, Globe,
|
||||
Clock, ChevronDown, ChevronUp, Cpu, BarChart3, Infinity,
|
||||
FileText, Layers, Brain, BadgeCheck, Gauge
|
||||
Check, CheckCircle2, X, Zap, Building2, Crown, Sparkles, ArrowRight,
|
||||
ArrowLeft, ChevronLeft, Star, Shield, Rocket, Users, Headphones, Lock,
|
||||
Globe, Clock, ChevronDown, ChevronUp, Cpu, BarChart3, Infinity,
|
||||
FileText, Layers, Brain, BadgeCheck, Gauge, Activity,
|
||||
} 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 { cn } from "@/lib/utils";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
import { ANNUAL_DISCOUNT_PERCENT } from "@/lib/pricing";
|
||||
@@ -200,41 +199,41 @@ const STATIC_CREDITS: CreditPackage[] = [
|
||||
];
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Visual config by plan
|
||||
Visual config by plan — editorial design
|
||||
───────────────────────────────────────────── */
|
||||
const PLAN_ICONS: Record<string, any> = {
|
||||
free: Sparkles,
|
||||
free: Star,
|
||||
starter: Zap,
|
||||
pro: Crown,
|
||||
business: Building2,
|
||||
enterprise: Rocket,
|
||||
business: Globe,
|
||||
enterprise: Shield,
|
||||
};
|
||||
|
||||
const PLAN_COLORS: Record<string, { gradient: string; border: string; badge: string; button: string }> = {
|
||||
free: { gradient: "from-slate-600 to-slate-700", border: "border-slate-700/50", badge: "bg-slate-700", button: "bg-slate-700 hover:bg-slate-600" },
|
||||
starter: { gradient: "from-blue-600 to-blue-700", border: "border-blue-700/50", badge: "bg-blue-600", button: "bg-blue-600 hover:bg-blue-500" },
|
||||
pro: { gradient: "from-violet-600 to-violet-700", border: "border-violet-500/60",badge: "bg-violet-600", button: "bg-violet-600 hover:bg-violet-500" },
|
||||
business: { gradient: "from-emerald-600 to-emerald-700",border:"border-emerald-700/50",badge:"bg-emerald-600", button: "bg-emerald-600 hover:bg-emerald-500" },
|
||||
enterprise: { gradient: "from-amber-600 to-amber-700", border: "border-amber-700/50", badge: "bg-amber-600", button: "bg-amber-600 hover:bg-amber-500" },
|
||||
const PLAN_COLORS: Record<string, { header: string; iconColor: string; nameColor: string }> = {
|
||||
free: { header: "bg-muted", iconColor: "text-foreground/20", nameColor: "text-foreground/40" },
|
||||
starter: { header: "bg-foreground", iconColor: "text-white/20", nameColor: "text-white/50" },
|
||||
pro: { header: "bg-accent", iconColor: "text-white/30", nameColor: "text-white/50" },
|
||||
business: { header: "bg-foreground", iconColor: "text-accent/40", nameColor: "text-white/50" },
|
||||
enterprise: { header: "bg-[#252525]", iconColor: "text-white/10", nameColor: "text-white/50" },
|
||||
};
|
||||
|
||||
/** Avoids flash of static prices before the API responds on refresh. */
|
||||
function PricingDataSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-5">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-6">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-2xl border border-border/40 bg-card overflow-hidden animate-pulse"
|
||||
className="rounded-[24px] border border-black/[0.08] bg-white overflow-hidden animate-pulse"
|
||||
>
|
||||
<div className="h-28 bg-muted/60" />
|
||||
<div className="p-5 space-y-3">
|
||||
<div className="h-48 bg-muted/60" />
|
||||
<div className="p-8 space-y-3">
|
||||
<div className="h-4 bg-muted rounded w-3/4" />
|
||||
<div className="h-4 bg-muted rounded w-1/2" />
|
||||
<div className="h-4 bg-muted rounded w-full" />
|
||||
<div className="h-4 bg-muted rounded w-5/6" />
|
||||
<div className="h-9 bg-muted rounded-lg mt-4" />
|
||||
<div className="h-10 bg-muted rounded-2xl mt-4" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -404,27 +403,28 @@ export default function PricingPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
{/* ── Top navigation ── */}
|
||||
<div className="sticky top-0 z-40 border-b border-border/60 bg-background/85 backdrop-blur">
|
||||
<div className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between gap-3">
|
||||
{/* ── Top navigation — breadcrumb bar ── */}
|
||||
<div className="max-w-[1400px] mx-auto px-4 pt-4">
|
||||
<div className="flex justify-between items-center mb-20">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="flex items-center gap-3 text-[10px] font-black uppercase tracking-[0.4em] text-foreground/30 hover:text-foreground transition-all group"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<ChevronLeft size={16} className="group-hover:-translate-x-1 transition-transform" />
|
||||
{t('pricing.nav.back')}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
<div className="flex gap-2 p-1.5 bg-muted rounded-full border border-black/5 shadow-inner">
|
||||
<Link
|
||||
href={isLoggedIn ? "/dashboard" : "/"}
|
||||
className="px-3 py-1.5 rounded-lg text-sm bg-secondary hover:bg-secondary/80 text-secondary-foreground transition-colors"
|
||||
className="px-6 py-2 rounded-full text-[9px] font-black uppercase tracking-widest text-foreground/40 hover:text-foreground transition-all"
|
||||
>
|
||||
{isLoggedIn ? "Dashboard" : t('pricing.nav.home')}
|
||||
Dashboard
|
||||
</Link>
|
||||
{isLoggedIn && (
|
||||
<Link
|
||||
href="/dashboard/profile"
|
||||
className="px-3 py-1.5 rounded-lg text-sm bg-accent/80 hover:bg-accent text-accent-foreground transition-colors"
|
||||
className="px-6 py-2 bg-white rounded-full text-[9px] font-black uppercase tracking-widest text-foreground shadow-sm border border-black/5"
|
||||
>
|
||||
{t('pricing.nav.mySubscription')}
|
||||
</Link>
|
||||
@@ -438,185 +438,238 @@ export default function PricingPage() {
|
||||
<div className={cn(
|
||||
"fixed top-4 left-1/2 -translate-x-1/2 z-50 flex items-start gap-3 px-5 py-4 rounded-2xl shadow-2xl border max-w-lg w-full mx-4 backdrop-blur-sm",
|
||||
toastMsg.type === 'ok'
|
||||
? "bg-success/10 border-success/30 text-success"
|
||||
: "bg-destructive/10 border-destructive/30 text-destructive"
|
||||
? "bg-emerald-50 border-emerald-200 text-emerald-700 dark:bg-emerald-950/50 dark:border-emerald-800 dark:text-emerald-300"
|
||||
: "bg-red-50 border-red-200 text-red-700 dark:bg-red-950/50 dark:border-red-800 dark:text-red-300"
|
||||
)}>
|
||||
<span className="text-lg">{toastMsg.type === 'ok' ? '✓' : '✕'}</span>
|
||||
<p className="text-sm flex-1">{toastMsg.text}</p>
|
||||
<button onClick={() => setToastMsg(null)} className="text-muted-foreground hover:text-foreground text-lg leading-none">✕</button>
|
||||
<button onClick={() => setToastMsg(null)} className="text-muted-foreground hover:text-foreground text-lg leading-none">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="max-w-7xl mx-auto px-4 pt-20 pb-12 text-center">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-accent/10 border border-accent/20 text-accent text-sm font-medium mb-6">
|
||||
<Cpu className="w-4 h-4" />
|
||||
{t('pricing.header.badge')}
|
||||
</div>
|
||||
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-4 bg-gradient-to-r from-foreground via-accent/80 to-accent bg-clip-text text-transparent">
|
||||
{t('pricing.header.title')}
|
||||
<div className="max-w-[1400px] mx-auto px-4 text-center mb-20">
|
||||
<span className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full border border-black/5 mb-6">
|
||||
<div className="w-2 h-2 bg-accent rounded-full animate-pulse" />
|
||||
<span className="text-[10px] font-black tracking-[0.3em] opacity-40">{t('pricing.header.badge')}</span>
|
||||
</span>
|
||||
<h1 className="text-5xl md:text-7xl font-black uppercase tracking-tighter mb-6 leading-none">
|
||||
{t('pricing.header.title').split(' ').slice(0, -2).join(' ')}{" "}
|
||||
<span className="text-accent">
|
||||
{t('pricing.header.title').split(' ').slice(-2).join(' ')}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto mb-8">
|
||||
<p className="text-foreground/40 font-medium text-xl max-w-2xl mx-auto leading-relaxed">
|
||||
{t('pricing.header.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Monthly / Yearly toggle */}
|
||||
<div className="inline-flex items-center gap-3 bg-muted/60 border border-border/50 rounded-full p-1.5">
|
||||
{/* ── Monthly / Yearly toggle ── */}
|
||||
<div className="flex items-center justify-center gap-10 mb-20">
|
||||
<div className="flex p-1 bg-muted rounded-full border border-black/5 shadow-inner px-2">
|
||||
<button
|
||||
onClick={() => setIsYearly(false)}
|
||||
className={cn(
|
||||
"px-5 py-2 rounded-full text-sm font-medium transition-all",
|
||||
!isYearly ? "bg-foreground text-background shadow" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
className={`px-8 py-3 rounded-full text-[10px] font-black uppercase tracking-widest transition-all ${!isYearly ? 'bg-foreground text-white shadow-xl' : 'text-foreground/60 hover:text-foreground'}`}
|
||||
>
|
||||
{t('pricing.billing.monthly')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsYearly(true)}
|
||||
className={cn(
|
||||
"px-5 py-2 rounded-full text-sm font-medium transition-all flex items-center gap-2",
|
||||
isYearly ? "bg-foreground text-background shadow" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
className={`px-8 py-3 rounded-full text-[10px] font-black uppercase tracking-widest transition-all ${isYearly ? 'bg-foreground text-white shadow-xl' : 'text-foreground/60 hover:text-foreground'}`}
|
||||
>
|
||||
{t('pricing.billing.yearly')}
|
||||
<span className="bg-success text-success-foreground text-xs px-1.5 py-0.5 rounded-full">
|
||||
−{annualDiscountPercent} %
|
||||
</span>
|
||||
<span className={`ml-2 transition-colors ${isYearly ? 'text-accent' : 'text-accent/60'}`}>−{annualDiscountPercent} %</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Plan cards (+ comparison + credits): skeleton until API responds to avoid stale price flash ── */}
|
||||
<div className="max-w-7xl mx-auto px-4 pb-20">
|
||||
{/* ── Plan cards (skeleton until API responds) ── */}
|
||||
<div className="max-w-[1400px] mx-auto px-4 pb-20">
|
||||
{!pricingLoaded ? (
|
||||
<PricingDataSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-5">
|
||||
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-6 items-stretch">
|
||||
{plans.map((plan) => {
|
||||
const Icon = PLAN_ICONS[plan.id] ?? Sparkles;
|
||||
const colors = PLAN_COLORS[plan.id] ?? PLAN_COLORS.starter;
|
||||
const price = displayPrice(plan);
|
||||
const isCurrent = currentPlan === plan.id;
|
||||
const isEnterprise = plan.id === "enterprise";
|
||||
const isFree = plan.id === "free";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={cn(
|
||||
"relative flex flex-col rounded-2xl border bg-card backdrop-blur transition-all duration-300",
|
||||
"hover:scale-[1.02] hover:shadow-2xl",
|
||||
colors.border,
|
||||
plan.popular && "ring-2 ring-violet-500/50 shadow-violet-500/20 shadow-xl"
|
||||
"flex flex-col bg-white dark:bg-card rounded-[24px] border border-black/[0.08] dark:border-border/40 overflow-hidden transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.08)] hover:-translate-y-2 group",
|
||||
plan.popular && "border-accent/30 ring-4 ring-accent/5"
|
||||
)}
|
||||
>
|
||||
{/* Popular badge */}
|
||||
{plan.badge && (
|
||||
<div className={cn(
|
||||
"absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full text-xs font-bold text-white",
|
||||
colors.badge
|
||||
)}>
|
||||
{t(plan.badge)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCurrent && (
|
||||
<div className="absolute -top-3 right-4 px-3 py-1 rounded-full text-xs font-bold bg-emerald-600 text-white flex items-center gap-1">
|
||||
<BadgeCheck className="w-3 h-3" /> {t('pricing.card.myPlan')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className={cn("p-5 rounded-t-2xl bg-gradient-to-br", colors.gradient)}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="p-1.5 bg-white/10 rounded-lg">
|
||||
<Icon className="w-5 h-5 text-white" />
|
||||
{/* ── Header section ── */}
|
||||
<div className={cn("p-8 text-white relative h-48 flex flex-col justify-end", colors.header)}>
|
||||
{/* Badges for popular/current plan */}
|
||||
{plan.popular && (
|
||||
<div className="absolute top-0 right-0 p-3 flex gap-2">
|
||||
{plan.badge && (
|
||||
<span className="bg-foreground/20 backdrop-blur-md text-white text-[8px] font-black uppercase tracking-widest px-3 py-1 rounded-full border border-white/20 shadow-lg">
|
||||
{t(plan.badge)}
|
||||
</span>
|
||||
)}
|
||||
{isCurrent && (
|
||||
<span className="bg-foreground/40 backdrop-blur-md text-white text-[8px] font-black uppercase tracking-widest px-3 py-1 rounded-full border border-white/20 shadow-lg flex items-center gap-1">
|
||||
<div className="w-1.5 h-1.5 bg-accent rounded-full animate-pulse" /> {t('pricing.card.myPlan')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-bold text-white">{t(plan.name)}</span>
|
||||
</div>
|
||||
|
||||
{isEnterprise ? (
|
||||
<div className="text-3xl font-bold text-white">{t('pricing.card.onRequest')}</div>
|
||||
) : price === 0 ? (
|
||||
<div className="text-3xl font-bold text-white">{t('pricing.card.free')}</div>
|
||||
) : (
|
||||
<div className="flex items-end gap-1">
|
||||
<span className="text-3xl font-bold text-white">{price} €</span>
|
||||
<span className="text-white/70 text-sm pb-1">{t('pricing.card.perMonth')}</span>
|
||||
)}
|
||||
{plan.badge && !plan.popular && (
|
||||
<div className="absolute top-0 right-0 p-3">
|
||||
<span className="bg-white/10 backdrop-blur-md text-white text-[8px] font-black uppercase tracking-widest px-3 py-1 rounded-full border border-white/10">
|
||||
{t(plan.badge)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* "My Plan" badge for current non-popular plan */}
|
||||
{isCurrent && !plan.popular && (
|
||||
<div className="absolute top-0 right-0 p-3">
|
||||
<span className="bg-foreground/40 backdrop-blur-md text-white text-[8px] font-black uppercase tracking-widest px-3 py-1 rounded-full border border-white/20 shadow-lg flex items-center gap-1">
|
||||
<div className="w-1.5 h-1.5 bg-accent rounded-full animate-pulse" /> {t('pricing.card.myPlan')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Icon + plan name */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Icon size={20} className={colors.iconColor} />
|
||||
<span className={cn("text-sm font-black uppercase tracking-widest", isFree ? "text-foreground/40" : colors.nameColor)}>
|
||||
{t(plan.name)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="flex items-baseline gap-2">
|
||||
{isEnterprise ? (
|
||||
<h3 className={cn("text-3xl font-black uppercase tracking-tighter", isFree ? "text-foreground" : "text-white")}>
|
||||
{t('pricing.card.onRequest')}
|
||||
</h3>
|
||||
) : price === 0 ? (
|
||||
<h3 className="text-3xl font-black uppercase tracking-tighter text-foreground">
|
||||
{t('pricing.card.free')}
|
||||
</h3>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-3xl font-black uppercase tracking-tighter text-white">{price} €</h3>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-white/40">{t('pricing.card.perMonth')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Yearly billing note */}
|
||||
{isYearly && plan.price_yearly > 0 && (
|
||||
<div className="text-white/70 text-xs mt-1">
|
||||
<div className="text-white/70 text-[10px] mt-1">
|
||||
{t('pricing.card.billedYearly', { price: plan.price_yearly.toFixed(2) })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-white/60 text-xs mt-2">{t(plan.description || '')}</p>
|
||||
{/* Description */}
|
||||
<p className={cn("text-[10px] font-medium uppercase mt-3 tracking-widest leading-relaxed", isFree ? "text-foreground/40" : "text-white/60")}>
|
||||
{t(plan.description || '')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="flex-1 p-5 space-y-2.5">
|
||||
{/* Key stats */}
|
||||
<div className="grid grid-cols-1 gap-2 mb-3">
|
||||
<Stat
|
||||
icon={<FileText className="w-3.5 h-3.5" />}
|
||||
label={t('pricing.card.documents')}
|
||||
value={plan.docs_per_month === -1 ? t('pricing.card.unlimited') : `${plan.docs_per_month} ${t('pricing.card.perMonthStat')}`}
|
||||
/>
|
||||
<Stat
|
||||
icon={<Layers className="w-3.5 h-3.5" />}
|
||||
label={t('pricing.card.pagesMax')}
|
||||
value={plan.max_pages_per_doc === -1 ? t('pricing.card.unlimited') : `${plan.max_pages_per_doc} ${t('pricing.card.perDoc')}`}
|
||||
/>
|
||||
{/* ── Content section ── */}
|
||||
<div className="p-8 flex-1 flex flex-col">
|
||||
{/* Metrics */}
|
||||
<div className="space-y-2 mb-10">
|
||||
<div className="flex justify-between items-center py-2 border-b border-black/[0.03] dark:border-border/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText size={12} className="text-foreground/20" />
|
||||
<span className="text-[9px] font-black uppercase tracking-widest text-foreground/40">{t('pricing.card.documents')}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-black uppercase text-foreground">
|
||||
{plan.docs_per_month === -1 ? t('pricing.card.unlimited') : `${plan.docs_per_month} ${t('pricing.card.perMonthStat')}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b border-black/[0.03] dark:border-border/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers size={12} className="text-foreground/20" />
|
||||
<span className="text-[9px] font-black uppercase tracking-widest text-foreground/40">{t('pricing.card.pagesMax')}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-black uppercase text-foreground">
|
||||
{plan.max_pages_per_doc === -1 ? t('pricing.card.unlimited') : `${plan.max_pages_per_doc} ${t('pricing.card.perDoc')}`}
|
||||
</span>
|
||||
</div>
|
||||
{plan.ai_translation && (
|
||||
<Stat
|
||||
icon={<Brain className="w-3.5 h-3.5 text-violet-400" />}
|
||||
label={t('pricing.card.aiTranslation')}
|
||||
value={
|
||||
plan.ai_tier === "essential" ? t('pricing.card.aiEssential') :
|
||||
plan.ai_tier === "premium" ? t('pricing.card.aiEssentialPremium') : t('pricing.card.aiCustom')
|
||||
}
|
||||
highlight
|
||||
/>
|
||||
<div className={cn(
|
||||
"flex justify-between items-center py-3 px-3 rounded-lg mt-2",
|
||||
plan.ai_tier === "essential" ? "bg-accent/5" :
|
||||
plan.ai_tier === "premium" ? "bg-foreground/5" :
|
||||
"bg-black/5 dark:bg-border/20"
|
||||
)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity size={12} className={
|
||||
plan.ai_tier === "essential" ? "text-accent/40" :
|
||||
"text-foreground/20"
|
||||
} />
|
||||
<span className={cn(
|
||||
"text-[9px] font-black uppercase tracking-widest",
|
||||
plan.ai_tier === "essential" ? "text-accent/60" :
|
||||
"text-foreground/40"
|
||||
)}>{t('pricing.card.aiTranslation')}</span>
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-[9px] font-black uppercase",
|
||||
plan.ai_tier === "essential" ? "text-accent" :
|
||||
plan.ai_tier === "premium" ? "text-foreground" :
|
||||
"text-foreground"
|
||||
)}>
|
||||
{plan.ai_tier === "essential" ? t('pricing.card.aiEssential') :
|
||||
plan.ai_tier === "premium" ? t('pricing.card.aiEssentialPremium') :
|
||||
t('pricing.card.aiCustom')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/40 pt-3 space-y-2">
|
||||
{/* Features list */}
|
||||
<ul className="space-y-4 mb-12 flex-1">
|
||||
{plan.features.map((feat, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<Check className="w-4 h-4 text-emerald-500 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground text-xs leading-snug">{t(feat)}</span>
|
||||
</div>
|
||||
<li key={i} className="flex items-start gap-3">
|
||||
<div className="w-4 h-4 rounded-full bg-accent/10 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<CheckCircle2 size={10} className="text-accent" />
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-foreground/60 leading-normal">{t(feat)}</span>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ul>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="p-5 pt-0">
|
||||
{/* CTA */}
|
||||
{isCurrent ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-success/50 text-success hover:bg-success/10"
|
||||
onClick={() => router.push("/dashboard/profile")}
|
||||
<Link
|
||||
href="/dashboard/profile"
|
||||
className="w-full py-4 rounded-2xl text-[11px] font-black uppercase tracking-widest transition-all flex items-center justify-center gap-3 border shadow-sm hover:shadow-xl active:scale-95 bg-muted text-foreground border-black/5 hover:bg-foreground hover:text-white"
|
||||
>
|
||||
<BadgeCheck className="w-4 h-4 me-1" /> {t('pricing.card.managePlan')}
|
||||
</Button>
|
||||
) : plan.id === "free" && !currentPlan ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-border text-muted-foreground hover:bg-muted/30"
|
||||
onClick={() => router.push("/auth/register")}
|
||||
{t('pricing.card.managePlan')}
|
||||
<ArrowRight size={14} className="opacity-40" />
|
||||
</Link>
|
||||
) : isFree && !currentPlan ? (
|
||||
<Link
|
||||
href="/auth/register"
|
||||
className="w-full py-4 rounded-2xl text-[11px] font-black uppercase tracking-widest transition-all flex items-center justify-center gap-3 border shadow-sm hover:shadow-xl active:scale-95 bg-foreground text-white border-transparent hover:bg-accent"
|
||||
>
|
||||
{t('pricing.card.startFree')}
|
||||
</Button>
|
||||
<ArrowRight size={14} className="opacity-40" />
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleSubscribe(plan.id)}
|
||||
disabled={loadingPlanId !== null}
|
||||
className={cn(
|
||||
"w-full py-2.5 px-4 rounded-xl text-sm font-semibold text-white transition-all flex items-center justify-center gap-2",
|
||||
colors.button,
|
||||
"w-full py-4 rounded-2xl text-[11px] font-black uppercase tracking-widest transition-all flex items-center justify-center gap-3 border shadow-sm hover:shadow-xl active:scale-95",
|
||||
plan.popular
|
||||
? "bg-muted text-foreground border-black/5 hover:bg-foreground hover:text-white"
|
||||
: "bg-foreground text-white border-transparent hover:bg-accent",
|
||||
loadingPlanId !== null && "opacity-70 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
@@ -631,7 +684,7 @@ export default function PricingPage() {
|
||||
) : (
|
||||
<>
|
||||
{isEnterprise ? t('pricing.card.contactUs') : t('pricing.card.choosePlan')}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
<ArrowRight size={14} className="opacity-40" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -719,7 +772,7 @@ export default function PricingPage() {
|
||||
)}
|
||||
<div className="text-2xl font-bold text-foreground">{pkg.credits}</div>
|
||||
<div className="text-muted-foreground text-xs mb-3">{t('pricing.credits.unit')}</div>
|
||||
<div className="text-xl font-bold text-foreground">{pkg.price} €</div>
|
||||
<div className="text-xl font-bold text-foreground">{pkg.price} €</div>
|
||||
<div className="text-muted-foreground text-xs">{(pkg.price_per_credit * 100).toFixed(0)} {t('pricing.credits.centsPerCredit')}</div>
|
||||
<button className="mt-3 w-full py-1.5 rounded-lg bg-muted hover:bg-muted/80 text-foreground text-xs transition-all">
|
||||
{t('pricing.credits.buy')}
|
||||
@@ -766,7 +819,7 @@ export default function PricingPage() {
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<span className="px-2 py-1 bg-muted rounded">{t('pricing.aiModels.essential.context')}</span>
|
||||
<span className="px-2 py-1 bg-muted rounded">$0.25/$0.38 per 1M</span>
|
||||
<span className="px-2 py-1 bg-success/10 text-success rounded">{t('pricing.aiModels.essential.value')}</span>
|
||||
<span className="px-2 py-1 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 rounded">{t('pricing.aiModels.essential.value')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 rounded-xl bg-card border border-accent/30">
|
||||
@@ -840,17 +893,3 @@ export default function PricingPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Sub-components ── */
|
||||
function Stat({ icon, label, value, highlight }: { icon: React.ReactNode; label: string; value: string; highlight?: boolean }) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-lg text-xs",
|
||||
highlight ? "bg-accent/10 border border-accent/20" : "bg-muted/40 border border-border/30"
|
||||
)}>
|
||||
<span className={highlight ? "text-accent" : "text-muted-foreground"}>{icon}</span>
|
||||
<span className="text-muted-foreground">{label} :</span>
|
||||
<span className={cn("font-medium ml-auto", highlight ? "text-accent" : "text-foreground")}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,6 +154,12 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
"landing.pricing.business.f5": "Webhooks & automation",
|
||||
"landing.pricing.business.f6": "5 team seats",
|
||||
"landing.pricing.business.cta": "Contact Us",
|
||||
"landing.pricing.free.name": "Free",
|
||||
"landing.pricing.free.desc": "Perfect to discover the app",
|
||||
"landing.pricing.free.cta": "Choose this plan",
|
||||
"landing.pricing.enterprise.name": "Enterprise",
|
||||
"landing.pricing.enterprise.desc": "Custom solutions for large organizations",
|
||||
"landing.pricing.enterprise.cta": "Contact us",
|
||||
"landing.cta.title": "Start translating in 30 seconds",
|
||||
"landing.cta.subtitle": "No credit card required. Try for free now and bring your multilingual documents back to life.",
|
||||
"landing.cta.button": "Create Free Account",
|
||||
@@ -746,6 +752,22 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
"landing.hero.liveAnalysis": "Live Analysis",
|
||||
"landing.hero.termsDetected": "terms detected",
|
||||
"landing.steps.process": "PROCESS",
|
||||
"landing.translate.newProject": "New Project",
|
||||
"landing.translate.title": "Translate a document",
|
||||
"landing.translate.subtitle": "Import a file and choose the target language",
|
||||
"landing.translate.sourceDocument": "Source Document",
|
||||
"landing.translate.configuration": "Configuration",
|
||||
"landing.translate.sourceLang": "Source Language",
|
||||
"landing.translate.targetLang": "Target Language",
|
||||
"landing.translate.provider": "Provider",
|
||||
"landing.translate.startTranslation": "Start Translation",
|
||||
"landing.translate.zeroRetention": "Zero retention",
|
||||
"landing.translate.filesDeleted": "Files deleted after processing",
|
||||
"landing.translate.dropHere": "Drag & drop here",
|
||||
"landing.translate.supportedFormats": "DOCX, XLSX, PPTX or PDF files supported",
|
||||
"landing.translate.aiAnalysis": "Active AI Analysis",
|
||||
"landing.translate.processing": "Processing",
|
||||
"landing.translate.preservingLayout": "Your layout is being preserved",
|
||||
},
|
||||
|
||||
fr: {
|
||||
@@ -1483,6 +1505,22 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
"landing.hero.liveAnalysis": "Analyse en direct",
|
||||
"landing.hero.termsDetected": "termes détectés",
|
||||
"landing.steps.process": "PROCESSUS",
|
||||
"landing.translate.newProject": "Nouveau projet",
|
||||
"landing.translate.title": "Traduire un document",
|
||||
"landing.translate.subtitle": "Importez un fichier et choisissez la langue cible",
|
||||
"landing.translate.sourceDocument": "Document source",
|
||||
"landing.translate.configuration": "Configuration",
|
||||
"landing.translate.sourceLang": "Langue source",
|
||||
"landing.translate.targetLang": "Langue cible",
|
||||
"landing.translate.provider": "Fournisseur",
|
||||
"landing.translate.startTranslation": "Lancer la traduction",
|
||||
"landing.translate.zeroRetention": "Rétention zéro",
|
||||
"landing.translate.filesDeleted": "Fichiers supprimés après traitement",
|
||||
"landing.translate.dropHere": "Glissez-déposez ici",
|
||||
"landing.translate.supportedFormats": "Fichiers DOCX, XLSX, PPTX ou PDF supportés",
|
||||
"landing.translate.aiAnalysis": "Analyse IA Active",
|
||||
"landing.translate.processing": "Traitement en cours",
|
||||
"landing.translate.preservingLayout": "Votre mise en page est en cours de préservation",
|
||||
},
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SPANISH (es)
|
||||
@@ -2164,6 +2202,22 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
"landing.hero.liveAnalysis": "Análisis en vivo",
|
||||
"landing.hero.termsDetected": "términos detectados",
|
||||
"landing.steps.process": "PROCESO",
|
||||
"landing.translate.newProject": "Nuevo proyecto",
|
||||
"landing.translate.title": "Traducir un documento",
|
||||
"landing.translate.subtitle": "Importa un archivo y elige el idioma de destino",
|
||||
"landing.translate.sourceDocument": "Documento fuente",
|
||||
"landing.translate.configuration": "Configuración",
|
||||
"landing.translate.sourceLang": "Idioma de origen",
|
||||
"landing.translate.targetLang": "Idioma de destino",
|
||||
"landing.translate.provider": "Proveedor",
|
||||
"landing.translate.startTranslation": "Iniciar traducción",
|
||||
"landing.translate.zeroRetention": "Retención cero",
|
||||
"landing.translate.filesDeleted": "Archivos eliminados tras el procesamiento",
|
||||
"landing.translate.dropHere": "Arrastra y suelta aquí",
|
||||
"landing.translate.supportedFormats": "Archivos DOCX, XLSX, PPTX o PDF compatibles",
|
||||
"landing.translate.aiAnalysis": "Análisis IA Activo",
|
||||
"landing.translate.processing": "Procesando",
|
||||
"landing.translate.preservingLayout": "Tu diseño se está preservando",
|
||||
},
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// GERMAN (de)
|
||||
@@ -2845,6 +2899,22 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
"landing.hero.liveAnalysis": "Live-Analyse",
|
||||
"landing.hero.termsDetected": "Begriffe erkannt",
|
||||
"landing.steps.process": "PROZESS",
|
||||
"landing.translate.newProject": "Neues Projekt",
|
||||
"landing.translate.title": "Dokument übersetzen",
|
||||
"landing.translate.subtitle": "Datei importieren und Zielsprache wählen",
|
||||
"landing.translate.sourceDocument": "Quelldokument",
|
||||
"landing.translate.configuration": "Konfiguration",
|
||||
"landing.translate.sourceLang": "Quellsprache",
|
||||
"landing.translate.targetLang": "Zielsprache",
|
||||
"landing.translate.provider": "Anbieter",
|
||||
"landing.translate.startTranslation": "Übersetzung starten",
|
||||
"landing.translate.zeroRetention": "Keine Speicherung",
|
||||
"landing.translate.filesDeleted": "Dateien nach Verarbeitung gelöscht",
|
||||
"landing.translate.dropHere": "Hier ablegen",
|
||||
"landing.translate.supportedFormats": "DOCX, XLSX, PPTX oder PDF Dateien unterstützt",
|
||||
"landing.translate.aiAnalysis": "KI-Analyse aktiv",
|
||||
"landing.translate.processing": "Verarbeitung läuft",
|
||||
"landing.translate.preservingLayout": "Ihr Layout wird beibehalten",
|
||||
},
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// PORTUGUESE – BRAZILIAN (pt)
|
||||
@@ -3526,6 +3596,22 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
"landing.hero.liveAnalysis": "Análise em tempo real",
|
||||
"landing.hero.termsDetected": "termos detectados",
|
||||
"landing.steps.process": "PROCESSO",
|
||||
"landing.translate.newProject": "Novo projeto",
|
||||
"landing.translate.title": "Traduzir um documento",
|
||||
"landing.translate.subtitle": "Importe um arquivo e escolha o idioma de destino",
|
||||
"landing.translate.sourceDocument": "Documento fonte",
|
||||
"landing.translate.configuration": "Configuração",
|
||||
"landing.translate.sourceLang": "Idioma de origem",
|
||||
"landing.translate.targetLang": "Idioma de destino",
|
||||
"landing.translate.provider": "Fornecedor",
|
||||
"landing.translate.startTranslation": "Iniciar tradução",
|
||||
"landing.translate.zeroRetention": "Retenção zero",
|
||||
"landing.translate.filesDeleted": "Ficheiros eliminados após processamento",
|
||||
"landing.translate.dropHere": "Arraste e solte aqui",
|
||||
"landing.translate.supportedFormats": "Ficheiros DOCX, XLSX, PPTX ou PDF suportados",
|
||||
"landing.translate.aiAnalysis": "Análise IA Ativa",
|
||||
"landing.translate.processing": "Em processamento",
|
||||
"landing.translate.preservingLayout": "O seu layout está a ser preservado",
|
||||
},
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ITALIAN (it)
|
||||
@@ -4207,6 +4293,22 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
"landing.hero.liveAnalysis": "Analisi in tempo reale",
|
||||
"landing.hero.termsDetected": "termini rilevati",
|
||||
"landing.steps.process": "PROCESSO",
|
||||
"landing.translate.newProject": "Nuovo progetto",
|
||||
"landing.translate.title": "Traduci un documento",
|
||||
"landing.translate.subtitle": "Importa un file e scegli la lingua di destinazione",
|
||||
"landing.translate.sourceDocument": "Documento sorgente",
|
||||
"landing.translate.configuration": "Configurazione",
|
||||
"landing.translate.sourceLang": "Lingua sorgente",
|
||||
"landing.translate.targetLang": "Lingua di destinazione",
|
||||
"landing.translate.provider": "Fornitore",
|
||||
"landing.translate.startTranslation": "Avvia traduzione",
|
||||
"landing.translate.zeroRetention": "Zero conservazione",
|
||||
"landing.translate.filesDeleted": "File eliminati dopo l'elaborazione",
|
||||
"landing.translate.dropHere": "Trascina e rilascia qui",
|
||||
"landing.translate.supportedFormats": "File DOCX, XLSX, PPTX o PDF supportati",
|
||||
"landing.translate.aiAnalysis": "Analisi IA Attiva",
|
||||
"landing.translate.processing": "Elaborazione in corso",
|
||||
"landing.translate.preservingLayout": "Il tuo layout viene preservato",
|
||||
},
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// DUTCH (nl)
|
||||
@@ -4888,6 +4990,22 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
"landing.hero.liveAnalysis": "Live-analyse",
|
||||
"landing.hero.termsDetected": "termen gedetecteerd",
|
||||
"landing.steps.process": "PROCES",
|
||||
"landing.translate.newProject": "Nieuw project",
|
||||
"landing.translate.title": "Vertaal een document",
|
||||
"landing.translate.subtitle": "Importeer een bestand en kies de doeltaal",
|
||||
"landing.translate.sourceDocument": "Brondocument",
|
||||
"landing.translate.configuration": "Configuratie",
|
||||
"landing.translate.sourceLang": "Brontaal",
|
||||
"landing.translate.targetLang": "Doeltaal",
|
||||
"landing.translate.provider": "Provider",
|
||||
"landing.translate.startTranslation": "Vertaling starten",
|
||||
"landing.translate.zeroRetention": "Nul retentie",
|
||||
"landing.translate.filesDeleted": "Bestanden verwijderd na verwerking",
|
||||
"landing.translate.dropHere": "Sleep en zet hier neer",
|
||||
"landing.translate.supportedFormats": "DOCX, XLSX, PPTX of PDF bestanden ondersteund",
|
||||
"landing.translate.aiAnalysis": "Actieve AI-analyse",
|
||||
"landing.translate.processing": "Verwerking bezig",
|
||||
"landing.translate.preservingLayout": "Uw opmaak wordt behouden",
|
||||
},
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// RUSSIAN (ru)
|
||||
@@ -5571,6 +5689,22 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
"landing.hero.liveAnalysis": "Анализ в реальном времени",
|
||||
"landing.hero.termsDetected": "терминов обнаружено",
|
||||
"landing.steps.process": "ПРОЦЕСС",
|
||||
"landing.translate.newProject": "Новый проект",
|
||||
"landing.translate.title": "Перевести документ",
|
||||
"landing.translate.subtitle": "Импортируйте файл и выберите целевой язык",
|
||||
"landing.translate.sourceDocument": "Исходный документ",
|
||||
"landing.translate.configuration": "Настройки",
|
||||
"landing.translate.sourceLang": "Исходный язык",
|
||||
"landing.translate.targetLang": "Целевой язык",
|
||||
"landing.translate.provider": "Провайдер",
|
||||
"landing.translate.startTranslation": "Начать перевод",
|
||||
"landing.translate.zeroRetention": "Нулевое хранение",
|
||||
"landing.translate.filesDeleted": "Файлы удаляются после обработки",
|
||||
"landing.translate.dropHere": "Перетащите сюда",
|
||||
"landing.translate.supportedFormats": "Поддерживаются файлы DOCX, XLSX, PPTX или PDF",
|
||||
"landing.translate.aiAnalysis": "Активный ИИ-анализ",
|
||||
"landing.translate.processing": "Обработка",
|
||||
"landing.translate.preservingLayout": "Ваше форматирование сохраняется",
|
||||
},
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// JAPANESE (ja)
|
||||
@@ -6251,6 +6385,22 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
"landing.hero.liveAnalysis": "リアルタイム分析",
|
||||
"landing.hero.termsDetected": "件の用語を検出",
|
||||
"landing.steps.process": "プロセス",
|
||||
"landing.translate.newProject": "新規プロジェクト",
|
||||
"landing.translate.title": "ドキュメントを翻訳",
|
||||
"landing.translate.subtitle": "ファイルをインポートして翻訳先言語を選択",
|
||||
"landing.translate.sourceDocument": "ソースドキュメント",
|
||||
"landing.translate.configuration": "設定",
|
||||
"landing.translate.sourceLang": "翻訳元言語",
|
||||
"landing.translate.targetLang": "翻訳先言語",
|
||||
"landing.translate.provider": "プロバイダー",
|
||||
"landing.translate.startTranslation": "翻訳を開始",
|
||||
"landing.translate.zeroRetention": "ゼロ保持",
|
||||
"landing.translate.filesDeleted": "処理後にファイルを削除",
|
||||
"landing.translate.dropHere": "ここにドラッグ&ドロップ",
|
||||
"landing.translate.supportedFormats": "DOCX, XLSX, PPTX, PDFファイル対応",
|
||||
"landing.translate.aiAnalysis": "AI分析アクティブ",
|
||||
"landing.translate.processing": "処理中",
|
||||
"landing.translate.preservingLayout": "レイアウトを保持しています",
|
||||
},
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// KOREAN (ko)
|
||||
@@ -6931,6 +7081,22 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
"landing.hero.liveAnalysis": "실시간 분석",
|
||||
"landing.hero.termsDetected": "개 용어 감지됨",
|
||||
"landing.steps.process": "프로세스",
|
||||
"landing.translate.newProject": "새 프로젝트",
|
||||
"landing.translate.title": "문서 번역",
|
||||
"landing.translate.subtitle": "파일을 가져오고 대상 언어를 선택하세요",
|
||||
"landing.translate.sourceDocument": "원본 문서",
|
||||
"landing.translate.configuration": "설정",
|
||||
"landing.translate.sourceLang": "원본 언어",
|
||||
"landing.translate.targetLang": "대상 언어",
|
||||
"landing.translate.provider": "제공자",
|
||||
"landing.translate.startTranslation": "번역 시작",
|
||||
"landing.translate.zeroRetention": "제로 보관",
|
||||
"landing.translate.filesDeleted": "처리 후 파일 삭제됨",
|
||||
"landing.translate.dropHere": "여기에 드래그 앤 드롭",
|
||||
"landing.translate.supportedFormats": "DOCX, XLSX, PPTX 또는 PDF 파일 지원",
|
||||
"landing.translate.aiAnalysis": "AI 분석 활성",
|
||||
"landing.translate.processing": "처리 중",
|
||||
"landing.translate.preservingLayout": "레이아웃이 보존되고 있습니다",
|
||||
},
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CHINESE SIMPLIFIED (zh)
|
||||
@@ -7569,6 +7735,22 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
"landing.hero.liveAnalysis": "实时分析",
|
||||
"landing.hero.termsDetected": "个术语已检测",
|
||||
"landing.steps.process": "流程",
|
||||
"landing.translate.newProject": "新项目",
|
||||
"landing.translate.title": "翻译文档",
|
||||
"landing.translate.subtitle": "导入文件并选择目标语言",
|
||||
"landing.translate.sourceDocument": "源文档",
|
||||
"landing.translate.configuration": "配置",
|
||||
"landing.translate.sourceLang": "源语言",
|
||||
"landing.translate.targetLang": "目标语言",
|
||||
"landing.translate.provider": "提供商",
|
||||
"landing.translate.startTranslation": "开始翻译",
|
||||
"landing.translate.zeroRetention": "零保留",
|
||||
"landing.translate.filesDeleted": "处理后删除文件",
|
||||
"landing.translate.dropHere": "拖放到此处",
|
||||
"landing.translate.supportedFormats": "支持 DOCX, XLSX, PPTX 或 PDF 文件",
|
||||
"landing.translate.aiAnalysis": "AI 分析中",
|
||||
"landing.translate.processing": "正在处理",
|
||||
"landing.translate.preservingLayout": "正在保留您的排版",
|
||||
},
|
||||
|
||||
// ARABIC (ar)
|
||||
@@ -8207,6 +8389,22 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
"landing.hero.liveAnalysis": "تحليل مباشر",
|
||||
"landing.hero.termsDetected": "مصطلحات مكتشفة",
|
||||
"landing.steps.process": "العملية",
|
||||
"landing.translate.newProject": "مشروع جديد",
|
||||
"landing.translate.title": "ترجمة مستند",
|
||||
"landing.translate.subtitle": "استورد ملفًا واختر اللغة المستهدفة",
|
||||
"landing.translate.sourceDocument": "المستند المصدر",
|
||||
"landing.translate.configuration": "الإعدادات",
|
||||
"landing.translate.sourceLang": "لغة المصدر",
|
||||
"landing.translate.targetLang": "اللغة المستهدفة",
|
||||
"landing.translate.provider": "المزود",
|
||||
"landing.translate.startTranslation": "بدء الترجمة",
|
||||
"landing.translate.zeroRetention": "صفر احتفاظ",
|
||||
"landing.translate.filesDeleted": "الملفات محذوفة بعد المعالجة",
|
||||
"landing.translate.dropHere": "اسحب وأفلت هنا",
|
||||
"landing.translate.supportedFormats": "ملفات DOCX, XLSX, PPTX أو PDF مدعومة",
|
||||
"landing.translate.aiAnalysis": "تحليل AI نشط",
|
||||
"landing.translate.processing": "جاري المعالجة",
|
||||
"landing.translate.preservingLayout": "جاري الحفاظ على التنسيق",
|
||||
},
|
||||
|
||||
// PERSIAN (fa)
|
||||
@@ -8881,6 +9079,22 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
"landing.hero.liveAnalysis": "تحلیل زنده",
|
||||
"landing.hero.termsDetected": "اصطلاح شناسایی شد",
|
||||
"landing.steps.process": "فرآیند",
|
||||
"landing.translate.newProject": "پروژه جدید",
|
||||
"landing.translate.title": "ترجمه سند",
|
||||
"landing.translate.subtitle": "فایل را وارد کنید و زبان مقصد را انتخاب کنید",
|
||||
"landing.translate.sourceDocument": "سند منبع",
|
||||
"landing.translate.configuration": "پیکربندی",
|
||||
"landing.translate.sourceLang": "زبان منبع",
|
||||
"landing.translate.targetLang": "زبان مقصد",
|
||||
"landing.translate.provider": "ارائهدهنده",
|
||||
"landing.translate.startTranslation": "شروع ترجمه",
|
||||
"landing.translate.zeroRetention": "صفر نگهداری",
|
||||
"landing.translate.filesDeleted": "فایلها پس از پردازش حذف میشوند",
|
||||
"landing.translate.dropHere": "اینجا بکشید و رها کنید",
|
||||
"landing.translate.supportedFormats": "فایلهای DOCX, XLSX, PPTX یا PDF پشتیبانی میشوند",
|
||||
"landing.translate.aiAnalysis": "تحلیل AI فعال",
|
||||
"landing.translate.processing": "در حال پردازش",
|
||||
"landing.translate.preservingLayout": "طرحبندی شما حفظ میشود",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user