fix(ui): wave 2 from critique re-run — AA contrast, unified PageHeader, checkout confirm
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m46s

Nav: teams/settings/services back out of the nav until product-ready (user
decision); teams page was already half-finished (UUID member display is a
backend limitation).

Payment: explicit confirmation dialog (plan, amount, billing period, Stripe
note) before any redirect; ?plan= URL now pre-opens the dialog instead of
triggering a silent checkout.

Contrast AA sweep (71 replacements): functional micro-labels raised to
>=60-65% opacity and >=10px across sidebar, header, pricing, landing;
decorative all-caps 'interface' label removed.

Design unification: new PageHeader component (accent pill + serif
base/accent title) applied to settings, services, reviews, teams; pricing
header returned to the editorial voice (serif + accent pill); dead
GlossaryCard deleted.

i18n residuals: suggestion chips, 'Standard' provider label, notification
close, model-combobox strings extracted (+broken bg-surface/border-border-
subtle tokens fixed); ~45 new keys EN+FR; 13 dead keys removed; zero
missing keys verified.

Reviews: icon-only row actions now carry visible text; 'Approve all' is
two-step armed-confirm.

Accelerators: Ctrl/Cmd+Enter submits; arrow-key navigation in the language
combobox; recent-jobs history cap 8->20 with per-job download; source=target
config rejected; empty 'Master Quality' badge removed.

Cookie consent: reopenable via footer link, emoji replaced with drawn icon.

Verified: build exit 0, vitest 9/9, eslint 64 errors = previous level,
0 missing i18n keys.
This commit is contained in:
2026-08-30 22:02:35 +02:00
parent 50047ea8a2
commit 111f3cb69d
41 changed files with 470 additions and 280 deletions

View File

@@ -0,0 +1,73 @@
---
target: frontend/src (UI complète Wordly.art)
total_score: 24
max_score: 40
na_heuristics:
p0_count: 0
p1_count: 3
timestamp: 2026-08-30T19-50-34Z
slug: frontend-src
---
# Critique de design — Wordly.art (`frontend/src`) — re-run post-correctif
Method: dual-agent (A: agent_8b2492dd-bd0c-48a2-9293-d6c7960dc418 · B: agent_d8eeec19-1e9d-4df5-84a2-7eb2daf6beb0)
## Design Health Score
| # | Heuristique | Score | Problème clé | Δ |
|---|-------------|-------|--------------|---|
| 1 | Visibilité du statut | 3 | Progression aria-live, temps restant API affiché, skeletons ; mais « Tout approuver » boucle sans progression (`reviews/[jobId]/page.tsx:138-148`) et la sauvegarde des consignes simule un délai de 300 ms (`glossaries/page.tsx:64`). | +1 |
| 2 | Correspondance avec le réel | 2 | Jargon non défini : bouton nu « XLIFF » (`reviews/[jobId]/page.tsx:253`), « Phase 2 : Reconstruction contextuelle » (`fr/translate.json:70`) ; membre d'équipe affiché en `user_id.slice(0,8)…` (`teams/page.tsx:197`) ; « Standard » codé en dur EN (`ProviderSelector.tsx:161`). | = |
| 3 | Contrôle et liberté | 3 | Reprise du job après refresh (`useTranslationSubmit.ts:257-278`), sortie honnête « la traduction continue en arrière-plan » ; mais aucune annulation réelle d'un job, et le consentement cookies n'est plus rouvrable (`cookie-consent.tsx:14-26`). | +1 |
| 4 | Cohérence et standards | 2 | Trois systèmes visuels (éditorial serif / font-black pricing-settings / shadcn brut reviews-teams) ; `GlossaryCard.tsx` mort, jamais importé ; aria-label « Close notification » non traduit (`notification.tsx:184`). | +1 |
| 5 | Prévention des erreurs | 3 | Validation extension+taille, avertissements mismatch glossaire avant soumission, submit désactivé avec raison ; mais rien n'empêche source = cible (`useTranslationConfig.ts:233-237`). | +1 |
| 6 | Reconnaissance plutôt que rappel | 3 | Traductions récentes accessibles, récap config pendant l'attente ; mais actions de ligne icône-seule avec `title` seulement (`reviews/[jobId]/page.tsx:364-395`) et onglet « Consignes de contexte » caché sous Glossaires. | = |
| 7 | Flexibilité et efficacité | 1 | Aucun raccourci clavier global ; upload strictement mono-fichier (pas de `multiple`) ; historique plafonné à 8 jobs locaux, aucune page historique serveur ; pas de navigation fléchée dans le combobox langues. | = |
| 8 | Esthétique et minimalisme | 2 | Pricing = 5 plans + comparatif 10 lignes + 4 packs crédits + 4 trust + 2 modèles IA + 7 FAQ ; hiérarchie inversée sur translate — titre décoratif 5xl plus fort que le CTA 12px (`translate/page.tsx:230` vs `:351`). | = |
| 9 | Récupération d'erreurs | 3 | Humanisation en 9 familles, retry avec fichier conservé, cap polling réseau clair ; mais teams/reviews affichent `err.message` brut (`teams/page.tsx:102-105`). | = |
| 10 | Aide et documentation | 2 | Aides contextuelles réelles (glossaire, providers) mais zéro centre d'aide, zéro onboarding première connexion. | = |
| **Total** | | **24/40** | **Acceptable — en limite haute, la bande « Good » (28+) est atteignable** | **+4** |
## Verdict de spécificité design
**LLM assessment** : la landing est « presque signée » — le langage éditorial (Playfair/crème/encre/or) est tenu jusqu'au dashboard, et `SplitTitle` (base + accent italique, `translate/page.tsx:62-64`) est une vraie signature. Le dashboard translate est la meilleure surface : pipeline Upload→Search→Languages→Wrench→Check, glossaire avec paires de langues et ajout inline, providers thémés — rien de réutilisable tel quel par un autre produit. Restent trois failles d'identité : le hero landing (photo Unsplash + fausses cartes UI ne correspondant à aucun écran réel, `landing-page.tsx:139-167`), la page pricing qui parle une typographie opposée (`font-black uppercase tracking-tighter`, `pricing/page.tsx:504`), et reviews/teams jamais passés au design (shadcn brut). Occasion manquée majeure : aucun avant/après de document — LE visuel de la promesse « keep the format ».
**Deterministic scan** : 3 findings (4 avant le correctif). Le `side-tab` du profil est corrigé. Restent 2 `overused-font` Inter (`globals.css:141,147`) — choix assumé « outil sobre » de l'utilisateur, documenté — et 1 `border-accent-on-rounded` (`globals.css:215`) : **faux positif manifeste** (spinner canonique).
**Overlays navigateur** : indisponibles — « Browser is not available in subagent » (identique au run précédent, tentative réelle faite). Fallback : scan CLI.
## Impression générale
Le cœur produit est devenu solide : machine à états propre, reprise après refresh — « peu de SaaS gèrent le rafraîchissement en cours de traitement aussi proprement » —, erreurs humanisées, prévention au point de décision. La confiance n'est plus sabordée. Le plafond suivant se joue sur trois fronts : la conformité AA des micro-labels, l'unification des trois systèmes visuels, et les accélérateurs (batch, raccourcis, historique serveur) qui font la différence pour les utilisateurs intensifs.
## Ce qui fonctionne
1. **La machine à états + persistance du job** : cinq états nets, en-tête réécrit par état, progression réelle backend, localStorage avec TTL 24 h et reprise automatique (`useTranslationSubmit.ts:253-278`).
2. **L'identité éditoriale tenue de la landing au dashboard** : crème/encre/or + serif d'accent + pills, dark mode cohérent, `prefers-reduced-motion`/`prefers-contrast` gérés.
3. **La prévention et la récupération au point de décision** : mapping 9 familles d'erreurs + avertissements mismatch glossaire avant soumission (`GlossarySelector.tsx:340-357`).
## Problèmes prioritaires
1. **[P1] Contraste des micro-labels : illisibles et non-conformes AA.** Textes fonctionnels 9-11px en /20-/40 : nav sidebar `/40` (`DashboardSidebar.tsx:45-48`), stats pricing `text-[9px]/40` (`pricing/page.tsx:636`), « .DOCX » `/20` (`landing-page.tsx:307`). /40 sur crème ≈ 2.5:1. → Fix : plancher 11px, opacité ≥65 %, /20-/30 réservé à la décoration. **`$impeccable audit` + `$impeccable harden`**
2. **[P1] Trois systèmes de design qui ne se parlent pas.** Pricing/settings/services en `font-black uppercase` vs translate/glossaries en serif éditorial vs reviews/teams en shadcn brut ; `GlossaryCard.tsx` mort et divergent. → Fix : composant `PageHeader` unique (pill + titre base/accent) imposé partout ; supprimer GlossaryCard. **`$impeccable layout` + `$impeccable typeset`**
3. **[P1] UI non traduite dans un produit de traduction.** Chips de suggestions des consignes codées en dur FR (`glossaries/page.tsx:22-29`), « Standard » EN (`ProviderSelector.tsx:161`), « Close notification » (`notification.tsx:184`), strings FR sans accents dans `model-combobox.tsx:32,114,135`, littéraux `|| 'français'` résiduels. → Fix : extraire en clés, bannir le littéral JSX. **`$impeccable harden`**
4. **[P2] Actions icône-seule et identité technique exposée.** Éditer/approuver/reset en icônes 12px avec `title` seulement ; membres affichés `user_id.slice(0,8)…`. → Fix : boutons textuels compacts ou icône + libellé visible ; afficher e-mail/nom via l'API. **`$impeccable clarify`**
5. **[P2] Aucun accélérateur pour les utilisateurs intensifs.** Pas de Ctrl+Entrée, upload mono-fichier, historique 8 items locaux sans page serveur, pas de fléchées dans le combobox langues (40 options = 40 Tab). → Fix : raccourci, `multiple` + file d'attente, page Historique API, roving tabindex. **`$impeccable shape`**
## Red flags personas
**Alex (expert pressé)** : la config par défaut rend le flux rapide — mais 10 fichiers = 10 parcours complets (pas de `multiple`) ; « + Nouvelle traduction » réinitialise tout sans « re-traduire avec la même config » ; historique limité à 8 items locaux ; « Tout approuver » sans confirmation ni undo sur 400 segments.
**Sam (accessibilité)** : les fondations sont désormais réelles — `:focus-visible` global, dropzone focusable avec rôle + Entrée/Espace, progression aria-live, `role="switch"` : du sérieux. Échecs restants : contrastes /40-/20 massifs, dropdowns glossaire sans Échap ni gestion de focus out (`GlossarySelector.tsx:121-129`), combobox langues sans navigation fléchée, « edited » signifié par la couleur seule dans la cellule (sauvé par le badge).
**Jordan (premier usage)** : dropzone auto-explicative, chips rassurantes. Mais « XLIFF »/« Reconstruire et télécharger » sans explication, « Phase 2 : Reconstruction contextuelle » jargon interne, onglet « Consignes de contexte » introuvable par déduction, aucun onboarding première connexion, erreur 60 Mo sans limite affichée ni conseil.
## Observations mineures
Cookie consent non rouvrable + emoji 🍪 + chevauchement possible avec la barre mobile · hero Unsplash → vrai screenshot produit · mailto: brut pour Enterprise (sortie d'app) · switch « Traduire les images » fantôme pour les free · timer `formatElapsed` remis à 0 sur certains changements de statut · clés mortes fr/translate.json (« 100% SÉCURISÉ », « Qualité Maître », cancelProcess) · `model-combobox.tsx` référence des tokens inexistants (`bg-surface`, `border-border-subtle`) · nav dupliquée sidebar/header mobile · auto-checkout silencieux par paramètre d'URL `?plan=` sans re-confirmation (`pricing/page.tsx:339-349`).
## Questions à considérer
1. Pourquoi montrer six groupes de configuration à un free dont la moitié est verrouillée ? Que perdriez-vous à une page translate free de trois contrôles exactement ?
2. Où est le avant/après ? La promesse unique est « keep the format » et nulle part l'UI ne montre un document source à côté de sa traduction — surtout en RTL.
3. Pourquoi le paiement, moment de plus haute anxiété, est-il le seul sans écran de confirmation — et combien d'auto-checkouts silencieux par URL coûtent en litiges ?

View File

@@ -31,7 +31,7 @@ export function DashboardHeader() {
<header className="flex h-20 shrink-0 items-center justify-between border-b border-black/5 dark:border-white/5 bg-white/50 dark:bg-[#141414]/50 backdrop-blur-md px-6 lg:px-8">
{/* Mobile menu button */}
<button
className="lg:hidden p-2 text-brand-dark/40 dark:text-white/40 hover:text-brand-dark dark:hover:text-white transition-colors"
className="lg:hidden p-2 text-brand-dark/65 dark:text-white/65 hover:text-brand-dark dark:hover:text-white transition-colors"
onClick={() => setMobileOpen(!mobileOpen)}
aria-label={t('dashboard.header.toggleMenu')}
>
@@ -47,11 +47,7 @@ export function DashboardHeader() {
</div>
{/* Left side - desktop */}
<div className="hidden lg:flex items-center gap-3">
<span className="text-[10px] font-bold uppercase tracking-[0.4em] text-brand-dark/30 dark:text-white/30">
{t('dashboard.topbar.interfaceLabel')}
</span>
</div>
<div className="hidden lg:flex items-center gap-3" />
{/* Right side */}
<div className="flex items-center gap-3">
@@ -69,7 +65,7 @@ export function DashboardHeader() {
<span className="text-[11px] font-black uppercase tracking-tight text-brand-dark dark:text-white">
{user.name}
</span>
<span className="text-[9px] font-black uppercase tracking-widest text-brand-accent">
<span className="text-[10px] font-black uppercase tracking-wider text-brand-accent">
{user.tier && user.tier !== 'free'
? t('dashboard.topbar.premiumAccess', { defaultValue: 'Premium Access' })
: translateTier(t, user.tier)}
@@ -112,7 +108,7 @@ export function DashboardHeader() {
'flex items-center gap-4 rounded-2xl px-6 py-4 text-[11px] font-black uppercase tracking-[0.2em] transition-all duration-200',
isActive
? 'bg-brand-dark text-white shadow-xl'
: 'text-brand-dark/40 dark:text-white/40 hover:bg-brand-muted dark:hover:bg-[#1f1f1f]'
: 'text-brand-dark/65 dark:text-white/65 hover:bg-brand-muted dark:hover:bg-[#1f1f1f]'
)}
>
<item.icon size={18} className="shrink-0" />
@@ -132,7 +128,7 @@ export function DashboardHeader() {
<span className="text-[11px] font-black uppercase tracking-tight text-brand-dark dark:text-white">
{user.name}
</span>
<span className="px-1.5 py-0 rounded-full border border-brand-accent/30 text-brand-accent text-[7px] font-black uppercase w-fit">
<span className="px-1.5 py-0 rounded-full border border-brand-accent/30 text-brand-accent text-[10px] font-black uppercase w-fit">
{translateTier(t, user.tier)}
</span>
</div>
@@ -141,7 +137,7 @@ export function DashboardHeader() {
<button
onClick={logout}
className="flex items-center gap-3 rounded-2xl px-6 py-4 text-[11px] font-black uppercase tracking-[0.2em] text-brand-dark/40 dark:text-white/40 hover:text-red-500 transition-colors"
className="flex items-center gap-3 rounded-2xl px-6 py-4 text-[11px] font-black uppercase tracking-[0.2em] text-brand-dark/65 dark:text-white/65 hover:text-red-500 transition-colors"
>
<LogOut size={18} className="shrink-0" />
{t('dashboard.sidebar.signOut')}

View File

@@ -45,7 +45,7 @@ export function DashboardSidebar() {
'flex items-center gap-4 rounded-2xl px-6 py-4 text-[11px] font-black uppercase tracking-[0.2em] transition-all duration-200',
isActive
? 'bg-brand-dark text-white shadow-xl'
: 'text-brand-dark/40 dark:text-white/40 hover:bg-brand-muted dark:hover:bg-[#1f1f1f]'
: 'text-brand-dark/65 dark:text-white/65 hover:bg-brand-muted dark:hover:bg-[#1f1f1f]'
)}
>
<item.icon size={18} className="shrink-0" />
@@ -80,7 +80,7 @@ export function DashboardSidebar() {
<span className="truncate text-[11px] font-black uppercase tracking-tight text-brand-dark dark:text-white">
{user.name}
</span>
<span className="truncate text-[9px] text-brand-dark/40 dark:text-white/40">
<span className="truncate text-[10px] text-brand-dark/60 dark:text-white/60">
{user.email}
</span>
</div>

View File

@@ -1,4 +1,4 @@
import { FileText, BookText, User, Users, Settings, Wrench, type LucideIcon } from 'lucide-react';
import { FileText, BookText, User, type LucideIcon } from 'lucide-react';
export interface NavItem {
labelKey: string;
@@ -10,10 +10,11 @@ export interface NavItem {
export const baseNavItems: NavItem[] = [
{ labelKey: 'dashboard.nav.translate', href: '/dashboard/translate', icon: FileText },
{ labelKey: 'dashboard.nav.glossaries', href: '/dashboard/glossaries', icon: BookText, proOnly: true },
{ labelKey: 'dashboard.nav.teams', href: '/dashboard/teams', icon: Users, proOnly: true },
{ labelKey: 'dashboard.nav.settings', href: '/dashboard/settings', icon: Settings },
{ labelKey: 'dashboard.nav.services', href: '/dashboard/services', icon: Wrench },
{ labelKey: 'dashboard.nav.profile', href: '/dashboard/profile', icon: User },
// API Keys nav item temporarily removed per request — uncomment to restore.
// Teams/Settings/Services/API Keys exist as routes but stay out of the nav until
// product-ready — uncomment to restore.
// { labelKey: 'dashboard.nav.teams', href: '/dashboard/teams', icon: Users, proOnly: true },
// { labelKey: 'dashboard.nav.settings', href: '/dashboard/settings', icon: Settings },
// { labelKey: 'dashboard.nav.services', href: '/dashboard/services', icon: Wrench },
// { labelKey: 'dashboard.nav.apiKeys', href: '/dashboard/api-keys', icon: Key, proOnly: true },
];

View File

@@ -1,85 +0,0 @@
'use client';
import { memo, useCallback } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { BookText, Pencil, Trash2 } from 'lucide-react';
import type { GlossaryListItem } from './types';
import { useI18n, formatDate } from '@/lib/i18n';
interface GlossaryCardProps {
glossary: GlossaryListItem;
onEdit: (id: string) => void;
onDelete: (id: string, name: string) => void;
isDeleting?: boolean;
}
export const GlossaryCard = memo(function GlossaryCard({
glossary,
onEdit,
onDelete,
isDeleting = false,
}: GlossaryCardProps) {
const { t, locale } = useI18n();
const handleEdit = useCallback(() => {
onEdit(glossary.id);
}, [glossary.id, onEdit]);
const handleDelete = useCallback(() => {
onDelete(glossary.id, glossary.name);
}, [glossary.id, glossary.name, onDelete]);
const formattedDate = formatDate(new Date(glossary.created_at), locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
return (
<Card className="group hover:border-border/80 transition-colors">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 min-w-0 flex-1">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-accent/10">
<BookText className="size-5 text-accent" />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-medium text-foreground truncate">{glossary.name}</h3>
<div className="flex items-center gap-2 mt-1">
<Badge variant="secondary" className="text-xs">
{glossary.terms_count} {glossary.terms_count === 1 ? t('glossaries.card.term') : t('glossaries.card.terms')}
</Badge>
<span className="text-xs text-muted-foreground">
{t('glossaries.card.created')} {formattedDate}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon-sm"
onClick={handleEdit}
className="opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={`Edit ${glossary.name}`}
>
<Pencil className="size-3.5 text-muted-foreground hover:text-foreground" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={handleDelete}
disabled={isDeleting}
className="opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={`Delete ${glossary.name}`}
>
<Trash2 className="size-3.5 text-muted-foreground hover:text-destructive" />
</Button>
</div>
</div>
</CardContent>
</Card>
);
});

View File

@@ -19,13 +19,14 @@ import { SUPPORTED_LANGUAGES } from './types';
import { useTranslationStore } from '@/lib/store';
// ── Chips de suggestions pour les consignes de contexte ─────────────────────
const CONTEXT_SUGGESTIONS = [
{ label: 'Ton formel', value: 'Utilise toujours un ton formel et professionnel dans tes traductions.' },
{ label: 'Noms propres', value: 'Ne traduis pas les noms propres, marques et noms de personnes.' },
{ label: 'Chiffres', value: 'Garde tous les chiffres, pourcentages et montants tels quels sans les modifier.' },
{ label: 'Placeholders', value: 'Ne traduis pas les variables entre accolades comme {nom}, {date}, {montant}.' },
{ label: 'Termes techniques', value: 'Conserve les termes techniques en langue originale et ne les traduis pas.' },
{ label: 'Style concis', value: 'Préfère des formulations courtes et directes. Évite les périphrases.' },
// Prompt bodies stay in French (LLM content); labels translate with the UI.
const CONTEXT_SUGGESTIONS: { labelKey: string; value: string }[] = [
{ labelKey: 'glossaries.suggestion.formal', value: 'Utilise toujours un ton formel et professionnel dans tes traductions.' },
{ labelKey: 'glossaries.suggestion.proprietary', value: 'Ne traduis pas les noms propres, marques et noms de personnes.' },
{ labelKey: 'glossaries.suggestion.numbers', value: 'Garde tous les chiffres, pourcentages et montants tels quels sans les modifier.' },
{ labelKey: 'glossaries.suggestion.placeholders', value: 'Ne traduis pas les variables entre accolades comme {nom}, {date}, {montant}.' },
{ labelKey: 'glossaries.suggestion.technical', value: 'Conserve les termes techniques en langue originale et ne les traduis pas.' },
{ labelKey: 'glossaries.suggestion.concise', value: 'Préfère des formulations courtes et directes. Évite les périphrases.' },
];
export default function GlossariesPage() {
@@ -225,11 +226,11 @@ export default function GlossariesPage() {
<div className="flex flex-wrap gap-2">
{CONTEXT_SUGGESTIONS.map((s) => (
<button
key={s.label}
key={s.labelKey}
onClick={() => handleAddSuggestion(s.value)}
className="px-3 py-1.5 rounded-lg text-[11px] font-semibold text-[#3D3D3D] dark:text-white/70 bg-[#F0EDE8] dark:bg-white/5 border border-[#D9D5CE] dark:border-white/10 hover:bg-[#E8E2DA] hover:border-[#C5A17A] dark:hover:bg-brand-accent/10 dark:hover:border-brand-accent/30 transition-all cursor-pointer"
>
+ {s.label}
+ {t(s.labelKey)}
</button>
))}
</div>

View File

@@ -17,6 +17,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/components/ui/toast';
import { useI18n } from '@/lib/i18n';
import { PageHeader } from '@/components/layout/page-header';
import { cn } from '@/lib/utils';
interface Segment {
@@ -77,6 +78,7 @@ export default function ReviewPage() {
const [draft, setDraft] = useState('');
const [savingId, setSavingId] = useState<string | null>(null);
const [isRebuilding, setIsRebuilding] = useState(false);
const [approveArmed, setApproveArmed] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -136,6 +138,13 @@ export default function ReviewPage() {
};
const approveAll = async () => {
// Two-step: the first click arms, the second confirms — no accidental bulk-approve.
if (!approveArmed) {
setApproveArmed(true);
setTimeout(() => setApproveArmed(false), 4000);
return;
}
setApproveArmed(false);
const pending = segments.filter((s) => s.status === 'pending');
for (const seg of pending) {
// sequential is fine — server-side each is a tiny PATCH
@@ -232,10 +241,13 @@ export default function ReviewPage() {
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-foreground">{t('reviews.title')}</h1>
<p className="text-sm text-muted-foreground truncate max-w-xl">
<span className="accent-pill mb-3 block w-fit">{t('reviews.pill')}</span>
<h1 className="mb-2 text-3xl font-serif font-medium leading-tight tracking-tight text-brand-dark dark:text-white">
{t('reviews.titleBase')} <span className="italic">{t('reviews.titleAccent')}</span>
</h1>
<p className="max-w-xl truncate text-sm text-brand-dark/60 dark:text-white/60">
{t('reviews.subtitle', {
file: fileName ?? jobId,
total: counts.total,
@@ -246,8 +258,11 @@ export default function ReviewPage() {
</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" /> {t('reviews.approveAll')}
<Button variant={approveArmed ? 'default' : 'outline'} size="sm" onClick={approveAll} disabled={counts.pending === 0}>
<CheckCheck className="size-3.5" />
{approveArmed
? t('reviews.approveAllConfirm', { count: counts.pending })
: t('reviews.approveAll')}
</Button>
<Button variant="outline" size="sm" onClick={exportXliff} disabled={!segments.length}>
<FileDown className="size-3.5" /> XLIFF
@@ -360,38 +375,35 @@ export default function ReviewPage() {
<div className="flex flex-col items-start gap-2">
{statusBadge(seg.status)}
{!isEditing && (
<div className="flex gap-1">
<div className="flex flex-wrap gap-1">
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
title={t('reviews.action.edit')}
className="h-7 gap-1 px-2 text-[11px]"
onClick={() => {
setEditingId(seg.id);
setDraft(finalText);
}}
>
<Pencil className="size-3" />
<Pencil className="size-3" /> {t('reviews.action.edit')}
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
title={t('reviews.action.approve')}
className="h-7 gap-1 px-2 text-[11px]"
disabled={seg.status === 'approved' || savingId === seg.id}
onClick={() => patchSegment(seg.id, { status: 'approved' })}
>
<Check className="size-3" />
<Check className="size-3" /> {t('reviews.action.approveShort')}
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
title={t('reviews.action.reset')}
className="h-7 gap-1 px-2 text-[11px]"
disabled={seg.status === 'pending'}
onClick={() => patchSegment(seg.id, { status: 'pending' })}
>
<Undo2 className="size-3" />
<Undo2 className="size-3" /> {t('reviews.action.resetShort')}
</Button>
</div>
)}

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { Zap, CheckCircle2, Lock, Loader2, Globe, Brain } from 'lucide-react';
import { API_BASE } from '@/lib/config';
import { useI18n } from '@/lib/i18n';
import { PageHeader } from '@/components/layout/page-header';
const FALLBACK_PROVIDERS_FACTORY = (t: (k: string) => string) => [
{ id: "google", label: t('services.fallback.google.label'), description: t('services.fallback.google.desc'), mode: "classic" as const },
@@ -52,13 +53,13 @@ export default function TranslationServicesPage() {
return (
<div className="max-w-4xl mx-auto w-full p-6 lg:p-8">
{/* ── Editorial Header ───────────────────────────────────── */}
<div className="mb-12">
<span className="accent-pill mb-4 block w-fit">{t('services.pill')}</span>
<h1 className="text-5xl font-black uppercase tracking-tighter mb-4 leading-none text-brand-dark dark:text-white">
{t('services.title')}
</h1>
<p className="text-brand-dark/50 dark:text-white/50 font-medium">{t('services.subtitle')}</p>
</div>
<PageHeader
pill={t('services.pill')}
titleBase={t('services.title')}
titleAccent={t('services.titleAccent')}
subtitle={t('services.subtitle')}
className="mb-12"
/>
{isLoading ? (
<div className="flex items-center justify-center min-h-[30vh]">

View File

@@ -5,6 +5,7 @@ import {
Settings, Trash2, Loader2, FileSpreadsheet, FileText, Presentation,
} from 'lucide-react';
import { useI18n } from '@/lib/i18n';
import { PageHeader } from '@/components/layout/page-header';
export default function GeneralSettingsPage() {
const { t } = useI18n();
@@ -38,13 +39,13 @@ export default function GeneralSettingsPage() {
return (
<div className="max-w-4xl mx-auto w-full p-6 lg:p-8">
{/* ── Editorial Header ───────────────────────────────────── */}
<div className="mb-12">
<span className="accent-pill mb-4 block w-fit">{t('settings.pill')}</span>
<h1 className="text-5xl font-black uppercase tracking-tighter mb-4 leading-none text-brand-dark dark:text-white">
{t('settings.title')}
</h1>
<p className="text-brand-dark/50 dark:text-white/50 font-medium">{t('settings.subtitle')}</p>
</div>
<PageHeader
pill={t('settings.pill')}
titleBase={t('settings.title')}
titleAccent={t('settings.titleAccent')}
subtitle={t('settings.subtitle')}
className="mb-12"
/>
<div className="space-y-12">
{/* Supported Formats */}

View File

@@ -16,6 +16,7 @@ import {
} from '@/components/ui/select';
import { useToast } from '@/components/ui/toast';
import { useI18n } from '@/lib/i18n';
import { PageHeader } from '@/components/layout/page-header';
interface WorkspaceMember {
id: string;
@@ -133,19 +134,12 @@ export default function TeamsPage() {
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-blue-600/20">
<Users className="size-5 text-blue-400" />
</div>
<div>
<h1 className="text-xl font-semibold text-foreground">{t('teams.title')}</h1>
<p className="text-sm text-muted-foreground">
{t('teams.subtitle')}
</p>
</div>
</div>
</div>
<PageHeader
pill={t('teams.pill')}
titleBase={t('teams.titleBase')}
titleAccent={t('teams.titleAccent')}
subtitle={t('teams.subtitle')}
/>
{needsBusiness ? (
<div className="rounded-lg border border-border bg-card p-6 text-center">

View File

@@ -70,16 +70,34 @@ function Combobox({
const label = value === 'auto' ? autoLabel : allOptions.find(l => l.code === value)?.name ?? value;
const handleListboxKeys = (e: React.KeyboardEvent) => {
if (e.key === 'Escape' && open) {
e.stopPropagation();
closeAndRefocus();
return;
}
if (!open || (e.key !== 'ArrowDown' && e.key !== 'ArrowUp')) return;
e.preventDefault();
const options = Array.from(
ref.current?.querySelectorAll<HTMLButtonElement>('[role="option"]') ?? []
);
if (options.length === 0) return;
const idx = options.indexOf(document.activeElement as HTMLButtonElement);
const next = e.key === 'ArrowDown'
? options[Math.min(options.length - 1, idx + 1)]
: options[Math.max(0, idx - 1)];
// from the search input, ArrowDown goes to the first option
(idx === -1 && document.activeElement === inputRef.current
? options[e.key === 'ArrowDown' ? 0 : options.length - 1]
: next
).focus();
};
return (
<div
ref={ref}
className="relative text-left"
onKeyDown={(e) => {
if (e.key === 'Escape' && open) {
e.stopPropagation();
closeAndRefocus();
}
}}
onKeyDown={handleListboxKeys}
>
<button
ref={triggerRef}

View File

@@ -155,10 +155,10 @@ export function ProviderSelector({
>
<div className="flex items-center justify-between">
<span className={cn(
"text-[9.5px] font-black uppercase tracking-tight truncate flex-1",
isSelected ? 'text-brand-accent' : 'text-brand-dark/40 dark:text-white/40'
"text-[10px] font-black uppercase tracking-tight truncate flex-1",
isSelected ? 'text-brand-accent' : 'text-brand-dark/60 dark:text-white/60'
)}>
Standard
{t('providerSelector.standard')}
</span>
{isSelected && <div className="w-1.5 h-1.5 rounded-full bg-brand-accent animate-pulse shrink-0 ml-1" />}
</div>

View File

@@ -142,13 +142,13 @@ export default function TranslatePage() {
};
const handleNewTranslation = () => { submit.reset(); upload.removeFile(); setElapsed(0); setRecentJobs(getRecentJobs()); };
const handleDownload = async () => {
if (!submit.jobId) return;
const handleDownload = async (jobId: string = submit.jobId ?? '') => {
if (!jobId) return;
const token = localStorage.getItem('token');
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
try {
const response = await fetch(`${API_BASE}/api/v1/download/${submit.jobId}`, { headers });
const response = await fetch(`${API_BASE}/api/v1/download/${jobId}`, { headers });
if (!response.ok) {
showError({ title: t('dashboard.translate.error.title'), description: t('translate.downloadFailed') });
return;
@@ -180,6 +180,19 @@ export default function TranslatePage() {
const isCompleted = submit.status === 'completed';
const isFailed = submit.status === 'failed';
// Ctrl/Cmd+Enter launches the translation from anywhere on the page
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && upload.file && config.isConfigValid && !submit.isSubmitting && !isProcessing) {
e.preventDefault();
handleTranslate();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [upload.file, config.isConfigValid, submit.isSubmitting, isProcessing]);
const showUpload = !upload.file && !isProcessing && !isCompleted && !isFailed;
const showConfiguring = isConfiguring;
const showProcessing = isProcessing;
@@ -311,18 +324,27 @@ export default function TranslatePage() {
{t('translate.recent.title')}
</h4>
<ul className="divide-y divide-black/[0.03] dark:divide-white/[0.03]">
{recentJobs.slice(0, 4).map((job) => (
{recentJobs.slice(0, 6).map((job) => (
<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">
<FileText className="size-3.5 shrink-0 text-brand-accent" />
<span className="truncate">{job.fileName || job.jobId}</span>
</span>
<span className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={() => handleDownload(job.jobId)}
className="rounded-lg px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-brand-dark/60 dark:text-white/60 border border-black/10 dark:border-white/10 hover:bg-brand-muted/50 transition-colors"
>
<Download size={11} className="inline" />
</button>
<Link
href={`/dashboard/reviews/${job.jobId}`}
className="shrink-0 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-accent border border-brand-accent/25 hover:bg-brand-accent/10 transition-colors"
>
{t('translate.recent.review')}
</Link>
</span>
</li>
))}
</ul>
@@ -446,21 +468,18 @@ export default function TranslatePage() {
</div>
<div className="text-left">
<p className="text-[13px] font-bold uppercase tracking-[0.1em] text-brand-dark dark:text-white">
{t('translate.header.completedTitle')}
{t('translate.header.completedTitleBase')} {t('translate.header.completedTitleAccent')}
</p>
<p className="text-[11px] text-brand-dark/50 dark:text-white/50 font-semibold uppercase mt-1 tracking-wider max-w-[300px] truncate">
{submit.fileName}
</p>
</div>
</div>
<span className="px-5 py-2 bg-white dark:bg-[#1a1a1a] rounded-full text-[10px] font-bold uppercase tracking-wider text-brand-accent border border-brand-accent/20 shadow-sm shrink-0">
{t('translate.complete.masterQuality')}
</span>
</div>
<div className="flex-1 flex flex-col items-center justify-center py-16 bg-brand-muted/20 dark:bg-white/5 rounded-[40px] border border-black/5 dark:border-white/5">
<button
onClick={handleDownload}
onClick={() => handleDownload()}
className="premium-button px-24 py-6 text-xl !rounded-full flex items-center gap-6 mb-8 group cursor-pointer hover:scale-[1.02] active:scale-95"
>
<Download size={28} className="group-hover:translate-y-1 transition-transform" />

View File

@@ -233,8 +233,10 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
const isConfigValid = useMemo(() => {
if (!hasFile || !targetLang) return false;
if (!provider) return false;
// Translating a language into itself is always a mistake
if (sourceLang !== 'auto' && sourceLang === targetLang) return false;
return true;
}, [hasFile, targetLang, provider]);
}, [hasFile, targetLang, provider, sourceLang]);
const getConfig = useCallback((): TranslationConfig => ({
sourceLang,

View File

@@ -15,7 +15,7 @@ const MAX_POLLING_FAILURES = 3;
/* ── Job persistence: survive a refresh mid-translation ─────────── */
const ACTIVE_JOB_KEY = 'wordly:activeJob';
const RECENT_JOBS_KEY = 'wordly:recentJobs';
const MAX_RECENT_JOBS = 8;
const MAX_RECENT_JOBS = 20;
const ACTIVE_JOB_TTL_MS = 24 * 60 * 60 * 1000; // discard stale entries after 24h
interface StoredJob { jobId: string; fileName: string | null; savedAt: number }

View File

@@ -11,6 +11,9 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { API_BASE } from "@/lib/config";
import { ANNUAL_DISCOUNT_PERCENT } from "@/lib/pricing";
@@ -213,7 +216,7 @@ const PLAN_ICONS: Record<string, any> = {
};
const PLAN_COLORS: Record<string, { header: string; iconColor: string; nameColor: string }> = {
free: { header: "bg-muted", iconColor: "text-foreground/20", nameColor: "text-foreground/40" },
free: { header: "bg-muted", iconColor: "text-foreground/40", nameColor: "text-foreground/40" },
starter: { header: "bg-foreground", iconColor: "text-white/20", nameColor: "text-white/50" },
pro: { header: "bg-accent", iconColor: "text-white/30", nameColor: "text-white/50" },
business: { header: "bg-foreground", iconColor: "text-accent/40", nameColor: "text-white/50" },
@@ -292,6 +295,8 @@ export default function PricingPage() {
const [annualDiscountPercent, setAnnualDiscountPercent] = useState(ANNUAL_DISCOUNT_PERCENT);
/** Until false: don't show STATIC_PLANS (avoids flash of stale prices on refresh). */
const [pricingLoaded, setPricingLoaded] = useState(false);
/** Plan awaiting explicit confirmation before any Stripe redirect. */
const [confirmPlan, setConfirmPlan] = useState<Plan | null>(null);
useEffect(() => {
// Fetch live plans — backend returns prices set by admin (no browser cache)
@@ -335,14 +340,13 @@ export default function PricingPage() {
}
}, []);
// Auto-trigger checkout when ?plan= is in the URL and user is already logged in
// ?plan= in the URL pre-opens the confirmation dialog — never a silent checkout.
useEffect(() => {
if (!pricingLoaded) return;
if (planFromUrl && isLoggedIn && plans.length > 0 && currentPlan !== null) {
const targetPlan = plans.find(p => p.id === planFromUrl);
if (targetPlan && targetPlan.price_monthly > 0 && currentPlan !== planFromUrl) {
// Directly initiate checkout
handleSubscribe(planFromUrl);
setConfirmPlan(targetPlan);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -465,7 +469,7 @@ export default function PricingPage() {
<div className="flex gap-2 p-1.5 bg-muted rounded-full border border-black/5 shadow-inner">
<Link
href={isLoggedIn ? "/dashboard" : "/"}
className="px-6 py-2 rounded-full text-[9px] font-black uppercase tracking-widest text-foreground/40 hover:text-foreground transition-all"
className="px-6 py-2 rounded-full text-[11px] font-bold uppercase tracking-wider text-foreground/60 hover:text-foreground transition-all"
>
{t('pricing.dashboard')}
</Link>
@@ -481,6 +485,47 @@ export default function PricingPage() {
</div>
</div>
{/* ── Checkout confirmation — no Stripe redirect without an explicit confirm ── */}
<Dialog open={!!confirmPlan} onOpenChange={(open) => { if (!open) setConfirmPlan(null); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('pricing.confirm.title')}</DialogTitle>
<DialogDescription>{t('pricing.confirm.subtitle')}</DialogDescription>
</DialogHeader>
{confirmPlan && (
<div className="space-y-4 py-2">
<div className="flex items-center justify-between rounded-xl border border-border/60 px-4 py-3">
<span className="text-sm font-semibold">{t(confirmPlan.name)}</span>
<span className="text-lg font-bold">
{isYearly
? `${Number(confirmPlan.price_yearly.toFixed(2))} € / ${t('pricing.confirm.year')}`
: `${Number(confirmPlan.price_monthly.toFixed(2))} € / ${t('pricing.confirm.month')}`}
</span>
</div>
{isYearly && confirmPlan.price_yearly > 0 && (
<p className="text-xs text-muted-foreground">
{t('pricing.confirm.monthlyEquivalent', { price: (confirmPlan.price_yearly / 12).toFixed(2) })}
</p>
)}
<p className="text-xs text-muted-foreground leading-relaxed">
{t('pricing.confirm.secureNote')}
</p>
</div>
)}
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setConfirmPlan(null)}>
{t('pricing.confirm.cancel')}
</Button>
<Button
disabled={loadingPlanId !== null}
onClick={() => { if (confirmPlan) handleSubscribe(confirmPlan.id); }}
>
{loadingPlanId !== null ? t('pricing.card.processing') : t('pricing.confirm.cta')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Toast notification ── */}
{toastMsg && (
<div className={cn(
@@ -497,17 +542,17 @@ export default function PricingPage() {
{/* ── Header ── */}
<div className="max-w-[1400px] mx-auto px-4 text-center mb-20">
<span className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full border border-black/5 mb-6">
<div className="w-2 h-2 bg-accent rounded-full animate-pulse" />
<span className="text-[10px] font-black tracking-[0.3em] opacity-40">{t('pricing.header.badge')}</span>
<span className="accent-pill mb-6 mx-auto flex w-fit items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-accent animate-pulse" aria-hidden="true" />
{t('pricing.header.badge')}
</span>
<h1 className="text-5xl md:text-7xl font-black uppercase tracking-tighter mb-6 leading-none">
<h1 className="mb-6 text-4xl md:text-6xl font-serif font-medium tracking-tight leading-tight text-brand-dark dark:text-white">
{t('pricing.header.titleBase')}{" "}
<span className="text-accent">
<span className="italic text-accent">
{t('pricing.header.titleAccent')}
</span>
</h1>
<p className="text-foreground/40 font-medium text-xl max-w-2xl mx-auto leading-relaxed">
<p className="text-brand-dark/60 dark:text-white/60 font-light text-lg max-w-2xl mx-auto leading-relaxed">
{t('pricing.header.subtitle')}
</p>
</div>
@@ -560,12 +605,12 @@ export default function PricingPage() {
{plan.popular && (
<div className="absolute top-0 right-0 p-3 flex gap-2">
{plan.badge && (
<span className="bg-foreground/20 backdrop-blur-md text-white text-[8px] font-black uppercase tracking-widest px-3 py-1 rounded-full border border-white/20 shadow-lg">
<span className="bg-foreground/20 backdrop-blur-md text-white text-[10px] font-black uppercase tracking-wider px-3 py-1 rounded-full border border-white/20 shadow-lg">
{t(plan.badge)}
</span>
)}
{isCurrent && (
<span className="bg-foreground/40 backdrop-blur-md text-white text-[8px] font-black uppercase tracking-widest px-3 py-1 rounded-full border border-white/20 shadow-lg flex items-center gap-1">
<span className="bg-foreground/40 backdrop-blur-md text-white text-[10px] font-black uppercase tracking-wider px-3 py-1 rounded-full border border-white/20 shadow-lg flex items-center gap-1">
<div className="w-1.5 h-1.5 bg-accent rounded-full animate-pulse" /> {t('pricing.card.myPlan')}
</span>
)}
@@ -573,7 +618,7 @@ export default function PricingPage() {
)}
{plan.badge && !plan.popular && (
<div className="absolute top-0 right-0 p-3">
<span className="bg-white/10 backdrop-blur-md text-white text-[8px] font-black uppercase tracking-widest px-3 py-1 rounded-full border border-white/10">
<span className="bg-white/10 backdrop-blur-md text-white text-[10px] font-black uppercase tracking-wider px-3 py-1 rounded-full border border-white/10">
{t(plan.badge)}
</span>
</div>
@@ -581,7 +626,7 @@ export default function PricingPage() {
{/* "My Plan" badge for current non-popular plan */}
{isCurrent && !plan.popular && (
<div className="absolute top-0 right-0 p-3">
<span className="bg-foreground/40 backdrop-blur-md text-white text-[8px] font-black uppercase tracking-widest px-3 py-1 rounded-full border border-white/20 shadow-lg flex items-center gap-1">
<span className="bg-foreground/40 backdrop-blur-md text-white text-[10px] font-black uppercase tracking-wider px-3 py-1 rounded-full border border-white/20 shadow-lg flex items-center gap-1">
<div className="w-1.5 h-1.5 bg-accent rounded-full animate-pulse" /> {t('pricing.card.myPlan')}
</span>
</div>
@@ -608,7 +653,7 @@ export default function PricingPage() {
) : (
<>
<h3 className="text-3xl font-black uppercase tracking-tighter text-white">{price} &euro;</h3>
<span className="text-[10px] font-bold uppercase tracking-widest text-white/40">{t('pricing.card.perMonth')}</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-white/70">{t('pricing.card.perMonth')}</span>
</>
)}
</div>
@@ -632,8 +677,8 @@ export default function PricingPage() {
<div className="space-y-2 mb-10">
<div className="flex justify-between items-center py-2 border-b border-black/[0.03] dark:border-border/20">
<div className="flex items-center gap-2">
<FileText size={12} className="text-foreground/20" />
<span className="text-[9px] font-black uppercase tracking-widest text-foreground/40">{t('pricing.card.documents')}</span>
<FileText size={12} className="text-foreground/40" />
<span className="text-[11px] font-bold uppercase tracking-wider text-foreground/60">{t('pricing.card.documents')}</span>
</div>
<span className="text-[10px] font-black uppercase text-foreground">
{plan.docs_per_month === -1 ? t('pricing.card.unlimited') : `${plan.docs_per_month} ${t('pricing.card.perMonthStat')}`}
@@ -641,8 +686,8 @@ export default function PricingPage() {
</div>
<div className="flex justify-between items-center py-2 border-b border-black/[0.03] dark:border-border/20">
<div className="flex items-center gap-2">
<Layers size={12} className="text-foreground/20" />
<span className="text-[9px] font-black uppercase tracking-widest text-foreground/40">{t('pricing.card.pagesMax')}</span>
<Layers size={12} className="text-foreground/40" />
<span className="text-[11px] font-bold uppercase tracking-wider text-foreground/60">{t('pricing.card.pagesMax')}</span>
</div>
<span className="text-[10px] font-black uppercase text-foreground">
{plan.max_pages_per_doc === -1 ? t('pricing.card.unlimited') : `${plan.max_pages_per_doc} ${t('pricing.card.perDoc')}`}
@@ -658,7 +703,7 @@ export default function PricingPage() {
<div className="flex items-center gap-2">
<Activity size={12} className={
plan.ai_tier === "essential" ? "text-accent/40" :
"text-foreground/20"
"text-foreground/40"
} />
<span className={cn(
"text-[9px] font-black uppercase tracking-widest",
@@ -711,7 +756,7 @@ export default function PricingPage() {
</Link>
) : (
<button
onClick={() => handleSubscribe(plan.id)}
onClick={() => setConfirmPlan(plan)}
disabled={loadingPlanId !== null}
className={cn(
"w-full py-4 rounded-2xl text-[11px] font-black uppercase tracking-widest transition-all flex items-center justify-center gap-3 border shadow-sm hover:shadow-xl active:scale-95",

View File

@@ -106,7 +106,7 @@ const Hero = () => {
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, ease: "easeOut" }}
>
<div className="inline-flex items-center gap-2 px-4 py-1.5 mb-10 text-[10px] font-bold tracking-[0.3em] text-brand-dark/40 dark:text-white/40 uppercase bg-transparent border border-black/10 dark:border-white/10 rounded-full">
<div className="inline-flex items-center gap-2 px-4 py-1.5 mb-10 text-[10px] font-bold tracking-[0.3em] text-brand-dark/60 dark:text-white/60 uppercase bg-transparent border border-black/10 dark:border-white/10 rounded-full">
<Languages size={10} /> {t('landing.hero.tag')}
</div>
<h1 className="text-6xl md:text-8xl mb-10 text-brand-dark dark:text-white font-serif font-medium tracking-tight leading-[1.1]">
@@ -187,7 +187,7 @@ const Steps = () => {
<div className="mb-24">
<span className="accent-pill mb-6 block w-fit">{t('landing.steps.process')}</span>
<h2 className="text-4xl md:text-5xl text-brand-dark dark:text-white font-serif font-medium tracking-tight mb-6">{t('landing.steps.title')}</h2>
<p className="text-brand-dark/40 dark:text-white/40 text-lg max-w-xl font-light">{t('landing.steps.subtitle')}</p>
<p className="text-brand-dark/60 dark:text-white/60 text-lg max-w-xl font-light">{t('landing.steps.subtitle')}</p>
</div>
<div className="grid md:grid-cols-3 gap-16">
@@ -197,7 +197,7 @@ const Steps = () => {
{st.icon}
</div>
<h3 className="text-xl font-serif font-medium text-brand-dark dark:text-white mb-4">{st.title}</h3>
<p className="text-brand-dark/40 dark:text-white/40 leading-relaxed font-light">{st.desc}</p>
<p className="text-brand-dark/60 dark:text-white/60 leading-relaxed font-light">{st.desc}</p>
</div>
))}
</div>
@@ -222,9 +222,9 @@ const FeaturesContext = () => {
<div className="grid md:grid-cols-3 gap-8">
{[
{ icon: <Monitor className="text-brand-dark/40 dark:text-white/40" />, title: t('landing.features.context.title'), desc: t('landing.features.context.desc') },
{ icon: <Layers className="text-brand-dark/40 dark:text-white/40" />, title: t('landing.features.glossary.title'), desc: t('landing.features.glossary.desc') },
{ icon: <Eye className="text-brand-dark/40 dark:text-white/40" />, title: t('landing.features.vision.title'), desc: t('landing.features.vision.desc') },
{ icon: <Monitor className="text-brand-dark/60 dark:text-white/60" />, title: t('landing.features.context.title'), desc: t('landing.features.context.desc') },
{ icon: <Layers className="text-brand-dark/60 dark:text-white/60" />, title: t('landing.features.glossary.title'), desc: t('landing.features.glossary.desc') },
{ icon: <Eye className="text-brand-dark/60 dark:text-white/60" />, title: t('landing.features.vision.title'), desc: t('landing.features.vision.desc') },
].map((item, i) => (
<div key={i} className="editorial-card p-10 flex flex-col items-start gap-8">
<div className="w-10 h-10 border border-black/5 dark:border-white/10 rounded-full flex items-center justify-center">
@@ -232,7 +232,7 @@ const FeaturesContext = () => {
</div>
<div>
<h4 className="text-xl font-serif font-medium mb-4 text-brand-dark dark:text-white">{item.title}</h4>
<p className="text-brand-dark/40 dark:text-white/40 leading-relaxed font-light text-sm">{item.desc}</p>
<p className="text-brand-dark/60 dark:text-white/60 leading-relaxed font-light text-sm">{item.desc}</p>
</div>
</div>
))}
@@ -283,10 +283,10 @@ const LayoutFeatures = () => {
const FormatsSection = () => {
const { t } = useI18n();
const formats = [
{ id: ".DOCX", name: t('landing.formats.word.name'), icon: <FileText className="text-brand-dark/40 dark:text-white/40" />, items: [t('landing.formats.word.i1'), t('landing.formats.word.i2'), t('landing.formats.word.i3'), t('landing.formats.word.i4')] },
{ id: ".XLSX", name: t('landing.formats.excel.name'), icon: <FileSpreadsheet className="text-brand-dark/40 dark:text-white/40" />, items: [t('landing.formats.excel.i1'), t('landing.formats.excel.i2'), t('landing.formats.excel.i3'), t('landing.formats.excel.i4')] },
{ id: ".PPTX", name: t('landing.formats.pptx.name'), icon: <Presentation className="text-brand-dark/40 dark:text-white/40" />, items: [t('landing.formats.pptx.i1'), t('landing.formats.pptx.i2'), t('landing.formats.pptx.i3'), t('landing.formats.pptx.i4')] },
{ id: ".PDF", name: t('landing.formats.pdf.name'), icon: <FileText className="text-brand-dark/40 dark:text-white/40" />, items: [t('landing.formats.pdf.i1'), t('landing.formats.pdf.i2'), t('landing.formats.pdf.i3'), t('landing.formats.pdf.i4')] },
{ id: ".DOCX", name: t('landing.formats.word.name'), icon: <FileText className="text-brand-dark/60 dark:text-white/60" />, items: [t('landing.formats.word.i1'), t('landing.formats.word.i2'), t('landing.formats.word.i3'), t('landing.formats.word.i4')] },
{ id: ".XLSX", name: t('landing.formats.excel.name'), icon: <FileSpreadsheet className="text-brand-dark/60 dark:text-white/60" />, items: [t('landing.formats.excel.i1'), t('landing.formats.excel.i2'), t('landing.formats.excel.i3'), t('landing.formats.excel.i4')] },
{ id: ".PPTX", name: t('landing.formats.pptx.name'), icon: <Presentation className="text-brand-dark/60 dark:text-white/60" />, items: [t('landing.formats.pptx.i1'), t('landing.formats.pptx.i2'), t('landing.formats.pptx.i3'), t('landing.formats.pptx.i4')] },
{ id: ".PDF", name: t('landing.formats.pdf.name'), icon: <FileText className="text-brand-dark/60 dark:text-white/60" />, items: [t('landing.formats.pdf.i1'), t('landing.formats.pdf.i2'), t('landing.formats.pdf.i3'), t('landing.formats.pdf.i4')] },
];
return (
@@ -295,7 +295,7 @@ const FormatsSection = () => {
<div className="mb-24">
<span className="accent-pill mb-6 block w-fit">{t('landing.formats.pill')}</span>
<h2 className="text-4xl md:text-5xl text-brand-dark dark:text-white mb-6 leading-tight font-serif font-medium tracking-tight">{t('landing.formats.title')} <span className="italic">{t('landing.formats.title2')}</span></h2>
<p className="text-brand-dark/40 dark:text-white/40 max-w-xl text-lg font-light">{t('landing.formats.subtitle')}</p>
<p className="text-brand-dark/60 dark:text-white/60 max-w-xl text-lg font-light">{t('landing.formats.subtitle')}</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
{formats.map((f, i) => (
@@ -304,12 +304,12 @@ 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">
{f.icon}
</div>
<span className="text-[9px] font-bold text-brand-dark/20 dark:text-white/20 uppercase tracking-[0.2em]">{f.id}</span>
<span className="text-[9px] font-bold text-brand-dark/50 dark:text-white/50 uppercase tracking-[0.2em]">{f.id}</span>
</div>
<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">
{f.items.map((item, j) => (
<li key={j} className="flex items-start gap-3 text-xs text-brand-dark/40 dark:text-white/40 font-medium">
<li key={j} className="flex items-start gap-3 text-xs text-brand-dark/60 dark:text-white/60 font-medium">
<div className="w-1 h-1 bg-brand-accent rounded-full mt-1.5 shrink-0 opacity-40"></div> {item}
</li>
))}
@@ -337,14 +337,14 @@ const PricingSection = () => {
<div className="max-w-7xl mx-auto">
<div className="text-center mb-24">
<h2 className="text-4xl md:text-5xl mb-8 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">{t('landing.pricing.title')}</h2>
<p className="text-brand-dark/40 dark:text-white/40 text-xl font-light max-w-2xl mx-auto">{t('landing.pricing.subtitle')}</p>
<p className="text-brand-dark/60 dark:text-white/60 text-xl font-light max-w-2xl mx-auto">{t('landing.pricing.subtitle')}</p>
</div>
<div className="flex items-center justify-center gap-12 mb-32">
<button onClick={() => setIsAnnual(false)} className={`text-sm font-bold uppercase tracking-widest transition-all ${!isAnnual ? 'text-brand-dark dark:text-white underline underline-offset-8' : 'text-brand-dark/30 dark:text-white/30 hover:text-brand-dark dark:hover:text-white'}`}>
<button onClick={() => setIsAnnual(false)} className={`text-sm font-bold uppercase tracking-widest transition-all ${!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.monthly')}
</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/30 dark:text-white/30 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')}
<span className="absolute -top-6 left-1/2 -translate-x-1/2 text-[9px] font-bold text-brand-accent whitespace-nowrap italic">
(-20%)
@@ -356,11 +356,11 @@ const PricingSection = () => {
{plans.map((p, i) => (
<div key={i} className="p-16 bg-white dark:bg-[#141414] flex flex-col text-left transition-all duration-500 hover:z-10 hover:shadow-2xl hover:scale-[1.01]">
<h3 className="text-2xl font-serif font-medium mb-2 text-brand-dark dark:text-white tracking-tight">{p.name}</h3>
<p className="text-brand-dark/40 dark:text-white/40 text-xs font-light mb-14 tracking-widest uppercase">{p.desc}</p>
<p className="text-brand-dark/60 dark:text-white/60 text-xs font-light mb-14 tracking-widest uppercase">{p.desc}</p>
<div className="mb-14">
<span className="text-5xl font-serif font-medium leading-none text-brand-dark dark:text-white">{isAnnual ? p.annualPrice : p.monthlyPrice}</span>
<span className="text-brand-dark/30 dark:text-white/30 font-light ml-2 uppercase text-[10px] tracking-[0.2em]">/ {t('landing.pricing.month')}</span>
<span className="text-brand-dark/55 dark:text-white/55 font-light ml-2 uppercase text-[10px] tracking-[0.2em]">/ {t('landing.pricing.month')}</span>
</div>
<ul className="space-y-6 mb-16 flex-grow">
@@ -381,7 +381,7 @@ const PricingSection = () => {
))}
</div>
<p className="text-center mt-12 text-brand-dark/20 dark:text-white/20 text-xs font-bold uppercase tracking-widest">{t('landing.pricing.footer')}</p>
<p className="text-center mt-12 text-brand-dark/50 dark:text-white/50 text-xs font-bold uppercase tracking-widest">{t('landing.pricing.footer')}</p>
</div>
</section>
);
@@ -395,7 +395,7 @@ const CTASection = () => {
<h2 className="text-5xl md:text-7xl text-brand-dark dark:text-white mb-10 leading-tight tracking-tight font-serif font-medium">
{t('landing.cta.title')}
</h2>
<p className="text-lg text-brand-dark/40 dark:text-white/40 mb-16 font-light">
<p className="text-lg text-brand-dark/60 dark:text-white/60 mb-16 font-light">
{t('landing.cta.subtitle')}
</p>
<div className="flex flex-col sm:flex-row gap-6 justify-center items-center">
@@ -416,14 +416,14 @@ const Footer = () => {
<div className="grid md:grid-cols-12 gap-20 mb-40">
<div className="md:col-span-12 lg:col-span-6">
<Logo />
<p className="text-brand-dark/40 dark:text-white/40 text-lg font-medium mt-12 mb-12 max-w-sm leading-relaxed uppercase tracking-widest">
<p className="text-brand-dark/60 dark:text-white/60 text-lg font-medium mt-12 mb-12 max-w-sm leading-relaxed uppercase tracking-widest">
{t('landing.footer.desc')}
</p>
</div>
<div className="md:col-span-4 lg:col-span-2">
<h4 className="text-[10px] font-bold uppercase tracking-[0.3em] text-brand-dark dark:text-white mb-10">{t('landing.footer.product')}</h4>
<ul className="space-y-6 text-xs text-brand-dark/40 dark:text-white/40 font-bold uppercase tracking-widest leading-none">
<ul className="space-y-6 text-xs text-brand-dark/60 dark:text-white/60 font-bold uppercase tracking-widest leading-none">
<li><a href="#features" className="hover:text-brand-dark dark:hover:text-white transition-colors">{t('landing.nav.why')}</a></li>
<li><a href="#formats" className="hover:text-brand-dark dark:hover:text-white transition-colors">{t('landing.nav.formats')}</a></li>
<li><a href="#pricing" className="hover:text-brand-dark dark:hover:text-white transition-colors">{t('landing.nav.pricing')}</a></li>
@@ -433,14 +433,23 @@ const Footer = () => {
</div>
<div className="pt-10 border-t border-black/5 dark:border-white/5 flex flex-col md:flex-row justify-between items-center gap-10">
<div className="text-[10px] font-bold text-brand-dark/20 dark:text-white/20 uppercase tracking-[0.4em]">
<div className="text-[10px] font-bold text-brand-dark/50 dark:text-white/50 uppercase tracking-[0.4em]">
{t('landing.footer.rights')}
</div>
<div className="text-[10px] font-mono text-brand-dark/30 dark:text-white/30 uppercase tracking-widest">
<div className="flex items-center gap-6">
<button
type="button"
onClick={() => import('@/components/ui/cookie-consent').then(m => m.reopenCookieConsent())}
className="text-[10px] font-bold text-brand-dark/50 dark:text-white/50 uppercase tracking-[0.2em] hover:text-brand-dark dark:hover:text-white transition-colors"
>
{t('cookieConsent.reopen')}
</button>
<div className="text-[10px] font-mono text-brand-dark/55 dark:text-white/55 uppercase tracking-widest">
wordly.art
</div>
</div>
</div>
</div>
</footer>
);
};

View File

@@ -0,0 +1,40 @@
'use client';
import type { ReactNode } from 'react';
/**
* Single editorial page header: accent pill + serif title (base + italic accent)
* + subtitle. Every dashboard surface uses this so the product speaks one voice.
*/
export function PageHeader({
pill,
titleBase,
titleAccent,
subtitle,
action,
className = '',
}: {
pill: string;
titleBase: string;
titleAccent: string;
subtitle?: string;
action?: ReactNode;
className?: string;
}) {
return (
<div className={`mb-10 flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between ${className}`}>
<div>
<span className="accent-pill mb-4 block w-fit">{pill}</span>
<h1 className="mb-3 text-4xl font-serif font-medium leading-tight tracking-tight text-brand-dark dark:text-white md:text-5xl">
{titleBase} <span className="italic">{titleAccent}</span>
</h1>
{subtitle && (
<p className="max-w-xl text-sm font-light leading-relaxed text-brand-dark/60 dark:text-white/60">
{subtitle}
</p>
)}
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
);
}

View File

@@ -6,6 +6,13 @@ import { motion, AnimatePresence } from "framer-motion";
import { Button } from "@/components/ui/button";
const STORAGE_KEY = "cookie-consent";
const REOPEN_EVENT = "wordly:reopen-cookie-consent";
/** Reopen the banner after a choice was made (footer link uses this). */
export function reopenCookieConsent() {
localStorage.removeItem(STORAGE_KEY);
window.dispatchEvent(new Event(REOPEN_EVENT));
}
export function CookieConsent() {
const { t } = useI18n();
@@ -13,6 +20,9 @@ export function CookieConsent() {
useEffect(() => {
setVisible(!localStorage.getItem(STORAGE_KEY));
const onReopen = () => setVisible(true);
window.addEventListener(REOPEN_EVENT, onReopen);
return () => window.removeEventListener(REOPEN_EVENT, onReopen);
}, []);
function acceptAll() {
@@ -38,7 +48,7 @@ export function CookieConsent() {
<div className="mx-auto max-w-3xl rounded-t-2xl border border-border bg-background/95 backdrop-blur-md shadow-2xl p-6">
<div className="flex items-start gap-3">
<span className="text-2xl shrink-0" aria-hidden="true">
🍪
<CookieIcon />
</span>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground mb-1">
@@ -63,3 +73,16 @@ export function CookieConsent() {
</AnimatePresence>
);
}
/** Drawn icon instead of an emoji — keeps one consistent icon system. */
function CookieIcon() {
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-foreground/70" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<circle cx="9" cy="9" r="0.5" fill="currentColor" />
<circle cx="14" cy="13" r="0.5" fill="currentColor" />
<circle cx="10" cy="15" r="0.5" fill="currentColor" />
<circle cx="16" cy="8" r="0.5" fill="currentColor" />
</svg>
);
}

View File

@@ -3,6 +3,7 @@
import React, { useState, useRef, useEffect, useCallback } from "react";
import { Search, List, Loader2, Check } from "lucide-react";
import { Input } from "@/components/ui/input";
import { useI18n } from "@/lib/i18n";
export interface ModelOption {
id: string;
@@ -31,6 +32,7 @@ export function ModelCombobox({
providerLabel,
placeholder = "nom-du-modele",
}: ModelComboboxProps) {
const { t } = useI18n();
const [isOpen, setIsOpen] = useState(false);
const [filter, setFilter] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
@@ -108,17 +110,17 @@ export function ModelCombobox({
type="button"
onClick={handleBrowse}
disabled={isLoading}
className="flex items-center gap-1.5 px-3 h-10 rounded-lg border border-border-subtle bg-surface text-sm text-muted-foreground hover:bg-surface-elevated hover:text-foreground transition-colors disabled:opacity-50"
className="flex items-center gap-1.5 px-3 h-10 rounded-lg border border-border bg-card text-sm text-muted-foreground hover:bg-card hover:text-foreground transition-colors disabled:opacity-50"
>
{isLoading ? <Loader2 className="size-4 animate-spin" /> : <List className="size-4" />}
<span className="hidden sm:inline">Parcourir</span>
<span className="hidden sm:inline">{t('common.browse')}</span>
</button>
</div>
{isOpen && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border-subtle bg-card shadow-xl max-h-72 overflow-hidden flex flex-col">
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border bg-card shadow-xl max-h-72 overflow-hidden flex flex-col">
{/* Filter */}
<div className="p-2 border-b border-border-subtle">
<div className="p-2 border-b border-border">
<Input
placeholder={`Filtrer...`}
value={filter}
@@ -132,7 +134,7 @@ export function ModelCombobox({
<ul ref={listRef} className="overflow-y-auto flex-1">
{filtered.length === 0 ? (
<li className="px-3 py-4 text-xs text-muted-foreground text-center">
{models.length === 0 ? "Aucun modele recupere" : "Aucun modele ne correspond"}
{models.length === 0 ? t('common.noModels') : t('common.noModelMatch')}
</li>
) : (
filtered.map((model, i) => (
@@ -141,7 +143,7 @@ export function ModelCombobox({
onClick={() => handleSelect(model)}
onMouseEnter={() => setHighlightIndex(i)}
className={`flex items-center justify-between px-3 py-2 text-xs cursor-pointer transition-colors ${
i === highlightIndex ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-surface-elevated"
i === highlightIndex ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-card"
} ${value === model.id ? "bg-primary/5" : ""}`}
>
<div className="flex-1 min-w-0">
@@ -160,7 +162,7 @@ export function ModelCombobox({
</ul>
{models.length > 0 && (
<div className="px-3 py-1.5 border-t border-border-subtle text-xs text-muted-foreground text-center bg-card">
<div className="px-3 py-1.5 border-t border-border text-xs text-muted-foreground text-center bg-card">
{filtered.length} modele{filtered.length !== 1 ? "s" : ""}
{filter ? ` sur ${models.length}` : ""} nom libre possible
</div>

View File

@@ -4,6 +4,7 @@ import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { X, CheckCircle, AlertCircle, AlertTriangle, Info, Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
import { useI18n } from "@/lib/i18n"
const notificationVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-lg border p-4 pe-8 shadow-lg transition-all duration-300 ease-out",
@@ -74,6 +75,7 @@ const Notification = React.forwardRef<HTMLDivElement, NotificationProps>(
children,
...props
}, ref) => {
const { t } = useI18n()
const [visible, setVisible] = React.useState(true)
const [progress, setProgress] = React.useState(100)
@@ -181,7 +183,7 @@ const Notification = React.forwardRef<HTMLDivElement, NotificationProps>(
type="button"
onClick={handleClose}
className="absolute right-2 top-2 flex-shrink-0 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:opacity-100 focus-visible:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
aria-label="Close notification"
aria-label={t("common.closeNotification")}
>
<X className="h-4 w-4" />
</button>

View File

@@ -1,4 +1,8 @@
{
"common.loading": "Loading...",
"common.backToHome": "Back to home"
"common.backToHome": "Back to home",
"common.browse": "Browse",
"common.noModels": "No models retrieved",
"common.noModelMatch": "No model matches your search",
"common.closeNotification": "Close notification"
}

View File

@@ -3,5 +3,6 @@
"cookieConsent.description": "We use essential cookies so the app works (session, security, language). With your permission, we also use optional cookies to measure traffic and improve the product.",
"cookieConsent.acceptAll": "Accept all",
"cookieConsent.essentialOnly": "Essential only",
"cookieConsent.learnMore": "Learn more"
"cookieConsent.learnMore": "Learn more",
"cookieConsent.reopen": "Cookie preferences"
}

View File

@@ -220,5 +220,11 @@
"glossaries.card.use": "Use",
"glossaries.createFirst": "Create my first glossary",
"glossaries.noResults": "No results for “{query}”",
"glossaries.card.terms": "terms"
"glossaries.card.terms": "terms",
"glossaries.suggestion.formal": "Formal tone",
"glossaries.suggestion.proprietary": "Proper nouns",
"glossaries.suggestion.numbers": "Numbers",
"glossaries.suggestion.placeholders": "Placeholders",
"glossaries.suggestion.technical": "Technical terms",
"glossaries.suggestion.concise": "Concise style"
}

View File

@@ -152,5 +152,13 @@
"pricing.enterprise.subject": "Enterprise plan enquiry",
"pricing.toast.close": "Close",
"pricing.header.titleBase": "A plan for",
"pricing.header.titleAccent": "every need"
"pricing.header.titleAccent": "every need",
"pricing.confirm.title": "Confirm your subscription",
"pricing.confirm.subtitle": "You will be redirected to our secure payment provider.",
"pricing.confirm.month": "month",
"pricing.confirm.year": "year",
"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.cancel": "Cancel",
"pricing.confirm.cta": "Continue to payment"
}

View File

@@ -3,5 +3,6 @@
"providerSelector.noLlm": "No AI model configured.",
"providerSelector.costOne": "Cost: 1 credit per page",
"providerSelector.costFive": "Cost: 5 credits per page (Premium factor)",
"providerSelector.unlockContextual": "Unlock premium contextual translation for your entire documents."
"providerSelector.unlockContextual": "Unlock premium contextual translation for your entire documents.",
"providerSelector.standard": "Standard"
}

View File

@@ -28,5 +28,11 @@
"reviews.error.title": "Error",
"reviews.error.load": "Loading failed",
"reviews.error.update": "Could not update",
"reviews.error.download": "Download failed (HTTP {status})"
"reviews.error.download": "Download failed (HTTP {status})",
"reviews.pill": "Quality control",
"reviews.titleBase": "Translation",
"reviews.titleAccent": "review",
"reviews.approveAllConfirm": "Confirm ({count})",
"reviews.action.approveShort": "OK",
"reviews.action.resetShort": "Re-review"
}

View File

@@ -11,5 +11,6 @@
"services.adminOnly.desc": "API keys, model selection, and provider activation are managed exclusively by the administrator in the admin panel. You never need to enter an API key.",
"services.fallback.google.label": "Google Translate",
"services.fallback.google.desc": "Fast translation, 130+ languages",
"services.pill": "Engines"
"services.pill": "Engines",
"services.titleAccent": "status"
}

View File

@@ -19,5 +19,6 @@
"settings.formats.powerpoint.name": "PowerPoint",
"settings.pill": "Configuration",
"settings.cache.confirm": "Confirm and reload",
"settings.cache.cancelClear": "Cancel"
"settings.cache.cancelClear": "Cancel",
"settings.titleAccent": "presets"
}

View File

@@ -22,5 +22,8 @@
"teams.invite": "Invite",
"teams.empty": "No workspace yet — create the first one above.",
"teams.error.title": "Error",
"teams.error.load": "Could not load"
"teams.error.load": "Could not load",
"teams.pill": "Collaboration",
"teams.titleBase": "Team",
"teams.titleAccent": "workspaces"
}

View File

@@ -49,12 +49,8 @@
"translate.glossary.targetWarningBut": "but your document targets",
"translate.glossary.targetWarningEnd": "Terms may not be relevant.",
"translate.header.processing": "Processing in progress",
"translate.header.aiActive": "AI analysis active",
"translate.header.aiActiveDesc": "Your layout is being preserved by our contextual engine.",
"translate.header.completed": "Completed",
"translate.header.completedTitle": "Translation completed",
"translate.header.proSpace": "Pro space",
"translate.header.translateDoc": "Translate a document",
"translate.header.translateDocDesc": "Preserve the original layout with our ultra-high-fidelity translation engine.",
"translate.upload.nativeFormat": "Native format",
"translate.fileType.word": "Word (.docx)",
@@ -68,12 +64,7 @@
"translate.contextEngineActive": "Contextual engine active",
"translate.phase1": "Phase 1: Initialisation",
"translate.phase2": "Phase 2: Contextual reconstruction",
"translate.stat.segments": "segments",
"translate.stat.precision": "precision",
"translate.stat.speedLabel": "speed",
"translate.stat.turbo": "Turbo",
"translate.stat.time": "time",
"translate.complete.masterQuality": "✓ Master quality",
"translate.download": "Download",
"translate.newTranslation": "+ New translation",
"translate.failedTitle": "Translation error",
@@ -81,10 +72,6 @@
"translate.uploadAnother": "Upload another file",
"translate.monitor": "AI monitor",
"translate.summary": "Summary",
"translate.cancelProcess": "⟳ Cancel the process",
"translate.layoutIntegrity": "Layout integrity",
"translate.secureHundred": "100% SECURE",
"translate.okHundred": "100% OK",
"translate.preserveLayout": "Preserve layout",
"translate.preserveLayoutDesc": "Preserve the layout",
"translate.textOnly": "Text only",

View File

@@ -1,4 +1,8 @@
{
"common.loading": "Chargement...",
"common.backToHome": "Retour à l'accueil"
"common.backToHome": "Retour à l'accueil",
"common.browse": "Parcourir",
"common.noModels": "Aucun modèle récupéré",
"common.noModelMatch": "Aucun modèle ne correspond",
"common.closeNotification": "Fermer la notification"
}

View File

@@ -3,5 +3,6 @@
"cookieConsent.description": "Nous utilisons des cookies strictement nécessaires au fonctionnement du service (session, sécurité, langue). Avec votre accord, nous pouvons aussi utiliser des cookies optionnels pour mesurer l'audience et améliorer le produit.",
"cookieConsent.acceptAll": "Tout accepter",
"cookieConsent.essentialOnly": "Nécessaires uniquement",
"cookieConsent.learnMore": "En savoir plus"
"cookieConsent.learnMore": "En savoir plus",
"cookieConsent.reopen": "Préférences cookies"
}

View File

@@ -220,5 +220,11 @@
"glossaries.card.use": "Utiliser",
"glossaries.createFirst": "Créer mon premier glossaire",
"glossaries.noResults": "Aucun résultat pour « {query} »",
"glossaries.card.terms": "termes"
"glossaries.card.terms": "termes",
"glossaries.suggestion.formal": "Ton formel",
"glossaries.suggestion.proprietary": "Noms propres",
"glossaries.suggestion.numbers": "Chiffres",
"glossaries.suggestion.placeholders": "Placeholders",
"glossaries.suggestion.technical": "Termes techniques",
"glossaries.suggestion.concise": "Style concis"
}

View File

@@ -152,5 +152,13 @@
"pricing.enterprise.subject": "Offre Enterprise",
"pricing.toast.close": "Fermer",
"pricing.header.titleBase": "Un forfait pour",
"pricing.header.titleAccent": "chaque besoin"
"pricing.header.titleAccent": "chaque besoin",
"pricing.confirm.title": "Confirmez votre abonnement",
"pricing.confirm.subtitle": "Vous serez redirigé vers notre prestataire de paiement sécurisé.",
"pricing.confirm.month": "mois",
"pricing.confirm.year": "an",
"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.cancel": "Annuler",
"pricing.confirm.cta": "Continuer vers le paiement"
}

View File

@@ -3,5 +3,6 @@
"providerSelector.noLlm": "Aucun modèle IA configuré.",
"providerSelector.costOne": "Coût : 1 crédit par page",
"providerSelector.costFive": "Coût : 5 crédits par page (Facteur Premium)",
"providerSelector.unlockContextual": "Débloquez la traduction contextuelle haut de gamme pour vos documents entiers."
"providerSelector.unlockContextual": "Débloquez la traduction contextuelle haut de gamme pour vos documents entiers.",
"providerSelector.standard": "Standard"
}

View File

@@ -28,5 +28,11 @@
"reviews.error.title": "Erreur",
"reviews.error.load": "Erreur de chargement",
"reviews.error.update": "Mise à jour impossible",
"reviews.error.download": "Téléchargement échoué (HTTP {status})"
"reviews.error.download": "Téléchargement échoué (HTTP {status})",
"reviews.pill": "Contrôle qualité",
"reviews.titleBase": "Relecture de la",
"reviews.titleAccent": "traduction",
"reviews.approveAllConfirm": "Confirmer ({count})",
"reviews.action.approveShort": "OK",
"reviews.action.resetShort": "À relire"
}

View File

@@ -11,5 +11,6 @@
"services.adminOnly.desc": "Les clés API, la sélection de modèle et l'activation des fournisseurs sont gérées exclusivement par l'administrateur. Vous n'avez jamais besoin de saisir de clé API.",
"services.fallback.google.label": "Google Traduction",
"services.fallback.google.desc": "Traduction rapide, 130+ langues",
"services.pill": "Moteurs"
"services.pill": "Moteurs",
"services.titleAccent": "disponibles"
}

View File

@@ -19,5 +19,6 @@
"settings.formats.powerpoint.name": "PowerPoint",
"settings.pill": "Configuration",
"settings.cache.confirm": "Confirmer et recharger",
"settings.cache.cancelClear": "Annuler"
"settings.cache.cancelClear": "Annuler",
"settings.titleAccent": "préréglages"
}

View File

@@ -22,5 +22,8 @@
"teams.invite": "Inviter",
"teams.empty": "Aucun espace pour l'instant — créez le premier ci-dessus.",
"teams.error.title": "Erreur",
"teams.error.load": "Chargement impossible"
"teams.error.load": "Chargement impossible",
"teams.pill": "Collaboration",
"teams.titleBase": "Espaces de",
"teams.titleAccent": "travail"
}

View File

@@ -49,12 +49,8 @@
"translate.glossary.targetWarningBut": "mais votre document cible",
"translate.glossary.targetWarningEnd": "Les termes risquent de ne pas être pertinents.",
"translate.header.processing": "Traitement en cours",
"translate.header.aiActive": "Analyse IA active",
"translate.header.aiActiveDesc": "Votre mise en page est en cours de préservation par notre moteur contextuel.",
"translate.header.completed": "Complété",
"translate.header.completedTitle": "Traduction terminée",
"translate.header.proSpace": "Espace Pro",
"translate.header.translateDoc": "Traduire un document",
"translate.header.translateDocDesc": "Conservez la mise en page d'origine grâce au moteur de traduction ultra-haute fidélité.",
"translate.upload.nativeFormat": "Format natif",
"translate.fileType.word": "Word (.docx)",
@@ -68,12 +64,7 @@
"translate.contextEngineActive": "Moteur contextuel actif",
"translate.phase1": "Phase 1 : Initialisation",
"translate.phase2": "Phase 2 : Reconstruction contextuelle",
"translate.stat.segments": "segments",
"translate.stat.precision": "précision",
"translate.stat.speedLabel": "vitesse",
"translate.stat.turbo": "Turbo",
"translate.stat.time": "temps",
"translate.complete.masterQuality": "✓ Qualité Maître",
"translate.download": "Télécharger",
"translate.newTranslation": "+ Nouvelle traduction",
"translate.failedTitle": "Erreur lors de la traduction",
@@ -81,10 +72,6 @@
"translate.uploadAnother": "Téléverser un autre fichier",
"translate.monitor": "Moniteur IA",
"translate.summary": "Récapitulatif",
"translate.cancelProcess": "⟳ Annuler le processus",
"translate.layoutIntegrity": "Intégrité Layout",
"translate.secureHundred": "100% SÉCURISÉ",
"translate.okHundred": "100% OK",
"translate.preserveLayout": "Conserver la mise en page",
"translate.preserveLayoutDesc": "Conserver la mise en page",
"translate.textOnly": "Texte brut",