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
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:
73
.impeccable/critique/2026-08-30T19-50-34Z__frontend-src.md
Normal file
73
.impeccable/critique/2026-08-30T19-50-34Z__frontend-src.md
Normal 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 ?
|
||||||
@@ -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">
|
<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 */}
|
{/* Mobile menu button */}
|
||||||
<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)}
|
onClick={() => setMobileOpen(!mobileOpen)}
|
||||||
aria-label={t('dashboard.header.toggleMenu')}
|
aria-label={t('dashboard.header.toggleMenu')}
|
||||||
>
|
>
|
||||||
@@ -47,11 +47,7 @@ export function DashboardHeader() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Left side - desktop */}
|
{/* Left side - desktop */}
|
||||||
<div className="hidden lg:flex items-center gap-3">
|
<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>
|
|
||||||
|
|
||||||
{/* Right side */}
|
{/* Right side */}
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<span className="text-[11px] font-black uppercase tracking-tight text-brand-dark dark:text-white">
|
||||||
{user.name}
|
{user.name}
|
||||||
</span>
|
</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'
|
{user.tier && user.tier !== 'free'
|
||||||
? t('dashboard.topbar.premiumAccess', { defaultValue: 'Premium Access' })
|
? t('dashboard.topbar.premiumAccess', { defaultValue: 'Premium Access' })
|
||||||
: translateTier(t, user.tier)}
|
: 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',
|
'flex items-center gap-4 rounded-2xl px-6 py-4 text-[11px] font-black uppercase tracking-[0.2em] transition-all duration-200',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-brand-dark text-white shadow-xl'
|
? '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" />
|
<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">
|
<span className="text-[11px] font-black uppercase tracking-tight text-brand-dark dark:text-white">
|
||||||
{user.name}
|
{user.name}
|
||||||
</span>
|
</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)}
|
{translateTier(t, user.tier)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,7 +137,7 @@ export function DashboardHeader() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
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" />
|
<LogOut size={18} className="shrink-0" />
|
||||||
{t('dashboard.sidebar.signOut')}
|
{t('dashboard.sidebar.signOut')}
|
||||||
|
|||||||
@@ -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',
|
'flex items-center gap-4 rounded-2xl px-6 py-4 text-[11px] font-black uppercase tracking-[0.2em] transition-all duration-200',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-brand-dark text-white shadow-xl'
|
? '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" />
|
<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">
|
<span className="truncate text-[11px] font-black uppercase tracking-tight text-brand-dark dark:text-white">
|
||||||
{user.name}
|
{user.name}
|
||||||
</span>
|
</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}
|
{user.email}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 {
|
export interface NavItem {
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
@@ -10,10 +10,11 @@ export interface NavItem {
|
|||||||
export const baseNavItems: NavItem[] = [
|
export const baseNavItems: NavItem[] = [
|
||||||
{ labelKey: 'dashboard.nav.translate', href: '/dashboard/translate', icon: FileText },
|
{ labelKey: 'dashboard.nav.translate', href: '/dashboard/translate', icon: FileText },
|
||||||
{ labelKey: 'dashboard.nav.glossaries', href: '/dashboard/glossaries', icon: BookText, proOnly: true },
|
{ 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 },
|
{ 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 },
|
// { labelKey: 'dashboard.nav.apiKeys', href: '/dashboard/api-keys', icon: Key, proOnly: true },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -19,13 +19,14 @@ import { SUPPORTED_LANGUAGES } from './types';
|
|||||||
import { useTranslationStore } from '@/lib/store';
|
import { useTranslationStore } from '@/lib/store';
|
||||||
|
|
||||||
// ── Chips de suggestions pour les consignes de contexte ─────────────────────
|
// ── Chips de suggestions pour les consignes de contexte ─────────────────────
|
||||||
const CONTEXT_SUGGESTIONS = [
|
// Prompt bodies stay in French (LLM content); labels translate with the UI.
|
||||||
{ label: 'Ton formel', value: 'Utilise toujours un ton formel et professionnel dans tes traductions.' },
|
const CONTEXT_SUGGESTIONS: { labelKey: string; value: string }[] = [
|
||||||
{ label: 'Noms propres', value: 'Ne traduis pas les noms propres, marques et noms de personnes.' },
|
{ labelKey: 'glossaries.suggestion.formal', value: 'Utilise toujours un ton formel et professionnel dans tes traductions.' },
|
||||||
{ label: 'Chiffres', value: 'Garde tous les chiffres, pourcentages et montants tels quels sans les modifier.' },
|
{ labelKey: 'glossaries.suggestion.proprietary', value: 'Ne traduis pas les noms propres, marques et noms de personnes.' },
|
||||||
{ label: 'Placeholders', value: 'Ne traduis pas les variables entre accolades comme {nom}, {date}, {montant}.' },
|
{ labelKey: 'glossaries.suggestion.numbers', value: 'Garde tous les chiffres, pourcentages et montants tels quels sans les modifier.' },
|
||||||
{ label: 'Termes techniques', value: 'Conserve les termes techniques en langue originale et ne les traduis pas.' },
|
{ labelKey: 'glossaries.suggestion.placeholders', value: 'Ne traduis pas les variables entre accolades comme {nom}, {date}, {montant}.' },
|
||||||
{ label: 'Style concis', value: 'Préfère des formulations courtes et directes. Évite les périphrases.' },
|
{ 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() {
|
export default function GlossariesPage() {
|
||||||
@@ -225,11 +226,11 @@ export default function GlossariesPage() {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{CONTEXT_SUGGESTIONS.map((s) => (
|
{CONTEXT_SUGGESTIONS.map((s) => (
|
||||||
<button
|
<button
|
||||||
key={s.label}
|
key={s.labelKey}
|
||||||
onClick={() => handleAddSuggestion(s.value)}
|
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"
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { useToast } from '@/components/ui/toast';
|
import { useToast } from '@/components/ui/toast';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface Segment {
|
interface Segment {
|
||||||
@@ -77,6 +78,7 @@ export default function ReviewPage() {
|
|||||||
const [draft, setDraft] = useState('');
|
const [draft, setDraft] = useState('');
|
||||||
const [savingId, setSavingId] = useState<string | null>(null);
|
const [savingId, setSavingId] = useState<string | null>(null);
|
||||||
const [isRebuilding, setIsRebuilding] = useState(false);
|
const [isRebuilding, setIsRebuilding] = useState(false);
|
||||||
|
const [approveArmed, setApproveArmed] = useState(false);
|
||||||
const [isImporting, setIsImporting] = useState(false);
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -136,6 +138,13 @@ export default function ReviewPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const approveAll = async () => {
|
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');
|
const pending = segments.filter((s) => s.status === 'pending');
|
||||||
for (const seg of pending) {
|
for (const seg of pending) {
|
||||||
// sequential is fine — server-side each is a tiny PATCH
|
// sequential is fine — server-side each is a tiny PATCH
|
||||||
@@ -232,10 +241,13 @@ export default function ReviewPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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>
|
<div>
|
||||||
<h1 className="text-xl font-semibold text-foreground">{t('reviews.title')}</h1>
|
<span className="accent-pill mb-3 block w-fit">{t('reviews.pill')}</span>
|
||||||
<p className="text-sm text-muted-foreground truncate max-w-xl">
|
<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', {
|
{t('reviews.subtitle', {
|
||||||
file: fileName ?? jobId,
|
file: fileName ?? jobId,
|
||||||
total: counts.total,
|
total: counts.total,
|
||||||
@@ -246,8 +258,11 @@ export default function ReviewPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={approveAll} disabled={counts.pending === 0}>
|
<Button variant={approveArmed ? 'default' : 'outline'} size="sm" onClick={approveAll} disabled={counts.pending === 0}>
|
||||||
<CheckCheck className="size-3.5" /> {t('reviews.approveAll')}
|
<CheckCheck className="size-3.5" />
|
||||||
|
{approveArmed
|
||||||
|
? t('reviews.approveAllConfirm', { count: counts.pending })
|
||||||
|
: t('reviews.approveAll')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={exportXliff} disabled={!segments.length}>
|
<Button variant="outline" size="sm" onClick={exportXliff} disabled={!segments.length}>
|
||||||
<FileDown className="size-3.5" /> XLIFF
|
<FileDown className="size-3.5" /> XLIFF
|
||||||
@@ -360,38 +375,35 @@ export default function ReviewPage() {
|
|||||||
<div className="flex flex-col items-start gap-2">
|
<div className="flex flex-col items-start gap-2">
|
||||||
{statusBadge(seg.status)}
|
{statusBadge(seg.status)}
|
||||||
{!isEditing && (
|
{!isEditing && (
|
||||||
<div className="flex gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-7 px-2"
|
className="h-7 gap-1 px-2 text-[11px]"
|
||||||
title={t('reviews.action.edit')}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingId(seg.id);
|
setEditingId(seg.id);
|
||||||
setDraft(finalText);
|
setDraft(finalText);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pencil className="size-3" />
|
<Pencil className="size-3" /> {t('reviews.action.edit')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-7 px-2"
|
className="h-7 gap-1 px-2 text-[11px]"
|
||||||
title={t('reviews.action.approve')}
|
|
||||||
disabled={seg.status === 'approved' || savingId === seg.id}
|
disabled={seg.status === 'approved' || savingId === seg.id}
|
||||||
onClick={() => patchSegment(seg.id, { status: 'approved' })}
|
onClick={() => patchSegment(seg.id, { status: 'approved' })}
|
||||||
>
|
>
|
||||||
<Check className="size-3" />
|
<Check className="size-3" /> {t('reviews.action.approveShort')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-7 px-2"
|
className="h-7 gap-1 px-2 text-[11px]"
|
||||||
title={t('reviews.action.reset')}
|
|
||||||
disabled={seg.status === 'pending'}
|
disabled={seg.status === 'pending'}
|
||||||
onClick={() => patchSegment(seg.id, { status: 'pending' })}
|
onClick={() => patchSegment(seg.id, { status: 'pending' })}
|
||||||
>
|
>
|
||||||
<Undo2 className="size-3" />
|
<Undo2 className="size-3" /> {t('reviews.action.resetShort')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Zap, CheckCircle2, Lock, Loader2, Globe, Brain } from 'lucide-react';
|
import { Zap, CheckCircle2, Lock, Loader2, Globe, Brain } from 'lucide-react';
|
||||||
import { API_BASE } from '@/lib/config';
|
import { API_BASE } from '@/lib/config';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
|
||||||
const FALLBACK_PROVIDERS_FACTORY = (t: (k: string) => string) => [
|
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 },
|
{ 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 (
|
return (
|
||||||
<div className="max-w-4xl mx-auto w-full p-6 lg:p-8">
|
<div className="max-w-4xl mx-auto w-full p-6 lg:p-8">
|
||||||
{/* ── Editorial Header ───────────────────────────────────── */}
|
{/* ── Editorial Header ───────────────────────────────────── */}
|
||||||
<div className="mb-12">
|
<PageHeader
|
||||||
<span className="accent-pill mb-4 block w-fit">{t('services.pill')}</span>
|
pill={t('services.pill')}
|
||||||
<h1 className="text-5xl font-black uppercase tracking-tighter mb-4 leading-none text-brand-dark dark:text-white">
|
titleBase={t('services.title')}
|
||||||
{t('services.title')}
|
titleAccent={t('services.titleAccent')}
|
||||||
</h1>
|
subtitle={t('services.subtitle')}
|
||||||
<p className="text-brand-dark/50 dark:text-white/50 font-medium">{t('services.subtitle')}</p>
|
className="mb-12"
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center min-h-[30vh]">
|
<div className="flex items-center justify-center min-h-[30vh]">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Settings, Trash2, Loader2, FileSpreadsheet, FileText, Presentation,
|
Settings, Trash2, Loader2, FileSpreadsheet, FileText, Presentation,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
|
||||||
export default function GeneralSettingsPage() {
|
export default function GeneralSettingsPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -38,13 +39,13 @@ export default function GeneralSettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto w-full p-6 lg:p-8">
|
<div className="max-w-4xl mx-auto w-full p-6 lg:p-8">
|
||||||
{/* ── Editorial Header ───────────────────────────────────── */}
|
{/* ── Editorial Header ───────────────────────────────────── */}
|
||||||
<div className="mb-12">
|
<PageHeader
|
||||||
<span className="accent-pill mb-4 block w-fit">{t('settings.pill')}</span>
|
pill={t('settings.pill')}
|
||||||
<h1 className="text-5xl font-black uppercase tracking-tighter mb-4 leading-none text-brand-dark dark:text-white">
|
titleBase={t('settings.title')}
|
||||||
{t('settings.title')}
|
titleAccent={t('settings.titleAccent')}
|
||||||
</h1>
|
subtitle={t('settings.subtitle')}
|
||||||
<p className="text-brand-dark/50 dark:text-white/50 font-medium">{t('settings.subtitle')}</p>
|
className="mb-12"
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
{/* Supported Formats */}
|
{/* Supported Formats */}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { useToast } from '@/components/ui/toast';
|
import { useToast } from '@/components/ui/toast';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
|
||||||
interface WorkspaceMember {
|
interface WorkspaceMember {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -133,19 +134,12 @@ export default function TeamsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<PageHeader
|
||||||
<div className="flex items-center gap-3">
|
pill={t('teams.pill')}
|
||||||
<div className="flex size-10 items-center justify-center rounded-lg bg-blue-600/20">
|
titleBase={t('teams.titleBase')}
|
||||||
<Users className="size-5 text-blue-400" />
|
titleAccent={t('teams.titleAccent')}
|
||||||
</div>
|
subtitle={t('teams.subtitle')}
|
||||||
<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>
|
|
||||||
|
|
||||||
{needsBusiness ? (
|
{needsBusiness ? (
|
||||||
<div className="rounded-lg border border-border bg-card p-6 text-center">
|
<div className="rounded-lg border border-border bg-card p-6 text-center">
|
||||||
|
|||||||
@@ -70,16 +70,34 @@ function Combobox({
|
|||||||
|
|
||||||
const label = value === 'auto' ? autoLabel : allOptions.find(l => l.code === value)?.name ?? value;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="relative text-left"
|
className="relative text-left"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={handleListboxKeys}
|
||||||
if (e.key === 'Escape' && open) {
|
|
||||||
e.stopPropagation();
|
|
||||||
closeAndRefocus();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
|
|||||||
@@ -155,10 +155,10 @@ export function ProviderSelector({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-[9.5px] 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/40 dark:text-white/40'
|
isSelected ? 'text-brand-accent' : 'text-brand-dark/60 dark:text-white/60'
|
||||||
)}>
|
)}>
|
||||||
Standard
|
{t('providerSelector.standard')}
|
||||||
</span>
|
</span>
|
||||||
{isSelected && <div className="w-1.5 h-1.5 rounded-full bg-brand-accent animate-pulse shrink-0 ml-1" />}
|
{isSelected && <div className="w-1.5 h-1.5 rounded-full bg-brand-accent animate-pulse shrink-0 ml-1" />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -142,13 +142,13 @@ export default function TranslatePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleNewTranslation = () => { submit.reset(); upload.removeFile(); setElapsed(0); setRecentJobs(getRecentJobs()); };
|
const handleNewTranslation = () => { submit.reset(); upload.removeFile(); setElapsed(0); setRecentJobs(getRecentJobs()); };
|
||||||
const handleDownload = async () => {
|
const handleDownload = async (jobId: string = submit.jobId ?? '') => {
|
||||||
if (!submit.jobId) return;
|
if (!jobId) return;
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
try {
|
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) {
|
if (!response.ok) {
|
||||||
showError({ title: t('dashboard.translate.error.title'), description: t('translate.downloadFailed') });
|
showError({ title: t('dashboard.translate.error.title'), description: t('translate.downloadFailed') });
|
||||||
return;
|
return;
|
||||||
@@ -180,6 +180,19 @@ export default function TranslatePage() {
|
|||||||
const isCompleted = submit.status === 'completed';
|
const isCompleted = submit.status === 'completed';
|
||||||
const isFailed = submit.status === 'failed';
|
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 showUpload = !upload.file && !isProcessing && !isCompleted && !isFailed;
|
||||||
const showConfiguring = isConfiguring;
|
const showConfiguring = isConfiguring;
|
||||||
const showProcessing = isProcessing;
|
const showProcessing = isProcessing;
|
||||||
@@ -311,18 +324,27 @@ export default function TranslatePage() {
|
|||||||
{t('translate.recent.title')}
|
{t('translate.recent.title')}
|
||||||
</h4>
|
</h4>
|
||||||
<ul className="divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
<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">
|
<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-accent" />
|
||||||
<span className="truncate">{job.fileName || job.jobId}</span>
|
<span className="truncate">{job.fileName || job.jobId}</span>
|
||||||
</span>
|
</span>
|
||||||
<Link
|
<span className="flex shrink-0 items-center gap-1.5">
|
||||||
href={`/dashboard/reviews/${job.jobId}`}
|
<button
|
||||||
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"
|
type="button"
|
||||||
>
|
onClick={() => handleDownload(job.jobId)}
|
||||||
{t('translate.recent.review')}
|
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"
|
||||||
</Link>
|
>
|
||||||
|
<Download size={11} className="inline" />
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{t('translate.recent.review')}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -446,21 +468,18 @@ export default function TranslatePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<p className="text-[13px] font-bold uppercase tracking-[0.1em] text-brand-dark dark:text-white">
|
<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>
|
||||||
<p className="text-[11px] text-brand-dark/50 dark:text-white/50 font-semibold uppercase mt-1 tracking-wider max-w-[300px] truncate">
|
<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}
|
{submit.fileName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<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">
|
<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
|
<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"
|
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" />
|
<Download size={28} className="group-hover:translate-y-1 transition-transform" />
|
||||||
|
|||||||
@@ -233,8 +233,10 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
|
|||||||
const isConfigValid = useMemo(() => {
|
const isConfigValid = useMemo(() => {
|
||||||
if (!hasFile || !targetLang) return false;
|
if (!hasFile || !targetLang) return false;
|
||||||
if (!provider) return false;
|
if (!provider) return false;
|
||||||
|
// Translating a language into itself is always a mistake
|
||||||
|
if (sourceLang !== 'auto' && sourceLang === targetLang) return false;
|
||||||
return true;
|
return true;
|
||||||
}, [hasFile, targetLang, provider]);
|
}, [hasFile, targetLang, provider, sourceLang]);
|
||||||
|
|
||||||
const getConfig = useCallback((): TranslationConfig => ({
|
const getConfig = useCallback((): TranslationConfig => ({
|
||||||
sourceLang,
|
sourceLang,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const MAX_POLLING_FAILURES = 3;
|
|||||||
/* ── Job persistence: survive a refresh mid-translation ─────────── */
|
/* ── Job persistence: survive a refresh mid-translation ─────────── */
|
||||||
const ACTIVE_JOB_KEY = 'wordly:activeJob';
|
const ACTIVE_JOB_KEY = 'wordly:activeJob';
|
||||||
const RECENT_JOBS_KEY = 'wordly:recentJobs';
|
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
|
const ACTIVE_JOB_TTL_MS = 24 * 60 * 60 * 1000; // discard stale entries after 24h
|
||||||
|
|
||||||
interface StoredJob { jobId: string; fileName: string | null; savedAt: number }
|
interface StoredJob { jobId: string; fileName: string | null; savedAt: number }
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { API_BASE } from "@/lib/config";
|
import { API_BASE } from "@/lib/config";
|
||||||
import { ANNUAL_DISCOUNT_PERCENT } from "@/lib/pricing";
|
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 }> = {
|
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" },
|
starter: { header: "bg-foreground", iconColor: "text-white/20", nameColor: "text-white/50" },
|
||||||
pro: { header: "bg-accent", iconColor: "text-white/30", 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" },
|
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);
|
const [annualDiscountPercent, setAnnualDiscountPercent] = useState(ANNUAL_DISCOUNT_PERCENT);
|
||||||
/** Until false: don't show STATIC_PLANS (avoids flash of stale prices on refresh). */
|
/** Until false: don't show STATIC_PLANS (avoids flash of stale prices on refresh). */
|
||||||
const [pricingLoaded, setPricingLoaded] = useState(false);
|
const [pricingLoaded, setPricingLoaded] = useState(false);
|
||||||
|
/** Plan awaiting explicit confirmation before any Stripe redirect. */
|
||||||
|
const [confirmPlan, setConfirmPlan] = useState<Plan | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Fetch live plans — backend returns prices set by admin (no browser cache)
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!pricingLoaded) return;
|
if (!pricingLoaded) return;
|
||||||
if (planFromUrl && isLoggedIn && plans.length > 0 && currentPlan !== null) {
|
if (planFromUrl && isLoggedIn && plans.length > 0 && currentPlan !== null) {
|
||||||
const targetPlan = plans.find(p => p.id === planFromUrl);
|
const targetPlan = plans.find(p => p.id === planFromUrl);
|
||||||
if (targetPlan && targetPlan.price_monthly > 0 && currentPlan !== planFromUrl) {
|
if (targetPlan && targetPlan.price_monthly > 0 && currentPlan !== planFromUrl) {
|
||||||
// Directly initiate checkout
|
setConfirmPlan(targetPlan);
|
||||||
handleSubscribe(planFromUrl);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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">
|
<div className="flex gap-2 p-1.5 bg-muted rounded-full border border-black/5 shadow-inner">
|
||||||
<Link
|
<Link
|
||||||
href={isLoggedIn ? "/dashboard" : "/"}
|
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')}
|
{t('pricing.dashboard')}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -481,6 +485,47 @@ export default function PricingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 ── */}
|
{/* ── Toast notification ── */}
|
||||||
{toastMsg && (
|
{toastMsg && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
@@ -497,17 +542,17 @@ export default function PricingPage() {
|
|||||||
|
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<div className="max-w-[1400px] mx-auto px-4 text-center mb-20">
|
<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">
|
<span className="accent-pill mb-6 mx-auto flex w-fit items-center gap-2">
|
||||||
<div className="w-2 h-2 bg-accent rounded-full animate-pulse" />
|
<span className="h-1.5 w-1.5 rounded-full bg-accent animate-pulse" aria-hidden="true" />
|
||||||
<span className="text-[10px] font-black tracking-[0.3em] opacity-40">{t('pricing.header.badge')}</span>
|
{t('pricing.header.badge')}
|
||||||
</span>
|
</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')}{" "}
|
{t('pricing.header.titleBase')}{" "}
|
||||||
<span className="text-accent">
|
<span className="italic text-accent">
|
||||||
{t('pricing.header.titleAccent')}
|
{t('pricing.header.titleAccent')}
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</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')}
|
{t('pricing.header.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -560,12 +605,12 @@ export default function PricingPage() {
|
|||||||
{plan.popular && (
|
{plan.popular && (
|
||||||
<div className="absolute top-0 right-0 p-3 flex gap-2">
|
<div className="absolute top-0 right-0 p-3 flex gap-2">
|
||||||
{plan.badge && (
|
{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)}
|
{t(plan.badge)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isCurrent && (
|
{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')}
|
<div className="w-1.5 h-1.5 bg-accent rounded-full animate-pulse" /> {t('pricing.card.myPlan')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -573,7 +618,7 @@ export default function PricingPage() {
|
|||||||
)}
|
)}
|
||||||
{plan.badge && !plan.popular && (
|
{plan.badge && !plan.popular && (
|
||||||
<div className="absolute top-0 right-0 p-3">
|
<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)}
|
{t(plan.badge)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -581,7 +626,7 @@ export default function PricingPage() {
|
|||||||
{/* "My Plan" badge for current non-popular plan */}
|
{/* "My Plan" badge for current non-popular plan */}
|
||||||
{isCurrent && !plan.popular && (
|
{isCurrent && !plan.popular && (
|
||||||
<div className="absolute top-0 right-0 p-3">
|
<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')}
|
<div className="w-1.5 h-1.5 bg-accent rounded-full animate-pulse" /> {t('pricing.card.myPlan')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -608,7 +653,7 @@ export default function PricingPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<h3 className="text-3xl font-black uppercase tracking-tighter text-white">{price} €</h3>
|
<h3 className="text-3xl font-black uppercase tracking-tighter text-white">{price} €</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>
|
</div>
|
||||||
@@ -632,8 +677,8 @@ export default function PricingPage() {
|
|||||||
<div className="space-y-2 mb-10">
|
<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 justify-between items-center py-2 border-b border-black/[0.03] dark:border-border/20">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileText size={12} className="text-foreground/20" />
|
<FileText size={12} className="text-foreground/40" />
|
||||||
<span className="text-[9px] font-black uppercase tracking-widest text-foreground/40">{t('pricing.card.documents')}</span>
|
<span className="text-[11px] font-bold uppercase tracking-wider text-foreground/60">{t('pricing.card.documents')}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-black uppercase text-foreground">
|
<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')}`}
|
{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>
|
||||||
<div className="flex justify-between items-center py-2 border-b border-black/[0.03] dark:border-border/20">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<Layers size={12} className="text-foreground/20" />
|
<Layers size={12} className="text-foreground/40" />
|
||||||
<span className="text-[9px] font-black uppercase tracking-widest text-foreground/40">{t('pricing.card.pagesMax')}</span>
|
<span className="text-[11px] font-bold uppercase tracking-wider text-foreground/60">{t('pricing.card.pagesMax')}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-black uppercase text-foreground">
|
<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')}`}
|
{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">
|
<div className="flex items-center gap-2">
|
||||||
<Activity size={12} className={
|
<Activity size={12} className={
|
||||||
plan.ai_tier === "essential" ? "text-accent/40" :
|
plan.ai_tier === "essential" ? "text-accent/40" :
|
||||||
"text-foreground/20"
|
"text-foreground/40"
|
||||||
} />
|
} />
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-[9px] font-black uppercase tracking-widest",
|
"text-[9px] font-black uppercase tracking-widest",
|
||||||
@@ -711,7 +756,7 @@ export default function PricingPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSubscribe(plan.id)}
|
onClick={() => setConfirmPlan(plan)}
|
||||||
disabled={loadingPlanId !== null}
|
disabled={loadingPlanId !== null}
|
||||||
className={cn(
|
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",
|
"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",
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ const Hero = () => {
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
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')}
|
<Languages size={10} /> {t('landing.hero.tag')}
|
||||||
</div>
|
</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]">
|
<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">
|
<div className="mb-24">
|
||||||
<span className="accent-pill mb-6 block w-fit">{t('landing.steps.process')}</span>
|
<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>
|
<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>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-16">
|
<div className="grid md:grid-cols-3 gap-16">
|
||||||
@@ -197,7 +197,7 @@ const Steps = () => {
|
|||||||
{st.icon}
|
{st.icon}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-serif font-medium text-brand-dark dark:text-white mb-4">{st.title}</h3>
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -222,9 +222,9 @@ const FeaturesContext = () => {
|
|||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-8">
|
<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: <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/40 dark:text-white/40" />, title: t('landing.features.glossary.title'), desc: t('landing.features.glossary.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/40 dark:text-white/40" />, title: t('landing.features.vision.title'), desc: t('landing.features.vision.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) => (
|
].map((item, i) => (
|
||||||
<div key={i} className="editorial-card p-10 flex flex-col items-start gap-8">
|
<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">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-xl font-serif font-medium mb-4 text-brand-dark dark:text-white">{item.title}</h4>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -283,10 +283,10 @@ const LayoutFeatures = () => {
|
|||||||
const FormatsSection = () => {
|
const FormatsSection = () => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const formats = [
|
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: ".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/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: ".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/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: ".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/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: ".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 (
|
return (
|
||||||
@@ -295,7 +295,7 @@ const FormatsSection = () => {
|
|||||||
<div className="mb-24">
|
<div className="mb-24">
|
||||||
<span className="accent-pill mb-6 block w-fit">{t('landing.formats.pill')}</span>
|
<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>
|
<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>
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
|
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
{formats.map((f, i) => (
|
{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">
|
<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/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>
|
</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">
|
||||||
{f.items.map((item, j) => (
|
{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}
|
<div className="w-1 h-1 bg-brand-accent rounded-full mt-1.5 shrink-0 opacity-40"></div> {item}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -337,14 +337,14 @@ const PricingSection = () => {
|
|||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="text-center mb-24">
|
<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>
|
<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>
|
||||||
|
|
||||||
<div className="flex items-center justify-center gap-12 mb-32">
|
<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')}
|
{t('landing.pricing.monthly')}
|
||||||
</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/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')}
|
{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-[9px] font-bold text-brand-accent whitespace-nowrap italic">
|
||||||
(-20%)
|
(-20%)
|
||||||
@@ -356,11 +356,11 @@ const PricingSection = () => {
|
|||||||
{plans.map((p, i) => (
|
{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]">
|
<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>
|
<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">
|
<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-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>
|
</div>
|
||||||
|
|
||||||
<ul className="space-y-6 mb-16 flex-grow">
|
<ul className="space-y-6 mb-16 flex-grow">
|
||||||
@@ -381,7 +381,7 @@ const PricingSection = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</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">
|
<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')}
|
{t('landing.cta.title')}
|
||||||
</h2>
|
</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')}
|
{t('landing.cta.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row gap-6 justify-center items-center">
|
<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="grid md:grid-cols-12 gap-20 mb-40">
|
||||||
<div className="md:col-span-12 lg:col-span-6">
|
<div className="md:col-span-12 lg:col-span-6">
|
||||||
<Logo />
|
<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')}
|
{t('landing.footer.desc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-4 lg:col-span-2">
|
<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>
|
<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="#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="#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>
|
<li><a href="#pricing" className="hover:text-brand-dark dark:hover:text-white transition-colors">{t('landing.nav.pricing')}</a></li>
|
||||||
@@ -433,11 +433,20 @@ const Footer = () => {
|
|||||||
</div>
|
</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="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')}
|
{t('landing.footer.rights')}
|
||||||
</div>
|
</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">
|
||||||
wordly.art
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
40
frontend/src/components/layout/page-header.tsx
Normal file
40
frontend/src/components/layout/page-header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,13 @@ import { motion, AnimatePresence } from "framer-motion";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
const STORAGE_KEY = "cookie-consent";
|
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() {
|
export function CookieConsent() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -13,6 +20,9 @@ export function CookieConsent() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVisible(!localStorage.getItem(STORAGE_KEY));
|
setVisible(!localStorage.getItem(STORAGE_KEY));
|
||||||
|
const onReopen = () => setVisible(true);
|
||||||
|
window.addEventListener(REOPEN_EVENT, onReopen);
|
||||||
|
return () => window.removeEventListener(REOPEN_EVENT, onReopen);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function acceptAll() {
|
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="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">
|
<div className="flex items-start gap-3">
|
||||||
<span className="text-2xl shrink-0" aria-hidden="true">
|
<span className="text-2xl shrink-0" aria-hidden="true">
|
||||||
🍪
|
<CookieIcon />
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-foreground mb-1">
|
<h3 className="font-semibold text-foreground mb-1">
|
||||||
@@ -63,3 +73,16 @@ export function CookieConsent() {
|
|||||||
</AnimatePresence>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { Search, List, Loader2, Check } from "lucide-react";
|
import { Search, List, Loader2, Check } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
export interface ModelOption {
|
export interface ModelOption {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -31,6 +32,7 @@ export function ModelCombobox({
|
|||||||
providerLabel,
|
providerLabel,
|
||||||
placeholder = "nom-du-modele",
|
placeholder = "nom-du-modele",
|
||||||
}: ModelComboboxProps) {
|
}: ModelComboboxProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -108,17 +110,17 @@ export function ModelCombobox({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleBrowse}
|
onClick={handleBrowse}
|
||||||
disabled={isLoading}
|
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" />}
|
{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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isOpen && (
|
{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 */}
|
{/* Filter */}
|
||||||
<div className="p-2 border-b border-border-subtle">
|
<div className="p-2 border-b border-border">
|
||||||
<Input
|
<Input
|
||||||
placeholder={`Filtrer...`}
|
placeholder={`Filtrer...`}
|
||||||
value={filter}
|
value={filter}
|
||||||
@@ -132,7 +134,7 @@ export function ModelCombobox({
|
|||||||
<ul ref={listRef} className="overflow-y-auto flex-1">
|
<ul ref={listRef} className="overflow-y-auto flex-1">
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<li className="px-3 py-4 text-xs text-muted-foreground text-center">
|
<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>
|
</li>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((model, i) => (
|
filtered.map((model, i) => (
|
||||||
@@ -141,7 +143,7 @@ export function ModelCombobox({
|
|||||||
onClick={() => handleSelect(model)}
|
onClick={() => handleSelect(model)}
|
||||||
onMouseEnter={() => setHighlightIndex(i)}
|
onMouseEnter={() => setHighlightIndex(i)}
|
||||||
className={`flex items-center justify-between px-3 py-2 text-xs cursor-pointer transition-colors ${
|
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" : ""}`}
|
} ${value === model.id ? "bg-primary/5" : ""}`}
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -160,7 +162,7 @@ export function ModelCombobox({
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{models.length > 0 && (
|
{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" : ""}
|
{filtered.length} modele{filtered.length !== 1 ? "s" : ""}
|
||||||
{filter ? ` sur ${models.length}` : ""} — nom libre possible
|
{filter ? ` sur ${models.length}` : ""} — nom libre possible
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as React from "react"
|
|||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { X, CheckCircle, AlertCircle, AlertTriangle, Info, Loader2 } from "lucide-react"
|
import { X, CheckCircle, AlertCircle, AlertTriangle, Info, Loader2 } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useI18n } from "@/lib/i18n"
|
||||||
|
|
||||||
const notificationVariants = cva(
|
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",
|
"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,
|
children,
|
||||||
...props
|
...props
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const [visible, setVisible] = React.useState(true)
|
const [visible, setVisible] = React.useState(true)
|
||||||
const [progress, setProgress] = React.useState(100)
|
const [progress, setProgress] = React.useState(100)
|
||||||
|
|
||||||
@@ -181,7 +183,7 @@ const Notification = React.forwardRef<HTMLDivElement, NotificationProps>(
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleClose}
|
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"
|
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" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
{
|
{
|
||||||
"common.loading": "Loading...",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.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.acceptAll": "Accept all",
|
||||||
"cookieConsent.essentialOnly": "Essential only",
|
"cookieConsent.essentialOnly": "Essential only",
|
||||||
"cookieConsent.learnMore": "Learn more"
|
"cookieConsent.learnMore": "Learn more",
|
||||||
|
"cookieConsent.reopen": "Cookie preferences"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,5 +220,11 @@
|
|||||||
"glossaries.card.use": "Use",
|
"glossaries.card.use": "Use",
|
||||||
"glossaries.createFirst": "Create my first glossary",
|
"glossaries.createFirst": "Create my first glossary",
|
||||||
"glossaries.noResults": "No results for “{query}”",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,5 +152,13 @@
|
|||||||
"pricing.enterprise.subject": "Enterprise plan enquiry",
|
"pricing.enterprise.subject": "Enterprise plan enquiry",
|
||||||
"pricing.toast.close": "Close",
|
"pricing.toast.close": "Close",
|
||||||
"pricing.header.titleBase": "A plan for",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,6 @@
|
|||||||
"providerSelector.noLlm": "No AI model configured.",
|
"providerSelector.noLlm": "No AI model configured.",
|
||||||
"providerSelector.costOne": "Cost: 1 credit per page",
|
"providerSelector.costOne": "Cost: 1 credit per page",
|
||||||
"providerSelector.costFive": "Cost: 5 credits per page (Premium factor)",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,5 +28,11 @@
|
|||||||
"reviews.error.title": "Error",
|
"reviews.error.title": "Error",
|
||||||
"reviews.error.load": "Loading failed",
|
"reviews.error.load": "Loading failed",
|
||||||
"reviews.error.update": "Could not update",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.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.label": "Google Translate",
|
||||||
"services.fallback.google.desc": "Fast translation, 130+ languages",
|
"services.fallback.google.desc": "Fast translation, 130+ languages",
|
||||||
"services.pill": "Engines"
|
"services.pill": "Engines",
|
||||||
|
"services.titleAccent": "status"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,5 +19,6 @@
|
|||||||
"settings.formats.powerpoint.name": "PowerPoint",
|
"settings.formats.powerpoint.name": "PowerPoint",
|
||||||
"settings.pill": "Configuration",
|
"settings.pill": "Configuration",
|
||||||
"settings.cache.confirm": "Confirm and reload",
|
"settings.cache.confirm": "Confirm and reload",
|
||||||
"settings.cache.cancelClear": "Cancel"
|
"settings.cache.cancelClear": "Cancel",
|
||||||
|
"settings.titleAccent": "presets"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,5 +22,8 @@
|
|||||||
"teams.invite": "Invite",
|
"teams.invite": "Invite",
|
||||||
"teams.empty": "No workspace yet — create the first one above.",
|
"teams.empty": "No workspace yet — create the first one above.",
|
||||||
"teams.error.title": "Error",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,12 +49,8 @@
|
|||||||
"translate.glossary.targetWarningBut": "but your document targets",
|
"translate.glossary.targetWarningBut": "but your document targets",
|
||||||
"translate.glossary.targetWarningEnd": "Terms may not be relevant.",
|
"translate.glossary.targetWarningEnd": "Terms may not be relevant.",
|
||||||
"translate.header.processing": "Processing in progress",
|
"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.aiActiveDesc": "Your layout is being preserved by our contextual engine.",
|
||||||
"translate.header.completed": "Completed",
|
"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.header.translateDocDesc": "Preserve the original layout with our ultra-high-fidelity translation engine.",
|
||||||
"translate.upload.nativeFormat": "Native format",
|
"translate.upload.nativeFormat": "Native format",
|
||||||
"translate.fileType.word": "Word (.docx)",
|
"translate.fileType.word": "Word (.docx)",
|
||||||
@@ -68,12 +64,7 @@
|
|||||||
"translate.contextEngineActive": "Contextual engine active",
|
"translate.contextEngineActive": "Contextual engine active",
|
||||||
"translate.phase1": "Phase 1: Initialisation",
|
"translate.phase1": "Phase 1: Initialisation",
|
||||||
"translate.phase2": "Phase 2: Contextual reconstruction",
|
"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.stat.time": "time",
|
||||||
"translate.complete.masterQuality": "✓ Master quality",
|
|
||||||
"translate.download": "Download",
|
"translate.download": "Download",
|
||||||
"translate.newTranslation": "+ New translation",
|
"translate.newTranslation": "+ New translation",
|
||||||
"translate.failedTitle": "Translation error",
|
"translate.failedTitle": "Translation error",
|
||||||
@@ -81,10 +72,6 @@
|
|||||||
"translate.uploadAnother": "Upload another file",
|
"translate.uploadAnother": "Upload another file",
|
||||||
"translate.monitor": "AI monitor",
|
"translate.monitor": "AI monitor",
|
||||||
"translate.summary": "Summary",
|
"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.preserveLayout": "Preserve layout",
|
||||||
"translate.preserveLayoutDesc": "Preserve the layout",
|
"translate.preserveLayoutDesc": "Preserve the layout",
|
||||||
"translate.textOnly": "Text only",
|
"translate.textOnly": "Text only",
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
{
|
{
|
||||||
"common.loading": "Chargement...",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.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.acceptAll": "Tout accepter",
|
||||||
"cookieConsent.essentialOnly": "Nécessaires uniquement",
|
"cookieConsent.essentialOnly": "Nécessaires uniquement",
|
||||||
"cookieConsent.learnMore": "En savoir plus"
|
"cookieConsent.learnMore": "En savoir plus",
|
||||||
|
"cookieConsent.reopen": "Préférences cookies"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,5 +220,11 @@
|
|||||||
"glossaries.card.use": "Utiliser",
|
"glossaries.card.use": "Utiliser",
|
||||||
"glossaries.createFirst": "Créer mon premier glossaire",
|
"glossaries.createFirst": "Créer mon premier glossaire",
|
||||||
"glossaries.noResults": "Aucun résultat pour « {query} »",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,5 +152,13 @@
|
|||||||
"pricing.enterprise.subject": "Offre Enterprise",
|
"pricing.enterprise.subject": "Offre Enterprise",
|
||||||
"pricing.toast.close": "Fermer",
|
"pricing.toast.close": "Fermer",
|
||||||
"pricing.header.titleBase": "Un forfait pour",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,6 @@
|
|||||||
"providerSelector.noLlm": "Aucun modèle IA configuré.",
|
"providerSelector.noLlm": "Aucun modèle IA configuré.",
|
||||||
"providerSelector.costOne": "Coût : 1 crédit par page",
|
"providerSelector.costOne": "Coût : 1 crédit par page",
|
||||||
"providerSelector.costFive": "Coût : 5 crédits par page (Facteur Premium)",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,5 +28,11 @@
|
|||||||
"reviews.error.title": "Erreur",
|
"reviews.error.title": "Erreur",
|
||||||
"reviews.error.load": "Erreur de chargement",
|
"reviews.error.load": "Erreur de chargement",
|
||||||
"reviews.error.update": "Mise à jour impossible",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.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.label": "Google Traduction",
|
||||||
"services.fallback.google.desc": "Traduction rapide, 130+ langues",
|
"services.fallback.google.desc": "Traduction rapide, 130+ langues",
|
||||||
"services.pill": "Moteurs"
|
"services.pill": "Moteurs",
|
||||||
|
"services.titleAccent": "disponibles"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,5 +19,6 @@
|
|||||||
"settings.formats.powerpoint.name": "PowerPoint",
|
"settings.formats.powerpoint.name": "PowerPoint",
|
||||||
"settings.pill": "Configuration",
|
"settings.pill": "Configuration",
|
||||||
"settings.cache.confirm": "Confirmer et recharger",
|
"settings.cache.confirm": "Confirmer et recharger",
|
||||||
"settings.cache.cancelClear": "Annuler"
|
"settings.cache.cancelClear": "Annuler",
|
||||||
|
"settings.titleAccent": "préréglages"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,5 +22,8 @@
|
|||||||
"teams.invite": "Inviter",
|
"teams.invite": "Inviter",
|
||||||
"teams.empty": "Aucun espace pour l'instant — créez le premier ci-dessus.",
|
"teams.empty": "Aucun espace pour l'instant — créez le premier ci-dessus.",
|
||||||
"teams.error.title": "Erreur",
|
"teams.error.title": "Erreur",
|
||||||
"teams.error.load": "Chargement impossible"
|
"teams.error.load": "Chargement impossible",
|
||||||
|
"teams.pill": "Collaboration",
|
||||||
|
"teams.titleBase": "Espaces de",
|
||||||
|
"teams.titleAccent": "travail"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,12 +49,8 @@
|
|||||||
"translate.glossary.targetWarningBut": "mais votre document cible",
|
"translate.glossary.targetWarningBut": "mais votre document cible",
|
||||||
"translate.glossary.targetWarningEnd": "Les termes risquent de ne pas être pertinents.",
|
"translate.glossary.targetWarningEnd": "Les termes risquent de ne pas être pertinents.",
|
||||||
"translate.header.processing": "Traitement en cours",
|
"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.aiActiveDesc": "Votre mise en page est en cours de préservation par notre moteur contextuel.",
|
||||||
"translate.header.completed": "Complété",
|
"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.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.upload.nativeFormat": "Format natif",
|
||||||
"translate.fileType.word": "Word (.docx)",
|
"translate.fileType.word": "Word (.docx)",
|
||||||
@@ -68,12 +64,7 @@
|
|||||||
"translate.contextEngineActive": "Moteur contextuel actif",
|
"translate.contextEngineActive": "Moteur contextuel actif",
|
||||||
"translate.phase1": "Phase 1 : Initialisation",
|
"translate.phase1": "Phase 1 : Initialisation",
|
||||||
"translate.phase2": "Phase 2 : Reconstruction contextuelle",
|
"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.stat.time": "temps",
|
||||||
"translate.complete.masterQuality": "✓ Qualité Maître",
|
|
||||||
"translate.download": "Télécharger",
|
"translate.download": "Télécharger",
|
||||||
"translate.newTranslation": "+ Nouvelle traduction",
|
"translate.newTranslation": "+ Nouvelle traduction",
|
||||||
"translate.failedTitle": "Erreur lors de la traduction",
|
"translate.failedTitle": "Erreur lors de la traduction",
|
||||||
@@ -81,10 +72,6 @@
|
|||||||
"translate.uploadAnother": "Téléverser un autre fichier",
|
"translate.uploadAnother": "Téléverser un autre fichier",
|
||||||
"translate.monitor": "Moniteur IA",
|
"translate.monitor": "Moniteur IA",
|
||||||
"translate.summary": "Récapitulatif",
|
"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.preserveLayout": "Conserver la mise en page",
|
||||||
"translate.preserveLayoutDesc": "Conserver la mise en page",
|
"translate.preserveLayoutDesc": "Conserver la mise en page",
|
||||||
"translate.textOnly": "Texte brut",
|
"translate.textOnly": "Texte brut",
|
||||||
|
|||||||
Reference in New Issue
Block a user