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
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:
@@ -10,7 +10,7 @@ import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUser } from '@/app/dashboard/useUser';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useI18n, formatDate } from '@/lib/i18n';
|
||||
import { useGlossaries } from './useGlossaries';
|
||||
import type { GlossaryListItem } from './types';
|
||||
import { ProUpgradePrompt } from './ProUpgradePrompt';
|
||||
@@ -28,19 +28,8 @@ const CONTEXT_SUGGESTIONS = [
|
||||
{ label: 'Style concis', value: 'Préfère des formulations courtes et directes. Évite les périphrases.' },
|
||||
];
|
||||
|
||||
function renderTitle(title: string) {
|
||||
const lastSpaceIndex = title.lastIndexOf(' ');
|
||||
if (lastSpaceIndex === -1) return title;
|
||||
return (
|
||||
<>
|
||||
{title.substring(0, lastSpaceIndex)}{' '}
|
||||
<span className="italic">{title.substring(lastSpaceIndex + 1)}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GlossariesPage() {
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const { data: user, isLoading: isLoadingUser } = useUser();
|
||||
const { glossaries, isLoading: isLoadingGlossaries } = useGlossaries();
|
||||
@@ -53,7 +42,8 @@ export default function GlossariesPage() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<'glossaries' | 'context'>('glossaries');
|
||||
|
||||
const isPro = user?.tier === 'pro';
|
||||
// Same tier logic as the sidebar: business/enterprise are Pro-grade plans.
|
||||
const isPro = ['pro', 'business', 'enterprise'].includes(user?.tier ?? '');
|
||||
const isLoading = isLoadingUser || isLoadingGlossaries;
|
||||
|
||||
const currentTargetLang = settings.defaultTargetLanguage;
|
||||
@@ -122,7 +112,7 @@ export default function GlossariesPage() {
|
||||
{t('glossaries.yourGlossaries') || 'Vos Glossaires'}
|
||||
</span>
|
||||
<h1 className="text-4xl md:text-5xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
|
||||
{renderTitle(t('glossaries.title') || 'Glossaires & Contexte')}
|
||||
{t('glossaries.titleBase')} <span className="italic">{t('glossaries.titleAccent')}</span>
|
||||
</h1>
|
||||
<p className="text-[#555555] dark:text-white/60 text-sm font-light leading-relaxed">
|
||||
{t('glossaries.description') || 'Gérez vos glossaires et instructions de contexte pour des traductions plus précises.'}
|
||||
@@ -134,7 +124,7 @@ export default function GlossariesPage() {
|
||||
className="shrink-0 flex items-center gap-2 px-5 py-2.5 rounded-xl bg-[#1A1A1A] hover:bg-[#333333] text-white text-xs font-bold uppercase tracking-widest transition-all"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Nouveau glossaire
|
||||
{t('glossaries.new')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -144,15 +134,14 @@ export default function GlossariesPage() {
|
||||
<Info size={13} />
|
||||
</div>
|
||||
<p className="text-[11px] text-[#2D2D2D] dark:text-white/70 font-medium leading-relaxed flex-1">
|
||||
<strong className="font-bold text-[#1A1A1A] dark:text-white">Pour utiliser un glossaire dans une traduction :</strong>{' '}
|
||||
cliquez sur <span className="font-bold text-[#7A5C35] dark:text-brand-accent">« Utiliser »</span> sur la carte souhaitée.
|
||||
Vous serez redirigé sur la page Traduire avec ce glossaire déjà sélectionné.
|
||||
<strong className="font-bold text-[#1A1A1A] dark:text-white">{t('glossaries.info.title')}</strong>{' '}
|
||||
{t('glossaries.info.body')}
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard/translate"
|
||||
className="shrink-0 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-[#7A5C35] dark:text-brand-accent hover:underline whitespace-nowrap"
|
||||
>
|
||||
Aller traduire <ArrowRight size={11} />
|
||||
{t('glossaries.info.cta')} <ArrowRight size={11} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -160,6 +149,8 @@ export default function GlossariesPage() {
|
||||
<div className="flex border-b border-black/10 dark:border-white/10 mb-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('glossaries')}
|
||||
aria-selected={activeTab === 'glossaries'}
|
||||
role="tab"
|
||||
className={cn(
|
||||
'pb-4 px-6 text-xs uppercase tracking-widest font-bold border-b-2 transition-all cursor-pointer',
|
||||
activeTab === 'glossaries'
|
||||
@@ -171,6 +162,8 @@ export default function GlossariesPage() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('context')}
|
||||
aria-selected={activeTab === 'context'}
|
||||
role="tab"
|
||||
className={cn(
|
||||
'pb-4 px-6 text-xs uppercase tracking-widest font-bold border-b-2 transition-all cursor-pointer',
|
||||
activeTab === 'context'
|
||||
@@ -196,10 +189,10 @@ export default function GlossariesPage() {
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-serif font-semibold text-[#1A1A1A] dark:text-white tracking-tight">
|
||||
Consignes de contexte
|
||||
{t('glossaries.context.title')}
|
||||
</h2>
|
||||
<p className="text-[11px] text-[#555555] dark:text-white/50 font-light mt-0.5">
|
||||
Ces instructions sont envoyées automatiquement à l'IA avant chaque traduction en mode Pro LLM.
|
||||
{t('glossaries.context.desc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,16 +200,16 @@ export default function GlossariesPage() {
|
||||
{promptHasUnsavedChanges ? (
|
||||
<span className="shrink-0 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-amber-700 bg-amber-100 dark:text-amber-400 dark:bg-amber-500/10 px-3 py-1.5 rounded-full border border-amber-300 dark:border-amber-500/20">
|
||||
<AlertCircle size={11} />
|
||||
Non sauvegardé
|
||||
{t('glossaries.context.unsaved')}
|
||||
</span>
|
||||
) : promptIsActive ? (
|
||||
<span className="shrink-0 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-emerald-700 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-500/10 px-3 py-1.5 rounded-full border border-emerald-300 dark:border-emerald-500/20">
|
||||
<CheckCircle2 size={11} />
|
||||
Actif
|
||||
{t('glossaries.context.active')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="shrink-0 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-[#555555] bg-[#EBEBEB] dark:text-white/30 dark:bg-white/5 px-3 py-1.5 rounded-full border border-[#CCCCCC] dark:border-white/10">
|
||||
Inactif
|
||||
{t('glossaries.context.inactive')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -226,7 +219,7 @@ export default function GlossariesPage() {
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Sparkles size={13} className="text-[#8B6F47]" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-[#555555] dark:text-white/40">
|
||||
Suggestions rapides — cliquez pour ajouter
|
||||
{t('glossaries.context.suggestions')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -246,7 +239,8 @@ export default function GlossariesPage() {
|
||||
<textarea
|
||||
value={systemPrompt}
|
||||
onChange={e => { setSystemPrompt(e.target.value); setPromptSaved(false); }}
|
||||
placeholder="Ex : Vous traduisez des documents techniques HVAC. Utilisez une terminologie d'ingénierie précise et un ton professionnel…"
|
||||
placeholder={t('glossaries.context.placeholder')}
|
||||
aria-label={t('glossaries.context.title')}
|
||||
className="w-full h-44 p-4 bg-[#FAFAF8] dark:bg-white/[0.02] rounded-xl border border-[#D9D6D0] dark:border-white/10 text-[#1A1A1A] dark:text-white text-xs leading-relaxed focus:ring-2 focus:ring-brand-accent/30 focus:border-brand-accent/50 transition-all outline-none resize-y placeholder:text-[#AAAAAA] dark:placeholder:text-white/25"
|
||||
/>
|
||||
|
||||
@@ -254,8 +248,8 @@ export default function GlossariesPage() {
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<p className="text-[10px] text-[#777777] dark:text-white/25 font-light">
|
||||
{systemPrompt.length > 0
|
||||
? `${systemPrompt.length} caractères`
|
||||
: 'Aucune consigne enregistrée'}
|
||||
? t('glossaries.context.chars', { count: systemPrompt.length })
|
||||
: t('glossaries.context.noPrompt')}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
@@ -264,7 +258,7 @@ export default function GlossariesPage() {
|
||||
className="px-5 py-2.5 bg-[#EBEBEB] dark:bg-white/5 text-[#444444] dark:text-white/40 rounded-lg text-xs font-bold uppercase tracking-wider hover:text-[#1A1A1A] hover:bg-[#E0E0E0] dark:hover:text-white transition-all cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 size={12} className="inline mr-1.5" />
|
||||
Tout effacer
|
||||
{t('glossaries.context.clear')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSavePrompt}
|
||||
@@ -272,10 +266,10 @@ export default function GlossariesPage() {
|
||||
className="premium-button px-8 py-2.5 text-xs uppercase tracking-widest !rounded-lg flex items-center gap-2 disabled:opacity-50 cursor-pointer font-bold"
|
||||
>
|
||||
{isSavingPrompt
|
||||
? <><Loader2 size={14} className="animate-spin" /> Enregistrement…</>
|
||||
? <><Loader2 size={14} className="animate-spin" /> {t('glossaries.context.saving')}</>
|
||||
: promptSaved
|
||||
? <><CheckCircle2 size={14} /> Enregistré</>
|
||||
: <><Save size={14} /> Enregistrer</>
|
||||
? <><CheckCircle2 size={14} /> {t('glossaries.context.saved')}</>
|
||||
: <><Save size={14} /> {t('glossaries.context.save')}</>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
@@ -296,13 +290,13 @@ export default function GlossariesPage() {
|
||||
</h2>
|
||||
<p className="text-[11px] text-[#555555] dark:text-white/40 font-light mt-1">
|
||||
{glossaries.length > 0
|
||||
? `${glossaries.length} glossaire${glossaries.length > 1 ? 's' : ''} — cliquez sur une carte pour la modifier`
|
||||
: 'Aucun glossaire — créez-en un pour commencer'}
|
||||
? t('glossaries.countMany', { count: glossaries.length })
|
||||
: t('glossaries.countNone')}
|
||||
</p>
|
||||
</div>
|
||||
{currentTargetInfo && (
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-bold text-[#444444] dark:text-white/40 bg-[#EBEBEB] dark:bg-white/5 border border-[#CCCCCC] dark:border-white/5 px-3 py-1.5 rounded-full shrink-0">
|
||||
<span>Traduction active :</span>
|
||||
<span>{t('glossaries.activeTarget')}</span>
|
||||
<span>{currentTargetInfo.flag} {currentTargetInfo.label}</span>
|
||||
</span>
|
||||
)}
|
||||
@@ -316,7 +310,8 @@ export default function GlossariesPage() {
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder="Rechercher un glossaire…"
|
||||
placeholder={t('glossaries.searchPlaceholder')}
|
||||
aria-label={t('glossaries.searchPlaceholder')}
|
||||
className="w-full pl-9 pr-3 py-2.5 text-xs rounded-lg border border-[#D9D6D0] dark:border-white/10 bg-white dark:bg-[#141414] text-[#1A1A1A] dark:text-white placeholder:text-[#AAAAAA] dark:placeholder:text-white/25 focus:outline-none focus:ring-2 focus:ring-brand-accent/20 focus:border-brand-accent/30"
|
||||
/>
|
||||
</div>
|
||||
@@ -339,13 +334,13 @@ export default function GlossariesPage() {
|
||||
className="inline-flex items-center gap-2 px-6 py-2.5 rounded-xl bg-[#1A1A1A] hover:bg-[#333333] text-white text-xs font-bold uppercase tracking-widest transition-all"
|
||||
>
|
||||
<Plus size={12} />
|
||||
Créer mon premier glossaire
|
||||
{t('glossaries.createFirst')}
|
||||
</Link>
|
||||
</div>
|
||||
) : filteredGlossaries.length === 0 ? (
|
||||
<div className="p-8 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl text-center">
|
||||
<p className="text-sm text-[#555555] dark:text-white/40 font-light">
|
||||
Aucun résultat pour « {searchQuery} »
|
||||
{t('glossaries.noResults', { query: searchQuery })}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -373,17 +368,22 @@ export default function GlossariesPage() {
|
||||
{/* Badge compatible / autre langue */}
|
||||
{matchesTarget && (
|
||||
<div className="absolute top-3 right-3 flex items-center gap-1 bg-emerald-100 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider border border-emerald-300 dark:border-emerald-500/20">
|
||||
<CheckCircle2 size={9} /> Compatible
|
||||
<CheckCircle2 size={9} /> {t('glossaries.badge.compatible')}
|
||||
</div>
|
||||
)}
|
||||
{mismatch && (
|
||||
<div className="absolute top-3 right-3 flex items-center gap-1 bg-amber-100 dark:bg-amber-500/10 text-amber-700 dark:text-amber-400 px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider border border-amber-300 dark:border-amber-500/20">
|
||||
<AlertCircle size={9} /> Autre langue
|
||||
<AlertCircle size={9} /> {t('glossaries.badge.otherLang')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Corps cliquable → page de détail */}
|
||||
<div className="cursor-pointer flex-1" onClick={() => router.push(`/dashboard/glossaries/${glossary.id}`)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/dashboard/glossaries/${glossary.id}`)}
|
||||
className="cursor-pointer flex-1 text-start"
|
||||
aria-label={`${t('glossaries.card.edit')} — ${glossary.name}`}
|
||||
>
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-[#F0EDE8] dark:bg-white/10 rounded-xl flex items-center justify-center text-[#8B6F47] shrink-0">
|
||||
<Library size={18} />
|
||||
@@ -405,14 +405,14 @@ export default function GlossariesPage() {
|
||||
<div className="flex justify-between items-center pb-4">
|
||||
<span className="flex items-center gap-1 text-xs font-semibold text-[#333333] dark:text-white/65">
|
||||
<Hash size={12} className="text-[#8B6F47] dark:text-brand-accent" />
|
||||
{termCount} terme{termCount > 1 ? 's' : ''}
|
||||
{termCount > 1 ? t('glossaries.termsMany', { count: termCount }) : t('glossaries.termsOne')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 font-mono text-[9px] text-[#666666] dark:text-white/40">
|
||||
<span className="flex items-center gap-1 font-mono text-[10px] text-[#666666] dark:text-white/40">
|
||||
<Calendar size={11} />
|
||||
{new Date(glossary.created_at).toLocaleDateString('fr-FR')}
|
||||
{formatDate(new Date(glossary.created_at), locale)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-4 border-t border-[#F0EDE8] dark:border-white/10 mt-auto shrink-0">
|
||||
@@ -420,13 +420,13 @@ export default function GlossariesPage() {
|
||||
href={`/dashboard/glossaries/${glossary.id}`}
|
||||
className="flex-none px-3 py-2 rounded-lg bg-[#EBEBEB] dark:bg-white/5 hover:bg-[#E0D9D1] text-[#333333] dark:text-white/50 hover:text-[#1A1A1A] text-[10px] font-bold uppercase tracking-wider transition-all border border-[#CCCCCC] dark:border-white/10"
|
||||
>
|
||||
Éditer
|
||||
{t('glossaries.card.edit')}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/dashboard/translate?glossaryId=${glossary.id}`}
|
||||
className="flex-1 text-center py-2 rounded-lg bg-[#1A1A1A] hover:bg-[#333333] text-white text-[10px] font-bold uppercase tracking-wider transition-all flex items-center justify-center gap-1.5"
|
||||
>
|
||||
Utiliser <ArrowRight size={11} />
|
||||
{t('glossaries.card.use')} <ArrowRight size={11} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user