Compare commits

..

39 Commits

Author SHA1 Message Date
d008baf81b feat(landing): nouvelle animation d'accueil — une recette déclinée dans les quatre formats
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m34s
Le nouveau dessin montre une tarte aux pommes, comprise de tous,
présentée successivement en document Word, tableau Excel, présentation
PowerPoint et PDF : une onde de traduction traverse chaque document,
la langue change, la mise en page reste rigoureusement identique.
Contenu universel, plus aucun jargon de spécialiste.

Également : deux nettoyages DeepL restés non envoyés (commentaires
dans database/models.py et translators/word_translator.py).

Vérifié : construction réussie, 14/14 tests, aucune erreur d'analyse,
mode sombre et « réduire les animations » pris en charge.
2026-08-30 23:49:05 +02:00
3f45ccdf50 feat(ui): vague 4 — la zone de dépôt répond, l'or devient lisible, tarifs binaires, avant/après réel
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m58s
Zone de dépôt : le contour s'allume en doré et la carte s'agrandit
quand on la survole avec un fichier (l'information existait, elle
n'était jamais affichée).

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

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

Accueil : le mur de six cartes génériques est remplacé par un vrai
avant/après — une page « Cahier des charges — Traitement d'air » à
côté de sa traduction anglaise, structure identique au mot près
(terme technique en gras des deux côtés), sceau « Même mise en page,
mot pour mot » entre les deux, et trois preuves précises (SmartArt
reconstruits, séries de graphiques traduites, tables des matières
régénérées) à la place des promesses vagues.
2026-08-30 23:27:53 +02:00
9d5b5ce9c3 feat(landing): hero animé dessiné — la promesse du produit montrée au lieu d'une photo de banque d'images
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m11s
Deux pages de document côte à côte (source FR, traduction qui cycle
EN/ES/DE) : squelette identique (titre, lignes, tableau, graphique),
seuls les mots et les longueurs de lignes changent, avec balayage doré
à chaque changement et points animés sur le connecteur. Respecte le
réglage « réduire les animations ». Légende : « Même mise en page,
nouvelle langue — rien d'autre ne bouge ».
2026-08-30 23:19:07 +02:00
5ae6c48435 docs: règles de communication en français correct, sans jargon
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m33s
2026-08-30 23:08:08 +02:00
a96b3c099a feat(translate): multi-file queue with dedicated runner tests + context polish
Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled
Batch upload: dropzone and inputs accept multiple files (cap 10/run,
validated + deduped); 2+ files switch the submit to a queue screen —
sequential jobs with per-file progress, failure messages through the
humanizer, per-file download/review links, staggered download-all,
real server-side stop (cancel API) plus between-files abort. The
one-file flow is untouched: files.length === 1 takes the exact
existing path.

Runner: standalone runTranslationJob (submit + poll + abort + cancel)
with injectable poll interval; 5 dedicated vitest cases covering
completed-with-progress, backend failure, submit HTTP error, mid-flight
abort with server cancel, and network-failure cutoff.

Context page close review: /dashboard/context is a clean redirect to
glossaries (no duplicate); the fake 300ms save delay removed (instant
zustand persist); suggestion chips now resolve their prompt bodies
through i18n (EN+FR — no more French prompts for English users); the
translate config column shows a 'Context guidelines active' chip that
links to the editor when a Pro LLM prompt is set.

Dead code: legacy file-uploader.tsx (+webllm.ts, its only consumer)
and the unused PRESETS/applyPreset/clearContext store block removed
(-14KB of glossary strings).

Verified: build exit 0, vitest 14/14 (9 prior + 5 runner), eslint 62
errors (vs 64 at HEAD), 0 missing i18n keys.
2026-08-30 23:05:37 +02:00
67365918ae feat(ui,api): wave 3 — editorial pricing, real cancel, server history, DeepL purge
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m36s
Pricing: full editorial redesign — serif card headers with accent pills
replace the colored font-black blocks, tone sweep across toggle/metrics/
features/CTAs, PLAN_COLORS removed; one design system app-wide.

Translate: decorative titles one step down (CTA hierarchy restored);
glossary and image-translation blocks hidden entirely for free users
(progressive disclosure — three controls for free).

Reviews: XLIFF hint line explains the exchange format; backend errors
routed through a friendly mapper (session/not-found/rate-limit/server).

Landing: fabricated hero UI cards (fake 'Context Engine' overlay)
removed — the photo no longer promises screens that don't exist.

Nav: single DashboardNavLinks component shared by sidebar and mobile
drawer (was duplicated markup).

API: GET /api/v1/translations (user job history, paginated; completed
jobs retained 24h) and POST /api/v1/translations/{id}/cancel —
cooperative cancellation with worker checkpoints before dispatch and
before finalisation, reserved quota released immediately. Translate
monitor now offers a real 'Cancel translation' next to 'Back to start';
recent-jobs list reads server history first, localStorage fallback.

DeepL purge (backend): provider module, registry registration, config
attrs/defaults, dispatch branch, admin settings schema + test branch,
legacy availability block, validation rules, plan provider lists,
error-code mappings, MCP enums, translator prompt mention, related
tests updated/removed. Fallback resolver skips unknown providers, so
stale chains containing 'deepl' degrade gracefully.

Verified: backend 110 tests passed; frontend build exit 0, vitest 9/9,
0 missing i18n keys, eslint 63 errors (vs 64 at HEAD).
2026-08-30 22:42:29 +02:00
52748ee653 feat(pricing): remove DeepL from all user-facing surfaces
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m57s
Pricing: 'deepl' removed from plan provider lists and the comparison
table row. Landing and pricing copy (13 locales) no longer mention
DeepL (65 values cleaned, 39 dead keys deleted: pricing.comparison.deepl,
providerTheme.classic.deepl.*).

Providers: available-provider responses are filtered so a DeepL entry
from the backend can never surface in the engine picker or the services
page; DeepL theme entry and static lib/api.ts list entry removed.

Admin: DeepL config card, type fields, defaults and fallback-chain
mentions removed; stats/chart/status maps and mock data cleaned.

Backend adapter untouched — DeepL is invisible app-wide via the UI
filter; removing the Python adapter itself is a separate step if wanted.

Verified: build exit 0, vitest 9/9, eslint clean on touched files,
zero 'deepl' occurrences outside the two intentional UI filters.
2026-08-30 22:20:53 +02:00
017b998941 fix(landing): pricing cards read live plan prices from /api/v1/auth/plans
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m54s
The landing hardcoded 9/19/49 (monthly) and 7/15/39 (annual-equivalent)
while /pricing renders admin-configurable prices — the two surfaces
drifted apart whenever prices changed. The landing now fetches the same
live endpoint (no-store) with the previous values as load/failure
fallback, and shows the French '9 €' format like /pricing (was '€9').
2026-08-30 22:12:52 +02:00
111f3cb69d fix(ui): wave 2 from critique re-run — AA contrast, unified PageHeader, checkout confirm
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m46s
Nav: teams/settings/services back out of the nav until product-ready (user
decision); teams page was already half-finished (UUID member display is a
backend limitation).

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

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

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

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

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

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

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

Verified: build exit 0, vitest 9/9, eslint 64 errors = previous level,
0 missing i18n keys.
2026-08-30 22:02:35 +02:00
50047ea8a2 fix(ui): design critique overhaul — a11y, honest metrics, i18n repair (critique 2026-08-30)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m51s
P0 a11y: keyboard-accessible dropzone (role/tabIndex/Enter-Space), ARIA
combobox + listbox pattern for language selector, role=switch on glossary
toggle, role=status live region on notifications, aria-labels on password
toggles, visible-on-focus close buttons, RTL logical positioning (start-*).

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

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

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

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

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

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

Verified: next build exit 0, vitest 9/9, eslint 64 errors = HEAD
(no regression, -3 warnings), detector 4 -> 3 findings.
2026-08-30 21:44:53 +02:00
1a67241ad5 fix(migrations): use sa.false() for boolean server default
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m48s
PostgreSQL rejects 'BOOLEAN DEFAULT 0' (DatatypeMismatchError: default
expression is of type integer), which aborted the production deploy
during the segments/workspaces migration. sa.false() renders DEFAULT
false on PostgreSQL and DEFAULT 0 on SQLite.
2026-08-30 09:17:18 +02:00
e864caac00 fix(landing): replace language cycle button with dropdown selector
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m49s
The landing page language switcher cycled to the next language on every
click. It is now a dropdown listing all 13 languages; the cycling button
variant is removed from the LanguageSwitcher component.
2026-08-30 09:08:58 +02:00
b4e873ad2c feat(review,teams): review foundation — segments, side-by-side editor, rebuild, XLIFF, team workspaces
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 2m14s
Foundations:
- TranslationSegment model + migration f7e8d9c0b1a2 (segments, workspaces,
  workspace_members, glossaries.workspace_id)
- SegmentRecorder injected into all 4 translators: unique (source,
  translation) pairs captured per job and persisted (best-effort)
- set_segment_overrides: human-reviewed translations applied verbatim on
  rebuild — top priority over TM and provider, zero API calls

Review API (routes/review_routes.py):
- GET /translations/{id}/segments (owner or job token)
- PATCH /segments/{id} edit/approve — feeds the per-user TM so approved
  translations are reused in later jobs
- POST /translations/{id}/rebuild — rebuild document with reviewed text
- GET/POST /translations/{id}/xliff — XLIFF 1.2 export/import (edited
  segments export their reviewed text)

Review editor (frontend /dashboard/reviews/[jobId]):
- side-by-side source/translation table, inline edit, approve (single or
  all), rebuild & download (auth blob), XLIFF export/import, 13 locales
- 'Relire et corriger' link on the translation-complete screen

Team workspaces (routes/workspace_routes.py + /dashboard/teams):
- Workspace/WorkspaceMember models, roles owner/admin/member
- create (Business plan), list with seat usage, invite by email with
  seat-limit enforcement (Business=5, Enterprise unlimited), removal
- shared glossaries: workspace members can use a glossary shared to their
  workspace (access check extended)

Tests: 1184 passed / 0 failed (11 new: recorder, overrides, docx
capture->rebuild e2e, XLIFF structure/escaping, seats, workspace CRUD,
shared glossary access)
2026-08-29 19:04:32 +02:00
526c87348f feat(translation): quality pipeline overhaul + new features (audit 2026-08-29)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m20s
Translation quality & format preservation:
- Word: merge adjacent same-format runs into one unit (sentence-level
  coherence like inline-tag handling); translate comments/balloons;
  dedupe textbox collection (was translated twice); RTL no longer
  overrides center/justify alignment; CJK/Arabic font hints (eastAsia/cs)
- PPTX: chart translations now actually reach the output file
  (ChartPart.blob is read-only — rewrite chart XML in the saved ZIP);
  CJK typeface hints (a:ea)
- Excel: sheet renames no longer break references — rewrite cell
  formulas (3D/quoted), defined names, data validations, cond. formats
- PDF: bold/italic honored (hebo/heit/hebi); table cells never merge;
  unchanged blocks left untouched (typography preserved, fixes duplicate
  hyperlinks); attempted/changed stats + route gate now cover PDF;
  CJK font paths; scanned PDFs via Mistral OCR (detection + admin settings)

Features:
- formality param (formal/informal) + automatic regional-variant prompts
- output_mode=bilingual docx (source above translation)
- per-user translation memory on Redis (falls back to LRU), context-hashed
- QA report + 0-100 confidence score in job status; L0 on by default
- OpenAI-compatible providers: whole chunk in ONE numbered-JSON request
  (~15x fewer calls) with per-item fallback; base prompt always present
  (custom prompt no longer replaces translation instructions)

Infra & marketing alignment:
- plan-based engine gating + vision gating (closes paid-engine leak);
  /providers/available filtered per plan; 107 languages exposed
- zh-CN/zh-TW validation fixed; libmagic disabled on Windows (native crash)
- admin: Mistral OCR settings + engine status dashboard; httpx<0.28 pin
  (TestClient breakage); Prometheus test fixture fixed
- marketing docs aligned with code (PDF+OCR, retention, engines, pricing)
- security: .env.ionos/.env.production/provider_settings.json removed

Tests: 1173 passed / 0 failed (6 network tests deselected: free Google
endpoint temporarily blocked from this machine)
2026-08-29 18:38:09 +02:00
992f13d53c fix(translate): apply Excel chart text setters (were silently dropped)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m35s
Chart <a:t> elements (title, axis labels, series names) were collected and
sent to the LLM, but the apply loop never invoked their setters. Reason:
the apply loop iterated only text_elements[:sheet_name_offset], which
excluded the chart-text positions (sheet names were inserted in the
middle, pushing chart texts past sheet_name_offset). The LLM correctly
returned French for "Revenue by Product" / "Quantity Trend" / "Order #"
but the result was thrown away.

Fix: include the chart-text slice [sheet_name_offset + N .. total_texts]
in the apply loop. Setters for sheet names are still None, so they are
naturally skipped.

Add test_excel_chart_text_applied.py (2 end-to-end tests) using a fixed
provider that pre-translates every known chart text; the test asserts
all chart <a:t> values in the output .xlsx come from the FR table, not
the original English source.
2026-07-15 22:21:53 +02:00
07c4c12e6a fix(translate): chart labels, sheet-refs, sheet-name offset, paid-user Memento
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m19s
Multiple translation bugs in Word/Excel/PPTX that caused chart elements
to be left untranslated or charts to render as empty series.

Backend
-------
* providers (deepseek/openai/minimax): tighten system prompt so the LLM
  actually translates chart titles/axis labels/legend/category labels,
  month abbreviations, and clarifies what counts as a 'real' proper noun
  (people/place/company/product names) vs. technical labels. Old rule
  'keep proper nouns unchanged' was being read too broadly by the model
  and caused chart text to be skipped.
* excel_translator.py:
  - Sheet reference rewrite: when a sheet is renamed, the chart XML's
    c:f refs (e.g. 'Sales 2024'!$D$2:$D$61) are now rewritten to the
    new name with proper apostrophe escaping (Chiffre d'affaires ->
    'Chiffre d''affaires') and auto-quoting when the new name contains
    spaces or special chars. Without this, the chart points at a sheet
    that no longer exists and renders 0/empty series.
  - Sheet name offset bug: sheet_name_offset was computed after chart
    text was appended to text_elements, causing sheet names to receive
    chart text translations. Now captured BEFORE sheet names are added.
* New tests:
  - test_excel_chart_sheet_refs.py (10 unit tests, synthetic inputs)
  - test_chart_translation_prompt.py (3 contract tests on the prompt)

Frontend
--------
* DashboardSidebar / translate/page: hide the Memento promo section
  for paying users (tier != 'free').
* constants.ts: temporarily comment out the 'CLES API' nav item.
  Update constants.test.ts to match the new state.

All fixes are generic - no file-specific hardcoding, edge cases covered
(empty mapping, missing bang, apostrophe escaping, partial renames,
multi-series).
2026-07-15 22:04:40 +02:00
8f96ddfe71 feat(admin): Stripe live-mode safety + one-click webhook setup
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m31s
User said 'I want this to be automatic'. Three gaps were addressed.

Gap 1 — test key in production went undetected
  The admin page showed 'cle secrete OK' for both sk_test_ and sk_live_.
  If a misconfigured VPS kept its sk_test_… in production, the app
  would create real signup flow but never charge real cards. Added
  services.pricing_config:
    - stripe_mode() -> 'live' | 'test' | 'unknown' (key prefix)
    - is_test_mode_in_production() -> True if ENV=production AND sk_test_
  GET /admin/pricing now exposes {mode, is_test_mode_in_production, env}.
  POST /admin/pricing/setup-stripe and the new setup-webhook refuse
  to run in that state unless the admin passes {force: true}.

Gap 2 — webhook setup was 100% manual
  The admin had to go to Stripe Dashboard, create the endpoint, copy
  the whsec_, paste it back. Stripe API supports creating webhook
  endpoints programmatically, so the new endpoint
  POST /admin/pricing/setup-webhook does it all in one click:
    - derives the webhook URL from the request (X-Forwarded-Proto + Host)
    - calls stripe.WebhookEndpoint.create() (or .update() if the URL
      already exists) with the 6 events the backend actually handles
      (checkout.session.completed, customer.subscription.*, invoice.*)
    - persists the returned whsec_ to .env via _update_env_file
    - hot-reloads the runtime config (no restart needed)
  Refuses http:// URLs in live mode (Stripe requires https).

Gap 3 — obsolete script leaked a test secret
  scripts/stripe_setup.py contained a hardcoded sk_test_… in source.
  It had been replaced by POST /admin/pricing/setup-stripe but was
  still in the repo. Deleted via git rm. The key was also rolled: the
  user should rotate that sk_test_ in the Stripe Dashboard.

Frontend changes (admin pricing page):
  - LIVE / TEST / non-configure badges next to 'Statut Stripe'
  - ENV=... chip in the header
  - BLOCKING red banner if test mode detected in production
  - Stepped numbering: 1. Produits & prix / 2. Webhook Stripe
  - New 'Setup webhook auto' button
  - Auto-setup error 409 (TEST_MODE_IN_PRODUCTION) -> confirmation
    dialog to retry with force=true

11 new tests for stripe_mode() and is_test_mode_in_production(),
covering live key, test key, missing key, garbage key, whitespace,
ENV vs ENVIRONMENT alias, all 5 prod/dev combinations.

Total: 471 tests pass (was 460), zero regression.
2026-07-14 20:19:49 +02:00
9b15b7c9fa feat(format): B3.10 — preserve code-block / callout layout (drawing-covered blocks)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m10s
User reported that on the page 3 of the test PDF ('2. Installation
and Setup'), the curl code block was visually broken: the 5 code
lines (curl, -H, -F, -F, -F) were split, with the first 2 lines
above the gray background box and the last 3 inside (or vice versa).

Root cause: each code line is its own PDF block. The merge logic
correctly combined them into a single block (same x0, similar font,
small gap). The smart-fit then wrote the entire 5-line text into
the merged block's bbox, shrinking the font to fit. The result
no longer aligned with the fixed-extent gray background drawing.

Fix: detect when a block is covered by a colored background drawing
(code block, callout box, info box, etc.) and:
  1. Mark each line as _no_merge=True so the merge logic keeps them
     as separate per-line blocks
  2. Each line keeps its original y position
  3. The smart-fit writes each line at its own bbox, preserving
     alignment with the surrounding drawing

Detection: a block is 'covered by drawing' if >= 50% of its bbox
area intersects a filled drawing on the page. This is conservative
enough to avoid false positives from drawings that merely touch a
corner of the block.

The same logic applies to callout boxes, info boxes, and any other
visual element where the background defines a fixed extent that the
text must align with. The detection is generic — no hardcoded
patterns, no font-based heuristics.

3 new tests added:
  - test_code_block_lines_marked_no_merge: 5 lines inside a
    background drawing all marked _no_merge=True
  - test_paragraph_not_marked_no_merge: 3 plain lines (no drawing)
    still merge into 1 block (regression check)
  - test_code_block_end_to_end_preserves_lines: full translate
    pipeline, each line stays at its own y, all inside the drawing

Total: 460 tests pass (was 457), zero regression.
2026-07-14 19:56:12 +02:00
2da2c4765c feat(format): B3.9 — preserve PDF table column structure during translation
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m30s
User reported that the page 6 table on the test PDF ('6. Performance
and Scaling' page) was completely broken: the 3-column table
('Document size | Avg latency (s) | Throughput (docs/min)' with
5 data rows) was rendered as a vertical list of label/value pairs
instead of as a proper table.

Root cause: a PDF 'block' that contains multiple LINES at the SAME
y but different x positions is a table row (3 cells side-by-side).
The extractor was treating the whole row as one paragraph, joining
all cell texts with newline. When the smart-fit logic wrote the
text back, it used the row's full-width bbox and \insert_textbox\
wrote everything left-aligned, collapsing all columns into one.

Fix: at extraction time, detect horizontal-layout blocks (lines at
the same y, different x within 5pt tolerance) and split them into
one sub-block per line. Each cell gets its own bbox, so the
translator writes each cell at its original x position, preserving
the column structure.

Detection heuristic:
  - Block has >= 2 lines
  - All lines have y0 within 3pt of each other (SAME_ROW_Y_TOLERANCE)
  - At least 2 lines have different x0 (within > 5pt)
If all three hold, it's a table row. Otherwise, keep the old
multi-line-paragraph behavior.

Note: PyMuPDF re-groups cells into row-blocks when reading the
output back (so 'len(blocks)' looks unchanged), but the LINES
within each block are at their correct x positions. Tests check
the line x0 values, not the block count.

Visual proof: page 7 of sample_files/test_corpus/test_pdf_translated.pdf
now shows the table with proper 3-column structure (Taille du document
| Latence moyenne (s) | Débit (docs/min)) instead of an '[translation
overflow]' placeholder.

4 new tests added:
  - test_horizontal_layout_detected: 3 lines at same y -> 3 blocks
  - test_vertical_layout_kept_as_one_block: 3 lines at different y -> 1 block
  - test_single_line_block_unchanged: 1 line -> 1 block
  - test_table_cell_each_at_own_x: e2e table translation, cells at
    correct x positions

Total: 457 tests pass (was 453), zero regression.
2026-07-14 19:25:24 +02:00
4aebb49c7b fix(brand): Memento (not Momento) — fix typo + add link to memento-note.com
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m25s
The product name is 'Memento' (not 'Momento'). The 'Momento' typo was
present in 12 i18n locale files (en, fr, es, de, it, pt, nl, ja, ko,
zh, ru, ar, fa) and in code comments.

Also added a 'memento.url' i18n key with the canonical link
https://memento-note.com/ and wrapped the Memento promo cards in
both DashboardSidebar.tsx and translate/page.tsx with an <a> tag
pointing to that URL. Previously the cards were a non-clickable
<div>, so users had no way to reach the Memento product page.

Note: 'momento' is a legitimate word in Italian, Spanish and
Portuguese meaning 'moment' (e.g. 'a qualsiasi momento'). Those
occurrences in pricing.json and services.json were NOT changed —
they are correct translations of UI strings about 'at any moment'.
2026-07-14 19:19:00 +02:00
4255a1a0c5 feat(format): B3.8 — column-aware next-block layout for multi-column PDFs
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m32s
User asked whether B3.6+B3.7 are generic for ALL PDFs or just for the
test_pdf.pdf. Audit found 2 genericity bugs:

1. _populate_next_block_y was sorting blocks globally by y0. In a
   multi-column PDF (journals, brochures, newspapers), the 'next
   block' of a left-column block would point to the right-column
   block at the same y, which is wrong. Fix: group blocks into
   columns by x0 proximity (15pt tolerance), then sort each column
   by y0. Each block's next_block_y is the y0 of its column-mate
   directly below it, not just the next block in y-order globally.

2. max_expand_y could go negative if next_block_y was above the
   current block (rare edge case in extracted blocks with weird
   bbox ordering). A negative max_expand_y would create an invalid
   fitz.Rect with y1 < y0, causing silent failures. Fix: clamp
   max_expand_y to >= 0.

7 new tests added:
  - test_two_columns_get_separate_next_block_y: 2-col layout,
    left and right columns get independent next_block_y mappings
  - test_centered_full_width_header_gets_own_column: full-width
    header between 2 columns is its own column
  - test_three_columns: 3-column newspaper layout
  - test_single_block_page, test_empty_block_list: edge cases
  - test_max_expand_y_clamped_to_zero: negative-expansion safety
  - test_two_column_pdf_translation_end_to_end: e2e test on a
    2-col journal PDF, 4 input blocks -> 4 output blocks preserved
    at correct positions, no cross-column overlap

Visual verification:
  scripts/verify_b3_8_multicolumn.py renders a 2-col journal PDF
  before and after translation, confirms 4 left + 4 right blocks
  preserved at exact positions.

Total tests: 453 (was 446), zero regression.
2026-07-14 19:05:47 +02:00
3ae28dd3cb feat(format): B3.6+B3.7 — PDF transparent redaction + next-block-aware layout
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m36s
B3.6 — fix two visual bugs reported on the user's prod PDF:
  1. Title 'Spécification technique : Office Translator v3.0' overflowed
     its 2-line bbox and overlapped the 'Version du document...' block.
     Root cause: MAX_VERTICAL_EXPANSION was 1.5x the original height,
     way too small for a long French title. Bumped to 6.0x.
  2. 'Avis important' blue background had white rectangular patches.
     Root cause: redaction always used fill=(1,1,1) (opaque white),
     which erased the colored drawing underneath the text.
     Fix: detect when a block's bbox intersects a page drawing,
     and use fill=None (transparent) for the redaction in that case.
     The original drawing survives intact.

B3.7 — eliminate remaining block-vs-next-block overlap:
  Computes each block's 'next_block_y' (the y0 of the nearest block
  below it on the same page) and uses it as the ceiling for vertical
  expansion. Previously the smart-fit logic used the page bottom as
  the ceiling, which let long translated blocks flow into their
  neighbour (e.g. 'Pour la dernière version...' overlapping
  '8. Résolution des problèmes' in the TOC).

Also includes:
  - 9 new tests (6 B3.6 + 3 B3.7) — total 446 tests pass, zero regression
  - scripts/verify_b3_6_fix.py — visual+structural verification
  - Updated sample_files/test_corpus/test_pdf_translated.pdf with the
    clean B3.6+B3.7 output
2026-07-14 18:56:31 +02:00
e706cef5d6 feat(format): B3.5 — PDF smart-fit rewrite + critical fontname=None fix
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m44s
ROOT CAUSE FIX: PyMuPDF silently raised AttributeError when fontname=None
was passed to insert_textbox. The try/except in _try_insert was swallowing
the error and returning None, causing every block to be skipped via the
graceful failure path. Setting fontname='helv' as the default unblocks
the entire PDF translation pipeline.

SMART-FIT: rewrite _write_translated_block with proper tier-fallback:
  - Tier 0: original bbox at original size
  - Tier 1: expanded horizontal
  - Tier 2: expanded vertical (3x original height)
  - Tier 3: shrink once (0.93x)
  - Tier 4: shrink twice (0.87x cumulative)
  - Tier 5: min size floor (90% for headings, 75% for body)
  - Tier 6: graceful skip with visible placeholder

REDACTION: single redaction per block (was per sub-bbox, creating 100+
redaction rectangles per page). Now only 1 redaction per text block.

FEATURE FLAG: PDF_SMART_FIT_ENABLED (default true, observation-first).

METRICS: text_overflow -> format_elements_lost_total.

RESULT ON REAL PDF:
  Before: fonts shrunk 22pt->5.6pt, hierarchy destroyed
  After:  fonts EXACT match: [8, 11, 12, 14, 16, 22] preserved
2026-07-14 18:36:12 +02:00
12cd0c6893 test: COMPLEX test corpus (5 sections, 80 hyperlinks, 10 footnotes, 3 SmartArt, 8 PDF pages with TOC)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m32s
2026-07-14 18:06:23 +02:00
b706cbf802 test: generate test corpus for B1/B2/B3 (Word+Excel+PPTX+PDF with hyperlinks/footnotes/charts/SmartArt)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m56s
2026-07-14 17:52:06 +02:00
dd1e005c70 feat(cache): C2 — Redis translation cache with user/prompt/glossary namespacing
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m13s
2026-07-14 17:05:24 +02:00
04a9328860 feat(format): B3 — PDF hyperlink preservation + safe redaction + LibreOffice log
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m29s
2026-07-14 17:02:21 +02:00
d40d7f3e86 feat(providers): C1.1 — NewProviderAsLegacyAdapter + LegacyProviderAsNewAdapter
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m22s
2026-07-14 16:58:11 +02:00
c794eff823 feat(quality): A4 — L2 Pro premium judge (8 dims, gpt-4o, Pro-gated, opt-in)
Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled
2026-07-14 16:56:04 +02:00
8d0fc818ef feat(metrics): C3 — Prometheus counters for L0/L1/format-loss/retry
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m30s
2026-07-14 16:53:16 +02:00
13d2f83081 feat(format): B2 — PPTX placeholder filter, SmartArt diagrams, chart XPath matching
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m3s
2026-07-14 16:50:06 +02:00
4d466699fd feat(quality): A3 — L1 LLM judge via API (5 chunks, 0.0003 USD/job)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m26s
L1 quality layer — uses a cheap LLM via the OpenAI-compatible API to
validate translation quality. Designed to be the SECOND line of defense
after L0 (script detection, length, pattern).

Architecture:
  - sampler.py — picks 5 representative chunks per job (longest first,
    skips L0-failed indices, skips too-short or identical pairs)
  - llm_judge.py — OpenAI-compatible client, binary verdict per chunk
    (accurate / fluent / correct_language / no_leaks), JSON output,
    hard timeout, defensive (never raises), cost estimation built in
  - pipeline.py — defensive wrapper that integrates both, never breaks
    a translation job, always logs a structured event

Integration:
  - 5 feature flags in config.py (QUALITY_L1_ENABLED, _LOG_ONLY, etc.)
  - QUALITY_L1_LOG_ONLY=true by default: log-only mode, verdict NEVER
    blocks or retries a job
  - Reuses the chunks extracted by L0 (no double work)
  - Passes the set of L0-failed indices so L1 doesn't re-judge them
  - Wrapped in try/except so a misconfigured L1 NEVER breaks a job

Default config: deepseek-chat via DeepSeek API
  - Cost: ~0.0003 USD per job (5 chunks)
  - Speed: typically 1-2s per call, hard ceiling at 8s
  - Easy to swap: just set L1_JUDGE_BASE_URL and L1_JUDGE_MODEL

LLM judge is intentionally a SEPARATE model from the translator
(self-evaluation bias mitigation — Meta/Stanford papers 2024-2025).

Tests:
  test_sampler.py — 9 tests covering the sampling strategy
  test_llm_judge.py — 22 tests covering init, parsing, mocked API,
    cost estimation, env factory
  test_l1_pipeline.py — 6 tests covering the wrapper
  Total new: 37 tests, all pass
  Grand total quality+format: 264 tests passing (0 regression)

  All 36 new tests + 111 L0 tests + 117 existing translator tests = 264

Phase 1 (observation) for 2 weeks. Then QUALITY_L1_LOG_ONLY=false
to enable auto-retry via the fallback chain.
2026-07-14 16:39:47 +02:00
5ae1587428 feat(format): B1 — Word/Excel quick wins for format preservation
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m35s
Word fixes:
  W1 — Fix hyperlink double-collect: a run inside <w:hyperlink> was
       previously collected twice (once via paragraph.runs, once via
       the manual hyperlink iter). Now uses a dedup set of element
       ids to collect each run exactly once.

       NB: python-docx 1.x's paragraph.runs does NOT include runs
       inside hyperlinks, so the iteration now does both:
       paragraph.runs (direct children) + a manual iter of all
       <w:r> in the tree (catches hyperlink runs).

  W2 — Fix footnotes import: used document.part.package.part_related_by
       which doesn't exist in python-docx 1.x, so footnotes were never
       collected. Now uses document.part.related_parts to find the
       footnotes part by content type, walks the XML directly with
       lxml (avoids the 'r_lst' error from wrapping foreign elements
       in python-docx's Paragraph class), and registers a post-save
       callback to re-write the footnotes.xml part with translated
       text (since python-docx doesn't manage that part on save).
       Same fix applied to endnotes.

  W4 — Chart matching by element path: was matching <a:t> and <c:v>
       elements by string equality, so two charts with the same text
       (e.g. two 'Revenue' series) would only have the first one
       translated. Now stores the XPath-like element path at collect
       time and navigates to the exact element at apply time. Falls
       back to string matching for legacy entries without a path.

Excel fixes:
  E2 — Translate cell comments: openpyxl Comment objects are now
       collected and their text translated. The Comment object is
       replaced in place after translation.

  E3 — Translate cell hyperlink display labels: cell.hyperlink.display
       (or .target if no display) is collected and translated. The
       URL itself is never sent for translation, so it remains
       intact. A run that already exists for the cell value is
       not double-translated (the dedup check is automatic).

  E4 — Chart matching by element path: same fix as W4 but for
       Excel. Two charts in the same workbook with the same text
       now each get their own translation.

Tests:
  Added tests/test_translators/test_b1_format_fixes.py with 11 tests
  covering all the fixes. All 11 pass. Existing translator tests
  (38 word + 38 excel + 30 pptx = 106) still pass — 0 regressions.

  Total tests for the quality+format layer: 228 passing
  (111 L0 Python + 63 L0 TypeScript + 11 B1 + 43 other translator).

All fixes are surgical: existing translation flow is preserved.
The only new file path through the code is for footnotes/endnotes
which previously didn't work at all.
2026-07-14 16:28:17 +02:00
f403b2851d feat(quality): add L0 quality layer (Track A1 + A2 of dev plan)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m5s
L0 quality detection layer to catch translation failures BEFORE they
reach users. Pure Python/TypeScript, zero new dependencies, no API calls.

Backend (Python — services/quality/):
  - Script detection: 145 langs mapped to 23 scripts (Latin, Cyrillic,
    Greek, Arabic, Hebrew, CJK, Hangul, Kana, Devanagari, Bengali, etc.)
  - Language confusion detection (e.g. Arabic text for French target)
  - Arabic-script variant discrimination (Persian/Urdu/Pashto/Kurdish
    confusion — e.g. Persian text returned when Arabic was requested)
  - Length sanity check (with numeric/short-source exemptions)
  - Prompt leak detection (Translation: / Voici la traduction: / 翻译:)
  - Repetition hallucination detection (token + character level)
  - File text extraction for .docx/.xlsx/.pptx/.pdf (no translator
    changes needed)
  - Defensive pipeline that never raises (L0 must NEVER break a job)

Frontend (TypeScript — wordly.art---traduction-de-documents/src/utils/):
  - Exact 1:1 mirror of the Python module
  - Zero dependencies, works in browser AND Node.js
  - Native Unicode regex (\\p{L}/u) and codePoint iteration
  - 63 tests using Node's built-in test runner

Integration:
  - Feature-flagged: QUALITY_L0_ENABLED=false (default)
  - Observation only: logs structured events, never modifies files
  - try/except wrapped: impossible to break a translation job
  - Lazy imports: only loaded when flag is on
  - Zero impact on existing tests / behavior

Tests:
  - 111 Python tests covering all paths (config, script, length, leak,
    pipeline, file_extractor) — 100% pass
  - 63 TypeScript tests (Node --test) — 100% pass
  - 174/174 total tests for the L0 layer

Bug fixes in script mapping:
  - yi (Yiddish) -> hebrew (was incorrectly mapped to arabic)
  - dv (Maldivian) -> thaana (was incorrectly mapped to arabic)
  - ja (Japanese) -> hiragana_katakana (distinguishes from Chinese CJK)

Phase 1 (backend) + Phase 2 (frontend) of Track A complete.
Next: Track B1 (Word/Excel format preservation quick wins).

Closes Track A phase 1+2 of the dev plan.
2026-07-14 16:17:43 +02:00
ebb2537fda feat(glossaries): implement a 3-step wizard for CSV/file imports with custom source/target language selection
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m21s
2026-06-28 11:38:18 +02:00
36aeac2c5e fix(glossaries): prevent translation data destruction on language selector change for multi glossaries
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m53s
2026-06-28 11:03:31 +02:00
de9407f974 revert(docker): remove automated database glossary translation from startup sequence
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m0s
2026-06-28 10:55:19 +02:00
5e3fb0098b feat(docker): run glossary translation script automatically on backend container startup
Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled
2026-06-28 10:53:58 +02:00
a57b8a8e4d fix(glossaries): normalize language code casing when retrieving display translations 2026-06-28 10:52:08 +02:00
319 changed files with 28329 additions and 5566 deletions

View File

@@ -71,6 +71,16 @@ OPENAI_HEALTH_CHECK_TIMEOUT=5
OPENROUTER_API_KEY=
OPENROUTER_MODEL=deepseek/deepseek-chat
# Mistral OCR — translation of SCANNED PDFs (image-only pages).
# Optional. If absent: scanned PDFs are rejected with an explicit error.
# Get API key from: https://console.mistral.ai/ (~$1 / 1000 pages)
MISTRAL_API_KEY=
MISTRAL_OCR_MODEL=mistral-ocr-latest
MISTRAL_OCR_TIMEOUT=180
MISTRAL_OCR_ENABLED=true
# Average extractable chars/page below which a PDF is treated as scanned
SCANNED_PDF_MIN_CHARS_PER_PAGE=100
# Ollama Configuration. Optional. If absent: default http://localhost:11434 (provider may be disabled).
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=llama3
@@ -96,6 +106,38 @@ TRANSLATIONS_PER_MINUTE=10
TRANSLATIONS_PER_HOUR=50
MAX_CONCURRENT_TRANSLATIONS=5
# ============== Quality Layer (L0) ==============
# Track A1 of the dev plan — observability only, no behavior change.
# When enabled, the L0 layer logs the script detection result for each
# translation job but does NOT modify the file or the job status.
# Default: false (opt-in). Set to "true" to enable.
QUALITY_L0_ENABLED=false
QUALITY_L0_SAMPLE_SIZE=20
# ============== Quality Layer (L1) ==============
# Track A3 of the dev plan — API-based LLM judge.
# Sends 5 sampled chunks per job to a cheap LLM (deepseek-chat by default)
# and logs the verdict (pass/fail) but does NOT modify the file or job
# status. Log-only by default for the first 2 weeks of observation.
# After that, set QUALITY_L1_LOG_ONLY=false to enable auto-retry.
#
# Cost: ~$0.0003 per job with deepseek-chat. ~$0.001 with gpt-4o-mini.
QUALITY_L1_ENABLED=false
QUALITY_L1_LOG_ONLY=true
QUALITY_L1_SAMPLE_SIZE=5
QUALITY_L1_MIN_CHUNKS=10
QUALITY_L1_TIMEOUT_SEC=8.0
# L1 judge configuration (any OpenAI-compatible endpoint).
# DeepSeek is the default (cheapest, ~$0.14/M input tokens).
L1_JUDGE_API_KEY=
L1_JUDGE_BASE_URL=https://api.deepseek.com/v1
L1_JUDGE_MODEL=deepseek-chat
# L1_JUDGE_MODEL=gpt-4o-mini
# L1_JUDGE_BASE_URL=https://api.openai.com/v1
# L1_JUDGE_MODEL=google/gemini-2.5-flash-lite
# L1_JUDGE_BASE_URL=https://openrouter.ai/api/v1
# ============== Cleanup Service ==============
# Enable automatic file cleanup
CLEANUP_ENABLED=true

View File

@@ -1,93 +0,0 @@
# ============================================================
# PRODUCTION — Ionos VPS
# Copiez ce fichier en .env sur le serveur et remplissez TOUT
# Ne committez JAMAIS ce fichier avec de vraies valeurs
# ============================================================
# ─── Application ────────────────────────────────────────────
APP_NAME="Office Translator"
ENV=production
DEBUG=false
LOG_LEVEL=INFO
LOG_FORMAT=json
# ─── Domaine & URLs ─────────────────────────────────────────
# Remplacez par votre vrai domaine (configuré dans Ionos DNS)
DOMAIN=wordly.art
NEXT_PUBLIC_API_URL=https://wordly.art
CORS_ORIGINS=https://wordly.art
# ─── Sécurité JWT ─────────────────────────────────────────
# OBLIGATOIRE — Générez avec :
# python3 -c "import secrets; print(secrets.token_urlsafe(64))"
JWT_SECRET_KEY=REMPLACEZ_PAR_UNE_CLE_SECRETE_DE_64_CARACTERES
# ─── Admin ──────────────────────────────────────────────────
ADMIN_USERNAME=admin
# Hash bcrypt de votre mot de passe admin
# Générez avec : python3 -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('VotreMotDePasse'))"
ADMIN_PASSWORD_HASH=REMPLACEZ_PAR_HASH_BCRYPT
# ─── Base de données PostgreSQL ─────────────────────────────
POSTGRES_USER=translate
# Mot de passe fort — minimum 32 caractères
# Générez avec : python3 -c "import secrets; print(secrets.token_urlsafe(32))"
POSTGRES_PASSWORD=REMPLACEZ_PAR_MOT_DE_PASSE_FORT
POSTGRES_DB=translate_db
DATABASE_URL=postgresql+asyncpg://translate:REMPLACEZ_PAR_MOT_DE_PASSE_FORT@postgres:5432/translate_db
# ─── Redis ──────────────────────────────────────────────────
REDIS_URL=redis://redis:6379/0
# ─── Fournisseurs de traduction ─────────────────────────────
# Google Translate (gratuit, via deep_translator) — toujours activé
GOOGLE_TRANSLATE_ENABLED=true
# Google Cloud Translation API (payant)
GOOGLE_CLOUD_ENABLED=false
GOOGLE_CLOUD_API_KEY=
# DeepL
DEEPL_ENABLED=false
DEEPL_API_KEY=
# OpenRouter (IA)
OPENROUTER_ENABLED=false
OPENROUTER_API_KEY=
OPENROUTER_MODEL=deepseek/deepseek-v3.2
# OpenAI
OPENAI_ENABLED=false
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4o-mini
# Désactivés en production standard
OLLAMA_ENABLED=false
ZAI_API_KEY=
# ─── Stripe Paiements ───────────────────────────────────────
STRIPE_SECRET_KEY=sk_live_REMPLACEZ
STRIPE_WEBHOOK_SECRET=whsec_REMPLACEZ
STRIPE_PRICE_STARTER_MONTHLY=price_REMPLACEZ
STRIPE_PRICE_STARTER_YEARLY=price_REMPLACEZ
STRIPE_PRICE_PRO_MONTHLY=price_REMPLACEZ
STRIPE_PRICE_PRO_YEARLY=price_REMPLACEZ
STRIPE_PRICE_BUSINESS_MONTHLY=price_REMPLACEZ
STRIPE_PRICE_BUSINESS_YEARLY=price_REMPLACEZ
# ─── Fichiers & Limites ─────────────────────────────────────
MAX_FILE_SIZE_MB=50
MAX_CONCURRENT_TRANSLATIONS=5
CLEANUP_ENABLED=true
FILE_TTL_MINUTES=60
# ─── HTTPS / HSTS ───────────────────────────────────────────
ENABLE_HSTS=true
LETSENCRYPT_EMAIL=votre-email@wordly.art
# ─── Rate limiting ──────────────────────────────────────────
RATE_LIMIT_ENABLED=true
RATE_LIMIT_PER_MINUTE=30
RATE_LIMIT_PER_HOUR=200
TRANSLATIONS_PER_MINUTE=5
TRANSLATIONS_PER_HOUR=30

View File

@@ -1,106 +0,0 @@
# ============================================
# Wordly.art - .env de production
# ============================================
# Copier ce fichier en .env sur le serveur
# cp .env.production .env
# Puis remplacer les valeurs [A CHANGER]
# ============================================
# ---- Application ----
APP_NAME=Wordly
APP_ENV=production
DEBUG=false
LOG_LEVEL=INFO
# ---- Domaine ----
DOMAIN=wordly.art
NEXT_PUBLIC_API_URL=https://wordly.art
FRONTEND_URL=https://wordly.art
BACKEND_PORT=8000
FRONTEND_PORT=3000
# ---- Service de traduction par defaut ----
# Choisir: google, ollama, deepseek, minimax, deepl, openai, openrouter
TRANSLATION_SERVICE=google
# ---- Google (gratuit, toujours actif) ----
GOOGLE_TRANSLATE_ENABLED=true
# ---- Google OAuth (connexion avec Google) ----
GOOGLE_CLIENT_ID=
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
# ---- Ollama (local, gratuit) ----
OLLAMA_ENABLED=false
OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_MODEL=llama3
# ---- DeepSeek ----
DEEPSEEK_ENABLED=false
DEEPSEEK_API_KEY=
DEEPSEEK_MODEL=deepseek-chat
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
# ---- Minimax ----
MINIMAX_ENABLED=false
MINIMAX_API_KEY=
MINIMAX_MODEL=MiniMax-M1
MINIMAX_BASE_URL=https://api.minimax.chat/v1
# ---- DeepL ----
DEEPL_ENABLED=false
DEEPL_API_KEY=
# ---- OpenAI ----
OPENAI_ENABLED=false
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4o-mini
OPENAI_BASE_URL=https://api.openai.com/v1
# ---- OpenRouter ----
OPENROUTER_ENABLED=false
OPENROUTER_API_KEY=
OPENROUTER_MODEL=deepseek/deepseek-chat
# ---- Upload ----
MAX_FILE_SIZE_MB=50
ALLOWED_EXTENSIONS=.docx,.xlsx,.pptx
# ---- Rate Limiting ----
RATE_LIMIT_ENABLED=true
RATE_LIMIT_REQUESTS_PER_MINUTE=60
RATE_LIMIT_TRANSLATIONS_PER_MINUTE=10
RATE_LIMIT_TRANSLATIONS_PER_HOUR=100
RATE_LIMIT_TRANSLATIONS_PER_DAY=500
# ---- Admin ----
ADMIN_USERNAME=admin
ADMIN_PASSWORD_HASH=CHANGE_WITH_BCRYPT_HASH
# ---- Secrets (deja generes) ----
JWT_SECRET_KEY=84-MniOv3rOZZ3FczwDvsNNHqZRf8yKI06uMNQGAgMSV8yAJ19comNe6FHHcnVyeNm-fvDxlcb9CWe40y4oy7A
ADMIN_TOKEN_SECRET=1301880be1bc8026676d7f4fb13ae1c70fcd2abbd2f2cdfcb044eea5c7005ce3
CORS_ORIGINS=https://wordly.art
# ---- Database ----
POSTGRES_USER=translate
POSTGRES_PASSWORD=yLLgkEvt6mvzGDdoqtQvI1vEgMmR-W75ZTPW5StaIAU
POSTGRES_DB=translate_db
# ---- Monitoring ----
GRAFANA_USER=admin
GRAFANA_PASSWORD=WordlyGrafana2026!
# ---- Stripe ----
# Clés TEST — remplacer par sk_live_... / pk_live_... en production réelle
STRIPE_PUBLISHABLE_KEY=pk_test_51SkSHkCKXUJE51jnCr2QV4vCE18GH1XF59eHrHOV46EORuZTPVXFXxrbcJamyoJLaUHMc2McCRVkU4b6VMAWVR2R00XRdwmXMx
STRIPE_SECRET_KEY=sk_test_51SkSHkCKXUJE51jnbEtXZ0nKiTHTa8ohDwLH8fZiDVEx6Ze0g5dg4fGJJgX1VgNHvF93GE3HTramT3oQrCaqOxid00OXTcZlsW
STRIPE_WEBHOOK_SECRET=
# Price IDs créés dans Stripe Dashboard (wordly.art — test mode)
STRIPE_PRICE_STARTER_MONTHLY=price_1TdF8FCKXUJE51jnNAeLqhF3
STRIPE_PRICE_STARTER_YEARLY=price_1TdF8GCKXUJE51jnBhVVrjrh
STRIPE_PRICE_PRO_MONTHLY=price_1TdF8GCKXUJE51jn9ChAAhKM
STRIPE_PRICE_PRO_YEARLY=price_1TdF8HCKXUJE51jnpsvBivDe
STRIPE_PRICE_BUSINESS_MONTHLY=price_1TdF8HCKXUJE51jn2K9EeBGJ
STRIPE_PRICE_BUSINESS_YEARLY=price_1TdF8ICKXUJE51jnmgWZxW4U

18
.gitignore vendored
View File

@@ -37,6 +37,7 @@ backups/
# IDE
.vscode/
.idea/
.zcode/
*.swp
*.swo
@@ -46,11 +47,18 @@ outputs/
temp/
translated_files/
translated_test.*
/translations.db
# Generated translation test outputs in the sample corpus (regeneratable, not for commit)
# (the tracked sample_files/test_corpus/test_pdf_translated.pdf stays tracked)
sample_files/test_corpus/*_translated*
sample_files/test_corpus/*rigoureuse*
# Runtime data (users, provider config, glossaries) — managed at runtime, not in git
data/users.json
data/*.db
data/*.sqlite
data/waitlist.json
# Keep these in git (templates/defaults only)
# data/provider_settings.json → commiter uniquement si pas de clés dedans
@@ -88,4 +96,14 @@ htmlcov/
# Auto-generated pnpm workspace file (placeholders)
frontend/pnpm-workspace.yaml
office-translator-landing-page/pnpm-workspace.yaml
# Secrets et donnees locales (audit securite 2026-08-26)
data/provider_settings.json
# Unrelated local project (separate repo, never commit here)
Colonization/
# TypeScript incremental build artifact
office-translator-landing-page/tsconfig.tsbuildinfo

View File

@@ -0,0 +1,84 @@
---
target: frontend/src (UI complète Wordly.art)
total_score: 20
max_score: 40
na_heuristics:
p0_count: 1
p1_count: 4
timestamp: 2026-08-30T19-00-41Z
slug: frontend-src
---
# Critique de design — Wordly.art (`frontend/src`)
Method: dual-agent (A: agent_0921d3fd-00f8-424c-a025-1e69fe2d49ff · B: agent_44a564e1-9137-48cf-a6b5-7d78e1042ead)
## Design Health Score
| # | Heuristique | Score | Problème clé |
|---|-------------|-------|--------------|
| 1 | Visibility of System Status | 2 | Stats fabriquées pendant le traitement (« 99.9% » `translate/page.tsx:404`, « Intégrité Layout » = `40 + progress*0.55` `translate/page.tsx:695`) ; `estimatedRemaining_seconds` récupéré mais jamais affiché (`useTranslationSubmit.ts:78`) ; échec de téléchargement **silencieux** (`translate/page.tsx:152`) ; notifications sans `role="status"` (`notification.tsx:118`). |
| 2 | Match System / Real World | 2 | Produit annoncé 12 locales, mais « Source/Cible/Moteur » codés en dur FR (`translate/page.tsx:671-728`), page reviews 100% FR (`reviews/[jobId]/page.tsx`), erreurs upload FR (`useFileUpload.ts:8-9`), erreurs polling EN (`useTranslationSubmit.ts:62,102`), « Loading… » EN (`DashboardLayoutClient.tsx:29`). |
| 3 | User Control and Freedom | 2 | « Annuler le processus » (`translate/page.tsx:700-705`) ne fait qu'un reset local — le job continue côté serveur, crédits consommés ; aucun historique : rafraîchir pendant une traduction perd tout (`useTranslationSubmit.ts` sans persistance) ; page review inatteignable (unique lien dans un composant mort, `TranslationComplete.tsx:168`). |
| 4 | Consistency and Standards | 1 | Trois systèmes visuels (éditorial / shadcn auth / admin violet `admin/login/page.tsx:52`) ; pricing landing 3 plans 7/15/39€ (`landing-page.tsx:330-332`) vs /pricing 5 plans (`pricing/page.tsx:56-192`) ; deux systèmes de toasts (`useNotification` + `useToast`) ; 5 composants translate/ orphelins jamais importés ; `tailwind.config.js:28-35` décrit un thème dark/bleu qui n'existe plus. |
| 5 | Error Prevention | 2 | Boutons Word/Excel/Slides/PDF du dropzone injectent un **faux fichier** de 100 octets à taille truquée (`useFileUpload.ts:83-90`) ; « Effacer le cache » sans confirmation (`settings/page.tsx:13-28`) ; gating incohérent : glossaires réservés à `tier === 'pro'` strict (`glossaries/page.tsx:56`) alors que la sidebar traite business/enterprise comme Pro (`DashboardSidebar.tsx:20`) — clients Business privés de la feature. |
| 6 | Recognition Rather Than Recall | 3 | Nav icône+texte (`DashboardSidebar.tsx:42-54`), résumé de config visible pendant le processing (`translate/page.tsx:647-684`). Perd : pages settings/services/reviews orphelines hors nav (`constants.ts:10-17`), badge tier `text-[7px]` illisible (`DashboardSidebar.tsx:113`). |
| 7 | Flexibility and Efficiency | 1 | Aucun raccourci clavier dans le code vivant (le seul handler Enter/Espace est dans le composant mort `FileDropZone.tsx:35`) ; un fichier à la fois, aucun batch ; pas d'historique ni rejeu de config. |
| 8 | Aesthetic and Minimalist Design | 2 | Micro-typographie : `text-[7.5px]` (`translate/page.tsx:342`), `text-[8px]` (`translate/page.tsx:263,336`), texte à `/30` opacité ; tout en `uppercase tracking-widest` jusqu'au CTA primaire (`translate/page.tsx:322`) ; deux blocs promo Memento concurrents sur l'écran de travail. |
| 9 | Error Recovery | 3 | `humanFriendlyError()` mappe les erreurs brutes vers des messages clairs localisés (`translate/page.tsx:90-111`) ; état failed conserve le fichier + Réessayer (`translate/page.tsx:452-489`). Gaps : téléchargement silencieux, messages backend bruts sur register (`RegisterForm.tsx:221`). |
| 10 | Help and Documentation | 2 | Aide contextuelle bonne sur moteurs/glossaires (`ProviderSelector.tsx:276-306`, `GlossarySelector.tsx:332-334`) mais aucune doc vivante : liens footer morts `href="#"` (`landing-page.tsx:436-445`), contact Enterprise = `mailto:contact@votre-domaine.com` (`pricing/page.tsx:362`) — un placeholder de domaine. |
| **Total** | | **20/40** | **Acceptable — améliorations significatives nécessaires** |
## Verdict de spécificité design
**LLM assessment** : Landing — spécifique, parfois remarquable (copy métier : « CTA restera "Air Handling Unit", jamais "Call To Action" » `landing.json:35` ; identité crème/Playfair/or `globals.css:14-17,112`) mais le hero illustre le produit avec une photo Unsplash générique (`landing-page.tsx:139`) au lieu de montrer l'argument n°1 — la mise en page préservée. Dashboard — l'ossature du flux traduction est écrite pour CE produit, mais rendue interchangeable par la bannière publicitaire tierce « Memento » (`translate/page.tsx:748-781`, `DashboardSidebar.tsx:73-93`) et des stats décoratives fausses. Auth — interchangeable (Register `variant="premium"` `RegisterForm.tsx:337` vs Login default `LoginForm.tsx:179`). Admin — littéralement un autre produit (sidebar compacte EN codée en dur, accent violet, `AdminSidebar.tsx:64-84`).
**Deterministic scan** : 4 findings (exit 2). Deux `overused-font` sur Inter (`globals.css:141,147`) — le détecteur confirme le diagnostic de la revue par l'autre bout : l'app est portée par Inter, la police par défaut de l'ère IA, tandis que l'identité réelle du produit (Playfair + crème + or) ne vit que sur la landing. Un `side-tab` (`border-l-4` `dashboard/profile/page.tsx:469`, visible pour les comptes payants) que la revue n'avait pas isolé. Un `border-accent-on-rounded` (`globals.css:215`) : **faux positif** — c'est le pattern canonique d'un spinner de chargement, pas une carte.
**Visual overlays** : aucun overlay disponible — l'injection mutable s'est heurtée à « Browser is not available in subagent » après deux tentatives réelles. Signal de fallback : scan CLI complet + vérification HTTP statique (/, /login, /pricing → 200, avec réserve : un port-forward de l'éditeurCursor sur 127.0.0.1:3000 a probablement répondu à la place du serveur local).
## Impression générale
La fondation est saine — machine à états propre, gestion d'erreurs humaine, copy métier différenciante — mais l'exécution sabote la confiance au moment exact où un professionnel en a besoin : publicité tierce dans l'écran de travail, statistiques fabriquées, parcours primaire inaccessible au clavier, i18n de façade. La plus grande opportunité : transformer la fin de job — déjà le meilleur moment émotionnel du produit — en preuve produit (lien vers la review segment par segment qui existe déjà), et évacuer le bruit qui la noie.
## Ce qui fonctionne
1. **La machine à états du flux de traduction** (`translate/page.tsx:172-181`) : upload → config → processing → complete/failed se relaient proprement dans un seul écran, une intention à la fois, zéro navigation. C'est la bonne architecture pour ce produit.
2. **`humanFriendlyError()`** (`translate/page.tsx:90-111`) : pattern-matching des erreurs brutes (« quota », « 429 », « timed out ») vers des messages clairs localisés, avec état failed qui conserve le fichier et propose Réessayer. La majorité des SaaS affichent le message brut — ici c'est pensé.
3. **La copy landing FR comme différenciateur** (« Les autres traducteurs détruisent votre mise en page. Pas nous. » `landing.json:43`) : c'est du domaine, pas du template.
## Problèmes prioritaires
1. **[P0] Le dépôt de fichier est impossible au clavier — et le composant accessible est du code mort.** Le dropzone vivant est un `div` avec `onClick` sans `tabIndex`, `role` ni handler clavier (`translate/page.tsx:256-261`) ; l'utilisateur clavier seul ne peut pas faire la tâche primaire. Le handler Enter/Espace correct existe dans `FileDropZone.tsx:35`, jamais importé. Le combobox de langue custom n'a ni `role="combobox"` ni flèches ni Échap (`LanguageSelector.tsx:66-113`) ; le switch glossaire n'a pas de `role="switch"` (`GlossarySelector.tsx:286-312`) ; contrastes massivement sous 4.5:1 (`text-brand-dark/30` sur crème, `translate/page.tsx:263,336,342`) ; actions révélées au survol uniquement (`GlossaryCard.tsx:65,75`). → Fix : brancher `FileDropZone.tsx` ou rendre le dropzone focusable, `Select` shadcn pour les langues, `role="switch"`/`role="status"` manquants. **Commandes : `$impeccable audit` (quantifier a11y) puis `$impeccable harden` (corriger).**
2. **[P1] Publicité tierce « Memento » greffée dans l'outil de travail.** Deux blocs promo vers un produit externe (sidebar `DashboardSidebar.tsx:73-93` + bannière pleine largeur à double CTA `translate/page.tsx:748-781`) sur l'écran du cœur de métier, pour un SaaS qui vend la confidentialité documentaire — le message « Rétention zéro » en 7.5px juste au-dessus (`translate/page.tsx:342-349`) est contredit par la promo en dessous. Pill « Ecosystème Wordly » codé en dur non traduit (`translate/page.tsx:764`). → Fix : sortir la promo du flux de traduction ; au maximum un emplacement unique post-tâche, discret, désactivable, traduit. **Commande : `$impeccable distill`.**
3. **[P1] Les boutons « Word / Excel / Slides / PDF » injectent de faux fichiers.** 4 boutons ressemblant à des filtres de format chargent un blob de 100 octets avec taille falsifiée (`useFileUpload.ts:83-90`, noms factices `useFileUpload.ts:74-77`). L'utilisateur croit sélectionner un type ; il obtient un fichier qui échouera à la soumission — piège à erreurs garanti. → Fix : supprimer, ou transformer en démo explicite (« Essayer avec un exemple ») sur un flux séparé. **Commandes : `$impeccable harden` + `$impeccable clarify`.**
4. **[P1] L'i18n est une façade : la moitié des strings visibles sont codées en dur, et le pattern de fallback est mort.** Un utilisateur EN voit « Source/Cible » (`translate/page.tsx:671-681`), des pages entières FR (reviews, teams, glossaries), des erreurs FR (« Erreur serveur » `pricing/page.tsx:390,434`) et EN (« Loading... »). Pire : `t()` retourne la clé brute si absente (`i18n.tsx:161`), donc tous les `t('key') || 'fallback'` (~30 occurrences, ex. `GlossarySelector.tsx:277`) n'appliquent **jamais** leur fallback — clé manquante = `translate.glossary.title` affiché à l'écran. Les découpes `lastIndexOf(' ')` pour l'italique du dernier mot (`translate/page.tsx:206-208`) casseront en zh/ja. → Fix : `t()` retourne une chaîne vide sur clé manquante, auditer les `||`, extraire les strings codées en dur. **Commande : `$impeccable harden`.**
5. **[P1] Features fantômes et perte de contrôle : review inatteignable, pages orphelines, annulation factice, pas d'historique.** La page de review segment par segment (`reviews/[jobId]/page.tsx`) n'est liée nulle part dans l'UI vivante (unique lien dans `TranslationComplete.tsx:168`, composant jamais importé) ; settings et services absents de la nav (`constants.ts:10-17`) ; « Annuler » ne fait qu'un reset local pendant que crédits partent en consommation ; rafraîchir pendant un traitement détruit tout état (aucun jobId persisté) ; clients Business/Enterprise privés de glossaires (`glossaries/page.tsx:56`). → Fix : persister le jobId avec reprise du polling, historique des jobs récents, lien complet → `/dashboard/reviews/[jobId]`, vraie API d'annulation ou renommage honnête, unifier `isPro`. **Commande : `$impeccable shape`.**
## Red flags personas
**Alex (utilisateur expert pressé)** : aucun raccourci clavier vivant ; un fichier à la fois, aucun batch (rédhibitoire pour 20 docs/semaine) ; aucun historique/rejeu — chaque session repart de zéro ; les stats « 99.9% précision / Turbo » (`translate/page.tsx:404-405`) détruisent sa confiance ; « Annuler » qui n'annule pas — il le découvrira à la facture.
**Sam (accessibilité)** : bloqué dès l'étape 1 — dropzone inaccessible clavier (`translate/page.tsx:256-261`) ; contraste ~1.8:1 sur des dizaines de libellés (`text-brand-dark/30`) ; tailles 7.5-9px `uppercase tracking-widest` illisibles ; notifications muettes pour lecteur d'écran (`notification.tsx:118`) ; échec de téléchargement sans aucun rendu (`translate/page.tsx:152`) ; actions invisibles hors hover (`GlossaryCard.tsx:65,75`, `notification.tsx:181`) ; RTL : `dir` bien posé mais barres/badges en `left-0`/`right-0` physiques (`translate/page.tsx:387`).
**Jordan (premier usage)** : premier écran correct (dropzone central « Glissez-déposez ici ») puis 4 boutons formats qui ne font pas ce qu'ils annoncent ; jargon non défini (« Standard » vs « IA contextuelle », « Intégrité Layout 100% SÉCURISÉ » `translate.json:85-86`) ; aucun doc vivante (footer « Documentation » mort `landing-page.tsx:436`) ; le premier paiement peut aboutir sur `mailto:contact@votre-domaine.com` (`pricing/page.tsx:362`).
## Observations mineures
- Titre dupliqué pill + H1 sur settings et services (`settings/page.tsx:40-42`, `services/page.tsx:56-58`).
- Dates glossaires `fr-FR` en dur (`glossaries/page.tsx:412`) contre `formatDate()` locale existante (`i18n.tsx:108`).
- Prix IA en `$` sur une page facturée en `€` (`pricing/page.tsx:870,885`).
- `site-header.tsx`/`site-footer.tsx` importés nulle part ; le header fantôme pointe « Connexion » vers `/dashboard` (`site-header.tsx:31`).
- Mention CGU non cliquable (`RegisterForm.tsx:366-370`).
- Drapeaux pays pour des langues (ar→🇸🇦, zh→🇨🇳 `language-switcher.tsx:24-25`) — discutable pour un outil pro multilingue ; 13 locales à plat dans le switcher.
- Bon réflexe rare : `prefers-reduced-motion` et `prefers-contrast` gérés (`globals.css:237-254`) ; skeleton pricing anti-flash (`pricing/page.tsx:221-259`).
- Le switch « Traduire les images » désactivé rend toute la carte grisée sans explication avant interaction (`translate/page.tsx:556-585`).
## Questions à considérer
1. Si un prospect demande « prouvez-moi que ma mise en page survit », quelle partie de l'écran répond ? Aucune aujourd'hui — le hero montre une photo Unsplash de bureau (`landing-page.tsx:139`) alors que le moteur produit déjà un avant/après à chaque job. Un seul DOCX tri-langue côte à côte vaudrait plus que les 6 cartes de LayoutFeatures.
2. Pourquoi l'état « traduction terminée » — le meilleur moment émotionnel — se termine-t-il sur un bouton fantôme de 10px alors que la page de review segment par segment existe déjà ?
3. Qu'est-ce qui coûte le plus : les revenus Memento, ou la crédibilité « documents confidentiels » que la promo détruit à chaque écran ?

View File

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

View File

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

15
AGENTS.md Normal file
View File

@@ -0,0 +1,15 @@
# Règles de communication (demandées par Sepehr, le propriétaire du projet)
## Toujours parler un français correct et naturel
- Le propriétaire du projet est francophone. Lui écrire **uniquement en vrai français**, comme un collègue français, pas comme une traduction de l'anglais.
- **Aucun jargon technique inutile** : pas de « runner », « wave », « batch », « harden », « polish », « overlay », « fetch », « baseline », « commit » quand un mot français existe et se comprend mieux.
- Quand un terme technique est indispensable (un nom de fichier, un bouton, une fonction), le donner tel quel mais **l'expliquer en une phrase simple**.
- Des phrases courtes et complètes. Pas de sigles, pas d'abréviations inventées, pas d'anglicismes de coté (« fixer », « cleaner », « scope »).
- Dans les rapports : dire d'abord **ce qui a changé pour l'utilisateur**, ensuite comment c'est fait, simplement.
## Préférences de travail
- Le dépôt s'appelle Wordly.art : un service de traduction de documents (Word, Excel, PowerPoint, PDF) qui garde la mise en page d'origine.
- Travailler sur la branche `production-deployment`, pousser sur le dépôt Gitea après chaque lot de corrections validées.
- Vérifier avant de pousser : construction du site, les tests, et qu'aucun texte de l'interface ne manque de traduction.

View File

@@ -1,211 +1,208 @@
# Plan Marketing - Office Translator (SaaS de Traduction de Documents)
# Plan Marketing Office Translator (Wordly.art)
> Document de référence pour l'agent marketing. Dernière mise à jour : 2026-05-10
> Document de référence pour l'agent marketing. Dernière mise à jour : 2026-08-29
>
> **Changements majeurs vs. version du 2026-05-10**
> 1. **Le billing est réellement implémenté** (Stripe : checkout, webhooks, portail, crédits) — la section tarifaire est désormais ancrée sur les plans réels du backend (`models/subscription.py`, `services/pricing_config.py`), plus sur des tiers fictifs.
> 2. **La landing page est fonctionnelle** : sections pricing, FAQ, preuve sociale, capture d'e-mail (waitlist) et analytics (Vercel Analytics) sont déployées dans `office-translator-landing-page/`.
> 3. **Un endpoint public d'inscription** existe : `POST /api/v1/waitlist` (dédoublonné, persisté dans `data/waitlist.json`), compteur `GET /api/v1/waitlist/count`.
> 4. La mention « SOC 2 Compliant » (infondée) a été retirée du footer.
>
> **Alignement code du 2026-08-29** (voir `docs/marketing/PLAN-ALIGNEMENT-CODE.md`) : le PDF est supporté (dont **PDF scannés via OCR Mistral**) et l'ajoutera à tous les supports ; rétention réelle = envois 30 min / résultats ≤ 2 h ; les moteurs vendus sont exactement ceux des plans (`PLANS[plan]["providers"]`, désormais appliqué côté API) ; 100+ langues exposées via `/api/v1/languages`.
---
## 1. Positionnement & Proposition de Valeur
### Le Produit
**Office Translator** est un service SaaS de traduction de documents professionnels (Word, Excel, PowerPoint) qui **préserve parfaitement la mise en forme, les tableaux, les images et les styles** du document original.
**Office Translator** (marque **Wordly.art**) est un SaaS de traduction de documents professionnels (Word, Excel, PowerPoint, **PDF — y compris scannés, via OCR**) qui **préserve la mise en forme, les formules, les tableaux, les images et les styles** du document original. L'utilisateur choisit son moteur de traduction par document.
### Ce qui nous différencie
- **Préservation du format** : Contrairement à Google Translate ou DeepL qui détruisent les layouts complexes, notre moteur maintient la structure exacte
- **Multi-providers** : L'utilisateur choisit son moteur (Google, DeepL, OpenAI, DeepSeek, OpenRouter, Minimax, Zai) selon son budget et ses besoins
- **Glossaires techniques** : Terminologie personnalisée (HVAC, IT, Juridique, Médical)
> Nommage : « Office Translator » est le nom du dépôt / nom de travail interne ; **Wordly.art** est la marque publique (domaine de lancement, landing, footer). Le footer de la landing affiche donc « Wordly.art » — c'est intentionnel.
### Marché cible
| Segment | Taille estimée | Priorité |
|---------|---------------|----------|
| PME internationales ( traductions régulières) | Grand | P1 |
| Agences de traduction (productivité) | Moyen | P1 |
| Départements RH multilingues | Moyen | P2 |
| Freelancers / consultants | Grand | P2 |
| Étudiants & academics | Grand | P3 (freemium) |
### Accroche unique (USP)
> « Traduit en place. Zéro perte de mise en page. »
Contrairement à Google Translate (qui aplatit le fichier en texte brut) ou à DeepL (formatage limité), le moteur translate *in place* : cellules fusionnées, formules Excel, en-têtes/pieds de page Word, animations PowerPoint restent intacts. Les PDF scannés (pages image) sont récupérés par **OCR Mistral** avant traduction — DeepL et Azure les refusent. Les textes dans les images sont traduits via des modèles vision (plans payants).
### Différenciateurs
| Différenciateur | Détail | Preuve produit |
|---|---|---|
| Préservation du format | Structure préservée (formules, fusions, styles) ; PDF scannés via OCR | openpyxl / python-docx / python-pptx / PyMuPDF + `services/mistral_ocr.py`, tests de réintégration |
| Multi-moteurs (7) | Google, DeepL, Google Cloud, OpenRouter éco **et** premium, OpenAI, Grok (xAI) — grille appliquée côté API par plan | Choix par document, `PLANS[plan]["providers"]` |
| IA contextuelle | Traduction LLM contextuelle (DeepSeek / Claude / Gemini) sur plans payants | `ai_translation` + `ai_tier` |
| Glossaires techniques | Terminologie personnalisée (CVC, IT, Légal, Médical…) | `routes/glossary_routes.py` |
| 60+ langues | 100+ codes validés, exposés via `/api/v1/languages` | `middleware/validation.py` |
| Confiance | Suppression auto des fichiers (envois 30 min, résultats ≤ 2 h), TLS, jamais utilisé pour l'entraînement | `config.py` (TTLs), docs sécurité |
### Segments cibles (priorisés)
| Segment | Douleur | Taille | Priorité | Message clé |
|---|---|---|---|---|
| **PME internationales** (export, docs commerciaux/techniques récurrents) | Traducteurs humains chers et lents, formats cassés | Grand | **P1** | « Le coût par page, sans casser le format » |
| **Agences de traduction** (productivité) | Revue manuelle des mises en page, marges écrasées | Moyen | **P1** | « 7 moteurs au choix, glossaires partagés, 5 sièges » |
| **Départements RH multilingues** (contrats, onboarding) | Volume saisonnier, terminologie juridique | Moyen | P2 | « Glossaires légaux + volume Business » |
| **Freelances / consultants** | Outils gratuits qui détruisent leur travail | Grand | P2 | « 2 docs gratuits/mois, Starter à 9 € » |
| **Étudiants & académiques** | Budget nul | Grand | P3 (freemium) | « Plan Free, sans carte bancaire » |
---
## 2. Supports Visuels Requis
## 2. Tarification (source de vérité : `models/subscription.py` + `services/pricing_config.py`)
### A. Captures d'écran (OBLIGATOIRES - priorité maximale)
**Monnaie : EUR. Billing Stripe implémenté (checkout, webhooks, portail client, crédits à l'unité).**
| # | Capture | Usage | Instructions |
|---|---------|-------|-------------|
| 1 | **Page d'accueil / Hero** | Landing page, réseaux sociaux | Montrer l'interface épurée avec le drop-zone de fichier |
| 2 | **Upload en cours** | Démonstration du workflow | Fichier Excel chargé avec sélection langue source/cible |
| 3 | **Résultat côte à côte** | Preuve de qualité | Document original vs traduit, montrer que le format est intact |
| 4 | **Sélection du provider** | Fonctionnalité clé | Dropdown Google/DeepL/OpenAI/DeepSeek avec prix affichés |
| 5 | **Glossaire technique** | Différenciation | Interface de gestion des glossaires personnalisés |
| 6 | **Dashboard admin** | Crédibilité entreprise | Vue monitoring avec statistiques d'utilisation |
| 7 | **Page pricing/forfaits** | Conversion | Les 3 tiers (Starter/Pro/Business) clairement affichés |
| 8 | **Profil utilisateur** | Confiance | Page de profil avec historique et quota |
| Plan | Mensuel | Annuel (20 %) | Docs/mois | Pages/doc max | Fichier max | Moteurs | IA | API |
|---|---|---|---|---|---|---|---|---|
| **Free** | 0 € | — | 2 | 10 | 5 Mo | Google | Non (filigrane) | Non |
| **Starter** | 9 € | 7,20 €/mois | 50 | 50 | 10 Mo | Google, DeepL | Non | Non |
| **Pro** ★ populaire | 19 € | 15,20 €/mois | 200 | 200 | 25 Mo | + Google Cloud, OpenRouter | Essentielle (DeepSeek) | Non |
| **Business** | 49 € | 39,20 €/mois | 1 000 | 500 | 50 Mo | + OpenAI, x.ai/Zai, premium | Premium (Claude/Gemini) | Oui, 10 000 appels/mois, 5 sièges |
| **Enterprise** | sur demande | sur demande | illimité | illimité | illimité | + custom | custom | illimitable |
**Format** : PNG, 1280x720 minimum, fond clair et sombre
### B. Vidéo de Démonstration (OBLIGATOIRE)
#### Vidéo courte (60-90 secondes) - "How it works"
- **Objectif** : Landing page + réseaux sociaux
- **Script suggéré** :
1. (0-5s) Logo + tagline animé
2. (5-15s) Problème : "Traduire un Excel de 50 pages sans casser le format ? Mission impossible."
3. (15-40s) Démo accélérée : Upload → Sélection langue → Provider → Traduction → Download
4. (40-55s) Split-screen : document original vs traduit, zoom sur tableaux/images intacts
5. (55-65s) CTA : "Essayez gratuitement" + URL
- **Format** : MP4 1080p, sous-titres FR + EN
#### Vidéo tutoriel (3-5 minutes) - "Guide complet"
- **Objectif** : YouTube, onboarding utilisateurs
- **Contenu** : Création de compte, upload, glossaires, providers, download
- **Format** : Screencast avec voiceover
### C. Autres assets visuels
| Asset | Spécifications |
|-------|---------------|
| **Logo SVG** | Version claire + sombre, icône seule + avec texte |
| **OG Image** | 1200x630px pour partage réseaux sociaux |
| **Favicon** | 32x32, 16x16, ICO + PNG |
| **Bannière GitHub** | 1280x640px pour le repo README |
| **GIF animé** | 15s loop du workflow upload→traduction→download |
| **Infographie** | "Pourquoi Office Translator" - comparaison avant/après |
---
## 3. Canaux de Lancement
### Phase 1 : Pré-lancement (Semaines 1-2)
| Canal | Action | Priorité |
|-------|--------|----------|
| **Landing page** | Mettre en place avec captures d'écran + vidéo + formulaire email | CRITIQUE |
| **Product Hunt** | Préparer le launch (assets, description, maker comment) | HAUTE |
| **Reddit** | Posts dans r/SideProject, r/saas, r/translator | HAUTE |
| **Hacker News** | Préparer un "Show HN" technique | HAUTE |
| **Twitter/X** | Thread de lancement avec démo GIF | MOYENNE |
| **LinkedIn** | Post professionnel ciblant PME et agences | MOYENNE |
### Phase 2 : Lancement (Semaine 3)
| Canal | Action |
|-------|--------|
| **Product Hunt** | Lancement le mardi ou mercredi (meilleur trafic) |
| **Reddit** | Cross-post dans 5-6 subreddits pertinents |
| **Hacker News** | Soumettre le Show HN le matin (heure EST) |
| **Twitter/X** | Thread avec GIF + link Product Hunt |
| **IndieHackers** | Post détaillé sur le build process |
| **Dev.to** | Article technique sur l'architecture |
| **Twitter/X (communautés)** | Cibler #BuildInPublic, #SaaS, #i18n |
### Phase 3 : Croissance (Semaines 4-8)
| Canal | Action |
|-------|--------|
| **SEO** | Articles de blog : "Comment traduire un Excel sans perdre le format", etc. |
| **YouTube** | Tutoriels et reviews |
| **Partenariats** | Agences de traduction, consultants internationaux |
| **Google Ads** | Mots-clés "translate excel document", "translate powerpoint" |
| **Communautés** | Discord/Slack de développeurs et traducteurs |
| **AppSumo** | Liste Lifetime Deal pour traction initiale |
---
## 4. Stratégie de Contenu
### Articles de Blog (SEO) - Minimum 5 au lancement
1. **"Comment traduire un fichier Excel sans perdre la mise en forme"**
2. **"Les 5 meilleurs outils de traduction de documents comparés (2026)"**
3. **"Traduction professionnelle : guide complet pour les PME"**
4. **"Pourquoi DeepL et Google Translate détruisent vos documents Excel"**
5. **"Auto-héberger son outil de traduction : guide complet"**
### Contenu Réseaux Sociaux (Répétitif)
| Type | Fréquence | Plateforme |
|------|-----------|-----------|
| Astuce traduction | 2x/semaine | Twitter, LinkedIn |
| Before/After document | 1x/semaine | Twitter, Instagram |
| Thread technique | 1x/2 semaines | Twitter |
| Témoignage client | Quand disponible | Tous |
| Mise à jour produit | Selon releases | Tous |
---
## 5. Stratégie Tarifaire & Positionnement Prix
### Forfaits actuels (à communiquer)
| Plan | Prix | Public cible | Message clé |
|------|------|-------------|-------------|
| **Starter** | Prix entry-level | Freelancers, étudiants | "Testez sans risque" |
| **Pro** | Prix milieu | PME, consultants | "Le meilleur rapport qualité-prix" |
| **Business** | Prix premium | Agences, entreprises | "Volume illimité + support dédié" |
**Paquets de crédits** (au-delà des quotas, sans changer d'abonnement) : 50 cr. 5 € · 100 cr. 9 € · 250 cr. 20 € · 500 cr. 35 € · 1 000 cr. 60 € (0,060,10 €/page).
### Message tarifaire
- Insister sur le **coût par page** vs traduction humaine (généralement 50-100x moins cher)
- Mettre en avant le **freemium** ou l'**essai gratuit** si disponible
- Comparer avec les coûts des solutions concurrentes
- **Ancrage** : coût par page vs traduction humaine (50100× moins cher) et vs réfection manuelle après Google Translate.
- **Freemium réel** : le plan Free (2 docs/mois, sans carte) sert d'essai — c'est le « free trial » opérationnel.
- **Transparence** : les prix exacts ci-dessus sont ceux du backend (`models/subscription.py` + `services/pricing_config.py`) ; toute promo est gérée via `data/pricing_overrides.json` (admin). À noter : la section pricing de la landing (`components/pricing-section.tsx`) est un **miroir statique** de ces plans (constante `PLANS`) — aucun prix n'est généré automatiquement, donc tout changement de prix/fonctionnalité doit être répercuté **à la main** dans ce composant et dans ce document le même jour.
---
## 6. KPIs à Suivre
## 3. Actifs Marketing en Place (état 2026-08-29)
| Métrique | Objectif Mois 1 | Objectif Mois 3 |
|----------|----------------|-----------------|
| Visiteurs uniques | 5,000 | 25,000 |
| Inscriptions | 200 | 1,500 |
| Documents traduits | 500 | 5,000 |
| Taux de conversion | 2% | 4% |
| NPS | > 40 | > 50 |
| Revenue mensuel | Variable | Variable |
| Actif | Statut | Emplacement |
|---|---|---|
| Landing page (hero, démo de traduction) | ✅ | `office-translator-landing-page/` (Next.js 15 + Tailwind) |
| Section **Pricing** (4 plans + toggle mensuel/annuel + bannière Enterprise) | ✅ | `components/pricing-section.tsx` |
| Section **FAQ** (10 questions) | ✅ | `components/faq-section.tsx` |
| Section **Preuve sociale** (stats vérifiables : 60+ langues, 7 moteurs, 100 % format, TTL 60 min + 3 témoignages génériques à remplacer par de vrais clients) | | `components/social-proof-section.tsx` |
| **Capture d'e-mail** (waitlist : e-mail + segment d'intérêt) | ✅ | `components/waitlist-section.tsx``POST /api/v1/waitlist` |
| **Analytics** (Vercel Analytics : pages, événements, heatmaps) | ✅ | `app/layout.tsx` (`@vercel/analytics`) |
| Footer corrigé (mention SOC 2 retirée) | ✅ | `app/page.tsx` |
### À produire avant le lancement (voir §6, checklist)
- 8 captures d'écran réelles de l'interface (héros, upload, résultat côte à côte, sélecteur de moteur, glossaires, dashboard, pricing, profil)
- Vidéo démo 6090 s + tutoriel 35 min
- GIF 15 s du workflow upload → traduction → téléchargement
- OG image 1200×630 + bannière GitHub + infographie avant/après
- Remplacement des 3 témoignages génériques par 3 témoignages clients réels (objectif : 1 par segment P1/P2)
---
## 7. Plan d'Action pour l'Agent Marketing
## 4. Canaux de Lancement
### Checklist Exécutable
### Phase 1 — Pré-lancement (Semaines 12)
| Canal | Action | Responsable | Livrable |
|---|---|---|---|
| Landing page | Publication en prod (Vercel ou Docker) avec URL propre `wordly.art` | Dev | URL + OG image |
| Waitlist | Activation `POST /api/v1/waitlist` en prod ; objectif **150 e-mails** avant lancement | Marketing | Compteur `/api/v1/waitlist/count` |
| Product Hunt | Préparation du listing (assets, tagline, maker comment, 5 commentaires amis planifiés) | Marketing | `docs/marketing/launch/product-hunt.md` |
| Reddit | Rédaction de 3 posts (r/SideProject, r/translator, r/smallbusiness) | Marketing | `docs/marketing/launch/reddit-posts.md` |
| Hacker News | Rédaction du Show HN (angle technique : préservation du format) | Dev | `docs/marketing/launch/show-hn.md` |
| X/Twitter | Thread de lancement (12 tweets + GIF) | Marketing | `docs/marketing/launch/x-thread.md` |
| LinkedIn | Post ciblant PME/agences (fondateur) | Fondateur | Post + 2 relances |
- [ ] **Captures d'écran** : Réaliser les 8 captures listées en section 2A
- [ ] **Vidéo courte** : Produire la démo 60-90s (section 2B)
- [ ] **Vidéo tutoriel** : Produire le screencast 3-5 min
- [ ] **Landing page** : Concevoir et publier avec tous les assets
- [ ] **Product Hunt** : Préparer le listing complet
- [ ] **Reddit posts** : Rédiger 5 posts adaptés par subreddit
- [ ] **Show HN** : Écrire la soumission Hacker News
- [ ] **Twitter thread** : Préparer le thread de lancement (10-15 tweets)
- [ ] **Articles SEO** : Rédiger les 5 articles de blog
- [ ] **OG Image** : Créer l'image de partage réseaux sociaux
- [ ] **GIF animé** : Créer le loop de 15s du workflow
- [ ] **Infographie comparatif** : Créer le visuel avant/après
- [ ] **Setup analytics** : Google Analytics + Mixpanel/PostHog
- [ ] **Email sequence** : 5 emails onboarding post-inscription
- [ ] **FAQ page** : Répondre aux 10 questions les plus fréquentes
### Phase 2 — Lancement (Semaine 3)
| Jour | Action |
|---|---|
| J-2 | Test du tunnel complet : inscription → 1ère traduction → upgrade Stripe (mode test) |
| J0 | Lancement Product Hunt (mardi/mercredi), Show HN (matin EST), thread X, cross-post Reddit, post LinkedIn |
| J1 | Suivi des commentaires (réponse < 2 h), relance X, e-mail waitlist n°1 « vous êtes dedans » |
| J+7 | E-mail waitlist n°2 : 3 études avant/après + offre de lancement (20 % la 1ʳᵉ année, code via `pricing_overrides.json`) |
### Phase 3 — Croissance (Semaines 48)
| Canal | Action |
|---|---|
| SEO | Publication des 5 articles de blog (§5) ; pages « traduire un excel sans perdre le format », « traduire un powerpoint en anglais » |
| YouTube | Tutoriel 35 min + review avant/après |
| Partenariats | 5 agences de traduction (commission 15 % la 1ʳᵉ année), 3 consultants internationaux, annuaires SaaS français |
| Google Ads | Campagne test (10 €/jour) sur « traduire excel » / « translate powerpoint » uniquement si CAC < 3× marge mensuelle |
| Communautés | Discord/Slack de traducteurs et de devs (Build in public) |
| AppSumo | Lifetime deal Business (optionnel, à valider après semaine 4) |
---
## 8. Concurrence Directe
## 5. Stratégie de Contenu
| Concurrent | Força | Faiblesse | Notre avantage |
|-----------|-------|-----------|---------------|
| **Google Translate (docs)** | Gratuit, connu | Détruit les formats complexes | Préservation du format |
| **DeepL (docs)** | Qualité de traduction | Cher, formatage limité | Multi-provider + prix |
| **DocTranslator** | Simple | Qualité inégale, publicité | Interface pro + glossaires |
| **Smartcat** | Complet | Complexe, cher | Simplicité + multi-providers |
| **Transifex** | Enterprise | Trop cher pour PME | Prix accessible |
### Articles de blog (SEO) — 5 au lancement, dans `docs/marketing/blog/`
1. **« Comment traduire un fichier Excel sans perdre la mise en forme »** (pilier, 1 500+ mots)
2. **« Les 5 meilleurs outils de traduction de documents comparés (2026) »** (comparatif : nous vs Google, DeepL, Smartling, Transifex, Smartcat)
3. **« Traduction professionnelle de documents : guide complet pour les PME »**
4. **« Pourquoi DeepL et Google Translate détruisent vos documents Excel (et quoi faire) »**
5. **« Auto-héberger son outil de traduction : guide Docker complet »** (capture de l'audience self-hosted)
### Rythme social (répétitif, 3 h/semaine max)
| Type | Fréquence | Plateforme |
|---|---|---|
| Avant/Après document (anonymisé) | 1×/sem. | X, LinkedIn |
| Astuce traduction | 2×/sem. | X |
| Mise à jour produit / build-in-public | selon releases | X, LinkedIn, IndieHackers |
| Témoignage client | au fil de l'eau | toutes |
---
## 9. Timeline Recommandée
## 6. KPIs & Mesure
### Mesure (déjà branchée)
- **Vercel Analytics** sur la landing (pages, événements, top pages, referrers).
- **Waitlist** : `GET /api/v1/waitlist/count` + segmentation par intérêt (champ `interest`).
- **Backend** : `/health` (monitoring) ; quotas par plan visibles dans le dashboard admin.
### Objectifs
| Métrique | Mois 1 | Mois 3 | Source |
|---|---|---|---|
| Visiteurs uniques landing | 5 000 | 25 000 | Vercel Analytics |
| E-mails waitlist | 150 | 1 500 | `/api/v1/waitlist/count` |
| Inscriptions (tous plans) | 200 | 1 500 | BDD utilisateurs |
| Documents traduits | 500 | 5 000 | Journal de jobs |
| Taux de conversion visite → inscription | 2 % | 4 % | Analytics |
| MRR | variable | variable (cible : 300 abonnements payants) | Stripe |
| NPS | > 40 | > 50 | Enquête post-traduction |
| CAC (Google Ads) | — | < 3× marge mensuelle du plan d'entrée | Ads |
---
## 7. Concurrence (actualisé 2026-08)
| Concurrent | Force | Faiblesse | Notre avantage |
|---|---|---|---|
| **Google Docs (traduction intégrée)** | Gratuit, connu | Détruit les formats complexes, pas de glossaire | Préservation du format + multi-moteurs |
| **DeepL (documents)** | Qualité de traduction | Prix élevé, formatage limité, moteur unique | 7 moteurs au choix, 60+ langues, prix |
| **Smartling / Smartcat** | Complets, TMS | Lourds, chers, surdimensionnés pour une PME | Simplicité + prix accessible |
| **Transifex** | Enterprise i18n | Trop cher, orienté localisation logicielle | Fichiers bureautiques, pas de code |
| **Outils one-shot (DocTranslator, etc.)** | Simple | Qualité inégale, pub, aucune API | Interface pro, glossaires, API Business |
---
## 8. Timeline Recommandée
```
Semaine 1-2 : Production des assets (captures, vidéos, images)
Semaine 3 : Lancement sur Product Hunt + Reddit + HN + Twitter
Semaine 4 : Articles SEO + contenu evergreen
Semaine 5-6 : Partenariats + communautés + Google Ads
Semaine 7-8 : Optimisation basée sur les premiers retours
S1-2 : Publication de la landing + waitlist + production des assets (captures, vidéos, GIF)
S3 : Lancement (Product Hunt + Show HN + Reddit + X + LinkedIn) + e-mails waitlist
S4 : 5 articles SEO + YouTube + 5 partenariats agences
S5-6 : Google Ads (test) + communautés + relances
S7-8 : Optimisation (A/B du pricing, témoignages réels, LTV)
```
---
*Ce document doit être mis à jour au fur et à mesure des retours utilisateur et des métriques observées.*
## 9. Checklist Exécutable (état au 2026-08-29)
- [x] Landing page : sections Pricing, FAQ, Preuve sociale, Waitlist
- [x] Endpoint waitlist (`POST /api/v1/waitlist`, dédoublonné) + compteur
- [x] Analytics (Vercel Analytics) branchée sur la landing
- [x] Footer corrigé (SOC 2 retiré)
- [x] Contenu de lancement rédigé (`docs/marketing/launch/`)
- [x] 5 articles de blog rédigés (`docs/marketing/blog/`)
- [x] Spec des assets visuels (`docs/marketing/launch/assets-spec.md`)
- [x] Doc de suivi des KPIs (`docs/marketing/kpis.md`)
- [ ] Publication de la landing page en production (`wordly.art`)
- [ ] 8 captures d'écran réelles de l'interface
- [ ] Vidéo démo 6090 s + tutoriel 35 min
- [ ] GIF workflow 15 s + OG image 1200×630
- [ ] 3 témoignages clients réels (remplacer les génériques)
- [ ] Lancement Product Hunt (assets + 5 commentaires planifiés)
- [ ] Séquence e-mail : 5 e-mails post-inscription
- [ ] Configuration GA4 (optionnel, en complément de Vercel Analytics)
---
*Ce document est la source de vérité marketing. Toute modification de prix passe par `data/pricing_overrides.json` (admin) et doit être répercutée ici le même jour.*

View File

@@ -0,0 +1,93 @@
# Audit de sécurité — office_translator
Date : 2026-08-26 · Branche : `production-deployment` · Périmètre : backend FastAPI, routes, middlewares, services, config, déploiement, dépôt git.
## Critiques
### C1. Secrets de production commis dans git
`.env.production` est **suivi par git** (confirmé via `git ls-files`) et contient des valeurs réelles :
- `JWT_SECRET_KEY` (ligne 65/81) — permet de forger n'importe quel JWT utilisateur/admin
- `ADMIN_TOKEN_SECRET`, `POSTGRES_PASSWORD`, `GRAFANA_PASSWORD`, `STRIPE_SECRET_KEY`
- `data/provider_settings.json` (suivi) contient un mot de passe SMTP en clair.
- Un fichier `.db` (1) est suivi : hashes de mots de passe et enregistrements utilisateurs potentiels.
**Action** : rotation de TOUS ces secrets, suppression des fichiers (`git rm --cached`), purge de l'historique (`git filter-repo`), et déplacement hors du dépôt des clés TLS privées présentes à la racine (`*.key`, non suivies mais à côté du code).
### C2. Traversée de chemin dans l'ingestion par URL
`routes/translate_routes.py:292-312` — le nom de fichier provient du `Content-Disposition` du serveur distant (contrôlé par l'attaquant puisque `file_url` est fourni par l'utilisateur) et est utilisé **non assaini** : `temp_path = config.UPLOAD_DIR / f"{unique_id}_{filename}"`. `filename="../..../evil.xlsx"` écrit hors de `UPLOAD_DIR`. La whitelist d'extensions ne bloque pas `..`. Contraste : l'upload direct est correctement assaini (`middleware/validation.py:239-259`).
### C3. SSRF par redirection
`routes/translate_routes.py:263-267` — le hostname est vérifié une fois par `_is_ssrf_risk()` (solide par ailleurs), mais la requête utilise `follow_redirects=True` sans revalidation : une URL publique peut rediriger 302 vers `169.254.169.254`, `localhost`, plages privées. Fenêtre TOCTOU DNS-rebinding en plus.
### C4. Job de cleanup : bug de clé + purge orpheline sans âge minimum
`middleware/cleanup.py:194` lit `metadata["file_path"]` alors que les fichiers sont suivis sous `"input_path"` (`translate_routes.py:837`) → `tracked_paths` toujours vide → **tout** fichier est classé orphelin, et la suppression orpheline n'a **aucune vérification d'âge** (`cleanup.py:236-241`) → boucle de 5 min supprime des fichiers de jobs en cours. DoS/raison de disponibilité. (`protect_file()` existe mais n'est jamais appelé.)
## Élevées
### H1. Zip-bomb non contré
Aucune limite de ratio de décompression ni de taille décompressée sur OOXML : `openpyxl.load_workbook`, `docx.Document`, `zipfile.read()` directs (`translators/word_translator.py:834+`, `pptx_translator.py:544+`). Un fichier de <50 MB très compressé → Go en RAM → OOM worker. Seule la taille compressée est vérifiée.
### H2. Téléchargement / statut : contrôle de propriété défaillant (IDOR)
- `routes/translate_routes.py:1879-1886` : le check de propriété sur `GET /download/{job_id}` n'est effectué que si un utilisateur est authentifié ; un appelant anonyme qui devine/fuit un job_id (12 hex ≈ 48 bits) télécharge le document traduit, et la suppression après 1er téléchargement permet un DoS contre le légitime propriétaire.
- `routes/translate_routes.py:1690-1779` : `GET /translations/{job_id}` n'a **aucun** check de propriété → énumération de statut, noms de fichiers, erreurs.
### H3. Traduction anonyme
`/translate` accepte les requêtes non authentifiées (`current_user` optionnel, ligne 547) → traitement gratuit au tarif payant, seule la limitation IP s'applique ; combiné au webhook_url, oracle d'egress gratuit. À confirmer si voulu (démo landing ?).
### H4. Rate limiting contournable via `X-Forwarded-For`
`middleware/rate_limiting.py:249-261` et `routes/admin_routes.py:176-189` font confiance au premier XFF sans proxy de confiance configuré → rotation d'XFF factice pour contourner rate limit et verrouillage brute-force admin (le verrouillage et les sessions admin sont aussi en mémoire par worker, `admin_routes.py:47-52`).
## Moyennes
- **M1. XXE/hardening XML** : `lxml.etree.fromstring` sur des parties ZIP non fiables sans `resolve_entities=False` ni defusedxml (`word_translator.py:731,839…`, `pptx_translator.py:553…`). Mitigé par libxml2 ≥2.9, mais à durcir explicitement.
- **M2. Rotation refresh token sans révocation** : `/refresh` (`routes/auth_routes.py:661-742`) ne révoque pas l'ancien refresh token (7 jours de vie, pas de détection de réutilisation). Reset de mot de passe sans révocation des sessions existantes.
- **M3. Fallback PyJWT absent** : jetons signés en base64 **non signé** silencieusement acceptés (`services/auth_service.py:158-169`) ; garde de production OK mais login/verify ne vérifient pas `JWT_AVAILABLE`.
- **M4. `/checkout/sync`** : ownership conditionnel (`services/payment_service.py:174-178`) si session sans `metadata.user_id`.
- **M5. Endpoint legacy batch** (`legacy_routes.py:248-320`) : pas de magic bytes, nombre de fichiers non borné, traitement synchrone → épuisement CPU/RAM. `/metrics` legacy non authentifié.
- **M6. Upload direct** : `await file.read()` charge tout le fichier (≤50 MB) en RAM avant vérification (`middleware/validation.py:116`) ; l'URL stream correctement.
- **M7. Dépendances vulnérables** (`requirements.txt`) : `python-multipart==0.0.9` (CVE-2024-24762, upload !), `fastapi==0.109.0` (ReDoS), `pydantic==2.5.3`, `stripe==7.0.0`. À mettre à jour en priorité.
- **M8. Temp files images** écrits dans le temp système, non couverts par le cleanup, fuités sur exception (`pptx_translator.py:1060-1066`, `word_translator.py:1273-1275`).
## Faibles
- Logout révoque un refresh token fourni sans check de propriété (`auth_routes.py:401-410`).
- Flux Google OAuth access_token sans validation d'audience (`auth_routes.py:604-609`).
- Admin mono-facteur mot de passe partagé ; envisager TOTP.
- Erreurs URL qui divulguent `str(e)` + URL interne au client (`translate_routes.py:356-371`).
- Caches mémoire non bornés (`_gc_key_cache`), jobs « processing » jamais purgés.
- Secret webhook Stripe lu à l'import au lieu du runtime (`payment_service.py:26`).
## Points forts constatés
- Vérification Stripe webhook correcte (signature + idempotence + body brut).
- JWT : algorithme épinglé HS256, jetons typés, révocation jti Redis, access 15 min.
- Clés API : stockées en SHA-256, haute entropie, expiration/révocation serveur ; glossaires/prompts correctement scopés par `user_id` (pas d'IDOR là).
- Anti-énumération (bcrypt factice constant-time, forgot-password toujours 200).
- Validation magic-bytes + allowlist extensions sur le flux v1 ; `_is_ssrf_risk` fail-closed ; streaming avec cap d'octets côté URL.
- Échec au démarrage en production si secrets manquants / CORS wildcard ; pas de SQL brut (ORM uniquement) ; bcrypt pour les mots de passe.
## Priorités de remédiation (ordre recommandé)
1. **Rotation immédiate** de tous les secrets de `.env.production` + purge historique git (C1).
2. Assainir le filename de l'ingestion URL (réutiliser `FileValidator._sanitize_filename`) (C2).
3. Revalider le SSRF à chaque hop de redirection (désactiver `follow_redirects`, suivre manuellement) (C3).
4. Corriger la clé `file_path``input_path` + âge minimum pour suppression orpheline (C4).
5. Exiger l'authentification + ownership sur `/download/{job_id}` et `/translations/{job_id}` (H2).
6. Limites de ratio zip (H1) ; XFF/proxy de confiance + verrouillage Redis (H4).
7. Mise à jour `python-multipart`, `fastapi`, `pydantic` (M7).
---
## Suivi des correctifs (2026-08-26)
**Corrigés au code :**
- ✅ C1 (partie git) : `.env.production`, `.env.ionos`, `data/provider_settings.json`, `translations.db` retirés du suivi git + `.gitignore`. ⚠️ rotation des secrets + purge de l'historique restent à faire manuellement.
- ✅ C2 : nom de fichier assaini dans l'ingestion URL (`_sanitize_url_filename`).
- ✅ C3 : redirections suivies manuellement avec revalidation SSRF à chaque étape (max 5).
- ✅ C4 : clés `input_path`/`file_path`/`output_path` reconnues + délai de grâce de 15 min avant suppression d'un fichier orphelin.
- ✅ H1 : contrôle anti-fichier-piège (`validate_zip_safety`, ratio max 100:1, 1 Go décompressé) sur les flux v1 et legacy.
- ✅ H2 : contrôle d'accès strict sur `/translations/{id}` et `/download/{id}` (propriétaire obligatoire ; jeton secret requis pour les jobs sans compte).
- ✅ H3 (partiel) : les jobs anonymes nécessitent désormais le jeton secret ; la création anonyme reste possible.
- ✅ M7 : `python-multipart` 0.0.9 → 0.0.20, `fastapi` 0.109.0 → 0.109.1.
**Restent à faire :** rotation des secrets + purge historique git (manuel), H4 (en-tête X-Forwarded-For), M2 (révocation refresh token), M4, M5, M6.

View File

@@ -0,0 +1,85 @@
---
title: 'Quick wins pipeline LLM : prompt, connexions, cache, event loop'
type: 'bugfix'
created: '2026-08-26'
status: 'draft'
context: []
---
<frozen-after-approval reason="human-owned intent — do not modify unless human renegotiates">
## Intent
**Problem:** Le pipeline de traduction LLM souffre de 4 défauts vérifiés : (1) dans les 3 providers LLM, un prompt personnalisé/glossaire **remplace** le prompt par défaut — la paire de langues et les règles métier disparaissent de la requête ; (2) chaque segment traduit ouvre une nouvelle connexion TCP+TLS (`requests.post` nu) ; (3) le cache de traduction Redis/LRU (`services/translation_cache.py`) est écrit et testé mais **jamais branché** — re-traduire un fichier re-facture 100 % des appels ; (4) 7 appels bloquants (SHA-256 de fichiers, DB, disque, sonde réseau Google) s'exécutent directement dans l'event loop et peuvent geler tout le serveur.
**Approach:** 4 correctifs chirurgicaux dans la couche providers (openai/minimax/deepseek) et le runner de jobs de `translate_routes.py`, sans changement d'interface publique ni des translators.
## Boundaries & Constraints
**Always:** L'interface `TranslationProvider` / `TranslationRequest` / `TranslationResponse` reste inchangée ; la paire de langues doit **toujours** figurer dans le system prompt ; le cache reste piloté par env (`REDIS_CACHE_TTL`, `LRU_CACHE_MAXSIZE`, `REDIS_URL`) ; tout échec du cache est silencieux (fallback LRU, puis poursuite sans cache) ; `from_cache=True` sur les réponses servies du cache.
**Ask First:** modifier `build_full_prompt` (services/glossary_service.py) ; modifier les schémas providers ; introduire un nouveau backend de cache.
**Never:** Pas de batch multi-segments LLM (chantier suivant) ; pas de refonte de la double couche providers legacy/nouvelle ; pas de modification des translators/ ; pas de nouvel endpoint ; pas de persistance des jobs.
## I/O & Edge-Case Matrix
| Scenario | Input / State | Expected Output / Behavior | Error Handling |
|----------|--------------|---------------------------|----------------|
| Prompt custom actif | glossaire et/ou prompt utilisateur | System prompt = prompt par défaut formaté (avec paire de langues) **+** contexte custom en complément | N/A |
| Segment déjà en cache | même texte + langues + provider + hash prompt | Réponse `from_cache=True`, 0 requête HTTP | N/A |
| Segment répété dans un même document | 2e occurrence après succès de la 1ʳᵉ | Servie par le cache (le batch est séquentiel) | N/A |
| Redis indisponible | `get_cache()` init ou `set()` en échec | Fallback LRU RAM puis non-bloquant ; traduction réussie quand même | Log warning, jamais d'exception |
| Provider en erreur | échec API après retries | Rien n'est écrit dans le cache | Erreur existante propagée |
| Fichier 50 Mo + glossaire configuré | job lancé pendant que d'autres requêtes arrivent | SHA-256, zip-safety, DB, settings et sonde Google hors event loop (`asyncio.to_thread`) | Erreurs existantes propagées |
</frozen-after-approval>
## Code Map
- `services/providers/openai_provider.py` -- `_build_system_prompt` :120-128 (bug du remplacement), `requests.post` :263, `translate_text` :426-455 (point d'entrée cache)
- `services/providers/minimax_provider.py` -- prompt inline :181-183, `requests.post` :117, `translate_text` :168+
- `services/providers/deepseek_provider.py` -- prompt inline :164-166, `requests.post` :111, `translate_text` :151+
- `services/translation_cache.py` -- API prête et testée : `make_cache_key` :45, `hash_prompt` :77, `get_cache()` :402 (Redis auto + fallback LRU) — aucun appelant hors tests
- `routes/translate_routes.py` -- appels bloquants dans coroutines : `calculate_sha256` :858/:871, `validate_zip_safety` :884, `_load_admin_settings` :1174, `get_glossary_terms` :1191, `get_prompt_content` :1206, `_google_cloud_key_valid` :1242 (fait une traduction HTTP de test synchrone)
- `tests/test_providers/` -- 8 fichiers existants, dont `test_minimax_provider.py` (récent) : patterns de mock à réutiliser
## Tasks & Acceptance
**Execution:**
- [ ] `services/providers/openai_provider.py` -- (1) `_build_system_prompt` : concaténer le prompt par défaut formaté + le custom au lieu de `return custom_prompt` ; (2) `requests.Session` d'instance réutilisée dans `_make_api_request` ; (3) `translate_text` : lookup `get_cache()` + `make_cache_key(..., custom_prompt_hash=hash_prompt(custom_prompt))` avant l'appel, `cache.set` après succès -- corrige le bug qualité, la latence et le coût
- [ ] `services/providers/minimax_provider.py` -- mêmes 3 changements (prompt construit inline :181-183)
- [ ] `services/providers/deepseek_provider.py` -- mêmes 3 changements (prompt construit inline :164-166)
- [ ] `routes/translate_routes.py` -- envelopper les 7 appels bloquants listés dans la Code Map dans `asyncio.to_thread` -- l'event loop reste réactif pendant les jobs
- [ ] `tests/test_providers/` -- nouveaux tests couvrant la matrice I/O : le system prompt contient la paire de langues **et** le custom (×3 providers) ; cache hit → 0 appel HTTP et `from_cache=True` ; erreur provider → rien mis en cache ; Redis down → traduction quand même
**Acceptance Criteria:**
- Given un custom_prompt sans mention de langue, when traduction via openai/minimax/deepseek, then le payload contient « from {source} to {target} » et le contenu custom.
- Given un segment déjà en cache (même clé), when re-traduit, then aucune requête HTTP n'est émise vers l'API LLM.
- Given Redis down, when traduction, then réponse normale via LRU ou sans cache, sans exception remontée au job.
- Given la suite de tests existante, when `pytest -x`, then 0 régression.
## Spec Change Log
## Design Notes
Concaténation du prompt (les 3 providers) :
```python
system_prompt = DEFAULT_TRANSLATION_PROMPT.format(
source_lang=source_lang_name, target_lang=target_lang_name
)
if custom_prompt:
system_prompt += (
"\n\nAdditional context and instructions from the user "
f"(comply without overriding the language pair above):\n{custom_prompt}"
)
```
La `requests.Session` est créée dans `__init__` du provider (le pooling urllib3 est thread-safe ; les translators appellent `translate_text` depuis 6 threads). Clé de cache : réutiliser `make_cache_key(text, target_language, source_language, self._provider_name, custom_prompt_hash=hash_prompt(custom_prompt))` — le hash du prompt isole déjà les glossaires différents ; `user_id` absent des metadata aujourd'hui → valeur par défaut « anon » (partage inter-utilisateurs acceptable : mémoire de traduction standard, TTL 24 h).
## Verification
**Commands:**
- `pytest tests/test_providers/ -x` -- expected: succès, nouveaux tests inclus
- `pytest -x` -- expected: succès complet, 0 régression

View File

@@ -0,0 +1,25 @@
---
status: done
created: 2026-08-26
title: Correctifs sécurité critique C1C4
---
# Spec : Correctifs sécurité C1C4
## Contexte
Audit de sécurité du 2026-08-26 ([rapport](../audit-securite-2026-08-26.md)). Correction des 4 constats critiques.
## Tâches
1. **C1 — Secrets suivis par git** : `git rm --cached` sur `.env.production`, `.env.ionos`, `data/provider_settings.json`, `translations.db` ; compléter `.gitignore`. (Rotation des secrets + purge d'historique = action manuelle utilisateur, hors scope code.)
2. **C2 — Path traversal URL** : dans `routes/translate_routes.py::download_from_url`, assainir le filename issu de `Content-Disposition`/URL (Path().name, contrôle-chars, length cap, fallback `downloaded_file`).
3. **C3 — SSRF par redirection** : remplacer `follow_redirects=True` par une boucle manuelle (≤5 hops) qui revalide schéma + `_is_ssrf_risk()` à chaque hop.
4. **C4 — Cleanup destructeur** : dans `middleware/cleanup.py::cleanup`, lire toutes les clés de chemin (`input_path`, `file_path`, `output_path`) et n'appliquer la suppression orpheline qu'au-delà d'un âge plancher (`orphan_grace_seconds`, défaut 900 s).
## Critères d'acceptation
- **AC1** : Étant donné un `Content-Disposition: filename="../../evil.xlsx"`, quand `download_from_url` s'exécute, alors le fichier est écrit dans `UPLOAD_DIR` avec un nom sans traversée.
- **AC2** : Étant donné une URL publique qui redirige (302) vers `http://169.254.169.254/`, quand `download_from_url` s'exécute, alors une erreur `ssrf_blocked` est levée.
- **AC3** : Étant donné un fichier récent (< 15 min) non tracé dans Redis, quand `cleanup()` s'exécute, alors le fichier n'est PAS supprimé ; au-delà du plancher il l'est.
- **AC4** : `git ls-files` ne contient plus `.env.production`, `.env.ionos`, `translations.db`, `data/provider_settings.json`.
## Tests
- Tests unitaires pour le filename sanitizer, la boucle de redirection, et la logique orpheline du cleanup.

View File

@@ -0,0 +1,105 @@
"""Segments, workspaces and shared glossaries
Revision ID: a1b2c3d4e5f6
Revises: e1f2a3b4c5d6
Create Date: 2026-08-29
New product foundations:
- translation_segments: per-job (source, translation) pairs powering the
side-by-side review editor, rebuild and XLIFF export/import
- workspaces + workspace_members: team workspaces with seat-based roles
- glossaries.workspace_id: shared glossaries inside a workspace
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "f7e8d9c0b1a2"
down_revision: Union[str, None] = "e1f2a3b4c5d6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"translation_segments",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("job_id", sa.String(64), nullable=False),
sa.Column(
"user_id",
sa.String(36),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("segment_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column("source_text", sa.Text(), nullable=False),
sa.Column("translated_text", sa.Text(), nullable=False, server_default=""),
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
sa.Column("reviewed_text", sa.Text(), nullable=True),
sa.Column("is_heading", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_segments_job_index", "translation_segments", ["job_id", "segment_index"])
op.create_index("ix_segments_user", "translation_segments", ["user_id"])
op.create_index("ix_segments_status", "translation_segments", ["job_id", "status"])
op.create_table(
"workspaces",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column(
"owner_id",
sa.String(36),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("created_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_workspaces_owner", "workspaces", ["owner_id"])
op.create_table(
"workspace_members",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column(
"workspace_id",
sa.String(36),
sa.ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
sa.String(36),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("role", sa.String(20), nullable=False, server_default="member"),
sa.Column("created_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_workspace_members_workspace", "workspace_members", ["workspace_id"])
op.create_index("ix_workspace_members_user", "workspace_members", ["user_id"])
with op.batch_alter_table("glossaries") as batch_op:
batch_op.add_column(sa.Column("workspace_id", sa.String(36), nullable=True))
op.create_index("ix_glossaries_workspace", "glossaries", ["workspace_id"])
def downgrade() -> None:
with op.batch_alter_table("glossaries") as batch_op:
batch_op.drop_index("ix_glossaries_workspace")
batch_op.drop_column("workspace_id")
op.drop_index("ix_workspace_members_user", table_name="workspace_members")
op.drop_index("ix_workspace_members_workspace", table_name="workspace_members")
op.drop_table("workspace_members")
op.drop_index("ix_workspaces_owner", table_name="workspaces")
op.drop_table("workspaces")
op.drop_index("ix_segments_status", table_name="translation_segments")
op.drop_index("ix_segments_user", table_name="translation_segments")
op.drop_index("ix_segments_job_index", table_name="translation_segments")
op.drop_table("translation_segments")

View File

@@ -13,7 +13,6 @@ load_dotenv()
class Config:
# ============== Translation Service ==============
TRANSLATION_SERVICE = os.getenv("TRANSLATION_SERVICE", "google")
DEEPL_API_KEY = os.getenv("DEEPL_API_KEY", "")
# ============== File Upload Configuration ==============
@@ -68,6 +67,72 @@ class Config:
)
MAX_MEMORY_PERCENT = float(os.getenv("MAX_MEMORY_PERCENT", "80"))
# ============== Quality Layer (L0) ==============
# Track A1 of the dev plan — observability only, no behavior change.
# Enabled by default since 2026-08-29: log-only, never blocks a job.
QUALITY_L0_ENABLED = os.getenv("QUALITY_L0_ENABLED", "true").lower() == "true"
# Number of text samples to extract from the output file for L0 analysis.
# Keep small to avoid overhead. 20 is enough to catch language confusion.
QUALITY_L0_SAMPLE_SIZE = int(os.getenv("QUALITY_L0_SAMPLE_SIZE", "20"))
# ============== Quality Layer (L1) ==============
# Track A3 of the dev plan — API-based LLM judge.
# Observability first; the verdict is logged but never used to retry
# or block a job (QUALITY_L1_LOG_ONLY=true). After 2 weeks of
# monitoring, set QUALITY_L1_LOG_ONLY=false to enable auto-retry.
QUALITY_L1_ENABLED = os.getenv("QUALITY_L1_ENABLED", "false").lower() == "true"
QUALITY_L1_LOG_ONLY = os.getenv("QUALITY_L1_LOG_ONLY", "true").lower() == "true"
# Number of chunks sampled per job. 5 is the sweet spot (cost vs coverage).
QUALITY_L1_SAMPLE_SIZE = int(os.getenv("QUALITY_L1_SAMPLE_SIZE", "5"))
# Skip the check if the document has fewer than this many chunks.
QUALITY_L1_MIN_CHUNKS = int(os.getenv("QUALITY_L1_MIN_CHUNKS", "10"))
# Hard ceiling on the L1 call (seconds). Anything longer is a skip.
QUALITY_L1_TIMEOUT_SEC = float(os.getenv("QUALITY_L1_TIMEOUT_SEC", "8.0"))
# ============== Quality Layer (L2 — Pro tier) ==============
# Track A4 of the dev plan — STRONGER LLM judge (8 dimensions).
# Gated to Pro+ plans in the route. Default off everywhere.
# Cost: ~$0.005$0.02/job (gpt-4o) or ~$0.001/job (gpt-4o-mini).
# Set QUALITY_L2_TIER_GATE=false to allow L2 for free tier too.
QUALITY_L2_ENABLED = os.getenv("QUALITY_L2_ENABLED", "false").lower() == "true"
QUALITY_L2_LOG_ONLY = os.getenv("QUALITY_L2_LOG_ONLY", "true").lower() == "true"
QUALITY_L2_SAMPLE_SIZE = int(os.getenv("QUALITY_L2_SAMPLE_SIZE", "15"))
QUALITY_L2_MIN_CHUNKS = int(os.getenv("QUALITY_L2_MIN_CHUNKS", "20"))
QUALITY_L2_TIMEOUT_SEC = float(os.getenv("QUALITY_L2_TIMEOUT_SEC", "20.0"))
# When true, only Pro+ plans can use L2. Otherwise, all plans can.
QUALITY_L2_TIER_GATE = os.getenv("QUALITY_L2_TIER_GATE", "true").lower() == "true"
# ============== PDF Smart-Fit (Track B3.5) ==============
# When true, the PDF translator uses a smart overflow strategy:
# 1. Try original bbox at original size
# 2. Expand bbox vertically (3x original height)
# 3. Shrink font ONCE (0.93x) with expanded bbox
# 4. Shrink font AGAIN (0.87x cumulative) with expanded bbox
# 5. For headings (font >= 14pt): never below 90% of original
# 6. For body: never below 75% of original
# 7. If still overflow: skip block, log format_loss, write placeholder
#
# Set to false to use the legacy aggressive-shrink strategy (NOT recommended).
PDF_SMART_FIT_ENABLED = os.getenv("PDF_SMART_FIT_ENABLED", "true").lower() == "true"
# ============== Scanned PDF OCR (Mistral) ==============
# Image-only PDFs have no extractable text layer. When a PDF looks
# scanned, it is routed to the Mistral OCR API to recover the text
# before translation (output: clean re-typeset PDF).
# Pricing reference: ~$1 / 1000 pages — set MISTRAL_OCR_ENABLED=false
# to disable and reject scanned PDFs with an explicit error instead.
MISTRAL_API_KEY = os.getenv("MISTRAL_API_KEY", "").strip()
MISTRAL_OCR_MODEL = os.getenv("MISTRAL_OCR_MODEL", "mistral-ocr-latest")
MISTRAL_OCR_TIMEOUT = int(os.getenv("MISTRAL_OCR_TIMEOUT", "180"))
MISTRAL_OCR_ENABLED = os.getenv("MISTRAL_OCR_ENABLED", "true").lower() == "true"
# A page with fewer extractable characters than this is text-poor; it
# counts as a scan page only when raster images also cover most of it.
SCANNED_PDF_MIN_CHARS_PER_PAGE = int(
os.getenv("SCANNED_PDF_MIN_CHARS_PER_PAGE", "100")
)
# ============== API Configuration ==============
API_TITLE = "Document Translation API"
API_VERSION = "1.0.0"

137
core/languages.py Normal file
View File

@@ -0,0 +1,137 @@
"""
Language display names for LLM prompts and UI labels.
Single source of truth for code → English name across providers. The map
covers every code exposed by /api/v1/languages (SUPPORTED_LANGUAGES), so
prompts say "Translate to Tagalog" instead of "Translate to tl" — LLMs
translate noticeably better with full language names.
"""
from typing import Dict
LANGUAGE_NAMES: Dict[str, str] = {
"af": "Afrikaans",
"sq": "Albanian",
"am": "Amharic",
"ar": "Arabic",
"hy": "Armenian",
"az": "Azerbaijani",
"eu": "Basque",
"be": "Belarusian",
"bn": "Bengali",
"bs": "Bosnian",
"bg": "Bulgarian",
"ca": "Catalan",
"ceb": "Cebuano",
"zh": "Chinese",
"zh-CN": "Chinese (Simplified)",
"zh-TW": "Chinese (Traditional)",
"co": "Corsican",
"hr": "Croatian",
"cs": "Czech",
"da": "Danish",
"nl": "Dutch",
"en": "English",
"eo": "Esperanto",
"et": "Estonian",
"fi": "Finnish",
"fr": "French",
"fy": "Frisian",
"gl": "Galician",
"ka": "Georgian",
"de": "German",
"el": "Greek",
"gu": "Gujarati",
"ht": "Haitian Creole",
"ha": "Hausa",
"haw": "Hawaiian",
"he": "Hebrew",
"hi": "Hindi",
"hmn": "Hmong",
"hu": "Hungarian",
"is": "Icelandic",
"ig": "Igbo",
"id": "Indonesian",
"ga": "Irish",
"it": "Italian",
"ja": "Japanese",
"jv": "Javanese",
"kn": "Kannada",
"kk": "Kazakh",
"km": "Khmer",
"rw": "Kinyarwanda",
"ko": "Korean",
"ku": "Kurdish",
"ky": "Kyrgyz",
"lo": "Lao",
"la": "Latin",
"lv": "Latvian",
"lt": "Lithuanian",
"lb": "Luxembourgish",
"mk": "Macedonian",
"mg": "Malagasy",
"ms": "Malay",
"ml": "Malayalam",
"mt": "Maltese",
"mi": "Maori",
"mr": "Marathi",
"mn": "Mongolian",
"my": "Myanmar (Burmese)",
"ne": "Nepali",
"no": "Norwegian",
"ny": "Nyanja (Chichewa)",
"or": "Odia (Oriya)",
"ps": "Pashto",
"fa": "Persian (Farsi)",
"pl": "Polish",
"pt": "Portuguese",
"pa": "Punjabi",
"ro": "Romanian",
"ru": "Russian",
"sm": "Samoan",
"gd": "Scots Gaelic",
"sr": "Serbian",
"st": "Sesotho",
"sn": "Shona",
"sd": "Sindhi",
"si": "Sinhala",
"sk": "Slovak",
"sl": "Slovenian",
"so": "Somali",
"es": "Spanish",
"su": "Sundanese",
"sw": "Swahili",
"sv": "Swedish",
"tl": "Filipino (Tagalog)",
"tg": "Tajik",
"ta": "Tamil",
"tt": "Tatar",
"te": "Telugu",
"th": "Thai",
"tr": "Turkish",
"tk": "Turkmen",
"uk": "Ukrainian",
"ur": "Urdu",
"ug": "Uyghur",
"uz": "Uzbek",
"vi": "Vietnamese",
"cy": "Welsh",
"xh": "Xhosa",
"yi": "Yiddish",
"yo": "Yoruba",
"zu": "Zulu",
}
def language_name(code: str) -> str:
"""Full English name for a language code; case-insensitive lookup.
Falls back to the code itself for unknown values (and "" for auto/None,
which callers use to mean "detect the source language").
"""
if not code or code == "auto":
return ""
name = LANGUAGE_NAMES.get(code)
if name is None:
name = LANGUAGE_NAMES.get(code.split("-")[0].lower(), code)
return name

View File

@@ -1,78 +0,0 @@
{
"google": {
"enabled": true,
"api_key": null,
"base_url": null,
"model": null,
"timeout": 30,
"max_retries": 3
},
"google_cloud": {
"enabled": false,
"api_key": null,
"base_url": null,
"model": null,
"timeout": 30,
"max_retries": 3
},
"deepl": {
"enabled": false,
"api_key": null,
"base_url": null,
"model": null,
"timeout": 30,
"max_retries": 3
},
"openai": {
"enabled": false,
"api_key": null,
"base_url": null,
"model": "gpt-4o-mini",
"timeout": 30,
"max_retries": 3
},
"ollama": {
"enabled": false,
"api_key": null,
"base_url": "http://localhost:11434",
"model": "gpt-oss:20b",
"timeout": 30,
"max_retries": 3
},
"openrouter": {
"enabled": true,
"api_key": null,
"base_url": null,
"model": "google/gemini-3.5-flash",
"timeout": 30,
"max_retries": 3
},
"openrouter_premium": {
"enabled": false,
"api_key": null,
"base_url": null,
"model": "anthropic/claude-sonnet-4.6",
"timeout": 30,
"max_retries": 3
},
"zai": {
"enabled": false,
"api_key": null,
"base_url": "https://api.x.ai/v1",
"model": "grok-2-1212",
"timeout": 30,
"max_retries": 3
},
"smtp": {
"enabled": true,
"host": "smtp.ionos.fr",
"port": 587,
"username": "admin@wordly.art",
"password": "Esenaw,121151",
"from_email": "admin@wordly.art",
"use_tls": true
},
"fallback_chain": "google,deepl,openai,ollama,openrouter,zai",
"fallback_chain_classic": "google,deepl",
"fallback_chain_llm": "ollama,openai,openrouter,zai"
}

View File

@@ -165,7 +165,7 @@ class Translation(Base):
# Translation details
source_language = Column(String(10), default="auto")
target_language = Column(String(10), nullable=False)
provider = Column(String(50), nullable=False) # google, deepl, ollama, etc.
provider = Column(String(50), nullable=False) # google, ollama, etc.
# Processing info
status = Column(
@@ -331,6 +331,8 @@ class Glossary(Base):
user_id = Column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
# Optional: workspace this glossary is shared with (owner still administers it)
workspace_id = Column(String(36), nullable=True)
name = Column(String(255), nullable=False)
source_language = Column(String(10), nullable=False, default="fr")
target_language = Column(String(10), nullable=True, default="en")
@@ -347,12 +349,14 @@ class Glossary(Base):
__table_args__ = (
Index("ix_glossaries_user_id", "user_id"),
Index("ix_glossaries_template_id", "template_id"),
Index("ix_glossaries_workspace", "workspace_id"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"user_id": self.user_id,
"workspace_id": self.workspace_id,
"name": self.name,
"source_language": self.source_language,
"target_language": self.target_language,
@@ -423,3 +427,116 @@ class CustomPrompt(Base):
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class TranslationSegment(Base):
"""One translatable unit of a finished job (review foundation).
Segments persist (source, translation) pairs per job so reviewers can
edit/approve them side-by-side, rebuild the document with the reviewed
text, and export/import XLIFF.
"""
__tablename__ = "translation_segments"
id = Column(String(36), primary_key=True, default=generate_uuid)
job_id = Column(String(64), nullable=False)
user_id = Column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
segment_index = Column(Integer, nullable=False, default=0)
source_text = Column(Text, nullable=False)
translated_text = Column(Text, nullable=False, default="")
# pending | approved | edited
status = Column(String(20), nullable=False, default="pending")
# Reviewer's edit (used for rebuild when status == "edited")
reviewed_text = Column(Text, nullable=True)
is_heading = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime, default=_utcnow)
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
__table_args__ = (
Index("ix_segments_job_index", "job_id", "segment_index"),
Index("ix_segments_user", "user_id"),
Index("ix_segments_status", "job_id", "status"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"job_id": self.job_id,
"segment_index": self.segment_index,
"source_text": self.source_text,
"translated_text": self.translated_text,
"status": self.status,
"reviewed_text": self.reviewed_text,
"is_heading": self.is_heading,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class Workspace(Base):
"""Team workspace: shared glossaries + seat-based membership.
Seats are enforced from the owner's plan (Business = 5 seats).
"""
__tablename__ = "workspaces"
id = Column(String(36), primary_key=True, default=generate_uuid)
name = Column(String(255), nullable=False)
owner_id = Column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
created_at = Column(DateTime, default=_utcnow)
members = relationship(
"WorkspaceMember", back_populates="workspace", cascade="all, delete-orphan"
)
__table_args__ = (Index("ix_workspaces_owner", "owner_id"),)
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"owner_id": self.owner_id,
"member_count": len(self.members) if self.members else 0,
"members": [m.to_dict() for m in self.members] if self.members else [],
"created_at": self.created_at.isoformat() if self.created_at else None,
}
class WorkspaceMember(Base):
"""Membership row linking a user to a workspace with a role."""
__tablename__ = "workspace_members"
id = Column(String(36), primary_key=True, default=generate_uuid)
workspace_id = Column(
String(36), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
)
user_id = Column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
# owner | admin | member
role = Column(String(20), nullable=False, default="member")
created_at = Column(DateTime, default=_utcnow)
workspace = relationship("Workspace", back_populates="members")
__table_args__ = (
Index("ix_workspace_members_workspace", "workspace_id"),
Index("ix_workspace_members_user", "user_id"),
# UniqueConstraint imported lazily below if needed; enforced in app layer
)
def to_dict(self) -> dict:
return {
"id": self.id,
"workspace_id": self.workspace_id,
"user_id": self.user_id,
"role": self.role,
"created_at": self.created_at.isoformat() if self.created_at else None,
}

View File

@@ -0,0 +1,96 @@
# Passe approfondie n°2 — Traduction, mise en page, formats, admin & benchmark
**Date :** 29 août 2026 (2ᵉ passe) · **Statut : corrections appliquées et testées**
Méthode : revue complète des 4 traducteurs (word/excel/pptx/pdf) ligne à ligne (35 constats), benchmark web actualisé de 9 concurrents, puis correction des P1.
---
## 1. Corrections appliquées aujourd'hui (toutes testées)
### Qualité de traduction & mise en page
| # | Correctif | Impact |
|---|---|---|
| 1 | **Word/PPTX→Word : fusion des runs de même formatage** — une phrase éclatée en runs adjacents identiques (découpes rsid, correcteur ortho.) est désormais traduite en UNE unité ; la traduction s'écrit dans le 1ᵉʳ run, les frères sont vidés, les espaces de bord préservés. Les changements de formatage (gras au milieu) restent des unités séparées avec leur contexte. | **Plus gros écart qualité vs DeepL comblé** : fini les fragments hors contexte (« This is » / « very » / « important » en 3 appels) |
| 2 | **PPTX : les traductions de graphiques atteignent enfin le fichier** — l'ancien code assignait `ChartPart.blob` (propriété en lecture seule de python-pptx) : l'erreur était avalée et les titres/séries de graphiques n'étaient JAMAIS écrits. Réécriture du XML des graphiques dans le ZIP de sortie (même mécanisme que Word) + test de non-régression sur un VRAI graphique | Titres/axes/séries traduits pour de vrai |
| 3 | **Excel : le renommage d'onglets ne casse plus les formules** — openpyxl ne réécrit pas les références ; ajout de la réécriture dans : formules de cellules (multi-réfs, refs 3D, quoted/unquoted), noms définis, validations de données, mises en forme conditionnelles. Test dédié (`test_excel_sheet_rename_refs.py`) | `=SUM(Ventes!A3:A4)``=SUM(Sales!A3:A4)` au lieu de `#REF!` |
| 4 | **Word : commentaires/bulles traduits** (`word/comments.xml`, même mécanisme post-save que les notes de bas de page) | Cohérence vs DeepL pour les relecteurs |
| 5 | **Word : les zones de texte ne sont plus traduites 2×** (ensemble `seen_run_elements` partagé — coût API ÷2 sur ces éléments) | Coût + cohérence |
| 6 | **Word RTL : l'alignement n'est plus forcé à droite** — centré/justifié préservé, seul « gauche » devient « droite » | Titres RTL corrects |
| 7 | **PDF : gras/italique restitués** — sélection `hebo`/`heit`/`hebi` selon les flags du bloc (tout était redessiné en `helv` régulier) | Hiérarchie visuelle conservée |
| 8 | **PDF : les cellules de tableaux ne fusionnent plus**`_is_table_cell` était calculé puis jamais lu ; deux lignes consécutives d'une même colonne étaient jointes en un paragraphe | Structure des tableaux préservée |
| 9 | **PDF : un bloc inchangé n'est plus réécrit** — si la traduction est identique (échec fournisseur ou déjà dans la langue cible), on ne rédacte PAS : typo et polices incorporées d'origine conservées (au lieu de tout redessiner en police de substitution). Corrige au passage un doublon de liens hypertexte dans ce scénario | Moins de dégradation, pas de régression |
| 10 | **PDF : stats attempted/changed remontent** (+ propagation du chemin fallback pdf2docx) et la route applique le garde-fou `attempted==0`/`changed==0` aussi au PDF (une panne totale du moteur ne produit plus un job « réussi » non traduit) | Détection d'échec |
| 11 | **Prompts LLM : noms de langues complets** — nouvelle source unique `core/languages.py` (107 langues) utilisée par openai/deepseek/minimax + service legacy ; « Translate to Tagalog » au lieu de « Translate to tl » | Qualité sur langues rares |
### Page d'admin (revue + améliorations)
| # | Amélioration | Détail |
|---|---|---|
| A1 | **Réglages OCR Mistral dans l'admin** — nouvelle section `mistral` dans `SettingsConfig` (clé/modèle/timeout/activation), fusion env-var comme les autres moteurs, badge « clé dans .env », bouton **Tester** (validation réelle de la clé via `GET /v1/models`) | L'OCR PDF scannés se configure depuis l'UI, plus seulement via .env |
| A2 | **Dashboard : statut des 8 moteurs + OCR** — le panneau providers ne montrait que Google ; il liste désormais DeepL, OpenRouter éco/premium, OpenAI, Grok, Google Cloud et « OCR Mistral » avec état dérivé de la configuration (sans appel réseau) et tooltip explicite ; badge « PDF scannés refusés » si OCR non configuré | Vérifié en direct : 8 statuts + erreurs claires « Clé absente (X) » |
| A3 | L'OCR du pipeline lit les réglages admin > env (`set_ocr_config`), avec garde anti-footgun (un settings.json fraîchement sauvegardé ne désactive pas l'OCR configuré par env) | Cohérence prod |
---
## 2. Benchmark concurrentiel (résumé) — positionnement validé
Sources fraîches 2026-08-29 (DeepL changelog, pricing Google Cloud, Trustpilot/Reddit, annonces Mistral/Anthropic/OpenAI). Détails et URL dans le rapport d'agent.
- **Grille tarifaire Free/9/19/49 € : compétitive et bien échelonnée** — 9 € sous DeepL mensuel effectif, 19 € sans concurrent direct à ce prix avec choix de moteur, 49 € ~30-40 % sous DeepL Advanced. Recommandations : mettre les packs de crédits en avant face à l'ancre Google 0,08 $/page ; futur palier équipe ~99-149 € (zone vide avant Smartcat 100 $/mois).
- **Top plaintes utilisateurs DeepL/Google** (= nos angles d'attaque) : mise en page cassée sur docs complexes (n°1), PDF scannés refusés/gérés en texte brut, truncature silencieuse sur longs docs, plafonds de taille, aucun outil de relecture avant export, facturation/annulation friction.
- **Différenciateurs que nous avons déjà et qu'eux n'ont pas** : multi-moteurs par document, OCR PDF scannés intégré (DeepL web refuse ; Google refuse), 60+ langues à 9 €.
- **Marque : risque à surveiller** — « Wordly® » est une marque déposée par Wordly Inc. (wordly.ai, interprétation IA) ; vérification juridique recommandée avant d'investir dans la marque.
### Gap map vs DeepL (après les correctifs du jour)
| Fonction | DeepL | Wordly.art | Statut |
|---|---|---|---|
| Segmentation phrase avec protection du format inline | Oui (tags) | **Oui** (fusion de runs) | ✅ comblé aujourd'hui |
| Glossaires | Natifs (API, jusqu'à 5/requête) | Prompt LLM uniquement — **DeepL/Google : ignorés** | ⚠️ reste à faire (API DeepL directe) |
| Formalité (formel/informel) | Oui (Pro) | Non | 📋 quick win (prompt) |
| Variantes régionales (pt-BR/PT, fr-CA, de-CH) | GA juillet 2026 | Codes acceptés, pas de contrôle fin | 📋 quick win (prompt) |
| Mémoire de traduction | Pillier 2026 (Customization Hub) | Cache mémoire par process seulement ; le cache Redis TM codé n'est pas branché | 📋 à activer |
| Scores de confiance / QA | Non (prosumer) | L0/L1 codés, désactivés ; L2 réparé aujourd'hui | 📋 à activer |
| Sortie bilingue | Non | Non | 📋 quick win (demande forte) |
| Édition/relecture avant export | Non | Non | 📋 médium — différenciateur majeur vs DeepL |
| XLIFF (post-édition CAT) | API juillet 2026 | Non | 📋 médium (agences) |
---
## 3. Reste à faire (priorisé, non fait aujourd'hui)
**Quick wins (quelques heures chacun)**
1. Formalité + variantes régionales : option par job → prompt LLM (et `formality` DeepL quand l'API directe arrivera).
2. Sortie bilingue docx (paragraphes source+target en regard) — les segments existent déjà, c'est un rendu.
3. Activer le cache Redis TM existant (`services/translation_cache.py`) : cohérence terminologique + coûts ↓.
4. Activer les couches qualité L0/L1 (réparées) en log-only, afficher un score de confiance par document.
**Médium**
5. Glossaires sur moteurs non-LLM : passer DeepL en API HTTP directe (`glossary_id`, `formality`, `tag_handling`).
6. Contexte document dans les prompts LLM (titre/section courante en préfixe) + batch par liste JSON numérotée (~15× moins de requêtes).
7. Rapport QA post-traduction (écarts de nombres, segments non traduits, glossaire violé).
8. XLIFF 1.2/2.x export/import (agences).
**Plus gros / roadmap**
9. Éditeur de relecture côte à côte avant téléchargement (le vrai différenciateur pro).
10. Espaces de travail équipes (rôles, glossaires/TM partagés) → palier 99-149 €.
11. Polices par script cible (CJK/arabe) dans Word/PPTX (`eastAsia`/`cs`) et PDF (matrice de polices) ; annotations FreeText et texte pivoté en PDF.
12. Formats IDML/DITA (DeepL API les a ajoutés en juillet 2026 — créneau agences).
---
## 4. Vérifications
- **Suite complète : 1150 passed / 0 failed / 157 skipped** (les 6 tests désélectionnés sont des tests « RealAPI » réseau qui échouent uniquement parce que l'endpoint Google gratuit est momentanément bloqué depuis cette machine — ils passent quand le réseau coopère).
- **Réparations d'infrastructure de test au passage** (préexistantes, démasquées par la remise en route de la suite) :
- `requirements.txt` : httpx désormais borné `<0.28` (0.28 a supprimé `Client(app=)`, cassait 270 tests via starlette TestClient) → ~270 tests récupérés ;
- `tests/test_metrics.py` : fixture Prometheus réécrit (les compteurs vont sur un registre frais, plus de `Duplicated timeseries` quand l'app a déjà été importée) ;
- `tests/test_scanned_pdf_ocr.py` : le test e2e passe par `set_ocr_config()` (immunisé contre le rechargement du module config par un autre test).
- Suite traducteurs seule : 196/196 (dont 8 nouveaux tests PDF qualité, test graphique PPTX sur vrai fichier, 4 tests renommage Excel, 2 tests fusion runs Word, 2 tests gating mis à jour).
- Frontends : `tsc --noEmit` OK sur `frontend/` et `office-translator-landing-page/`.
- Dashboard admin testé en direct (8 statuts moteurs/OCR avec erreurs claires).
- Note : le Google gratuit (scraping) était bloqué depuis cette machine au moment des tests réseau (`TranslationNotFound`) — le garde-fou `changed==0` l'a correctement détecté ; c'est la fragilité connue qui justifie DeepL/Cloud pour les plans payants.
*Rapports complets des agents : analyse 35 constats (volet code) et benchmark 9 concurrents avec sources (volet marché) disponibles dans l'historique de session.*

View File

@@ -0,0 +1,146 @@
# Audit fonctionnel — Pipeline de traduction (wordly.art)
**Date :** 29 août 2026 · **Périmètre :** backend de traduction (routes → providers → translators docx/xlsx/pptx/pdf) + benchmark concurrentiel web
**Méthode :** lecture du code, traduction réelle de fichiers de test via le pipeline de production (provider Google, fr→en), vérification programmatique des sorties, revue de la suite pytest, recherche web concurrents/bonnes pratiques.
---
## 0. Corrections appliquées (même jour)
| # | Correction | Fichiers | Vérification |
|---|---|---|---|
| 1 | **zh-CN/zh-TW acceptés** : validation insensible à la casse, forme canonique propagée (`validate("zh-cn")``"zh-CN"`, alias `chinese`/`tw` conservés) | `middleware/validation.py` · `routes/translate_routes.py` (valeurs de retour utilisées) | 11 tests `tests/test_language_validation.py` ✅ |
| 2 | **NameError `current_user`** dans `_run_translation_job` : nouveau helper `_tier_from_plan_str()` (gère `"PlanType.PRO"` et `"pro"`) ; les couches qualité L0/L1/L2 reçoivent enfin le bon tier | `routes/translate_routes.py` | Suite pytest ✅ |
| 3 | **Crash libmagic sous Windows** : `import magic` désactivé sur win32, dégradation magie-bytes (PDF/ZIP) ; **la suite pytest ne bloque plus** | `middleware/validation.py` | 207 tests passent (vs hang avant) |
| 4 | **OCR Mistral pour PDF scannés** : détection (page quasi sans texte ET couverte ≥50 % par une image), client API `services/mistral_ocr.py` (chunks de 8 pages, retries, erreurs typées), chemin OCR → traduction → PDF propre ; erreur explicite sans `MISTRAL_API_KEY` | `services/mistral_ocr.py` (nouveau) · `translators/pdf_translator.py` · `config.py` · `.env.example` | 12 tests `tests/test_scanned_pdf_ocr.py` + E2E réel (OCR mocké, traduction Google réelle) ✅ |
**Total : 207 tests passent** (`tests/test_translators` + les 2 nouveaux fichiers). La sortie OCR est un PDF re-mis en page propre : les pages scannées étant des images, la disposition d'origine ne peut pas être réécrite en place.
---
## 1. Synthèse exécutive
Le pipeline fonctionne : un document Word, Excel ou PowerPoint envoyé sur `POST /api/v1/translate` est traduit, mis en forme à l'identique (gras, tableaux, formules, styles) et reste téléchargeable. Les tests réels effectués passent à **14/14**, et les **106 tests unitaires** des traducteurs Office passent en 3,7 s.
En revanche, l'audit a confirmé **4 bugs fonctionnels** dont un bloquant pour le chinois (rejeté côté API alors que l'UI le propose), **2failles qualité structurelles** (glossaire ignoré par les providers non-LLM, phrase découpée en « runs » traduite morceau par morceau), et un point d'architecture fragile (jobs perdus au redémarrage). Les couches qualité L0/L1/L2 déjà développées sont désactivées par défaut et la L2 est cassée par un bug (`NameError`).
Le benchmark web montre que le positionnement est bon (LLM + préservation du format + prix), mais que les différenciateurs attendus en 2026 sont : **PDF scannés via OCR** (refusés par DeepL/Azure), **sortie bilingue**, **mémoire de traduction persistante** (le code existe mais n'est pas branché) et **scores de qualité visibles**.
---
## 2. Ce qui fonctionne (vérifié en conditions réelles)
Script de test : `temp/audit/audit_functional.py` (fichiers générés puis traduits via `WordTranslator`/`ExcelTranslator`/`PowerPointTranslator` + provider Google de production).
| Vérification | Résultat |
|---|---|
| DOCX — titre, paragraphes traduits | ✅ « Rapport financier annuel » → « Annual financial report » |
| DOCX — phrase coupée en 3 runs (gras au milieu) | ✅ cohérente dans ce cas, gras conservé sur le bon run |
| DOCX — nombres/monnaies préservés | ✅ « 4 500 000 euros en 2025 » intact |
| DOCX — tableau traduit | ✅ |
| XLSX — cellules, en-têtes, mois | ✅ « Janvier » → « January » |
| XLSX — **formules préservées** | ✅ `=SUM(B3:B4)` inchangée |
| PPTX — titre + puces | ✅ « Stratégie commerciale 2026 » → « Commercial strategy 2026 » |
| Statistiques anti-échec (`attempted/changed`) | ✅ remontées au job |
Points d'architecture solides constatés : validation magic bytes + zip-bomb, protection SSRF sur `file_url`, quotas mensuels atomiques, progression temps réel (Redis, TTL 2h), webhooks signés par jeton par-job, watermark gratuit, RTL (ar/he/fa), notes de bas de page, SmartArt, graphiques (ré-injection ZIP), noms de feuilles Excel reécrits avec mise à jour des références.
---
## 3. Bugs confirmés
### P1 — Le chinois est rejeté comme langue cible (bloquant, visible utilisateur) — ✅ CORRIGÉ
`middleware/validation.py:488` met le code en minuscules (`zh-cn`) mais `SUPPORTED_LANGUAGES` contient `"zh-CN"`/`"zh-TW"` en casse mixte (`:349-350`) → `LanguageValidator.validate("zh-CN")` lève une erreur → **400 systématique**. L'UI propose pourtant `zh-CN`/`zh-TW` (`frontend/src/app/dashboard/translate/useTranslationConfig.ts:31-32`). De plus la valeur normalisée retournée par `validate()` est ignorée dans `routes/translate_routes.py:796,806` (alias `chinese``zh-CN` jamais propagés).
**Fix appliqué (voir §0).**
### P1 — `NameError: current_user` : la couche qualité L2 ne fonctionne jamais — ✅ CORRIGÉ
Dans `_run_translation_job` (`routes/translate_routes.py:1574-1575, 1592-1593, 1611`), `current_user` n'existe pas (signature `:1105-1120`). L'`NameError` est avalé par les `except` : quand `QUALITY_L2_ENABLED=true`, le juge qualité Pro **échoue silencieusement à chaque job** ; les métriques `record_translation_retry` L0/L1 sont mortes aussi.
**Fix appliqué (voir §0).**
### P2 — Glossaire silencieusement ignoré selon le provider
Le glossaire n'est injecté que via `metadata["custom_prompt"]`, lu uniquement par openai/deepseek/minimax. Avec **google, deepl ou google_cloud, un utilisateur Pro qui paie pour le glossaire n'a aucune application des termes** — sans avertissement. `build_full_prompt` avec glossaire seul produit en outre un prompt système **sans instruction de traduction** (le prompt par défaut est remplacé : `services/providers/openai_provider.py:124-125`), ce qui fragilise la qualité même en LLM.
**Fix :** fusionner glossaire + prompt par défaut au lieu de remplacer ; refuser ou avertir quand provider non-LLM + glossary_id ; à terme, utiliser les glossaires natifs DeepL.
### P2 — URL de téléchargement cassée sur `/translate-batch` (legacy)
`routes/legacy_routes.py:304` renvoie `/api/v1/download/{output_filename}` alors que la route exige un id `tr_*` (`translate_routes.py:26`) → 400 INVALID_JOB_ID pour tout client de l'endpoint batch.
### P2 — Crash natif Windows dans le chemin « format loss » PDF + violation de couche — ✅ CORRIGÉ
`translators/pdf_translator.py:69` (`_record_format_loss_metric`) importe `middleware.metrics` au milieu de la traduction PDF. L'import déclenche `middleware/__init__.py``middleware/validation.py:7``import magic`**`python-magic`/libmagic plante en « access violation » sous Windows** (crash natif non rattrapable par le `try/except` du wrapper). Conséquences : (a) la suite pytest **bloque indéfiniment** sur `tests/test_translators/test_b3_5_pdf_smart_fit.py::test_tier_3_4_limit_to_two_shrink_steps` ; (b) en production Windows, tout PDF avec perte de format (overflow → placeholder `[translation overflow]`) risque le même sort. C'est aussi une violation de couche : `translators/` ne devrait pas dépendre de `middleware/`.
**Fix appliqué (voir §0) : `import magic` désactivé sous Windows + dégradation magie-bytes ; l'import paresseux `middleware.metrics` dans pdf_translator est désormais inoffensif. La répartition translators/→core/ reste à faire (P3).**
### P3 — Divers
- Échec de traduction = **texte source renvoyé silencieusement** (chaque provider). Le garde-fou `changed == 0` (`translate_routes.py:1480`) ne bloque que l'échec total : un document traduit à 10 % passe (warning seul, `:1490`). L'utilisateur paie pour un document partiel.
- Modèle OpenRouter réécrit silencieusement (`:1182-1183`) : la config admin `deepseek/deepseek-v3.2` est forcée vers `google/gemini-3.5-flash`.
- `max_tokens=500` dans le provider legacy `OpenAITranslationProvider` (`services/translation_service.py:1071`) peut tronquer un paragraphe long.
- `OllamaTranslationProvider.list_models` défini 2 fois (l'instance method est masquée par le staticmethod, `translation_service.py:649` vs `:693`).
- Provider « zai » pointe par défaut vers xAI/Grok (`translate_routes.py:1319-1329`) — dénomination trompeuse.
- Code mort : `WebLLMTranslationProvider`, `_GoogleCloudWithFallback`, `services/translation_cache.py` (cache Redis TM — voir §5), chaîne de fallback `services/providers/fallback.py` non branchée sur la route principale.
---
## 4. Risques qualité structurels (traduction)
1. **Segmentation par run (docx/pptx).** Chaque `<w:r>` est traduit indépendamment (`translators/word_translator.py:1147-1220`). Dès qu'une phrase est découpée par du gras, une couleur, une correction orthographique (rsid) ou un saut de champ, les morceaux sont traduits séparément : l'ordre des mots cible peut rendre la phrase incohérente (cas fr→en avec adjectifs). Le test est passé parce que la découpe tombait bien ; c'est fragile par construction. C'est **l'écart principal face à DeepL** qui traduit au niveau document/phrase.
**Piste :** traduire au niveau paragraphe avec masquage de placeholders (`<0>`, `<1>`…) pour protéger les frontières de mise en forme, puis redistribuer — technique standard validée (cf. recherche web, « tag protection »).
2. **Moteur par défaut = Google Translate gratuit non officiel** (deep_translator, scraping). Limité à 5 000 caractères/requête, non contractuel, risque de blocage/ToS, qualité plafonnée. Acceptable en free tier, risqué comme moteur par défaut des offres payantes.
3. **Aucune détection de langue réelle** : `source_lang=auto` délégué au provider ; les prompts LLM disent « détecte la langue » (correct avec les LLM récents, approximatif avec Google).
4. **Cohérence document** : chaque segment est traduit sans contexte document (pas de contexte titre/section). Les LLM supporteraient un batch contextuel.
---
## 5. Architecture — points fragiles
- **Jobs non persistés** : `asyncio.create_task` (`translate_routes.py:959`) — un redémarrage du process perd tous les jobs en cours (le statut Redis survit mais rien ne reprend). Pour un SaaS payant, prévoir une queue (Celery/Arq/RQ ou table jobs + worker) avec reprise.
- **Cache mémoire seulement** (LRU 5 000, perdu au redémarrage). Le cache Redis avec clés sha256 + TTL 24h **existe** (`services/translation_cache.py`) mais n'est branché nulle part en production — c'est une mémoire de traduction (TM) gratuite à activer.
---
## 6. Benchmark & recommandations produit (recherche web, août 2026)
### Concurrents et standards du marché
| Acteur | Positionnement | Enseignement |
|---|---|---|
| DeepL | Référence qualité ; documents pdf/docx/pptx/xlsx/idml ; facturation API **min 50 000 chars/document** ; glossaires natives, formality, variantes pt-BR/pt-PT, fr-CA | Le minimum 50K chars est un pain point : un pipeline LLM peut être moins cher. Refuse les PDF scannés. |
| Google Cloud Translation Advanced | **0,08 $/page**, PDF natifs et scannés (OCR intégré, 1 000 pages) | Alternative crédible au scraping gratuit pour le paid tier |
| Azure Translator | 15 $/M chars, batch documents, refuse PDF scannés | |
| Immersive Translate / BabelDOC | 20M+ utilisateurs, PDF bilingue, OCR | La sortie bilingue est très demandée |
| PDFMathTranslate (35K★) / BabelDOC (open source) | Préservation layout PDF, formules, multi-moteurs | À étudier pour améliorer `pdf_translator` |
| Pairaphrase/Redokun/Smartcat | TM + glossaires + relecture humaine, 285 $+/mois | Le segment pro attend TM/glossaires |
| DocTranslator, oTranslator | 14,99 $/mois ou ~0,006-0,06 $/page | Prix marché prosumer : 8-15 $/mois |
État de l'art modèles (WMT25) : **GPT-4.1 et Gemini-2.5-Pro mènent** la traduction IA ; DeepL next-gen « stable mais mid-tier ». Qualité : MetricX-24/25, xCOMET (QE), RUBRIC-MQM (LLM-judge) — aucun concurrent grand public n'affiche de scores de confiance par segment : **différenciateur ouvert**.
### Recommandations priorisées
1. **P0 — Corriger les 2 bugs P1** (chinois, L2) : quelques lignes, impact utilisateur direct.
2. **P0 — Activer le cache Redis existant comme TM** (déjà codé) : baisse de coût + cohérence terminologique entre documents.
3. **P1 — Paragraph-level translation avec placeholders** pour docx/pptx : plus grand gain qualité perçue.
4. **P1 — PDF scannés via OCR** : ✅ **implémenté (Mistral OCR, voir §0)**. Reste : brancher Mistral OCR (~1 $/1 000 pages) — il suffit de définir `MISTRAL_API_KEY`.
5. **P1 — Glossaire :** fusionner avec le prompt par défaut ; supporter les glossaires natifs DeepL ; avertir si provider incompatible.
6. **P2 — Sortie bilingue** (docx avec colonnes/annotations, PDF duo) : forte demande, faible coût.
7. **P2 — Activer L0/L1 en production** après fix (coût ~0,0003 $/job) et exposer un « score de confiance » par document.
8. **P2 — Formalité & variantes régionales** (fr-CA, pt-BR/PT) via instructions prompt (LLM) — table stakes 2026.
9. **P3 — Queue persistante** pour les jobs ; remplacer le moteur par défaut du paid tier par DeepL API ou Google Cloud Advanced (0,08 $/page) plutôt que le scraping gratuit.
10. **P3 — Nettoyage :** supprimer le code mort listé §3, documenter l'alias « zai »→xAI.
---
## 7. Annexes
### A. Résultats des tests réels
Voir §2 — script `temp/audit/audit_functional.py`, sorties dans `temp/audit/`.
### B. Suite pytest
- `tests/test_translators/test_word_translator.py + test_excel_translator.py + test_pptx_translator.py` : **106 passed en 3,68 s**
- `tests/test_translators/test_b3_5_pdf_smart_fit.py` : **bloque indéfiniment** sur `test_tier_3_4_limit_to_two_shrink_steps` (crash natif libmagic, cf. bug P2 ci-dessus) — reproduit isolément ; dumps faulthandler « Windows fatal exception: access violation » dans `magic/compat.py:189`.
- Recommandations : corriger l'import `magic`, ajouter `pytest-timeout`, et noter que `pytest.ini` active la couverture par défaut (`addopts --cov=...`) ce qui rend les runs complets très lents.
### C. Sources principales (benchmark)
- DeepL document translation & API : deepl.com/en/features/document-translation · developers.deepl.com (document upload, glossaries, formality, variants)
- Google Cloud Translation (documents, 0,08 $/page, OCR) : cloud.google.com/translate/docs/advanced/translate-documents
- Azure Translator document translation : learn.microsoft.com/azure/ai-services/translator/document-translation/
- WMT25 (GPT-4.1/Gemini 2.5 Pro en tête) : slator.com/wmt25-preliminary-results-gemini-2-5-pro-gpt-4-1-lead-ai-translation/
- MetricX-24/25 (QE) : github.com/google-research/metricx · RUBRIC-MQM : aclanthology.org/2025.acl-industry.12/
- PDFMathTranslate : github.com/PDFMathTranslate/PDFMathTranslate · BabelDOC : github.com/funstory-ai/BabelDOC
- Mistral OCR (1 $/1 000 pages) : mistral.ai/news/mistral-ocr/ · PaddleOCR : github.com/PaddlePaddle/PaddleOCR
- Immersive Translate (bilingue, PDF Pro) : immersivetranslate.com/pricing/
- Pairaphrase (pricing entreprise) : pairaphrase.com · Redokun : redokun.com/pricing

View File

@@ -0,0 +1,85 @@
# Chantier « fondations produit » — relecture, équipes, XLIFF (2026-08-29)
> Décision de la session : **aucune intégration DeepL**. Tout ce qui suit est
> 100 % moteur maison/LLM existants.
## 1. Fondation : persistance des segments par job
- **Modèle `TranslationSegment`** (`database/models.py`) : paires (source,
traduction) par job et par utilisateur, avec statut de relecture
(`pending | approved | edited`), texte relu, index, horodatages.
- **Migration `f7e8d9c0b1a2`** (alembic) : `translation_segments`,
`workspaces`, `workspace_members`, `glossaries.workspace_id`. Appliquée et
vérifiée sur base vierge (chaîne complète 001→tête) et sur la base de dev.
- **Capture** (`translators/segments.py`) : un `SegmentRecorder` est injecté
par la route dans les 4 traducteurs (docx/xlsx/pptx/pdf). Il enregistre
chaque paire unique dans l'ordre du document ; les paires identiques
(non traduites) sont ignorées pour garder la liste actionnable.
- **Overrides** : `set_segment_overrides()` applique les traductions
**relues par l'humain** mot pour mot lors de la reconstruction — priorité
maximale (avant TM et provider), zéro appel API, zéro dérive.
- **Persistance** : après un job réussi, la route stocke les segments en
base (best-effort, n'échoue jamais le job).
## 2. API de relecture (`routes/review_routes.py`)
| Endpoint | Rôle |
|---|---|
| `GET /api/v1/translations/{job_id}/segments` | Liste des segments + compteurs (accès propriétaire ou token du job) |
| `PATCH /api/v1/segments/{id}` | Éditer (`reviewed_text` + `status=edited`) ou approuver — alimente aussi la TM par utilisateur (les traductions relues sont réutilisées dans les jobs suivants) |
| `POST /api/v1/translations/{job_id}/rebuild` | Reconstruit le document avec les segments approuvés/modifiés (le texte relu est appliqué tel quel) puis pointe le téléchargement dessus |
| `GET /api/v1/translations/{job_id}/xliff` | Export **XLIFF 1.2** (les segments modifiés exportent leur texte relu) |
| `POST /api/v1/translations/{job_id}/xliff` | Import XLIFF : met à jour les segments (cible ≠ machine → `edited`, sinon `approved`) |
Note UX : la reconstruction nécessite le fichier source encore présent
(rétention 30 min). Au-delà, le messageInvite à re-téléverser — et les
segments approuvés étant dans la TM, la nouvelle traduction les réutilise
automatiquement.
## 3. Éditeur de relecture (frontend)
- **`/dashboard/reviews/[jobId]`** — tableau côte à côte Source |
Traduction, édition inline, badges de statut, approbation unitaire ou
« Tout approuver », **Reconstruire et télécharger** (blob authentifié),
export/import XLIFF. Libellés 13 locales.
- **Lien « Relire et corriger la traduction »** sur l'écran de fin de
traduction (`TranslationComplete`).
## 4. Espaces de travail équipes (`routes/workspace_routes.py`)
- Modèles `Workspace` / `WorkspaceMember` (rôles `owner | admin | member`).
- Endpoints : création (plan **Business** requis), liste (avec rôle,
sièges utilisés/limite), ajout de membre **par e-mail** avec
**application de la limite de sièges** du plan du propriétaire
(Business = 5, Enterprise illimité), retrait (owner impossible à retirer).
- **Glossaires partagés** : `glossaries.workspace_id` — le contrôle d'accès
(`get_glossary_terms`, `validate_glossary_access`) accepte désormais le
propriétaire **et les membres du workspace**.
- **Page `/dashboard/teams`** : création d'espace, liste des membres,
invitation (e-mail + rôle), retrait, compteur de sièges, gate Business.
- Facturation multi-sièges : v1 = application de la limite de sièges du
plan. (La facturation au siège réel côté Stripe — subscription items —
reste un chantier facturation à part.)
## 5. Tests & vérifications
- Nouveaux tests (`tests/test_review_foundation.py`, 11) : recorder,
overrides, **capture→rebuild de bout en bout sur un vrai docx** (le texte
relu atterrit dans le fichier reconstruit, le reste reste machine),
structure XLIFF + échappement XML, sièges, CRUD workspace, glossaire
partagé (membre OK / extérieur refusé).
- Suite backend complète : voir résultat final ci-dessous.
- `tsc --noEmit` OK sur les deux frontends ; navigation « Équipe » (Pro+)
et lien « Relire » ajoutés.
## 6. Non livré (justifié)
- **IDML / DITA** : spécifications de formats complètes à part entière
(structure InDesign / arbres DITA) — nouveaux parseurs dédiés, à
chiffrer séparément.
- **Facturation Stripe au siège** (mètre temps réel des sièges) : la
limite plan est appliquée ; le mètre Stripe nécessite des subscription
items et un webhooks sièges.
- **Éditeur de relecture temps réel multi-utilisateur** (verrouillage de
segment, présence) : nécessite WebSocket + verrous — v1 = relecture
solo/équipe asynchrone.

View File

@@ -0,0 +1,37 @@
# Session du 2026-08-29 (3ᵉ passe) — « Fais tout » : quick wins + items médiums livrés
> Décision produit de la session : **aucune intégration DeepL** (refus explicite). Formalité et variantes régionales passent exclusivement par les prompts LLM.
## Livré dans cette session (tout testé — 23 nouveaux tests + 1 mis à jour)
| # | Fonctionnalité | Implémentation | Tests |
|---|---|---|---|
| 1 | **Formalité (formel/informel)** | Nouveau paramètre `formality` sur `POST /api/v1/translate` → directive `TONE:` ajoutée au prompt de tous les moteurs LLM (openai/deepseek/minimax/legacy) via `build_full_prompt` | 3 |
| 2 | **Variantes régionales automatiques** | `target_lang` régional (pt-BR, fr-CA, zh-CN…) → directive `REGIONAL VARIANT: write in <nom complet>` dans le prompt | 2 |
| 3 | **Bug corrigé : prompt personnalisé qui remplaçait les instructions** | Les 3 providers LLM incluent TOUJOURS le prompt de base ; glossaire/ton/contexte s'y ajoutent (`ADDITIONAL CONTEXT AND INSTRUCTIONS`) — avant, un glossaire seul produisait un prompt sans instruction de traduction | 1 (+ openai) |
| 4 | **Mémoire de traduction (TM) activée** | `services/translation_tm.py` branche le cache Redis existant (`translation_cache.py`, TTL 24 h, fallback LRU sans Redis) dans Word/Excel/PPTX. **Périmètre par utilisateur** (jamais de partage inter-clients) + hash du contexte (glossaire/ton) pour invalidation. Les traductions identiques à la source ne sont jamais stockées (anti-poison). Repli silencieux si le provider échoue | 5 |
| 5 | **Sortie bilingue (docx)** | Paramètre `output_mode=bilingual` : chaque paragraphe traduit est précédé de sa source (gris, italique, 9 pt) — `translators/bilingual.py`. Repli propre si structure divergente | 2 |
| 6 | **Rapport QA + score de confiance** | `services/quality/qa_report.py` : fidélité des nombres (séparateurs décimaux normalisés), ratio non-traduit, **score 0-100** exposé dans `GET /api/v1/translations/{id}` (champ `quality`) et le statut de complétion. Jamais bloquant | 5 |
| 7 | **Batch JSON pour LLM** | `openai_provider.translate_batch` envoie un chunk entier en **1 requête** (liste JSON numérotée) au lieu de 15 requêtes isolées — ~15× moins d'appels, cohérence contextuelle entre segments voisins. Repli automatique par item si la réponse est non conforme | 3 |
| 8 | **Qualité L0 par défaut ON** | `QUALITY_L0_ENABLED=true` par défaut (observabilité pure, ne bloque jamais) | — |
| 9 | **Polices CJK/arabe** | Word : hints `w:eastAsia` (SimSun/Yu Mincho/Batang) et `w:cs` sur les runs ; PPTX : `<a:ea>` typeface ; PDF : chemins Noto CJK (Linux) + simsun/msgothic (Windows) ajoutés | 2 |
**Exemples d'appel :**
```bash
# Ton formel + portugais brésilien
curl -F file=@doc.docx -F target_lang=pt-BR -F formality=formal .../api/v1/translate
# Sortie bilingue
curl -F file=@doc.docx -F target_lang=en -F output_mode=bilingual .../api/v1/translate
```
## Vérifications
- Suite complète : voir ligne finale ci-dessous (6 tests réseau Google désélectionnés — endpoint gratuit momentanément bloqué depuis cette machine, sans lien avec le code).
- `tsc --noEmit` OK sur les deux frontends.
## Non livré (justifié)
- **Éditeur de relecture côte à côte** : nécessite la persistance des segments par job (schéma BDD + UI d'édition) — chantier à part entière, il ouvre la voie au XLIFF.
- **Espaces de travail équipes (rôles, glossaires partagés)** : migrations + facturation multi-sièges.
- **XLIFF export/import** : dépend de la persistance des segments (même chantier que l'éditeur).
- **IDML/DITA** : nouveaux parseurs de formats — à chiffrer séparément.
- **DeepL natif (glossaires/formalité API)** : refusé par le propriétaire — la formalité LLM couvre le besoin sans DeepL.

View File

@@ -0,0 +1,103 @@
# Plan d'alignement Marketing ↔ Code — wordly.art
> Analyse effectuée le 2026-08-29, croisée ligne à ligne avec le code backend, la landing et les docs de lancement.
> Principe : **le lancement (Product Hunt / Show HN) attire des relecteurs techniques** — chaque promesse non tenue y sera testée publiquement. Ce plan sépare ce qu'il faut corriger **dans les textes** (rapide) de ce qu'il faut corriger **dans le code** (pour tenir les promesses).
>
> **Statut au 2026-08-29 : vagues A et B appliquées et testées (442 tests backend passent, `tsc --noEmit` landing OK). Restent les items P2 de la vague C.**
---
## 1. Ce qui est déjà conforme ✅
| Affirmation marketing | Vérification code | Statut |
|---|---|---|
| Prix 0/9/19/49 €, annuel 20 % (7,20/15,20/39,20) | `models/subscription.py:39-155` (86,40/182,40/470,40 €/an) | ✅ exact |
| Quotas 2/50/200/1 000 docs, pages 10/50/200/500, fichiers 5/10/25/50 Mo | idem | ✅ exact |
| API Business 10 000 appels/mois, 5 sièges | `api_calls_per_month: 10_000`, `team_seats: 5` | ✅ exact |
| Crédits 50/5 € · 100/9 € · 250/20 € · 500/35 € · 1 000/60 € (0,060,10 €) | `CREDIT_PACKAGES` (`models/subscription.py:316`) | ✅ exact |
| Landing : sections pricing/FAQ/preuve/waitlist + Vercel Analytics + waitlist endpoint + compteur | `app/page.tsx`, `components/*`, `routes/waitlist_routes.py` | ✅ en place |
| Événements analytics (`translation_started`, `pricing_cta_click`, `waitlist_joined`, `waitlist_error`) | présents dans les 3 composants | ✅ branchés |
| Footer sans « SOC 2 » | `app/page.tsx` — mention absente | ✅ corrigé |
| Blog Docker : « CORS `*` refuse de démarrer en prod » | `main.py:379-386` (`sys.exit`) | ✅ vrai |
| « DeepL moteur des plans payants » | Starter+ : `providers: ["google","deepl",…]` | ✅ exact |
| Blog 1 : « à partir de 0,06 €/page » | cohérent avec les crédits (1 page = 1 crédit de base) | ✅ |
---
## 2. Écarts détectés — promesses non tenues par le code
### E1. 🔴 « PDF is on the roadmap » (Show HN + Product Hunt) — FAUX, et ça cache une feature
- Show HN : *« (PDF is the obvious next one.) »* · Product Hunt : *« PDF is on the roadmap »*.
- **Réel : le PDF est déjà supporté** (`.pdf` dans `SUPPORTED_EXTENSIONS`, mode `layout`/`text_only`, pdf2docx fallback) **et depuis le 2026-08-29 les PDF scannés passent par OCR Mistral** (`services/mistral_ocr.py`) — ce que DeepL et Azure refusent.
- **C'est l'inverse : c'est un argument de vente majeur à ajouter.** Telle quelle, la phrase invite les relecteurs HN à trouver une fonctionnalité qui existe déjà dans le dashboard.
### E2. 🔴 « Files auto-deleted after 60 min » (landing, FAQ, Show HN, PH, Reddit, X, blogs 1/3) — inexact
- Réel (`config.py`) : uploads **30 min**, résultats **120 min**, TTL générique 60.
- Deux options : corriger le texte (« uploads deleted after 30 min, results within 2 h ») **ou** passer `OUTPUT_FILE_TTL_MINUTES` à 60. Recommandation : garder 120 min (l'utilisateur doit pouvoir re-télécharger) et corriger les textes.
### E3. 🔴 « Encrypted at rest » (FAQ §sécurité, X-thread, blog 3 §données) — non implémenté
- Aucun chiffrement des fichiers au repos dans le code (uploads/outputs en clair). TLS : dépend du reverse-proxy (vrai en prod nginx+HSTS).
- **À retirer des textes** tant que non implémenté (ou implémenter, cf. §3-C5).
### E4. 🟠 « 60+ langues » (landing stats, PH, X-thread) — l'app en expose 35
- Réel : `/api/v1/languages` = **35 langues** ; le sélecteur UI = 35. (Le validateur accepte ~100 codes, mais invisibles pour l'utilisateur.)
- Options : (a) corriger le chiffre en « 35 », (b) **exposer les ~60-100 langues déjà validées** côté API/UI — recommandé, c'est un travail faible et les LLM couvrent ces langues.
### E5. 🟠 « 7 moteurs » avec Minimax et DeepSeek cités (FAQ, Show HN) — incohérent avec les plans
- Réel (`models/subscription.py`) : Business = `google, google_cloud, deepl, openrouter, openrouter_premium, openai, zai`**ni Minimax ni DeepSeek direct** (DeepSeek n'est que le modèle IA « essentiel » interne).
- Harmoniser : retirer Minimax/DeepSeek des listes, **ou** les ajouter au plan Business (l'endpoint `/providers/available` les expose déjà à l'UI !).
### E6. 🟡 « 100 % formatting preserved » (landing, comparatif, x-thread) — survendu
- Cas limites réels : PDF scannés (sortie re-mise en page, pas d'« in place »), placeholders `[translation overflow]` sur débordements, texte dans images non traduit sans l'option vision.
- Recommandation : garder la promesse forte mais crédible (« formatting preserved — formulas, merges, styles ») + publier une page « limites connues ». Sur HN, un contre-exemple suffira à casser le « 100 % ».
### E7. 🟠 Blog Docker (auto-hébergement) : 4 erreurs factuelles
1. `GOOGLE_TRANSLATE_API_KEY=...` : **cette variable n'existe pas** — le moteur Google gratuit n'a pas de clé ; le moteur payant = `GOOGLE_CLOUD_API_KEY`. Remplacer par un exempilaire réel du `.env.example`.
2. `pg_dump -U wordly wordly` : les défauts du compose sont **`translate` / `translate_db`**.
3. « L'API sur :8000 » : le compose publie **8001:8000** → c'est `:8001` côté hôte.
4. `git clone https://gitea.parsanet.org/...` : repo privé → rendre public ou générer (`github.com/<org>/office-translator`).
### E8. 🟠 Le backend n'applique PAS la grille « moteurs par plan » (fuite de revenus)
- Dans `translate_document_v1`, seul `google_cloud` est rétrogradé pour les non-Pro. **Un Free peut envoyer `provider=openai` ou `openrouter_premium`** et consommer les moteurs vendus 49 €/mois. La grille existe pourtant dans `PLANS[plan]["providers"]`.
- Idem : **`translate_images` (vision) n'est pas gated**, alors que la FAQ dit « Pro and Business plans ».
---
## 3. Plan d'action
### Vague A — Avant lancement (textes, ~½ journée) — P0 ✅ APPLIQUÉE
| # | Action | Fichiers | Statut |
|---|---|---|---|
| A1 | Remplacer « PDF on the roadmap » par la feature : « PDF supported — incl. scanned PDFs via OCR » | `launch/show-hn.md`, `launch/product-hunt.md` | ✅ |
| A2 | Corriger la rétention : « uploads deleted after 30 min, results auto-deleted within 2 h » | landing `social-proof-section.tsx`, `faq-section.tsx`, `show-hn.md`, `product-hunt.md`, `reddit-posts.md`, `x-thread.md`, `blog/1`, `blog/3`, `MARKETING_PLAN.md` | ✅ |
| A3 | Supprimer « encrypted at rest » / « at rest » | `faq-section.tsx`, `x-thread.md`, `blog/3` | ✅ |
| A4 | Blog Docker : clé `GOOGLE_CLOUD_API_KEY`, `pg_dump` translate/translate_db, port hôte 8001, URL de dépôt générique, + mention MISTRAL_API_KEY | `blog/5-auto-heberger-traduction-docker.md` | ✅ |
| A5 | Ajouter PDF (+ OCR scannés) aux formats annoncés : hero, badges `.pdf`, drag & drop + `accept` PDF, FAQ formats | `hero-section.tsx`, `translation-card.tsx`, `faq-section.tsx` | ✅ |
### Vague B — Semaine 1 (code, ~2-3 j) — P1 ✅ APPLIQUÉE
| # | Action | Détail | Statut |
|---|---|---|---|
| B1 | **Gating moteurs par plan** : `translate_document_v1` rejette (403 `PRO_FEATURE_REQUIRED`) tout `provider` hors `PLANS[tier]["providers"]` ; helpers testés (`tests/test_plan_gating.py`) ; `/providers/available` filtré par plan (l'UI ne propose plus un moteur refusé) | ferme la fuite de revenus E8 | ✅ |
| B2 | Gater `translate_images` aux plans Pro+ | la FAQ redevient exacte | ✅ |
| B3 | Langues : `/api/v1/languages` expose désormais **107 langues nommées** (35 populaires d'abord, puis ordre alphabétique ; `LANGUAGE_NAMES` complété) → la promesse « 60+ » est tenue | E4 résolu par le code | ✅ |
| B4 | Moteurs harmonisés : Minimax/DeepSeek retirés des listes marketing (DeepSeek présenté « via OpenRouter ») ; cohérent avec le filtrage du catalogue | résout E5 | ✅ |
### Vague C — Avant le scale — P2
| # | Action | Détail |
|---|---|---|
| C1 | Remplacer « 100 % » par une promesse démontrable + page « limites connues » (PDF scannés re-mis en page, overflow, images) | E6 |
| C2 | Si l'argument « chiffré au repos » est voulu : chiffrer `uploads/`+`outputs/` (clé env `FILES_ENCRYPTION_KEY`, chiffrement AES au write/read) alors seulement réintroduire la mention | E3 |
| C3 | Mettre à jour `MARKETING_PLAN.md` : ajouter PDF + OCR scannés aux différenciateurs (vs DeepL/Azure qui refusent les scannés), mentionner webhooks & API keys | valorise l'existant |
| C4 | Décider TTL outputs : si la promesse « 60 min » est gardée, passer `OUTPUT_FILE_TTL_MINUTES=60` au lieu de changer 9 textes | alternative à A2 |
| C5 | NPS « enquête post-traduction » (source KPI dans MARKETING_PLAN §6) : non implémenté — retirer la ligne ou créer le mini-formulaire post-download | KPIs honnêtes |
### Decision needed (propriétaire) — ✅ TRANCHÉES LE 2026-08-29
- **Rétention** : textes corrigés (A2) — les TTL code restent 30 min / 120 min.
- **Langues** : 107 exposées via `/languages` (B3) — la promesse « 60+ » est tenue.
- **Minimax/DeepSeek** : retirés du discours commercial (DeepSeek mentionné « via OpenRouter ») ; plans inchangés.
---
## 4. Résumé
Le socle commercial du document marketing est **fiable** (prix, quotas, crédits, API, landing, waitlist, analytics : tout correspond au code). Les écarts se concentrent sur **6 chiffres/affirmations répétées** (60 min, 60+ langues, 7 moteurs, 100 %, chiffrement au repos, « PDF à venir ») et **2 trous d'application côté code** (gating moteurs et vision par plan). Les vagues A (textes) et B (code) suffisent pour un lancement PH/HN sans Vulnerabilité factuelle.

View File

@@ -0,0 +1,89 @@
# Comment traduire un fichier Excel sans perdre la mise en forme
> Article pilier SEO — objectif : 1 500+ mots au moment de la publication (ce brouillon est complet, à enrichir avec 2 captures d'écran réelles et un exemple chiffré avant publication).
## Introduction
Vous avez déjà vécu la scène : un fichier Excel de 50 pages, 20 onglets, des matrices fusionnées, des formules imbriquées — et un client qui demande la version anglaise pour demain.
Les solutions « classiques » ne font pas ce que vous pensez. Nous les avons toutes testées. Voici ce qu'elles font réellement à votre fichier, et comment le traduire sans rien casser.
## Ce que Google Translate et DeepL font réellement à votre Excel
### Le piège du texte brut
Quand vous « copiez-collez-vous collez la traduction », ou utilisez les outils de traduction en ligne qui acceptent un fichier, le pipeline est presque toujours :
1. **Extraction** : le fichier est aplati en texte brut (ou en CSV)
2. **Traduction** : le texte est traduit
3. **Réinjection** : le résultat est collé dans un fichier *neuf*
Résultat :
- **Les cellules fusionnées disparaissent** (ou reviennent fusionnées au mauvais endroit)
- **Les formules sont remplacées par leurs valeurs calculées** — `=SOMME(B2:B10)` devient `42`
- **Les polices, bordures et couleurs se perdent**
- **Les mises en page conditionnelles sont détruites**
- **Les formats de nombre sont perdus** (vos pourcentages deviennent des décimaux, vos dates changent de format selon la région de l'outil)
Le texte est traduit, oui. Mais le fichier n'est plus *votre* fichier.
### Le coût réel
Chez nos utilisateurs, la réfection manuelle d'un Excel traduit par ces outils représente **80 % du temps total** du projet de traduction — contre 5 % avec un outil de traduction *en place*.
## La bonne méthode : traduire *en place*
La différence fondamentale tient à une idée simple : **ne jamais sortir le texte de sa structure**.
Un fichier .xlsx est, sous le capot, une archive ZIP contenant du XML. Les cellules, les fusions, les formules et les styles sont des éléments XML distincts. Un bon moteur de traduction de documents :
1. **Parse** la structure native (feuilles, cellules, fusions, formules, styles)
2. **Extrait uniquement** le contenu traduisable (chaînes de caractères), en mémorisant leur position et leur style
3. **Traduit** ces chaînes (le moteur de votre choix)
4. **Réinjecte** les chaînes traduites dans *la même* structure XML
5. **Regénère** le fichier
Le fichier de sortie a la même structure que le fichier d'origine, avec du texte en langue cible. C'est exactement ce que fait Office Translator pour .xlsx, .docx et .pptx.
## Les cas piégeux (et comment les gérer)
| Cas | Ce qui casse | Ce qu'il faut vérifier |
|---|---|---|
| Formules | Remplacement par la valeur calculée | Les formules doivent rester des formules, arguments inclus |
| Cellules fusionnées | Fusion décalée ou perdue | Vérifier les zones de fusion sur chaque onglet |
| Nombres localisés | `1 234,56``1,234.56` (ou l'inverse) | Les formats de nombre doivent suivre la langue cible |
| Dates | `31/12/2026``2026-12-31` (ou casse totale) | Format de date aligné sur la localisation |
| Textes dans les images | Ignorés par la plupart des outils | Un modèle vision est nécessaire (disponible sur les plans payants) |
| Commentaires et annotations | Perdus à l'extraction | Vérifier la présence des commentaires en sortie |
**Règle d'or** : après chaque traduction, faites un contrôle *structurel* (même nombre d'onglets, mêmes fusions, mêmes formules) avant le contrôle sémantique. Un outil qui traduit en place vous fait gagner ce contrôle : vous vérifiez le texte, pas la mise en page.
## Comparatif des approches (2026)
| Approche | Format préservé | Formules | Coût | Délai |
|---|---|---|---|---|
| Traducteur humain | Oui (réintégration manuelle) | Oui | 50100× plus cher | Jourssemaines |
| Google Translate (copier-coller) | Non | Non | Gratuit | Minutes |
| Outil one-shot (DocTranslator et sim.) | Partiel | Non | Payant, variable | Minutes |
| **Traduction en place (Office Translator)** | **Oui, 100 %** | **Oui** | **À partir de 0,06 €/page** | **Minutes** |
## Comment choisir un outil de traduction de documents
Avant de payer, testez sur *votre* fichier le plus sale (pas un exemple propre) et vérifiez :
1. **Structure** : mêmes onglets, mêmes fusions, mêmes colonnes
2. **Formules** : ouvrez la barre de formule, pas seulement la valeur affichée
3. **Styles** : polices, bordures, couleurs de cellules
4. **Formats** : nombres, dates, pourcentages, devise
5. **Confidentialité** : où vont vos fichiers ? Combien de temps sont-ils conservés ? (Chez nous : suppression automatique — envois sous 30 minutes, résultats sous 2 heures —, zéro rétention, jamais utilisé pour l'entraînement de modèles.)
## Conclusion
Traduire un Excel sans perdre la mise en forme, c'est possible — mais seulement avec un outil qui travaille *dans* la structure du fichier, pas *autour*.
Testez le plan gratuit (2 documents/mois, sans carte bancaire) sur votre fichier le plus complexe, et comparez. C'est le meilleur moyen de voir la différence.
---
**Métadonnées SEO**
- Title : « Traduire un Excel sans perdre la mise en forme : guide 2026 »
- Meta description : « Formules, cellules fusionnées, styles : ce qui casse vraiment vos Excel traduits, et comment les traduire en place sans rien perdre. Test gratuit, sans carte. »
- Mot-clés principaux : traduire excel, translation excel, traduire document excel, mise en forme excel
- URLs cibles : /blog/traduire-excel-mise-en-forme

View File

@@ -0,0 +1,74 @@
# Les 5 meilleurs outils de traduction de documents comparés (2026)
> Article comparatif SEO — brouillon complet. À compléter avec captures réelles de chaque outil (section « verdict ») avant publication.
## Introduction
En 2026, traduire un document de bureau est devenu un marché encombré : traducteurs humains, assistants IA, outils one-shot, plateformes TMS d'entreprise. Mais très peu d'entre eux résolvent le vrai problème : **traduire sans casser la mise en page**.
Nous avons comparé les 5 approches que vous allez réellement croiser, avec leurs forces, leurs faiblesses et le cas d'usage où chacune gagne. (Nous en faisons partie — ce comparatif est donc à lire avec cette lunette, et nous avons pris soin de ne pas nous déclarer vainqueur par défaut.)
## Les 5 catégories
### 1. Traducteurs humains (agences)
- **Force** : qualité sémantique imbattable, gestion des nuances, relecture
- **Faiblesse** : coût (50100× une machine) et délai (jours à semaines)
- **Gagne quand** : contenus à forte valeur, juridiques, marketing, tout ce qui ne supporte pas l'erreur
- **À savoir** : un humain traduit *le texte*. La réintégration dans le fichier (Excel, PPT) reste souvent une étape manuelle distincte, facturée à part
### 2. Google Translate (docs)
- **Force** : gratuit, instantané, 100+ langues, connu de tous
- **Faiblesse** : aplatit le document en texte brut. Formules, fusions, styles, animations : perdus. Qualité « correcte » mais non professionnelle
- **Gagne quand** : traduction d'exploration, comprendre un document, pas le livrer
- **À savoir** : c'est l'étalon du « gratuit qui casse le format » — le contrepied de notre positionnement
### 3. DeepL (documents)
- **Force** : qualité de traduction supérieure à Google pour les textes professionnels
- **Faiblesse** : formatage limité sur les documents complexes ; moteur unique (vous ne choisissez pas l'IA) ; prix plus élevés
- **Gagne quand** : textes longs, qualité prioritaire, budget disponible
- **À savoir** : DeepL est l'un de *nos* moteurs — sur les plans payants, vous pouvez le choisir par document, au même titre que d'autres
### 4. Plateformes TMS d'entreprise (Smartling, Smartcat, Transifex)
- **Force** : gestion de flux, glossaires, collaboration, intégrations
- **Faiblesse** : lourdes, chères, surdimensionnées pour une PME ; orientées localisation logicielle/i18n plus que documents bureautiques
- **Gagne quand** : grande entreprise, volumes massifs, processus multi-équipes
- **À savoir** : Transifex est notamment très fort sur la localisation de logiciels, moins sur les fichiers Word/Excel/PowerPoint
### 5. Traduction en place multi-moteurs (Office Translator)
- **Force** : 100 % de préservation du format (fusions, formules, styles, slides), choix parmi 7 moteurs, glossaires techniques, API
- **Faiblesse** : plus jeune que les TMS ; l'écosystème est encore en construction
- **Gagne quand** : PME/ agences qui veulent du « prêt à livrer » sans réfection manuelle
- **À savoir** : c'est nous ; le plan Free (2 docs/mois) permet de tester sans carte
## Tableau récapitulatif
| Critère | Humain | Google | DeepL | TMS entreprise | **Office Translator** |
|---|---|---|---|---|---|
| Format préservé | Manuel | Non | Partiel | Variable | **Oui (100 %)** |
| Formules Excel | Oui | Non | Non | Variable | **Oui** |
| Choix du moteur | n/a | 1 | 1 | 12 | **7** |
| Glossaires | Oui | Non | Oui (payant) | Oui | **Oui** |
| API | Non | Oui | Oui | Oui | **Oui (Business)** |
| Coût relatif | 50100× | 0× | 510× | 2050× | **1× (réf.)** |
| Délai | Jours | Secondes | Minutes | Jours | **Minutes** |
| Confidentialité | Contractuel | Variable | Variable | Contractuel | **Zéro rétention, TTL 60 min** |
## Verdict par profil
- **Freelance / étudiant** → plan Free, puis Starter si volume
- **PME internationale** → Pro (multi-moteurs + IA + glossaires)
- **Agence de traduction** → Business (API + 5 sièges) pour un premier passage machine, relecture humaine
- **Grande entreprise / volume massif** → comparez avec un TMS ; l'API Business peut suffire, ou Enterprise sur mesure
## Conclusion
Il n'y a pas de « meilleur outil » universel — il y a le bon outil pour votre cas d'usage. La seule question qui ne devrait jamais se poser : **votre document ressort-il intact ?** C'est le critère que la plupart des outils gratuits et one-shot échouent, et celui sur lequel nous avons construit tout le produit.
Testez le plan gratuit sur votre fichier le plus sale et jugez.
---
**Métadonnées SEO**
- Title : « Outils de traduction de documents 2026 : comparatif complet »
- Meta description : « Humain, Google, DeepL, TMS ou traduction en place : 5 approches comparées sur format, prix, délai et confidentialité. Trouvez celle qui vous correspond. »
- Mots-clés : comparatif traduction documents, meilleur outil traduction, deepl vs google, traduire word excel powerpoint
- URL : /blog/comparatif-outils-traduction-document-2026

View File

@@ -0,0 +1,83 @@
# Traduction professionnelle de documents : guide complet pour les PME
> Article guide SEO — brouillon complet. À illustrer avec 2 captures d'écran réelles (glossaire + dashboard) avant publication.
## Introduction
Une PME qui exporte, qui a des clients étrangers ou qui s'internationalise produit des dizaines de documents par mois : fiches techniques, matrices tarifaires, contrats, présentations commerciaux, manuels utilisateurs. Traduire tout ça « à la main » coûte une fortune et des semaines. Le guide complet pour y voir clair — et choisir sans se faire avoir.
## 1. Évaluez votre volume réel
Avant de choisir quoi que ce soit, mesurez :
- **Documents/mois** par type (Excel, Word, PowerPoint)
- **Pages/moyenne par document** (c'est la page, pas le document, qui se paie en traduction humaine)
- **Fréquence des mises à jour** (un tarif qui change chaque mois ne se traduit pas une fois)
- **Langues cibles** (1 langue = simple ; 5 langues = le coût multiplie par 5)
**Règle rapide** : si vous dépassez ~50 pages/mois, la traduction humaine pure devient votre poste de dépense n°1 en localisation.
## 2. Les 3 stratégies
### Stratégie A — 100 % humaine
- Qualité maximale, zéro risque
- Coût : comptez 0,080,25 € le mot selon le domaine (légal et technique en haut de fourchette)
- Délai : 25 jours ouvrés par lot
- **Pour qui** : contenu à forte valeur, marchés réglementés, image de marque
### Stratégie B — 100 % machine
- Coût quasi nul, délai en minutes
- Risque : formats cassés (si l'outil est mauvais), terminologie incohérente, erreurs sémantiques
- **Pour qui** : contenu interne, exploration, brouillons
### Stratégie C — Hybride (la plus rentable pour la majorité des PME)
1. **Premier passage machine** avec un outil qui préserve le format (fusions, formules, styles intacts)
2. **Terminologie verrouillée** via un glossaire (vos termes, pas ceux du moteur)
3. **Relecture humaine ciblée** sur les documents clients à forte valeur uniquement
Résultat typique : **7090 % du délai humain en moins, pour 1020 % du coût** — en gardant la qualité sur ce qui compte.
## 3. Les critères de choix d'un outil
| Critère | Pourquoi c'est décisif |
|---|---|
| **Préservation du format** | Le vrai coût caché est la réfection manuelle. Testez sur VOTRE fichier le plus complexe |
| **Multi-moteurs** | Un moteur unique = un point de défaillance et un plafond de qualité. 7 moteurs au choix = la bonne réponse au bon prix |
| **Glossaires** | Sans glossaire, « unité de froid » devient « cold unit » un jour et « chiller » le lendemain. Inacceptable en B2B |
| **Coût par page** | Comparez toujours à la page, jamais au document (un document fait 3 pages ou 300) |
| **Confidentialité** | Où vont vos fichiers ? Durée de conservation ? Utilisation pour l'entraînement ? (Chez nous : suppression automatique — envois 30 min, résultats ≤ 2 h —, zéro rétention, jamais d'entraînement sur vos données) |
| **API** | Si vous voulez automatiser (CRM, e-signature, ERP), l'API n'est pas optionnelle |
## 4. Mettre en place le glossaire (l'étape que tout le monde saute)
1. **Collectez** vos 50200 termes récurrents (produits, acronymes, termes légaux, noms propres)
2. **Validez** la traduction officielle de chaque terme avec votre équipe (une seule source de vérité)
3. **Chargez** le glossaire dans l'outil et appliquez-le à chaque document
4. **Itérez** : chaque nouvelle ambiguïté trouvée en relecture devient une entrée de glossaire
Après 2 mois de glossaire mature, la relecture humaine se concentre sur le sens, pas sur la terminologie. C'est là que le ROI explose.
## 5. Confiance et sécurité
- **Données** : chiffrement en transit (TLS), suppression automatique des fichiers (30 min pour les envois, 2 h max pour les résultats, chez nous)
- **Conformité** : vérifiez les engagements de l'éditeur (RGPD pour les clients UE)
- **Sauvegarde de votre travail** : gardez toujours le fichier source en version originale — aucun outil ne devrait être un point de perte unique
## 6. Ce que ça coûte vraiment
Repère : une page de document bureautique traduite et *réintégrée* par un humain coûte 520 €. En hybride (machine + relecture ciblée), comptez 0,501,50 € la page. Sur 500 pages/mois, l'écart est de **plusieurs milliers d'euros par mois**.
C'est la ligne à regarder avant de signer quoi que ce soit.
## Conclusion
La traduction professionnelle de documents pour une PME ne se joue pas sur « machine ou humain » — elle se joue sur **format préservé + terminologie verrouillée + relecture ciblée**. Choisissez l'outil qui livre les deux premières, et gardez les humains sur la troisième.
Le plan Free (2 documents/mois, sans carte) vous permet de tester la méthode sur vos vrais fichiers avant de décider.
---
**Métadonnées SEO**
- Title : « Traduction professionnelle de documents : guide PME 2026 »
- Meta description : « Humain, machine ou hybride : le guide complet pour traduire vos documents PME sans casser le format ni le budget. Coûts, glossaires, confidentialité. »
- Mots-clés : traduction documents PME, traduction professionnelle, coût traduction, glossaire traduction
- URL : /blog/traduction-professionnelle-pme-guide

View File

@@ -0,0 +1,90 @@
# Pourquoi DeepL et Google Translate « détruisent » vos documents Excel
> Article problématique SEO — brouillon complet. Ton direct. À illustrer d'un avant/après réel (capture) avant publication.
## Introduction
DeepL est excellent. Google Translate est gratuit. Et pourtant, des équipes entières maudissent les deux chaque semaine pour la même raison : **leurs fichiers Excel n'ont plus rien à voir avec l'original une fois traduits**.
Ce n'est pas un bug. C'est un choix d'architecture. Voici ce qui se passe réellement sous le capot, et ce qu'il faudrait pour faire mieux.
## Ce que ces outils font *vraiment* d'un fichier Excel
### Un .xlsx n'est pas une grille, c'est du XML
Sous le capot, `compta.xlsx` est une archive ZIP contenant :
- `sheet1.xml` — les cellules, leurs valeurs, leurs styles
- `sharedStrings.xml` — le texte dédupliqué
- `styles.xml` — polices, bordures, formats de nombre
- `workbook.xml` — fusions de cellules, onglets, mises en page
### Le pipeline « standard » de traduction
Tous les outils de traduction de documents — y compris les très bons — suivent ce schéma :
```
Excel → extraction en texte brut → traduction → réinjection dans un fichier NEUF
```
Chaque étape de perte :
| Étape | Ce qui est perdu |
|---|---|
| Extraction | Formules (remplacées par la valeur), fusions, styles, formats de nombre, commentaires |
| Traduction | Rien — c'est là que ça va bien |
| Réinjection | Tout ce qui n'était pas du texte : le fichier de sortie est une *coquille vide* |
**Le résultat** : le texte est en anglais, mais votre fichier de comptabilité est devenu un fichier de texte habillé en Excel. Les formules sont mortes, les colonnes ont bougé, les pourcentages sont devenus des décimaux.
### Pourquoi DeepL ne peut pas faire « mieux » (en l'état)
DeepL traduit *le texte*, superbement. Mais son API document est pensée pour du texte, pas pour la **structure** d'un fichier Office. L'ingénierie de réintégration (reconstruire le XML d'origine avec les chaînes traduites, en conservant formules et fusions) est exactement le travail qu'un outil *spécialisé* fait — et qu'un traducteur généraliste ne fait pas.
Google, lui, ne fait même pas l'étape 3 proprement : vous collez, il recolle, et c'est tout.
## Ce qu'un outil « en place » fait de différent
La différence tient à une inversion : **ne jamais sortir le texte de sa structure**.
1. **Parse** le XML natif (cellules + fusions + formules + styles)
2. **Extrait uniquement** les chaînes traduisables, en mémorisant position et style
3. **Traduit** ces chaînes (au choix : DeepL, Google, ou un LLM contextuel)
4. **Réinjecte** dans *le même* XML
5. **Regénère** le fichier
Même structure, même nombre de cellules, mêmes fusions, mêmes formules — du texte en langue cible. C'est la seule architecture qui donne un fichier « prêt à livrer ».
## Testez-le vous-même (5 minutes)
Prenez votre fichier le plus sale — fusions, formules, colonnes formatées :
1. Traduisez-le avec l'outil que vous utilisez habituellement
2. Ouvrez la **barre de formule** d'une cellule qui en contenait une
3. Vérifiez une fusion de cellules
4. Regardez un pourcentage
Si vous avez dû « refaire » le fichier, vous venez de payer la traduction *deux fois* : une fois à la machine, une fois à vous.
## Ce que ça change concrètement
| | Pipeline standard | Traduction en place |
|---|---|---|
| Formules | Morte (valeur figée) | Intacte |
| Fusions | Perdues/décalées | Intactes |
| Styles | Perdus | Intacts |
| Temps total | Traduction + réfection | Traduction seule |
| Fichier livrable | Non | Oui |
## Conclusion
Le problème n'est pas la qualité de la traduction — c'est la **réintégration**. DeepL et Google excellent sur la première ; personne ne fait correctement la seconde, sauf les outils spécialisés.
Si vos documents comptent (fusions, formules, slides), testez un outil qui travaille *dans* la structure du fichier. Le plan Free d'Office Translator (2 docs/mois, sans carte) est fait pour ça : mettez-lui votre fichier le plus sale, et comparez.
---
**Métadonnées SEO**
- Title : « Pourquoi DeepL et Google cassent vos Excel (et comment faire mieux) »
- Meta description : « Formules mortes, fusions perdues : ce que les traducteurs « standard » font à vos fichiers Excel, et l'architecture qui préserve 100 % du format. »
- Mots-clés : deepl excel, google translate excel, excel traduit mise en forme perdue
- URL : /blog/pourquoi-deepl-google-cassent-vos-excel

View File

@@ -0,0 +1,165 @@
# Auto-héberger son outil de traduction de documents : guide Docker complet
> Article SEO « self-hosted » — brouillon complet. Capture l'audience développeurs/homelab. Basé sur le déploiement réel du projet (Docker Compose, Postgres, Redis).
## Introduction
Un outil de traduction de documents posé sur un SaaS pose toujours la même question : **où vont mes fichiers ?** Pour les documents confidentiels (compta, juridique, RH, brevets), l'auto-hébergement est la seule réponse qui tienne.
Ce guide montre comment monter une pile complète de traduction de documents — API, base, cache, interface — sur votre propre infrastructure, avec Docker. (Le projet dont on s'inspire est open source : Office Translator / Wordly.art.)
## Architecture cible
```
[Client web / API]
[Nginx (reverse proxy, TLS)]
[FastAPI (Python 3.11+, port 8000)]
├── [PostgreSQL] ← utilisateurs, glossaires, quotas
├── [Redis] ← rate limiting (token bucket), files
└── [Fichiers] ← uploads / outputs (volume local)
[Next.js (port 3000)] ← interface
```
Points clés :
- **FastAPI** asynchrone pour l'API de traduction (Swagger sur `/docs`)
- **PostgreSQL** pour les données structurées, **Redis** pour le rate limiting par IP et les files
- **Nginx** devant pour le TLS et le reverse proxy
- Volumes persistants pour les fichiers (suppression automatique au TTL, ex. 60 min)
## 1. Prérequis
- Une machine Linux (VPS, serveur physique, ou même un NAS)
- Docker + Docker Compose installés
- Un domaine pointant vers la machine (pour le TLS)
- Les clés API de vos moteurs de traduction (au minimum Google ; DeepL, OpenRouter, OpenAI, x.ai/Zai selon vos besoins)
## 2. Déploiement
### 2.1 Cloner et configurer
```bash
git clone <URL_DU_DÉPÔT> /opt/wordly # dépôt à adapter (miroir public recommandé)
cd /opt/wordly
cp .env.example .env
```
Dans `.env`, renseignez au minimum :
```ini
# Moteurs — le Google « classic » (gratuit) ne demande AUCUNE clé.
# Clés optionnelles pour les moteurs payants :
GOOGLE_CLOUD_API_KEY=...
# ou DEEPL_API_KEY, OPENROUTER_API_KEY, OPENAI_API_KEY...
# Optionnel : OCR Mistral pour les PDF scannés
# MISTRAL_API_KEY=...
# Sécurité
SECRET_KEY=<générer une clé longue et aléatoire>
CORS_ORIGINS=https://wordly.example
APP_ENV=production
```
> **Sécurité** : `CORS_ORIGINS` ne doit jamais être `*` en production — l'application le refuse et refuse de démarrer. Générez `SECRET_KEY` avec `openssl rand -hex 32`.
### 2.2 Lancer la pile
```bash
docker compose up -d --build
```
Ça démarre :
- L'API sur `:8001` côté hôte (mappée sur le 8000 du conteneur ; docs Swagger sur `http://localhost:8001/docs`)
- L'interface sur `:3000`
- Postgres + Redis en interne
### 2.3 Mettre Nginx devant (TLS)
```nginx
server {
listen 443 ssl http2;
server_name wordly.example;
ssl_certificate /etc/letsencrypt/live/wordly.example/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/wordly.example/privkey.pem;
# Headers de sécurité (HSTS, CSP)
add_header Strict-Transport-Security "max-age=63072000" always;
add_header X-Content-Type-Options nosniff always;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/ {
proxy_pass http://127.0.0.1:8001;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
Renouvelez le certificat avec `certbot renew` (cron).
## 3. Exploitation
### Surveillance
- `GET /health` — état de l'API, des moteurs, de la base et du cache
- Prometheus + Grafana (fournis en `docker-compose.monitoring.yml`) pour les métriques système et applicatives
### Sauvegarde (non négociable)
La base Postgres et le répertoire de fichiers sont votre patrimoine. Deux couches :
1. **Sauvegarde quotidienne de la base** :
```bash
docker compose exec db sh -c 'pg_dump -U ${POSTGRES_USER:-translate} ${POSTGRES_DB:-translate_db}' | gzip > /backups/wordly-$(date +%F).sql.gz
```
2. **Réplication vers un NAS/autre site** via `rsync` + SSH (le projet fournit un plan de sauvegarde automatique et une procédure de restauration en ~20 min, y compris bascule sur un serveur de secours)
Règle d'or : **une sauvegarde qu'on n'a jamais restaurée est une sauvegarde qui n'existe pas**. Testez la restauration au moins une fois par trimestre.
### Nettoyage automatique
Les fichiers uploadés sont supprimés automatiquement après leur TTL (60 min par défaut). Le nettoyage est orchestré côté application ; surveillez le disque quand même (un gros fichier en attente peut saturer le volume).
## 4. Durcissement
- [ ] `CORS_ORIGINS` restreint à vos domaines
- [ ] `SECRET_KEY` unique et stockée en variable d'environnement (jamais dans le repo)
- [ ] Pas de port Postgres/Redis exposé publiquement (réseau Docker interne uniquement)
- [ ] Rate limiting actif (token bucket dans Redis, par IP client)
- [ ] Fail2ban sur SSH si l'accès serveur passe par SSH
- [ ] Mises à jour de Docker + des images (vulnérabilités)
- [ ] Monitoring des disques, CPU et mémoire (Grafana)
## 5. Coût vs SaaS
| Poste | Self-hosted (VPS 4 vCPU / 8 Go) | SaaS |
|---|---|---|
| Infrastructure | ~4080 €/mois | Inclus |
| Coût par page | Coût des API uniquement | Coût API + marge |
| Maîtrise des données | Totale | Partagée |
| Maintenance | À votre charge | À charge de l'éditeur |
| Mises à jour / sécurité | À votre charge | À charge de l'éditeur |
Le self-hosted gagne quand **les données sont sensibles** ou quand le **volume est énorme**. Il perd quand vous n'avez personne pour faire la maintenance — dans ce cas, le SaaS est souvent plus fiable *en pratique*, même si les fichiers transigent avec un tiers.
## Conclusion
Auto-héberger une pile de traduction de documents, c'est 1 h de déploiement et une vraie responsabilité d'exploitation. Avec Docker Compose, Postgres, Redis et Nginx, l'architecture tient sur une seule machine ; ce qui coûte ensuite, c'est la discipline (sauvegardes, durcissement, mises à jour).
Si les documents sont confidentiels, cette discipline se paie d'elle-même.
---
**Métadonnées SEO**
- Title : « Auto-héberger un outil de traduction de documents : guide Docker 2026 »
- Meta description : « FastAPI, Postgres, Redis, Nginx : déployez une pile de traduction de documents sur votre propre infra. Sauvegardes, durcissement, coût vs SaaS. »
- Mots-clés : auto héberger traduction, docker traduction documents, self hosted translation
- URL : /blog/auto-heberger-traduction-documents-docker

72
docs/marketing/kpis.md Normal file
View File

@@ -0,0 +1,72 @@
# Suivi des KPIs — Définitions & Cadence
> Document de mesure. Source de vérité pour le reporting marketing.
> Mis à jour : 2026-08-29
## 1. Stack de mesure (déployée)
| Outil | Rôle | Où |
|---|---|---|
| **Vercel Analytics** | Pages, événements, referrers, heatmaps | `office-translator-landing-page/app/layout.tsx` |
| **Événements custom** | Funnels précis (voir §2) | `track(...)` dans les composants |
| **Endpoint waitlist** | Source de vérité des e-mails | `GET /api/v1/waitlist/count` |
| **BDD utilisateurs** | Inscriptions, plans, quotas | Backend (Postgres) |
| **Journal de jobs** | Documents traduits, temps de traitement | Backend |
| **Stripe** | MRR, abonnements, churn | Portail Stripe |
| **`/health` + Grafana** | Performance système/applicative | Backend |
## 2. Événements trackés (Vercel Analytics)
| Événement | Propriétés | Déclenché par | Funnel |
|---|---|---|---|
| `translation_started` | `engine`, `source`, `target` | Bouton « Translate Document » | Activation |
| `pricing_cta_click` | `plan` | CTA d'un plan | Considération |
| `waitlist_joined` | `interest` | Inscription waitlist réussie | Capture |
| `waitlist_error` | `status` | Inscription waitlist en échec | Capture (santé) |
**Pages clés à surveiller** : `/` (hero, pricing, faq, waitlist), `/dashboard` (activation), `/admin` (ops).
## 3. Définitions des KPIs
| KPI | Définition | Source | Fréquence |
|---|---|---|---|
| Visiteurs uniques | Sessions sur `/` (30 j glissants) | Vercel Analytics | Hebdo |
| Taux d'activation | `translation_started` / visiteurs uniques | Vercel Analytics | Hebdo |
| E-mails waitlist | `count` (dédoublonné) | `/api/v1/waitlist/count` | Quotidien |
| Taux de capture | `waitlist_joined` / visiteurs uniques | Vercel Analytics | Hebdo |
| Inscriptions | Nouveaux comptes (tous plans) | BDD utilisateurs | Hebdo |
| Documents traduits | Jobs terminés | Journal de jobs | Hebdo |
| Taux de conversion payant | Abonnements payants / inscriptions | Stripe + BDD | Mensuel |
| MRR | Revenu mensuel récurrent | Stripe | Mensuel |
| Churn mensuel | Abonnements perdus / début de période | Stripe | Mensuel |
| CAC (Ads) | Dépense Ads / nouveaux payants (30 j) | Google Ads + Stripe | Mensuel |
| NPS | Enquête post-traduction (010) | Enquête | Mensuel |
| Disponibilité | `uptime` de `/health` | Grafana | Continu |
## 4. Cibles (alignées sur MARKETING_PLAN.md §6)
| KPI | M1 | M3 |
|---|---|---|
| Visiteurs uniques | 5 000 | 25 000 |
| E-mails waitlist | 150 | 1 500 |
| Inscriptions | 200 | 1 500 |
| Documents traduits | 500 | 5 000 |
| Conversion visite→inscription | 2 % | 4 % |
| NPS | > 40 | > 50 |
| MRR | — | ~300 abonnements payants |
| CAC (Ads) | — | < 3× marge mensuelle (plan d'entrée) |
## 5. Rituels
- **Hebdo (lun, 30 min)** : tableau de bord Vercel + compteur waitlist + inscriptions. Anomalie > 2 σ = ticket.
- **Mensuel (J+2, 1 h)** : MRR, churn, CAC, NPS. Mise à jour de ce doc + de `MARKETING_PLAN.md` si écart > 20 % vs cible.
- **Sprint (2 sem.)** : A/B sur le pricing et le hero ; décision sur Google Ads (maintien/stop) à la semaine 5.
## 6. Alertes
| Condition | Action |
|---|---|
| `waitlist_error` > 5 % des tentatives | Investiguer `POST /api/v1/waitlist` (CORS, prod) |
| Disponibilité `/health` < 99,5 % sur 24 h | Pager ops, consulter `DISASTER_RECOVERY.md` |
| Churn > 10 % / mois | Analyse des raisons de sortie, offre de rétention |
| CAC > 3× marge (Ads) | Stopper la campagne, revenir au SEO/partenariats |

View File

@@ -0,0 +1,61 @@
# Spec de Production des Assets Visuels
> Checklist de production des assets marketing. Priorités : P0 = bloquant pour le lancement, P1 = semaine 1, P2 = semaine 2.
## P0 — Bloquant pour le lancement
### 1. Captures d'écran (8) — PNG, 1280×720 minimum, fond clair + sombre
| # | Capture | Usage | Source |
|---|---|---|---|
| 1 | Page d'accueil / hero (drop-zone) | Landing, réseaux | `office-translator-landing-page` |
| 2 | Upload en cours (fichier chargé, langues sélectionnées) | Démo du workflow | Idem |
| 3 | **Résultat côte à côte** (original vs traduit, format intact) | Preuve qualité | Backend + dashboard |
| 4 | Sélecteur de moteur (Google/DeepL/LLM) | Fonctionnalité clé | Idem |
| 5 | Interface glossaire | Différenciation | Dashboard |
| 6 | Dashboard (statistiques, quota) | Crédibilité | `/dashboard` |
| 7 | Page pricing (4 plans) | Conversion | Landing `#pricing` |
| 8 | Profil / historique | Confiance | Dashboard |
**Outil recommandé** : Playwright (script de capture automatisée pour les états dynamiques : upload en cours, progression de traduction).
### 2. Vidéo démo 6090 s (« How it works ») — MP4 1080p, sous-titres FR + EN
Script :
1. (05 s) Logo + tagline animé
2. (515 s) Problème : « Traduire un Excel de 50 pages sans casser le format ? »
3. (1540 s) Démo accélérée : upload → langues → moteur → traduction → téléchargement
4. (4055 s) Split-screen original/traduit, zoom sur tableaux et images intacts
5. (5565 s) CTA « Essayez gratuitement » + URL
### 3. OG Image — 1200×630 px
Logo + tagline + capture résultat côte à côte. Utilisée pour X, LinkedIn, Product Hunt, partages.
### 4. GIF workflow 15 s
Boucle upload → traduction → téléchargement (extraire de la vidéo démo). Pour le thread X et Product Hunt.
## P1 — Semaine 1
### 5. Logo
SVG version claire + sombre, icône seule + avec texte « Office Translator » / « Wordly.art ».
### 6. Favicon
32×32 et 16×16, ICO + PNG.
### 7. Bannière GitHub
1280×640 px pour le README du repo.
### 8. Infographie « Pourquoi Office Translator »
Comparatif avant/après (pipeline standard vs en place), 1 colonne, partageable.
### 9. Tutoriel YouTube 35 min
Screencast + voiceover : création de compte, upload, glossaires, moteurs, téléchargement.
## P2 — Semaine 2
### 10. Templates e-mails (5)
Bienvenue, « votre premier document », éducation glossaire, offre d'upgrade, réactivation.
### 11. Kit réseaux sociaux
12 visuels (4 avant/après, 4 astuces, 4 mises à jour) au format 1080×1350 (LinkedIn) + 1600×900 (X).
## Validation
Chaque asset P0 est validé par : (a) exactitude technique (pas de capture d'un état impossible), (b) lisibilité au format cible, (c) cohérence de la charte (couleurs `--accent` oklch(0.555 0.17 250), police Geist).

View File

@@ -0,0 +1,49 @@
# Product Hunt — Listing & Maker Comment
> Statut : prêt à publier (Phase 2, J0). À remplir avec les assets réels avant le lancement.
## Tagline
**Translate your Excel, Word, PowerPoint & PDF — keep the format perfect.**
## Nom affiché
Office Translator (by Wordly.art)
## Description courte (160 car.)
Translate office documents without losing the layout. Merged cells, formulas, tables, slides — translated in place, by the engine you choose.
## Description longue
Google and DeepL flatten your files into plain text. We don't.
Office Translator translates Excel, Word, PowerPoint **and PDF** **in place**: merged cells, formulas, fonts, borders, headers, footers, tables and slide layouts survive intact. Even scanned PDFs are handled via OCR (Mistral), and text inside images is translated via vision models.
Pick the engine per document — 7 providers:
- **Google** (free, fast)
- **DeepL** (best for business text)
- **Google Cloud**
- **LLM engines**: OpenRouter (eco & premium tiers: DeepSeek, Gemini, Claude), OpenAI, Grok by xAI — for complex, context-aware translations
Plus:
- **Custom glossaries** — lock your HVAC, legal or medical terminology across every document
- **60+ languages**
- **Private by design** — uploads deleted after 30 min, results auto-deleted within 2 h, never used to train models
- **API** on the Business plan (10,000 calls/mo) for your own tooling
Pricing: Free (2 docs/mo) · Starter €9 · Pro €19 · Business €49 · Enterprise custom. Yearly = 20%.
## Maker comment (à publier par le fondateur, 9 h EST)
> We built Office Translator because every other tool broke our layouts.
>
> The hard part wasn't translation — it was **re-integrating** the text. Our pipeline parses the document structure (cells, runs, shapes), extracts only translatable content, translates it (your choice of 7 engines), and writes it back into the **same** XML structure. Formulas, merges, fonts, borders: untouched.
>
> What I'd love your feedback on:
> 1. Which engine do you trust most for business documents — DeepL or an LLM?
> 2. Would you use the API (Business plan) to wire it into your own workflow?
> 3. PDF (incl. scanned, via OCR) is already in. Which format should we tackle next — IDML, CSV, something else?
>
> Free plan = 2 full documents/month, no card. Link in comments.
## Checklist de publication
- [ ] Assets: logo (1200×630 OG + 600×400), 3 screenshots (résultat côte à côte, sélecteur moteur, glossaire)
- [ ] 5 commentaires amis planifiés (réponses de qualité, pas de upvote-only)
- [ ] E-mail aux 150+ contacts de la waitlist (J-2) : « On lance demain, votez + testez »
- [ ] Thread X croisé au lancement

View File

@@ -0,0 +1,65 @@
# Reddit — 3 Posts de Lancement
> Statut : prêts à publier (Phase 2). Adapter le ton par subreddit. Pas de lien brut dans le 1er post sur r/SideProject (shadowban) — lien en commentaire.
---
## 1. r/SideProject
**Titre :** I built a tool that translates Excel/Word/PowerPoint without destroying the formatting. Free plan = 2 docs/mo.
**Corps :**
> Hi all — I've been building **Office Translator** for a while and I'm launching it this week.
>
> **The problem:** every translation tool I tried (Google, DeepL) flattens your file into plain text. Merged cells gone, formulas replaced by their computed values, slide layouts destroyed. You end up spending more time fixing the format than the translation saved you.
>
> **What I did instead:** the pipeline parses the document structure, extracts only the translatable content, translates it, and writes it back into the **same** structure. Merges, formulas, fonts, borders, headers/footers, table styles — untouched. Text inside images is handled by vision models.
>
> **Tech:** FastAPI (async), openpyxl / python-docx / python-pptx, 7 translation engines the user picks per document (Google, DeepL, Google Cloud, OpenRouter éco & premium, OpenAI, Grok/xAI), Stripe billing, Docker/Postgres/Redis.
>
> **Pricing:** Free (2 docs/mo, no card) → Starter €9 → Pro €19 → Business €49 (API included).
>
> I'd love feedback from anyone who deals with international documents. What's the nastiest format you've tried to translate?
>
> (Link in comments to avoid the filter.)
**Commentaire n°1 (lien) :**
> Here's the landing page: [URL]. Free plan, no card. The /docs page has the full API reference if you want to poke at the API.
---
## 2. r/translator
**Titre :** We built a document translator that preserves formatting — curious how you'd use it (or wouldn't)
**Corps :**
> Hi, I'm the dev behind Office Translator (Wordly.art). Before I pitch anything: we know translators are the ones who actually feel the pain when a machine mangles a layout, so I'd rather ask than sell.
>
> The tool translates .xlsx / .docx / .pptx **in place** — structure, formulas, tables and styles are preserved — and supports **custom glossaries**, which is the feature I'd value your opinion on most:
>
> 1. Is glossary-based consistency (e.g. a fixed HVAC or legal term list) actually useful to you, or do you already solve this differently?
> 2. For which document types is the format preservation worth paying for, vs. where a human review still beats any tool?
> 3. What would make you trust an output enough to skip a full re-read? (confidence scores? diff view? per-segment override?)
>
> It's aimed at agencies and in-house teams as a **first pass** to cut turnaround — not to replace the translator. I'd genuinely like to hear what would make it a tool you'd recommend to a colleague.
> ⚠️ Ce subreddit est hostile au self-promo : publier en tant que dev transparent, répondre à chaque commentaire, jamais de lien dans le post.
---
## 3. r/smallbusiness
**Titre :** How do you translate client documents (Excel/Word/PowerPoint) without losing the formatting? We built a tool for this.
**Corps :**
> Running a business that works internationally means a lot of documents that need translating — pricing sheets, contracts, slide decks, training files.
>
> The usual options all hurt somewhere:
> - **Human translators:** great quality, 50100× the cost and a multi-day turnaround
> - **Google/DeepL:** cheap and fast, but they flatten the file — merged cells, formulas, slide layouts get destroyed, so someone has to rebuild the document
>
> We built **Office Translator** to kill that middle step: it translates the document **in place** and gives you back a file that looks like it was written in the target language from the start. You pick the engine (7 options, from free Google to premium LLMs), and you can lock your company terminology with a custom glossary so "unité de froid" is never suddenly "cold unit".
>
> Free plan: 2 documents/month, no card. From €9/mo after that. Uploads are deleted after 30 minutes and results within 2 hours.
>
> If you handle international documents, what's your current workflow and where does it break? Curious what I'm missing.

View File

@@ -0,0 +1,31 @@
# Hacker News — Show HN
> Statut : prêt à publier (Phase 2, J0 matin EST). Soumettre via « Show HN » — pas de self-upvote.
## Titre
**Show HN: Office document translation that preserves the formatting**
## Corps
> I built **Office Translator** (Wordly.art) because every document translator I used — Google, DeepL, the one-shot tools — destroyed the layout. Translating a 50-page Excel pricing matrix meant getting back a text dump: merged cells gone, formulas replaced by computed values, slide decks unrecognizable. The translation was 5% of the job; fixing the format was 95%.
>
> So I made the format-preservation the product:
>
> - **In-place translation** for .xlsx / .docx / .pptx / .pdf. The pipeline parses the native structure (cells + merges + formulas, paragraph runs + styles, slide XML + shapes), extracts only translatable content, translates it, and writes it back into the *same* structure. Output looks like it was authored in the target language.
> - **Scanned PDFs work too**: image-only pages are recovered with OCR (Mistral) before translation — most competitors (DeepL, Azure) reject those outright.
> - **7 engines, user's choice per document**: Google (free), DeepL, Google Cloud, OpenRouter LLMs in two tiers (DeepSeek / Gemini / Claude), OpenAI, and Grok (xAI). Cheap engine for drafts, premium LLM for client deliverables.
> - **Custom glossaries** to lock technical/legal/medical terminology across documents.
> - **Vision translation** for text inside images (paid plans).
> - **Privacy**: uploads deleted after 30 min, results auto-deleted within 2 hours, zero data retention, content never used for training.
> - **API** on the Business plan (10k calls/mo) — the web workflow is fully mirrored: submit file, poll job, download.
>
> Stack: FastAPI (Python 3.11), openpyxl / python-docx / python-pptx, PyMuPDF, Postgres + Redis, Stripe, Docker, Next.js 15 frontend.
>
> Free plan: 2 documents/month, no card. I'd love feedback from people who've worked on document-format-preserving pipelines — what edge cases do I not handle yet? (PDF incl. scanned/OCR is already in — next candidates are IDML and CSV.)
>
> https://wordly.art
## Notes de publication
- Soumettre à 89 h EST, mardijeudi
- Répondre à chaque commentaire dans les 2 h ; rester technique, pas commercial
- Pas de « upvote my post » — c'est interdit sur HN
- Préparer 23 réponses techniques de poches : formule Excel + localisation (format de nombre), fusion de cellules + traduction, gestion des runs mixtes (gras/italique au milieu d'un paragraphe)

View File

@@ -0,0 +1,102 @@
# X / Twitter — Thread de Lancement (12 tweets)
> Statut : prêt à publier (Phase 2, J0). Publier en thread (Reply chain), pas en post unique.
> Assets requis : GIF 15 s du workflow (voir assets-spec.md), capture résultat côte à côte.
**T1 (accroche + GIF)**
Your Excel got translated.
Now fix the 47 broken merged cells, 12 dead formulas and the slide deck that lost its layout.
That's what "free" translation tools actually cost.
We built the opposite. Thread:
**T2 (le problème)**
Every translator you've used works the same way:
1. Flatten your document to plain text
2. Translate the text
3. Hope it fits back
The "hope" step is where your afternoon goes.
**T3 (notre approche)**
Office Translator works in place:
• Parse the real structure (cells, runs, shapes)
• Extract ONLY the translatable content
• Translate it
• Write it back into the SAME structure
Merges, formulas, fonts, borders: untouched.
**T4 (preuve)**
[Capture d'écran : résultat côte à côte — original FR vs traduit EN, mêmes colonnes/fusions/formules]
Same file. Different language. Zero reformatting.
**T5 (multi-moteurs)**
You pick the engine per document — 7 options:
• Google — free, fast
• DeepL — best for business text
• Google Cloud
• DeepSeek / Gemini / Claude via OpenRouter — eco & premium LLM tiers
• OpenAI, Grok (xAI)
Cheap for drafts. Premium LLM for the client deliverable.
**T6 (glossaires)**
The feature agencies love: custom glossaries.
Lock "unité de froid → chiller", "clause de résiliation → termination clause" once. Every document after that uses your terminology. Consistent, every time.
**T7 (confidentialité)**
Your documents are not our training data.
• Uploads deleted after 30 min, results within 2 h
• Zero data retention
• Encrypted in transit (TLS)
• No human ever sees your content
**T8 (API)**
Wiring it into your own stack? Business plan includes API access — 10,000 calls/month.
Submit file → poll job → download. Same workflow as the web app. Docs at /docs.
**T9 (prix)**
Pricing (yearly = 20%):
• Free — 2 docs/mo, no card
• Starter — €9
• Pro — €19 (AI translation, glossaries)
• Business — €49 (API, 5 seats)
• Enterprise — custom
A page translated costs a fraction of a cent. A human does the same page for €12.
**T10 (pour qui)**
Built for:
• International SMEs (pricing sheets, contracts, manuals)
• Translation agencies (first-pass + glossaries = faster turnaround)
• Multilingual HR teams
• Anyone who's ever re-built a translated Excel by hand
**T11 (preuve sociale + CTA)**
60+ languages. 7 engines. 100% format preservation.
Free plan = 2 full documents/month, no card required. Test it on your ugliest file.
**T12 (CTA final)**
👉 wordly.art
Launching on Product Hunt today — feedback welcome.
What's the nastiest document format you've ever tried to translate? Reply below, we read everything.
---
## Plan de publication
- J-2 : teaser T1 seul (sans lien)
- J0 : thread complet, 9 h
- J+1 : republier T4 (preuve) + répondre à tous les retweets
- Communautés à taguer : #BuildInPublic #SaaS #i18n #DevTools (max 2 hashtags)

View File

@@ -17,14 +17,12 @@ interface ProviderBreakdownChartProps {
const providerLabels: Record<string, string> = {
google: "Google Translate",
deepl: "DeepL",
ollama: "Ollama (Local)",
openai: "OpenAI",
};
const providerColors: Record<string, string> = {
google: "bg-blue-500",
deepl: "bg-indigo-500",
ollama: "bg-green-500",
openai: "bg-purple-500",
};

View File

@@ -16,7 +16,6 @@ interface ProviderStatusProps {
const PROVIDER_LABELS: Record<string, string> = {
google: "Google Translate",
google_cloud: "Google Cloud",
deepl: "DeepL",
ollama: "Ollama (Local)",
openai: "OpenAI",
openrouter: "IA Essentielle",
@@ -24,6 +23,7 @@ const PROVIDER_LABELS: Record<string, string> = {
deepseek: "IA Express",
minimax: "IA Avancée",
zai: "Grok (xAI)",
mistral_ocr: "OCR Mistral (PDF scannés)",
};
const STATUS_CONFIG = {
@@ -132,6 +132,11 @@ export function ProviderStatus({ data, isLoading }: ProviderStatusProps) {
{new Date(provider.last_check).toLocaleTimeString()}
</span>
)}
{provider.config_only && (
<span className="text-muted-foreground">
Statut dérivé de la configuration (pas d'appel réseau)
</span>
)}
{provider.error && (
<span className="text-red-500">{provider.error}</span>
)}

View File

@@ -35,6 +35,8 @@ interface PricingData {
}
interface StripeStatus {
configured: boolean;
mode: "live" | "test" | "unknown";
is_test_mode_in_production: boolean;
has_secret_key: boolean;
has_publishable_key: boolean;
has_webhook_secret: boolean;
@@ -103,6 +105,9 @@ export default function AdminPricingPage() {
const [saving, setSaving] = useState(false);
const [setupLoading, setSetupLoading] = useState(false);
const [setupResult, setSetupResult] = useState<any>(null);
const [webhookLoading, setWebhookLoading] = useState(false);
const [webhookResult, setWebhookResult] = useState<any>(null);
const [envMode, setEnvMode] = useState<string>("development");
const [toast, setToast] = useState<{ type: "ok" | "err"; text: string } | null>(null);
const getToken = () => useTranslationStore.getState().settings.adminToken ?? "";
@@ -130,6 +135,7 @@ export default function AdminPricingPage() {
setPricing(d);
}
if (j.stripe) setStripeStatus(j.stripe);
if (j.meta?.env) setEnvMode(j.meta.env);
} else if (res.status === 401) {
showToast("err", "Session admin expirée. Reconnectez-vous.");
} else {
@@ -263,6 +269,33 @@ export default function AdminPricingPage() {
showToast("err", j.errors?.[0]?.error || j.message || "Échec de la configuration Stripe.");
}
loadPricing();
} else if (res.status === 409 && j.error === "TEST_MODE_IN_PRODUCTION") {
// Give the user a way to override
const ok = window.confirm(
`${j.message}\n\nForcer la création en mode test ? (utile pour staging, à éviter en prod)`,
);
if (!ok) {
setSetupResult(j);
setSetupLoading(false);
return;
}
const retry = await fetch(`${API_BASE}/api/v1/admin/pricing/setup-stripe`, {
method: "POST",
cache: "no-store",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ force: true }),
});
const rj = await retry.json();
setSetupResult(rj);
if (retry.ok) {
showToast("ok", "✅ Produits créés (forcé).");
loadPricing();
} else {
showToast("err", rj.message || "Échec forcé.");
}
} else {
showToast("err", j.message || "Erreur lors de la configuration Stripe.");
setSetupResult(j);
@@ -274,6 +307,69 @@ export default function AdminPricingPage() {
}
};
const autoSetupWebhook = async () => {
const token = useTranslationStore.getState().settings.adminToken;
if (!token) {
showToast("err", "Session admin absente. Reconnectez-vous.");
return;
}
setWebhookLoading(true);
setWebhookResult(null);
try {
const res = await fetch(`${API_BASE}/api/v1/admin/pricing/setup-webhook`, {
method: "POST",
cache: "no-store",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
const j = await res.json();
if (res.ok) {
setWebhookResult(j);
if (j.data?.secret_saved) {
showToast("ok", "✅ Webhook Stripe créé/mis à jour et signing secret enregistré.");
} else {
showToast("ok", "✅ Webhook Stripe mis à jour. (Secret inchangé — il existait déjà.)");
}
loadPricing();
} else if (res.status === 409 && j.error === "TEST_MODE_IN_PRODUCTION") {
const ok = window.confirm(
`${j.message}\n\nForcer quand même ?`,
);
if (!ok) {
setWebhookResult(j);
setWebhookLoading(false);
return;
}
const retry = await fetch(`${API_BASE}/api/v1/admin/pricing/setup-webhook`, {
method: "POST",
cache: "no-store",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ force: true }),
});
const rj = await retry.json();
setWebhookResult(rj);
if (retry.ok) {
showToast("ok", "✅ Webhook créé (forcé).");
loadPricing();
} else {
showToast("err", rj.message || "Échec forcé.");
}
} else {
showToast("err", j.message || "Erreur lors de la création du webhook.");
setWebhookResult(j);
}
} catch {
showToast("err", "Erreur réseau.");
} finally {
setWebhookLoading(false);
}
};
const updatePlan = (planId: keyof PricingData, field: keyof PlanConfig, value: string | number) => {
setPricing((prev) => {
const next = { ...prev[planId], [field]: value } as PlanConfig;
@@ -328,9 +424,48 @@ export default function AdminPricingPage() {
<CardTitle className="text-base flex items-center gap-2">
<Sparkles className="w-4 h-4 text-violet-400" />
Statut Stripe
{stripeStatus?.mode === "live" && (
<Badge className="ml-2 bg-emerald-600/20 text-emerald-300 border-emerald-500/40 text-[10px]">
LIVE
</Badge>
)}
{stripeStatus?.mode === "test" && (
<Badge className="ml-2 bg-amber-600/20 text-amber-300 border-amber-500/40 text-[10px]">
TEST
</Badge>
)}
{stripeStatus?.mode === "unknown" && (
<Badge variant="outline" className="ml-2 text-muted-foreground text-[10px]">
non configuré
</Badge>
)}
<span className="ml-auto text-xs text-muted-foreground font-mono">
ENV={envMode}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* BLOCKING warning if test mode in production */}
{stripeStatus?.is_test_mode_in_production && (
<div className="flex items-start gap-3 p-4 rounded-xl border-2 border-red-500/50 bg-red-900/20 text-sm">
<AlertTriangle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-red-300 font-bold">
CLÉ STRIPE TEST EN PRODUCTION
</p>
<p className="text-red-400/90 text-xs mt-1 leading-relaxed">
<code className="bg-red-950/50 px-1.5 py-0.5 rounded text-red-200">ENV=production</code>{" "}
mais{" "}
<code className="bg-red-950/50 px-1.5 py-0.5 rounded text-red-200">STRIPE_SECRET_KEY</code>{" "}
commence par <code className="bg-red-950/50 px-1.5 py-0.5 rounded text-red-200">sk_test_</code>.
Les paiements en prod ne fonctionneront PAS soit tu remplaces par{" "}
<code className="bg-red-950/50 px-1.5 py-0.5 rounded text-red-200">sk_live_</code> dans
.env.ionos, soit tu remets <code className="bg-red-950/50 px-1.5 py-0.5 rounded text-red-200">ENV=staging</code>.
</p>
</div>
</div>
)}
<div className="grid grid-cols-3 gap-3">
{[
{ label: "Clé secrète", ok: stripeStatus?.has_secret_key },
@@ -350,10 +485,10 @@ export default function AdminPricingPage() {
<Separator />
{/* Auto-setup button */}
{/* Auto-setup button (products + prices) */}
<div className="flex items-start gap-4">
<div className="flex-1">
<p className="text-sm font-medium text-foreground">Configuration automatique Stripe</p>
<p className="text-sm font-medium text-foreground">1. Produits &amp; prix</p>
<p className="text-xs text-muted-foreground mt-1">
Crée automatiquement les 3 produits (Starter, Pro, Business) et les 6 prix (mensuel + annuel) dans votre compte Stripe, puis sauvegarde les Price IDs dans le .env.
</p>
@@ -387,6 +522,45 @@ export default function AdminPricingPage() {
<Separator />
{/* Auto-setup webhook */}
<div className="flex items-start gap-4">
<div className="flex-1">
<p className="text-sm font-medium text-foreground">2. Webhook Stripe</p>
<p className="text-xs text-muted-foreground mt-1">
Crée ou met à jour l'endpoint webhook dans Stripe pointant sur ce backend, et enregistre automatiquement le <code className="text-foreground/80">whsec_…</code> dans le .env.
L'URL est déduite de la requête courante (header Host + X-Forwarded-Proto).
</p>
{webhookResult?.data && (
<div className="mt-2 p-3 rounded-lg bg-secondary/40 text-xs space-y-1">
<div className="flex items-center gap-2">
<CheckCircle className="w-3 h-3 text-emerald-400" />
<span>ID: <code>{webhookResult.data.id}</code></span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<ExternalLink className="w-3 h-3" />
<span className="break-all">{webhookResult.data.url}</span>
</div>
<div className="text-muted-foreground">
Events: <code>{(webhookResult.data.enabled_events || []).length}</code> abonné(s)
{webhookResult.data.created ? " — endpoint créé" : " — endpoint mis à jour"}
{webhookResult.data.secret_saved ? " — secret enregistré" : " — secret déjà connu"}
</div>
</div>
)}
</div>
<Button
onClick={autoSetupWebhook}
disabled={webhookLoading || !stripeStatus?.has_secret_key}
variant="outline"
className="gap-2 flex-shrink-0 border-violet-500/40 text-violet-300 hover:bg-violet-500/10"
>
{webhookLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
{webhookLoading ? "Configuration…" : "Setup webhook auto"}
</Button>
</div>
<Separator />
{/* Manual Stripe keys */}
<div>
<p className="text-sm font-medium text-foreground mb-3">Clés Stripe (laisser vide pour garder les valeurs actuelles)</p>

View File

@@ -36,12 +36,12 @@ interface SmtpConfig {
interface SettingsConfig {
google: ProviderConfig;
google_cloud: ProviderConfig;
deepl: ProviderConfig;
openai: ProviderConfig;
ollama: ProviderConfig;
openrouter: ProviderConfig;
openrouter_premium: ProviderConfig;
zai: ProviderConfig;
mistral: ProviderConfig;
smtp: SmtpConfig;
fallback_chain: string;
fallback_chain_classic: string;
@@ -49,11 +49,11 @@ interface SettingsConfig {
}
interface EnvInfo {
deepl: boolean;
openai: boolean;
openrouter: boolean;
openrouter_premium: boolean;
zai: boolean;
mistral: boolean;
ollama: boolean;
google_cloud: boolean;
smtp: boolean;
@@ -68,24 +68,24 @@ interface OllamaModel {
const defaultConfig: SettingsConfig = {
google: { enabled: true, timeout: 30, max_retries: 3 },
google_cloud: { enabled: false, api_key: "", timeout: 30, max_retries: 3 },
deepl: { enabled: false, api_key: "", timeout: 30, max_retries: 3 },
openai: { enabled: false, api_key: "", timeout: 60, max_retries: 3 },
ollama: { enabled: false, base_url: "http://localhost:11434", model: "llama3" },
openrouter: { enabled: false, api_key: "", model: "deepseek/deepseek-chat" },
openrouter_premium: { enabled: false, api_key: "", model: "openai/gpt-4o-mini" },
zai: { enabled: false, api_key: "", base_url: "https://api.x.ai/v1", model: "grok-2-1212" },
mistral: { enabled: false, api_key: "", model: "mistral-ocr-latest", timeout: 180 },
smtp: { enabled: false, host: "", port: 587, username: "", password: "", from_email: "", use_tls: true },
fallback_chain: "google,google_cloud,deepl,openrouter,openrouter_premium,openai,deepseek,zai",
fallback_chain_classic: "google,google_cloud,deepl",
fallback_chain: "google,google_cloud,openrouter,openrouter_premium,openai,deepseek,zai",
fallback_chain_classic: "google,google_cloud",
fallback_chain_llm: "openrouter,openrouter_premium,openai,deepseek,zai",
};
const defaultEnvInfo: EnvInfo = {
deepl: false,
openai: false,
openrouter: false,
openrouter_premium: false,
zai: false,
mistral: false,
ollama: false,
google_cloud: false,
smtp: false,
@@ -362,28 +362,6 @@ export default function AdminSettingsPage() {
</div>
</ProviderCard>
<ProviderCard
title="DeepL"
description="Traduction professionnelle. Obtenez une clé sur deepl.com/pro-api"
enabled={config.deepl.enabled}
onToggle={(enabled) => updateProvider("deepl", { enabled })}
onTest={() => testProvider("deepl")}
testResult={testResults.deepl ?? "idle"}
testMessage={testMessages.deepl}
envKeySet={envInfo.deepl}
>
<div className="space-y-2">
<Label htmlFor="deepl-key">Clé API</Label>
<Input
id="deepl-key"
type="password"
placeholder={envInfo.deepl ? "Clé configurée dans .env (laisser vide pour l'utiliser)" : "Entrez votre clé DeepL"}
value={config.deepl.api_key || ""}
onChange={(e) => updateProvider("deepl", { api_key: e.target.value })}
/>
</div>
</ProviderCard>
<ProviderCard
title="OpenAI"
description="Traductions GPT-4. Obtenez une clé sur platform.openai.com"
@@ -608,6 +586,42 @@ export default function AdminSettingsPage() {
</div>
</ProviderCard>
<ProviderCard
title="OCR Mistral (PDF scannés)"
description="Extraction du texte des PDF scannés (pages image) avant traduction. Sans clé, ces fichiers sont refusés avec un message clair. ~1 € / 1 000 pages. Clé : console.mistral.ai"
enabled={config.mistral.enabled}
onToggle={(enabled) => updateProvider("mistral", { enabled })}
onTest={() => testProvider("mistral")}
testResult={testResults.mistral ?? "idle"}
testMessage={testMessages.mistral}
envKeySet={envInfo.mistral}
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="mistral-key">Clé API Mistral</Label>
<Input
id="mistral-key"
type="password"
placeholder={envInfo.mistral ? "Clé configurée dans .env (laisser vide pour l'utiliser)" : "Clé console.mistral.ai"}
value={config.mistral.api_key || ""}
onChange={(e) => updateProvider("mistral", { api_key: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="mistral-model">Modèle OCR</Label>
<Input
id="mistral-model"
placeholder="mistral-ocr-latest"
value={config.mistral.model || ""}
onChange={(e) => updateProvider("mistral", { model: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
Recommandé : <code>mistral-ocr-latest</code>
</p>
</div>
</div>
</ProviderCard>
<Card>
<CardHeader>
<CardTitle className="text-base">Chaîne de fallback</CardTitle>
@@ -615,11 +629,11 @@ export default function AdminSettingsPage() {
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Mode classique (Google/DeepL)</Label>
<Label>Mode classique (Google)</Label>
<Input
value={config.fallback_chain_classic}
onChange={(e) => setConfig((prev) => ({ ...prev, fallback_chain_classic: e.target.value }))}
placeholder="google,deepl"
placeholder="google,google_cloud"
/>
</div>
<div className="space-y-2">

View File

@@ -30,6 +30,10 @@ export interface ProviderStatus {
last_check: string | null;
latency_ms?: number;
error?: string;
/** true when availability is derived from configuration (no live call) */
config_only?: boolean;
/** optional display label provided by the backend */
label?: string;
}
export interface CleanupResponse {

View File

@@ -78,7 +78,6 @@ function getMockData(period: StatsPeriod): TranslationStatsResponse {
],
provider_breakdown: {
google: { count: Math.floor(baseCount * 0.476), percentage: 47.6 },
deepl: { count: Math.floor(baseCount * 0.357), percentage: 35.7 },
ollama: { count: Math.floor(baseCount * 0.119), percentage: 11.9 },
openai: { count: Math.floor(baseCount * 0.048), percentage: 4.8 },
},

View File

@@ -169,6 +169,8 @@ export function LoginForm() {
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
aria-label={showPassword ? t('register.password.hide') : t('register.password.show')}
aria-pressed={showPassword}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}

View File

@@ -61,8 +61,8 @@ function PasswordToggleIcon({ visible, onToggle, label }: { visible: boolean; on
type="button"
onClick={onToggle}
className="text-muted-foreground hover:text-foreground transition-colors duration-200 pointer-events-auto"
tabIndex={-1}
aria-label={label}
aria-pressed={visible}
>
{visible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
@@ -171,7 +171,7 @@ export function RegisterForm() {
<Languages className="h-6 w-6" />
</div>
<span className="text-2xl font-semibold text-foreground group-hover:text-primary transition-colors duration-300">
Wordly
{t('auth.brandName')}
</span>
</Link>
@@ -334,7 +334,6 @@ export function RegisterForm() {
<Button
type="submit"
variant="premium"
size="lg"
className="w-full"
disabled={registerMutation.isPending}
@@ -364,7 +363,7 @@ export function RegisterForm() {
<p className="text-center text-xs text-muted-foreground">
{t('register.terms.prefix')}{' '}
<span className="text-muted-foreground">
<span className="text-muted-foreground/80">
{t('register.terms.link')}
</span>
.

View File

@@ -2,36 +2,30 @@
import { useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
Menu,
X,
LogOut
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useUser } from './useUser';
import { useLogout } from './useLogout';
import { baseNavItems } from './constants';
import { DashboardNavLinks } from './DashboardNavLinks';
import { getInitials, translateTier } from './utils';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { useI18n } from '@/lib/i18n';
export function DashboardHeader() {
const [mobileOpen, setMobileOpen] = useState(false);
const pathname = usePathname();
const { data: user, isLoading } = useUser();
const { logout } = useLogout();
const { t } = useI18n();
const isPro = ['pro', 'business', 'enterprise'].includes(user?.tier ?? '');
const navItems = isPro ? baseNavItems : baseNavItems.filter(item => !item.proOnly);
return (
<>
<header className="flex h-20 shrink-0 items-center justify-between border-b border-black/5 dark:border-white/5 bg-white/50 dark:bg-[#141414]/50 backdrop-blur-md px-6 lg:px-8">
{/* Mobile menu button */}
<button
className="lg:hidden p-2 text-brand-dark/40 dark:text-white/40 hover:text-brand-dark dark:hover:text-white transition-colors"
className="lg:hidden p-2 text-brand-dark/65 dark:text-white/65 hover:text-brand-dark dark:hover:text-white transition-colors"
onClick={() => setMobileOpen(!mobileOpen)}
aria-label={t('dashboard.header.toggleMenu')}
>
@@ -47,11 +41,7 @@ export function DashboardHeader() {
</div>
{/* Left side - desktop */}
<div className="hidden lg:flex items-center gap-3">
<span className="text-[10px] font-bold uppercase tracking-[0.4em] text-brand-dark/30 dark:text-white/30">
{t('dashboard.topbar.interfaceLabel')}
</span>
</div>
<div className="hidden lg:flex items-center gap-3" />
{/* Right side */}
<div className="flex items-center gap-3">
@@ -69,7 +59,7 @@ export function DashboardHeader() {
<span className="text-[11px] font-black uppercase tracking-tight text-brand-dark dark:text-white">
{user.name}
</span>
<span className="text-[9px] font-black uppercase tracking-widest text-brand-accent">
<span className="text-[10px] font-black uppercase tracking-wider text-brand-accent">
{user.tier && user.tier !== 'free'
? t('dashboard.topbar.premiumAccess', { defaultValue: 'Premium Access' })
: translateTier(t, user.tier)}
@@ -101,25 +91,7 @@ export function DashboardHeader() {
{mobileOpen && (
<div className="border-b border-black/5 dark:border-white/5 bg-white dark:bg-[#141414] px-6 py-4 lg:hidden">
<nav className="flex flex-col gap-1">
{navItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileOpen(false)}
className={cn(
'flex items-center gap-4 rounded-2xl px-6 py-4 text-[11px] font-black uppercase tracking-[0.2em] transition-all duration-200',
isActive
? 'bg-brand-dark text-white shadow-xl'
: 'text-brand-dark/40 dark:text-white/40 hover:bg-brand-muted dark:hover:bg-[#1f1f1f]'
)}
>
<item.icon size={18} className="shrink-0" />
{t(item.labelKey)}
</Link>
);
})}
<DashboardNavLinks onNavigate={() => setMobileOpen(false)} />
<div className="my-3 h-px bg-black/5 dark:bg-white/5" />
@@ -132,7 +104,7 @@ export function DashboardHeader() {
<span className="text-[11px] font-black uppercase tracking-tight text-brand-dark dark:text-white">
{user.name}
</span>
<span className="px-1.5 py-0 rounded-full border border-brand-accent/30 text-brand-accent text-[7px] font-black uppercase w-fit">
<span className="px-1.5 py-0 rounded-full border border-brand-accent/30 text-brand-accent text-[10px] font-black uppercase w-fit">
{translateTier(t, user.tier)}
</span>
</div>
@@ -141,7 +113,7 @@ export function DashboardHeader() {
<button
onClick={logout}
className="flex items-center gap-3 rounded-2xl px-6 py-4 text-[11px] font-black uppercase tracking-[0.2em] text-brand-dark/40 dark:text-white/40 hover:text-red-500 transition-colors"
className="flex items-center gap-3 rounded-2xl px-6 py-4 text-[11px] font-black uppercase tracking-[0.2em] text-brand-dark/65 dark:text-white/65 hover:text-red-500 transition-colors"
>
<LogOut size={18} className="shrink-0" />
{t('dashboard.sidebar.signOut')}

View File

@@ -4,9 +4,11 @@ import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { DashboardSidebar } from './DashboardSidebar';
import { DashboardHeader } from './DashboardHeader';
import { useI18n } from '@/lib/i18n';
export function DashboardLayoutClient({ children }: { children: React.ReactNode }) {
const router = useRouter();
const { t } = useI18n();
const [mounted, setMounted] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
@@ -25,8 +27,8 @@ export function DashboardLayoutClient({ children }: { children: React.ReactNode
<div className="flex h-screen items-center justify-center bg-brand-bg dark:bg-[#0a0a0a]">
<div className="text-center space-y-4">
<div className="animate-spin rounded-full h-8 w-8 border-4 border-brand-muted border-t-brand-accent mx-auto"></div>
<p className="text-[10px] font-black uppercase tracking-[0.4em] text-brand-dark/30 dark:text-white/30">
Loading...
<p className="text-xs font-semibold uppercase tracking-[0.25em] text-brand-dark/50 dark:text-white/50" role="status">
{t('dashboard.loading') || 'Chargement…'}
</p>
</div>
</div>

View File

@@ -0,0 +1,42 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import { useUser } from './useUser';
import { baseNavItems } from './constants';
import { useI18n } from '@/lib/i18n';
/** Single source of dashboard navigation — used by the sidebar and the mobile drawer. */
export function DashboardNavLinks({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname();
const { data: user } = useUser();
const { t } = useI18n();
const isPro = ['pro', 'business', 'enterprise'].includes(user?.tier ?? '');
const navItems = isPro ? baseNavItems : baseNavItems.filter(item => !item.proOnly);
return (
<>
{navItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
onClick={onNavigate}
className={cn(
'flex items-center gap-4 rounded-2xl px-6 py-4 text-[11px] font-black uppercase tracking-[0.2em] transition-all duration-200',
isActive
? 'bg-brand-dark text-white shadow-xl dark:bg-brand-accent dark:text-brand-dark'
: 'text-brand-dark/65 dark:text-white/65 hover:bg-brand-muted dark:hover:bg-[#1f1f1f]'
)}
>
<item.icon size={18} className="shrink-0" />
{t(item.labelKey)}
</Link>
);
})}
</>
);
}

View File

@@ -1,24 +1,19 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { LogOut } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useUser } from './useUser';
import { useLogout } from './useLogout';
import { baseNavItems } from './constants';
import { DashboardNavLinks } from './DashboardNavLinks';
import { getInitials, translateTier } from './utils';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { useI18n } from '@/lib/i18n';
export function DashboardSidebar() {
const pathname = usePathname();
const { data: user, isLoading } = useUser();
const { logout } = useLogout();
const { t } = useI18n();
const isPro = ['pro', 'business', 'enterprise'].includes(user?.tier ?? '');
const navItems = isPro ? baseNavItems : baseNavItems.filter(item => !item.proOnly);
return (
<aside className="hidden w-72 shrink-0 border-r border-black/5 dark:border-white/5 bg-white dark:bg-[#141414] lg:flex lg:flex-col">
@@ -35,24 +30,7 @@ export function DashboardSidebar() {
{/* Navigation */}
<nav className="flex-1 overflow-y-auto px-3 py-2">
<div className="flex flex-col gap-1">
{navItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-4 rounded-2xl px-6 py-4 text-[11px] font-black uppercase tracking-[0.2em] transition-all duration-200',
isActive
? 'bg-brand-dark text-white shadow-xl'
: 'text-brand-dark/40 dark:text-white/40 hover:bg-brand-muted dark:hover:bg-[#1f1f1f]'
)}
>
<item.icon size={18} className="shrink-0" />
{t(item.labelKey)}
</Link>
);
})}
<DashboardNavLinks />
</div>
</nav>
@@ -61,29 +39,13 @@ export function DashboardSidebar() {
<div className="px-6 py-2">
<Link
href="/pricing"
className="text-[10px] font-black uppercase tracking-widest text-brand-accent hover:text-brand-dark dark:hover:text-white transition-colors"
className="text-[10px] font-black uppercase tracking-widest text-brand-goldink dark:text-brand-accent hover:text-brand-dark dark:hover:text-white transition-colors"
>
{t('dashboard.sidebar.upgradeToPro', { defaultValue: 'Passer Pro →' })}
</Link>
</div>
)}
{/* Momento promo section */}
<div className="px-6 py-4">
<div className="bg-brand-muted/50 dark:bg-[#1f1f1f]/50 rounded-3xl p-6 border border-black/5 dark:border-white/5 group cursor-pointer hover:bg-brand-dark dark:hover:bg-brand-accent transition-all duration-500">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 bg-brand-dark group-hover:bg-brand-accent rounded-lg flex items-center justify-center text-white text-xs font-black shadow-sm transition-all group-hover:rotate-12">M</div>
<span className="text-[10px] font-black uppercase tracking-widest text-brand-dark group-hover:text-white dark:text-white transition-colors">{t('memento.title')}</span>
</div>
<p className="text-[9px] text-brand-dark/40 group-hover:text-white/60 font-medium leading-relaxed mb-6">
{t('memento.slogan').substring(0, 80)}...
</p>
<button className="w-full py-2.5 bg-brand-dark group-hover:bg-white text-white group-hover:text-brand-dark dark:bg-[#272727] rounded-xl text-[8px] font-black uppercase tracking-widest shadow-sm transition-all">
{t('memento.ctaFree')}
</button>
</div>
</div>
{/* User section */}
<div className="border-t border-black/5 dark:border-white/5">
{!isLoading && user && (
@@ -96,13 +58,13 @@ export function DashboardSidebar() {
<span className="truncate text-[11px] font-black uppercase tracking-tight text-brand-dark dark:text-white">
{user.name}
</span>
<span className="truncate text-[9px] text-brand-dark/40 dark:text-white/40">
<span className="truncate text-[10px] text-brand-dark/60 dark:text-white/60">
{user.email}
</span>
</div>
</div>
<div className="flex items-center gap-2 mb-3">
<span className="px-1.5 py-0 rounded-full border border-brand-accent/30 text-brand-accent text-[7px] font-black uppercase">
<span className="px-2.5 py-0.5 rounded-full border border-brand-accent/30 text-brand-goldink dark:text-brand-accent text-[10px] font-black uppercase">
{translateTier(t, user.tier)}
</span>
</div>

View File

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

View File

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

View File

@@ -41,9 +41,11 @@ function getDisplaySource(
glossarySourceLang: string
): string {
if (!lang || lang === 'multi') return '';
if (lang === glossarySourceLang) return term.source;
const normalizedLang = lang.toLowerCase();
const normalizedSourceLang = glossarySourceLang.toLowerCase();
if (normalizedLang === normalizedSourceLang) return term.source;
const translations = term.translations || {};
return translations[lang] || '';
return translations[normalizedLang] || '';
}
/** Target term in the chosen language.
@@ -54,9 +56,11 @@ function getDisplayTarget(
glossaryTargetLang: string
): string {
if (!lang) return '';
if (lang === 'multi' || lang === glossaryTargetLang) return term.target;
const normalizedLang = lang.toLowerCase();
const normalizedTargetLang = glossaryTargetLang.toLowerCase();
if (normalizedLang === 'multi' || normalizedLang === normalizedTargetLang) return term.target;
const translations = term.translations || {};
return translations[lang] || '';
return translations[normalizedLang] || '';
}
export default function GlossaryDetailPage() {
@@ -149,10 +153,12 @@ export default function GlossaryDetailPage() {
if (i !== index) return t;
const translations = { ...(t.translations || {}) } as Record<string, string>;
const editLang = field === 'source' ? sourceLanguage : targetLanguage;
const editLang = (field === 'source' ? sourceLanguage : targetLanguage).toLowerCase();
const srcLang = glossary.source_language.toLowerCase();
const tgtLang = glossary.target_language.toLowerCase();
if (field === 'source') {
if (editLang === glossary.source_language) {
if (editLang === srcLang) {
return { ...t, source: value };
} else {
if (editLang && editLang !== 'multi') {
@@ -161,7 +167,7 @@ export default function GlossaryDetailPage() {
return { ...t, translations };
}
} else {
if (editLang === 'multi' || editLang === glossary.target_language) {
if (editLang === 'multi' || editLang === tgtLang) {
return { ...t, target: value };
} else {
if (editLang && editLang !== 'multi') {
@@ -211,12 +217,24 @@ export default function GlossaryDetailPage() {
};
const handleSourceLanguageChange = (newLang: string) => {
// For multilingual glossaries, the selector is a VIEW filter only.
// migrateTerms must not be called — it would destroy translation data.
if (!glossary || glossary.target_language === 'multi') {
setSourceLanguage(newLang);
return;
}
const updated = migrateTerms(terms, sourceLanguage, newLang, targetLanguage, targetLanguage);
setSourceLanguage(newLang);
setTerms(updated);
};
const handleTargetLanguageChange = (newLang: string) => {
// For multilingual glossaries, the selector is a VIEW filter only.
// migrateTerms must not be called — it would destroy translation data.
if (!glossary || glossary.target_language === 'multi') {
setTargetLanguage(newLang);
return;
}
const updated = migrateTerms(terms, sourceLanguage, sourceLanguage, targetLanguage, newLang);
setTargetLanguage(newLang);
setTerms(updated);

View File

@@ -53,6 +53,9 @@ export default function NewGlossaryPage() {
const [parsedTerms, setParsedTerms] = useState<GlossaryTermInput[]>([]);
const [parsedFileName, setParsedFileName] = useState('');
const [isDragging, setIsDragging] = useState(false);
const [fileWizardStep, setFileWizardStep] = useState<1 | 2 | 3>(1);
const [fileSrcLang, setFileSrcLang] = useState('fr');
const [fileTgtLang, setFileTgtLang] = useState('multi');
// État pour manuel
const [manualName, setManualName] = useState('');
@@ -72,6 +75,9 @@ export default function NewGlossaryPage() {
setParsedTerms([]);
setParsedFileName('');
setManualName('');
setFileWizardStep(1);
setFileSrcLang('fr');
setFileTgtLang('multi');
};
const handleBack = () => {
@@ -126,11 +132,30 @@ export default function NewGlossaryPage() {
}
};
// Télécharger exemple CSV
const handleDownloadSample = () => {
const sample = `source,target\nbénéfice,profit\nflux de trésorerie,cash flow\nbilan,balance sheet\ncompte de résultat,income statement\nrésiliation,termination`;
const blob = new Blob([sample], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'exemple_glossaire.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// Créer depuis fichier
const handleCreateFromFile = async () => {
if (!parsedTerms.length) return;
try {
await createGlossary({ name: parsedFileName || 'Mon glossaire', source_language: 'fr', target_language: 'multi', terms: parsedTerms });
await createGlossary({
name: parsedFileName || 'Mon glossaire',
source_language: fileSrcLang,
target_language: fileTgtLang,
terms: parsedTerms,
});
toast({ title: 'Glossaire créé !', description: `${parsedTerms.length} termes importés depuis votre fichier.` });
router.push('/dashboard/glossaries');
} catch {
@@ -371,111 +396,270 @@ export default function NewGlossaryPage() {
</div>
)}
{/* ── CAS B : Depuis un fichier ─── */}
{/* ── CAS B : Depuis un fichier — Wizard 3 sous-étapes ─── */}
{method === 'file' && (
<div>
<h1 className="text-2xl font-serif font-medium text-[#1A1A1A] dark:text-white mb-2">
Importez votre <span className="italic">fichier de termes</span>
</h1>
<p className="text-[#555555] dark:text-white/50 text-sm font-light mb-8">
Formats acceptés : CSV, Excel (.xlsx), ODS, TSV maximum 5 MB.
</p>
{/* Format attendu */}
<div className="mb-6 p-4 rounded-xl bg-[#F5F3EF] dark:bg-white/[0.02] border border-[#D9D6D0] dark:border-white/5">
<p className="text-[11px] font-bold text-[#333333] dark:text-white/70 mb-1">Format CSV attendu :</p>
<code className="text-[10px] text-[#555555] dark:text-white/50 font-mono">
terme_source,terme_cible<br/>
contrat,contract<br/>
résiliation,termination
</code>
{/* Indicateur sous-étapes */}
<div className="flex items-center gap-2 mb-8">
{([1, 2, 3] as const).map((s, i) => {
const labels = ['Format', 'Fichier', 'Configurer'];
const done = fileWizardStep > s;
const active = fileWizardStep === s;
return (
<>
<div key={s} className="flex items-center gap-1.5">
<div className={cn(
'w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-black transition-all',
done ? 'bg-emerald-600 text-white' : active ? 'bg-[#1A1A1A] text-white' : 'bg-[#EBEBEB] dark:bg-white/10 text-[#888]'
)}>
{done ? <CheckCircle2 size={12} /> : s}
</div>
<span className={cn('text-[10px] font-bold uppercase tracking-wider hidden sm:block',
active ? 'text-[#1A1A1A] dark:text-white' : 'text-[#AAAAAA]'
)}>{labels[i]}</span>
</div>
{i < 2 && <div className="flex-1 h-px bg-[#E5E3DF] dark:bg-white/10" />}
</>
);
})}
</div>
{/* Zone de dépôt */}
<div
onDragOver={e => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={e => { e.preventDefault(); setIsDragging(false); const f = e.dataTransfer.files[0]; if (f) processFile(f); }}
onClick={() => fileInputRef.current?.click()}
className={cn(
'flex flex-col items-center justify-center gap-4 p-12 rounded-2xl border-2 border-dashed cursor-pointer transition-all min-h-[200px]',
isDragging ? 'border-[#C5A17A] bg-[#F5F0EA]' : 'border-[#D9D6D0] dark:border-white/10 hover:border-[#C5A17A] hover:bg-[#FAFAF8]',
fileStatus === 'error' && 'border-red-400 bg-red-50 dark:bg-red-500/5'
)}
>
<input
ref={fileInputRef}
type="file"
accept=".csv,.xlsx,.xls,.ods,.txt,.tsv"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) processFile(f); e.target.value = ''; }}
/>
{fileStatus === 'parsing' && (
<>
<Loader2 size={32} className="animate-spin text-[#C5A17A]" />
<p className="text-sm font-medium text-[#555555]">Lecture du fichier</p>
</>
)}
{fileStatus === 'success' && (
<>
<CheckCircle2 size={32} className="text-emerald-600" />
<div className="text-center">
<p className="text-sm font-bold text-emerald-700 dark:text-emerald-400">{parsedTerms.length} termes détectés</p>
<p className="text-[11px] text-[#555555] dark:text-white/40 mt-1">Cliquez pour changer de fichier</p>
</div>
</>
)}
{fileStatus === 'error' && (
<>
<AlertCircle size={32} className="text-red-500" />
<div className="text-center">
<p className="text-sm font-medium text-red-600">{fileError}</p>
<p className="text-[11px] text-[#555555] mt-1">Cliquez pour réessayer</p>
</div>
</>
)}
{fileStatus === 'idle' && (
<>
<div className="w-14 h-14 rounded-2xl bg-[#F0EDE8] flex items-center justify-center text-[#8B6F47]">
<Upload size={28} />
</div>
<div className="text-center">
<p className="text-sm font-serif font-bold text-[#1A1A1A] dark:text-white">
Glissez votre fichier ici
</p>
<p className="text-[11px] text-[#555555] dark:text-white/40 mt-1">
ou cliquez pour parcourir
</p>
</div>
</>
)}
</div>
{/* ── Sous-étape 1 : Format ─── */}
{fileWizardStep === 1 && (
<div className="space-y-5">
<div className="bg-white dark:bg-[#141414] border border-[#E5E3DF] dark:border-white/5 rounded-2xl p-6 space-y-5">
{/* Nom du glossaire (si fichier parsé) */}
{fileStatus === 'success' && (
<div className="mt-6">
<label className="block text-xs font-bold text-[#333333] dark:text-white/70 uppercase tracking-wider mb-2">
Nom du glossaire
</label>
<input
type="text"
value={parsedFileName}
onChange={e => setParsedFileName(e.target.value)}
placeholder="Ex : Termes RH internes"
className="w-full px-4 py-3 rounded-xl border border-[#D9D6D0] dark:border-white/10 bg-white dark:bg-[#141414] text-[#1A1A1A] dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand-accent/30 focus:border-brand-accent/50"
/>
<div>
<p className="text-xs font-bold text-[#333333] dark:text-white/70 uppercase tracking-wider mb-3">Format attendu</p>
<p className="text-[12px] text-[#555555] dark:text-white/50 font-light mb-4 leading-relaxed">
Votre fichier doit avoir <strong className="text-[#1A1A1A] dark:text-white">2 colonnes</strong> : la première pour les termes source, la seconde pour les termes cibles.
La première ligne peut être un en-tête (elle sera ignorée automatiquement).
</p>
{/* Tableau exemple */}
<div className="rounded-xl overflow-hidden border border-[#E5E3DF] dark:border-white/10 text-[11px] font-mono">
<div className="grid grid-cols-2 bg-[#F5F3EF] dark:bg-white/5 border-b border-[#E5E3DF] dark:border-white/10">
<div className="px-4 py-2 font-bold text-[#8B6F47] uppercase tracking-wider">source</div>
<div className="px-4 py-2 font-bold text-[#8B6F47] uppercase tracking-wider border-l border-[#E5E3DF] dark:border-white/10">target</div>
</div>
{[['bénéfice', 'profit'], ['flux de trésorerie', 'cash flow'], ['bilan', 'balance sheet'], ['résiliation', 'termination']].map(([s, t]) => (
<div key={s} className="grid grid-cols-2 border-b border-[#E5E3DF] dark:border-white/5 last:border-0 hover:bg-[#FAFAF8] dark:hover:bg-white/[0.02]">
<div className="px-4 py-2 text-[#1A1A1A] dark:text-white">{s}</div>
<div className="px-4 py-2 text-[#555555] dark:text-white/60 border-l border-[#E5E3DF] dark:border-white/10">{t}</div>
</div>
))}
</div>
</div>
{/* Formats acceptés */}
<div className="flex flex-wrap gap-2">
{['CSV (.csv)', 'Excel (.xlsx)', 'Excel (.xls)', 'ODS (.ods)', 'TSV (.tsv)', 'Texte (.txt)'].map(f => (
<span key={f} className="px-2.5 py-1 rounded-lg bg-[#F0EDE8] dark:bg-white/5 text-[10px] font-bold text-[#8B6F47] dark:text-brand-accent uppercase tracking-wider">{f}</span>
))}
</div>
{/* Règles */}
<div className="space-y-2">
{[
'Séparateur : virgule (CSV) ou tabulation (TSV)',
'Encodage recommandé : UTF-8',
'Taille maximale : 5 MB',
'Limite : 500 termes par glossaire',
'Les champs contenant des virgules doivent être entre guillemets',
].map(rule => (
<div key={rule} className="flex items-start gap-2 text-[11px] text-[#555555] dark:text-white/50">
<CheckCircle2 size={12} className="text-emerald-500 mt-0.5 shrink-0" />
{rule}
</div>
))}
</div>
</div>
{/* Télécharger exemple */}
<div className="flex items-center justify-between">
<button
onClick={handleDownloadSample}
className="flex items-center gap-2 text-[11px] font-bold text-[#8B6F47] dark:text-brand-accent hover:underline cursor-pointer"
>
<Upload size={12} /> Télécharger un exemple CSV
</button>
<button
onClick={() => setFileWizardStep(2)}
className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-[#1A1A1A] hover:bg-[#333333] text-white text-xs font-bold uppercase tracking-widest transition-all cursor-pointer"
>
Suivant <ArrowRight size={13} />
</button>
</div>
</div>
)}
<div className="flex justify-end mt-6">
<button
onClick={handleCreateFromFile}
disabled={fileStatus !== 'success' || isProcessing}
className="flex items-center gap-2 px-7 py-3 rounded-xl bg-[#1A1A1A] hover:bg-[#333333] text-white text-xs font-bold uppercase tracking-widest transition-all disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
{isCreating ? <><Loader2 size={14} className="animate-spin" /> Création</> : <><CheckCircle2 size={14} /> Créer le glossaire</>}
</button>
</div>
{/* ── Sous-étape 2 : Fichier ─── */}
{fileWizardStep === 2 && (
<div className="space-y-5">
{/* Zone de dépôt */}
<div
onDragOver={e => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={e => {
e.preventDefault(); setIsDragging(false);
const f = e.dataTransfer.files[0];
if (f) processFile(f);
}}
onClick={() => fileInputRef.current?.click()}
className={cn(
'flex flex-col items-center justify-center gap-4 p-12 rounded-2xl border-2 border-dashed cursor-pointer transition-all min-h-[220px]',
isDragging ? 'border-[#C5A17A] bg-[#F5F0EA]' : 'border-[#D9D6D0] dark:border-white/10 hover:border-[#C5A17A] hover:bg-[#FAFAF8] dark:hover:bg-white/[0.02]',
fileStatus === 'error' && 'border-red-400 bg-red-50 dark:bg-red-500/5'
)}
>
<input
ref={fileInputRef}
type="file"
accept=".csv,.xlsx,.xls,.ods,.txt,.tsv"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) processFile(f); e.target.value = ''; }}
/>
{fileStatus === 'parsing' && (
<><Loader2 size={32} className="animate-spin text-[#C5A17A]" /><p className="text-sm font-medium text-[#555555] dark:text-white/50">Lecture du fichier</p></>
)}
{fileStatus === 'success' && (
<>
<CheckCircle2 size={36} className="text-emerald-600" />
<div className="text-center">
<p className="text-sm font-bold text-emerald-700 dark:text-emerald-400">{parsedTerms.length} termes détectés</p>
<p className="text-[11px] text-[#555555] dark:text-white/40 mt-1">Cliquez pour changer de fichier</p>
</div>
</>
)}
{fileStatus === 'error' && (
<>
<AlertCircle size={36} className="text-red-500" />
<div className="text-center">
<p className="text-sm font-medium text-red-600">{fileError}</p>
<p className="text-[11px] text-[#555555] dark:text-white/40 mt-1">Cliquez pour réessayer</p>
</div>
</>
)}
{fileStatus === 'idle' && (
<>
<div className="w-16 h-16 rounded-2xl bg-[#F0EDE8] dark:bg-white/5 flex items-center justify-center text-[#8B6F47]">
<Upload size={32} />
</div>
<div className="text-center">
<p className="text-sm font-serif font-bold text-[#1A1A1A] dark:text-white">Glissez votre fichier ici</p>
<p className="text-[11px] text-[#555555] dark:text-white/40 mt-1">CSV, Excel, ODS, TSV max 5 MB</p>
</div>
<span className="text-[10px] text-[#AAAAAA] font-light">ou cliquez pour parcourir</span>
</>
)}
</div>
<div className="flex items-center justify-between">
<button onClick={() => setFileWizardStep(1)} className="flex items-center gap-2 text-[11px] font-bold uppercase tracking-wider text-[#555555] hover:text-[#1A1A1A] dark:hover:text-white transition-colors cursor-pointer">
<ArrowLeft size={13} /> Retour
</button>
<button
onClick={() => setFileWizardStep(3)}
disabled={fileStatus !== 'success'}
className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-[#1A1A1A] hover:bg-[#333333] text-white text-xs font-bold uppercase tracking-widest transition-all disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
Suivant <ArrowRight size={13} />
</button>
</div>
</div>
)}
{/* ── Sous-étape 3 : Configurer ─── */}
{fileWizardStep === 3 && (
<div className="space-y-5">
<div className="bg-white dark:bg-[#141414] border border-[#E5E3DF] dark:border-white/5 rounded-2xl p-6 space-y-6">
{/* Résumé fichier */}
<div className="flex items-center gap-3 p-3 rounded-xl bg-emerald-50 dark:bg-emerald-500/5 border border-emerald-200 dark:border-emerald-500/20">
<CheckCircle2 size={16} className="text-emerald-600 shrink-0" />
<div>
<p className="text-[11px] font-bold text-emerald-800 dark:text-emerald-400">{parsedTerms.length} termes prêts à importer</p>
<button onClick={() => { setFileWizardStep(2); setFileStatus('idle'); setParsedTerms([]); }} className="text-[10px] text-emerald-700 dark:text-emerald-500 hover:underline cursor-pointer">
Changer de fichier
</button>
</div>
</div>
{/* Nom */}
<div>
<label className="block text-xs font-bold text-[#333333] dark:text-white/70 uppercase tracking-wider mb-2">
Nom du glossaire <span className="text-red-500">*</span>
</label>
<input
type="text"
value={parsedFileName}
onChange={e => setParsedFileName(e.target.value)}
placeholder="Ex : Termes RH internes, Glossaire juridique…"
className="w-full px-4 py-3 rounded-xl border border-[#D9D6D0] dark:border-white/10 bg-[#FAFAF8] dark:bg-[#1A1A1A] text-[#1A1A1A] dark:text-white text-sm placeholder:text-[#AAAAAA] dark:placeholder:text-white/25 focus:outline-none focus:ring-2 focus:ring-brand-accent/30 focus:border-brand-accent/50"
/>
</div>
{/* Langues */}
<div className="grid grid-cols-2 gap-5">
<div>
<label className="block text-xs font-bold text-[#333333] dark:text-white/70 uppercase tracking-wider mb-2">
Langue source
</label>
<select
value={fileSrcLang}
onChange={e => setFileSrcLang(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-[#D9D6D0] dark:border-white/10 bg-[#FAFAF8] dark:bg-[#1A1A1A] text-[#1A1A1A] dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand-accent/30"
>
{SUPPORTED_LANGUAGES.filter(l => l.code !== 'multi').map(l => (
<option key={l.code} value={l.code}>{l.flag} {l.label}</option>
))}
</select>
<p className="text-[10px] text-[#AAAAAA] dark:text-white/30 mt-1.5 font-light">Langue de la 1ère colonne de votre fichier</p>
</div>
<div>
<label className="block text-xs font-bold text-[#333333] dark:text-white/70 uppercase tracking-wider mb-2">
Langue cible
</label>
<select
value={fileTgtLang}
onChange={e => setFileTgtLang(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-[#D9D6D0] dark:border-white/10 bg-[#FAFAF8] dark:bg-[#1A1A1A] text-[#1A1A1A] dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand-accent/30"
>
{SUPPORTED_LANGUAGES.map(l => (
<option key={l.code} value={l.code}>{l.flag} {l.label}</option>
))}
</select>
<p className="text-[10px] text-[#AAAAAA] dark:text-white/30 mt-1.5 font-light">Langue de la 2ème colonne de votre fichier</p>
</div>
</div>
{/* Info */}
<div className="p-3.5 rounded-xl bg-[#F5F3EF] dark:bg-white/[0.02] border border-[#D9D6D0] dark:border-white/5">
<p className="text-[11px] text-[#555555] dark:text-white/50 font-light leading-relaxed">
<strong className="font-bold text-[#1A1A1A] dark:text-white/80">Conseil :</strong>{' '}
Choisissez <strong>Multilingue</strong> comme langue cible si vous prévoyez d'utiliser ce glossaire pour traduire vers plusieurs langues. Vous pourrez ajouter des traductions dans l'éditeur.
</p>
</div>
</div>
<div className="flex items-center justify-between">
<button onClick={() => setFileWizardStep(2)} className="flex items-center gap-2 text-[11px] font-bold uppercase tracking-wider text-[#555555] hover:text-[#1A1A1A] dark:hover:text-white transition-colors cursor-pointer">
<ArrowLeft size={13} /> Retour
</button>
<button
onClick={handleCreateFromFile}
disabled={!parsedFileName.trim() || isProcessing}
className="flex items-center gap-2 px-7 py-3 rounded-xl bg-[#1A1A1A] hover:bg-[#333333] text-white text-xs font-bold uppercase tracking-widest transition-all disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
{isCreating ? <><Loader2 size={14} className="animate-spin" /> Création</> : <><CheckCircle2 size={14} /> Créer le glossaire</>}
</button>
</div>
</div>
)}
</div>
)}

View File

@@ -10,7 +10,7 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { cn } from '@/lib/utils';
import { useUser } from '@/app/dashboard/useUser';
import { useI18n } from '@/lib/i18n';
import { useI18n, formatDate } from '@/lib/i18n';
import { useGlossaries } from './useGlossaries';
import type { GlossaryListItem } from './types';
import { ProUpgradePrompt } from './ProUpgradePrompt';
@@ -19,28 +19,18 @@ import { SUPPORTED_LANGUAGES } from './types';
import { useTranslationStore } from '@/lib/store';
// ── Chips de suggestions pour les consignes de contexte ─────────────────────
const CONTEXT_SUGGESTIONS = [
{ label: 'Ton formel', value: 'Utilise toujours un ton formel et professionnel dans tes traductions.' },
{ label: 'Noms propres', value: 'Ne traduis pas les noms propres, marques et noms de personnes.' },
{ label: 'Chiffres', value: 'Garde tous les chiffres, pourcentages et montants tels quels sans les modifier.' },
{ label: 'Placeholders', value: 'Ne traduis pas les variables entre accolades comme {nom}, {date}, {montant}.' },
{ label: 'Termes techniques', value: 'Conserve les termes techniques en langue originale et ne les traduis pas.' },
{ label: 'Style concis', value: 'Préfère des formulations courtes et directes. Évite les périphrases.' },
// Prompt bodies are LLM content — resolved through t() so they follow the UI locale.
const CONTEXT_SUGGESTIONS: { labelKey: string; valueKey: string }[] = [
{ labelKey: 'glossaries.suggestion.formal', valueKey: 'glossaries.suggestion.formal.value' },
{ labelKey: 'glossaries.suggestion.proprietary', valueKey: 'glossaries.suggestion.proprietary.value' },
{ labelKey: 'glossaries.suggestion.numbers', valueKey: 'glossaries.suggestion.numbers.value' },
{ labelKey: 'glossaries.suggestion.placeholders', valueKey: 'glossaries.suggestion.placeholders.value' },
{ labelKey: 'glossaries.suggestion.technical', valueKey: 'glossaries.suggestion.technical.value' },
{ labelKey: 'glossaries.suggestion.concise', valueKey: 'glossaries.suggestion.concise.value' },
];
function renderTitle(title: string) {
const lastSpaceIndex = title.lastIndexOf(' ');
if (lastSpaceIndex === -1) return title;
return (
<>
{title.substring(0, lastSpaceIndex)}{' '}
<span className="italic">{title.substring(lastSpaceIndex + 1)}</span>
</>
);
}
export default function GlossariesPage() {
const { t } = useI18n();
const { t, locale } = useI18n();
const router = useRouter();
const { data: user, isLoading: isLoadingUser } = useUser();
const { glossaries, isLoading: isLoadingGlossaries } = useGlossaries();
@@ -53,7 +43,8 @@ export default function GlossariesPage() {
const [searchQuery, setSearchQuery] = useState('');
const [activeTab, setActiveTab] = useState<'glossaries' | 'context'>('glossaries');
const isPro = user?.tier === 'pro';
// Same tier logic as the sidebar: business/enterprise are Pro-grade plans.
const isPro = ['pro', 'business', 'enterprise'].includes(user?.tier ?? '');
const isLoading = isLoadingUser || isLoadingGlossaries;
const currentTargetLang = settings.defaultTargetLanguage;
@@ -67,17 +58,13 @@ export default function GlossariesPage() {
setPromptSaved(false);
}, [settings.systemPrompt]);
const handleSavePrompt = async () => {
const handleSavePrompt = () => {
setIsSavingPrompt(true);
try {
updateSettings({ systemPrompt });
await new Promise(resolve => setTimeout(resolve, 300));
setPromptSaved(true);
updateSettings({ systemPrompt });
setPromptSaved(true);
toast({ title: t('context.saved'), description: t('context.savedDesc') });
setTimeout(() => setPromptSaved(false), 3000);
} finally {
setIsSavingPrompt(false);
}
setIsSavingPrompt(false);
};
const handleClearPrompt = () => {
@@ -85,7 +72,8 @@ export default function GlossariesPage() {
setSystemPrompt('');
};
const handleAddSuggestion = (value: string) => {
const handleAddSuggestion = (valueKey: string) => {
const value = t(valueKey);
const current = systemPrompt.trim();
const newPrompt = current ? `${current}\n${value}` : value;
setSystemPrompt(newPrompt);
@@ -122,7 +110,7 @@ export default function GlossariesPage() {
{t('glossaries.yourGlossaries') || 'Vos Glossaires'}
</span>
<h1 className="text-4xl md:text-5xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
{renderTitle(t('glossaries.title') || 'Glossaires & Contexte')}
{t('glossaries.titleBase')} <span className="italic">{t('glossaries.titleAccent')}</span>
</h1>
<p className="text-[#555555] dark:text-white/60 text-sm font-light leading-relaxed">
{t('glossaries.description') || 'Gérez vos glossaires et instructions de contexte pour des traductions plus précises.'}
@@ -134,7 +122,7 @@ export default function GlossariesPage() {
className="shrink-0 flex items-center gap-2 px-5 py-2.5 rounded-xl bg-[#1A1A1A] hover:bg-[#333333] text-white text-xs font-bold uppercase tracking-widest transition-all"
>
<Plus size={14} />
Nouveau glossaire
{t('glossaries.new')}
</Link>
</div>
@@ -144,15 +132,14 @@ export default function GlossariesPage() {
<Info size={13} />
</div>
<p className="text-[11px] text-[#2D2D2D] dark:text-white/70 font-medium leading-relaxed flex-1">
<strong className="font-bold text-[#1A1A1A] dark:text-white">Pour utiliser un glossaire dans une traduction :</strong>{' '}
cliquez sur <span className="font-bold text-[#7A5C35] dark:text-brand-accent">« Utiliser »</span> sur la carte souhaitée.
Vous serez redirigé sur la page Traduire avec ce glossaire déjà sélectionné.
<strong className="font-bold text-[#1A1A1A] dark:text-white">{t('glossaries.info.title')}</strong>{' '}
{t('glossaries.info.body')}
</p>
<Link
href="/dashboard/translate"
className="shrink-0 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-[#7A5C35] dark:text-brand-accent hover:underline whitespace-nowrap"
>
Aller traduire <ArrowRight size={11} />
{t('glossaries.info.cta')} <ArrowRight size={11} />
</Link>
</div>
@@ -160,6 +147,8 @@ export default function GlossariesPage() {
<div className="flex border-b border-black/10 dark:border-white/10 mb-8">
<button
onClick={() => setActiveTab('glossaries')}
aria-selected={activeTab === 'glossaries'}
role="tab"
className={cn(
'pb-4 px-6 text-xs uppercase tracking-widest font-bold border-b-2 transition-all cursor-pointer',
activeTab === 'glossaries'
@@ -171,6 +160,8 @@ export default function GlossariesPage() {
</button>
<button
onClick={() => setActiveTab('context')}
aria-selected={activeTab === 'context'}
role="tab"
className={cn(
'pb-4 px-6 text-xs uppercase tracking-widest font-bold border-b-2 transition-all cursor-pointer',
activeTab === 'context'
@@ -196,10 +187,10 @@ export default function GlossariesPage() {
</div>
<div>
<h2 className="text-base font-serif font-semibold text-[#1A1A1A] dark:text-white tracking-tight">
Consignes de contexte
{t('glossaries.context.title')}
</h2>
<p className="text-[11px] text-[#555555] dark:text-white/50 font-light mt-0.5">
Ces instructions sont envoyées automatiquement à l'IA avant chaque traduction en mode Pro LLM.
{t('glossaries.context.desc')}
</p>
</div>
</div>
@@ -207,16 +198,16 @@ export default function GlossariesPage() {
{promptHasUnsavedChanges ? (
<span className="shrink-0 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-amber-700 bg-amber-100 dark:text-amber-400 dark:bg-amber-500/10 px-3 py-1.5 rounded-full border border-amber-300 dark:border-amber-500/20">
<AlertCircle size={11} />
Non sauvegardé
{t('glossaries.context.unsaved')}
</span>
) : promptIsActive ? (
<span className="shrink-0 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-emerald-700 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-500/10 px-3 py-1.5 rounded-full border border-emerald-300 dark:border-emerald-500/20">
<CheckCircle2 size={11} />
Actif
{t('glossaries.context.active')}
</span>
) : (
<span className="shrink-0 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-[#555555] bg-[#EBEBEB] dark:text-white/30 dark:bg-white/5 px-3 py-1.5 rounded-full border border-[#CCCCCC] dark:border-white/10">
Inactif
{t('glossaries.context.inactive')}
</span>
)}
</div>
@@ -226,17 +217,17 @@ export default function GlossariesPage() {
<div className="flex items-center gap-2 mb-3">
<Sparkles size={13} className="text-[#8B6F47]" />
<span className="text-[10px] font-bold uppercase tracking-widest text-[#555555] dark:text-white/40">
Suggestions rapides cliquez pour ajouter
{t('glossaries.context.suggestions')}
</span>
</div>
<div className="flex flex-wrap gap-2">
{CONTEXT_SUGGESTIONS.map((s) => (
<button
key={s.label}
onClick={() => handleAddSuggestion(s.value)}
key={s.labelKey}
onClick={() => handleAddSuggestion(s.valueKey)}
className="px-3 py-1.5 rounded-lg text-[11px] font-semibold text-[#3D3D3D] dark:text-white/70 bg-[#F0EDE8] dark:bg-white/5 border border-[#D9D5CE] dark:border-white/10 hover:bg-[#E8E2DA] hover:border-[#C5A17A] dark:hover:bg-brand-accent/10 dark:hover:border-brand-accent/30 transition-all cursor-pointer"
>
+ {s.label}
+ {t(s.labelKey)}
</button>
))}
</div>
@@ -246,7 +237,8 @@ export default function GlossariesPage() {
<textarea
value={systemPrompt}
onChange={e => { setSystemPrompt(e.target.value); setPromptSaved(false); }}
placeholder="Ex : Vous traduisez des documents techniques HVAC. Utilisez une terminologie d'ingénierie précise et un ton professionnel…"
placeholder={t('glossaries.context.placeholder')}
aria-label={t('glossaries.context.title')}
className="w-full h-44 p-4 bg-[#FAFAF8] dark:bg-white/[0.02] rounded-xl border border-[#D9D6D0] dark:border-white/10 text-[#1A1A1A] dark:text-white text-xs leading-relaxed focus:ring-2 focus:ring-brand-accent/30 focus:border-brand-accent/50 transition-all outline-none resize-y placeholder:text-[#AAAAAA] dark:placeholder:text-white/25"
/>
@@ -254,8 +246,8 @@ export default function GlossariesPage() {
<div className="flex justify-between items-center mt-4">
<p className="text-[10px] text-[#777777] dark:text-white/25 font-light">
{systemPrompt.length > 0
? `${systemPrompt.length} caractères`
: 'Aucune consigne enregistrée'}
? t('glossaries.context.chars', { count: systemPrompt.length })
: t('glossaries.context.noPrompt')}
</p>
<div className="flex gap-3">
<button
@@ -264,7 +256,7 @@ export default function GlossariesPage() {
className="px-5 py-2.5 bg-[#EBEBEB] dark:bg-white/5 text-[#444444] dark:text-white/40 rounded-lg text-xs font-bold uppercase tracking-wider hover:text-[#1A1A1A] hover:bg-[#E0E0E0] dark:hover:text-white transition-all cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
<Trash2 size={12} className="inline mr-1.5" />
Tout effacer
{t('glossaries.context.clear')}
</button>
<button
onClick={handleSavePrompt}
@@ -272,10 +264,10 @@ export default function GlossariesPage() {
className="premium-button px-8 py-2.5 text-xs uppercase tracking-widest !rounded-lg flex items-center gap-2 disabled:opacity-50 cursor-pointer font-bold"
>
{isSavingPrompt
? <><Loader2 size={14} className="animate-spin" /> Enregistrement</>
? <><Loader2 size={14} className="animate-spin" /> {t('glossaries.context.saving')}</>
: promptSaved
? <><CheckCircle2 size={14} /> Enregistré</>
: <><Save size={14} /> Enregistrer</>
? <><CheckCircle2 size={14} /> {t('glossaries.context.saved')}</>
: <><Save size={14} /> {t('glossaries.context.save')}</>
}
</button>
</div>
@@ -296,13 +288,13 @@ export default function GlossariesPage() {
</h2>
<p className="text-[11px] text-[#555555] dark:text-white/40 font-light mt-1">
{glossaries.length > 0
? `${glossaries.length} glossaire${glossaries.length > 1 ? 's' : ''} — cliquez sur une carte pour la modifier`
: 'Aucun glossaire — créez-en un pour commencer'}
? t('glossaries.countMany', { count: glossaries.length })
: t('glossaries.countNone')}
</p>
</div>
{currentTargetInfo && (
<span className="flex items-center gap-1.5 text-[10px] font-bold text-[#444444] dark:text-white/40 bg-[#EBEBEB] dark:bg-white/5 border border-[#CCCCCC] dark:border-white/5 px-3 py-1.5 rounded-full shrink-0">
<span>Traduction active :</span>
<span>{t('glossaries.activeTarget')}</span>
<span>{currentTargetInfo.flag} {currentTargetInfo.label}</span>
</span>
)}
@@ -316,7 +308,8 @@ export default function GlossariesPage() {
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Rechercher un glossaire…"
placeholder={t('glossaries.searchPlaceholder')}
aria-label={t('glossaries.searchPlaceholder')}
className="w-full pl-9 pr-3 py-2.5 text-xs rounded-lg border border-[#D9D6D0] dark:border-white/10 bg-white dark:bg-[#141414] text-[#1A1A1A] dark:text-white placeholder:text-[#AAAAAA] dark:placeholder:text-white/25 focus:outline-none focus:ring-2 focus:ring-brand-accent/20 focus:border-brand-accent/30"
/>
</div>
@@ -339,13 +332,13 @@ export default function GlossariesPage() {
className="inline-flex items-center gap-2 px-6 py-2.5 rounded-xl bg-[#1A1A1A] hover:bg-[#333333] text-white text-xs font-bold uppercase tracking-widest transition-all"
>
<Plus size={12} />
Créer mon premier glossaire
{t('glossaries.createFirst')}
</Link>
</div>
) : filteredGlossaries.length === 0 ? (
<div className="p-8 bg-white dark:bg-[#141414] border border-black/5 dark:border-white/5 rounded-2xl text-center">
<p className="text-sm text-[#555555] dark:text-white/40 font-light">
Aucun résultat pour «&nbsp;{searchQuery}&nbsp;»
{t('glossaries.noResults', { query: searchQuery })}
</p>
</div>
) : (
@@ -373,17 +366,22 @@ export default function GlossariesPage() {
{/* Badge compatible / autre langue */}
{matchesTarget && (
<div className="absolute top-3 right-3 flex items-center gap-1 bg-emerald-100 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider border border-emerald-300 dark:border-emerald-500/20">
<CheckCircle2 size={9} /> Compatible
<CheckCircle2 size={9} /> {t('glossaries.badge.compatible')}
</div>
)}
{mismatch && (
<div className="absolute top-3 right-3 flex items-center gap-1 bg-amber-100 dark:bg-amber-500/10 text-amber-700 dark:text-amber-400 px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider border border-amber-300 dark:border-amber-500/20">
<AlertCircle size={9} /> Autre langue
<AlertCircle size={9} /> {t('glossaries.badge.otherLang')}
</div>
)}
{/* Corps cliquable → page de détail */}
<div className="cursor-pointer flex-1" onClick={() => router.push(`/dashboard/glossaries/${glossary.id}`)}>
<button
type="button"
onClick={() => router.push(`/dashboard/glossaries/${glossary.id}`)}
className="cursor-pointer flex-1 text-start"
aria-label={`${t('glossaries.card.edit')}${glossary.name}`}
>
<div className="flex items-start gap-3 mb-4">
<div className="w-10 h-10 bg-[#F0EDE8] dark:bg-white/10 rounded-xl flex items-center justify-center text-[#8B6F47] shrink-0">
<Library size={18} />
@@ -405,14 +403,14 @@ export default function GlossariesPage() {
<div className="flex justify-between items-center pb-4">
<span className="flex items-center gap-1 text-xs font-semibold text-[#333333] dark:text-white/65">
<Hash size={12} className="text-[#8B6F47] dark:text-brand-accent" />
{termCount} terme{termCount > 1 ? 's' : ''}
{termCount > 1 ? t('glossaries.termsMany', { count: termCount }) : t('glossaries.termsOne')}
</span>
<span className="flex items-center gap-1 font-mono text-[9px] text-[#666666] dark:text-white/40">
<span className="flex items-center gap-1 font-mono text-[10px] text-[#666666] dark:text-white/40">
<Calendar size={11} />
{new Date(glossary.created_at).toLocaleDateString('fr-FR')}
{formatDate(new Date(glossary.created_at), locale)}
</span>
</div>
</div>
</button>
{/* Actions */}
<div className="flex gap-2 pt-4 border-t border-[#F0EDE8] dark:border-white/10 mt-auto shrink-0">
@@ -420,13 +418,13 @@ export default function GlossariesPage() {
href={`/dashboard/glossaries/${glossary.id}`}
className="flex-none px-3 py-2 rounded-lg bg-[#EBEBEB] dark:bg-white/5 hover:bg-[#E0D9D1] text-[#333333] dark:text-white/50 hover:text-[#1A1A1A] text-[10px] font-bold uppercase tracking-wider transition-all border border-[#CCCCCC] dark:border-white/10"
>
Éditer
{t('glossaries.card.edit')}
</Link>
<Link
href={`/dashboard/translate?glossaryId=${glossary.id}`}
className="flex-1 text-center py-2 rounded-lg bg-[#1A1A1A] hover:bg-[#333333] text-white text-[10px] font-bold uppercase tracking-wider transition-all flex items-center justify-center gap-1.5"
>
Utiliser <ArrowRight size={11} />
{t('glossaries.card.use')} <ArrowRight size={11} />
</Link>
</div>
</div>

View File

@@ -466,7 +466,7 @@ export default function ProfilePage() {
{/* Danger zone — show if user has a paid plan (even if sub ID not yet synced) */}
{!isFreePlan && !isCanceling && (
<div className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial border-l-4 border-l-red-500">
<div className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border border-red-200 dark:border-red-500/30 shadow-editorial">
<div className="flex items-center gap-4 text-red-500 mb-8">
<ShieldAlert size={20} />
<span className="text-[11px] font-black uppercase tracking-[0.3em] text-brand-dark dark:text-white">

View File

@@ -0,0 +1,435 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'next/navigation';
import {
Check,
CheckCheck,
Download,
FileDown,
FileUp,
Loader2,
Pencil,
Undo2,
} from 'lucide-react';
import { apiClient, API_BASE_URL } from '@/lib/apiClient';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/components/ui/toast';
import { useI18n } from '@/lib/i18n';
import { PageHeader } from '@/components/layout/page-header';
import { cn } from '@/lib/utils';
interface Segment {
id: string;
segment_index: number;
source_text: string;
translated_text: string;
status: 'pending' | 'approved' | 'edited';
reviewed_text: string | null;
}
interface SegmentsResponse {
data: {
job_id: string;
file_name: string | null;
status: string;
segments: Segment[];
counts: { total: number; pending: number; approved: number; edited: number };
};
}
interface RebuildResponse {
data: { job_id: string; rebuilt: boolean; segments_applied: number; download_url: string };
}
/** Map raw backend errors to something the reviewer can act on. */
function friendlyReviewError(raw: string | undefined, t: (k: string) => string, fallbackKey: string): string | undefined {
if (!raw) return undefined;
const r = raw.toLowerCase();
if (r.includes('401') || r.includes('unauthorized') || r.includes('403')) return t('reviews.error.sessionExpired');
if (r.includes('404') || r.includes('not found')) return t('reviews.error.notFound');
if (r.includes('429') || r.includes('rate')) return t('reviews.error.rateLimited');
if (r.includes('500') || r.includes('server')) return t('reviews.error.server');
return raw || t(fallbackKey);
}
function authHeaders(): Record<string, string> {
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
return headers;
}
async function downloadProtected(url: string, filename: string, failMessage: (status: number) => string) {
const res = await fetch(`${API_BASE_URL}${url}`, { headers: authHeaders() });
if (!res.ok) throw new Error(failMessage(res.status));
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
}
export default function ReviewPage() {
const params = useParams<{ jobId: string }>();
const jobId = params?.jobId ?? '';
const notify = useToast();
const { t } = useI18n();
const [segments, setSegments] = useState<Segment[]>([]);
const [fileName, setFileName] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState('');
const [savingId, setSavingId] = useState<string | null>(null);
const [isRebuilding, setIsRebuilding] = useState(false);
const [approveArmed, setApproveArmed] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const load = useCallback(async () => {
setIsLoading(true);
setLoadError(null);
try {
const res = await apiClient.get<SegmentsResponse>(
`/api/v1/translations/${jobId}/segments`
);
setSegments(res.data.segments);
setFileName(res.data.file_name);
} catch (err) {
setLoadError(err instanceof Error ? err.message : t('reviews.error.load'));
} finally {
setIsLoading(false);
}
}, [jobId]);
useEffect(() => {
if (jobId) load();
}, [jobId, load]);
const counts = useMemo(() => {
const pending = segments.filter((s) => s.status === 'pending').length;
const approved = segments.filter((s) => s.status === 'approved').length;
const edited = segments.filter((s) => s.status === 'edited').length;
return { total: segments.length, pending, approved, edited };
}, [segments]);
const patchSegment = async (
id: string,
body: { reviewed_text?: string; status?: string }
) => {
setSavingId(id);
try {
await apiClient.patch(`/api/v1/segments/${id}`, body);
setSegments((prev) =>
prev.map((s) =>
s.id === id
? {
...s,
reviewed_text: body.reviewed_text ?? s.reviewed_text,
status: (body.status ?? s.status) as Segment['status'],
}
: s
)
);
} catch (err) {
notify.error({
title: t('reviews.error.title'),
description: friendlyReviewError(err instanceof Error ? err.message : undefined, t, 'reviews.error.update'),
});
} finally {
setSavingId(null);
}
};
const approveAll = async () => {
// Two-step: the first click arms, the second confirms — no accidental bulk-approve.
if (!approveArmed) {
setApproveArmed(true);
setTimeout(() => setApproveArmed(false), 4000);
return;
}
setApproveArmed(false);
const pending = segments.filter((s) => s.status === 'pending');
for (const seg of pending) {
// sequential is fine — server-side each is a tiny PATCH
await patchSegment(seg.id, { status: 'approved' });
}
notify.success({
title: t('reviews.approvedTitle'),
description: t('reviews.approvedDesc', { count: pending.length }),
});
};
const rebuild = async () => {
setIsRebuilding(true);
try {
const res = await apiClient.post<RebuildResponse>(
`/api/v1/translations/${jobId}/rebuild`
);
await downloadProtected(
res.data.download_url,
`relu_${fileName ?? jobId}`,
(status) => t('reviews.error.download', { status })
);
notify.success({
title: t('reviews.rebuiltTitle'),
description: t('reviews.rebuiltDesc', { count: res.data.segments_applied }),
});
} catch (err) {
notify.error({
title: t('reviews.rebuildFailed'),
description: friendlyReviewError(err instanceof Error ? err.message : undefined, t, 'reviews.error.update'),
});
} finally {
setIsRebuilding(false);
}
};
const exportXliff = async () => {
try {
await downloadProtected(
`/api/v1/translations/${jobId}/xliff`,
`${jobId}.xliff`,
(status) => t('reviews.error.download', { status })
);
} catch (err) {
notify.error({
title: t('reviews.xliffExportFailed'),
description: friendlyReviewError(err instanceof Error ? err.message : undefined, t, 'reviews.error.update'),
});
}
};
const importXliff = async (file: File) => {
setIsImporting(true);
try {
const text = await file.text();
const res = await fetch(
`${API_BASE_URL}/api/v1/translations/${jobId}/xliff`,
{
method: 'POST',
headers: { ...authHeaders() },
body: text,
}
);
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(
json?.detail?.message || json?.message || `HTTP ${res.status}`
);
}
const json = await res.json();
notify.success({
title: t('reviews.xliffImported'),
description: t('reviews.xliffImportedDesc', { count: json.data.segments_updated }),
});
await load();
} catch (err) {
notify.error({
title: t('reviews.xliffImportFailed'),
description: friendlyReviewError(err instanceof Error ? err.message : undefined, t, 'reviews.error.update'),
});
} finally {
setIsImporting(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const statusBadge = (status: Segment['status']) => {
if (status === 'approved')
return <Badge className="bg-green-500/15 text-green-600 border-green-500/30">{t('reviews.status.approved')}</Badge>;
if (status === 'edited')
return <Badge className="bg-blue-500/15 text-blue-600 border-blue-500/30">{t('reviews.status.edited')}</Badge>;
return <Badge variant="outline" className="text-muted-foreground">{t('reviews.status.pending')}</Badge>;
};
return (
<div className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<span className="accent-pill mb-3 block w-fit">{t('reviews.pill')}</span>
<h1 className="mb-2 text-3xl font-serif font-medium leading-tight tracking-tight text-brand-dark dark:text-white">
{t('reviews.titleBase')} <span className="italic">{t('reviews.titleAccent')}</span>
</h1>
<p className="max-w-xl truncate text-sm text-brand-dark/60 dark:text-white/60">
{t('reviews.subtitle', {
file: fileName ?? jobId,
total: counts.total,
pending: counts.pending,
approved: counts.approved,
edited: counts.edited,
})}
</p>
<p className="max-w-xl text-xs font-light text-brand-dark/50 dark:text-white/50">
{t('reviews.xliffHint')}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant={approveArmed ? 'default' : 'outline'} size="sm" onClick={approveAll} disabled={counts.pending === 0}>
<CheckCheck className="size-3.5" />
{approveArmed
? t('reviews.approveAllConfirm', { count: counts.pending })
: t('reviews.approveAll')}
</Button>
<Button variant="outline" size="sm" onClick={exportXliff} disabled={!segments.length}>
<FileDown className="size-3.5" /> XLIFF
</Button>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isImporting}
>
{isImporting ? <Loader2 className="size-3.5 animate-spin" /> : <FileUp className="size-3.5" />}
{t('reviews.importXliff')}
</Button>
<input
ref={fileInputRef}
type="file"
accept=".xliff,.xlf,application/xliff+xml"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) importXliff(f);
}}
/>
<Button size="sm" onClick={rebuild} disabled={isRebuilding || counts.approved + counts.edited === 0}>
{isRebuilding ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
{t('reviews.rebuild')}
</Button>
</div>
</div>
{loadError && (
<div className="rounded-lg border border-red-200/30 bg-red-500/10 px-4 py-3 text-sm text-red-500">
{loadError}
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="overflow-hidden rounded-lg border border-border">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="w-1/2 px-4 py-2.5 font-medium">{t('reviews.col.source')}</th>
<th className="w-1/2 px-4 py-2.5 font-medium">{t('reviews.col.target')}</th>
<th className="w-40 px-4 py-2.5 font-medium">{t('reviews.col.status')}</th>
</tr>
</thead>
<tbody>
{segments.map((seg) => {
const isEditing = editingId === seg.id;
const finalText = seg.status === 'edited' && seg.reviewed_text
? seg.reviewed_text
: seg.translated_text;
return (
<tr key={seg.id} className="border-t border-border align-top">
<td className="px-4 py-3 text-muted-foreground">
{seg.source_text}
</td>
<td className="px-4 py-3">
{isEditing ? (
<div className="space-y-2">
<textarea
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
value={draft}
onChange={(e) => setDraft(e.target.value)}
/>
<div className="flex gap-2">
<Button
size="sm"
disabled={savingId === seg.id || !draft.trim()}
onClick={async () => {
await patchSegment(seg.id, {
reviewed_text: draft,
status: 'edited',
});
setEditingId(null);
}}
>
{savingId === seg.id ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Check className="size-3.5" />
)}
{t('reviews.save')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setEditingId(null)}
>
{t('reviews.cancel')}
</Button>
</div>
</div>
) : (
<div
className={cn(
'whitespace-pre-wrap',
seg.status === 'edited' && 'text-blue-600 dark:text-blue-400'
)}
>
{finalText || <span className="italic text-muted-foreground">{t('reviews.emptyCell')}</span>}
</div>
)}
</td>
<td className="px-4 py-3">
<div className="flex flex-col items-start gap-2">
{statusBadge(seg.status)}
{!isEditing && (
<div className="flex flex-wrap gap-1">
<Button
size="sm"
variant="ghost"
className="h-7 gap-1 px-2 text-[11px]"
onClick={() => {
setEditingId(seg.id);
setDraft(finalText);
}}
>
<Pencil className="size-3" /> {t('reviews.action.edit')}
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 gap-1 px-2 text-[11px]"
disabled={seg.status === 'approved' || savingId === seg.id}
onClick={() => patchSegment(seg.id, { status: 'approved' })}
>
<Check className="size-3" /> {t('reviews.action.approveShort')}
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 gap-1 px-2 text-[11px]"
disabled={seg.status === 'pending'}
onClick={() => patchSegment(seg.id, { status: 'pending' })}
>
<Undo2 className="size-3" /> {t('reviews.action.resetShort')}
</Button>
</div>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { Zap, CheckCircle2, Lock, Loader2, Globe, Brain } from 'lucide-react';
import { API_BASE } from '@/lib/config';
import { useI18n } from '@/lib/i18n';
import { PageHeader } from '@/components/layout/page-header';
const FALLBACK_PROVIDERS_FACTORY = (t: (k: string) => string) => [
{ id: "google", label: t('services.fallback.google.label'), description: t('services.fallback.google.desc'), mode: "classic" as const },
@@ -32,7 +33,7 @@ export default function TranslationServicesPage() {
const res = await fetch(`${API_BASE}/api/v1/providers/available`, { headers });
if (res.ok) {
const data = await res.json();
const list = data.providers || [];
const list = (data.providers || []).filter((p: AvailableProvider) => p.id !== "deepl");
setProviders(list.length > 0 ? list : FALLBACK_PROVIDERS);
} else {
setProviders(FALLBACK_PROVIDERS);
@@ -52,24 +53,24 @@ export default function TranslationServicesPage() {
return (
<div className="max-w-4xl mx-auto w-full p-6 lg:p-8">
{/* ── Editorial Header ───────────────────────────────────── */}
<div className="mb-12">
<span className="accent-pill mb-4 block w-fit">{t('services.title')}</span>
<h1 className="text-5xl font-black uppercase tracking-tighter mb-4 leading-none text-brand-dark dark:text-white">
{t('services.title')}
</h1>
<p className="text-brand-dark/40 dark:text-white/40 font-medium">{t('services.subtitle')}</p>
</div>
<PageHeader
pill={t('services.pill')}
titleBase={t('services.title')}
titleAccent={t('services.titleAccent')}
subtitle={t('services.subtitle')}
className="mb-12"
/>
{isLoading ? (
<div className="flex items-center justify-center min-h-[30vh]">
<div className="text-center space-y-4">
<div className="animate-spin rounded-full h-8 w-8 border-4 border-brand-muted border-t-brand-accent mx-auto"></div>
<p className="text-[9px] font-black text-brand-dark/40 dark:text-white/40 uppercase tracking-widest">{t('services.loading')}</p>
<p className="text-xs font-bold text-brand-dark/50 dark:text-white/50 uppercase tracking-wider" role="status">{t('services.loading')}</p>
</div>
</div>
) : providers.length === 0 ? (
<div className="editorial-card p-16 bg-white dark:bg-[#141414] border-none shadow-editorial text-center">
<p className="text-brand-dark/40 dark:text-white/40 font-medium">{t('services.noProviders')}</p>
<p className="text-brand-dark/50 dark:text-white/50 font-medium">{t('services.noProviders')}</p>
</div>
) : (
<div className="space-y-12">
@@ -78,7 +79,7 @@ export default function TranslationServicesPage() {
<section className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial">
<div className="flex items-center gap-4 mb-8 text-brand-accent">
<Globe size={20} />
<h3 className="text-[11px] font-black uppercase tracking-[0.3em] text-brand-dark dark:text-white">
<h3 className="text-xs font-black uppercase tracking-[0.2em] text-brand-dark dark:text-white">
{t('services.classic')}
</h3>
</div>
@@ -90,9 +91,9 @@ export default function TranslationServicesPage() {
>
<div>
<p className="text-[11px] font-black uppercase tracking-tight text-brand-dark dark:text-white">{p.label}</p>
<p className="text-[10px] text-brand-dark/30 dark:text-white/30 font-bold uppercase tracking-widest mt-1">{p.description}</p>
<p className="text-[11px] text-brand-dark/50 dark:text-white/50 font-semibold uppercase tracking-wider mt-1">{p.description}</p>
</div>
<span className="flex items-center gap-2 px-4 py-2 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 rounded-full text-[9px] font-black uppercase tracking-widest">
<span className="flex items-center gap-2 px-4 py-2 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 rounded-full text-[10px] font-black uppercase tracking-wider">
<CheckCircle2 size={12} /> {t('services.available')}
</span>
</div>
@@ -106,7 +107,7 @@ export default function TranslationServicesPage() {
<section className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial">
<div className="flex items-center gap-4 mb-8 text-brand-accent">
<Brain size={20} />
<h3 className="text-[11px] font-black uppercase tracking-[0.3em] text-brand-dark dark:text-white">
<h3 className="text-xs font-black uppercase tracking-[0.2em] text-brand-dark dark:text-white">
{t('services.llmPro')}
</h3>
</div>
@@ -118,14 +119,14 @@ export default function TranslationServicesPage() {
>
<div>
<p className="text-[11px] font-black uppercase tracking-tight text-brand-dark dark:text-white">{p.label}</p>
<p className="text-[10px] text-brand-dark/30 dark:text-white/30 font-bold uppercase tracking-widest mt-1">{p.description}</p>
<p className="text-[11px] text-brand-dark/50 dark:text-white/50 font-semibold uppercase tracking-wider mt-1">{p.description}</p>
{p.model && (
<p className="mt-2 text-[9px] font-mono text-brand-dark/20 dark:text-white/20 uppercase tracking-widest">
<p className="mt-2 text-[10px] font-mono text-brand-dark/40 dark:text-white/40 uppercase tracking-wider">
{t('services.model')}: {p.model}
</p>
)}
</div>
<span className="flex items-center gap-2 px-4 py-2 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 rounded-full text-[9px] font-black uppercase tracking-widest">
<span className="flex items-center gap-2 px-4 py-2 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 rounded-full text-[10px] font-black uppercase tracking-wider">
<CheckCircle2 size={12} /> {t('services.available')}
</span>
</div>
@@ -141,10 +142,10 @@ export default function TranslationServicesPage() {
<div className="flex items-center gap-4 text-amber-600 dark:text-amber-400">
<Lock size={20} />
<div>
<h3 className="text-[11px] font-black uppercase tracking-[0.3em] text-amber-800 dark:text-amber-400">
<h3 className="text-xs font-black uppercase tracking-[0.2em] text-amber-800 dark:text-amber-400">
{t('services.adminOnly.title')}
</h3>
<p className="text-[10px] text-amber-600/60 dark:text-amber-400/60 font-bold uppercase tracking-widest mt-2">
<p className="text-[11px] text-amber-600/80 dark:text-amber-400/80 font-semibold uppercase tracking-wider mt-2">
{t('services.adminOnly.desc')}
</p>
</div>

View File

@@ -5,10 +5,12 @@ import {
Settings, Trash2, Loader2, FileSpreadsheet, FileText, Presentation,
} from 'lucide-react';
import { useI18n } from '@/lib/i18n';
import { PageHeader } from '@/components/layout/page-header';
export default function GeneralSettingsPage() {
const { t } = useI18n();
const [isClearing, setIsClearing] = useState(false);
const [isConfirmingClear, setIsConfirmingClear] = useState(false);
const handleClearCache = async () => {
setIsClearing(true);
@@ -24,6 +26,7 @@ export default function GeneralSettingsPage() {
} catch (error) {
console.error('Error clearing cache:', error);
setIsClearing(false);
setIsConfirmingClear(false);
}
};
@@ -36,13 +39,13 @@ export default function GeneralSettingsPage() {
return (
<div className="max-w-4xl mx-auto w-full p-6 lg:p-8">
{/* ── Editorial Header ───────────────────────────────────── */}
<div className="mb-12">
<span className="accent-pill mb-4 block w-fit">{t('settings.title')}</span>
<h1 className="text-5xl font-black uppercase tracking-tighter mb-4 leading-none text-brand-dark dark:text-white">
{t('settings.title')}
</h1>
<p className="text-brand-dark/40 dark:text-white/40 font-medium">{t('settings.subtitle')}</p>
</div>
<PageHeader
pill={t('settings.pill')}
titleBase={t('settings.title')}
titleAccent={t('settings.titleAccent')}
subtitle={t('settings.subtitle')}
className="mb-12"
/>
<div className="space-y-12">
{/* Supported Formats */}
@@ -52,10 +55,10 @@ export default function GeneralSettingsPage() {
<Settings size={24} />
</div>
<div>
<h3 className="text-[11px] font-black uppercase tracking-[0.3em] text-brand-dark dark:text-white">
<h3 className="text-xs font-black uppercase tracking-[0.2em] text-brand-dark dark:text-white">
{t('settings.formats.title')}
</h3>
<p className="text-[9px] text-brand-dark/30 dark:text-white/30 font-bold uppercase tracking-widest mt-1">
<p className="text-[10px] text-brand-dark/50 dark:text-white/50 font-semibold uppercase tracking-wider mt-1">
{t('settings.formats.subtitle')}
</p>
</div>
@@ -72,17 +75,17 @@ export default function GeneralSettingsPage() {
<div className="w-12 h-12 bg-white dark:bg-white/10 rounded-2xl flex items-center justify-center shadow-sm mb-6">
<Icon size={24} className={f.color} />
</div>
<p className="text-[11px] font-black uppercase tracking-tight text-brand-dark dark:text-white mb-2">
<p className="text-xs font-black uppercase tracking-tight text-brand-dark dark:text-white mb-2">
{f.name}
</p>
<p className="text-[9px] text-brand-dark/30 dark:text-white/30 font-bold uppercase tracking-widest mb-4">
<p className="text-[10px] text-brand-dark/50 dark:text-white/50 font-bold uppercase tracking-wider mb-4">
{f.ext}
</p>
<div className="flex flex-wrap gap-2">
{f.features.map(ft => (
<span
key={ft}
className="px-3 py-1 bg-white dark:bg-white/10 rounded-full text-[8px] font-black uppercase tracking-widest text-brand-dark/40 dark:text-white/40 border border-black/5 dark:border-white/10"
className="px-3 py-1 bg-white dark:bg-white/10 rounded-full text-[10px] font-black uppercase tracking-wider text-brand-dark/50 dark:text-white/50 border border-black/5 dark:border-white/10"
>
{ft}
</span>
@@ -104,22 +107,43 @@ export default function GeneralSettingsPage() {
<h3 className="text-2xl font-black uppercase tracking-tight text-brand-dark dark:text-white">
{t('settings.cache.title')}
</h3>
<p className="text-brand-dark/30 dark:text-white/30 text-[10px] font-black uppercase tracking-widest mt-2 leading-relaxed">
<p className="text-brand-dark/50 dark:text-white/50 text-xs font-semibold uppercase tracking-wider mt-2 leading-relaxed">
{t('settings.cache.desc')}
</p>
</div>
</div>
<button
onClick={handleClearCache}
disabled={isClearing}
className="px-8 py-3 bg-brand-muted dark:bg-white/10 text-brand-dark/40 dark:text-white/40 rounded-xl text-[9px] font-black uppercase tracking-widest hover:text-brand-dark dark:hover:text-white transition-all disabled:opacity-50 shrink-0"
>
{isClearing ? (
<><Loader2 className="me-1.5 h-3.5 w-3.5 animate-spin inline" />{t('settings.cache.clearing')}</>
<div className="flex flex-col gap-2 shrink-0">
{isConfirmingClear ? (
<>
<button
onClick={handleClearCache}
disabled={isClearing}
className="px-8 py-3 bg-red-500 text-white rounded-xl text-xs font-black uppercase tracking-wider hover:bg-red-600 transition-all disabled:opacity-50 shrink-0"
>
{isClearing ? (
<><Loader2 className="me-1.5 h-3.5 w-3.5 animate-spin inline" />{t('settings.cache.clearing')}</>
) : (
t('settings.cache.confirm')
)}
</button>
<button
onClick={() => setIsConfirmingClear(false)}
disabled={isClearing}
className="px-8 py-2 text-[11px] font-bold uppercase tracking-wider text-brand-dark/50 dark:text-white/50 hover:text-brand-dark dark:hover:text-white transition-colors"
>
{t('settings.cache.cancelClear')}
</button>
</>
) : (
<><Trash2 className="me-1.5 h-3.5 w-3.5 inline" />{t('settings.cache.clear')}</>
<button
onClick={() => setIsConfirmingClear(true)}
disabled={isClearing}
className="px-8 py-3 bg-brand-muted dark:bg-white/10 text-brand-dark/50 dark:text-white/50 rounded-xl text-xs font-black uppercase tracking-wider hover:text-brand-dark dark:hover:text-white transition-all disabled:opacity-50 shrink-0"
>
<Trash2 className="me-1.5 h-3.5 w-3.5 inline" />{t('settings.cache.clear')}
</button>
)}
</button>
</div>
</section>
</div>
</div>

View File

@@ -0,0 +1,263 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { Users, Plus, UserPlus, Trash2, Loader2, Crown } from 'lucide-react';
import { apiClient } from '@/lib/apiClient';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useToast } from '@/components/ui/toast';
import { useI18n } from '@/lib/i18n';
import { PageHeader } from '@/components/layout/page-header';
interface WorkspaceMember {
id: string;
user_id: string;
role: string;
created_at: string | null;
}
interface Workspace {
id: string;
name: string;
owner_id: string;
member_count: number;
members: WorkspaceMember[];
my_role?: string;
seat_limit?: number; // -1 = illimité
}
export default function TeamsPage() {
const notify = useToast();
const { t } = useI18n();
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [needsBusiness, setNeedsBusiness] = useState(false);
const [newName, setNewName] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [inviteEmail, setInviteEmail] = useState<Record<string, string>>({});
const [inviteRole, setInviteRole] = useState<Record<string, string>>({});
const [busyWs, setBusyWs] = useState<string | null>(null);
const load = useCallback(async () => {
setIsLoading(true);
try {
const res = await apiClient.get<{ data: Workspace[] }>('/api/v1/workspaces');
setWorkspaces(res.data);
setNeedsBusiness(false);
} catch (err) {
const message = err instanceof Error ? err.message : '';
if (message.includes('Business') || message.includes('PLAN_REQUIRED')) {
setNeedsBusiness(true);
} else {
notify.error({ title: t('teams.error.title'), description: message || t('teams.error.load') });
}
} finally {
setIsLoading(false);
}
}, [notify]);
useEffect(() => {
load();
}, [load]);
const createWorkspace = async () => {
if (!newName.trim()) return;
setIsCreating(true);
try {
await apiClient.post('/api/v1/workspaces', { name: newName.trim() });
setNewName('');
notify.success({ title: t('teams.created'), description: t('teams.createdDesc') });
await load();
} catch (err) {
notify.error({
title: t('teams.createFailed'),
description: err instanceof Error ? err.message : undefined,
});
} finally {
setIsCreating(false);
}
};
const inviteMember = async (wsId: string) => {
const email = (inviteEmail[wsId] || '').trim();
if (!email) return;
setBusyWs(wsId);
try {
await apiClient.post(`/api/v1/workspaces/${wsId}/members`, {
email,
role: inviteRole[wsId] || 'member',
});
setInviteEmail((p) => ({ ...p, [wsId]: '' }));
notify.success({ title: t('teams.memberAdded'), description: email });
await load();
} catch (err) {
notify.error({
title: t('teams.addFailed'),
description: err instanceof Error ? err.message : undefined,
});
} finally {
setBusyWs(null);
}
};
const removeMember = async (wsId: string, userId: string) => {
setBusyWs(wsId);
try {
await apiClient.delete(`/api/v1/workspaces/${wsId}/members/${userId}`);
await load();
} catch (err) {
notify.error({
title: t('teams.removeFailed'),
description: err instanceof Error ? err.message : undefined,
});
} finally {
setBusyWs(null);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-16">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
pill={t('teams.pill')}
titleBase={t('teams.titleBase')}
titleAccent={t('teams.titleAccent')}
subtitle={t('teams.subtitle')}
/>
{needsBusiness ? (
<div className="rounded-lg border border-border bg-card p-6 text-center">
<p className="text-sm text-muted-foreground">
{t('teams.needsBusiness')}
</p>
</div>
) : (
<>
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-border bg-card p-4">
<div className="flex-1 min-w-56 space-y-1.5">
<Label htmlFor="ws-name">{t('teams.newWorkspace')}</Label>
<Input
id="ws-name"
placeholder={t('teams.newPlaceholder')}
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
</div>
<Button onClick={createWorkspace} disabled={isCreating || !newName.trim()}>
{isCreating ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
{t('teams.create')}
</Button>
</div>
<div className="space-y-4">
{workspaces.map((ws) => (
<div key={ws.id} className="rounded-lg border border-border bg-card p-4 space-y-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">{ws.name}</span>
{ws.my_role === 'owner' && (
<Badge className="gap-1 bg-amber-500/15 text-amber-600 border-amber-500/30">
<Crown className="size-3" /> {t('teams.owner')}
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground">
{ws.seat_limit === -1
? t('teams.seatsUnlimited', { count: ws.member_count })
: t('teams.seats', { count: ws.member_count, limit: ws.seat_limit ?? '?' })}
</span>
</div>
<ul className="divide-y divide-border rounded-md border border-border">
{ws.members.map((m) => (
<li key={m.id} className="flex items-center justify-between px-3 py-2 text-sm">
<span className="flex items-center gap-2">
<span className="text-muted-foreground">{m.user_id.slice(0, 8)}</span>
<Badge variant="outline">{m.role}</Badge>
</span>
{m.role !== 'owner' && (
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-red-500"
disabled={busyWs === ws.id || ws.my_role === 'member'}
onClick={() => removeMember(ws.id, m.user_id)}
title={t('teams.removeMember')}
>
<Trash2 className="size-3.5" />
</Button>
)}
</li>
))}
</ul>
{(ws.my_role === 'owner' || ws.my_role === 'admin') && (
<div className="flex flex-wrap items-end gap-2">
<div className="flex-1 min-w-48 space-y-1.5">
<Label htmlFor={`invite-${ws.id}`}>{t('teams.addMember')}</Label>
<Input
id={`invite-${ws.id}`}
type="email"
placeholder={t('teams.emailPlaceholder')}
value={inviteEmail[ws.id] || ''}
onChange={(e) =>
setInviteEmail((p) => ({ ...p, [ws.id]: e.target.value }))
}
/>
</div>
<Select
value={inviteRole[ws.id] || 'member'}
onValueChange={(v) => setInviteRole((p) => ({ ...p, [ws.id]: v }))}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">{t('teams.role.member')}</SelectItem>
<SelectItem value="admin">{t('teams.role.admin')}</SelectItem>
</SelectContent>
</Select>
<Button
size="sm"
onClick={() => inviteMember(ws.id)}
disabled={busyWs === ws.id || !(inviteEmail[ws.id] || '').trim()}
>
{busyWs === ws.id ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<UserPlus className="size-3.5" />
)}
{t('teams.invite')}
</Button>
</div>
)}
</div>
))}
{workspaces.length === 0 && (
<div className="rounded-lg border border-border bg-card p-6 text-center text-sm text-muted-foreground">
{t('teams.empty')}
</div>
)}
</div>
</>
)}
</div>
);
}

View File

@@ -1,92 +0,0 @@
'use client';
import { useRef } from 'react';
import { Upload, FileSpreadsheet, FileText, Presentation } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import type { UseFileUploadReturn } from './types';
interface FileDropZoneProps {
upload: UseFileUploadReturn;
}
export function FileDropZone({ upload }: FileDropZoneProps) {
const inputRef = useRef<HTMLInputElement>(null);
const { t } = useI18n();
const handleClick = () => inputRef.current?.click();
return (
<div
role="button"
tabIndex={0}
aria-label={t('dashboard.translate.dropzone.uploadAria')}
className={cn(
'relative flex flex-col items-center justify-center gap-6 rounded-2xl border-2 border-dashed',
'min-h-[320px] cursor-pointer select-none transition-all duration-200 px-8 py-12',
upload.isDragOver
? 'border-primary bg-primary/8 scale-[1.01]'
: 'border-border bg-muted/20 hover:border-primary/50 hover:bg-muted/40'
)}
onDragOver={upload.handleDragOver}
onDragLeave={upload.handleDragLeave}
onDrop={upload.handleDrop}
onClick={handleClick}
onKeyDown={(e) => e.key === 'Enter' || e.key === ' ' ? handleClick() : undefined}
>
{/* Icon */}
<div className={cn(
'flex size-20 items-center justify-center rounded-2xl transition-colors',
upload.isDragOver ? 'bg-primary/15' : 'bg-secondary'
)}>
<Upload className={cn(
'size-9 transition-colors',
upload.isDragOver ? 'text-primary' : 'text-muted-foreground'
)} />
</div>
{/* Text */}
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-lg font-semibold text-foreground">
{t('dashboard.translate.dropzone.title')}
</p>
<p className="text-sm text-muted-foreground">
{t('dashboard.translate.dropzone.subtitle')}
</p>
</div>
{/* Format badges */}
<div className="flex flex-wrap items-center justify-center gap-3">
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
<FileText className="size-3.5 text-blue-500" />
Word (.docx)
</span>
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
<FileSpreadsheet className="size-3.5 text-green-500" />
Excel (.xlsx)
</span>
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
<Presentation className="size-3.5 text-orange-500" />
PowerPoint (.pptx)
</span>
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
<svg className="size-3.5 text-red-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
<polyline points="14 2 14 8 20 8"/>
<text x="12" y="16" textAnchor="middle" fontSize="5" fontWeight="bold" fill="currentColor" stroke="none">PDF</text>
</svg>
PDF (.pdf)
</span>
</div>
<input
ref={inputRef}
type="file"
accept=".xlsx,.docx,.pptx,.pdf"
className="hidden"
onChange={upload.handleFileSelect}
aria-label={t('dashboard.translate.dropzone.uploadAria')}
/>
</div>
);
}

View File

@@ -1,53 +0,0 @@
'use client';
import { FileSpreadsheet, FileText, Presentation, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
const FILE_ICONS: Record<string, React.ElementType> = {
xlsx: FileSpreadsheet,
docx: FileText,
pptx: Presentation,
};
interface FilePreviewProps {
file: File;
onRemove: () => void;
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function FilePreview({ file, onRemove }: FilePreviewProps) {
const ext = file.name.split('.').pop()?.toLowerCase() || '';
const FileIcon = FILE_ICONS[ext] || FileText;
return (
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-secondary">
<FileIcon className="size-5 text-foreground" />
</div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-medium text-foreground truncate">
{file.name}
</span>
<span className="text-xs text-muted-foreground">
{formatFileSize(file.size)} · .{ext}
</span>
</div>
<Button
variant="ghost"
size="icon-sm"
className="ms-2 text-muted-foreground hover:text-foreground shrink-0"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
<X className="size-4" />
</Button>
</div>
);
}

View File

@@ -272,7 +272,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
>
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<BookOpen size={14} className="text-brand-accent shrink-0" />
<BookOpen size={14} className="text-brand-goldink dark:text-brand-accent shrink-0" />
<span className="text-xs font-black uppercase tracking-[0.1em] text-brand-dark dark:text-white truncate">
{t('translate.glossary.title') || 'Glossaire & Terminologie'}
</span>
@@ -285,6 +285,9 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
{isPro && mode === 'llm' && (
<button
type="button"
role="switch"
aria-checked={isGlossaryEnabled}
aria-label={t('translate.glossary.title') || 'Glossaire'}
disabled={disabled}
onClick={() => {
const nextVal = !isGlossaryEnabled;
@@ -307,7 +310,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
>
<div className={cn(
"w-3.5 h-3.5 bg-white rounded-full absolute top-0.5 shadow transition-all",
isGlossaryEnabled ? 'left-[13px]' : 'left-0.5'
isGlossaryEnabled ? 'start-[13px]' : 'start-0.5'
)} />
</button>
)}
@@ -359,6 +362,8 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
type="button"
disabled={disabled}
onClick={() => setIsOpen(!isOpen)}
aria-haspopup="listbox"
aria-expanded={isOpen}
className={cn(
"w-full bg-white dark:bg-[#1a1a1a] border border-black/5 dark:border-white/5 hover:border-black/10 dark:hover:border-white/10 py-2.5 px-3 rounded-lg flex items-center justify-between shadow-sm transition-all cursor-pointer",
isOpen && "border-brand-accent dark:border-brand-accent",
@@ -398,7 +403,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
onClick={() => setFilterByLang(!filterByLang)}
className={cn(
"text-[10px] font-bold px-2 py-0.5 rounded transition-colors cursor-pointer",
filterByLang ? "bg-brand-accent/10 text-brand-accent" : "bg-brand-dark/10 dark:bg-white/10 text-brand-dark/50 dark:text-white/50"
filterByLang ? "bg-brand-accent/10 text-brand-goldink dark:text-brand-accent" : "bg-brand-dark/10 dark:bg-white/10 text-brand-dark/50 dark:text-white/50"
)}
>
{filterByLang ? t('translate.glossary.active') || 'Actif' : t('translate.glossary.inactive') || 'Inactif'}
@@ -426,7 +431,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
className={cn(
"w-full text-left px-2.5 py-2 rounded-lg transition-colors flex items-center justify-between cursor-pointer",
isSelected
? 'bg-brand-accent/5 text-brand-accent'
? 'bg-brand-accent/5 text-brand-goldink dark:text-brand-accent'
: 'hover:bg-brand-muted dark:hover:bg-white/5 text-brand-dark/70 dark:text-white/70'
)}
>
@@ -436,7 +441,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
{flag} {g.target_language === 'multi' ? `🌐 ${t('translate.glossary.multilingual') || 'MULTILINGUE'}` : getFlag(g.target_language || targetLang)} {g.terms_count} {t('translate.glossary.terms') || 'termes'}
</span>
</div>
{isSelected && <Check size={12} className="text-brand-accent shrink-0" />}
{isSelected && <Check size={12} className="text-brand-goldink dark:text-brand-accent shrink-0" />}
</button>
);
})}
@@ -475,7 +480,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
className={cn(
"w-full text-left px-2.5 py-2 rounded-lg transition-colors flex items-center justify-between cursor-pointer",
isAlreadySelected
? 'bg-brand-accent/5 text-brand-accent'
? 'bg-brand-accent/5 text-brand-goldink dark:text-brand-accent'
: 'hover:bg-brand-muted dark:hover:bg-white/5 text-brand-dark/70 dark:text-white/70'
)}
>
@@ -488,9 +493,9 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
</span>
</div>
{isImporting ? (
<Loader2 size={12} className="animate-spin text-brand-accent shrink-0" />
<Loader2 size={12} className="animate-spin text-brand-goldink dark:text-brand-accent shrink-0" />
) : isAlreadySelected ? (
<Check size={12} className="text-brand-accent shrink-0" />
<Check size={12} className="text-brand-goldink dark:text-brand-accent shrink-0" />
) : null}
</button>
);
@@ -542,7 +547,7 @@ export function GlossarySelector({ sourceLang, targetLang, isPro, mode, glossary
{isLoadingDetail ? (
<div className="flex items-center justify-center py-3 gap-1.5 text-xs text-brand-dark/40 dark:text-white/30 font-light">
<Loader2 size={12} className="animate-spin text-brand-accent" /> {t('translate.glossary.loading') || 'Chargement...'}
<Loader2 size={12} className="animate-spin text-brand-goldink dark:text-brand-accent" /> {t('translate.glossary.loading') || 'Chargement...'}
</div>
) : selectedGlossaryDetail?.terms && selectedGlossaryDetail.terms.length > 0 ? (
<div className="grid grid-cols-1 gap-1 max-h-[120px] overflow-y-auto pr-1">

View File

@@ -24,6 +24,7 @@ function Combobox({
autoLabel,
placeholder,
onChange,
ariaLabel,
}: {
value: string;
options: Language[];
@@ -31,12 +32,14 @@ function Combobox({
autoLabel: string;
placeholder: string;
onChange: (code: string) => void;
ariaLabel: string;
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const ref = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (open) inputRef.current?.focus();
@@ -50,6 +53,12 @@ function Combobox({
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const closeAndRefocus = () => {
setOpen(false);
setQuery('');
triggerRef.current?.focus();
};
const allOptions = includeAuto
? [{ code: 'auto', name: autoLabel }, ...options]
: options;
@@ -61,21 +70,56 @@ function Combobox({
const label = value === 'auto' ? autoLabel : allOptions.find(l => l.code === value)?.name ?? value;
const handleListboxKeys = (e: React.KeyboardEvent) => {
if (e.key === 'Escape' && open) {
e.stopPropagation();
closeAndRefocus();
return;
}
if (!open || (e.key !== 'ArrowDown' && e.key !== 'ArrowUp')) return;
e.preventDefault();
const options = Array.from(
ref.current?.querySelectorAll<HTMLButtonElement>('[role="option"]') ?? []
);
if (options.length === 0) return;
const idx = options.indexOf(document.activeElement as HTMLButtonElement);
const next = e.key === 'ArrowDown'
? options[Math.min(options.length - 1, idx + 1)]
: options[Math.max(0, idx - 1)];
// from the search input, ArrowDown goes to the first option
(idx === -1 && document.activeElement === inputRef.current
? options[e.key === 'ArrowDown' ? 0 : options.length - 1]
: next
).focus();
};
return (
<div ref={ref} className="relative text-left">
<div
ref={ref}
className="relative text-left"
onKeyDown={handleListboxKeys}
>
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(!open)}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={ariaLabel}
className={cn(
'w-full py-2.5 px-3.5 bg-brand-muted/60 dark:bg-white/5 rounded-xl border text-xs font-bold uppercase tracking-wider text-brand-dark dark:text-white flex items-center justify-between hover:border-brand-accent/50 transition-all select-none cursor-pointer',
open ? 'border-brand-accent/50' : 'border-brand-accent/20 dark:border-white/10'
)}
>
<span className="truncate">{label || placeholder}</span>
<ChevronDown className={cn('size-3.5 shrink-0 text-brand-accent transition-transform ms-2', open && 'rotate-180')} />
<span className="truncate normal-case">{label || placeholder}</span>
<ChevronDown className={cn('size-3.5 shrink-0 text-brand-goldink dark:text-brand-accent transition-transform ms-2', open && 'rotate-180')} />
</button>
{open && (
<div className="absolute top-[102%] right-0 left-0 bg-white dark:bg-[#1a1a1a] border border-black/10 dark:border-white/10 rounded-xl shadow-2xl p-2 z-50 max-h-48 overflow-y-auto animate-fade-in">
<div
role="listbox"
aria-label={ariaLabel}
className="absolute top-[102%] right-0 left-0 bg-white dark:bg-[#1a1a1a] border border-black/10 dark:border-white/10 rounded-xl shadow-2xl p-2 z-50 max-h-48 overflow-y-auto animate-fade-in"
>
<div className="border-b border-black/5 dark:border-white/5 px-2 py-1.5">
<input
ref={inputRef}
@@ -83,6 +127,7 @@ function Combobox({
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('langSelector.search')}
aria-label={t('langSelector.search')}
className="w-full bg-transparent px-1 py-1 text-xs outline-none placeholder:text-brand-dark/30 dark:placeholder:text-white/30 text-brand-dark dark:text-white"
/>
</div>
@@ -94,16 +139,18 @@ function Combobox({
<button
key={lang.code}
type="button"
onClick={() => { onChange(lang.code); setOpen(false); setQuery(''); }}
role="option"
aria-selected={value === lang.code}
onClick={() => { onChange(lang.code); closeAndRefocus(); }}
className={cn(
'flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-xs font-bold uppercase tracking-wider transition-colors cursor-pointer',
value === lang.code
? 'bg-brand-accent/10 text-brand-accent'
? 'bg-brand-accent/10 text-brand-goldink dark:text-brand-accent'
: 'text-brand-dark/70 dark:text-white/70 hover:bg-brand-muted dark:hover:bg-white/5'
)}
>
<span className="truncate">{lang.name}</span>
{value === lang.code && <Check className="size-3.5 text-brand-accent shrink-0" />}
{value === lang.code && <Check className="size-3.5 text-brand-goldink dark:text-brand-accent shrink-0" />}
</button>
))}
</div>
@@ -152,6 +199,7 @@ export default function LanguageSelector({
autoLabel={t('dashboard.translate.language.autoDetect') || 'Auto-détecté'}
placeholder={t('dashboard.translate.language.selectPlaceholder') || 'Langue...'}
onChange={onSourceChange}
ariaLabel={`${t('langSelector.source')}${t('dashboard.translate.language.autoDetect') || 'Auto-détecté'}`}
/>
</div>
@@ -161,10 +209,11 @@ export default function LanguageSelector({
type="button"
onClick={() => canSwap && (() => { const s = sourceLang; onSourceChange(targetLang); onTargetChange(s); })()}
disabled={!canSwap}
aria-label={t('langSelector.swap')}
className={cn(
'flex size-7 items-center justify-center rounded-xl transition-all cursor-pointer',
canSwap
? 'text-brand-dark/50 dark:text-white/50 hover:text-brand-accent hover:bg-brand-muted/50 dark:hover:bg-white/5'
? 'text-brand-dark/50 dark:text-white/50 hover:text-brand-goldink dark:text-brand-accent hover:bg-brand-muted/50 dark:hover:bg-white/5'
: 'cursor-not-allowed opacity-30'
)}
title={t('langSelector.swap')}
@@ -183,6 +232,7 @@ export default function LanguageSelector({
autoLabel=""
placeholder={t('dashboard.translate.language.selectPlaceholder') || 'Langue...'}
onChange={onTargetChange}
ariaLabel={t('langSelector.target')}
/>
</div>
</div>

View File

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

View File

@@ -1,173 +0,0 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import {
CheckCircle2, Download, Plus, Loader2, FileText,
Timer, Activity, TrendingUp,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useNotification } from '@/components/ui/notification';
import { useI18n } from '@/lib/i18n';
import { API_BASE } from '@/lib/config';
import { cn } from '@/lib/utils';
interface TranslationCompleteProps {
jobId: string;
fileName: string | null;
onNewTranslation: () => void;
}
export function TranslationComplete({
jobId,
fileName,
onNewTranslation,
}: TranslationCompleteProps) {
const [isDownloading, setIsDownloading] = useState(false);
const { success, error } = useNotification();
const { t } = useI18n();
const blobUrlRef = useRef<string | null>(null);
const handleDownload = async () => {
setIsDownloading(true);
try {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const response = await fetch(`${API_BASE}/api/v1/download/${jobId}`, { headers });
if (!response.ok) {
let msg = t('dashboard.translate.complete.toastFailDesc');
try {
const body = await response.json();
msg = body.message || body.error || msg;
} catch { /* not JSON */ }
throw new Error(msg);
}
const contentDisposition = response.headers.get('Content-Disposition');
let downloadFilename = 'translated_document';
if (contentDisposition) {
const match = contentDisposition.match(/filename\*?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']+)/i);
if (match?.[1]) downloadFilename = match[1];
} else if (fileName) {
const ext = fileName.split('.').pop() || '';
const base = fileName.replace(/\.[^.]+$/, '');
downloadFilename = `${base}_translated.${ext}`;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
blobUrlRef.current = url;
const a = document.createElement('a');
a.href = url;
a.download = downloadFilename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => {
if (blobUrlRef.current) { URL.revokeObjectURL(blobUrlRef.current); blobUrlRef.current = null; }
}, 1000);
success({
title: t('dashboard.translate.complete.toastOkTitle'),
description: t('dashboard.translate.complete.toastOkDesc', { name: downloadFilename }),
});
} catch (err) {
error({
title: t('dashboard.translate.complete.toastFailTitle'),
description: err instanceof Error ? err.message : t('dashboard.translate.complete.toastFailDesc'),
});
} finally {
setIsDownloading(false);
setTimeout(() => {
if (blobUrlRef.current) { URL.revokeObjectURL(blobUrlRef.current); blobUrlRef.current = null; }
}, 5000);
}
};
useEffect(() => {
return () => {
if (blobUrlRef.current) { URL.revokeObjectURL(blobUrlRef.current); blobUrlRef.current = null; }
};
}, []);
return (
<div className="flex w-full max-w-lg flex-col gap-0 overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
{/* ═══ Success header ═══ */}
<div className="relative overflow-hidden border-b border-emerald-200/50 bg-gradient-to-r from-emerald-500/8 via-emerald-500/5 to-transparent px-8 py-6 text-center">
<div className="mx-auto flex size-16 items-center justify-center rounded-2xl bg-emerald-500 shadow-lg shadow-emerald-500/20">
<CheckCircle2 className="size-8 text-white" />
</div>
<h3 className="mt-4 text-xl font-bold text-foreground">
{t('dashboard.translate.complete.title')}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{fileName
? t('dashboard.translate.complete.descNamed', { name: fileName })
: t('dashboard.translate.complete.descGeneric')}
</p>
<div className="mt-3 inline-flex items-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700 dark:border-emerald-800/50 dark:bg-emerald-950/30 dark:text-emerald-400">
<TrendingUp className="size-3" /> {t('translateComplete.highQuality')}
</div>
</div>
<div className="p-8 space-y-6">
{/* ═══ Result stats ═══ */}
<div className="grid grid-cols-3 gap-2.5">
<div className="flex flex-col items-center gap-1 rounded-xl border border-emerald-100 bg-emerald-50/50 p-3 dark:border-emerald-900/30 dark:bg-emerald-950/10">
<FileText className="size-4 text-emerald-600" />
<p className="text-sm font-bold text-foreground">142</p>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{t('translateComplete.segments')}</p>
</div>
<div className="flex flex-col items-center gap-1 rounded-xl border border-emerald-100 bg-emerald-50/50 p-3 dark:border-emerald-900/30 dark:bg-emerald-950/10">
<Activity className="size-4 text-emerald-600" />
<p className="text-sm font-bold text-foreground">12.8k</p>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{t('translateComplete.characters')}</p>
</div>
<div className="flex flex-col items-center gap-1 rounded-xl border border-emerald-100 bg-emerald-50/50 p-3 dark:border-emerald-900/30 dark:bg-emerald-950/10">
<Timer className="size-4 text-emerald-600" />
<p className="text-sm font-bold text-emerald-600">96%</p>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{t('translateComplete.confidence')}</p>
</div>
</div>
{/* ═══ Actions ═══ */}
<div className="flex flex-col gap-3">
<Button
size="lg"
className="h-12 w-full gap-2 text-base font-semibold"
onClick={handleDownload}
disabled={isDownloading}
>
{isDownloading ? (
<>
<Loader2 className="size-5 animate-spin" />
{t('dashboard.translate.complete.downloading')}
</>
) : (
<>
<Download className="size-5" />
{t('dashboard.translate.complete.download')}
</>
)}
</Button>
<Button
variant="outline"
size="lg"
className="h-11 w-full gap-2"
onClick={onNewTranslation}
>
<Plus className="size-4" />
{t('dashboard.translate.complete.newTranslation')}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,89 +0,0 @@
'use client';
import { Lock } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { TranslationMode } from './types';
import { useI18n } from '@/lib/i18n';
interface TranslationModeToggleProps {
mode: TranslationMode;
onModeChange: (mode: TranslationMode) => void;
isPro: boolean;
}
export function TranslationModeToggle({
mode,
onModeChange,
isPro,
}: TranslationModeToggleProps) {
const { t } = useI18n();
return (
<TooltipProvider>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
{t('translate.mode.label')}
</label>
<div className="flex rounded-lg border border-border bg-muted p-1">
<button
type="button"
className={cn(
'flex-1 rounded-md px-4 py-2 text-sm font-medium transition-all',
mode === 'classic'
? 'bg-card text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
onClick={() => onModeChange('classic')}
>
{t('translate.mode.classic')}
<span className="ms-1.5 text-xs text-muted-foreground">
{t('translate.mode.classicDesc')}
</span>
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
'flex-1 rounded-md px-4 py-2 text-sm font-medium transition-all relative',
mode === 'llm'
? 'bg-card text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
!isPro && 'cursor-not-allowed opacity-60'
)}
onClick={() => isPro && onModeChange('llm')}
disabled={!isPro}
>
{t('translate.mode.proLlm')}
<span className="ms-1.5 text-xs text-muted-foreground">
{t('translate.mode.proLlmDesc')}
</span>
{!isPro && (
<Lock className="absolute right-2 top-1/2 -translate-y-1/2 size-3 text-muted-foreground" />
)}
</button>
</TooltipTrigger>
{!isPro && (
<TooltipContent side="top">
<p>{t('translate.mode.tooltip')}</p>
</TooltipContent>
)}
</Tooltip>
</div>
{!isPro && (
<p className="text-xs text-muted-foreground">
<a href="/pricing" className="text-primary hover:underline">
{t('translate.mode.upgradeLink')}
</a>{' '}
{t('translate.mode.upgradeDesc')}
</p>
)}
</div>
</TooltipProvider>
);
}

View File

@@ -1,358 +0,0 @@
'use client';
import { useEffect, useRef, useState, useMemo } from 'react';
import {
AlertTriangle, Loader2, Clock, WifiOff,
Upload, Search, Languages, Wrench, CheckCircle2,
FileText, Timer, Gauge, Activity,
RotateCcw,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
/* ── Types ──────────────────────────────────────────────────────── */
interface TranslationProgressProps {
progress: number;
currentStep: string;
estimatedRemaining: number | null;
error: string | null;
isPolling?: boolean;
isUploading?: boolean;
isCompleted?: boolean;
/** Extra info for the monitor panel */
fileName?: string | null;
sourceLang?: string;
targetLang?: string;
providerLabel?: string | null;
onCancel?: () => void;
}
/* ── Pipeline step definition ───────────────────────────────────── */
interface PipelineStep {
id: string;
label: string;
icon: React.ElementType;
/** Progress range where this step is active */
startsAt: number;
}
const PIPELINE_STEPS: PipelineStep[] = [
{ id: 'upload', label: 'Upload', icon: Upload, startsAt: 0 },
{ id: 'analyze', label: 'Analyse', icon: Search, startsAt: 10 },
{ id: 'translate', label: 'Traduction', icon: Languages, startsAt: 25 },
{ id: 'rebuild', label: 'Reconstruction', icon: Wrench, startsAt: 75 },
{ id: 'finalize', label: 'Finalisation', icon: CheckCircle2, startsAt: 92 },
];
/* ── Simulated activity messages ────────────────────────────────── */
const ACTIVITY_MESSAGES: Record<string, string[]> = {
upload: [
'Fichier reçu avec succès',
'Vérification du format...',
],
analyze: [
'Analyse de la structure du document',
'Extraction des segments de texte',
'Détection de la langue source',
],
translate: [
'Connexion au moteur de traduction',
'Traduction des segments en cours...',
'Traitement des tableaux et graphiques',
'Conservation de la mise en forme',
'Validation des traductions',
],
rebuild: [
'Reconstruction du document traduit',
'Application de la mise en forme originale',
'Vérification de l\'intégrité',
],
finalize: [
'Contrôle qualité final',
'Préparation du téléchargement',
],
};
/* ── Helpers ────────────────────────────────────────────────────── */
function getActiveStepIndex(progress: number): number {
let idx = 0;
for (let i = PIPELINE_STEPS.length - 1; i >= 0; i--) {
if (progress >= PIPELINE_STEPS[i].startsAt) { idx = i; break; }
}
return idx;
}
function formatTime(seconds: number | null): string {
if (seconds === null || seconds <= 0) return '';
if (seconds < 60) return `~${seconds}s`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `~${m}m${s > 0 ? ` ${s}s` : ''}`;
}
function formatElapsed(totalSeconds: number): string {
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
/* ── Component ──────────────────────────────────────────────────── */
export function TranslationProgress({
progress,
currentStep,
estimatedRemaining,
error,
isPolling = true,
isUploading = false,
isCompleted = false,
fileName,
sourceLang,
targetLang,
providerLabel,
onCancel,
}: TranslationProgressProps) {
const { t } = useI18n();
const [animate, setAnimate] = useState(false);
const [elapsed, setElapsed] = useState(0);
const [activities, setActivities] = useState<{ text: string; time: string }[]>([]);
const prevStepIdx = useRef(-1);
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Animate progress bar on first progress
useEffect(() => {
if (progress > 0) setAnimate(true);
else {
setAnimate(false);
const tid = setTimeout(() => setAnimate(true), 50);
return () => clearTimeout(tid);
}
}, [progress]);
// Elapsed timer
useEffect(() => {
if (isCompleted || error) {
if (elapsedRef.current) clearInterval(elapsedRef.current);
return;
}
elapsedRef.current = setInterval(() => setElapsed(e => e + 1), 1000);
return () => { if (elapsedRef.current) clearInterval(elapsedRef.current); };
}, [isCompleted, error]);
// Activity log — push messages when step changes
const activeIdx = getActiveStepIndex(progress);
useEffect(() => {
if (activeIdx !== prevStepIdx.current) {
prevStepIdx.current = activeIdx;
const step = PIPELINE_STEPS[activeIdx];
if (!step) return;
const msgs = ACTIVITY_MESSAGES[step.id] || [];
msgs.forEach((msg, i) => {
setTimeout(() => {
setActivities(prev => {
const next = [{ text: msg, time: formatElapsed(elapsed) }, ...prev];
return next.slice(0, 10);
});
}, i * 600);
});
}
}, [activeIdx, elapsed]);
// Simulated stats
const totalSegments = 142;
const totalChars = 12847;
const doneSeg = Math.round(totalSegments * progress / 100);
const doneChars = Math.round(totalChars * progress / 100);
const speed = elapsed > 3 ? (doneSeg / (elapsed / 60)).toFixed(1) : '—';
/* ── Error state ──────────────────────────────────────────────── */
if (error) {
return (
<div className="flex flex-col gap-6">
<div className="rounded-xl bg-destructive/10 border border-destructive/20 p-5" role="alert" aria-live="assertive">
<div className="flex items-start gap-3">
<AlertTriangle className="size-5 text-destructive shrink-0 mt-0.5" />
<div>
<p className="text-sm font-semibold text-destructive mb-1">{t('dashboard.translate.progress.failedTitle')}</p>
<p className="text-sm text-destructive/80">{error}</p>
</div>
</div>
</div>
{onCancel && (
<button onClick={onCancel} className="mx-auto flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground transition hover:bg-muted hover:text-foreground">
<RotateCcw className="size-4" />{t('dashboard.translate.actions.tryAgain')}
</button>
)}
</div>
);
}
const showConnectionLost = !isPolling && !isCompleted && !isUploading;
return (
<div className="flex flex-col gap-0 overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
{/* ═══ Header band ═══ */}
<div className="relative overflow-hidden border-b border-border bg-gradient-to-r from-primary/5 via-primary/8 to-primary/3 px-6 py-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10">
<Loader2 className="size-5 animate-spin text-primary" />
</div>
<div>
<h2 className="text-base font-bold text-foreground leading-tight">Traduction en cours</h2>
{fileName && (
<p className="text-xs text-muted-foreground mt-0.5 truncate max-w-[260px]">{fileName}</p>
)}
</div>
</div>
{estimatedRemaining != null && estimatedRemaining > 0 && (
<div className="flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1.5 text-xs font-semibold text-primary">
<Clock className="size-3.5" />
{formatTime(estimatedRemaining)}
</div>
)}
</div>
</div>
<div className="flex flex-col gap-6 p-6">
{/* ═══ Pipeline Stepper ═══ */}
<div className="flex items-start justify-between">
{PIPELINE_STEPS.map((step, i) => {
const isActive = i === activeIdx;
const isDone = i < activeIdx;
const Icon = step.icon;
return (
<div key={step.id} className="flex flex-col items-center gap-1.5 relative" style={{ flex: i < PIPELINE_STEPS.length - 1 ? 1 : 'none' }}>
{/* Step circle + connector */}
<div className="flex items-center w-full">
<div className={cn(
'flex size-9 shrink-0 items-center justify-center rounded-full transition-all duration-500',
isDone
? 'bg-primary text-primary-foreground shadow-md shadow-primary/20'
: isActive
? 'bg-primary text-primary-foreground shadow-lg shadow-primary/25 ring-[3px] ring-primary/20'
: 'bg-muted text-muted-foreground',
)}>
{isDone ? (
<CheckCircle2 className="size-4" />
) : (
<Icon className={cn('size-4', isActive && 'animate-pulse')} />
)}
</div>
{i < PIPELINE_STEPS.length - 1 && (
<div className={cn(
'mx-1 h-[2px] flex-1 rounded-full transition-colors duration-500',
i < activeIdx ? 'bg-primary' : 'bg-border',
)} />
)}
</div>
<span className={cn(
'text-[11px] font-medium transition-colors duration-300',
isDone || isActive ? 'text-primary' : 'text-muted-foreground',
)}>
{step.label}
</span>
</div>
);
})}
</div>
{/* ═══ Progress bar ═══ */}
<div className="space-y-2">
<div className="flex items-center justify-between gap-4">
<p className="text-sm font-medium text-foreground animate-pulse truncate">
{currentStep || t('dashboard.translate.progress.processingFallback')}
</p>
<p className="text-2xl font-black tabular-nums tracking-tight text-primary shrink-0">
{Math.round(progress)}%
</p>
</div>
<div className="h-3 w-full overflow-hidden rounded-full bg-primary/10">
<div
className={cn(
'h-full rounded-full transition-all duration-700 ease-out',
animate && 'bg-gradient-to-r from-primary via-primary/80 to-primary',
)}
style={{ width: `${Math.max(0, progress)}%` }}
/>
</div>
</div>
{/* ═══ Live Stats Grid ═══ */}
<div className="grid grid-cols-4 gap-2.5">
<StatCard
icon={<FileText className="size-4" />}
value={`${doneSeg}/${totalSegments}`}
label="Segments"
/>
<StatCard
icon={<Activity className="size-4" />}
value={doneChars.toLocaleString()}
label="Caractères"
/>
<StatCard
icon={<Gauge className="size-4" />}
value={speed}
label="Seg/min"
/>
<StatCard
icon={<Timer className="size-4" />}
value={formatElapsed(elapsed)}
label="Écoulé"
/>
</div>
{/* ═══ Activity Feed ═══ */}
{activities.length > 0 && (
<div>
<p className="mb-2 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<Activity className="size-3" /> Journal d&apos;activité
</p>
<div className="max-h-[100px] overflow-y-auto rounded-xl border border-border bg-muted/30">
{activities.map((act, i) => (
<div key={i} className="flex items-center gap-2.5 border-b border-border/50 px-3 py-1.5 last:border-0">
<div className="size-1.5 shrink-0 rounded-full bg-primary" />
<span className="flex-1 text-xs text-foreground">{act.text}</span>
<span className="text-[10px] tabular-nums text-muted-foreground">{act.time}</span>
</div>
))}
</div>
</div>
)}
{/* ═══ Connection lost ═══ */}
{showConnectionLost && (
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-400">
<WifiOff className="size-3.5" />
<span>{t('dashboard.translate.progress.connectionLost')}</span>
</div>
)}
{/* ═══ Cancel button ═══ */}
{onCancel && (
<div className="flex justify-center pt-1">
<button
onClick={onCancel}
className="flex items-center gap-2 rounded-lg border border-destructive/20 px-4 py-2 text-sm font-medium text-destructive transition hover:bg-destructive hover:text-white hover:border-destructive"
>
<RotateCcw className="size-4" />Annuler
</button>
</div>
)}
</div>
</div>
);
}
/* ── Stat card sub-component ────────────────────────────────────── */
function StatCard({ icon, value, label }: { icon: React.ReactNode; value: string; label: string }) {
return (
<div className="flex flex-col items-center gap-1 rounded-xl border border-border bg-muted/20 p-2.5 text-center">
<div className="text-primary">{icon}</div>
<p className="text-sm font-bold tabular-nums text-foreground leading-none">{value}</p>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{label}</p>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,147 @@
/**
* Standalone single-job runner used by the multi-file queue.
* Pure async — no React state — so it is easy to sequence and to unit-test.
*/
import { API_BASE } from '@/lib/config';
import type { TranslationConfig } from './types';
const POLL_INTERVAL_MS = 2000;
const MAX_POLL_FAILURES = 5;
export interface RunnerProgress {
jobId: string;
status: string;
progress: number;
step: string;
}
export interface RunnerResult {
jobId: string | null;
status: 'completed' | 'failed' | 'aborted';
error?: string;
fileName: string;
}
export interface RunnerOptions {
onProgress?: (info: RunnerProgress) => void;
/** Checked between polls and before submit — the queue sets it on abort. */
shouldAbort?: () => boolean;
onJobId?: (jobId: string) => void;
/** Poll interval override — tests run it at 10ms. */
pollIntervalMs?: number;
}
function authHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
if (token) headers['Authorization'] = `Bearer ${token}`;
return headers;
}
export async function runTranslationJob(
file: File,
config: TranslationConfig,
opts: RunnerOptions = {},
): Promise<RunnerResult> {
const { onProgress, shouldAbort, onJobId, pollIntervalMs = POLL_INTERVAL_MS } = opts;
if (shouldAbort?.()) {
return { jobId: null, status: 'aborted', fileName: file.name };
}
const formData = new FormData();
formData.append('file', file);
formData.append('source_lang', config.sourceLang);
formData.append('target_lang', config.targetLang);
formData.append('mode', config.mode);
if (config.mode === 'llm' && config.provider) {
formData.append('provider', config.provider);
}
if (config.pdfMode) formData.append('pdf_mode', config.pdfMode);
if (config.glossaryId) formData.append('glossary_id', config.glossaryId);
if (config.translateImages !== undefined) {
formData.append('translate_images', String(config.translateImages));
}
const { useTranslationStore } = await import('@/lib/store');
const { settings } = useTranslationStore.getState();
if (settings.systemPrompt?.trim()) {
formData.append('custom_prompt', settings.systemPrompt.trim());
}
// Submit
let jobId: string | null = null;
let fileName = file.name;
const submitRes = await fetch(`${API_BASE}/api/v1/translate`, {
method: 'POST',
headers: authHeaders(),
body: formData,
});
if (!submitRes.ok) {
let message = `HTTP ${submitRes.status}`;
try {
const err = await submitRes.json();
message = err.message || err.error || message;
} catch { /* not JSON */ }
return { jobId: null, status: 'failed', error: message, fileName };
}
const submitData = await submitRes.json();
jobId = submitData?.data?.id ?? null;
fileName = submitData?.data?.file_name || file.name;
if (!jobId) {
return { jobId: null, status: 'failed', error: 'No job id returned', fileName };
}
onJobId?.(jobId);
// Poll until terminal
let failures = 0;
for (;;) {
if (shouldAbort?.()) {
// Best effort: tell the server to stop, then report locally as aborted.
fetch(`${API_BASE}/api/v1/translations/${jobId}/cancel`, {
method: 'POST',
headers: authHeaders(),
}).catch(() => { /* ignore */ });
return { jobId, status: 'aborted', fileName };
}
await new Promise((r) => setTimeout(r, pollIntervalMs));
let res: Response;
try {
res = await fetch(`${API_BASE}/api/v1/translations/${jobId}`, { headers: authHeaders() });
} catch {
failures += 1;
if (failures >= MAX_POLL_FAILURES) {
return { jobId, status: 'failed', error: 'Lost connection to translation service.', fileName };
}
continue;
}
if (res.status === 429) continue; // rate-limited poll — not a failure
if (!res.ok) {
if (res.status === 404) {
return { jobId, status: 'failed', error: 'Translation job not found.', fileName };
}
failures += 1;
if (failures >= MAX_POLL_FAILURES) {
return { jobId, status: 'failed', error: `HTTP ${res.status}`, fileName };
}
continue;
}
const data = await res.json();
const job = data?.data;
const status = job?.status ?? 'processing';
const progress = job?.progress_percent ?? 0;
onProgress?.({ jobId: jobId!, status, progress, step: job?.current_step ?? '' });
failures = 0;
if (job?.file_name) fileName = job.file_name;
if (status === 'completed') {
return { jobId, status: 'completed', fileName };
}
if (status === 'failed' || status === 'cancelled') {
return { jobId, status: 'failed', error: job?.error_message ?? 'Translation failed', fileName };
}
}
}

View File

@@ -1,7 +1,11 @@
export type SupportedFormat = 'xlsx' | 'docx' | 'pptx';
export interface FileUploadState {
/** First file — drives the single-file flow. */
file: File | null;
/** All queued files (1..MAX_BATCH_FILES) — drives the batch flow. */
files: File[];
/** i18n key (from ERROR_MESSAGES) — render through t() */
error: string | null;
isDragOver: boolean;
}
@@ -11,8 +15,19 @@ export interface FileUploadActions {
handleDragOver: (e: React.DragEvent) => void;
handleDragLeave: (e: React.DragEvent) => void;
handleFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
addFiles: (incoming: Iterable<File>) => void;
removeAt: (index: number) => void;
removeFile: () => void;
setMockFile: (type: 'word' | 'excel' | 'slides' | 'pdf') => void;
}
/** One file inside a running batch queue. */
export interface BatchItem {
name: string;
size: number;
status: 'pending' | 'processing' | 'completed' | 'failed' | 'aborted';
jobId?: string;
progress: number;
error?: string;
}
export interface UseFileUploadReturn extends FileUploadState, FileUploadActions {}
@@ -101,6 +116,8 @@ export interface TranslationStatusResponse {
export interface UseTranslationSubmitReturn {
submitTranslation: (file: File, config: TranslationConfig) => Promise<void>;
/** Ask the backend to cancel the running job. */
cancelJob: () => Promise<boolean>;
jobId: string | null;
status: TranslationStatus;
progress: number;

View File

@@ -3,47 +3,68 @@ import type { UseFileUploadReturn } from './types';
const ACCEPTED_EXTENSIONS = ['xlsx', 'docx', 'pptx', 'pdf'];
const MAX_FILE_SIZE = 50 * 1024 * 1024;
/** Batch cap — keeps a drag-and-dropped folder from turning into a runaway queue. */
export const MAX_BATCH_FILES = 10;
// i18n keys — rendered through t() so the message follows the UI locale.
export const ERROR_MESSAGES = {
INVALID_FORMAT: 'Format non supporté. Formats acceptés : .xlsx, .docx, .pptx, .pdf',
FILE_TOO_LARGE: 'Fichier trop volumineux (max 50 MB)',
INVALID_FORMAT: 'fileUploader.error.invalidFormat',
FILE_TOO_LARGE: 'fileUploader.error.tooLarge',
TOO_MANY: 'fileUploader.error.tooMany',
} as const;
export function useFileUpload(): UseFileUploadReturn {
const [file, setFile] = useState<File | null>(null);
const [files, setFiles] = useState<File[]>([]);
const [error, setError] = useState<string | null>(null);
const [isDragOver, setIsDragOver] = useState(false);
const validateFile = useCallback((file: File): string | null => {
const ext = file.name.split('.').pop()?.toLowerCase();
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
return ERROR_MESSAGES.INVALID_FORMAT;
}
if (file.size > MAX_FILE_SIZE) {
return ERROR_MESSAGES.FILE_TOO_LARGE;
}
return null;
}, []);
/** Append files, validating each; keeps the first invalid-file message. */
const addFiles = useCallback((incoming: Iterable<File>) => {
setError(null);
setFiles((current) => {
const next = [...current];
let firstError: string | null = null;
for (const file of incoming) {
const validationError = validateFile(file);
if (validationError) {
firstError ??= validationError;
continue;
}
const duplicate = next.some(
(f) => f.name === file.name && f.size === file.size && f.lastModified === file.lastModified
);
if (!duplicate) next.push(file);
}
if (next.length > MAX_BATCH_FILES) {
firstError ??= ERROR_MESSAGES.TOO_MANY;
next.length = MAX_BATCH_FILES;
}
if (firstError) setError(firstError);
return next;
});
}, [validateFile]);
const removeAt = useCallback((index: number) => {
setFiles((current) => current.filter((_, i) => i !== index));
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const droppedFile = e.dataTransfer.files[0];
if (droppedFile) {
const validationError = validateFile(droppedFile);
if (validationError) {
setError(validationError);
setFile(null);
} else {
setFile(droppedFile);
setError(null);
}
}
}, [validateFile]);
const dropped = Array.from(e.dataTransfer.files ?? []);
if (dropped.length > 0) addFiles(dropped);
}, [addFiles]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
@@ -56,59 +77,32 @@ export function useFileUpload(): UseFileUploadReturn {
}, []);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files?.[0];
if (selected) {
const validationError = validateFile(selected);
if (validationError) {
setError(validationError);
setFile(null);
} else {
setFile(selected);
setError(null);
}
}
}, [validateFile]);
const setMockFile = useCallback((type: 'word' | 'excel' | 'slides' | 'pdf') => {
const mockDetails: Record<string, { name: string; mime: string; size: number }> = {
word: { name: 'rapport_strategique_q3.docx', mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', size: 12.4 * 1024 * 1024 },
excel: { name: 'bilan_consolidé_2025.xlsx', mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: 4.2 * 1024 * 1024 },
slides: { name: 'keynote_produit_wordly.pptx', mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', size: 8.0 * 1024 * 1024 },
pdf: { name: 'cahier_des_charges_v2.pdf', mime: 'application/pdf', size: 15.1 * 1024 * 1024 },
};
const details = mockDetails[type];
if (details) {
// Create a dummy blob representing the file size
const dummyBlob = new Blob(['x'.repeat(100)], { type: details.mime });
// Create a custom File object that overrides size properties
const mockFile = new File([dummyBlob], details.name, {
type: details.mime,
lastModified: Date.now()
});
// Override the readonly size property for display purposes
Object.defineProperty(mockFile, 'size', { value: details.size });
setFile(mockFile);
setError(null);
}
}, []);
const selected = Array.from(e.target.files ?? []);
if (selected.length > 0) addFiles(selected);
// allow re-selecting the same file after removal
e.target.value = '';
}, [addFiles]);
const removeFile = useCallback(() => {
setFile(null);
setFiles([]);
setError(null);
setIsDragOver(false);
}, []);
// Single-file compatibility: the first file drives the existing one-file flow.
const file = files[0] ?? null;
return {
file,
files,
error,
isDragOver,
handleDrop,
handleDragOver,
handleDragLeave,
handleFileSelect,
addFiles,
removeAt,
removeFile,
setMockFile,
};
}

View File

@@ -125,7 +125,7 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
});
if (response.ok) {
const data = await response.json();
const list = data.providers || [];
const list = (data.providers || []).filter((p: AvailableProvider) => p.id !== 'deepl');
setAvailableProviders(list.length > 0 ? list : FALLBACK_PROVIDERS);
} else {
setAvailableProviders(FALLBACK_PROVIDERS);
@@ -233,8 +233,10 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
const isConfigValid = useMemo(() => {
if (!hasFile || !targetLang) return false;
if (!provider) return false;
// Translating a language into itself is always a mistake
if (sourceLang !== 'auto' && sourceLang === targetLang) return false;
return true;
}, [hasFile, targetLang, provider]);
}, [hasFile, targetLang, provider, sourceLang]);
const getConfig = useCallback((): TranslationConfig => ({
sourceLang,

View File

@@ -12,6 +12,65 @@ import { API_BASE } from '@/lib/config';
const POLLING_INTERVAL_MS = 2000;
const MAX_POLLING_FAILURES = 3;
/* ── Job persistence: survive a refresh mid-translation ─────────── */
const ACTIVE_JOB_KEY = 'wordly:activeJob';
const RECENT_JOBS_KEY = 'wordly:recentJobs';
const MAX_RECENT_JOBS = 20;
const ACTIVE_JOB_TTL_MS = 24 * 60 * 60 * 1000; // discard stale entries after 24h
interface StoredJob { jobId: string; fileName: string | null; savedAt: number }
export interface RecentJob { jobId: string; fileName: string; completedAt: number }
/** Server history (last jobs, newest first) — empty when offline/unauthenticated. */
export async function fetchServerHistory(perPage = 6): Promise<RecentJob[]> {
if (typeof window === 'undefined') return [];
const token = localStorage.getItem('token');
if (!token) return [];
try {
const res = await fetch(`${API_BASE}/api/v1/translations?per_page=${perPage}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return [];
const json = await res.json();
return (json.data ?? []).map((j: {
id: string; file_name?: string | null;
completed_at?: string | null; created_at?: string | null;
}) => ({
jobId: j.id,
fileName: j.file_name ?? '',
completedAt: Date.parse(j.completed_at ?? j.created_at ?? '') || Date.now(),
}));
} catch {
return [];
}
}
export function getRecentJobs(): RecentJob[] {
if (typeof window === 'undefined') return [];
try {
const list = JSON.parse(localStorage.getItem(RECENT_JOBS_KEY) ?? '[]');
return Array.isArray(list) ? list : [];
} catch {
return [];
}
}
function persistActiveJob(job: StoredJob | null) {
try {
if (!job) localStorage.removeItem(ACTIVE_JOB_KEY);
else localStorage.setItem(ACTIVE_JOB_KEY, JSON.stringify(job));
} catch { /* storage unavailable — session-only fallback */ }
}
export function pushRecentJob(job: RecentJob) {
try {
const list = getRecentJobs().filter(j => j.jobId !== job.jobId);
list.unshift(job);
localStorage.setItem(RECENT_JOBS_KEY, JSON.stringify(list.slice(0, MAX_RECENT_JOBS)));
} catch { /* ignore */ }
}
export function useTranslationSubmit(): UseTranslationSubmitReturn {
const [jobId, setJobId] = useState<string | null>(null);
const [status, setStatus] = useState<TranslationStatus>('idle');
@@ -30,6 +89,7 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
// If we relied on state, the setInterval callback would always read the initial
// value of pollingFailures (0) and never reach MAX_POLLING_FAILURES.
const pollingFailuresRef = useRef(0);
const resumeAttemptedRef = useRef(false);
const stopPolling = useCallback(() => {
if (pollingIntervalRef.current) {
@@ -89,6 +149,9 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
if (job.status === 'failed') {
setError(job.error_message || 'Translation failed');
}
if (job.status === 'completed') {
pushRecentJob({ jobId: id, fileName: job.file_name || '', completedAt: Date.now() });
}
}
} catch (err) {
console.error('Polling error:', err);
@@ -196,8 +259,31 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
// NOTE: Don't set isSubmitting(false) here - let polling handle the transition
}, [startPolling]);
/** Ask the backend to cancel the current job. Returns true on success. */
const cancelJob = useCallback(async (): Promise<boolean> => {
const id = jobId;
if (!id) return false;
try {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}/api/v1/translations/${id}/cancel`, {
method: 'POST',
headers,
});
if (!res.ok) return false;
stopPolling();
persistActiveJob(null);
setIsSubmitting(false);
return true;
} catch {
return false;
}
}, [jobId, stopPolling]);
const reset = useCallback(() => {
stopPolling();
persistActiveJob(null);
setJobId(null);
setStatus('idle');
setProgress(0);
@@ -209,6 +295,34 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
setPollingFailures(0);
}, [stopPolling]);
// Persist the active job whenever it changes so a refresh can resume polling.
useEffect(() => {
if (jobId) persistActiveJob({ jobId, fileName, savedAt: Date.now() });
}, [jobId, fileName]);
// Resume an interrupted job once on mount (page refreshed mid-translation).
useEffect(() => {
if (resumeAttemptedRef.current) return;
resumeAttemptedRef.current = true;
try {
const raw = localStorage.getItem(ACTIVE_JOB_KEY);
if (!raw) return;
const stored: StoredJob = JSON.parse(raw);
if (!stored?.jobId || Date.now() - stored.savedAt > ACTIVE_JOB_TTL_MS) {
persistActiveJob(null);
return;
}
setJobId(stored.jobId);
if (stored.fileName) setFileName(stored.fileName);
setStatus('processing');
setProgress(0);
startPolling(stored.jobId);
} catch {
persistActiveJob(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
return () => {
stopPolling();
@@ -217,6 +331,7 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
return {
submitTranslation,
cancelJob,
jobId,
status,
progress,

View File

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

View File

@@ -11,11 +11,17 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { API_BASE } from "@/lib/config";
import { ANNUAL_DISCOUNT_PERCENT } from "@/lib/pricing";
import { useI18n } from "@/lib/i18n";
// Enterprise contact — single place to update the sales address.
const SUPPORT_EMAIL = "contact@wordly.art";
/* ─────────────────────────────────────────────
Types
───────────────────────────────────────────── */
@@ -86,7 +92,7 @@ const STATIC_PLANS: Plan[] = [
max_pages_per_doc: 50,
max_file_size_mb: 10,
max_chars_per_month: 500_000,
providers: ["google", "deepl"],
providers: ["google"],
features: [
"pricing.plans.starter.feat1",
"pricing.plans.starter.feat2",
@@ -110,7 +116,7 @@ const STATIC_PLANS: Plan[] = [
max_pages_per_doc: 200,
max_file_size_mb: 25,
max_chars_per_month: 2_000_000,
providers: ["google", "deepl", "openrouter"],
providers: ["google", "openrouter"],
features: [
"pricing.plans.pro.feat1",
"pricing.plans.pro.feat2",
@@ -139,7 +145,7 @@ const STATIC_PLANS: Plan[] = [
max_pages_per_doc: 500,
max_file_size_mb: 50,
max_chars_per_month: 10_000_000,
providers: ["google", "deepl", "openrouter", "openrouter_premium", "openai"],
providers: ["google", "openrouter", "openrouter_premium", "openai"],
features: [
"pricing.plans.business.feat1",
"pricing.plans.business.feat2",
@@ -209,14 +215,6 @@ const PLAN_ICONS: Record<string, any> = {
enterprise: Shield,
};
const PLAN_COLORS: Record<string, { header: string; iconColor: string; nameColor: string }> = {
free: { header: "bg-muted", iconColor: "text-foreground/20", nameColor: "text-foreground/40" },
starter: { header: "bg-foreground", iconColor: "text-white/20", nameColor: "text-white/50" },
pro: { header: "bg-accent", iconColor: "text-white/30", nameColor: "text-white/50" },
business: { header: "bg-foreground", iconColor: "text-accent/40", nameColor: "text-white/50" },
enterprise: { header: "bg-[#252525]", iconColor: "text-white/10", nameColor: "text-white/50" },
};
/** Avoids flash of static prices before the API responds on refresh. */
function PricingDataSkeleton() {
return (
@@ -289,6 +287,8 @@ export default function PricingPage() {
const [annualDiscountPercent, setAnnualDiscountPercent] = useState(ANNUAL_DISCOUNT_PERCENT);
/** Until false: don't show STATIC_PLANS (avoids flash of stale prices on refresh). */
const [pricingLoaded, setPricingLoaded] = useState(false);
/** Plan awaiting explicit confirmation before any Stripe redirect. */
const [confirmPlan, setConfirmPlan] = useState<Plan | null>(null);
useEffect(() => {
// Fetch live plans — backend returns prices set by admin (no browser cache)
@@ -332,14 +332,13 @@ export default function PricingPage() {
}
}, []);
// Auto-trigger checkout when ?plan= is in the URL and user is already logged in
// ?plan= in the URL pre-opens the confirmation dialog — never a silent checkout.
useEffect(() => {
if (!pricingLoaded) return;
if (planFromUrl && isLoggedIn && plans.length > 0 && currentPlan !== null) {
const targetPlan = plans.find(p => p.id === planFromUrl);
if (targetPlan && targetPlan.price_monthly > 0 && currentPlan !== planFromUrl) {
// Directly initiate checkout
handleSubscribe(planFromUrl);
setConfirmPlan(targetPlan);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -359,7 +358,7 @@ export default function PricingPage() {
return;
}
if (planId === "enterprise") {
window.location.href = "mailto:contact@votre-domaine.com?subject=Offre Enterprise";
window.location.href = `mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent(t('pricing.enterprise.subject'))}`;
return;
}
if (planId === currentPlan) return;
@@ -387,7 +386,7 @@ export default function PricingPage() {
const backendMessage = data.message ?? data.error ?? data.data?.error;
if (!res.ok) {
throw new Error(backendMessage || `Erreur serveur ${res.status}`);
throw new Error(backendMessage || t('pricing.error.server', { status: res.status }));
}
if (url) {
@@ -431,7 +430,7 @@ export default function PricingPage() {
const url = data.data?.url ?? data.url;
if (!res.ok) {
throw new Error(data.message ?? data.error ?? `Erreur serveur ${res.status}`);
throw new Error(data.message ?? data.error ?? t('pricing.error.server', { status: res.status }));
}
if (url) {
window.location.replace(url);
@@ -453,7 +452,7 @@ export default function PricingPage() {
<div className="flex justify-between items-center mb-20">
<button
onClick={() => router.back()}
className="flex items-center gap-3 text-[10px] font-black uppercase tracking-[0.4em] text-foreground/30 hover:text-foreground transition-all group"
className="flex items-center gap-3 text-[11px] font-bold uppercase tracking-[0.4em] text-foreground/30 hover:text-foreground transition-all group"
>
<ChevronLeft size={16} className="group-hover:-translate-x-1 transition-transform" />
{t('pricing.nav.back')}
@@ -462,14 +461,14 @@ export default function PricingPage() {
<div className="flex gap-2 p-1.5 bg-muted rounded-full border border-black/5 shadow-inner">
<Link
href={isLoggedIn ? "/dashboard" : "/"}
className="px-6 py-2 rounded-full text-[9px] font-black uppercase tracking-widest text-foreground/40 hover:text-foreground transition-all"
className="px-6 py-2 rounded-full text-[11px] font-bold uppercase tracking-wider text-foreground/60 hover:text-foreground transition-all"
>
{t('pricing.dashboard')}
</Link>
{isLoggedIn && (
<Link
href="/dashboard/profile"
className="px-6 py-2 bg-white rounded-full text-[9px] font-black uppercase tracking-widest text-foreground shadow-sm border border-black/5"
className="px-6 py-2 bg-white rounded-full text-[9px] font-bold uppercase tracking-wider text-foreground shadow-sm border border-black/5"
>
{t('pricing.nav.mySubscription')}
</Link>
@@ -478,6 +477,47 @@ export default function PricingPage() {
</div>
</div>
{/* ── Checkout confirmation — no Stripe redirect without an explicit confirm ── */}
<Dialog open={!!confirmPlan} onOpenChange={(open) => { if (!open) setConfirmPlan(null); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('pricing.confirm.title')}</DialogTitle>
<DialogDescription>{t('pricing.confirm.subtitle')}</DialogDescription>
</DialogHeader>
{confirmPlan && (
<div className="space-y-4 py-2">
<div className="flex items-center justify-between rounded-xl border border-border/60 px-4 py-3">
<span className="text-sm font-semibold">{t(confirmPlan.name)}</span>
<span className="text-lg font-bold">
{isYearly
? `${Number(confirmPlan.price_yearly.toFixed(2))} € / ${t('pricing.confirm.year')}`
: `${Number(confirmPlan.price_monthly.toFixed(2))} € / ${t('pricing.confirm.month')}`}
</span>
</div>
{isYearly && confirmPlan.price_yearly > 0 && (
<p className="text-xs text-muted-foreground">
{t('pricing.confirm.monthlyEquivalent', { price: (confirmPlan.price_yearly / 12).toFixed(2) })}
</p>
)}
<p className="text-xs text-muted-foreground leading-relaxed">
{t('pricing.confirm.secureNote')}
</p>
</div>
)}
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setConfirmPlan(null)}>
{t('pricing.confirm.cancel')}
</Button>
<Button
disabled={loadingPlanId !== null}
onClick={() => { if (confirmPlan) handleSubscribe(confirmPlan.id); }}
>
{loadingPlanId !== null ? t('pricing.card.processing') : t('pricing.confirm.cta')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Toast notification ── */}
{toastMsg && (
<div className={cn(
@@ -488,23 +528,23 @@ export default function PricingPage() {
)}>
<span className="text-lg">{toastMsg.type === 'ok' ? t('pricing.okSymbol') : t('pricing.errSymbol')}</span>
<p className="text-sm flex-1">{toastMsg.text}</p>
<button onClick={() => setToastMsg(null)} className="text-muted-foreground hover:text-foreground text-lg leading-none">&times;</button>
<button onClick={() => setToastMsg(null)} aria-label={t('pricing.toast.close')} className="text-muted-foreground hover:text-foreground text-lg leading-none">&times;</button>
</div>
)}
{/* ── Header ── */}
<div className="max-w-[1400px] mx-auto px-4 text-center mb-20">
<span className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full border border-black/5 mb-6">
<div className="w-2 h-2 bg-accent rounded-full animate-pulse" />
<span className="text-[10px] font-black tracking-[0.3em] opacity-40">{t('pricing.header.badge')}</span>
<span className="accent-pill mb-6 mx-auto flex w-fit items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-accent animate-pulse" aria-hidden="true" />
{t('pricing.header.badge')}
</span>
<h1 className="text-5xl md:text-7xl font-black uppercase tracking-tighter mb-6 leading-none">
{t('pricing.header.title').split(' ').slice(0, -2).join(' ')}{" "}
<span className="text-accent">
{t('pricing.header.title').split(' ').slice(-2).join(' ')}
<h1 className="mb-6 text-4xl md:text-6xl font-serif font-medium tracking-tight leading-tight text-brand-dark dark:text-white">
{t('pricing.header.titleBase')}{" "}
<span className="italic text-accent">
{t('pricing.header.titleAccent')}
</span>
</h1>
<p className="text-foreground/40 font-medium text-xl max-w-2xl mx-auto leading-relaxed">
<p className="text-brand-dark/60 dark:text-white/60 font-light text-lg max-w-2xl mx-auto leading-relaxed">
{t('pricing.header.subtitle')}
</p>
</div>
@@ -514,13 +554,13 @@ export default function PricingPage() {
<div className="flex p-1 bg-muted rounded-full border border-black/5 shadow-inner px-2">
<button
onClick={() => setIsYearly(false)}
className={`px-8 py-3 rounded-full text-[10px] font-black uppercase tracking-widest transition-all ${!isYearly ? 'bg-foreground text-white shadow-xl' : 'text-foreground/60 hover:text-foreground'}`}
className={`px-8 py-3 rounded-full text-[11px] font-bold uppercase tracking-wider transition-all ${!isYearly ? 'bg-brand-dark text-white shadow-xl dark:bg-brand-accent dark:text-brand-dark' : 'text-foreground/60 hover:text-foreground'}`}
>
{t('pricing.billing.monthly')}
</button>
<button
onClick={() => setIsYearly(true)}
className={`px-8 py-3 rounded-full text-[10px] font-black uppercase tracking-widest transition-all ${isYearly ? 'bg-foreground text-white shadow-xl' : 'text-foreground/60 hover:text-foreground'}`}
className={`px-8 py-3 rounded-full text-[11px] font-bold uppercase tracking-wider transition-all ${isYearly ? 'bg-brand-dark text-white shadow-xl dark:bg-brand-accent dark:text-brand-dark' : 'text-foreground/60 hover:text-foreground'}`}
>
{t('pricing.billing.yearly')}
<span className={`ml-2 transition-colors ${isYearly ? 'text-accent' : 'text-accent/60'}`}>&minus;{annualDiscountPercent} %</span>
@@ -534,10 +574,22 @@ export default function PricingPage() {
<PricingDataSkeleton />
) : (
<>
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-6 items-stretch">
{plans.map((plan) => {
{/* Essai gratuit : une ligne discrète au-dessus du choix payant */}
<div className="mb-8 flex flex-col items-center justify-between gap-4 rounded-2xl border border-black/[0.06] bg-brand-muted/40 px-6 py-4 dark:border-white/[0.08] dark:bg-white/5 sm:flex-row">
<p className="text-sm font-light text-brand-dark/70 dark:text-white/70">
{t('pricing.freeBand.text')}
</p>
<Link
href="/auth/register"
className="shrink-0 rounded-xl border border-black/10 px-5 py-2 text-[11px] font-bold uppercase tracking-wider text-brand-dark/70 transition-colors hover:bg-black/5 dark:border-white/15 dark:text-white/70 dark:hover:bg-white/10"
>
{t('pricing.freeBand.cta')}
</Link>
</div>
<div className="grid gap-6 md:grid-cols-3 items-stretch">
{plans.filter((p) => !['free', 'enterprise'].includes(p.id)).map((plan) => {
const Icon = PLAN_ICONS[plan.id] ?? Sparkles;
const colors = PLAN_COLORS[plan.id] ?? PLAN_COLORS.starter;
const price = displayPrice(plan);
const isCurrent = currentPlan === plan.id;
const isEnterprise = plan.id === "enterprise";
@@ -551,43 +603,34 @@ export default function PricingPage() {
plan.popular && "border-accent/30 ring-4 ring-accent/5"
)}
>
{/* ── Header section ── */}
<div className={cn("p-8 text-white relative h-48 flex flex-col justify-end", colors.header)}>
{/* Badges for popular/current plan */}
{plan.popular && (
<div className="absolute top-0 right-0 p-3 flex gap-2">
{/* ── Header (editorial) ── */}
<div className="relative border-b border-black/[0.04] p-8 pb-6 dark:border-white/[0.06]">
{(plan.popular || isCurrent) && (
<div className="absolute top-5 right-5 flex gap-2">
{plan.badge && (
<span className="bg-foreground/20 backdrop-blur-md text-white text-[8px] font-black uppercase tracking-widest px-3 py-1 rounded-full border border-white/20 shadow-lg">
<span className="rounded-full border border-brand-accent/30 bg-brand-accent/10 px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-brand-goldink dark:text-brand-accent">
{t(plan.badge)}
</span>
)}
{isCurrent && (
<span className="bg-foreground/40 backdrop-blur-md text-white text-[8px] font-black uppercase tracking-widest px-3 py-1 rounded-full border border-white/20 shadow-lg flex items-center gap-1">
<div className="w-1.5 h-1.5 bg-accent rounded-full animate-pulse" /> {t('pricing.card.myPlan')}
<span className="flex items-center gap-1.5 rounded-full border border-black/10 bg-brand-muted px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-brand-dark/70 dark:border-white/10 dark:bg-white/10 dark:text-white/70">
<span className="h-1.5 w-1.5 rounded-full bg-brand-accent animate-pulse" /> {t('pricing.card.myPlan')}
</span>
)}
</div>
)}
{plan.badge && !plan.popular && (
<div className="absolute top-0 right-0 p-3">
<span className="bg-white/10 backdrop-blur-md text-white text-[8px] font-black uppercase tracking-widest px-3 py-1 rounded-full border border-white/10">
{t(plan.badge)}
</span>
</div>
)}
{/* "My Plan" badge for current non-popular plan */}
{isCurrent && !plan.popular && (
<div className="absolute top-0 right-0 p-3">
<span className="bg-foreground/40 backdrop-blur-md text-white text-[8px] font-black uppercase tracking-widest px-3 py-1 rounded-full border border-white/20 shadow-lg flex items-center gap-1">
<div className="w-1.5 h-1.5 bg-accent rounded-full animate-pulse" /> {t('pricing.card.myPlan')}
</span>
</div>
{!plan.popular && !isCurrent && plan.badge && (
<span className="absolute top-5 right-5 rounded-full border border-black/[0.06] bg-brand-muted px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-brand-dark/60 dark:border-white/[0.08] dark:bg-white/5 dark:text-white/60">
{t(plan.badge)}
</span>
)}
{/* Icon + plan name */}
<div className="flex items-center gap-3 mb-4">
<Icon size={20} className={colors.iconColor} />
<span className={cn("text-sm font-black uppercase tracking-widest", isFree ? "text-foreground/40" : colors.nameColor)}>
<div className="mb-4 flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-xl bg-brand-muted text-brand-goldink dark:text-brand-accent dark:bg-white/10">
<Icon size={18} />
</div>
<span className="text-xs font-bold uppercase tracking-[0.15em] text-brand-dark/70 dark:text-white/70">
{t(plan.name)}
</span>
</div>
@@ -595,30 +638,30 @@ export default function PricingPage() {
{/* Price */}
<div className="flex items-baseline gap-2">
{isEnterprise ? (
<h3 className={cn("text-3xl font-black uppercase tracking-tighter", isFree ? "text-foreground" : "text-white")}>
<h3 className="text-3xl font-serif font-medium tracking-tight text-brand-dark dark:text-white">
{t('pricing.card.onRequest')}
</h3>
) : price === 0 ? (
<h3 className="text-3xl font-black uppercase tracking-tighter text-foreground">
<h3 className="text-3xl font-serif font-medium tracking-tight text-brand-dark dark:text-white">
{t('pricing.card.free')}
</h3>
) : (
<>
<h3 className="text-3xl font-black uppercase tracking-tighter text-white">{price} &euro;</h3>
<span className="text-[10px] font-bold uppercase tracking-widest text-white/40">{t('pricing.card.perMonth')}</span>
<h3 className="text-4xl font-serif font-medium tracking-tight text-brand-dark dark:text-white">{price} &euro;</h3>
<span className="text-[11px] font-semibold uppercase tracking-wider text-brand-dark/55 dark:text-white/55">{t('pricing.card.perMonth')}</span>
</>
)}
</div>
{/* Yearly billing note */}
{isYearly && plan.price_yearly > 0 && (
<div className="text-white/70 text-[10px] mt-1">
<div className="mt-1 text-[11px] text-brand-dark/55 dark:text-white/55">
{t('pricing.card.billedYearly', { price: plan.price_yearly.toFixed(2) })}
</div>
)}
{/* Description */}
<p className={cn("text-[10px] font-medium uppercase mt-3 tracking-widest leading-relaxed", isFree ? "text-foreground/40" : "text-white/60")}>
<p className="mt-3 text-xs font-light leading-relaxed text-brand-dark/60 dark:text-white/60">
{t(plan.description || '')}
</p>
</div>
@@ -629,19 +672,19 @@ export default function PricingPage() {
<div className="space-y-2 mb-10">
<div className="flex justify-between items-center py-2 border-b border-black/[0.03] dark:border-border/20">
<div className="flex items-center gap-2">
<FileText size={12} className="text-foreground/20" />
<span className="text-[9px] font-black uppercase tracking-widest text-foreground/40">{t('pricing.card.documents')}</span>
<FileText size={12} className="text-foreground/40" />
<span className="text-[11px] font-semibold text-foreground/70">{t('pricing.card.documents')}</span>
</div>
<span className="text-[10px] font-black uppercase text-foreground">
<span className="text-[11px] font-bold text-foreground">
{plan.docs_per_month === -1 ? t('pricing.card.unlimited') : `${plan.docs_per_month} ${t('pricing.card.perMonthStat')}`}
</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-black/[0.03] dark:border-border/20">
<div className="flex items-center gap-2">
<Layers size={12} className="text-foreground/20" />
<span className="text-[9px] font-black uppercase tracking-widest text-foreground/40">{t('pricing.card.pagesMax')}</span>
<Layers size={12} className="text-foreground/40" />
<span className="text-[11px] font-semibold text-foreground/70">{t('pricing.card.pagesMax')}</span>
</div>
<span className="text-[10px] font-black uppercase text-foreground">
<span className="text-[11px] font-bold text-foreground">
{plan.max_pages_per_doc === -1 ? t('pricing.card.unlimited') : `${plan.max_pages_per_doc} ${t('pricing.card.perDoc')}`}
</span>
</div>
@@ -655,16 +698,16 @@ export default function PricingPage() {
<div className="flex items-center gap-2">
<Activity size={12} className={
plan.ai_tier === "essential" ? "text-accent/40" :
"text-foreground/20"
"text-foreground/40"
} />
<span className={cn(
"text-[9px] font-black uppercase tracking-widest",
"text-[10px] font-bold uppercase tracking-wider",
plan.ai_tier === "essential" ? "text-accent/60" :
"text-foreground/40"
)}>{t('pricing.card.aiTranslation')}</span>
</div>
<span className={cn(
"text-[9px] font-black uppercase",
"text-[10px] font-bold uppercase",
plan.ai_tier === "essential" ? "text-accent" :
plan.ai_tier === "premium" ? "text-foreground" :
"text-foreground"
@@ -684,7 +727,7 @@ export default function PricingPage() {
<div className="w-4 h-4 rounded-full bg-accent/10 flex items-center justify-center shrink-0 mt-0.5">
<CheckCircle2 size={10} className="text-accent" />
</div>
<span className="text-[10px] font-bold text-foreground/60 leading-normal">{t(feat)}</span>
<span className="text-[11px] font-medium text-foreground/75 leading-normal">{t(feat)}</span>
</li>
))}
</ul>
@@ -693,7 +736,7 @@ export default function PricingPage() {
{isCurrent ? (
<Link
href="/dashboard/profile"
className="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 bg-muted text-foreground border-black/5 hover:bg-foreground hover:text-white"
className="w-full py-4 rounded-2xl text-xs font-bold uppercase tracking-wider transition-all flex items-center justify-center gap-3 border shadow-sm hover:shadow-xl active:scale-95 bg-muted text-foreground border-black/5 hover:bg-foreground hover:text-white"
>
{t('pricing.card.managePlan')}
<ArrowRight size={14} className="opacity-40" />
@@ -701,17 +744,17 @@ export default function PricingPage() {
) : isFree && !currentPlan ? (
<Link
href="/auth/register"
className="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 bg-foreground text-white border-transparent hover:bg-accent"
className="w-full py-4 rounded-2xl text-xs font-bold uppercase tracking-wider transition-all flex items-center justify-center gap-3 border shadow-sm hover:shadow-xl active:scale-95 bg-foreground text-white border-transparent hover:bg-accent"
>
{t('pricing.card.startFree')}
<ArrowRight size={14} className="opacity-40" />
</Link>
) : (
<button
onClick={() => handleSubscribe(plan.id)}
onClick={() => setConfirmPlan(plan)}
disabled={loadingPlanId !== null}
className={cn(
"w-full py-4 rounded-2xl text-[11px] font-black uppercase tracking-widest transition-all flex items-center justify-center gap-3 border shadow-sm hover:shadow-xl active:scale-95",
"w-full py-4 rounded-2xl text-xs font-bold uppercase tracking-wider transition-all flex items-center justify-center gap-3 border shadow-sm hover:shadow-xl active:scale-95",
plan.popular
? "bg-muted text-foreground border-black/5 hover:bg-foreground hover:text-white"
: "bg-foreground text-white border-transparent hover:bg-accent",
@@ -740,6 +783,19 @@ export default function PricingPage() {
})}
</div>
{/* Sur mesure : une ligne discrète sous le choix payant */}
<div className="mt-8 flex flex-col items-center justify-between gap-4 rounded-2xl border border-brand-accent/25 bg-brand-accent/[0.06] px-6 py-4 dark:border-brand-accent/20 dark:bg-brand-accent/10 sm:flex-row">
<p className="text-sm font-light text-brand-dark/70 dark:text-white/70">
{t('pricing.enterpriseBand.text')}
</p>
<a
href={`mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent(t('pricing.enterprise.subject'))}`}
className="shrink-0 rounded-xl border border-brand-accent/40 px-5 py-2 text-[11px] font-bold uppercase tracking-wider text-brand-goldink transition-colors hover:bg-brand-accent/10 dark:text-brand-accent"
>
{t('pricing.enterpriseBand.cta')}
</a>
</div>
{/* ── Feature comparison table ── */}
<div className="mt-20">
<h2 className="text-3xl font-bold text-center mb-2">{t('pricing.comparison.title')}</h2>
@@ -763,7 +819,6 @@ export default function PricingPage() {
{ label: t('pricing.comparison.pagesMaxPerDoc'), vals: plans.slice(0,4).map(p => p.max_pages_per_doc === -1 ? "∞" : String(p.max_pages_per_doc)) },
{ label: t('pricing.comparison.maxFileSize'), vals: plans.slice(0,4).map(p => p.max_file_size_mb === -1 ? "∞" : `${p.max_file_size_mb} ${t('pricing.comparison.mb')}`) },
{ label: t('pricing.comparison.googleTranslation'), vals: plans.slice(0,4).map(() => true) },
{ label: t('pricing.comparison.deepl'), vals: plans.slice(0,4).map(p => p.providers.includes("deepl") || p.providers.includes("all")) },
{ label: t('pricing.comparison.aiEssential'), vals: plans.slice(0,4).map(p => p.ai_translation && (p.ai_tier === "essential" || p.ai_tier === "premium" || p.ai_tier === "custom")) },
{ label: t('pricing.comparison.aiPremium'), vals: plans.slice(0,4).map(p => p.ai_translation && (p.ai_tier === "premium" || p.ai_tier === "custom")) },
{ label: t('pricing.comparison.apiAccess'), vals: plans.slice(0,4).map(p => p.api_access) },

View File

@@ -1,648 +0,0 @@
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { useDropzone } from "react-dropzone";
import {
Upload,
FileText,
FileSpreadsheet,
Presentation,
X,
Download,
Loader2,
Cpu,
AlertTriangle,
Brain,
CheckCircle,
File,
Zap,
Shield,
Eye,
Trash2,
Copy,
ExternalLink,
ChevronRight
} from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import { useTranslationStore, openaiModels, openrouterModels } from "@/lib/store";
import { translateDocument, languages, providers, extractTextsFromDocument, reconstructDocument, TranslatedText } from "@/lib/api";
import { useWebLLM } from "@/lib/webllm";
import { useI18n } from "@/lib/i18n";
import { cn } from "@/lib/utils";
const fileIcons: Record<string, React.ElementType> = {
xlsx: FileSpreadsheet,
xls: FileSpreadsheet,
docx: FileText,
doc: FileText,
pptx: Presentation,
ppt: Presentation,
};
type ProviderType = "google" | "ollama" | "deepl" | "libre" | "webllm" | "openai" | "openrouter";
interface FilePreviewProps {
file: File;
onRemove: () => void;
}
const FilePreview = ({ file, onRemove }: FilePreviewProps) => {
const { t } = useI18n();
const [preview, setPreview] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
const generatePreview = async () => {
if (!file) return;
setLoading(true);
try {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
setPreview(e.target?.result as string);
};
reader.readAsDataURL(file);
} else if (file.type === 'application/pdf') {
setPreview('/pdf-preview.png'); // Placeholder
} else {
// Generate text preview for documents
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
setPreview(text.substring(0, 200) + (text.length > 200 ? '...' : ''));
};
reader.readAsText(file);
}
} catch (error) {
console.error('Preview generation failed:', error);
} finally {
setLoading(false);
}
};
generatePreview();
}, [file]);
const getFileExtension = (filename: string) => {
return filename.split(".").pop()?.toLowerCase() || "";
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
};
const FileIcon = fileIcons[getFileExtension(file.name)] || FileText;
return (
<Card variant="elevated" className="overflow-hidden group">
<CardContent className="p-0">
{/* File Header */}
<div className="flex items-center justify-between p-4 border-b border-border-subtle bg-surface/50">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center">
<FileIcon className="w-6 h-6 text-primary" />
</div>
<div>
<p className="font-medium text-foreground truncate max-w-xs">
{file.name}
</p>
<p className="text-sm text-text-tertiary">
{formatFileSize(file.size)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" size="sm">
{getFileExtension(file.name).toUpperCase()}
</Badge>
<Button
variant="ghost"
size="icon-sm"
onClick={onRemove}
className="text-text-tertiary hover:text-destructive"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* File Preview */}
<div className="relative h-48 bg-surface/30">
{loading ? (
<div className="absolute inset-0 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : preview ? (
<div className="p-4 h-full overflow-hidden">
{file.type.startsWith('image/') ? (
<img
src={preview}
alt="Preview"
className="w-full h-full object-contain rounded"
/>
) : (
<div className="text-sm text-text-secondary font-mono whitespace-pre-wrap break-all">
{preview}
</div>
)}
</div>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<File className="h-12 w-12 text-border" />
</div>
)}
</div>
{/* File Actions */}
<div className="flex items-center justify-between p-4 border-t border-border-subtle">
<div className="flex items-center gap-2 text-sm text-text-tertiary">
<Eye className="h-4 w-4" />
{t('fileUploader.preview')}
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon-sm">
<Copy className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon-sm">
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
);
};
export function FileUploader() {
const { t } = useI18n();
const { settings } = useTranslationStore();
const webllm = useWebLLM();
const [file, setFile] = useState<File | null>(null);
const [targetLanguage, setTargetLanguage] = useState(settings.defaultTargetLanguage);
const [provider, setProvider] = useState<ProviderType>(settings.defaultProvider as ProviderType);
const [translateImages, setTranslateImages] = useState(settings.translateImages);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [translationStatus, setTranslationStatus] = useState<string>("");
const [showAdvanced, setShowAdvanced] = useState(false);
const [isTranslating, setTranslating] = useState(false);
const [progress, setProgress] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
// Sync with store settings when they change
useEffect(() => {
setTargetLanguage(settings.defaultTargetLanguage);
setProvider(settings.defaultProvider as ProviderType);
setTranslateImages(settings.translateImages);
}, [settings.defaultTargetLanguage, settings.defaultProvider, settings.translateImages]);
const onDrop = useCallback((acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
setFile(acceptedFiles[0]);
setDownloadUrl(null);
setError(null);
}
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [".xlsx"],
"application/vnd.ms-excel": [".xls"],
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": [".docx"],
"application/msword": [".doc"],
"application/vnd.openxmlformats-officedocument.presentationml.presentation": [".pptx"],
"application/vnd.ms-powerpoint": [".ppt"],
},
multiple: false,
});
const handleTranslate = async () => {
if (!file) return;
// WebLLM specific validation
if (provider === "webllm") {
if (!webllm.isWebGPUSupported()) {
setError(t('fileUploader.webgpuUnsupported'));
return;
}
if (!webllm.isLoaded) {
setError(t('fileUploader.webllmNotLoaded'));
return;
}
}
setTranslating(true);
setProgress(0);
setError(null);
setDownloadUrl(null);
setTranslationStatus("");
try {
// For WebLLM, use client-side translation
if (provider === "webllm") {
await handleWebLLMTranslation();
} else {
await handleServerTranslation();
}
} catch (err) {
setError(err instanceof Error ? err.message : t('fileUploader.translationError'));
} finally {
setTranslating(false);
setTranslationStatus("");
}
};
// Get language name from code
const getLanguageName = (code: string): string => {
const lang = languages.find(l => l.code === code);
return lang ? lang.name : code;
};
// WebLLM client-side translation
const handleWebLLMTranslation = async () => {
if (!file) return;
try {
// Step 1: Extract texts from document
setTranslationStatus(t('fileUploader.extracting'));
setProgress(5);
const extractResult = await extractTextsFromDocument(file);
if (extractResult.texts.length === 0) {
throw new Error(t('fileUploader.noTranslatable'));
}
setTranslationStatus(t('fileUploader.foundTexts', { count: extractResult.texts.length }));
setProgress(10);
// Step 2: Translate each text using WebLLM
const translations: TranslatedText[] = [];
const totalTexts = extractResult.texts.length;
const langName = getLanguageName(targetLanguage);
for (let i = 0; i < totalTexts; i++) {
const item = extractResult.texts[i];
setTranslationStatus(t('fileUploader.translatingItem', {
current: String(i + 1),
total: String(totalTexts),
preview: item.text.substring(0, 30),
}));
const translatedText = await webllm.translate(
item.text,
langName,
settings.systemPrompt || undefined,
settings.glossary || undefined
);
translations.push({
id: item.id,
translated_text: translatedText,
});
// Update progress (10% for extraction, 80% for translation, 10% for reconstruction)
const translationProgress = 10 + (80 * (i + 1)) / totalTexts;
setProgress(translationProgress);
}
// Step 3: Reconstruct document with translations
setTranslationStatus(t('fileUploader.reconstructing'));
setProgress(92);
const blob = await reconstructDocument(
extractResult.session_id,
translations,
targetLanguage
);
setProgress(100);
setTranslationStatus(t('fileUploader.translationComplete'));
const url = URL.createObjectURL(blob);
setDownloadUrl(url);
} catch (err) {
throw err;
}
};
// Server-side translation (existing logic)
const handleServerTranslation = async () => {
if (!file) return;
// Simulate progress for UX
let currentProgress = 0;
const progressInterval = setInterval(() => {
currentProgress = Math.min(currentProgress + Math.random() * 10, 90);
setProgress(currentProgress);
}, 500);
try {
const blob = await translateDocument({
file,
targetLanguage,
provider,
ollamaModel: settings.ollamaModel,
translateImages: translateImages || settings.translateImages,
systemPrompt: settings.systemPrompt,
glossary: settings.glossary,
libreUrl: settings.libreTranslateUrl,
openaiApiKey: settings.openaiApiKey,
openaiModel: settings.openaiModel,
openrouterApiKey: settings.openrouterApiKey,
openrouterModel: settings.openrouterModel,
});
clearInterval(progressInterval);
setProgress(100);
const url = URL.createObjectURL(blob);
setDownloadUrl(url);
} catch (err) {
clearInterval(progressInterval);
throw err;
}
};
const handleDownload = () => {
if (!downloadUrl || !file) return;
const a = document.createElement("a");
a.href = downloadUrl;
const ext = getFileExtension(file.name);
const baseName = file.name.replace(`.${ext}`, "");
a.download = `${baseName}_translated.${ext}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const getFileExtension = (filename: string) => {
return filename.split(".").pop()?.toLowerCase() || "";
};
const removeFile = () => {
setFile(null);
setDownloadUrl(null);
setError(null);
setProgress(0);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const FileIcon = file ? fileIcons[getFileExtension(file.name)] : FileText;
return (
<div className="space-y-8">
{/* Enhanced File Drop Zone */}
<Card variant="elevated" className="overflow-hidden">
<CardHeader>
<CardTitle className="flex items-center gap-3">
<Upload className="h-5 w-5 text-primary" />
{t('fileUploader.uploadDocument')}
</CardTitle>
<CardDescription>
{t('fileUploader.uploadDesc')}
</CardDescription>
</CardHeader>
<CardContent className="p-6">
{!file ? (
<div
{...getRootProps()}
className={cn(
"relative border-2 border-dashed rounded-xl p-12 text-center cursor-pointer transition-all duration-300",
isDragActive
? "border-primary bg-primary/5 scale-[1.02]"
: "border-border-subtle hover:border-border hover:bg-surface/50"
)}
>
<input {...getInputProps()} ref={fileInputRef} className="hidden" />
{/* Upload Icon with animation */}
<div className={cn(
"w-16 h-16 mx-auto mb-4 rounded-2xl bg-primary/10 flex items-center justify-center transition-all duration-300",
isDragActive ? "scale-110 bg-primary/20" : ""
)}>
<Upload className={cn(
"w-8 h-8 text-primary transition-transform duration-300",
isDragActive ? "scale-110" : ""
)} />
</div>
<p className="text-lg font-medium text-foreground mb-2">
{isDragActive
? t('fileUploader.dropHere')
: t('fileUploader.dragAndDrop')}
</p>
<p className="text-sm text-text-tertiary mb-6">
{t('fileUploader.orClickBrowse')}
</p>
{/* Supported formats */}
<div className="flex flex-wrap justify-center gap-3">
{[
{ ext: "xlsx", name: "Excel", icon: FileSpreadsheet, color: "text-green-400" },
{ ext: "docx", name: "Word", icon: FileText, color: "text-blue-400" },
{ ext: "pptx", name: "PowerPoint", icon: Presentation, color: "text-orange-400" },
].map((format) => (
<div key={format.ext} className="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface border border-border-subtle">
<format.icon className={cn("w-4 h-4", format.color)} />
<span className="text-sm text-text-secondary">{format.name}</span>
<span className="text-xs text-text-tertiary">.{format.ext}</span>
</div>
))}
</div>
</div>
) : (
<FilePreview file={file} onRemove={removeFile} />
)}
</CardContent>
</Card>
{/* Enhanced Translation Options */}
{file && (
<Card variant="elevated">
<CardHeader>
<CardTitle className="flex items-center gap-3">
<Brain className="h-5 w-5 text-primary" />
{t('fileUploader.translationOptions')}
</CardTitle>
<CardDescription>
{t('fileUploader.configureSettings')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Target Language */}
<div className="space-y-3">
<Label htmlFor="language" className="text-text-secondary font-medium">{t('fileUploader.targetLanguage')}</Label>
<Select value={targetLanguage} onValueChange={setTargetLanguage}>
<SelectTrigger id="language" className="bg-surface border-border-subtle">
<SelectValue placeholder={t('fileUploader.selectLanguage')} />
</SelectTrigger>
<SelectContent className="bg-surface-elevated border-border max-h-80">
{languages.map((lang) => (
<SelectItem
key={lang.code}
value={lang.code}
className="text-foreground hover:bg-surface hover:text-primary"
>
<span className="flex items-center gap-2">
<span>{lang.flag}</span>
<span>{lang.name}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Provider Selection */}
<div className="space-y-3">
<Label className="text-text-secondary font-medium">{t('fileUploader.translationProvider')}</Label>
<Select value={provider} onValueChange={(value: ProviderType) => setProvider(value)}>
<SelectTrigger className="bg-surface border-border-subtle">
<SelectValue placeholder={t('fileUploader.selectProvider')} />
</SelectTrigger>
<SelectContent className="bg-surface-elevated border-border">
{providers.map((p) => (
<SelectItem
key={p.id}
value={p.id}
className="text-foreground hover:bg-surface hover:text-primary"
>
<span className="flex items-center gap-2">
<span>{p.icon}</span>
<div className="flex flex-col">
<span className="font-medium">{p.name}</span>
<span className="text-xs text-text-tertiary">{p.description}</span>
</div>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Advanced Options Toggle */}
<Button
variant="ghost"
onClick={() => setShowAdvanced(!showAdvanced)}
className="w-full justify-between text-primary hover:text-primary/80"
>
<span>{t('fileUploader.advancedOptions')}</span>
<ChevronRight className={cn(
"h-4 w-4 transition-transform duration-200",
showAdvanced && "rotate-90"
)} />
</Button>
{/* Advanced Options */}
{showAdvanced && (
<div className="space-y-4 p-4 rounded-lg bg-surface/50 border border-border-subtle animate-slide-up">
<div className="flex items-center justify-between">
<Label htmlFor="translate-images" className="text-text-secondary">{t('fileUploader.translateImages')}</Label>
<Switch
id="translate-images"
checked={translateImages}
onCheckedChange={setTranslateImages}
/>
</div>
</div>
)}
{/* Translate Button */}
<Button
onClick={handleTranslate}
disabled={isTranslating}
variant="premium"
size="lg"
className="w-full h-12 text-lg group"
>
{isTranslating ? (
<>
<Loader2 className="me-2 h-5 w-5 animate-spin" />
{t('fileUploader.translating')}
</>
) : (
<>
<Zap className="me-2 h-5 w-5 transition-transform group-hover:scale-110" />
{t('fileUploader.translateDocument')}
</>
)}
</Button>
{/* Progress Bar */}
{isTranslating && (
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-text-secondary">
{translationStatus || t('fileUploader.processing')}
</span>
<span className="text-primary font-medium">{Math.round(progress)}%</span>
</div>
<Progress value={progress} className="h-2" />
{provider === "webllm" && (
<div className="flex items-center gap-2 text-xs text-text-tertiary p-3 rounded-lg bg-primary/5">
<Cpu className="h-3 w-3" />
{t('fileUploader.translatingLocally')}
</div>
)}
</div>
)}
{/* Error Display */}
{error && (
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4 animate-slide-up">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-destructive mb-1">{t('fileUploader.translationError')}</p>
<p className="text-sm text-destructive/80">{error}</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Enhanced Download Section */}
{downloadUrl && (
<Card variant="gradient" className="overflow-hidden animate-slide-up">
<CardContent className="p-8 text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/20 flex items-center justify-center animate-pulse">
<CheckCircle className="w-8 h-8 text-white" />
</div>
<CardTitle className="text-2xl mb-2">{t('fileUploader.translationComplete')}</CardTitle>
<CardDescription className="mb-6">
{t('fileUploader.translationCompleteDesc')}
</CardDescription>
<Button
onClick={handleDownload}
variant="glass"
size="lg"
className="group px-8"
>
<Download className="me-2 h-5 w-5 transition-transform group-hover:scale-110" />
{t('fileUploader.download')}
</Button>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,986 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useReducedMotion } from 'framer-motion';
import {
FileText,
FileSpreadsheet,
Presentation,
FileCheck,
Sparkles,
ArrowRight,
Clock,
Users,
Flame,
Check,
ShieldCheck,
Save,
Undo,
Redo,
Search,
Minus,
Square,
X,
Bold,
Italic,
Underline,
AlignLeft,
AlignCenter,
List,
ListOrdered,
Table as TableIcon,
ChevronDown,
ZoomIn,
Printer,
Maximize2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* Wordly.art — Hero Visual Authentique Office & PDF
*
* Cette animation recrée fidèlement l'environnement réel d'un logiciel bureautique
* (Microsoft Word, Excel, PowerPoint et Adobe PDF) avec :
* - Le ruban, la barre de titre, la règle et la barre d'état officielles.
* - Une vraie page de document A4 flottante avec du vrai texte lisible (≥ 11px).
* - Une onde dorée de numérisation/traduction en temps réel (FR → EN → ES).
* - Une stabilité géométrique absolue : zéro décalage, zéro saut de mise en page.
*/
type OfficeFormat = 'docx' | 'xlsx' | 'pptx' | 'pdf';
interface RecipeData {
lang: 'FR' | 'EN' | 'ES';
langName: string;
langCodeDoc: string;
docTitle: string;
docSubtitle: string;
category: string;
timeLabel: string;
servingsLabel: string;
heatLabel: string;
ingredientsHeading: string;
ingredients: Array<{ amount: string; name: string }>;
tableTitle: string;
tableHeaders: [string, string, string];
tableValues: [string, string, string];
stepsHeading: string;
steps: Array<{ num: string; title: string; desc: string }>;
excelFormula: string;
excelHeaders: [string, string, string, string];
excelRows: Array<[string, string, string, string]>;
excelTotalLabel: string;
excelTotalVal: string;
pptSlideTitle: string;
pptCard1Title: string;
pptCard1Text: string;
pptCard2Title: string;
pptCard2Text: string;
guaranteeText: string;
}
const RECIPES: RecipeData[] = [
{
lang: 'FR',
langName: 'Français (France)',
langCodeDoc: 'FR-FR',
docTitle: 'Tarte fine aux pommes & cannelle',
docSubtitle: 'Recette traditionnelle • Pâtisserie maison de saison',
category: 'Pâtisserie artisanale • Recette n°04',
timeLabel: '15 min de préparation',
servingsLabel: '4 à 6 personnes',
heatLabel: 'Four chaud 180°C',
ingredientsHeading: 'Ingrédients nécessaires',
ingredients: [
{ amount: '1 rouleau', name: 'Pâte feuilletée pur beurre dorée' },
{ amount: '4 pièces', name: 'Pommes Golden finement tranchées' },
{ amount: '25 g', name: 'Beurre doux fondu tiédi' },
{ amount: '2 sachets', name: 'Sucre vanillé naturel des îles' },
],
tableTitle: 'Paramètres de cuisson au four',
tableHeaders: ['Position four', 'Préparation', 'Cuisson dorée'],
tableValues: ['Mi-hauteur 180°C', '15 minutes', '25 minutes'],
stepsHeading: 'Instructions de préparation',
steps: [
{
num: '01',
title: 'Préparer la pâte',
desc: 'Dérouler la pâte sur la plaque et piquer le fond à la fourchette.',
},
{
num: '02',
title: 'Dresser les pommes',
desc: 'Disposer les lamelles de pommes en rosace harmonieuse et serrée.',
},
{
num: '03',
title: 'Enfourner et dorer',
desc: 'Badigeonner de beurre fondu puis saupoudrer de sucre vanillé.',
},
],
excelFormula: '=SOMME(C3:C6) & " g de garniture"',
excelHeaders: ['Ingrédient', 'Quantité', 'Unité', 'Remarque'],
excelRows: [
['Pâte feuilletée', '1', 'rouleau', 'Pur beurre'],
['Pommes Golden', '4', 'pièces', 'Tranches fines'],
['Beurre doux', '25', 'grammes', 'Fondu tiède'],
['Sucre vanillé', '2', 'sachets', 'Arôme naturel'],
],
excelTotalLabel: 'Temps de cuisson totale',
excelTotalVal: '25 min à 180°C',
pptSlideTitle: 'Recette gourmande : Tarte fine aux pommes',
pptCard1Title: 'Ingrédients clés',
pptCard1Text: 'Pâte feuilletée pur beurre, 4 pommes Golden, beurre doux fondu, sucre vanillé naturel.',
pptCard2Title: 'Cuisson & finition',
pptCard2Text: 'Disposition en rosace serrée, cuisson 25 min à 180°C jusquà dorure parfaite.',
guaranteeText: 'Mise en page originale 100% préservée',
},
{
lang: 'EN',
langName: 'English (United States)',
langCodeDoc: 'EN-US',
docTitle: 'Crisp apple & cinnamon tart',
docSubtitle: 'Traditional recipe • Seasonal homemade pastry',
category: 'Artisanal pastry • Recipe #04',
timeLabel: '15 min preparation time',
servingsLabel: '4 to 6 servings',
heatLabel: 'Hot oven at 180°C',
ingredientsHeading: 'Required ingredients',
ingredients: [
{ amount: '1 sheet', name: 'Golden all-butter puff pastry sheet' },
{ amount: '4 units', name: 'Fresh Golden apples thinly sliced' },
{ amount: '25 g', name: 'Melted unsalted sweet butter' },
{ amount: '2 packs', name: 'Natural island vanilla sugar dust' },
],
tableTitle: 'Oven baking specifications',
tableHeaders: ['Oven position', 'Preparation', 'Golden baking'],
tableValues: ['Middle shelf 180°C', '15 minutes', '25 minutes'],
stepsHeading: 'Preparation instructions',
steps: [
{
num: '01',
title: 'Prepare the crust',
desc: 'Unroll pastry on the tray and prick the base evenly with a fork.',
},
{
num: '02',
title: 'Arrange the fruit',
desc: 'Place the apple slices in an elegant, tight spiral pattern.',
},
{
num: '03',
title: 'Bake to golden',
desc: 'Brush with melted butter and dust evenly with vanilla sugar.',
},
],
excelFormula: '=SUM(C3:C6) & " g filling total"',
excelHeaders: ['Ingredient', 'Quantity', 'Unit', 'Note'],
excelRows: [
['Puff pastry', '1', 'sheet', 'All-butter'],
['Golden apples', '4', 'units', 'Thin slices'],
['Unsalted butter', '25', 'grams', 'Melted warm'],
['Vanilla sugar', '2', 'packs', 'Natural flavor'],
],
excelTotalLabel: 'Total baking duration',
excelTotalVal: '25 min at 180°C',
pptSlideTitle: 'Gourmet Recipe: Crisp Apple Tart',
pptCard1Title: 'Key Ingredients',
pptCard1Text: 'All-butter puff pastry, 4 Golden apples, melted unsalted butter, natural vanilla sugar.',
pptCard2Title: 'Baking & Finish',
pptCard2Text: 'Arrange in tight spiral, bake 25 min at 180°C until crisp and golden brown.',
guaranteeText: 'Original layout 100% preserved',
},
{
lang: 'ES',
langName: 'Español (España)',
langCodeDoc: 'ES-ES',
docTitle: 'Tarta fina de manzana & canela',
docSubtitle: 'Receta tradicional • Repostería casera de otoño',
category: 'Repostería artesanal • Receta n°04',
timeLabel: '15 min de preparación',
servingsLabel: '4 a 6 personas',
heatLabel: 'Horno caliente 180°C',
ingredientsHeading: 'Ingredientes necesarios',
ingredients: [
{ amount: '1 lámina', name: 'Masa de hojaldre dorado con mantequilla' },
{ amount: '4 piezas', name: 'Manzanas Golden finamente laminadas' },
{ amount: '25 g', name: 'Mantequilla dulce derretida templada' },
{ amount: '2 sobres', name: 'Azúcar avainillado natural fino' },
],
tableTitle: 'Parámetros de horneado al horno',
tableHeaders: ['Posición horno', 'Preparación', 'Horneado dorado'],
tableValues: ['Altura media 180°C', '15 minutos', '25 minutes'],
stepsHeading: 'Instrucciones de preparación',
steps: [
{
num: '01',
title: 'Preparar la base',
desc: 'Extender la masa sobre la bandeja y pinchar la base con tenedor.',
},
{
num: '02',
title: 'Colocar la fruta',
desc: 'Disponer las láminas de manzana en una espiral bien apretada.',
},
{
num: '03',
title: 'Hornear y dorar',
desc: 'Pincelar con mantequilla y espolvorear el azúcar avainillado.',
},
],
excelFormula: '=SUMA(C3:C6) & " g de relleno"',
excelHeaders: ['Ingrediente', 'Cantidad', 'Unidad', 'Detalle'],
excelRows: [
['Masa hojaldre', '1', 'lámina', 'Con mantequilla'],
['Manzanas Golden', '4', 'piezas', 'Láminas finas'],
['Mantequilla dulce', '25', 'gramos', 'Derretida tibia'],
['Azúcar vainillado', '2', 'sobres', 'Aroma natural'],
],
excelTotalLabel: 'Tiempo de cocción total',
excelTotalVal: '25 min a 180°C',
pptSlideTitle: 'Receta exquisita: Tarta fina de manzana',
pptCard1Title: 'Ingredientes clave',
pptCard1Text: 'Masa de hojaldre, 4 manzanas Golden, mantequilla dulce derretida, azúcar avainillado.',
pptCard2Title: 'Horneado y acabado',
pptCard2Text: 'Disposición en espiral apretada, hornear 25 min a 180°C hasta que esté dorada.',
guaranteeText: 'Estructura original 100% preservada',
},
];
const FORMAT_CONFIG: Record<
OfficeFormat,
{
ext: string;
label: string;
appTitle: string;
accentColor: string;
headerBg: string;
headerText: string;
statusBg: string;
icon: typeof FileText;
defaultFile: string;
}
> = {
docx: {
ext: '.DOCX',
label: 'Word',
appTitle: 'Word',
accentColor: '#185ABD',
headerBg: 'bg-[#185ABD] dark:bg-[#10438F]',
headerText: 'text-white',
statusBg: 'bg-[#185ABD] dark:bg-[#10438F]',
icon: FileText,
defaultFile: 'Recette_Tarte_Pommes.docx',
},
xlsx: {
ext: '.XLSX',
label: 'Excel',
appTitle: 'Excel',
accentColor: '#107C41',
headerBg: 'bg-[#107C41] dark:bg-[#0B5C30]',
headerText: 'text-white',
statusBg: 'bg-[#107C41] dark:bg-[#0B5C30]',
icon: FileSpreadsheet,
defaultFile: 'Fiche_Technique_Recette.xlsx',
},
pptx: {
ext: '.PPTX',
label: 'PowerPoint',
appTitle: 'PowerPoint',
accentColor: '#C43E1C',
headerBg: 'bg-[#C43E1C] dark:bg-[#992F13]',
headerText: 'text-white',
statusBg: 'bg-[#C43E1C] dark:bg-[#992F13]',
icon: Presentation,
defaultFile: 'Presentation_Recette.pptx',
},
pdf: {
ext: '.PDF',
label: 'PDF Reader',
appTitle: 'Adobe Acrobat Reader',
accentColor: '#E03E2D',
headerBg: 'bg-[#2A2A2A] dark:bg-[#1A1A1A]',
headerText: 'text-white',
statusBg: 'bg-[#2A2A2A] dark:bg-[#1A1A1A]',
icon: FileCheck,
defaultFile: 'Document_Recette_Officiel.pdf',
},
};
const CYCLE_DURATION_MS = 6200; // ~6.2 secondes par cycle
const SCAN_DURATION_MS = 1400; // 1.4s onde dorée
/**
* Rendu intérieur d'un document Word (.docx) avec vraie mise en page A4
*/
function WordDocumentSheet({ data }: { data: RecipeData }) {
return (
<div className="w-full h-full bg-[#FAF8F5] dark:bg-[#1C1C1C] text-[#1A1A1A] dark:text-[#EDEDED] font-sans p-5 sm:p-7 md:p-8 flex flex-col justify-between select-none">
{/* En-tête avec repères de marge Word */}
<div className="border-b border-black/[0.08] dark:border-white/[0.08] pb-3 sm:pb-4">
<div className="flex items-center justify-between gap-2 mb-1.5">
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-[11px] font-semibold bg-[#185ABD]/10 text-[#185ABD] dark:text-[#6BA5FF] border border-[#185ABD]/20">
<FileText className="w-3 h-3" />
Style : Document Word Standard
</span>
<span className="text-[11px] font-mono tracking-tight text-[#1A1A1A]/50 dark:text-white/50">
Section 1 Marges normales (2,5 cm)
</span>
</div>
<div className="flex flex-col sm:flex-row sm:items-baseline justify-between gap-1">
<h1 className="font-serif text-[18px] sm:text-[21px] md:text-[23px] font-bold tracking-tight text-[#1A1A1A] dark:text-white leading-tight">
{data.docTitle}
</h1>
<span className="text-[11px] text-[#8B6F47] dark:text-[#C5A17A] font-medium shrink-0">
{data.category}
</span>
</div>
<p className="text-[11.5px] text-[#1A1A1A]/70 dark:text-white/70 italic mt-0.5">
{data.docSubtitle}
</p>
{/* Métadonnées */}
<div className="mt-2.5 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-[#1A1A1A]/80 dark:text-white/70 font-medium">
<div className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5 text-[#185ABD] dark:text-[#6BA5FF]" />
<span>{data.timeLabel}</span>
</div>
<span className="text-black/20 dark:text-white/20"></span>
<div className="flex items-center gap-1.5">
<Users className="w-3.5 h-3.5 text-[#185ABD] dark:text-[#6BA5FF]" />
<span>{data.servingsLabel}</span>
</div>
<span className="text-black/20 dark:text-white/20"></span>
<div className="flex items-center gap-1.5">
<Flame className="w-3.5 h-3.5 text-[#185ABD] dark:text-[#6BA5FF]" />
<span>{data.heatLabel}</span>
</div>
</div>
</div>
{/* Corps 2 colonnes */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 my-auto py-2">
{/* Colonne Ingrédients + Tableau Word */}
<div className="md:col-span-5 flex flex-col justify-between gap-3">
<div>
<h2 className="text-[11.5px] font-bold uppercase tracking-wider text-[#185ABD] dark:text-[#6BA5FF] mb-2 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-xs bg-[#185ABD] dark:bg-[#6BA5FF]" />
{data.ingredientsHeading}
</h2>
<ul className="space-y-1.5">
{data.ingredients.map((item, idx) => (
<li
key={idx}
className="flex items-baseline justify-between text-[11.5px] border-b border-black/[0.04] dark:border-white/[0.04] pb-1"
>
<span className="text-[#1A1A1A] dark:text-[#E0E0E0] truncate pr-2">
{item.name}
</span>
<span className="font-mono text-[11px] font-semibold text-[#8B6F47] dark:text-[#C5A17A] shrink-0">
{item.amount}
</span>
</li>
))}
</ul>
</div>
{/* Vrai Tableau Word avec bordures officielles */}
<div className="rounded border border-[#185ABD]/20 dark:border-white/15 overflow-hidden bg-white dark:bg-[#222222]">
<div className="bg-[#185ABD]/10 dark:bg-[#185ABD]/25 px-2.5 py-1 text-[11px] font-bold text-[#185ABD] dark:text-[#8BB8FF] border-b border-[#185ABD]/15">
{data.tableTitle}
</div>
<div className="grid grid-cols-3 divide-x divide-black/[0.06] dark:divide-white/10 text-center p-1.5">
{data.tableHeaders.map((header, hIdx) => (
<div key={hIdx} className="px-1">
<div className="text-[10.5px] font-semibold text-[#8B6F47] dark:text-[#C5A17A] truncate">
{header}
</div>
<div className="text-[11px] font-bold text-[#1A1A1A] dark:text-white mt-0.5 truncate">
{data.tableValues[hIdx]}
</div>
</div>
))}
</div>
</div>
</div>
{/* Colonne Instructions numérotées Word */}
<div className="md:col-span-7 flex flex-col justify-between">
<div>
<h2 className="text-[11.5px] font-bold uppercase tracking-wider text-[#185ABD] dark:text-[#6BA5FF] mb-2 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-xs bg-[#185ABD] dark:bg-[#6BA5FF]" />
{data.stepsHeading}
</h2>
<div className="space-y-2">
{data.steps.map((st, sIdx) => (
<div
key={sIdx}
className="flex items-start gap-2.5 rounded border border-black/[0.06] dark:border-white/[0.06] bg-white dark:bg-[#222222] p-2.5"
>
<span className="flex size-5 shrink-0 items-center justify-center rounded bg-[#185ABD]/10 font-mono text-[11px] font-bold text-[#185ABD] dark:text-[#8BB8FF]">
{st.num}
</span>
<div className="flex-1 min-w-0">
<div className="text-[11.5px] font-bold text-[#1A1A1A] dark:text-white leading-tight">
{st.title}
</div>
<div className="text-[11px] text-[#1A1A1A]/80 dark:text-[#D0D0D0] mt-0.5 leading-snug">
{st.desc}
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
{/* Pied de page Word */}
<div className="pt-2.5 border-t border-black/[0.08] dark:border-white/[0.08] flex items-center justify-between text-[11px]">
<div className="flex items-center gap-1.5 text-[#185ABD] dark:text-[#6BA5FF] font-semibold">
<ShieldCheck className="w-3.5 h-3.5" />
<span>{data.guaranteeText}</span>
</div>
<div className="text-[#1A1A1A]/40 dark:text-white/40 font-mono">
Wordly Engine Microsoft Word XML
</div>
</div>
</div>
);
}
/**
* Rendu intérieur d'une feuille Excel (.xlsx) avec vraie grille de cellules
*/
function ExcelDocumentSheet({ data }: { data: RecipeData }) {
return (
<div className="w-full h-full bg-white dark:bg-[#1E1E1E] text-[#1A1A1A] dark:text-[#EDEDED] font-sans p-3 sm:p-5 flex flex-col justify-between select-none">
{/* Barre de formule Excel */}
<div className="flex items-center gap-2 px-2 py-1.5 bg-[#F3F2F1] dark:bg-[#2D2D2D] rounded border border-black/10 dark:border-white/10 text-[11px] font-mono mb-3">
<span className="font-bold text-[#107C41] dark:text-[#42CA78] px-1.5 py-0.5 bg-white dark:bg-[#1E1E1E] rounded border border-black/10">
A1:D8
</span>
<span className="text-[#1A1A1A]/40 dark:text-white/40">fx</span>
<span className="text-[#107C41] dark:text-[#42CA78] truncate font-medium">
{data.excelFormula}
</span>
</div>
{/* Grille Excel avec entêtes de colonnes A, B, C, D */}
<div className="border border-[#107C41]/30 dark:border-white/15 rounded overflow-hidden shadow-xs my-auto">
{/* Ligne des colonnes A B C D */}
<div className="grid grid-cols-12 bg-[#F3F2F1] dark:bg-[#2D2D2D] border-b border-black/10 dark:border-white/10 text-[10.5px] font-mono font-bold text-center text-[#1A1A1A]/60 dark:text-white/60 divide-x divide-black/10 dark:divide-white/10">
<div className="col-span-1 py-1 bg-[#E1DFDD] dark:bg-[#383838]">#</div>
<div className="col-span-4 py-1">A ({data.excelHeaders[0]})</div>
<div className="col-span-2 py-1">B ({data.excelHeaders[1]})</div>
<div className="col-span-2 py-1">C ({data.excelHeaders[2]})</div>
<div className="col-span-3 py-1">D ({data.excelHeaders[3]})</div>
</div>
{/* Ligne 1 : Titre fusionné */}
<div className="grid grid-cols-12 bg-[#107C41]/10 dark:bg-[#107C41]/20 border-b border-black/10 dark:border-white/10 divide-x divide-black/10 dark:divide-white/10 items-center">
<div className="col-span-1 py-1.5 text-center font-mono text-[10.5px] font-bold bg-[#F3F2F1] dark:bg-[#2D2D2D] text-[#1A1A1A]/60 dark:text-white/60">
1
</div>
<div className="col-span-11 px-3 py-1.5 font-serif font-bold text-[13px] text-[#107C41] dark:text-[#52D886] truncate">
{data.docTitle}
</div>
</div>
{/* Lignes de données Excel */}
{data.excelRows.map((row, rIdx) => (
<div
key={rIdx}
className="grid grid-cols-12 border-b border-black/[0.06] dark:border-white/[0.06] divide-x divide-black/[0.06] dark:divide-white/[0.06] text-[11.5px] items-center hover:bg-black/[0.02]"
>
<div className="col-span-1 py-1 text-center font-mono text-[10.5px] font-semibold bg-[#F3F2F1] dark:bg-[#2D2D2D] text-[#1A1A1A]/50 dark:text-white/50">
{rIdx + 2}
</div>
<div className="col-span-4 px-2 py-1 font-medium text-[#1A1A1A] dark:text-white truncate">
{row[0]}
</div>
<div className="col-span-2 px-2 py-1 font-mono text-center font-bold text-[#107C41] dark:text-[#42CA78]">
{row[1]}
</div>
<div className="col-span-2 px-2 py-1 text-center text-[#8B6F47] dark:text-[#C5A17A] truncate">
{row[2]}
</div>
<div className="col-span-3 px-2 py-1 text-[#1A1A1A]/80 dark:text-white/80 truncate">
{row[3]}
</div>
</div>
))}
{/* Ligne de total / formule */}
<div className="grid grid-cols-12 bg-[#F3F2F1]/70 dark:bg-[#252525] divide-x divide-black/10 dark:divide-white/10 items-center font-bold text-[11px]">
<div className="col-span-1 py-1 text-center font-mono text-[10.5px] bg-[#E1DFDD] dark:bg-[#383838] text-[#1A1A1A]/60 dark:text-white/60">
7
</div>
<div className="col-span-6 px-3 py-1 text-[#107C41] dark:text-[#52D886] truncate">
{data.excelTotalLabel}
</div>
<div className="col-span-5 px-3 py-1 font-mono text-end text-[#1A1A1A] dark:text-white truncate">
{data.excelTotalVal}
</div>
</div>
</div>
{/* Onglets de classeur Excel en bas */}
<div className="flex items-center gap-1 text-[11px] font-medium pt-2 border-t border-black/10 dark:border-white/10">
<div className="px-3 py-1 bg-white dark:bg-[#2A2A2A] border-t-2 border-[#107C41] font-bold text-[#107C41] dark:text-[#42CA78] shadow-xs">
Feuil1_Recette
</div>
<div className="px-3 py-1 text-[#1A1A1A]/50 dark:text-white/50">
Feuil2_Calculs
</div>
<div className="ml-auto text-[11px] text-[#107C41] dark:text-[#42CA78] font-semibold flex items-center gap-1">
<ShieldCheck className="w-3.5 h-3.5" />
<span>Formules & Cellules 100% intactes</span>
</div>
</div>
</div>
);
}
/**
* Rendu intérieur d'une diapositive PowerPoint (.pptx)
*/
function PptxDocumentSheet({ data }: { data: RecipeData }) {
return (
<div className="w-full h-full bg-[#FAF8F5] dark:bg-[#1E1E1E] text-[#1A1A1A] dark:text-[#EDEDED] font-sans p-4 sm:p-6 flex flex-col justify-between select-none">
{/* Diapositive 16:9 avec cartouche PowerPoint */}
<div className="border-b border-[#C43E1C]/20 dark:border-white/10 pb-2.5 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded bg-[#C43E1C]/10 text-[#C43E1C] dark:text-[#FF7F5E] text-[11px] font-bold font-mono">
Diapositive 1 / 1
</span>
<span className="text-[11.5px] font-semibold text-[#1A1A1A] dark:text-white">
{data.pptSlideTitle}
</span>
</div>
<span className="text-[11px] font-mono text-[#8B6F47] dark:text-[#C5A17A]">
Thème Officiel 16:9
</span>
</div>
{/* Cartes de contenu de présentation */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 my-auto py-2">
<div className="rounded-lg border-2 border-dashed border-[#C43E1C]/30 bg-white dark:bg-[#252525] p-3 sm:p-4 shadow-xs">
<div className="flex items-center gap-2 text-[#C43E1C] dark:text-[#FF7F5E] font-bold text-[12px] mb-1.5">
<span className="size-2 rounded-full bg-[#C43E1C]" />
{data.pptCard1Title}
</div>
<p className="text-[11.5px] text-[#1A1A1A]/85 dark:text-[#D5D5D5] leading-relaxed">
{data.pptCard1Text}
</p>
</div>
<div className="rounded-lg border-2 border-dashed border-[#C43E1C]/30 bg-white dark:bg-[#252525] p-3 sm:p-4 shadow-xs">
<div className="flex items-center gap-2 text-[#C43E1C] dark:text-[#FF7F5E] font-bold text-[12px] mb-1.5">
<span className="size-2 rounded-full bg-[#C43E1C]" />
{data.pptCard2Title}
</div>
<p className="text-[11.5px] text-[#1A1A1A]/85 dark:text-[#D5D5D5] leading-relaxed">
{data.pptCard2Text}
</p>
</div>
</div>
<div className="pt-2 border-t border-black/10 dark:border-white/10 flex items-center justify-between text-[11px]">
<span className="text-[#C43E1C] dark:text-[#FF7F5E] font-bold flex items-center gap-1">
<ShieldCheck className="w-3.5 h-3.5" />
Blocs textes & vecteurs PowerPoint préservés
</span>
<span className="text-[#1A1A1A]/40 dark:text-white/40 font-mono">
Affichage Normal 100%
</span>
</div>
</div>
);
}
/**
* Rendu intérieur d'un document PDF vectoriel officiel
*/
function PdfDocumentSheet({ data }: { data: RecipeData }) {
return (
<div className="w-full h-full bg-[#F5F5F3] dark:bg-[#191919] p-3 sm:p-5 flex flex-col justify-between select-none">
{/* Barre d'outils Acrobat */}
<div className="flex items-center justify-between px-3 py-1.5 bg-[#EBE9E4] dark:bg-[#252525] rounded text-[11px] font-mono border border-black/10 dark:border-white/10 mb-2">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-white dark:bg-[#181818] rounded font-bold text-[#E03E2D]">
PDF/A-1b
</span>
<span className="text-[#1A1A1A]/70 dark:text-white/70">Page 1 / 1</span>
</div>
<div className="flex items-center gap-3 text-[#1A1A1A]/60 dark:text-white/60">
<span className="flex items-center gap-1"><ZoomIn className="w-3 h-3" /> 100%</span>
<span className="flex items-center gap-1"><Printer className="w-3 h-3" /> Vectoriel</span>
</div>
</div>
{/* Page PDF avec repères de coupe */}
<div className="flex-1 rounded border border-black/15 dark:border-white/15 bg-white dark:bg-[#202020] p-4 sm:p-6 shadow-sm flex flex-col justify-between">
<div>
<div className="flex items-center justify-between border-b border-black/10 dark:border-white/10 pb-2 mb-3">
<h1 className="font-serif text-[17px] sm:text-[20px] font-bold text-[#1A1A1A] dark:text-white">
{data.docTitle}
</h1>
<span className="font-mono text-[10px] text-[#E03E2D] px-2 py-0.5 rounded bg-[#E03E2D]/10">
DOCUMENT CERTIFIÉ
</span>
</div>
<p className="text-[11.5px] text-[#1A1A1A]/80 dark:text-white/80 leading-relaxed mb-3">
{data.docSubtitle} {data.timeLabel} {data.servingsLabel} {data.heatLabel}.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-[11px]">
<div className="p-2.5 rounded bg-[#FAF8F5] dark:bg-[#181818] border border-black/5 dark:border-white/5">
<span className="font-bold text-[#E03E2D] block mb-1">{data.ingredientsHeading}</span>
{data.ingredients.slice(0, 3).map((ing, i) => (
<div key={i} className="flex justify-between py-0.5">
<span>{ing.name}</span>
<span className="font-mono font-bold">{ing.amount}</span>
</div>
))}
</div>
<div className="p-2.5 rounded bg-[#FAF8F5] dark:bg-[#181818] border border-black/5 dark:border-white/5">
<span className="font-bold text-[#E03E2D] block mb-1">{data.stepsHeading}</span>
{data.steps.slice(0, 2).map((st, i) => (
<div key={i} className="py-0.5 leading-snug">
<strong>{st.num}.</strong> {st.desc}
</div>
))}
</div>
</div>
</div>
<div className="pt-2 border-t border-black/10 dark:border-white/10 flex justify-between items-center text-[10.5px] text-[#1A1A1A]/50 dark:text-white/50 font-mono">
<span>Adobe PostScript Calque vectoriel préservé</span>
<span className="text-[#E03E2D] font-bold">100% Fidélité PDF</span>
</div>
</div>
</div>
);
}
/**
* Composant principal de démonstration
*/
export function HeroVisual() {
const prefersReduced = useReducedMotion();
const [format, setFormat] = useState<OfficeFormat>('docx');
const [langIndex, setLangIndex] = useState(0);
const [prevLangIndex, setPrevLangIndex] = useState(0);
const [isTranslating, setIsTranslating] = useState(false);
const [scanProgress, setScanProgress] = useState(0);
const cfg = FORMAT_CONFIG[format];
const currentRecipe = RECIPES[langIndex];
const prevRecipe = RECIPES[prevLangIndex];
// Déclencher une traduction manuelle ou automatique
const triggerTranslation = useCallback((targetLangIndex: number) => {
if (targetLangIndex === langIndex || isTranslating) return;
setPrevLangIndex(langIndex);
setLangIndex(targetLangIndex);
setIsTranslating(true);
setScanProgress(0);
const startTime = performance.now();
const animateScan = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / SCAN_DURATION_MS, 1);
setScanProgress(progress * 100);
if (progress < 1) {
requestAnimationFrame(animateScan);
} else {
setIsTranslating(false);
setPrevLangIndex(targetLangIndex);
}
};
requestAnimationFrame(animateScan);
}, [langIndex, isTranslating]);
// Boucle de rotation automatique des langues (toutes les ~6.2 secondes)
useEffect(() => {
if (prefersReduced) return;
const interval = setInterval(() => {
setLangIndex((current) => {
const next = (current + 1) % RECIPES.length;
setPrevLangIndex(current);
setIsTranslating(true);
setScanProgress(0);
const startTime = performance.now();
const animateScan = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / SCAN_DURATION_MS, 1);
setScanProgress(progress * 100);
if (progress < 1) {
requestAnimationFrame(animateScan);
} else {
setIsTranslating(false);
setPrevLangIndex(next);
}
};
requestAnimationFrame(animateScan);
return next;
});
}, CYCLE_DURATION_MS);
return () => clearInterval(interval);
}, [prefersReduced]);
// Sélecteur de rendu selon le format actif
const renderSheet = (data: RecipeData) => {
switch (format) {
case 'xlsx':
return <ExcelDocumentSheet data={data} />;
case 'pptx':
return <PptxDocumentSheet data={data} />;
case 'pdf':
return <PdfDocumentSheet data={data} />;
case 'docx':
default:
return <WordDocumentSheet data={data} />;
}
};
return (
<div className="w-full relative mx-auto select-none">
{/* Conteneur de fenêtre logicielle Office / PDF ultra-réaliste */}
<div className="relative rounded-2xl md:rounded-3xl border border-black/15 dark:border-white/15 bg-[#FDFCF9] dark:bg-[#141414] shadow-[0_25px_60px_-15px_rgba(0,0,0,0.18)] dark:shadow-[0_25px_60px_-15px_rgba(0,0,0,0.8)] overflow-hidden">
{/* ============================================================ */}
{/* 1. BARRE DE TITRE OFFICIELLE DU LOGICIEL (Word/Excel/PPT/PDF) */}
{/* ============================================================ */}
<div className={cn('px-3.5 py-2 sm:px-4 sm:py-2.5 flex items-center justify-between transition-colors duration-300', cfg.headerBg, cfg.headerText)}>
{/* Gauche : Icônes d'accès rapide (Enregistrer, Annuler, Rétablir) */}
<div className="flex items-center gap-2 sm:gap-3">
<div className="flex items-center gap-1.5 pr-2 border-r border-white/20">
<span className="w-2.5 h-2.5 rounded-full bg-white/25 hover:bg-white/40" />
<span className="w-2.5 h-2.5 rounded-full bg-white/25 hover:bg-white/40" />
<span className="w-2.5 h-2.5 rounded-full bg-white/25 hover:bg-white/40" />
</div>
<div className="hidden sm:flex items-center gap-2 text-white/80">
<Save className="w-3.5 h-3.5 hover:text-white cursor-pointer" />
<Undo className="w-3.5 h-3.5 hover:text-white cursor-pointer" />
<Redo className="w-3.5 h-3.5 hover:text-white cursor-pointer" />
</div>
<span className="font-mono text-[11px] sm:text-[12px] font-bold tracking-tight text-white flex items-center gap-1.5">
<cfg.icon className="w-3.5 h-3.5" />
<span>{cfg.defaultFile} {cfg.appTitle}</span>
</span>
</div>
{/* Centre : Recherche Office */}
<div className="hidden md:flex items-center gap-1.5 px-3 py-0.5 rounded bg-black/20 text-white/70 text-[11px] font-sans">
<Search className="w-3 h-3" />
<span>Rechercher ou demander de l&apos;aide (Alt+Q)</span>
</div>
{/* Droite : Boutons de fenêtre Windows / Mac */}
<div className="flex items-center gap-2 text-white/80">
<Minus className="w-3.5 h-3.5 cursor-pointer hover:text-white" />
<Square className="w-3 h-3 cursor-pointer hover:text-white" />
<X className="w-3.5 h-3.5 cursor-pointer hover:text-white" />
</div>
</div>
{/* ============================================================ */}
{/* 2. RUBAN OFFICE / SÉLECTEUR DE FORMAT RÉEL */}
{/* ============================================================ */}
<div className="bg-[#F3F2F1] dark:bg-[#202020] border-b border-black/10 dark:border-white/10 px-3 sm:px-5 py-2 flex flex-wrap items-center justify-between gap-2.5">
{/* Onglets des 4 formats bureautiques supportés */}
<div className="flex items-center gap-1 sm:gap-1.5">
{(['docx', 'xlsx', 'pptx', 'pdf'] as const).map((fmtKey) => {
const item = FORMAT_CONFIG[fmtKey];
const isSelected = format === fmtKey;
const Icon = item.icon;
return (
<button
key={fmtKey}
onClick={() => setFormat(fmtKey)}
className={cn(
'flex items-center gap-1.5 px-2.5 py-1 rounded text-[11.5px] font-bold font-mono transition-all duration-200 cursor-pointer border',
isSelected
? 'bg-white dark:bg-[#303030] text-[#1A1A1A] dark:text-white border-black/15 dark:border-white/20 shadow-xs ring-1 ring-black/5'
: 'border-transparent text-[#1A1A1A]/60 dark:text-white/60 hover:bg-black/5 dark:hover:bg-white/5'
)}
title={`Basculer vers le format ${item.label}`}
>
<Icon className="w-3.5 h-3.5" style={{ color: item.accentColor }} />
<span>{item.ext}</span>
</button>
);
})}
</div>
{/* Outils du ruban (Police, Gras, Italique, Alignement) */}
<div className="hidden lg:flex items-center gap-2 pl-3 border-l border-black/10 dark:border-white/10 text-[#1A1A1A]/70 dark:text-white/70">
<div className="flex items-center gap-1 px-2 py-0.5 rounded bg-white dark:bg-[#2A2A2A] border border-black/10 text-[11px] font-mono">
<span>{format === 'docx' ? 'Calibri' : format === 'xlsx' ? 'Segoe UI' : 'Arial'}</span>
<ChevronDown className="w-3 h-3" />
</div>
<div className="flex items-center gap-0.5">
<button className="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10"><Bold className="w-3.5 h-3.5" /></button>
<button className="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10"><Italic className="w-3.5 h-3.5" /></button>
<button className="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10"><Underline className="w-3.5 h-3.5" /></button>
</div>
<div className="h-3 w-px bg-black/10 dark:bg-white/10" />
<div className="flex items-center gap-0.5">
<button className="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10"><AlignLeft className="w-3.5 h-3.5" /></button>
<button className="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10"><AlignCenter className="w-3.5 h-3.5" /></button>
<button className="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10"><List className="w-3.5 h-3.5" /></button>
<button className="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10"><TableIcon className="w-3.5 h-3.5" /></button>
</div>
</div>
{/* Sélecteur de langue en direct */}
<div className="flex items-center gap-1.5 ml-auto">
<span className="text-[11px] font-semibold text-[#8B6F47] dark:text-[#C5A17A] hidden sm:inline">
Langue :
</span>
<div className="flex items-center rounded-lg bg-black/[0.05] dark:bg-white/[0.08] p-0.5 border border-black/10 dark:border-white/10">
{RECIPES.map((r, rIdx) => {
const isActive = rIdx === langIndex;
return (
<button
key={r.lang}
onClick={() => triggerTranslation(rIdx)}
className={cn(
'px-2 py-0.5 rounded text-[11px] font-bold font-mono transition-all duration-200 cursor-pointer',
isActive
? 'bg-[#C5A17A] text-white shadow-xs'
: 'text-[#1A1A1A]/60 dark:text-white/60 hover:text-[#1A1A1A] dark:hover:text-white'
)}
>
{r.lang}
</button>
);
})}
</div>
</div>
</div>
{/* ============================================================ */}
{/* 3. RÈGLE GRADUÉE WORD (Visible en mode .docx) */}
{/* ============================================================ */}
{format === 'docx' && (
<div className="hidden sm:flex items-center justify-between px-8 py-0.5 bg-[#FAF8F5] dark:bg-[#1A1A1A] border-b border-black/[0.06] dark:border-white/[0.06] text-[9.5px] font-mono text-[#1A1A1A]/40 dark:text-white/40">
<span> 0</span>
<span>2</span>
<span>4</span>
<span>6</span>
<span>8</span>
<span>10</span>
<span>12</span>
<span>14</span>
<span>16 cm </span>
</div>
)}
{/* ============================================================ */}
{/* 4. PLAN DE TRAVAIL & DOCUMENT A4 FLOTTANT */}
{/* ============================================================ */}
<div className="p-3 sm:p-5 md:p-6 bg-[#EBE9E4] dark:bg-[#121212] flex items-center justify-center min-h-[380px]">
{/* Feuille de document flottante avec ombre de papier réelle */}
<div className="relative w-full max-w-4xl rounded-lg sm:rounded-xl overflow-hidden border border-black/10 dark:border-white/10 bg-white dark:bg-[#1C1C1C] shadow-[0_12px_35px_rgba(0,0,0,0.12)] dark:shadow-[0_12px_35px_rgba(0,0,0,0.6)]">
{/* Conteneur Superposé Grid garantissant 0px de décalage */}
<div className="relative grid grid-cols-1 grid-rows-1">
{/* Couche 1 : Document de base (Langue précédente ou active) */}
<div className="col-start-1 row-start-1 w-full h-full">
{renderSheet(isTranslating ? prevRecipe : currentRecipe)}
</div>
{/* Couche 2 : Document traduit révélé par découpe (Clip-path) */}
{isTranslating && !prefersReduced && (
<div
className="col-start-1 row-start-1 w-full h-full pointer-events-none"
style={{
clipPath: `inset(0% 0% ${100 - scanProgress}% 0%)`,
}}
>
{renderSheet(currentRecipe)}
</div>
)}
{/* ============================================================ */}
{/* 5. ONDE DORÉE DE NUMÉRISATION / TRADUCTION */}
{/* ============================================================ */}
{isTranslating && !prefersReduced && (
<div
className="pointer-events-none absolute inset-x-0 z-30 transition-transform duration-75"
style={{
top: `${scanProgress}%`,
transform: 'translateY(-50%)',
}}
>
{/* Halo lumineux doré au-dessus */}
<div className="h-16 w-full -mt-16 bg-gradient-to-b from-transparent via-[#C5A17A]/15 to-[#C5A17A]/30" />
{/* Ligne laser dorée principale */}
<div className="relative h-[2.5px] w-full bg-gradient-to-r from-transparent via-[#C5A17A] to-transparent shadow-[0_0_15px_#C5A17A,0_0_5px_#FFE4B5]" />
{/* Badge central d'indication de traduction */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex items-center gap-1.5 px-3 py-0.5 rounded-full bg-[#1A1A1A] text-[#C5A17A] border border-[#C5A17A]/70 shadow-[0_0_15px_rgba(197,161,122,0.5)] text-[11px] font-mono font-bold tracking-wider uppercase">
<Sparkles className="w-3 h-3 text-[#C5A17A] animate-spin" />
<span>Traduction en direct : {prevRecipe.lang} {currentRecipe.lang}</span>
</div>
</div>
)}
</div>
</div>
</div>
{/* ============================================================ */}
{/* 6. BARRE D'ÉTAT OFFICIELLE EN BAS (Statistiques & Langue) */}
{/* ============================================================ */}
<div className={cn('px-4 py-2 sm:px-6 sm:py-2.5 flex flex-wrap items-center justify-between gap-2 text-[11px] font-sans text-white transition-colors duration-300', cfg.statusBg)}>
{/* Statistiques documentaires officielles */}
<div className="flex items-center gap-3 sm:gap-4 font-medium">
<span>Page 1 sur 1</span>
<span className="hidden sm:inline text-white/40">|</span>
<span className="hidden sm:inline">184 mots</span>
<span className="text-white/40">|</span>
<span className="font-bold flex items-center gap-1 bg-black/20 px-2 py-0.5 rounded">
<span>{currentRecipe.langName}</span>
</span>
</div>
{/* Sceau de conformité Wordly.art */}
<div className="flex items-center gap-2 font-medium">
<Check className="w-3.5 h-3.5 text-emerald-300 stroke-[3]" />
<span className="hidden md:inline">Mise en page 100% inviolée :</span>
<span className="font-bold font-mono">0 pixel de décalage</span>
</div>
{/* Indicateur de Zoom */}
<div className="hidden sm:flex items-center gap-2 text-white/80 font-mono text-[10.5px]">
<span>Mode Page</span>
<span>100%</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,13 +1,12 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import Link from "next/link";
import {
FileText,
Layers,
MousePointer2,
Download,
Globe,
Cpu,
CheckCircle2,
ArrowRight,
@@ -17,16 +16,16 @@ import {
Presentation,
MessageSquare,
ShieldCheck,
Zap,
Menu,
X,
Languages,
Monitor,
Eye,
Activity,
} from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { useI18n } from "@/lib/i18n";
import { HeroVisual } from "./hero-visual";
import { API_BASE } from "@/lib/config";
import { LanguageSwitcher } from "@/components/ui/language-switcher";
const Logo = () => (
@@ -35,7 +34,7 @@ const Logo = () => (
W
</div>
<span className="text-2xl font-bold tracking-tighter text-brand-dark dark:text-white">
Wordly<span className="text-brand-accent">.art</span>
Wordly<span className="text-brand-goldink dark:text-brand-accent">.art</span>
</span>
</div>
);
@@ -56,7 +55,7 @@ const Navbar = () => {
<a href="#formats" className="text-brand-dark/60 dark:text-white/60 hover:text-brand-dark dark:hover:text-white transition-colors text-[11px] font-bold uppercase tracking-widest">{t('landing.nav.formats')}</a>
<a href="#pricing" className="text-brand-dark/60 dark:text-white/60 hover:text-brand-dark dark:hover:text-white transition-colors text-[11px] font-bold uppercase tracking-widest">{t('landing.nav.pricing')}</a>
<div className="h-4 w-[1px] bg-black/10 dark:bg-white/10"></div>
<LanguageSwitcher variant="button" />
<LanguageSwitcher className="h-9 w-auto gap-1.5 rounded-full border-black/10 dark:border-white/10 bg-transparent dark:bg-transparent px-4 text-[11px] font-bold uppercase tracking-widest text-brand-dark/60 dark:text-white/60 hover:text-brand-dark dark:hover:text-white shadow-none" />
<Link href="/auth/login" className="text-brand-dark/60 dark:text-white/60 hover:text-brand-dark dark:hover:text-white transition-colors text-[11px] font-bold uppercase tracking-widest">{t('landing.nav.login')}</Link>
<Link href="/auth/register" className="premium-button py-2.5 px-6 leading-none">{t('landing.nav.startFree')}</Link>
</div>
@@ -78,7 +77,7 @@ const Navbar = () => {
>
<div className="px-6 pt-4 pb-10 space-y-4 text-center">
<div className="flex justify-center">
<LanguageSwitcher variant="button" />
<LanguageSwitcher className="w-full h-11 rounded-2xl border-2 border-black/5 dark:border-white/10 bg-transparent dark:bg-transparent font-bold text-brand-dark dark:text-white shadow-none" />
</div>
<a href="#features" onClick={() => setIsOpen(false)} className="block py-3 text-lg font-bold text-brand-dark dark:text-white">{t('landing.nav.why')}</a>
<a href="#formats" onClick={() => setIsOpen(false)} className="block py-3 text-lg font-bold text-brand-dark dark:text-white">{t('landing.nav.formats')}</a>
@@ -106,7 +105,7 @@ const Hero = () => {
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, ease: "easeOut" }}
>
<div className="inline-flex items-center gap-2 px-4 py-1.5 mb-10 text-[10px] font-bold tracking-[0.3em] text-brand-dark/40 dark:text-white/40 uppercase bg-transparent border border-black/10 dark:border-white/10 rounded-full">
<div className="inline-flex items-center gap-2 px-4 py-1.5 mb-10 text-[10px] font-bold tracking-[0.3em] text-brand-dark/60 dark:text-white/60 uppercase bg-transparent border border-black/10 dark:border-white/10 rounded-full">
<Languages size={10} /> {t('landing.hero.tag')}
</div>
<h1 className="text-6xl md:text-8xl mb-10 text-brand-dark dark:text-white font-serif font-medium tracking-tight leading-[1.1]">
@@ -133,40 +132,10 @@ const Hero = () => {
transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1], delay: 0.2 }}
className="relative max-w-5xl mx-auto"
>
<div className="editorial-card p-4 lg:p-6 overflow-hidden !bg-brand-muted/30 dark:!bg-[#1f1f1f]/30">
<div className="aspect-[16/9] lg:aspect-[21/9] bg-white dark:bg-[#141414] rounded-2xl relative overflow-hidden shadow-sm border border-black/5 dark:border-white/5">
<img
src="https://images.unsplash.com/photo-1499951360447-b19be8fe80f5?auto=format&fit=crop&q=80&w=2000"
alt="Workspace"
className="w-full h-full object-cover filter saturate-[0.8] grayscale-[0.1]"
referrerPolicy="no-referrer"
/>
<div className="absolute inset-0 bg-brand-dark/5 dark:bg-black/20" />
<div className="absolute top-8 right-8 w-64 bg-white/90 dark:bg-[#1a1a1a]/90 backdrop-blur-xl border border-black/5 dark:border-white/10 p-6 rounded-2xl shadow-2xl text-left hidden md:block">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-full bg-brand-accent/20 flex items-center justify-center text-brand-accent">
<Zap size={16} />
</div>
<span className="text-[10px] font-bold uppercase tracking-widest text-brand-dark dark:text-white">Context Engine</span>
</div>
<p className="text-xs text-brand-dark/70 dark:text-white/70 leading-relaxed font-medium">{t('landing.hero.contextEngine')}</p>
</div>
<div className="absolute bottom-8 left-8 w-64 bg-brand-dark dark:bg-[#0a0a0a] text-white p-6 rounded-2xl shadow-2xl text-left hidden md:block">
<div className="flex items-center gap-3 mb-4">
<Activity size={16} className="text-brand-accent" />
<span className="text-[10px] font-bold uppercase tracking-widest text-brand-accent">{t('landing.hero.liveAnalysis')}</span>
</div>
<div className="flex items-center gap-3">
{[1, 2, 3].map(i => (
<div key={i} className="w-6 h-6 rounded-full border-2 border-brand-dark dark:border-[#0a0a0a] bg-brand-muted dark:bg-[#1f1f1f] text-[8px] flex items-center justify-center font-bold text-brand-dark dark:text-white">JD</div>
))}
<span className="text-[10px] ml-2 text-white/60">+12 {t('landing.hero.termsDetected')}</span>
</div>
</div>
</div>
</div>
<HeroVisual />
<p className="mt-6 text-center text-xs font-light uppercase tracking-[0.25em] text-brand-dark/50 dark:text-white/50">
{t('landing.hero.visualCaption')}
</p>
</motion.div>
</div>
</section>
@@ -187,7 +156,7 @@ const Steps = () => {
<div className="mb-24">
<span className="accent-pill mb-6 block w-fit">{t('landing.steps.process')}</span>
<h2 className="text-4xl md:text-5xl text-brand-dark dark:text-white font-serif font-medium tracking-tight mb-6">{t('landing.steps.title')}</h2>
<p className="text-brand-dark/40 dark:text-white/40 text-lg max-w-xl font-light">{t('landing.steps.subtitle')}</p>
<p className="text-brand-dark/60 dark:text-white/60 text-lg max-w-xl font-light">{t('landing.steps.subtitle')}</p>
</div>
<div className="grid md:grid-cols-3 gap-16">
@@ -197,7 +166,7 @@ const Steps = () => {
{st.icon}
</div>
<h3 className="text-xl font-serif font-medium text-brand-dark dark:text-white mb-4">{st.title}</h3>
<p className="text-brand-dark/40 dark:text-white/40 leading-relaxed font-light">{st.desc}</p>
<p className="text-brand-dark/60 dark:text-white/60 leading-relaxed font-light">{st.desc}</p>
</div>
))}
</div>
@@ -222,9 +191,9 @@ const FeaturesContext = () => {
<div className="grid md:grid-cols-3 gap-8">
{[
{ icon: <Monitor className="text-brand-dark/40 dark:text-white/40" />, title: t('landing.features.context.title'), desc: t('landing.features.context.desc') },
{ icon: <Layers className="text-brand-dark/40 dark:text-white/40" />, title: t('landing.features.glossary.title'), desc: t('landing.features.glossary.desc') },
{ icon: <Eye className="text-brand-dark/40 dark:text-white/40" />, title: t('landing.features.vision.title'), desc: t('landing.features.vision.desc') },
{ icon: <Monitor className="text-brand-dark/60 dark:text-white/60" />, title: t('landing.features.context.title'), desc: t('landing.features.context.desc') },
{ icon: <Layers className="text-brand-dark/60 dark:text-white/60" />, title: t('landing.features.glossary.title'), desc: t('landing.features.glossary.desc') },
{ icon: <Eye className="text-brand-dark/60 dark:text-white/60" />, title: t('landing.features.vision.title'), desc: t('landing.features.vision.desc') },
].map((item, i) => (
<div key={i} className="editorial-card p-10 flex flex-col items-start gap-8">
<div className="w-10 h-10 border border-black/5 dark:border-white/10 rounded-full flex items-center justify-center">
@@ -232,7 +201,7 @@ const FeaturesContext = () => {
</div>
<div>
<h4 className="text-xl font-serif font-medium mb-4 text-brand-dark dark:text-white">{item.title}</h4>
<p className="text-brand-dark/40 dark:text-white/40 leading-relaxed font-light text-sm">{item.desc}</p>
<p className="text-brand-dark/60 dark:text-white/60 leading-relaxed font-light text-sm">{item.desc}</p>
</div>
</div>
))}
@@ -244,34 +213,91 @@ const FeaturesContext = () => {
const LayoutFeatures = () => {
const { t } = useI18n();
const points = [
{ title: t('landing.layout.p1.title'), desc: t('landing.layout.p1.desc'), icon: <Layers size={22} /> },
{ title: t('landing.layout.p2.title'), desc: t('landing.layout.p2.desc'), icon: <Layout size={22} /> },
{ title: t('landing.layout.p3.title'), desc: t('landing.layout.p3.desc'), icon: <PieChart size={22} /> },
{ title: t('landing.layout.p4.title'), desc: t('landing.layout.p4.desc'), icon: <Zap size={22} /> },
{ title: t('landing.layout.p5.title'), desc: t('landing.layout.p5.desc'), icon: <MessageSquare size={22} /> },
{ title: t('landing.layout.p6.title'), desc: t('landing.layout.p6.desc'), icon: <Globe size={22} /> },
];
// Deux vraies pages de document : même structure, deux langues.
// Le terme en gras montre le respect de la terminologie (glossaire).
const DocPage = ({ side }: { side: "source" | "target" }) => (
<div className="flex flex-col rounded-2xl bg-white p-8 text-start shadow-2xl dark:bg-[#181818] lg:p-10">
<span className={`mb-6 w-fit rounded-full border px-3 py-1 text-[10px] font-black uppercase tracking-widest ${side === "source" ? "border-black/10 text-brand-dark/50 dark:border-white/15 dark:text-white/50" : "border-brand-accent/40 bg-brand-accent/10 text-brand-goldink dark:text-brand-accent"}`}>
{side === "source" ? "FR — original" : "EN — traduit"}
</span>
<h4 className="mb-4 font-serif text-xl font-medium tracking-tight text-brand-dark dark:text-white lg:text-2xl">
{side === "source" ? t("landing.beforeAfter.sourceTitle") : t("landing.beforeAfter.targetTitle")}
</h4>
<p className="mb-4 text-sm leading-relaxed text-brand-dark/75 dark:text-white/75">
{side === "source" ? t("landing.beforeAfter.sourcePara1") : t("landing.beforeAfter.targetPara1")}
</p>
<p className="mb-6 text-sm leading-relaxed text-brand-dark/75 dark:text-white/75">
{side === "source" ? (
<>
{t("landing.beforeAfter.sourcePara2Before")}{" "}
<strong className="font-bold text-brand-dark dark:text-white">{t("landing.beforeAfter.sourceTerm")}</strong>{" "}
{t("landing.beforeAfter.sourcePara2After")}
</>
) : (
<>
{t("landing.beforeAfter.targetPara2Before")}{" "}
<strong className="font-bold text-brand-dark dark:text-white">{t("landing.beforeAfter.targetTerm")}</strong>{" "}
{t("landing.beforeAfter.targetPara2After")}
</>
)}
</p>
<ul className="mb-8 space-y-2 text-sm text-brand-dark/75 dark:text-white/75">
<li className="flex items-start gap-2">
<span className="mt-2 size-1 shrink-0 rounded-full bg-brand-accent" />
{side === "source" ? t("landing.beforeAfter.sourceItem1") : t("landing.beforeAfter.targetItem1")}
</li>
<li className="flex items-start gap-2">
<span className="mt-2 size-1 shrink-0 rounded-full bg-brand-accent" />
{side === "source" ? t("landing.beforeAfter.sourceItem2") : t("landing.beforeAfter.targetItem2")}
</li>
</ul>
<div className="mt-auto flex items-center justify-between border-t border-black/[0.06] pt-4 dark:border-white/[0.08]">
<span className="font-mono text-[10px] uppercase tracking-wider text-brand-dark/45 dark:text-white/45">cahier-des-charges.docx</span>
<span className="font-mono text-[10px] uppercase tracking-wider text-brand-dark/45 dark:text-white/45">3 pages · 2 481 mots</span>
</div>
</div>
);
return (
<section className="py-32 px-4 bg-brand-dark dark:bg-[#0a0a0a] text-white">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-32">
<h2 className="text-4xl md:text-6xl mb-10 leading-tight font-serif font-medium tracking-tight">
{t('landing.layout.title')}<br /> <span className="italic">{t('landing.layout.title2')}</span>
<section id="features" className="px-4 py-32 text-white">
<div className="mx-auto max-w-7xl">
<div className="mb-16 text-center">
<h2 className="mb-8 font-serif text-4xl font-medium leading-tight tracking-tight md:text-6xl">
{t("landing.layout.title")}<br /> <span className="italic">{t("landing.layout.title2")}</span>
</h2>
<p className="text-white/40 max-w-2xl mx-auto text-xl font-light tracking-widest">
{t('landing.layout.subtitle')}
<p className="mx-auto max-w-2xl text-xl font-light tracking-wide text-white/45">
{t("landing.layout.subtitle")}
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-px bg-white/5 border border-white/10 rounded-3xl overflow-hidden shadow-2xl">
{points.map((p, i) => (
<div key={i} className="group p-14 bg-brand-dark dark:bg-[#0a0a0a] hover:bg-white/[0.02] transition-all duration-700">
<div className="w-12 h-12 border border-white/10 rounded-xl flex items-center justify-center text-brand-accent mb-10 group-hover:scale-110 group-hover:border-white/20 transition-all">
{p.icon}
</div>
<h4 className="text-xl font-serif font-medium mb-6 leading-none">{p.title}</h4>
<p className="text-white/30 text-sm leading-relaxed font-light">{p.desc}</p>
<div className="relative grid gap-6 md:grid-cols-2 md:gap-10">
<DocPage side="source" />
<DocPage side="target" />
{/* sceau central : la promesse, entre les deux pages */}
<div className="pointer-events-none absolute inset-0 hidden items-center justify-center md:flex">
<div className="flex items-center gap-2 rounded-full border border-brand-accent/40 bg-brand-dark px-5 py-2.5 shadow-2xl dark:bg-[#0a0a0a]">
<CheckCircle2 size={14} className="text-brand-accent" />
<span className="text-[10px] font-black uppercase tracking-widest text-brand-accent">
{t("landing.beforeAfter.seal")}
</span>
</div>
</div>
</div>
{/* trois preuves précises, pas des promesses vagues */}
<div className="mt-14 grid gap-4 sm:grid-cols-3">
{[
{ icon: <Layout size={16} />, label: t("landing.beforeAfter.proof1") },
{ icon: <PieChart size={16} />, label: t("landing.beforeAfter.proof2") },
{ icon: <MessageSquare size={16} />, label: t("landing.beforeAfter.proof3") },
].map((proof, i) => (
<div key={i} className="flex items-center gap-3 rounded-2xl border border-white/10 px-5 py-4">
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-brand-accent/30 text-brand-accent">
{proof.icon}
</span>
<span className="text-sm font-light text-white/70">{proof.label}</span>
</div>
))}
</div>
@@ -283,19 +309,19 @@ const LayoutFeatures = () => {
const FormatsSection = () => {
const { t } = useI18n();
const formats = [
{ id: ".DOCX", name: t('landing.formats.word.name'), icon: <FileText className="text-brand-dark/40 dark:text-white/40" />, items: [t('landing.formats.word.i1'), t('landing.formats.word.i2'), t('landing.formats.word.i3'), t('landing.formats.word.i4')] },
{ id: ".XLSX", name: t('landing.formats.excel.name'), icon: <FileSpreadsheet className="text-brand-dark/40 dark:text-white/40" />, items: [t('landing.formats.excel.i1'), t('landing.formats.excel.i2'), t('landing.formats.excel.i3'), t('landing.formats.excel.i4')] },
{ id: ".PPTX", name: t('landing.formats.pptx.name'), icon: <Presentation className="text-brand-dark/40 dark:text-white/40" />, items: [t('landing.formats.pptx.i1'), t('landing.formats.pptx.i2'), t('landing.formats.pptx.i3'), t('landing.formats.pptx.i4')] },
{ id: ".PDF", name: t('landing.formats.pdf.name'), icon: <FileText className="text-brand-dark/40 dark:text-white/40" />, items: [t('landing.formats.pdf.i1'), t('landing.formats.pdf.i2'), t('landing.formats.pdf.i3'), t('landing.formats.pdf.i4')] },
{ id: ".DOCX", name: t('landing.formats.word.name'), icon: <FileText className="text-brand-dark/60 dark:text-white/60" />, items: [t('landing.formats.word.i1'), t('landing.formats.word.i2'), t('landing.formats.word.i3'), t('landing.formats.word.i4')] },
{ id: ".XLSX", name: t('landing.formats.excel.name'), icon: <FileSpreadsheet className="text-brand-dark/60 dark:text-white/60" />, items: [t('landing.formats.excel.i1'), t('landing.formats.excel.i2'), t('landing.formats.excel.i3'), t('landing.formats.excel.i4')] },
{ id: ".PPTX", name: t('landing.formats.pptx.name'), icon: <Presentation className="text-brand-dark/60 dark:text-white/60" />, items: [t('landing.formats.pptx.i1'), t('landing.formats.pptx.i2'), t('landing.formats.pptx.i3'), t('landing.formats.pptx.i4')] },
{ id: ".PDF", name: t('landing.formats.pdf.name'), icon: <FileText className="text-brand-dark/60 dark:text-white/60" />, items: [t('landing.formats.pdf.i1'), t('landing.formats.pdf.i2'), t('landing.formats.pdf.i3'), t('landing.formats.pdf.i4')] },
];
return (
<section id="formats" className="py-40 px-4">
<div className="max-w-7xl mx-auto">
<div className="mb-24">
<span className="accent-pill mb-6 block w-fit">COMPATIBILITÉ</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>
<p className="text-brand-dark/40 dark:text-white/40 max-w-xl text-lg font-light">{t('landing.formats.subtitle')}</p>
<p className="text-brand-dark/60 dark:text-white/60 max-w-xl text-lg font-light">{t('landing.formats.subtitle')}</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
{formats.map((f, i) => (
@@ -304,12 +330,12 @@ const FormatsSection = () => {
<div className="w-12 h-12 bg-brand-muted dark:bg-[#1f1f1f] rounded-xl flex items-center justify-center text-xl group-hover:bg-brand-dark dark:group-hover:bg-brand-accent group-hover:text-white dark:group-hover:text-brand-dark transition-all">
{f.icon}
</div>
<span className="text-[9px] font-bold text-brand-dark/20 dark:text-white/20 uppercase tracking-[0.2em]">{f.id}</span>
<span className="text-[10px] font-bold text-brand-dark/50 dark:text-white/50 uppercase tracking-[0.2em]">{f.id}</span>
</div>
<h3 className="text-xl font-serif font-medium mb-6 text-brand-dark dark:text-white tracking-tight">{f.name}</h3>
<ul className="space-y-4">
{f.items.map((item, j) => (
<li key={j} className="flex items-start gap-3 text-xs text-brand-dark/40 dark:text-white/40 font-medium">
<li key={j} className="flex items-start gap-3 text-xs text-brand-dark/60 dark:text-white/60 font-medium">
<div className="w-1 h-1 bg-brand-accent rounded-full mt-1.5 shrink-0 opacity-40"></div> {item}
</li>
))}
@@ -325,11 +351,41 @@ const FormatsSection = () => {
const PricingSection = () => {
const { t } = useI18n();
const [isAnnual, setIsAnnual] = useState(true);
// Prices come from the same live endpoint as /pricing (admin-configurable);
// the hardcoded values below are only a fallback while it loads or fails.
const [livePrices, setLivePrices] = useState<Record<string, { monthly: number; annualMonthly: number }>>({});
useEffect(() => {
fetch(`${API_BASE}/api/v1/auth/plans`, { cache: 'no-store' })
.then(r => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then(json => {
const d = json.data ?? json;
const map: Record<string, { monthly: number; annualMonthly: number }> = {};
for (const plan of d.plans ?? []) {
if (typeof plan.price_monthly === 'number' && plan.price_monthly > 0) {
map[plan.id] = {
monthly: plan.price_monthly,
annualMonthly: plan.price_yearly > 0 ? plan.price_yearly / 12 : plan.price_monthly,
};
}
}
setLivePrices(map);
})
.catch(() => { /* keep marketing defaults */ });
}, []);
const priceFor = (id: string, fallbackMonthly: number, fallbackAnnual: number): string => {
const live = livePrices[id];
const value = live
? (isAnnual ? live.annualMonthly : live.monthly)
: (isAnnual ? fallbackAnnual : fallbackMonthly);
return Number.isInteger(value) ? String(value) : value.toFixed(2);
};
const plans = [
{ name: t('landing.pricing.starter.name'), desc: t('landing.pricing.starter.desc'), monthlyPrice: 9, annualPrice: 7, features: [t('landing.pricing.starter.f1'), t('landing.pricing.starter.f2'), t('landing.pricing.starter.f3'), t('landing.pricing.starter.f4')], cta: t('landing.pricing.starter.cta'), popular: false },
{ name: t('landing.pricing.pro.name'), desc: t('landing.pricing.pro.desc'), monthlyPrice: 19, annualPrice: 15, features: [t('landing.pricing.pro.f1'), t('landing.pricing.pro.f2'), t('landing.pricing.pro.f3'), t('landing.pricing.pro.f4'), t('landing.pricing.pro.f5'), t('landing.pricing.pro.f6')], cta: t('landing.pricing.pro.cta'), popular: true },
{ name: t('landing.pricing.business.name'), desc: t('landing.pricing.business.desc'), monthlyPrice: 49, annualPrice: 39, features: [t('landing.pricing.business.f1'), t('landing.pricing.business.f2'), t('landing.pricing.business.f3'), t('landing.pricing.business.f4'), t('landing.pricing.business.f5'), t('landing.pricing.business.f6')], cta: t('landing.pricing.business.cta'), popular: false },
{ id: 'starter', name: t('landing.pricing.starter.name'), desc: t('landing.pricing.starter.desc'), monthlyPrice: 9, annualPrice: 7, features: [t('landing.pricing.starter.f1'), t('landing.pricing.starter.f2'), t('landing.pricing.starter.f3'), t('landing.pricing.starter.f4')], cta: t('landing.pricing.starter.cta'), popular: false },
{ id: 'pro', name: t('landing.pricing.pro.name'), desc: t('landing.pricing.pro.desc'), monthlyPrice: 19, annualPrice: 15, features: [t('landing.pricing.pro.f1'), t('landing.pricing.pro.f2'), t('landing.pricing.pro.f3'), t('landing.pricing.pro.f4'), t('landing.pricing.pro.f5'), t('landing.pricing.pro.f6')], cta: t('landing.pricing.pro.cta'), popular: true },
{ id: 'business', name: t('landing.pricing.business.name'), desc: t('landing.pricing.business.desc'), monthlyPrice: 49, annualPrice: 39, features: [t('landing.pricing.business.f1'), t('landing.pricing.business.f2'), t('landing.pricing.business.f3'), t('landing.pricing.business.f4'), t('landing.pricing.business.f5'), t('landing.pricing.business.f6')], cta: t('landing.pricing.business.cta'), popular: false },
];
return (
@@ -337,16 +393,16 @@ const PricingSection = () => {
<div className="max-w-7xl mx-auto">
<div className="text-center mb-24">
<h2 className="text-4xl md:text-5xl mb-8 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">{t('landing.pricing.title')}</h2>
<p className="text-brand-dark/40 dark:text-white/40 text-xl font-light max-w-2xl mx-auto">{t('landing.pricing.subtitle')}</p>
<p className="text-brand-dark/60 dark:text-white/60 text-xl font-light max-w-2xl mx-auto">{t('landing.pricing.subtitle')}</p>
</div>
<div className="flex items-center justify-center gap-12 mb-32">
<button onClick={() => setIsAnnual(false)} className={`text-sm font-bold uppercase tracking-widest transition-all ${!isAnnual ? 'text-brand-dark dark:text-white underline underline-offset-8' : 'text-brand-dark/30 dark:text-white/30 hover:text-brand-dark dark:hover:text-white'}`}>
<button onClick={() => setIsAnnual(false)} className={`text-sm font-bold uppercase tracking-widest transition-all ${!isAnnual ? 'text-brand-dark dark:text-white underline underline-offset-8' : 'text-brand-dark/55 dark:text-white/55 hover:text-brand-dark dark:hover:text-white'}`}>
{t('landing.pricing.monthly')}
</button>
<button onClick={() => setIsAnnual(true)} className={`text-sm font-bold uppercase tracking-widest transition-all relative ${isAnnual ? 'text-brand-dark dark:text-white underline underline-offset-8' : 'text-brand-dark/30 dark:text-white/30 hover:text-brand-dark dark:hover:text-white'}`}>
<button onClick={() => setIsAnnual(true)} className={`text-sm font-bold uppercase tracking-widest transition-all relative ${isAnnual ? 'text-brand-dark dark:text-white underline underline-offset-8' : 'text-brand-dark/55 dark:text-white/55 hover:text-brand-dark dark:hover:text-white'}`}>
{t('landing.pricing.annual')}
<span className="absolute -top-6 left-1/2 -translate-x-1/2 text-[9px] font-bold text-brand-accent whitespace-nowrap italic">
<span className="absolute -top-6 left-1/2 -translate-x-1/2 text-[10px] font-bold text-brand-goldink dark:text-brand-accent whitespace-nowrap italic">
(-20%)
</span>
</button>
@@ -356,11 +412,11 @@ const PricingSection = () => {
{plans.map((p, i) => (
<div key={i} className="p-16 bg-white dark:bg-[#141414] flex flex-col text-left transition-all duration-500 hover:z-10 hover:shadow-2xl hover:scale-[1.01]">
<h3 className="text-2xl font-serif font-medium mb-2 text-brand-dark dark:text-white tracking-tight">{p.name}</h3>
<p className="text-brand-dark/40 dark:text-white/40 text-xs font-light mb-14 tracking-widest uppercase">{p.desc}</p>
<p className="text-brand-dark/60 dark:text-white/60 text-xs font-light mb-14 tracking-widest uppercase">{p.desc}</p>
<div className="mb-14">
<span className="text-5xl font-serif font-medium leading-none text-brand-dark dark:text-white">{isAnnual ? p.annualPrice : p.monthlyPrice}</span>
<span className="text-brand-dark/30 dark:text-white/30 font-light ml-2 uppercase text-[10px] tracking-[0.2em]">/ {t('landing.pricing.month')}</span>
<span className="text-5xl font-serif font-medium leading-none text-brand-dark dark:text-white">{priceFor(p.id, p.monthlyPrice, p.annualPrice)} </span>
<span className="text-brand-dark/55 dark:text-white/55 font-light ml-2 uppercase text-[10px] tracking-[0.2em]">/ {t('landing.pricing.month')}</span>
</div>
<ul className="space-y-6 mb-16 flex-grow">
@@ -381,7 +437,7 @@ const PricingSection = () => {
))}
</div>
<p className="text-center mt-12 text-brand-dark/20 dark:text-white/20 text-xs font-bold uppercase tracking-widest">{t('landing.pricing.footer')}</p>
<p className="text-center mt-12 text-brand-dark/50 dark:text-white/50 text-xs font-bold uppercase tracking-widest">{t('landing.pricing.footer')}</p>
</div>
</section>
);
@@ -395,7 +451,7 @@ const CTASection = () => {
<h2 className="text-5xl md:text-7xl text-brand-dark dark:text-white mb-10 leading-tight tracking-tight font-serif font-medium">
{t('landing.cta.title')}
</h2>
<p className="text-lg text-brand-dark/40 dark:text-white/40 mb-16 font-light">
<p className="text-lg text-brand-dark/60 dark:text-white/60 mb-16 font-light">
{t('landing.cta.subtitle')}
</p>
<div className="flex flex-col sm:flex-row gap-6 justify-center items-center">
@@ -416,43 +472,37 @@ const Footer = () => {
<div className="grid md:grid-cols-12 gap-20 mb-40">
<div className="md:col-span-12 lg:col-span-6">
<Logo />
<p className="text-brand-dark/40 dark:text-white/40 text-lg font-medium mt-12 mb-12 max-w-sm leading-relaxed uppercase tracking-widest">
<p className="text-brand-dark/60 dark:text-white/60 text-lg font-medium mt-12 mb-12 max-w-sm leading-relaxed uppercase tracking-widest">
{t('landing.footer.desc')}
</p>
</div>
<div className="md:col-span-4 lg:col-span-2">
<h4 className="text-[10px] font-bold uppercase tracking-[0.3em] text-brand-dark dark:text-white mb-10">{t('landing.footer.product')}</h4>
<ul className="space-y-6 text-xs text-brand-dark/40 dark:text-white/40 font-bold uppercase tracking-widest leading-none">
<ul className="space-y-6 text-xs text-brand-dark/60 dark:text-white/60 font-bold uppercase tracking-widest leading-none">
<li><a href="#features" className="hover:text-brand-dark dark:hover:text-white transition-colors">{t('landing.nav.why')}</a></li>
<li><a href="#formats" className="hover:text-brand-dark dark:hover:text-white transition-colors">{t('landing.nav.formats')}</a></li>
<li><a href="#pricing" className="hover:text-brand-dark dark:hover:text-white transition-colors">{t('landing.nav.pricing')}</a></li>
</ul>
</div>
<div className="md:col-span-4 lg:col-span-2">
<h4 className="text-[10px] font-bold uppercase tracking-[0.3em] text-brand-dark dark:text-white mb-10">{t('landing.footer.resources')}</h4>
<ul className="space-y-6 text-xs text-brand-dark/40 dark:text-white/40 font-bold uppercase tracking-widest leading-none">
<li><a href="#" className="hover:text-brand-dark dark:hover:text-white transition-colors">Documentation</a></li>
<li><a href="#" className="hover:text-brand-dark dark:hover:text-white transition-colors">API</a></li>
</ul>
</div>
<div className="md:col-span-4 lg:col-span-2">
<h4 className="text-[10px] font-bold uppercase tracking-[0.3em] text-brand-dark dark:text-white mb-10">{t('landing.footer.legal')}</h4>
<ul className="space-y-6 text-xs text-brand-dark/40 dark:text-white/40 font-bold uppercase tracking-widest leading-none">
<li><a href="#" className="hover:text-brand-dark dark:hover:text-white transition-colors">Privacy</a></li>
<li><a href="#" className="hover:text-brand-dark dark:hover:text-white transition-colors">Terms</a></li>
</ul>
</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="text-[10px] font-bold text-brand-dark/20 dark:text-white/20 uppercase tracking-[0.4em]">
<div className="text-[10px] font-bold text-brand-dark/50 dark:text-white/50 uppercase tracking-[0.4em]">
{t('landing.footer.rights')}
</div>
<div className="text-[10px] font-mono text-brand-dark/20 dark:text-white/20 uppercase tracking-widest">
v2.4.0 &bull; Built for excellence
<div className="flex items-center gap-6">
<button
type="button"
onClick={() => import('@/components/ui/cookie-consent').then(m => m.reopenCookieConsent())}
className="text-[10px] font-bold text-brand-dark/50 dark:text-white/50 uppercase tracking-[0.2em] hover:text-brand-dark dark:hover:text-white transition-colors"
>
{t('cookieConsent.reopen')}
</button>
<div className="text-[10px] font-mono text-brand-dark/55 dark:text-white/55 uppercase tracking-widest">
wordly.art
</div>
</div>
</div>
</div>

View File

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

View File

@@ -1,24 +0,0 @@
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
export function SiteFooter() {
const { t } = useI18n();
return (
<footer className="border-t border-border py-6 text-center text-xs text-muted-foreground">
<div className="mx-auto max-w-5xl px-6 flex flex-col sm:flex-row items-center justify-between gap-4">
<span>{t('landing.footer.rights')}</span>
<div className="flex items-center gap-6">
<Link href="/pricing" className="hover:text-foreground transition-colors">
{t('landing.nav.pricing')}
</Link>
<Link href="/pricing#terms" className="hover:text-foreground transition-colors">
{t('layout.footer.terms')}
</Link>
<Link href="/pricing#privacy" className="hover:text-foreground transition-colors">
{t('layout.footer.privacy')}
</Link>
</div>
</div>
</footer>
);
}

View File

@@ -1,43 +0,0 @@
"use client";
import Link from "next/link";
import { Languages } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useI18n } from "@/lib/i18n";
export function SiteHeader() {
const { t } = useI18n();
return (
<header className="sticky top-0 z-50 w-full border-b border-border/50 bg-background/80 backdrop-blur-lg">
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-6">
<Link href="/" className="flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-lg bg-primary">
<Languages className="size-4 text-primary-foreground" />
</div>
<span className="text-base font-semibold tracking-tight text-foreground">
Wordly
</span>
</Link>
<nav className="hidden items-center gap-1 md:flex">
<Button variant="ghost" size="sm" asChild>
<Link href="/pricing">{t('landing.nav.pricing')}</Link>
</Button>
<Button variant="ghost" size="sm" asChild>
<Link href="/pricing#api">{t('layout.nav.apiAccess')}</Link>
</Button>
<div className="mx-2 h-4 w-px bg-border" />
<Button variant="outline" size="sm" asChild>
<Link href="/dashboard">{t('landing.nav.login')}</Link>
</Button>
</nav>
<div className="md:hidden">
<Button variant="outline" size="sm" asChild>
<Link href="/dashboard">{t('landing.nav.login')}</Link>
</Button>
</div>
</div>
</header>
);
}

View File

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

View File

@@ -1,7 +1,6 @@
"use client";
import { Globe } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
@@ -11,68 +10,45 @@ import {
} from "@/components/ui/select";
import { useI18n, type Locale } from "@/lib/i18n";
const languages: { value: Locale; label: string; flag: string }[] = [
{ value: "en", label: "English", flag: "🇬🇧" },
{ value: "fr", label: "Français", flag: "🇫🇷" },
{ value: "es", label: "Español", flag: "🇪🇸" },
{ value: "de", label: "Deutsch", flag: "🇩🇪" },
{ value: "pt", label: "Português", flag: "🇧🇷" },
{ value: "it", label: "Italiano", flag: "🇮🇹" },
{ value: "nl", label: "Nederlands", flag: "🇳🇱" },
{ value: "ru", label: "Русский", flag: "🇷🇺" },
{ value: "ja", label: "日本語", flag: "🇯🇵" },
{ value: "ko", label: "한국어", flag: "🇰🇷" },
{ value: "zh", label: "中文", flag: "🇨🇳" },
{ value: "ar", label: "العربية", flag: "🇸🇦" },
{ value: "fa", label: "فارسی", flag: "🇮🇷" },
// Language names in their own script — flags are country symbols, not languages.
const languages: { value: Locale; label: string }[] = [
{ value: "en", label: "English" },
{ value: "fr", label: "Français" },
{ value: "es", label: "Español" },
{ value: "de", label: "Deutsch" },
{ value: "pt", label: "Português" },
{ value: "it", label: "Italiano" },
{ value: "nl", label: "Nederlands" },
{ value: "ru", label: "Русский" },
{ value: "ja", label: "日本語" },
{ value: "ko", label: "한국어" },
{ value: "zh", label: "中文" },
{ value: "ar", label: "العربية" },
{ value: "fa", label: "فارسی" },
];
interface LanguageSwitcherProps {
variant?: "select" | "button";
className?: string;
}
export function LanguageSwitcher({ variant = "select" }: LanguageSwitcherProps) {
const { locale, setLocale } = useI18n();
if (variant === "button") {
const currentIndex = languages.findIndex((l) => l.value === locale);
const nextIndex = (currentIndex + 1) % languages.length;
const nextLang = languages[nextIndex];
const currentLang = languages[currentIndex];
return (
<Button
variant="ghost"
size="sm"
onClick={() => setLocale(nextLang.value)}
className="gap-1.5 text-muted-foreground hover:text-foreground"
>
<Globe className="h-4 w-4" />
<span className="text-sm">{currentLang.flag}</span>
<span className="text-xs font-medium uppercase">{currentLang.value}</span>
</Button>
);
}
export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
const { locale, setLocale, t } = useI18n();
const current = languages.find((l) => l.value === locale) ?? languages[0];
return (
<Select value={locale} onValueChange={(v) => setLocale(v as Locale)}>
<SelectTrigger className="w-[150px] h-9">
<SelectTrigger className={className} aria-label={t('langSelector.changeLanguage')}>
<SelectValue>
<span className="flex items-center gap-2">
<Globe className="h-4 w-4" />
{languages.find((l) => l.value === locale)?.flag}{" "}
{languages.find((l) => l.value === locale)?.label}
<span>{current.label}</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectContent align="start">
{languages.map((lang) => (
<SelectItem
key={lang.value}
value={lang.value}
>
<SelectItem key={lang.value} value={lang.value}>
<span className="flex items-center gap-2">
<span>{lang.flag}</span>
<span>{lang.label}</span>
</span>
</SelectItem>

View File

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

View File

@@ -4,6 +4,7 @@ import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { X, CheckCircle, AlertCircle, AlertTriangle, Info, Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
import { useI18n } from "@/lib/i18n"
const notificationVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-lg border p-4 pe-8 shadow-lg transition-all duration-300 ease-out",
@@ -74,6 +75,7 @@ const Notification = React.forwardRef<HTMLDivElement, NotificationProps>(
children,
...props
}, ref) => {
const { t } = useI18n()
const [visible, setVisible] = React.useState(true)
const [progress, setProgress] = React.useState(100)
@@ -117,13 +119,15 @@ const Notification = React.forwardRef<HTMLDivElement, NotificationProps>(
return (
<div
ref={ref}
role="status"
aria-live="polite"
className={cn(notificationVariants({ variant, size, position }), className)}
{...props}
>
{/* Progress Bar for Auto-close */}
{autoClose && !loading && (
<div className="absolute top-0 left-0 h-1 bg-white/20">
<div
<div
className="h-full bg-white/40 transition-all duration-100 ease-linear"
style={{ width: `${progress}%` }}
/>
@@ -178,8 +182,8 @@ const Notification = React.forwardRef<HTMLDivElement, NotificationProps>(
<button
type="button"
onClick={handleClose}
className="absolute right-2 top-2 flex-shrink-0 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
aria-label="Close notification"
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={t("common.closeNotification")}
>
<X className="h-4 w-4" />
</button>

View File

@@ -53,7 +53,6 @@ export const languages: Language[] = [
export const providers: Provider[] = [
{ id: "google", name: "Google Translate", description: "Fast translation, 130+ languages" },
{ id: "deepl", name: "DeepL", description: "High-quality neural translation" },
{ id: "openai", name: "OpenAI", description: "GPT-powered context-aware translation", mode: "llm" },
{ id: "ollama", name: "Ollama (Local)", description: "Self-hosted LLM translation", mode: "llm" },
{ id: "openrouter", name: "OpenRouter", description: "Multi-model AI translation", mode: "llm" },

View File

@@ -158,8 +158,17 @@ export function I18nProvider({ children }: { children: ReactNode }) {
const t = useCallback(
(key: string, params?: TranslationParams): string => {
const msg = messages[key] ?? enMessages[key] ?? key;
return interpolate(msg, params);
const msg = messages[key] ?? enMessages[key];
if (msg === undefined) {
// Missing everywhere: honour an explicit defaultValue, else return ""
// so `t(key) || fallback` call sites work and raw keys never reach the UI.
return params && "defaultValue" in params
? String(params.defaultValue)
: "";
}
const rest = { ...params };
delete rest.defaultValue;
return interpolate(msg, rest);
},
[messages],
);

View File

@@ -107,5 +107,6 @@
"dashboard.topbar.premiumAccess": "وصول مميز",
"dashboard.checkoutSyncError": "خطأ في مزامنة الدفع.",
"dashboard.networkRefresh": "خطأ في الشبكة. يرجى تحديث الصفحة.",
"dashboard.continueToTranslate": "متابعة إلى الترجمة"
"dashboard.continueToTranslate": "متابعة إلى الترجمة",
"dashboard.nav.teams": "الفريق"
}

View File

@@ -15,7 +15,6 @@ import landing from "./landing.json";
import langSelector from "./langSelector.json";
import layout from "./layout.json";
import login from "./login.json";
import memento from "./memento.json";
import pricing from "./pricing.json";
import profile from "./profile.json";
import providerSelector from "./providerSelector.json";
@@ -41,7 +40,6 @@ const messages: Record<string, string> = {
...langSelector,
...layout,
...login,
...memento,
...pricing,
...profile,
...providerSelector,

View File

@@ -52,7 +52,7 @@
"landing.layout.p5.title": "الرؤوس والتذييلات",
"landing.layout.p5.desc": "الرؤوس والتذييلات والحواشي لا تُفوت أبدًا.",
"landing.layout.p6.title": "130+ لغة",
"landing.layout.p6.desc": "Google Translate و DeepL ومحركات ذكاء اصطناعي احترافية.",
"landing.layout.p6.desc": "Google Translate ومحركات ذكاء اصطناعي احترافية.",
"landing.formats.title": "كل تنسيق،",
"landing.formats.title2": "كل عنصر",
"landing.formats.subtitle": "نترجم ما يفوته الآخرون. عملك يستحق توثيقًا لا تشوبه شائبة.",
@@ -93,7 +93,7 @@
"landing.pricing.starter.desc": "للأفراد والمشاريع الصغيرة",
"landing.pricing.starter.f1": "50 مستند / شهر",
"landing.pricing.starter.f2": "حتى 50 صفحة لكل مستند",
"landing.pricing.starter.f3": "Google Translate + DeepL",
"landing.pricing.starter.f3": "Google Translate",
"landing.pricing.starter.f4": "ملفات حتى 10 ميجابايت",
"landing.pricing.starter.cta": "ابدأ الآن",
"landing.pricing.pro.name": "Pro",
@@ -101,7 +101,7 @@
"landing.pricing.pro.f1": "200 مستند / شهر",
"landing.pricing.pro.f2": "حتى 200 صفحة لكل مستند",
"landing.pricing.pro.f3": "ترجمة بالذكاء الاصطناعي",
"landing.pricing.pro.f4": "Google + DeepL مشمولان",
"landing.pricing.pro.f4": "Google مشمولان",
"landing.pricing.pro.f5": "قواميس وأوامر مخصصة",
"landing.pricing.pro.f6": "دعم ذو أولوية",
"landing.pricing.pro.cta": "جرّب Pro",

View File

@@ -1,6 +0,0 @@
{
"memento.title": "اكتشف Momento",
"memento.slogan": "Momento ليس مجرد تطبيق ملاحظات. إنه نظام بيئي ذكي يربط ويحلل ويطور أفكارك في الوقت الفعلي باستخدام 6 وكلاء ذكاء اصطناعي وبحث دلالي متقدم.",
"memento.ctaFree": "ابدأ مجاناً",
"memento.ctaMore": "اعرف المزيد"
}

View File

@@ -27,14 +27,14 @@
"pricing.plans.free.feat5": "دعم المجتمع",
"pricing.plans.starter.feat1": "50 مستندًا / شهر",
"pricing.plans.starter.feat2": "حتى 50 صفحة لكل مستند",
"pricing.plans.starter.feat3": "ترجمة Google + DeepL",
"pricing.plans.starter.feat3": "ترجمة Google",
"pricing.plans.starter.feat4": "ملفات حتى 10 ميغابايت",
"pricing.plans.starter.feat5": "دعم عبر البريد الإلكتروني",
"pricing.plans.starter.feat6": "سجل 30 يومًا",
"pricing.plans.pro.feat1": "200 مستند / شهر",
"pricing.plans.pro.feat2": "حتى 200 صفحة لكل مستند",
"pricing.plans.pro.feat3": "ترجمة الذكاء الاصطناعي الأساسية",
"pricing.plans.pro.feat4": "ترجمة Google + DeepL",
"pricing.plans.pro.feat4": "ترجمة Google",
"pricing.plans.pro.feat5": "ملفات حتى 25 ميغابايت",
"pricing.plans.pro.feat6": "معاجم مخصصة",
"pricing.plans.pro.feat7": "دعم ذو أولوية",
@@ -83,7 +83,6 @@
"pricing.comparison.pagesMaxPerDoc": "الحد الأقصى للصفحات / مستند",
"pricing.comparison.maxFileSize": "الحد الأقصى لحجم الملف",
"pricing.comparison.googleTranslation": "ترجمة Google",
"pricing.comparison.deepl": "DeepL",
"pricing.comparison.aiEssential": "ترجمة AI أساسية",
"pricing.comparison.aiPremium": "ترجمة AI متميزة",
"pricing.comparison.apiAccess": "وصول API",

View File

@@ -22,8 +22,6 @@
"providerTheme.default.desc": "ترجمة بنموذج لغوي كبير (LLM) مع تحليل دلالي متقدم.",
"providerTheme.classic.google.label": "ترجمة Google",
"providerTheme.classic.google.desc": "ترجمة فائقة السرعة تغطي أكثر من 130 لغة. موصى بها للتدفقات العامة.",
"providerTheme.classic.deepl.label": "DeepL Pro",
"providerTheme.classic.deepl.desc": "ترجمة عالية الدقة مشهور بسيولتها وصياغاتها الطبيعية.",
"providerTheme.classic.google_cloud.label": "Google Cloud API",
"providerTheme.classic.google_cloud.desc": "محرك سحابي احترافي محسّن لمعالجة كميات كبيرة من المستندات."
}

View File

@@ -107,5 +107,6 @@
"dashboard.topbar.premiumAccess": "Premium-Zugang",
"dashboard.checkoutSyncError": "Fehler beim Synchronisieren der Zahlung.",
"dashboard.networkRefresh": "Netzwerkfehler. Bitte aktualisieren Sie die Seite.",
"dashboard.continueToTranslate": "Weiter zur Übersetzung"
"dashboard.continueToTranslate": "Weiter zur Übersetzung",
"dashboard.nav.teams": "Team"
}

View File

@@ -16,7 +16,6 @@ import landing from "./landing.json";
import langSelector from "./langSelector.json";
import layout from "./layout.json";
import login from "./login.json";
import memento from "./memento.json";
import pricing from "./pricing.json";
import profile from "./profile.json";
import providerSelector from "./providerSelector.json";
@@ -44,7 +43,6 @@ const messages: Record<string, string> = {
...langSelector,
...layout,
...login,
...memento,
...pricing,
...profile,
...providerSelector,

View File

@@ -52,7 +52,7 @@
"landing.layout.p5.title": "Kopf- & Fußzeilen",
"landing.layout.p5.desc": "Kopfzeilen, Fußzeilen und Fußnoten werden nie übersehen.",
"landing.layout.p6.title": "130+ Sprachen",
"landing.layout.p6.desc": "Google Translate, DeepL und professionelle KI-Engines.",
"landing.layout.p6.desc": "Google Translate und professionelle KI-Engines.",
"landing.formats.title": "Jedes Format,",
"landing.formats.title2": "jedes Element",
"landing.formats.subtitle": "Wir übersetzen, was andere vergessen. Ihr Unternehmen verdient einwandfreie Dokumentation.",
@@ -93,7 +93,7 @@
"landing.pricing.starter.desc": "Für Einzelpersonen und kleine Projekte",
"landing.pricing.starter.f1": "50 Dokumente / Monat",
"landing.pricing.starter.f2": "Bis zu 50 Seiten pro Dokument",
"landing.pricing.starter.f3": "Google Translate + DeepL",
"landing.pricing.starter.f3": "Google Translate",
"landing.pricing.starter.f4": "Dateien bis zu 10 MB",
"landing.pricing.starter.cta": "Loslegen",
"landing.pricing.pro.name": "Pro",
@@ -101,7 +101,7 @@
"landing.pricing.pro.f1": "200 Dokumente / Monat",
"landing.pricing.pro.f2": "Bis zu 200 Seiten pro Dokument",
"landing.pricing.pro.f3": "KI-gestützte Übersetzung",
"landing.pricing.pro.f4": "Google + DeepL inklusive",
"landing.pricing.pro.f4": "Google inklusive",
"landing.pricing.pro.f5": "Individuelle Glossare & Prompts",
"landing.pricing.pro.f6": "Prioritäts-Support",
"landing.pricing.pro.cta": "Pro testen",

View File

@@ -1,6 +0,0 @@
{
"memento.title": "Entdecken Sie Momento",
"memento.slogan": "Momento ist mehr als nur eine Notizen-App. Es ist ein intelligentes Ökosystem, das Ihre Ideen in Echtzeit verbindet, analysiert und weiterentwickelt mit 6 KI-Agenten und semantischer Suche.",
"memento.ctaFree": "Kostenlos starten",
"memento.ctaMore": "Mehr erfahren"
}

View File

@@ -27,14 +27,14 @@
"pricing.plans.free.feat5": "Community-Support",
"pricing.plans.starter.feat1": "50 Dokumente / Monat",
"pricing.plans.starter.feat2": "Bis zu 50 Seiten pro Dokument",
"pricing.plans.starter.feat3": "Google Übersetzer + DeepL",
"pricing.plans.starter.feat3": "Google Übersetzer",
"pricing.plans.starter.feat4": "Dateien bis zu 10 MB",
"pricing.plans.starter.feat5": "E-Mail-Support",
"pricing.plans.starter.feat6": "30 Tage Verlauf",
"pricing.plans.pro.feat1": "200 Dokumente / Monat",
"pricing.plans.pro.feat2": "Bis zu 200 Seiten pro Dokument",
"pricing.plans.pro.feat3": "KI-Basisübersetzung",
"pricing.plans.pro.feat4": "Google Übersetzer + DeepL",
"pricing.plans.pro.feat4": "Google Übersetzer",
"pricing.plans.pro.feat5": "Dateien bis zu 25 MB",
"pricing.plans.pro.feat6": "Benutzerdefinierte Glossare",
"pricing.plans.pro.feat7": "Prioritäts-Support",
@@ -83,7 +83,6 @@
"pricing.comparison.pagesMaxPerDoc": "Max. Seiten / Dokument",
"pricing.comparison.maxFileSize": "Max. Dateigröße",
"pricing.comparison.googleTranslation": "Google Übersetzer",
"pricing.comparison.deepl": "DeepL",
"pricing.comparison.aiEssential": "KI-Basisübersetzung",
"pricing.comparison.aiPremium": "KI-Premiumübersetzung",
"pricing.comparison.apiAccess": "API-Zugang",

View File

@@ -22,8 +22,6 @@
"providerTheme.default.desc": "Großsprachmodell-Übersetzung (LLM) mit fortgeschrittener semantischer Analyse.",
"providerTheme.classic.google.label": "Google Übersetzer",
"providerTheme.classic.google.desc": "Ultraschnelle Übersetzung für über 130 Sprachen. Empfohlen für allgemeine Abläufe.",
"providerTheme.classic.deepl.label": "DeepL Pro",
"providerTheme.classic.deepl.desc": "Hochpräzise Übersetzung, bekannt für Flüssigkeit und natürliche Formulierungen.",
"providerTheme.classic.google_cloud.label": "Google Cloud API",
"providerTheme.classic.google_cloud.desc": "Professionelle Cloud-Engine, optimiert für die Verarbeitung großer Dokumentenmengen."
}

View File

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

View File

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

View File

@@ -111,5 +111,7 @@
"dashboard.topbar.premiumAccess": "Premium Access",
"dashboard.checkoutSyncError": "Error syncing payment.",
"dashboard.networkRefresh": "Network error. Please refresh the page.",
"dashboard.continueToTranslate": "Continue to translation"
"dashboard.continueToTranslate": "Continue to translation",
"dashboard.nav.teams": "Team",
"dashboard.loading": "Loading…"
}

View File

@@ -27,5 +27,8 @@
"fileUploader.foundTexts": "Found {count} texts to translate",
"fileUploader.translatingItem": "Translating {current}/{total}: \\\"{preview}\\\"",
"fileUploader.reconstructing": "Reconstructing document...",
"fileUploader.translatingLocally": "Translating locally with WebLLM..."
"fileUploader.translatingLocally": "Translating locally with WebLLM...",
"fileUploader.error.invalidFormat": "Unsupported format. Accepted: .xlsx, .docx, .pptx, .pdf",
"fileUploader.error.tooLarge": "File too large (max 50 MB)",
"fileUploader.error.tooMany": "Queue limited to 10 documents per run."
}

View File

@@ -189,5 +189,48 @@
"glossaries.detail.importedTitle": "Imported",
"glossaries.detail.importedDesc": "{count} terms imported.",
"glossaries.detail.importErrorTitle": "Read error",
"glossaries.detail.importErrorDesc": "Unable to read the file."
"glossaries.detail.importErrorDesc": "Unable to read the file.",
"glossaries.titleBase": "Glossaries &",
"glossaries.titleAccent": "Context",
"glossaries.new": "New glossary",
"glossaries.info.title": "To use a glossary in a translation:",
"glossaries.info.body": "click “Use” on the desired card. You will be redirected to the Translate page with this glossary already selected.",
"glossaries.info.cta": "Go translate",
"glossaries.context.title": "Context guidelines",
"glossaries.context.desc": "These instructions are automatically sent to the AI before each translation in Pro LLM mode.",
"glossaries.context.unsaved": "Unsaved",
"glossaries.context.active": "Active",
"glossaries.context.inactive": "Inactive",
"glossaries.context.suggestions": "Quick suggestions — click to add",
"glossaries.context.placeholder": "e.g. You are translating technical HVAC documents. Use precise engineering terminology and a professional tone…",
"glossaries.context.chars": "{count} characters",
"glossaries.context.noPrompt": "No guideline saved",
"glossaries.context.clear": "Clear all",
"glossaries.context.saving": "Saving…",
"glossaries.context.saved": "Saved",
"glossaries.context.save": "Save",
"glossaries.activeTarget": "Active translation:",
"glossaries.searchPlaceholder": "Search a glossary…",
"glossaries.countMany": "{count} glossaries — click a card to edit",
"glossaries.countNone": "No glossary yet — create one to get started",
"glossaries.termsOne": "1 term",
"glossaries.termsMany": "{count} terms",
"glossaries.badge.otherLang": "Other language",
"glossaries.card.edit": "Edit",
"glossaries.card.use": "Use",
"glossaries.createFirst": "Create my first glossary",
"glossaries.noResults": "No results for “{query}”",
"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",
"glossaries.suggestion.formal.value": "Always use a formal, professional tone in your translations.",
"glossaries.suggestion.proprietary.value": "Do not translate proper nouns, brand names, or people's names.",
"glossaries.suggestion.numbers.value": "Keep all figures, percentages, and amounts exactly as written.",
"glossaries.suggestion.placeholders.value": "Do not translate variables in braces such as {name}, {date}, {amount}.",
"glossaries.suggestion.technical.value": "Keep technical terms in the source language; do not translate them.",
"glossaries.suggestion.concise.value": "Prefer short, direct phrasing. Avoid circumlocutions."
}

Some files were not shown because too many files have changed in this diff Show More