fix(ui): critical translation workflow improvements
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2s

- Extracted Translate button to sticky bottom wrapper (always visible)
- Added humanFriendlyError mapper for readable error messages
- Added 'Réessayer' and 'Nouveau fichier' buttons to FAILED state
- Hid GlossarySelector for non-Pro users
- Fixed silent failure: attempted=0 now fails explicitly
This commit is contained in:
2026-05-17 16:54:59 +02:00
parent 3e41bee470
commit 669cf7fde8
2 changed files with 166 additions and 85 deletions

View File

@@ -81,12 +81,36 @@ export default function TranslatePage() {
const isPdf = upload.file?.name.toLowerCase().endsWith('.pdf') ?? false;
/* ── Human-friendly error messages ──────────────────────────── */
const humanFriendlyError = (raw: string | null): string => {
if (!raw) return 'Une erreur inattendue est survenue. Réessayez.';
const r = raw.toLowerCase();
if (r.includes('0 out of') || r.includes('0 textes') || r.includes('0 texts'))
return "La traduction n'a produit aucun résultat. Vérifiez que le document contient du texte, puis réessayez ou choisissez un autre moteur.";
if (r.includes('api key') || r.includes('api_key') || r.includes('unauthorized') || r.includes('401'))
return "Clé API invalide ou manquante. Contactez l'administrateur pour configurer les clés.";
if (r.includes('quota') || r.includes('rate limit') || r.includes('429'))
return "Limite d'utilisation atteinte. Réessayez dans quelques minutes ou choisissez un autre moteur.";
if (r.includes('timeout') || r.includes('timed out') || r.includes('connexion'))
return "La connexion au service de traduction a expiré. Vérifiez votre réseau et réessayez.";
if (r.includes('not found') || r.includes('404'))
return "La session a expiré. Cliquez sur Réessayer pour relancer la traduction.";
if (r.includes('empty') || r.includes('vide') || r.includes('no translatable'))
return "Le document semble vide ou ne contient pas de texte traduisible (PDF image ?).";
if (r.includes('unsupported') || r.includes('format'))
return "Format de fichier non supporté ou fichier corrompu.";
if (r.includes('lost connection') || r.includes('internet'))
return "Connexion perdue. Vérifiez votre réseau et réessayez.";
// Generic fallback — show raw but readable
return `Traduction échouée : ${raw.charAt(0).toUpperCase() + raw.slice(1)}`;
};
useEffect(() => {
if (submit.error && submit.error !== lastErrorRef.current) {
lastErrorRef.current = submit.error;
showError({ title: t('dashboard.translate.errorNotificationTitle'), description: submit.error });
showError({ title: 'Traduction échouée', description: humanFriendlyError(submit.error) });
}
}, [submit.error, showError, t]);
}, [submit.error, showError]);
// Elapsed timer
useEffect(() => {
@@ -106,6 +130,13 @@ export default function TranslatePage() {
await submit.submitTranslation(upload.file, cfg);
};
const handleRetry = async () => {
submit.reset();
// Small delay to ensure reset completes
await new Promise(r => setTimeout(r, 50));
await handleTranslate();
};
const handleNewTranslation = () => { submit.reset(); upload.removeFile(); setElapsed(0); };
const handleDownload = async () => {
if (!submit.jobId) return;
@@ -356,21 +387,46 @@ export default function TranslatePage() {
{/* ── 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>
{/* Error message — friendly language */}
<div className="rounded-[24px] bg-red-50 border-2 border-red-200 dark:bg-red-950/30 dark:border-red-800/40 p-6 mb-6" role="alert">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-2xl bg-red-100 dark:bg-red-900/40 flex items-center justify-center shrink-0">
<AlertTriangle className="size-5 text-red-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-black uppercase tracking-tight text-red-600 dark:text-red-400 mb-2">Traduction échouée</p>
<p className="text-sm text-red-600/80 dark:text-red-300/80 leading-relaxed">{humanFriendlyError(submit.error)}</p>
</div>
</div>
</div>
{/* File strip */}
{(submit.fileName || upload.file?.name) && upload.file && (
<div className="mt-6">
<div className="mb-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>
)}
{/* Action buttons */}
<div className="flex flex-col gap-3">
{upload.file && config.isConfigValid && (
<button
onClick={handleRetry}
className="premium-button w-full py-5 text-[12px] uppercase tracking-[0.25em] flex items-center justify-center gap-3 !rounded-2xl"
>
<RotateCcw size={18} />
Réessayer la traduction
</button>
)}
<button
onClick={handleNewTranslation}
className="w-full py-4 border-2 border-black/10 dark:border-white/10 rounded-2xl text-[11px] font-black uppercase tracking-[0.25em] text-brand-dark/50 dark:text-white/50 hover:text-brand-dark dark:hover:text-white hover:border-brand-dark/20 dark:hover:border-white/20 transition-all flex items-center justify-center gap-3"
>
<Upload size={16} />
Nouveau fichier
</button>
</div>
</div>
)}
</div>
@@ -383,95 +439,111 @@ export default function TranslatePage() {
{/* ── 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="editorial-card bg-white border-none shadow-editorial dark:bg-[#141414] overflow-hidden">
<div className="p-10 pb-6">
<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}
/>
<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}
/>
<ProviderSelector
provider={config.provider} onProviderChange={config.setProvider}
availableProviders={config.availableProviders} isLoadingProviders={config.isLoadingProviders}
isPro={config.isPro}
/>
<GlossarySelector
sourceLang={config.sourceLang}
targetLang={config.targetLang}
isPro={config.isPro}
glossaryId={config.glossaryId}
onChange={config.setGlossaryId}
disabled={submit.isSubmitting}
/>
{/* Glossary — Pro + LLM mode only */}
{config.isPro && config.mode === 'llm' && (
<GlossarySelector
sourceLang={config.sourceLang}
targetLang={config.targetLang}
isPro={config.isPro}
glossaryId={config.glossaryId}
onChange={config.setGlossaryId}
disabled={submit.isSubmitting}
/>
)}
{/* 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>
{/* 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>
</div>
)}
)}
</div>
</div>
{/* ── TRANSLATE BUTTON — sticky at bottom, always visible ── */}
<div className="sticky bottom-0 p-6 pt-4 bg-white dark:bg-[#141414] border-t border-black/5 dark:border-white/5">
<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'
'w-full py-5 rounded-2xl text-[13px] font-black uppercase tracking-[0.2em] flex items-center justify-center gap-3 transition-all duration-200',
config.isConfigValid && upload.file && !submit.isSubmitting
? 'bg-brand-dark text-white hover:bg-brand-dark/90 shadow-lg shadow-brand-dark/20 dark:bg-brand-accent dark:text-brand-dark dark:hover:bg-brand-accent/90'
: 'bg-black/5 dark:bg-white/5 text-brand-dark/25 dark:text-white/25 cursor-not-allowed'
)}
>
{submit.isSubmitting ? (
<><Loader2 className="size-5 animate-spin" />{t('dashboard.translate.actions.uploading')}</>
<><Loader2 className="size-5 animate-spin" />Envoi en cours...</>
) : (
<>{t('landing.translate.startTranslation')}<ArrowRight size={18} className="text-brand-accent" /></>
<><ArrowRight size={20} />Lancer la traduction</>
)}
</button>
{!upload.file && (
<p className="text-center text-[10px] text-brand-dark/30 dark:text-white/30 mt-2 font-bold uppercase tracking-widest"> Déposez d'abord un fichier</p>
)}
{upload.file && !config.targetLang && (
<p className="text-center text-[10px] text-brand-dark/30 dark:text-white/30 mt-2 font-bold uppercase tracking-widest">↑ Sélectionnez une langue cible</p>
)}
</div>
</div>