fix(ui): design critique overhaul — a11y, honest metrics, i18n repair (critique 2026-08-30)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m51s

P0 a11y: keyboard-accessible dropzone (role/tabIndex/Enter-Space), ARIA
combobox + listbox pattern for language selector, role=switch on glossary
toggle, role=status live region on notifications, aria-labels on password
toggles, visible-on-focus close buttons, RTL logical positioning (start-*).

Trust: third-party Memento promo removed from translate page, sidebar and
all 13 locales; fabricated stats (99.9%, Turbo, computed layout-integrity
bars) replaced with real measurements incl. API estimated remaining time;
silent download failure now surfaces an error notification.

Honesty: fake 100-byte file injections removed (format chips are now
informational); cancel-that-doesn't renamed 'Back to start' with hint;
Enterprise contact placeholder replaced with contact@wordly.art.

i18n: t() no longer returns raw keys (empty string + defaultValue support,
~30 dead || fallbacks now work); ~170 new keys EN+FR across new reviews/
teams namespaces, glossaries context tab, translate monitor, settings,
services, pricing, landing, fileUploader; split-key italic titles replace
lastIndexOf() surgery (zh/ja-safe); key-audit script added 0 missing.

Flow: active job persisted across refresh with polling resume (24h TTL);
client-side recent-jobs history with review links; review page linked from
complete state; settings/services added to dashboard nav; Business/
Enterprise regain glossary access (tier gate unified).

Typeset (sober-tool direction): 7.5-9px labels raised to 10-12px, /30
opacity to /45-/55, uppercase tracking reduced, trust footer legible,
country flags removed from language switcher, localized dates.

Cleanup: 5 orphaned translate components, dead site header/footer,
fossil tailwind.config.js, PipelineStepper, duplicate pill+H1 titles,
two-step confirm for cache clear, dead landing footer links.

Verified: next build exit 0, vitest 9/9, eslint 64 errors = HEAD
(no regression, -3 warnings), detector 4 -> 3 findings.
This commit is contained in:
2026-08-30 21:44:53 +02:00
parent 1a67241ad5
commit 50047ea8a2
79 changed files with 878 additions and 1590 deletions

View File

@@ -16,6 +16,7 @@ import { apiClient, API_BASE_URL } from '@/lib/apiClient';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/components/ui/toast';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
interface Segment {
@@ -48,9 +49,9 @@ function authHeaders(): Record<string, string> {
return headers;
}
async function downloadProtected(url: string, filename: string) {
async function downloadProtected(url: string, filename: string, failMessage: (status: number) => string) {
const res = await fetch(`${API_BASE_URL}${url}`, { headers: authHeaders() });
if (!res.ok) throw new Error(`Téléchargement échoué (HTTP ${res.status})`);
if (!res.ok) throw new Error(failMessage(res.status));
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -66,6 +67,7 @@ export default function ReviewPage() {
const params = useParams<{ jobId: string }>();
const jobId = params?.jobId ?? '';
const notify = useToast();
const { t } = useI18n();
const [segments, setSegments] = useState<Segment[]>([]);
const [fileName, setFileName] = useState<string | null>(null);
@@ -88,7 +90,7 @@ export default function ReviewPage() {
setSegments(res.data.segments);
setFileName(res.data.file_name);
} catch (err) {
setLoadError(err instanceof Error ? err.message : 'Erreur de chargement');
setLoadError(err instanceof Error ? err.message : t('reviews.error.load'));
} finally {
setIsLoading(false);
}
@@ -125,8 +127,8 @@ export default function ReviewPage() {
);
} catch (err) {
notify.error({
title: 'Erreur',
description: err instanceof Error ? err.message : 'Mise à jour impossible',
title: t('reviews.error.title'),
description: err instanceof Error ? err.message : t('reviews.error.update'),
});
} finally {
setSavingId(null);
@@ -140,8 +142,8 @@ export default function ReviewPage() {
await patchSegment(seg.id, { status: 'approved' });
}
notify.success({
title: 'Segments approuvés',
description: `${pending.length} segment(s) approuvé(s).`,
title: t('reviews.approvedTitle'),
description: t('reviews.approvedDesc', { count: pending.length }),
});
};
@@ -153,15 +155,16 @@ export default function ReviewPage() {
);
await downloadProtected(
res.data.download_url,
`relu_${fileName ?? jobId}`
`relu_${fileName ?? jobId}`,
(status) => t('reviews.error.download', { status })
);
notify.success({
title: 'Document reconstruit',
description: `${res.data.segments_applied} segment(s) relu(s) appliqué(s).`,
title: t('reviews.rebuiltTitle'),
description: t('reviews.rebuiltDesc', { count: res.data.segments_applied }),
});
} catch (err) {
notify.error({
title: 'Reconstruction impossible',
title: t('reviews.rebuildFailed'),
description: err instanceof Error ? err.message : undefined,
});
} finally {
@@ -173,11 +176,12 @@ export default function ReviewPage() {
try {
await downloadProtected(
`/api/v1/translations/${jobId}/xliff`,
`${jobId}.xliff`
`${jobId}.xliff`,
(status) => t('reviews.error.download', { status })
);
} catch (err) {
notify.error({
title: 'Export XLIFF échoué',
title: t('reviews.xliffExportFailed'),
description: err instanceof Error ? err.message : undefined,
});
}
@@ -203,13 +207,13 @@ export default function ReviewPage() {
}
const json = await res.json();
notify.success({
title: 'XLIFF importé',
description: `${json.data.segments_updated} segment(s) mis à jour.`,
title: t('reviews.xliffImported'),
description: t('reviews.xliffImportedDesc', { count: json.data.segments_updated }),
});
await load();
} catch (err) {
notify.error({
title: 'Import XLIFF échoué',
title: t('reviews.xliffImportFailed'),
description: err instanceof Error ? err.message : undefined,
});
} finally {
@@ -220,25 +224,30 @@ export default function ReviewPage() {
const statusBadge = (status: Segment['status']) => {
if (status === 'approved')
return <Badge className="bg-green-500/15 text-green-600 border-green-500/30">Approuvé</Badge>;
return <Badge className="bg-green-500/15 text-green-600 border-green-500/30">{t('reviews.status.approved')}</Badge>;
if (status === 'edited')
return <Badge className="bg-blue-500/15 text-blue-600 border-blue-500/30">Modifié</Badge>;
return <Badge variant="outline" className="text-muted-foreground">À relire</Badge>;
return <Badge className="bg-blue-500/15 text-blue-600 border-blue-500/30">{t('reviews.status.edited')}</Badge>;
return <Badge variant="outline" className="text-muted-foreground">{t('reviews.status.pending')}</Badge>;
};
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-foreground">Relecture</h1>
<h1 className="text-xl font-semibold text-foreground">{t('reviews.title')}</h1>
<p className="text-sm text-muted-foreground truncate max-w-xl">
{fileName ?? jobId} {counts.total} segments ({counts.pending} à
relire, {counts.approved} approuvés, {counts.edited} modifiés)
{t('reviews.subtitle', {
file: fileName ?? jobId,
total: counts.total,
pending: counts.pending,
approved: counts.approved,
edited: counts.edited,
})}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={approveAll} disabled={counts.pending === 0}>
<CheckCheck className="size-3.5" /> Tout approuver
<CheckCheck className="size-3.5" /> {t('reviews.approveAll')}
</Button>
<Button variant="outline" size="sm" onClick={exportXliff} disabled={!segments.length}>
<FileDown className="size-3.5" /> XLIFF
@@ -250,7 +259,7 @@ export default function ReviewPage() {
disabled={isImporting}
>
{isImporting ? <Loader2 className="size-3.5 animate-spin" /> : <FileUp className="size-3.5" />}
Importer XLIFF
{t('reviews.importXliff')}
</Button>
<input
ref={fileInputRef}
@@ -264,7 +273,7 @@ export default function ReviewPage() {
/>
<Button size="sm" onClick={rebuild} disabled={isRebuilding || counts.approved + counts.edited === 0}>
{isRebuilding ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
Reconstruire et télécharger
{t('reviews.rebuild')}
</Button>
</div>
</div>
@@ -284,9 +293,9 @@ export default function ReviewPage() {
<table className="w-full text-sm">
<thead className="bg-muted/50 text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="w-1/2 px-4 py-2.5 font-medium">Source</th>
<th className="w-1/2 px-4 py-2.5 font-medium">Traduction</th>
<th className="w-40 px-4 py-2.5 font-medium">Statut</th>
<th className="w-1/2 px-4 py-2.5 font-medium">{t('reviews.col.source')}</th>
<th className="w-1/2 px-4 py-2.5 font-medium">{t('reviews.col.target')}</th>
<th className="w-40 px-4 py-2.5 font-medium">{t('reviews.col.status')}</th>
</tr>
</thead>
<tbody>
@@ -325,14 +334,14 @@ export default function ReviewPage() {
) : (
<Check className="size-3.5" />
)}
Enregistrer
{t('reviews.save')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setEditingId(null)}
>
Annuler
{t('reviews.cancel')}
</Button>
</div>
</div>
@@ -343,7 +352,7 @@ export default function ReviewPage() {
seg.status === 'edited' && 'text-blue-600 dark:text-blue-400'
)}
>
{finalText || <span className="italic text-muted-foreground"> vide </span>}
{finalText || <span className="italic text-muted-foreground">{t('reviews.emptyCell')}</span>}
</div>
)}
</td>
@@ -356,7 +365,7 @@ export default function ReviewPage() {
size="sm"
variant="ghost"
className="h-7 px-2"
title="Modifier la traduction"
title={t('reviews.action.edit')}
onClick={() => {
setEditingId(seg.id);
setDraft(finalText);
@@ -368,7 +377,7 @@ export default function ReviewPage() {
size="sm"
variant="ghost"
className="h-7 px-2"
title="Approuver tel quel"
title={t('reviews.action.approve')}
disabled={seg.status === 'approved' || savingId === seg.id}
onClick={() => patchSegment(seg.id, { status: 'approved' })}
>
@@ -378,7 +387,7 @@ export default function ReviewPage() {
size="sm"
variant="ghost"
className="h-7 px-2"
title="Remettre à relire"
title={t('reviews.action.reset')}
disabled={seg.status === 'pending'}
onClick={() => patchSegment(seg.id, { status: 'pending' })}
>