feat(ui): vague 4 — la zone de dépôt répond, l'or devient lisible, tarifs binaires, avant/après réel
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m58s

Zone de dépôt : le contour s'allume en doré et la carte s'agrandit
quand on la survole avec un fichier (l'information existait, elle
n'était jamais affichée).

Or lisible : nouveau jeton « or encre » (#8B6F47) pour les textes,
l'or clair reste aux traits, fonds et au mode sombre ; 52 textes dorés
convertis, pastille comprises ; plus aucun texte sous 10 px
(8,5 px → 11 px dans le sélecteur de moteurs).

Tarifs : le choix redevient binaire — les trois abonnements payants en
grandes cartes, le gratuit en ligne discrète au-dessus, le sur mesure
en ligne dorée dessous. Le tableau comparatif complet reste dessous.

Accueil : le mur de six cartes génériques est remplacé par un vrai
avant/après — une page « Cahier des charges — Traitement d'air » à
côté de sa traduction anglaise, structure identique au mot près
(terme technique en gras des deux côtés), sceau « Même mise en page,
mot pour mot » entre les deux, et trois preuves précises (SmartArt
reconstruits, séries de graphiques traduites, tables des matières
régénérées) à la place des promesses vagues.
This commit is contained in:
2026-08-30 23:27:53 +02:00
parent 9d5b5ce9c3
commit 3f45ccdf50
14 changed files with 286 additions and 86 deletions

View File

@@ -0,0 +1,65 @@
---
target: frontend/src (UI complète Wordly.art)
total_score: 28
max_score: 40
na_heuristics:
p0_count: 0
p1_count: 2
timestamp: 2026-08-30T21-23-40Z
slug: frontend-src
---
# Évaluation de design — Wordly.art (`frontend/src`) — 3ᵉ passage
Méthode : double agent isolé (A : agent_a56a818e · B : agent_fa9c4f90)
## Score de santé (10 règles d'ergonomie, 0-4 chacune)
| # | Règle | Note | Problème principal |
|---|-------|------|--------------------|
| 1 | L'utilisateur sait toujours où il en est | 3 | Aucun retour visuel quand on survole la zone de dépôt avec un fichier (`isDragOver` calculé dans `useFileUpload.ts:69-77` mais jamais affiché, `translate/page.tsx:359-415`) ; « Tout approuver » enchaîne les demandes sans progression (`reviews/[jobId]/page.tsx:159-167`). |
| 2 | Le produit parle la langue de ses utilisateurs | 3 | Restes de jargon : « Le job a été arrêté » (`fr/translate.json:102`), « Segments », « Moteur contextuel » — globalement bien vulgarisé (XLIFF expliqué). |
| 3 | L'utilisateur garde le contrôle | 3 | Retrait d'un membre d'équipe sans confirmation (`teams/page.tsx:195-205`) ; ailleurs excellent (annulation réelle, reprise après rafraîchissement). |
| 4 | Cohérence de l'ensemble | 2 | Page Glossaires en couleurs brutes hors système (`glossaries/page.tsx:93,115,130`), n'utilise pas l'en-tête commun `page-header.tsx` ; émojis ⚠️🎯🌐 dans les avertissements (`GlossarySelector.tsx:342,352,379`) ; achat de crédits sans confirmation alors que les abonnements en ont une. |
| 5 | Prévention des erreurs | 3 | Bouton bloqué sans explication quand langue source = langue cible (`useTranslationConfig.ts:237`, aides incomplètes `translate/page.tsx:617-622`). |
| 6 | Reconnaissance plutôt que mémoire | 3 | Récapitulatif pendant et après le traitement, historique récent avec liens de relecture. |
| 7 | Vitesse pour les utilisateurs intensifs | 3 | Ctrl+Entrée existe mais n'est jamais annoncé à l'écran ; pas de filtre « à relire uniquement » dans la table de relecture. |
| 8 | Sobriété visuelle | 2 | Micro-textes 8,5-10 px par centaines (`ProviderSelector.tsx:201`, `landing-page.tsx:278,350`) ; 5 plans × 5-10 fonctions par carte. |
| 9 | Récupération après erreur | 3 | Erreurs traduites en actions (`translate/page.tsx:91-112`), fichier conservé + Réessayer. |
| 10 | Aide disponible | 3 | Aides contextuelles réelles ; pas de centre d'aide. |
| **Total** | | **28/40** | **Palier « bonne interface » (28-35)** |
## Verdict de spécificité
**Revue** : l'accueil devient « parfois précieux » — le dessin animé du hero (`hero-visual.tsx`) est jugé « la meilleure pièce du site » : la promesse produit est dessinée, pas photographiée, avec le balayage doré « détail d'auteur ». L'outil Traduire est « la surface la plus authentique » (pipeline dédié, avertissements de glossaire, garde source = cible) ; la relecture parle « le vocabulaire de la localisation, pas du SaaS générique ». Restent deux plaques de modèle tout fait : le mur de 6 cartes de fonctionnalités de l'accueil, et les « signaux de confiance » de la page tarifs.
**Scan automatique** : 3 constats inchangés — les 2 Inter (choix assumé « outil sobre ») et le faux positif du rond de chargement.
## Ce qui marche
1. **L'état d'attente comme pièce maîtresse** : progression, phases nommées, temps restant estimé honnêtement, annulation serveur réelle, survie au rafraîchissement. « Peu de produits d'IA assument l'attente avec cette précision. »
2. **Le langage du métier assumé** : XLIFF expliqué, avertissements glossaire précis, garde anti source = cible.
3. **Les garde-fous des gestes irréversibles** : approbation en deux clics, confirmation avant paiement, confirmation de purge.
## Problèmes prioritaires
1. **[P1] La zone de dépôt est muette au survol.** Le premier geste du produit n'a aucun retour. → Afficher un contour doré et un léger agrandissement pendant le survol (`translate/page.tsx:361`).
2. **[P1] L'or des petits textes est illisible.** `#C5A17A` sur crème ≈ 2,3:1, et des textes à 8,5-10 px partout. → Assombrir l'or des textes à `#8B6F47`, plancher 11 px (`globals.css:196`, `ProviderSelector.tsx:201`).
3. **[P2] « Tout approuver » sans progression.** Sur 500 segments : 500 demandes enchaînées sans compteur ni arrêt. → Compteur dans le bouton + arrêt possible (`reviews/[jobId]/page.tsx:151-168`).
4. **[P2] Bouton bloqué sans raison quand source = cible.** → Ajouter l'explication manquante (`translate/page.tsx:617-622`).
5. **[P2] Page Glossaires hors système + sélecteur de glossaire trop chargé.** Couleurs brutes, pas d'en-tête commun ; dans le flux de traduction, le sélecteur empile 6 décisions (choix, filtre, avertissements, aperçu, ajout de terme). → Migrer la page aux couleurs du système ; réduire le sélecteur au choix + avertissements, l'ajout de terme derrière un lien dépliable (`glossaries/page.tsx:107`, `GlossarySelector.tsx:578-614`).
## Drapeaux rouges par persona
**Alex (expert pressé)** : le raccourci Ctrl+Entrée n'est annoncé nulle part ; pas de filtre « à relire uniquement » sur 800 segments ; les icônes du pipeline et les libellés d'étapes suivent deux seuils contradictoires (`translate/page.tsx:660` vs `:42`).
**Sam (accessibilité)** : or clair illisible + micro-textes ; sévérité des avertissements glossaire portée par des émojis et la seule couleur ; le bandeau d'annonce de la page tarifs n'est pas lu aux lecteurs d'écran (contrairement aux notifications).
**Jordan (premier usage)** : la mention légale à l'inscription n'est pas cliquable ; l'annulation réussie d'une traduction s'affiche en rouge comme une erreur ; membres d'équipe en codes illisibles.
## Observations mineures
Restes de replis français codés en dur (`translate/page.tsx:861,868,881`) ; le lien tarifs de l'accueil construit l'adresse avec le nom traduit du plan (cassera le jour où le nom est traduit) ; l'accueil montre 3 plans, la page tarifs 5 sans le gratuit en haut ; « document réservé libéré » formulation opaque ; double barre de défilement dans la liste des langues ; page « Réglages » sans aucun réglage ; bouton d'échange de langues mort mais visible ; titres par défaut en Inter alors que la charte repose sur le serif posé à la main ; clé dupliquée preserveLayout.
## Questions à se poser
1. La grille tarifaire doit-elle vraiment montrer les 5 plans en même temps ? Free et Enterprise en lignes secondaires rendraient la décision binaire.
2. Pourquoi l'ajout de termes existe-t-il à la fois dans le sélecteur de traduction ET dans la page glossaires ? Laquelle est la vraie maison du geste ?
3. Que gagnerait l'écran d'attente à montrer le document lui-même, comme le dessin animé de l'accueil — une continuité entre la promesse et l'exécution ?

View File

@@ -39,7 +39,7 @@ export function DashboardSidebar() {
<div className="px-6 py-2"> <div className="px-6 py-2">
<Link <Link
href="/pricing" href="/pricing"
className="text-[10px] font-black uppercase tracking-widest text-brand-accent hover:text-brand-dark dark:hover:text-white transition-colors" className="text-[10px] font-black uppercase tracking-widest text-brand-goldink dark:text-brand-accent hover:text-brand-dark dark:hover:text-white transition-colors"
> >
{t('dashboard.sidebar.upgradeToPro', { defaultValue: 'Passer Pro →' })} {t('dashboard.sidebar.upgradeToPro', { defaultValue: 'Passer Pro →' })}
</Link> </Link>
@@ -64,7 +64,7 @@ export function DashboardSidebar() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<span className="px-2.5 py-0.5 rounded-full border border-brand-accent/30 text-brand-accent text-[10px] font-black uppercase"> <span className="px-2.5 py-0.5 rounded-full border border-brand-accent/30 text-brand-goldink dark:text-brand-accent text-[10px] font-black uppercase">
{translateTier(t, user.tier)} {translateTier(t, user.tier)}
</span> </span>
</div> </div>

View File

@@ -272,7 +272,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
> >
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BookOpen size={14} className="text-brand-accent shrink-0" /> <BookOpen size={14} className="text-brand-goldink dark:text-brand-accent shrink-0" />
<span className="text-xs font-black uppercase tracking-[0.1em] text-brand-dark dark:text-white truncate"> <span className="text-xs font-black uppercase tracking-[0.1em] text-brand-dark dark:text-white truncate">
{t('translate.glossary.title') || 'Glossaire & Terminologie'} {t('translate.glossary.title') || 'Glossaire & Terminologie'}
</span> </span>
@@ -403,7 +403,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
onClick={() => setFilterByLang(!filterByLang)} onClick={() => setFilterByLang(!filterByLang)}
className={cn( className={cn(
"text-[10px] font-bold px-2 py-0.5 rounded transition-colors cursor-pointer", "text-[10px] font-bold px-2 py-0.5 rounded transition-colors cursor-pointer",
filterByLang ? "bg-brand-accent/10 text-brand-accent" : "bg-brand-dark/10 dark:bg-white/10 text-brand-dark/50 dark:text-white/50" filterByLang ? "bg-brand-accent/10 text-brand-goldink dark:text-brand-accent" : "bg-brand-dark/10 dark:bg-white/10 text-brand-dark/50 dark:text-white/50"
)} )}
> >
{filterByLang ? t('translate.glossary.active') || 'Actif' : t('translate.glossary.inactive') || 'Inactif'} {filterByLang ? t('translate.glossary.active') || 'Actif' : t('translate.glossary.inactive') || 'Inactif'}
@@ -431,7 +431,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
className={cn( className={cn(
"w-full text-left px-2.5 py-2 rounded-lg transition-colors flex items-center justify-between cursor-pointer", "w-full text-left px-2.5 py-2 rounded-lg transition-colors flex items-center justify-between cursor-pointer",
isSelected isSelected
? 'bg-brand-accent/5 text-brand-accent' ? 'bg-brand-accent/5 text-brand-goldink dark:text-brand-accent'
: 'hover:bg-brand-muted dark:hover:bg-white/5 text-brand-dark/70 dark:text-white/70' : 'hover:bg-brand-muted dark:hover:bg-white/5 text-brand-dark/70 dark:text-white/70'
)} )}
> >
@@ -441,7 +441,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
{flag} {g.target_language === 'multi' ? `🌐 ${t('translate.glossary.multilingual') || 'MULTILINGUE'}` : getFlag(g.target_language || targetLang)} {g.terms_count} {t('translate.glossary.terms') || 'termes'} {flag} {g.target_language === 'multi' ? `🌐 ${t('translate.glossary.multilingual') || 'MULTILINGUE'}` : getFlag(g.target_language || targetLang)} {g.terms_count} {t('translate.glossary.terms') || 'termes'}
</span> </span>
</div> </div>
{isSelected && <Check size={12} className="text-brand-accent shrink-0" />} {isSelected && <Check size={12} className="text-brand-goldink dark:text-brand-accent shrink-0" />}
</button> </button>
); );
})} })}
@@ -480,7 +480,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
className={cn( className={cn(
"w-full text-left px-2.5 py-2 rounded-lg transition-colors flex items-center justify-between cursor-pointer", "w-full text-left px-2.5 py-2 rounded-lg transition-colors flex items-center justify-between cursor-pointer",
isAlreadySelected isAlreadySelected
? 'bg-brand-accent/5 text-brand-accent' ? 'bg-brand-accent/5 text-brand-goldink dark:text-brand-accent'
: 'hover:bg-brand-muted dark:hover:bg-white/5 text-brand-dark/70 dark:text-white/70' : 'hover:bg-brand-muted dark:hover:bg-white/5 text-brand-dark/70 dark:text-white/70'
)} )}
> >
@@ -493,9 +493,9 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
</span> </span>
</div> </div>
{isImporting ? ( {isImporting ? (
<Loader2 size={12} className="animate-spin text-brand-accent shrink-0" /> <Loader2 size={12} className="animate-spin text-brand-goldink dark:text-brand-accent shrink-0" />
) : isAlreadySelected ? ( ) : isAlreadySelected ? (
<Check size={12} className="text-brand-accent shrink-0" /> <Check size={12} className="text-brand-goldink dark:text-brand-accent shrink-0" />
) : null} ) : null}
</button> </button>
); );
@@ -547,7 +547,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
{isLoadingDetail ? ( {isLoadingDetail ? (
<div className="flex items-center justify-center py-3 gap-1.5 text-xs text-brand-dark/40 dark:text-white/30 font-light"> <div className="flex items-center justify-center py-3 gap-1.5 text-xs text-brand-dark/40 dark:text-white/30 font-light">
<Loader2 size={12} className="animate-spin text-brand-accent" /> {t('translate.glossary.loading') || 'Chargement...'} <Loader2 size={12} className="animate-spin text-brand-goldink dark:text-brand-accent" /> {t('translate.glossary.loading') || 'Chargement...'}
</div> </div>
) : selectedGlossaryDetail?.terms && selectedGlossaryDetail.terms.length > 0 ? ( ) : selectedGlossaryDetail?.terms && selectedGlossaryDetail.terms.length > 0 ? (
<div className="grid grid-cols-1 gap-1 max-h-[120px] overflow-y-auto pr-1"> <div className="grid grid-cols-1 gap-1 max-h-[120px] overflow-y-auto pr-1">

View File

@@ -112,7 +112,7 @@ function Combobox({
)} )}
> >
<span className="truncate normal-case">{label || placeholder}</span> <span className="truncate normal-case">{label || placeholder}</span>
<ChevronDown className={cn('size-3.5 shrink-0 text-brand-accent transition-transform ms-2', open && 'rotate-180')} /> <ChevronDown className={cn('size-3.5 shrink-0 text-brand-goldink dark:text-brand-accent transition-transform ms-2', open && 'rotate-180')} />
</button> </button>
{open && ( {open && (
<div <div
@@ -145,12 +145,12 @@ function Combobox({
className={cn( className={cn(
'flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-xs font-bold uppercase tracking-wider transition-colors cursor-pointer', 'flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-xs font-bold uppercase tracking-wider transition-colors cursor-pointer',
value === lang.code value === lang.code
? 'bg-brand-accent/10 text-brand-accent' ? 'bg-brand-accent/10 text-brand-goldink dark:text-brand-accent'
: 'text-brand-dark/70 dark:text-white/70 hover:bg-brand-muted dark:hover:bg-white/5' : 'text-brand-dark/70 dark:text-white/70 hover:bg-brand-muted dark:hover:bg-white/5'
)} )}
> >
<span className="truncate">{lang.name}</span> <span className="truncate">{lang.name}</span>
{value === lang.code && <Check className="size-3.5 text-brand-accent shrink-0" />} {value === lang.code && <Check className="size-3.5 text-brand-goldink dark:text-brand-accent shrink-0" />}
</button> </button>
))} ))}
</div> </div>
@@ -213,7 +213,7 @@ export default function LanguageSelector({
className={cn( className={cn(
'flex size-7 items-center justify-center rounded-xl transition-all cursor-pointer', 'flex size-7 items-center justify-center rounded-xl transition-all cursor-pointer',
canSwap canSwap
? 'text-brand-dark/50 dark:text-white/50 hover:text-brand-accent hover:bg-brand-muted/50 dark:hover:bg-white/5' ? 'text-brand-dark/50 dark:text-white/50 hover:text-brand-goldink dark:text-brand-accent hover:bg-brand-muted/50 dark:hover:bg-white/5'
: 'cursor-not-allowed opacity-30' : 'cursor-not-allowed opacity-30'
)} )}
title={t('langSelector.swap')} title={t('langSelector.swap')}

View File

@@ -70,7 +70,7 @@ const LLM_THEMES_FACTORY = (t: (k: string) => string): Record<string, CardTheme>
const makeDefaultLlmTheme = (t: (k: string) => string): CardTheme => ({ const makeDefaultLlmTheme = (t: (k: string) => string): CardTheme => ({
badge: t('providerTheme.default.badge'), badge: t('providerTheme.default.badge'),
subBadge: t('providerTheme.default.subBadge'), subBadge: t('providerTheme.default.subBadge'),
accentClass: 'border-brand-accent/30 text-brand-accent bg-brand-accent/5', accentClass: 'border-brand-accent/30 text-brand-goldink dark:text-brand-accent bg-brand-accent/5',
glowClass: 'from-brand-accent/10 to-transparent', glowClass: 'from-brand-accent/10 to-transparent',
descriptionOverride: t('providerTheme.default.desc') descriptionOverride: t('providerTheme.default.desc')
}); });
@@ -118,7 +118,7 @@ export function ProviderSelector({
if (isLoadingProviders) { if (isLoadingProviders) {
return ( return (
<div className="flex items-center gap-2 text-sm text-brand-dark/50 dark:text-white/40 py-4"> <div className="flex items-center gap-2 text-sm text-brand-dark/50 dark:text-white/40 py-4">
<Loader2 className="size-4 animate-spin text-brand-accent" /> <Loader2 className="size-4 animate-spin text-brand-goldink dark:text-brand-accent" />
<span>{t('dashboard.translate.provider.loading')}</span> <span>{t('dashboard.translate.provider.loading')}</span>
</div> </div>
); );
@@ -152,7 +152,7 @@ export function ProviderSelector({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className={cn( <span className={cn(
"text-[10px] font-black uppercase tracking-tight truncate flex-1", "text-[10px] font-black uppercase tracking-tight truncate flex-1",
isSelected ? 'text-brand-accent' : 'text-brand-dark/60 dark:text-white/60' isSelected ? 'text-brand-goldink dark:text-brand-accent' : 'text-brand-dark/60 dark:text-white/60'
)}> )}>
{t('providerSelector.standard')} {t('providerSelector.standard')}
</span> </span>
@@ -188,7 +188,7 @@ export function ProviderSelector({
<div className="flex items-center justify-between mb-0.5"> <div className="flex items-center justify-between mb-0.5">
<span className={cn( <span className={cn(
"text-[9px] font-black uppercase tracking-tight truncate flex-1", "text-[9px] font-black uppercase tracking-tight truncate flex-1",
isSelected ? 'text-brand-accent' : 'text-brand-dark/40 dark:text-white/40' isSelected ? 'text-brand-goldink dark:text-brand-accent' : 'text-brand-dark/40 dark:text-white/40'
)}> )}>
{theme.badge} {theme.badge}
</span> </span>
@@ -198,7 +198,7 @@ export function ProviderSelector({
<span className="text-xs font-bold text-brand-dark dark:text-white block leading-none mb-0.5 truncate"> <span className="text-xs font-bold text-brand-dark dark:text-white block leading-none mb-0.5 truncate">
{displayLabel} {displayLabel}
</span> </span>
<span className="text-[8.5px] text-brand-dark/40 dark:text-white/45 uppercase font-bold block leading-none truncate"> <span className="text-[11px] text-brand-dark/40 dark:text-white/45 uppercase font-bold block leading-none truncate">
{theme.subBadge} {theme.subBadge}
</span> </span>
</button> </button>
@@ -238,7 +238,7 @@ export function ProviderSelector({
: 'text-brand-dark/50 dark:text-white/40 hover:text-brand-dark dark:hover:text-white' : 'text-brand-dark/50 dark:text-white/40 hover:text-brand-dark dark:hover:text-white'
)} )}
> >
<Sparkles className={cn("size-3", activeTab === 'llm' ? 'text-brand-accent' : 'text-brand-dark/35 dark:text-white/30')} /> <Sparkles className={cn("size-3", activeTab === 'llm' ? 'text-brand-goldink dark:text-brand-accent' : 'text-brand-dark/35 dark:text-white/30')} />
{t('dashboard.translate.provider.tabLLM')} {t('dashboard.translate.provider.tabLLM')}
{!isPro && <Lock className="size-2.5 opacity-60 ml-0.5" />} {!isPro && <Lock className="size-2.5 opacity-60 ml-0.5" />}
</button> </button>
@@ -279,7 +279,7 @@ export function ProviderSelector({
<p className="text-[10px] text-brand-dark/60 dark:text-white/60 leading-normal font-light"> <p className="text-[10px] text-brand-dark/60 dark:text-white/60 leading-normal font-light">
<span className="font-bold text-brand-dark dark:text-white">{meta?.labelOverride || activeP.label} : </span> <span className="font-bold text-brand-dark dark:text-white">{meta?.labelOverride || activeP.label} : </span>
{meta?.descriptionOverride || activeP.description} {meta?.descriptionOverride || activeP.description}
<span className="block mt-1 font-bold text-brand-accent">{t('providerSelector.costOne')}</span> <span className="block mt-1 font-bold text-brand-goldink dark:text-brand-accent">{t('providerSelector.costOne')}</span>
</p> </p>
) : null; ) : null;
})() })()
@@ -291,7 +291,7 @@ export function ProviderSelector({
<p className="text-[10px] text-brand-dark/60 dark:text-white/60 leading-normal font-light"> <p className="text-[10px] text-brand-dark/60 dark:text-white/60 leading-normal font-light">
<span className="font-bold text-brand-dark dark:text-white">{activeP.label} : </span> <span className="font-bold text-brand-dark dark:text-white">{activeP.label} : </span>
{theme.descriptionOverride} <span className="opacity-50 text-[9px] font-mono">({activeP.model || 'default'})</span> {theme.descriptionOverride} <span className="opacity-50 text-[9px] font-mono">({activeP.model || 'default'})</span>
<span className="block mt-1 font-bold text-brand-accent"> <span className="block mt-1 font-bold text-brand-goldink dark:text-brand-accent">
{activeP.id === 'openrouter_premium' ? t('providerSelector.costFive') : t('providerSelector.costOne')} {activeP.id === 'openrouter_premium' ? t('providerSelector.costFive') : t('providerSelector.costOne')}
</span> </span>
</p> </p>
@@ -305,7 +305,7 @@ export function ProviderSelector({
{/* Pro upgrade banner when llm is active and user is not pro */} {/* Pro upgrade banner when llm is active and user is not pro */}
{!isPro && activeTab === 'llm' && ( {!isPro && activeTab === 'llm' && (
<div className="p-3.5 rounded-xl border border-brand-accent/20 bg-brand-accent/[0.02] dark:bg-brand-accent/[0.01] text-center flex flex-col items-center gap-1.5"> <div className="p-3.5 rounded-xl border border-brand-accent/20 bg-brand-accent/[0.02] dark:bg-brand-accent/[0.01] text-center flex flex-col items-center gap-1.5">
<Sparkles className="size-3.5 text-brand-accent animate-pulse" /> <Sparkles className="size-3.5 text-brand-goldink dark:text-brand-accent animate-pulse" />
<span className="text-[11px] font-bold text-brand-dark dark:text-white leading-none"> <span className="text-[11px] font-bold text-brand-dark dark:text-white leading-none">
{t('dashboard.translate.provider.llmDivider')} {t('dashboard.translate.provider.llmDivider')}
</span> </span>

View File

@@ -358,7 +358,10 @@ export default function TranslatePage() {
{/* ── UPLOAD STATE: Editorial Dropzone ──────────────── */} {/* ── UPLOAD STATE: Editorial Dropzone ──────────────── */}
{showUpload && ( {showUpload && (
<div <div
className="relative bg-white border-2 border-dashed border-brand-accent/15 dark:border-white/10 rounded-[32px] p-12 flex flex-col items-center justify-center text-center group cursor-pointer hover:border-brand-accent/40 dark:hover:border-brand-accent/40 hover:bg-brand-muted/10 dark:hover:bg-brand-muted/5 transition-all shadow-editorial dark:bg-[#141414] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-accent/60 focus-visible:border-brand-accent/60" className={cn(
'relative bg-white border-2 border-dashed border-brand-accent/15 dark:border-white/10 rounded-[32px] p-12 flex flex-col items-center justify-center text-center group cursor-pointer hover:border-brand-accent/40 dark:hover:border-brand-accent/40 hover:bg-brand-muted/10 dark:hover:bg-brand-muted/5 transition-all shadow-editorial dark:bg-[#141414] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-accent/60 focus-visible:border-brand-accent/60',
upload.isDragOver && 'border-brand-accent bg-brand-accent/10 scale-[1.01] shadow-lg'
)}
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-label={t('translate.upload.ariaDropzone')} aria-label={t('translate.upload.ariaDropzone')}
@@ -378,7 +381,7 @@ export default function TranslatePage() {
{t('translate.upload.nativeFormat')} {t('translate.upload.nativeFormat')}
</div> </div>
<div className="w-16 h-16 bg-brand-muted dark:bg-white/5 rounded-2xl flex items-center justify-center text-brand-accent group-hover:scale-105 group-hover:bg-brand-dark dark:group-hover:bg-brand-accent group-hover:text-white dark:group-hover:text-brand-dark transition-all duration-300 mb-6 shadow-sm"> <div className="w-16 h-16 bg-brand-muted dark:bg-white/5 rounded-2xl flex items-center justify-center text-brand-goldink dark:text-brand-accent group-hover:scale-105 group-hover:bg-brand-dark dark:group-hover:bg-brand-accent group-hover:text-white dark:group-hover:text-brand-dark transition-all duration-300 mb-6 shadow-sm">
<Upload size={24} /> <Upload size={24} />
</div> </div>
@@ -444,7 +447,7 @@ export default function TranslatePage() {
<button <button
onClick={handleDownloadAll} onClick={handleDownloadAll}
disabled={!batch.items.some((i) => i.status === 'completed' && i.jobId)} disabled={!batch.items.some((i) => i.status === 'completed' && i.jobId)}
className="rounded-xl border border-brand-accent/30 px-4 py-2 text-[10px] font-bold uppercase tracking-wider text-brand-accent transition-colors hover:bg-brand-accent/10 disabled:opacity-40" className="rounded-xl border border-brand-accent/30 px-4 py-2 text-[10px] font-bold uppercase tracking-wider text-brand-goldink dark:text-brand-accent transition-colors hover:bg-brand-accent/10 disabled:opacity-40"
> >
<Download size={11} className="mr-1 inline" /> {t('translate.batch.downloadAll')} <Download size={11} className="mr-1 inline" /> {t('translate.batch.downloadAll')}
</button> </button>
@@ -465,7 +468,7 @@ export default function TranslatePage() {
className="flex items-center gap-3 rounded-2xl border border-black/5 bg-brand-muted/30 px-4 py-3 dark:border-white/5 dark:bg-white/5" className="flex items-center gap-3 rounded-2xl border border-black/5 bg-brand-muted/30 px-4 py-3 dark:border-white/5 dark:bg-white/5"
> >
{item.status === 'processing' ? ( {item.status === 'processing' ? (
<Loader2 className="size-4 shrink-0 animate-spin text-brand-accent" /> <Loader2 className="size-4 shrink-0 animate-spin text-brand-goldink dark:text-brand-accent" />
) : item.status === 'completed' ? ( ) : item.status === 'completed' ? (
<CheckCircle2 className="size-4 shrink-0 text-emerald-500" /> <CheckCircle2 className="size-4 shrink-0 text-emerald-500" />
) : item.status === 'failed' ? ( ) : item.status === 'failed' ? (
@@ -501,7 +504,7 @@ export default function TranslatePage() {
</button> </button>
<Link <Link
href={`/dashboard/reviews/${item.jobId}`} href={`/dashboard/reviews/${item.jobId}`}
className="rounded-lg border border-brand-accent/25 px-2.5 py-1.5 text-[10px] font-bold uppercase tracking-wider text-brand-accent" className="rounded-lg border border-brand-accent/25 px-2.5 py-1.5 text-[10px] font-bold uppercase tracking-wider text-brand-goldink dark:text-brand-accent"
> >
{t('translate.recent.review')} {t('translate.recent.review')}
</Link> </Link>
@@ -531,7 +534,7 @@ export default function TranslatePage() {
{recentJobs.slice(0, 6).map((job) => ( {recentJobs.slice(0, 6).map((job) => (
<li key={job.jobId} className="flex items-center justify-between gap-3 py-3"> <li key={job.jobId} className="flex items-center justify-between gap-3 py-3">
<span className="flex min-w-0 items-center gap-2.5 text-xs font-semibold text-brand-dark dark:text-white"> <span className="flex min-w-0 items-center gap-2.5 text-xs font-semibold text-brand-dark dark:text-white">
<FileText className="size-3.5 shrink-0 text-brand-accent" /> <FileText className="size-3.5 shrink-0 text-brand-goldink dark:text-brand-accent" />
<span className="truncate">{job.fileName || job.jobId}</span> <span className="truncate">{job.fileName || job.jobId}</span>
</span> </span>
<span className="flex shrink-0 items-center gap-1.5"> <span className="flex shrink-0 items-center gap-1.5">
@@ -544,7 +547,7 @@ export default function TranslatePage() {
</button> </button>
<Link <Link
href={`/dashboard/reviews/${job.jobId}`} href={`/dashboard/reviews/${job.jobId}`}
className="rounded-lg px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-brand-accent border border-brand-accent/25 hover:bg-brand-accent/10 transition-colors" className="rounded-lg px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-brand-goldink dark:text-brand-accent border border-brand-accent/25 hover:bg-brand-accent/10 transition-colors"
> >
{t('translate.recent.review')} {t('translate.recent.review')}
</Link> </Link>
@@ -608,9 +611,9 @@ export default function TranslatePage() {
{submit.isSubmitting ? ( {submit.isSubmitting ? (
<><Loader2 className="size-4 animate-spin" /> {t('translate.submit')}</> <><Loader2 className="size-4 animate-spin" /> {t('translate.submit')}</>
) : upload.files.length > 1 ? ( ) : upload.files.length > 1 ? (
<>{t('translate.startBatch', { count: upload.files.length })} <ArrowRight size={13} className="text-brand-accent" /></> <>{t('translate.startBatch', { count: upload.files.length })} <ArrowRight size={13} className="text-brand-goldink dark:text-brand-accent" /></>
) : ( ) : (
<>{t('translate.startTranslation')} <ArrowRight size={13} className={upload.files.length > 0 ? 'text-brand-accent' : 'opacity-20'} /></> <>{t('translate.startTranslation')} <ArrowRight size={13} className={upload.files.length > 0 ? 'text-brand-goldink dark:text-brand-accent' : 'opacity-20'} /></>
)} )}
</button> </button>
@@ -623,10 +626,10 @@ export default function TranslatePage() {
<div className="flex justify-between gap-4 text-[11px] font-semibold uppercase tracking-[0.06em] text-brand-dark/55 dark:text-white/55 border-t border-black/5 dark:border-white/5 pt-4"> <div className="flex justify-between gap-4 text-[11px] font-semibold uppercase tracking-[0.06em] text-brand-dark/55 dark:text-white/55 border-t border-black/5 dark:border-white/5 pt-4">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<ShieldCheck size={13} className="text-brand-accent" /> {t('landing.translate.zeroRetention') || 'Rétention Zéro'} <ShieldCheck size={13} className="text-brand-goldink dark:text-brand-accent" /> {t('landing.translate.zeroRetention') || 'Rétention Zéro'}
</span> </span>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Clock size={13} className="text-brand-accent" /> {t('landing.translate.filesDeleted') || 'Fichiers supprimés post-traitement'} <Clock size={13} className="text-brand-goldink dark:text-brand-accent" /> {t('landing.translate.filesDeleted') || 'Fichiers supprimés post-traitement'}
</span> </span>
</div> </div>
</div> </div>
@@ -636,7 +639,7 @@ export default function TranslatePage() {
{showProcessing && ( {showProcessing && (
<div className="editorial-card p-12 h-full border-none shadow-editorial bg-white dark:bg-[#141414] space-y-12"> <div className="editorial-card p-12 h-full border-none shadow-editorial bg-white dark:bg-[#141414] space-y-12">
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<div className="w-16 h-16 bg-brand-muted dark:bg-white/5 rounded-2xl flex items-center justify-center text-brand-accent border border-brand-accent/10 animate-pulse"> <div className="w-16 h-16 bg-brand-muted dark:bg-white/5 rounded-2xl flex items-center justify-center text-brand-goldink dark:text-brand-accent border border-brand-accent/10 animate-pulse">
<Activity size={32} /> <Activity size={32} />
</div> </div>
<div className="text-left"> <div className="text-left">
@@ -718,7 +721,7 @@ export default function TranslatePage() {
</button> </button>
<Link <Link
href={`/dashboard/reviews/${submit.jobId}`} href={`/dashboard/reviews/${submit.jobId}`}
className="mb-6 flex items-center gap-2 rounded-2xl border border-brand-accent/30 px-6 py-3 text-xs font-bold uppercase tracking-[0.15em] text-brand-accent transition-colors hover:bg-brand-accent/10" className="mb-6 flex items-center gap-2 rounded-2xl border border-brand-accent/30 px-6 py-3 text-xs font-bold uppercase tracking-[0.15em] text-brand-goldink dark:text-brand-accent transition-colors hover:bg-brand-accent/10"
> >
<Search size={14} /> <Search size={14} />
{t('translate.reviewCta')} {t('translate.reviewCta')}
@@ -810,7 +813,7 @@ export default function TranslatePage() {
<span className={cn( <span className={cn(
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-[0.1em]", "inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-[0.1em]",
config.mode === 'llm' config.mode === 'llm'
? "bg-brand-accent/10 text-brand-accent border border-brand-accent/20" ? "bg-brand-accent/10 text-brand-goldink dark:text-brand-accent border border-brand-accent/20"
: "bg-brand-muted/50 text-brand-dark/50 dark:bg-white/5 dark:text-white/50 border border-transparent" : "bg-brand-muted/50 text-brand-dark/50 dark:bg-white/5 dark:text-white/50 border border-transparent"
)}> )}>
{config.mode === 'llm' ? ( {config.mode === 'llm' ? (
@@ -831,7 +834,7 @@ export default function TranslatePage() {
{config.mode === 'llm' && !!systemPrompt?.trim() && ( {config.mode === 'llm' && !!systemPrompt?.trim() && (
<Link <Link
href="/dashboard/glossaries" href="/dashboard/glossaries"
className="flex items-center gap-2 rounded-xl border border-brand-accent/25 bg-brand-accent/5 px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-brand-accent transition-colors hover:bg-brand-accent/10" className="flex items-center gap-2 rounded-xl border border-brand-accent/25 bg-brand-accent/5 px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-brand-goldink dark:text-brand-accent transition-colors hover:bg-brand-accent/10"
> >
<FileText className="size-3.5 shrink-0" /> <FileText className="size-3.5 shrink-0" />
{t('translate.contextActive')} {t('translate.contextActive')}
@@ -856,7 +859,7 @@ export default function TranslatePage() {
<div className="bg-brand-muted/30 dark:bg-white/[0.02] border border-black/[0.03] dark:border-white/[0.03] p-4 rounded-xl space-y-3"> <div className="bg-brand-muted/30 dark:bg-white/[0.02] border border-black/[0.03] dark:border-white/[0.03] p-4 rounded-xl space-y-3">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ImageIcon className="size-3.5 text-brand-accent shrink-0" /> <ImageIcon className="size-3.5 text-brand-goldink dark:text-brand-accent shrink-0" />
<span className="text-[10px] font-black uppercase tracking-[0.1em] text-brand-dark dark:text-white"> <span className="text-[10px] font-black uppercase tracking-[0.1em] text-brand-dark dark:text-white">
{t('dashboard.translate.translateImages') || "Traduire les images"} {t('dashboard.translate.translateImages') || "Traduire les images"}
</span> </span>
@@ -905,7 +908,7 @@ export default function TranslatePage() {
)} )}
> >
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-tight text-brand-dark dark:text-white"> <div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-tight text-brand-dark dark:text-white">
<FileText className="size-3.5 text-brand-accent" /> <FileText className="size-3.5 text-brand-goldink dark:text-brand-accent" />
{t('dashboard.translate.pdfMode.preserveLayout') || 'Mise en page'} {t('dashboard.translate.pdfMode.preserveLayout') || 'Mise en page'}
</div> </div>
<p className="mt-1.5 text-[10px] text-brand-dark/50 dark:text-white/50 font-medium leading-relaxed"> <p className="mt-1.5 text-[10px] text-brand-dark/50 dark:text-white/50 font-medium leading-relaxed">
@@ -924,7 +927,7 @@ export default function TranslatePage() {
)} )}
> >
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-tight text-brand-dark dark:text-white"> <div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-tight text-brand-dark dark:text-white">
<Languages className="size-3.5 text-brand-accent" /> <Languages className="size-3.5 text-brand-goldink dark:text-brand-accent" />
{t('translate.textOnly')} {t('translate.textOnly')}
</div> </div>
<p className="mt-1.5 text-[10px] text-brand-dark/50 dark:text-white/50 font-medium leading-relaxed"> <p className="mt-1.5 text-[10px] text-brand-dark/50 dark:text-white/50 font-medium leading-relaxed">
@@ -950,7 +953,7 @@ export default function TranslatePage() {
{/* File summary */} {/* File summary */}
{(submit.fileName || upload.file?.name) && ( {(submit.fileName || upload.file?.name) && (
<div className="p-4 bg-brand-muted dark:bg-white/5 rounded-2xl mb-8 flex items-center gap-4 border border-black/5 dark:border-white/5"> <div className="p-4 bg-brand-muted dark:bg-white/5 rounded-2xl mb-8 flex items-center gap-4 border border-black/5 dark:border-white/5">
<div className="w-10 h-10 bg-white dark:bg-[#1a1a1a] rounded-xl flex items-center justify-center text-brand-accent shadow-sm"> <div className="w-10 h-10 bg-white dark:bg-[#1a1a1a] rounded-xl flex items-center justify-center text-brand-goldink dark:text-brand-accent shadow-sm">
{(() => { {(() => {
const name = submit.fileName || upload.file?.name || ''; const name = submit.fileName || upload.file?.name || '';
const ext = name.split('.').pop()?.toLowerCase() ?? ''; const ext = name.split('.').pop()?.toLowerCase() ?? '';
@@ -977,7 +980,7 @@ export default function TranslatePage() {
</div> </div>
<div className="flex justify-between items-center text-[11px] font-semibold uppercase tracking-[0.15em] text-brand-dark/50 dark:text-white/50"> <div className="flex justify-between items-center text-[11px] font-semibold uppercase tracking-[0.15em] text-brand-dark/50 dark:text-white/50">
<span>{t('translate.monitor.target')}</span> <span>{t('translate.monitor.target')}</span>
<span className="text-brand-accent normal-case">{tgtLangName}</span> <span className="text-brand-goldink dark:text-brand-accent normal-case">{tgtLangName}</span>
</div> </div>
{currentProvider && ( {currentProvider && (
<div className="flex justify-between items-center text-[11px] font-semibold uppercase tracking-[0.15em] text-brand-dark/50 dark:text-white/50"> <div className="flex justify-between items-center text-[11px] font-semibold uppercase tracking-[0.15em] text-brand-dark/50 dark:text-white/50">
@@ -1019,7 +1022,7 @@ export default function TranslatePage() {
</div> </div>
<div className="flex justify-between items-center text-[11px] font-semibold uppercase tracking-[0.15em] text-brand-dark/50 dark:text-white/50"> <div className="flex justify-between items-center text-[11px] font-semibold uppercase tracking-[0.15em] text-brand-dark/50 dark:text-white/50">
<span>{t('translate.monitor.target')}</span> <span>{t('translate.monitor.target')}</span>
<span className="text-brand-accent normal-case">{tgtLangName}</span> <span className="text-brand-goldink dark:text-brand-accent normal-case">{tgtLangName}</span>
</div> </div>
{currentProvider && ( {currentProvider && (
<div className="flex justify-between items-center text-[11px] font-semibold uppercase tracking-[0.15em] text-brand-dark/50 dark:text-white/50"> <div className="flex justify-between items-center text-[11px] font-semibold uppercase tracking-[0.15em] text-brand-dark/50 dark:text-white/50">
@@ -1032,7 +1035,7 @@ export default function TranslatePage() {
<div className="pt-6 border-t border-black/5 dark:border-white/5 text-left"> <div className="pt-6 border-t border-black/5 dark:border-white/5 text-left">
<div className="flex justify-between items-center text-[11px] font-semibold text-brand-dark/50 dark:text-white/50"> <div className="flex justify-between items-center text-[11px] font-semibold text-brand-dark/50 dark:text-white/50">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<ShieldCheck size={13} className="text-brand-accent" /> {t('landing.translate.zeroRetention') || 'Rétention Zéro'} <ShieldCheck size={13} className="text-brand-goldink dark:text-brand-accent" /> {t('landing.translate.zeroRetention') || 'Rétention Zéro'}
</span> </span>
</div> </div>
</div> </div>
@@ -1057,7 +1060,7 @@ export default function TranslatePage() {
{submit.isSubmitting ? ( {submit.isSubmitting ? (
<><Loader2 className="size-4 animate-spin" /> {t('translate.submit')}</> <><Loader2 className="size-4 animate-spin" /> {t('translate.submit')}</>
) : ( ) : (
<>{t('translate.startTranslation')} <ArrowRight size={13} className={upload.file ? 'text-brand-accent' : 'opacity-20'} /></> <>{t('translate.startTranslation')} <ArrowRight size={13} className={upload.file ? 'text-brand-goldink dark:text-brand-accent' : 'opacity-20'} /></>
)} )}
</button> </button>
</div> </div>
@@ -1095,7 +1098,7 @@ function FileStrip({ file, onRemove, onReplace, t }: { file: File; onRemove: ()
function StatBox({ icon, value, label }: { icon: React.ReactNode; value: string; label: string }) { function StatBox({ icon, value, label }: { icon: React.ReactNode; value: string; label: string }) {
return ( return (
<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="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> <div className="text-brand-goldink dark:text-brand-accent flex justify-center mb-4">{icon}</div>
<p className="text-sm font-black text-brand-dark mb-1 uppercase tracking-tight dark:text-white">{value}</p> <p className="text-sm font-black text-brand-dark mb-1 uppercase tracking-tight dark:text-white">{value}</p>
<p className="text-[10px] font-black text-brand-dark/50 uppercase tracking-[0.12em] dark:text-white/50">{label}</p> <p className="text-[10px] font-black text-brand-dark/50 uppercase tracking-[0.12em] dark:text-white/50">{label}</p>
</div> </div>

View File

@@ -14,6 +14,10 @@
--color-brand-bg: #FDFCF9; --color-brand-bg: #FDFCF9;
--color-brand-dark: #1A1A1A; --color-brand-dark: #1A1A1A;
--color-brand-accent: #C5A17A; --color-brand-accent: #C5A17A;
/* Or « encre » : même famille, assombri pour que les petits textes dorés
restent lisibles sur fond clair (≈ 5,4:1). L'or clair reste pour les
traits, fonds et le mode sombre. */
--color-brand-goldink: #8B6F47;
--color-brand-muted: #F3F1ED; --color-brand-muted: #F3F1ED;
/* shadcn compat */ /* shadcn compat */
@@ -194,7 +198,10 @@
} }
.accent-pill { .accent-pill {
@apply px-4 py-1.5 rounded-full border border-brand-accent/30 text-brand-accent text-[11px] font-bold uppercase tracking-widest bg-brand-accent/5; @apply px-4 py-1.5 rounded-full border border-brand-accent/40 text-brand-goldink text-[11px] font-bold uppercase tracking-widest bg-brand-accent/10;
}
.dark .accent-pill {
@apply text-brand-accent bg-brand-accent/5 border-brand-accent/20;
} }
.dark .accent-pill { .dark .accent-pill {
@apply border-brand-accent/20; @apply border-brand-accent/20;

View File

@@ -574,8 +574,21 @@ export default function PricingPage() {
<PricingDataSkeleton /> <PricingDataSkeleton />
) : ( ) : (
<> <>
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-6 items-stretch"> {/* Essai gratuit : une ligne discrète au-dessus du choix payant */}
{plans.map((plan) => { <div className="mb-8 flex flex-col items-center justify-between gap-4 rounded-2xl border border-black/[0.06] bg-brand-muted/40 px-6 py-4 dark:border-white/[0.08] dark:bg-white/5 sm:flex-row">
<p className="text-sm font-light text-brand-dark/70 dark:text-white/70">
{t('pricing.freeBand.text')}
</p>
<Link
href="/auth/register"
className="shrink-0 rounded-xl border border-black/10 px-5 py-2 text-[11px] font-bold uppercase tracking-wider text-brand-dark/70 transition-colors hover:bg-black/5 dark:border-white/15 dark:text-white/70 dark:hover:bg-white/10"
>
{t('pricing.freeBand.cta')}
</Link>
</div>
<div className="grid gap-6 md:grid-cols-3 items-stretch">
{plans.filter((p) => !['free', 'enterprise'].includes(p.id)).map((plan) => {
const Icon = PLAN_ICONS[plan.id] ?? Sparkles; const Icon = PLAN_ICONS[plan.id] ?? Sparkles;
const price = displayPrice(plan); const price = displayPrice(plan);
const isCurrent = currentPlan === plan.id; const isCurrent = currentPlan === plan.id;
@@ -595,7 +608,7 @@ export default function PricingPage() {
{(plan.popular || isCurrent) && ( {(plan.popular || isCurrent) && (
<div className="absolute top-5 right-5 flex gap-2"> <div className="absolute top-5 right-5 flex gap-2">
{plan.badge && ( {plan.badge && (
<span className="rounded-full border border-brand-accent/30 bg-brand-accent/10 px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-brand-accent"> <span className="rounded-full border border-brand-accent/30 bg-brand-accent/10 px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-brand-goldink dark:text-brand-accent">
{t(plan.badge)} {t(plan.badge)}
</span> </span>
)} )}
@@ -614,7 +627,7 @@ export default function PricingPage() {
{/* Icon + plan name */} {/* Icon + plan name */}
<div className="mb-4 flex items-center gap-3"> <div className="mb-4 flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-xl bg-brand-muted text-brand-accent dark:bg-white/10"> <div className="flex size-10 items-center justify-center rounded-xl bg-brand-muted text-brand-goldink dark:text-brand-accent dark:bg-white/10">
<Icon size={18} /> <Icon size={18} />
</div> </div>
<span className="text-xs font-bold uppercase tracking-[0.15em] text-brand-dark/70 dark:text-white/70"> <span className="text-xs font-bold uppercase tracking-[0.15em] text-brand-dark/70 dark:text-white/70">
@@ -770,6 +783,19 @@ export default function PricingPage() {
})} })}
</div> </div>
{/* Sur mesure : une ligne discrète sous le choix payant */}
<div className="mt-8 flex flex-col items-center justify-between gap-4 rounded-2xl border border-brand-accent/25 bg-brand-accent/[0.06] px-6 py-4 dark:border-brand-accent/20 dark:bg-brand-accent/10 sm:flex-row">
<p className="text-sm font-light text-brand-dark/70 dark:text-white/70">
{t('pricing.enterpriseBand.text')}
</p>
<a
href={`mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent(t('pricing.enterprise.subject'))}`}
className="shrink-0 rounded-xl border border-brand-accent/40 px-5 py-2 text-[11px] font-bold uppercase tracking-wider text-brand-goldink transition-colors hover:bg-brand-accent/10 dark:text-brand-accent"
>
{t('pricing.enterpriseBand.cta')}
</a>
</div>
{/* ── Feature comparison table ── */} {/* ── Feature comparison table ── */}
<div className="mt-20"> <div className="mt-20">
<h2 className="text-3xl font-bold text-center mb-2">{t('pricing.comparison.title')}</h2> <h2 className="text-3xl font-bold text-center mb-2">{t('pricing.comparison.title')}</h2>

View File

@@ -88,7 +88,7 @@ function DocumentCard({
'absolute right-[4%] top-[4%] rounded-full px-2 py-0.5 text-[9px] font-black tracking-widest', 'absolute right-[4%] top-[4%] rounded-full px-2 py-0.5 text-[9px] font-black tracking-widest',
side === 'source' side === 'source'
? 'border border-black/10 text-brand-dark/50 dark:border-white/15 dark:text-white/50' ? 'border border-black/10 text-brand-dark/50 dark:border-white/15 dark:text-white/50'
: 'border border-brand-accent/40 bg-brand-accent/10 text-brand-accent' : 'border border-brand-accent/40 bg-brand-accent/10 text-brand-goldink dark:text-brand-accent'
)} )}
> >
{tag} {tag}
@@ -172,7 +172,7 @@ export function HeroVisual() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }} exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.25 }} transition={{ duration: 0.25 }}
className="absolute bottom-[14%] rounded-full border border-brand-accent/30 px-2 py-0.5 text-[9px] font-black tracking-widest text-brand-accent" className="absolute bottom-[14%] rounded-full border border-brand-accent/30 px-2 py-0.5 text-[9px] font-black tracking-widest text-brand-goldink dark:text-brand-accent"
> >
{target.code} {target.code}
</motion.span> </motion.span>

View File

@@ -7,7 +7,6 @@ import {
Layers, Layers,
MousePointer2, MousePointer2,
Download, Download,
Globe,
Cpu, Cpu,
CheckCircle2, CheckCircle2,
ArrowRight, ArrowRight,
@@ -17,7 +16,6 @@ import {
Presentation, Presentation,
MessageSquare, MessageSquare,
ShieldCheck, ShieldCheck,
Zap,
Menu, Menu,
X, X,
Languages, Languages,
@@ -36,7 +34,7 @@ const Logo = () => (
W W
</div> </div>
<span className="text-2xl font-bold tracking-tighter text-brand-dark dark:text-white"> <span className="text-2xl font-bold tracking-tighter text-brand-dark dark:text-white">
Wordly<span className="text-brand-accent">.art</span> Wordly<span className="text-brand-goldink dark:text-brand-accent">.art</span>
</span> </span>
</div> </div>
); );
@@ -215,34 +213,91 @@ const FeaturesContext = () => {
const LayoutFeatures = () => { const LayoutFeatures = () => {
const { t } = useI18n(); const { t } = useI18n();
const points = [
{ title: t('landing.layout.p1.title'), desc: t('landing.layout.p1.desc'), icon: <Layers size={22} /> }, // Deux vraies pages de document : même structure, deux langues.
{ title: t('landing.layout.p2.title'), desc: t('landing.layout.p2.desc'), icon: <Layout size={22} /> }, // Le terme en gras montre le respect de la terminologie (glossaire).
{ title: t('landing.layout.p3.title'), desc: t('landing.layout.p3.desc'), icon: <PieChart size={22} /> }, const DocPage = ({ side }: { side: "source" | "target" }) => (
{ title: t('landing.layout.p4.title'), desc: t('landing.layout.p4.desc'), icon: <Zap size={22} /> }, <div className="flex flex-col rounded-2xl bg-white p-8 text-start shadow-2xl dark:bg-[#181818] lg:p-10">
{ title: t('landing.layout.p5.title'), desc: t('landing.layout.p5.desc'), icon: <MessageSquare size={22} /> }, <span className={`mb-6 w-fit rounded-full border px-3 py-1 text-[10px] font-black uppercase tracking-widest ${side === "source" ? "border-black/10 text-brand-dark/50 dark:border-white/15 dark:text-white/50" : "border-brand-accent/40 bg-brand-accent/10 text-brand-goldink dark:text-brand-accent"}`}>
{ title: t('landing.layout.p6.title'), desc: t('landing.layout.p6.desc'), icon: <Globe size={22} /> }, {side === "source" ? "FR — original" : "EN — traduit"}
]; </span>
<h4 className="mb-4 font-serif text-xl font-medium tracking-tight text-brand-dark dark:text-white lg:text-2xl">
{side === "source" ? t("landing.beforeAfter.sourceTitle") : t("landing.beforeAfter.targetTitle")}
</h4>
<p className="mb-4 text-sm leading-relaxed text-brand-dark/75 dark:text-white/75">
{side === "source" ? t("landing.beforeAfter.sourcePara1") : t("landing.beforeAfter.targetPara1")}
</p>
<p className="mb-6 text-sm leading-relaxed text-brand-dark/75 dark:text-white/75">
{side === "source" ? (
<>
{t("landing.beforeAfter.sourcePara2Before")}{" "}
<strong className="font-bold text-brand-dark dark:text-white">{t("landing.beforeAfter.sourceTerm")}</strong>{" "}
{t("landing.beforeAfter.sourcePara2After")}
</>
) : (
<>
{t("landing.beforeAfter.targetPara2Before")}{" "}
<strong className="font-bold text-brand-dark dark:text-white">{t("landing.beforeAfter.targetTerm")}</strong>{" "}
{t("landing.beforeAfter.targetPara2After")}
</>
)}
</p>
<ul className="mb-8 space-y-2 text-sm text-brand-dark/75 dark:text-white/75">
<li className="flex items-start gap-2">
<span className="mt-2 size-1 shrink-0 rounded-full bg-brand-accent" />
{side === "source" ? t("landing.beforeAfter.sourceItem1") : t("landing.beforeAfter.targetItem1")}
</li>
<li className="flex items-start gap-2">
<span className="mt-2 size-1 shrink-0 rounded-full bg-brand-accent" />
{side === "source" ? t("landing.beforeAfter.sourceItem2") : t("landing.beforeAfter.targetItem2")}
</li>
</ul>
<div className="mt-auto flex items-center justify-between border-t border-black/[0.06] pt-4 dark:border-white/[0.08]">
<span className="font-mono text-[10px] uppercase tracking-wider text-brand-dark/45 dark:text-white/45">cahier-des-charges.docx</span>
<span className="font-mono text-[10px] uppercase tracking-wider text-brand-dark/45 dark:text-white/45">3 pages · 2 481 mots</span>
</div>
</div>
);
return ( return (
<section className="py-32 px-4 bg-brand-dark dark:bg-[#0a0a0a] text-white"> <section id="features" className="px-4 py-32 text-white">
<div className="max-w-7xl mx-auto"> <div className="mx-auto max-w-7xl">
<div className="text-center mb-32"> <div className="mb-16 text-center">
<h2 className="text-4xl md:text-6xl mb-10 leading-tight font-serif font-medium tracking-tight"> <h2 className="mb-8 font-serif text-4xl font-medium leading-tight tracking-tight md:text-6xl">
{t('landing.layout.title')}<br /> <span className="italic">{t('landing.layout.title2')}</span> {t("landing.layout.title")}<br /> <span className="italic">{t("landing.layout.title2")}</span>
</h2> </h2>
<p className="text-white/40 max-w-2xl mx-auto text-xl font-light tracking-widest"> <p className="mx-auto max-w-2xl text-xl font-light tracking-wide text-white/45">
{t('landing.layout.subtitle')} {t("landing.layout.subtitle")}
</p> </p>
</div> </div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-px bg-white/5 border border-white/10 rounded-3xl overflow-hidden shadow-2xl">
{points.map((p, i) => ( <div className="relative grid gap-6 md:grid-cols-2 md:gap-10">
<div key={i} className="group p-14 bg-brand-dark dark:bg-[#0a0a0a] hover:bg-white/[0.02] transition-all duration-700"> <DocPage side="source" />
<div className="w-12 h-12 border border-white/10 rounded-xl flex items-center justify-center text-brand-accent mb-10 group-hover:scale-110 group-hover:border-white/20 transition-all"> <DocPage side="target" />
{p.icon}
{/* sceau central : la promesse, entre les deux pages */}
<div className="pointer-events-none absolute inset-0 hidden items-center justify-center md:flex">
<div className="flex items-center gap-2 rounded-full border border-brand-accent/40 bg-brand-dark px-5 py-2.5 shadow-2xl dark:bg-[#0a0a0a]">
<CheckCircle2 size={14} className="text-brand-accent" />
<span className="text-[10px] font-black uppercase tracking-widest text-brand-accent">
{t("landing.beforeAfter.seal")}
</span>
</div> </div>
<h4 className="text-xl font-serif font-medium mb-6 leading-none">{p.title}</h4> </div>
<p className="text-white/30 text-sm leading-relaxed font-light">{p.desc}</p> </div>
{/* trois preuves précises, pas des promesses vagues */}
<div className="mt-14 grid gap-4 sm:grid-cols-3">
{[
{ icon: <Layout size={16} />, label: t("landing.beforeAfter.proof1") },
{ icon: <PieChart size={16} />, label: t("landing.beforeAfter.proof2") },
{ icon: <MessageSquare size={16} />, label: t("landing.beforeAfter.proof3") },
].map((proof, i) => (
<div key={i} className="flex items-center gap-3 rounded-2xl border border-white/10 px-5 py-4">
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-brand-accent/30 text-brand-accent">
{proof.icon}
</span>
<span className="text-sm font-light text-white/70">{proof.label}</span>
</div> </div>
))} ))}
</div> </div>
@@ -275,7 +330,7 @@ const FormatsSection = () => {
<div className="w-12 h-12 bg-brand-muted dark:bg-[#1f1f1f] rounded-xl flex items-center justify-center text-xl group-hover:bg-brand-dark dark:group-hover:bg-brand-accent group-hover:text-white dark:group-hover:text-brand-dark transition-all"> <div className="w-12 h-12 bg-brand-muted dark:bg-[#1f1f1f] rounded-xl flex items-center justify-center text-xl group-hover:bg-brand-dark dark:group-hover:bg-brand-accent group-hover:text-white dark:group-hover:text-brand-dark transition-all">
{f.icon} {f.icon}
</div> </div>
<span className="text-[9px] font-bold text-brand-dark/50 dark:text-white/50 uppercase tracking-[0.2em]">{f.id}</span> <span className="text-[10px] font-bold text-brand-dark/50 dark:text-white/50 uppercase tracking-[0.2em]">{f.id}</span>
</div> </div>
<h3 className="text-xl font-serif font-medium mb-6 text-brand-dark dark:text-white tracking-tight">{f.name}</h3> <h3 className="text-xl font-serif font-medium mb-6 text-brand-dark dark:text-white tracking-tight">{f.name}</h3>
<ul className="space-y-4"> <ul className="space-y-4">
@@ -347,7 +402,7 @@ const PricingSection = () => {
</button> </button>
<button onClick={() => setIsAnnual(true)} className={`text-sm font-bold uppercase tracking-widest transition-all relative ${isAnnual ? 'text-brand-dark dark:text-white underline underline-offset-8' : 'text-brand-dark/55 dark:text-white/55 hover:text-brand-dark dark:hover:text-white'}`}> <button onClick={() => setIsAnnual(true)} className={`text-sm font-bold uppercase tracking-widest transition-all relative ${isAnnual ? 'text-brand-dark dark:text-white underline underline-offset-8' : 'text-brand-dark/55 dark:text-white/55 hover:text-brand-dark dark:hover:text-white'}`}>
{t('landing.pricing.annual')} {t('landing.pricing.annual')}
<span className="absolute -top-6 left-1/2 -translate-x-1/2 text-[9px] font-bold text-brand-accent whitespace-nowrap italic"> <span className="absolute -top-6 left-1/2 -translate-x-1/2 text-[10px] font-bold text-brand-goldink dark:text-brand-accent whitespace-nowrap italic">
(-20%) (-20%)
</span> </span>
</button> </button>

View File

@@ -150,5 +150,23 @@
"landing.translate.processing": "Processing", "landing.translate.processing": "Processing",
"landing.translate.preservingLayout": "Your layout is being preserved", "landing.translate.preservingLayout": "Your layout is being preserved",
"landing.formats.pill": "COMPATIBILITY", "landing.formats.pill": "COMPATIBILITY",
"landing.hero.visualCaption": "Same layout, new language — nothing else moves" "landing.hero.visualCaption": "Same layout, new language — nothing else moves",
"landing.beforeAfter.sourceTitle": "Cahier des charges — Traitement d'air",
"landing.beforeAfter.targetTitle": "Technical specification — Air handling",
"landing.beforeAfter.sourcePara1": "L'installation de ventilation doit être mise en service avant la fin du deuxième trimestre.",
"landing.beforeAfter.targetPara1": "The ventilation system must be commissioned before the end of the second quarter.",
"landing.beforeAfter.sourcePara2Before": "Le",
"landing.beforeAfter.sourceTerm": "groupe de traitement d'air",
"landing.beforeAfter.sourcePara2After": "doit respecter la classe de filtration F7 conformément au planning.",
"landing.beforeAfter.targetPara2Before": "The",
"landing.beforeAfter.targetTerm": "air handling unit",
"landing.beforeAfter.targetPara2After": "must comply with filter class F7 as specified in the schedule.",
"landing.beforeAfter.sourceItem1": "Charge thermique : 42 kW au débit nominal",
"landing.beforeAfter.targetItem1": "Thermal load: 42 kW at nominal flow",
"landing.beforeAfter.sourceItem2": "Niveau sonore inférieur à 45 dB(A) à 3 m",
"landing.beforeAfter.targetItem2": "Sound level below 45 dB(A) at 3 m",
"landing.beforeAfter.seal": "Same layout, word for word",
"landing.beforeAfter.proof1": "SmartArt diagrams rebuilt, not flattened",
"landing.beforeAfter.proof2": "Chart series and axes translated",
"landing.beforeAfter.proof3": "Tables of contents regenerated in the target language"
} }

View File

@@ -159,5 +159,9 @@
"pricing.confirm.monthlyEquivalent": "Billed yearly — {price} € / month equivalent.", "pricing.confirm.monthlyEquivalent": "Billed yearly — {price} € / month equivalent.",
"pricing.confirm.secureNote": "You can cancel anytime from your profile. Payment is processed by Stripe; your card details never touch our servers.", "pricing.confirm.secureNote": "You can cancel anytime from your profile. Payment is processed by Stripe; your card details never touch our servers.",
"pricing.confirm.cancel": "Cancel", "pricing.confirm.cancel": "Cancel",
"pricing.confirm.cta": "Continue to payment" "pricing.confirm.cta": "Continue to payment",
"pricing.freeBand.text": "Just want to try? Start free — 5 documents per month, no card required.",
"pricing.freeBand.cta": "Start free",
"pricing.enterpriseBand.text": "Volume, dedicated engines, on-premise options — let's talk.",
"pricing.enterpriseBand.cta": "Contact us"
} }

View File

@@ -150,5 +150,23 @@
"landing.translate.processing": "Traitement en cours", "landing.translate.processing": "Traitement en cours",
"landing.translate.preservingLayout": "Votre mise en page est en cours de préservation", "landing.translate.preservingLayout": "Votre mise en page est en cours de préservation",
"landing.formats.pill": "COMPATIBILITÉ", "landing.formats.pill": "COMPATIBILITÉ",
"landing.hero.visualCaption": "Même mise en page, nouvelle langue — rien d'autre ne bouge" "landing.hero.visualCaption": "Même mise en page, nouvelle langue — rien d'autre ne bouge",
"landing.beforeAfter.sourceTitle": "Cahier des charges — Traitement d'air",
"landing.beforeAfter.targetTitle": "Technical specification — Air handling",
"landing.beforeAfter.sourcePara1": "L'installation de ventilation doit être mise en service avant la fin du deuxième trimestre.",
"landing.beforeAfter.targetPara1": "The ventilation system must be commissioned before the end of the second quarter.",
"landing.beforeAfter.sourcePara2Before": "Le",
"landing.beforeAfter.sourceTerm": "groupe de traitement d'air",
"landing.beforeAfter.sourcePara2After": "doit respecter la classe de filtration F7 conformément au planning.",
"landing.beforeAfter.targetPara2Before": "The",
"landing.beforeAfter.targetTerm": "air handling unit",
"landing.beforeAfter.targetPara2After": "must comply with filter class F7 as specified in the schedule.",
"landing.beforeAfter.sourceItem1": "Charge thermique : 42 kW au débit nominal",
"landing.beforeAfter.targetItem1": "Thermal load: 42 kW at nominal flow",
"landing.beforeAfter.sourceItem2": "Niveau sonore inférieur à 45 dB(A) à 3 m",
"landing.beforeAfter.targetItem2": "Sound level below 45 dB(A) at 3 m",
"landing.beforeAfter.seal": "Même mise en page, mot pour mot",
"landing.beforeAfter.proof1": "Les SmartArt sont reconstruits, pas aplatis",
"landing.beforeAfter.proof2": "Séries et axes des graphiques traduits",
"landing.beforeAfter.proof3": "Tables des matières régénérées dans la langue cible"
} }

View File

@@ -159,5 +159,9 @@
"pricing.confirm.monthlyEquivalent": "Facturation annuelle — soit {price} € / mois.", "pricing.confirm.monthlyEquivalent": "Facturation annuelle — soit {price} € / mois.",
"pricing.confirm.secureNote": "Résiliable à tout moment depuis votre profil. Paiement traité par Stripe ; vos données bancaires ne passent jamais par nos serveurs.", "pricing.confirm.secureNote": "Résiliable à tout moment depuis votre profil. Paiement traité par Stripe ; vos données bancaires ne passent jamais par nos serveurs.",
"pricing.confirm.cancel": "Annuler", "pricing.confirm.cancel": "Annuler",
"pricing.confirm.cta": "Continuer vers le paiement" "pricing.confirm.cta": "Continuer vers le paiement",
"pricing.freeBand.text": "Juste envie d'essayer ? Commencez gratuitement — 5 documents par mois, sans carte.",
"pricing.freeBand.cta": "Commencer gratuitement",
"pricing.enterpriseBand.text": "Volume, moteurs dédiés, hébergement spécifique — parlons-en.",
"pricing.enterpriseBand.cta": "Nous contacter"
} }