From 0a013c679d7bca4782e02dca7fc2e9268dd8e3da Mon Sep 17 00:00:00 2001 From: sepehr Date: Sat, 5 Sep 2026 19:46:33 +0200 Subject: [PATCH] feat(admin): canaux IA par palier (z.ai/DeepSeek directs), emails generes par IA, reductions Stripe - Chaque modele d'un palier devient une route {canal, modele} : openrouter, deepseek direct, zhipu (z.ai, nouveau canal), minimax, openai, xai ; repli automatique sur la route suivante si la cle manque, compatibilite ascendante avec les anciens reglages a chaines - Page Fournisseurs refondue en sections ; interrupteurs et chaine de secours retablis ; tests de connexion avec delai d'attente - Emails de relance generes par IA (canal au choix, prompt construit depuis le plan marketing, HTML sane, RTL arabe/persan, jamais d'envoi automatique) - Codes promo : validation locale avant Stripe, Coupon+PromotionCode avec double plafond, comptage par session (webhook+sync=1 fois), ecriture atomique, relier-a-Stripe, suppression, validation publique au checkout - Page Tarifs : champ code promo (?promo=) avec verification traduite - Tests : 1357 verts (facturation par palier testee dans le worker, point de contact providers/available, promos, generation) --- .env.example | 30 + MARKETING_PLAN.md | 1 + .../implementation-artifacts/deferred-work.md | 7 + ...pec-admin-fournisseurs-ia-emails-promos.md | 149 +++ frontend/src/app/admin/constants.ts | 17 + frontend/src/app/admin/marketing/page.tsx | 918 ++++++++++++++---- frontend/src/app/admin/models/page.tsx | 193 +++- frontend/src/app/admin/settings/page.tsx | 714 ++++++++------ frontend/src/app/admin/types.ts | 56 +- frontend/src/app/pricing/page.tsx | 88 +- frontend/src/lib/i18n/messages/ar/admin.json | 177 +++- .../src/lib/i18n/messages/ar/pricing.json | 14 +- frontend/src/lib/i18n/messages/de/admin.json | 177 +++- .../src/lib/i18n/messages/de/pricing.json | 14 +- frontend/src/lib/i18n/messages/en/admin.json | 177 +++- .../src/lib/i18n/messages/en/pricing.json | 14 +- frontend/src/lib/i18n/messages/es/admin.json | 177 +++- .../src/lib/i18n/messages/es/pricing.json | 14 +- frontend/src/lib/i18n/messages/fa/admin.json | 177 +++- .../src/lib/i18n/messages/fa/pricing.json | 14 +- frontend/src/lib/i18n/messages/fr/admin.json | 177 +++- .../src/lib/i18n/messages/fr/pricing.json | 14 +- frontend/src/lib/i18n/messages/it/admin.json | 177 +++- .../src/lib/i18n/messages/it/pricing.json | 14 +- frontend/src/lib/i18n/messages/ja/admin.json | 177 +++- .../src/lib/i18n/messages/ja/pricing.json | 14 +- frontend/src/lib/i18n/messages/ko/admin.json | 177 +++- .../src/lib/i18n/messages/ko/pricing.json | 14 +- frontend/src/lib/i18n/messages/nl/admin.json | 177 +++- .../src/lib/i18n/messages/nl/pricing.json | 14 +- frontend/src/lib/i18n/messages/pt/admin.json | 177 +++- .../src/lib/i18n/messages/pt/pricing.json | 14 +- frontend/src/lib/i18n/messages/ru/admin.json | 177 +++- .../src/lib/i18n/messages/ru/pricing.json | 14 +- frontend/src/lib/i18n/messages/zh/admin.json | 177 +++- .../src/lib/i18n/messages/zh/pricing.json | 14 +- routes/admin_routes.py | 768 ++++++++++++++- routes/api_v1_router.py | 2 + routes/auth_routes.py | 61 +- routes/legacy_routes.py | 6 +- routes/translate_routes.py | 278 ++++-- services/payment_service.py | 431 +++++++- services/providers/config.py | 76 +- tests/test_admin_promos.py | 570 +++++++++++ tests/test_ai_tier_routes.py | 490 ++++++++++ tests/test_ai_tier_routing.py | 91 +- tests/test_marketing_email_generation.py | 460 +++++++++ 47 files changed, 7210 insertions(+), 679 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/spec-admin-fournisseurs-ia-emails-promos.md create mode 100644 tests/test_admin_promos.py create mode 100644 tests/test_ai_tier_routes.py create mode 100644 tests/test_marketing_email_generation.py diff --git a/.env.example b/.env.example index 7b27d6f..312756c 100644 --- a/.env.example +++ b/.env.example @@ -71,6 +71,36 @@ OPENAI_HEALTH_CHECK_TIMEOUT=5 OPENROUTER_API_KEY= OPENROUTER_MODEL=deepseek/deepseek-chat +# DeepSeek Configuration (API directe, compatible OpenAI). Optional. If absent: provider disabled. +# NOTE: pour le routage par palier (page « Modèles & abonnements ») et la +# génération d'emails, la clé API seule suffit (réglage admin ou DEEPSEEK_API_KEY). +# Les bascules *_ENABLED ne concernent que l'ancienne chaîne de secours du registre. +# Get API key from: https://platform.deepseek.com/api_keys +DEEPSEEK_ENABLED=false +DEEPSEEK_API_KEY= +DEEPSEEK_MODEL=deepseek-chat +# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 + +# z.ai / Zhipu Configuration (modèles GLM — API directe compatible OpenAI). Optional. If absent: provider disabled. +# NOTE: la clé API seule suffit pour le routage par palier et la génération +# d'emails ; ZHIPU_ENABLED ne concerne que l'ancienne chaîne de secours. +# Get API key from: https://z.ai/manage-apikey/apikey-list +ZHIPU_ENABLED=false +ZHIPU_API_KEY= +ZHIPU_MODEL=glm-5.3 +# Base par défaut ; l'API internationale z.ai est compatible OpenAI. +# ZHIPU_BASE_URL=https://api.z.ai/api/paas/v4 + +# Minimax Configuration (API publique compatible OpenAI). Optional. If absent: provider disabled. +# NOTE: la clé API seule suffit pour le routage par palier et la génération +# d'emails ; MINIMAX_ENABLED ne concerne que l'ancienne chaîne de secours. +# Get API key from: https://www.minimax.io/ (MINIMAX_GROUP_ID requis selon le compte) +MINIMAX_ENABLED=false +MINIMAX_API_KEY= +MINIMAX_MODEL=MiniMax-M3 +# MINIMAX_BASE_URL=https://api.minimax.io/v1 +MINIMAX_GROUP_ID= + # 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) diff --git a/MARKETING_PLAN.md b/MARKETING_PLAN.md index 7ca6e7f..6013e7f 100644 --- a/MARKETING_PLAN.md +++ b/MARKETING_PLAN.md @@ -67,6 +67,7 @@ Contrairement à Google Translate (qui aplatit le fichier en texte brut) ou à D ### Message tarifaire - **Ancrage** : coût par page vs traduction humaine (50–100× 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. +- **Relances et réductions** : les emails de relance (générés par IA à partir de ce plan) et les codes promo (Stripe Coupon + Promotion Code, double plafond) sont gérés depuis l'administration (page Marketing). - **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. **Au 2026-09-05 ce miroir est en retard** : il affiche encore DeepL (Starter et Pro), la FAQ dit « up to seven providers » et la preuve sociale affiche « 7 moteurs », « 60+ langues » et « 100 % » — correction à faire avant publication (checklist §9). --- diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md index 0ff879b..1a4730d 100644 --- a/_bmad-output/implementation-artifacts/deferred-work.md +++ b/_bmad-output/implementation-artifacts/deferred-work.md @@ -71,3 +71,10 @@ Reportés de la revue marketing du 2026-09-05 (spec-hermes-marketing-execution.m - source_spec: `_bmad-output/implementation-artifacts/spec-strategie-llm-abonnements-admin.md` summary: Les cartes de revenus ne distinguent pas « aucune donnée » de « base injoignable » (affichage silencieux de zéro). evidence: Relecture de contenu ; amélioration d'affichage à prévoir sur RevenueOverview.tsx. + +- source_spec: `_bmad-output/implementation-artifacts/spec-admin-fournisseurs-ia-emails-promos.md` + summary: Le verrou des fichiers JSON de codes promo ne couvre qu'un processus ; avec plusieurs workers, deux paiements simultanés peuvent dépasser le plafond local. + evidence: Relectures ; le plafond côté Stripe (max_redemptions sur le Promotion Code) reste le garde-fou réel. Un verrou inter-processus (fichier ou base) est un chantier à part, commun à tous les fichiers data/*.json. +- source_spec: `_bmad-output/implementation-artifacts/spec-admin-fournisseurs-ia-emails-promos.md` + summary: Environ 160 clés de traduction antérieures à ces chantiers n'existent que dans une partie des 13 langues (glossaires, traduction, connexion…). + evidence: Constat du relecteur et de l'implémentation ; rapprochement complet à faire dans un passage dédié. diff --git a/_bmad-output/implementation-artifacts/spec-admin-fournisseurs-ia-emails-promos.md b/_bmad-output/implementation-artifacts/spec-admin-fournisseurs-ia-emails-promos.md new file mode 100644 index 0000000..84054e9 --- /dev/null +++ b/_bmad-output/implementation-artifacts/spec-admin-fournisseurs-ia-emails-promos.md @@ -0,0 +1,149 @@ +--- +title: 'Fournisseur par palier IA (DeepSeek/z.ai directs), page Fournisseurs, emails générés par IA, réductions Stripe' +type: 'feature' +created: '2026-09-05' +status: 'done' +baseline_commit: '6c26712' +review_loop_iteration: 0 +context: [] +--- + + + +## Intent + +**Problem :** Le routage par palier n'accepte que des modèles OpenRouter : Sepehr paie des accès directs DeepSeek et z.ai (Zhipu) qu'il ne peut pas utiliser ; la page Fournisseurs est confuse ; les emails de relance s'écrivent à la main sans s'appuyer sur l'IA ni sur le plan marketing ; aucune réduction ne peut être proposée dans les relances de façon fiable. + +**Approach :** (1) Chaque modèle d'un palier devient une route `{provider, model}` : le palier choisit le canal (openrouter, deepseek direct, **zhipu** nouveau = z.ai, minimax, openai, zai) ; les chaînes simples restent acceptées (= openrouter, compatibilité). (2) Nouveau fournisseur `zhipu` (base `https://api.z.ai/api/paas/v4`, OpenAI-compatible) + refonte de la page Fournisseurs par sections. (3) Génération d'email par IA dans la page Marketing : fournisseur/modèle au choix, prompt système construit depuis `MARKETING_PLAN.md` (positionnement, tarifs, chiffres autorisés) ; rendu `{subject, html}` analysé avec robustesse. (4) Réductions : codes promo dans `data/promo_codes.json`, création Stripe Coupon+Promotion Code (double plafond), application au checkout et comptage des utilisations, sélecteur de promo dans l'email. + +## Boundaries & Constraints + +**Always :** Compatibilité ascendante : `models` à chaînes et `default_model` actuels continuent de fonctionner (testé) ; une route dont la clé manque est sautée au profit de la suivante de la liste ; tout appel LLM/Stripe a un délai d'attente, une erreur propagée claire et jamais d'HTTP 500 nu ; clés API jamais renvoyées en clair par de nouveaux endpoints ; interface en français correct, nouveaux textes dans les 13 langues ; facteur de coût : palier Premium = 5, Essentielle = 1 quel que soit le canal. + +**Ask First :** Supprimer le fournisseur existant `zai` (xAI/Grok) — il reste ; changer les prix des forfaits ; créer une table de BDD (rester sur JSON dans `data/`). + +**Never :** Passer Stripe en live ; appliquer une promo expirée/épuisée/hors forfait ; injecter le HTML généré sans longueur maximale et nettoyage basique ; bloquer un envoi de masse si la génération IA échoue ; toucher à `office-translator-landing-page/` ni aux dossiers d'outillage. + +## I/O & Edge-Case Matrix + +| Scenario | Input / State | Expected Output / Behavior | Error Handling | +|----------|--------------|---------------------------|----------------| +| Palier avec routes multi-canaux | essential.routes = [zhipu/glm-5.3-flash, openrouter/deepseek-v4-flash] | Traduction via z.ai direct si clé présente, sinon openrouter ; facteur coût 1 | Clé zhipu absente → route suivante, journal d'avertissement | +| Ancien réglage (chaînes) | ai_tiers.essential.models = ["deepseek/deepseek-v4-flash"] | Routé via openrouter comme aujourd'hui | N/A | +| Code promo valide au checkout | promo actif, forfait pro, 3/10 utilisations | Session Stripe avec remise ; compteur incrémenté à la complétion seulement | N/A | +| Code promo expiré/épuisé/mauvais forfait | n'importe lequel | Rejet explicite AVANT Stripe (message par cause) | Aucune session créée | +| Génération IA d'email | fournisseur choisi, brief + promo optionnelle | {subject, html} conforme au plan marketing, pieds de page serveur inchangés | Sortie non analysable → erreur claire, aucun envoi automatique | + + + +## Code Map + +- `routes/admin_routes.py` -- `AiTierSettings` l.1055 (évoluer vers routes `{provider, model, label}` + compat chaînes), `normalize_ai_tiers` l.1089 (validation des routes : fournisseur connu, dédoublonnage, anti-croisement de palier inchangé), endpoints marketing l.2388-2660 (y ajouter `POST /marketing/email/generate`, `GET/POST /marketing/promos`, `POST /marketing/promos/{code}/deactivate`), `_execute_bulk_send`. +- `routes/translate_routes.py` -- `_resolve_tier_model` l.220 (renvoyer la route complète : `(provider, model)`), bloc d'instanciation l.1390-1460 (dispatch par canal : openrouter/openrouter_premium déjà OpenAI-compat ; deepseek → `DeepSeekTranslationProvider` ; zhipu/minimax/zai/openai → `OpenAITranslationProvider` avec leur base_url), `_compute_cost_factor` l.608 (basé sur le palier, pas le nom du canal). +- `services/providers/config.py` -- motif env par fournisseur : ajouter `ZHIPU_ENABLED/ZHIPU_API_KEY/ZHIPU_MODEL/ZHIPU_BASE_URL` (défaut `https://api.z.ai/api/paas/v4`, modèle `glm-5.3`). +- `services/payment_service.py` -- `create_checkout_session` l.58 (paramètre `promo_code`, `discounts=[{"promotion_code": …}]`), `handle_checkout_completed` (incrément du compteur via `metadata.promo_code`), `handle_webhook` l.235 ; credits sans promo. +- `routes/auth_routes.py` -- `CheckoutRequest` l.72 (champ `promo_code` optionnel) + endpoint l.838. +- `services/pricing_config.py` -- motifs Stripe (clés, mode live/test) à réutiliser ; `data/pricing_overrides.json` = précédent de fichier JSON géré admin. +- `MARKETING_PLAN.md` (racine) -- source du prompt : sections 1 (positionnement/USP) et 2 (forfaits et chiffres autorisés) ; extraire côté serveur, tronquer (~4000 caractères). +- `frontend/src/app/admin/settings/page.tsx` -- refonte : sections « Traduction classique » (google, google_cloud), « Fournisseurs IA directs » (deepseek, zhipu nouveau, minimax, openai, zai), « Passerelles multi-modèles » (openrouter, openrouter_premium : clé seule, rappel « modèles gérés dans Modèles & abonnements »), OCR, Emails ; boutons de test existants conservés. +- `frontend/src/app/admin/models/page.tsx` -- chaque modèle gagne un sélecteur de fournisseur ; combobox libre (identifiants différents par canal) + catalogue OpenRouter pour ce canal. +- `frontend/src/app/admin/marketing/page.tsx` -- bloc « Générer avec l'IA » (fournisseur+modèle, brief, langue, ton, promo optionnelle → remplit sujet/HTML) + section « Réductions » (liste, création : type %/montant, forfaits, plafond, expiration ; désactivation). +- `frontend/src/app/pricing/page.tsx` -- champ code promo au checkout + lecture `?promo=` ; l'envoyer à create-checkout. +- `.env.example` -- bloc `ZHIPU_*` documenté (et rappel `DEEPSEEK_*`) comme les autres fournisseurs. +- i18n `admin.json`/`pricing.json` (13 langues) -- clés nouvelles (fournisseurs, génération IA, promos, champ code promo) ; `admin.nav.*` inchangé. +- `tests/` -- motifs `test_ai_tier_routing.py`, `test_admin_marketing.py`, `test_worker_tier_routing.py`, mocks Stripe éventuels dans `tests/`. + +## Tasks & Acceptance + +**Execution :** +- [ ] `routes/admin_routes.py` -- routes multi-canaux dans `AiTierSettings` (compat chaînes), validation dans `normalize_ai_tiers`, endpoints `/marketing/email/generate` (prompt = plan marketing + brief ; appel LLM du canal choisi ; analyse robuste JSON avec repli texte ; longueur max) et `/marketing/promos` (création Stripe Coupon+Promotion Code si Stripe configuré, sinon `local_only` refusé au checkout avec message clair ; désactivation ; plafonds côté local ET Stripe). +- [ ] `routes/translate_routes.py` -- résolution `(provider, model)` par route de palier avec repli sur la route suivante si la clé manque, dispatch d'instanciation par canal (zhipu inclus), facteur de coût par palier. +- [ ] `services/providers/config.py` + `.env.example` -- fournisseur `zhipu` (env documentées). +- [ ] `services/payment_service.py` + `routes/auth_routes.py` -- `promo_code` au checkout : validation locale (actif, forfait, expiration, plafond) AVANT Stripe, remise Stripe, métadonnée `promo_code`, incrément à la complétion (webhook ET sync). +- [ ] `frontend/src/app/admin/settings/page.tsx` -- refonte par sections + fournisseur zhipu. +- [ ] `frontend/src/app/admin/models/page.tsx` -- sélecteur de fournisseur par modèle. +- [ ] `frontend/src/app/admin/marketing/page.tsx` -- génération IA (choix du canal) + gestion des réductions. +- [ ] `frontend/src/app/pricing/page.tsx` -- champ/URL du code promo au checkout. +- [ ] i18n 13 langues -- toutes les nouvelles clés. +- [ ] `tests/` -- compat chaînes, multi-canaux avec repli de clé, zhipu, coût par palier ; promos : validation, checkout mocké, incrément à la complétion, rejets par cause ; génération IA : prompt contient le plan, repli d'analyse. +- [ ] `MARKETING_PLAN.md` -- une ligne « relances et réductions gérées depuis l'admin ». + +**Acceptance Criteria :** +- Given des clés DeepSeek et z.ai renseignées, when l'admin choisit ces canaux pour un palier, then les traductions du palier partent par ces canaux (repli automatique sur la route suivante en cas de clé absente) et la facturation (1×/5×) suit le palier. +- Given le plan marketing, when l'admin génère un email par IA, then le texte respecte le positionnement et les chiffres autorisés, sans aucun envoi automatique. +- Given un code promo à 3/10 utilisations, when trois souscriptions se complètent, then le compteur passe à 6 via webhook ou sync, et un 11ᵉ essai est refusé avant tout appel Stripe. +- Given `pytest`, `npm run build:local`, `npm run test:run` et le contrôle i18n, when exécutés, then tout est vert. + +## Design Notes + +Route de palier : `{"provider": "zhipu", "model": "glm-5.3-flash", "label": "GLM-5.3 Flash (z.ai)"}` ; chaîne `"z-ai/glm-5.3-flash"` = `{"provider": "openrouter", …}`. Génération d'email : le serveur construit le message système (plan marketing tronqué + règles : jamais de chiffre hors plan, HTML simple à styles en ligne, langue imposée) puis l'appel LLM avec `response` attendu en JSON `{"subject": …, "html": …}` ; en cas d'échec d'analyse, extraire ``/corps, sinon erreur 502 claire `AI_GENERATION_FAILED`. Promos : code `[A-Z0-9-]{3,24}` ; Stripe `coupon` (percent/amount EUR) + `promotion_code` (code lisible, `max_redemptions`, `expires_at`) ; `local_only` jamais appliqué au checkout ; compteur local incrémenté uniquement dans `handle_checkout_completed` via `metadata.promo_code`. + +## Verification + +**Commands :** +- `./venv/Scripts/python.exe -m pytest -q --no-cov` -- expected: nouveaux tests verts, pas de nouvelle régression +- `cd frontend && npm run build:local` -- expected: succès +- `cd frontend && npm run test:run` -- expected: verts +- script de comparaison i18n (clés identiques 13 langues) -- expected: OK + +**Manual checks (if no CLI):** +- Page Fournisseurs : sections lisibles, zhipu présent, tests de fournisseur fonctionnels. +- Page Marketing : génération IA produit un sujet + HTML ; création d'un code promo en mode test Stripe. + +## Spec Change Log + +## Suggested Review Order + +**Routage multi-canaux par palier** + +- Routes `{canal, modele}` d'un palier avec compatibilite des anciennes chaines. + [`admin_routes.py:1064`](../../routes/admin_routes.py#L1064) + +- Resolution unique consciente des cles (une route sans cle est sautee), utilisee par le worker ET l'affichage. + [`translate_routes.py:250`](../../routes/translate_routes.py#L250) + +- Catalogue des canaux (zhipu = z.ai) et variables d'environnement. + [`config.py:264`](../../services/providers/config.py#L264) + +**Reductions Stripe** + +- Validation locale avant tout appel Stripe (actif, forfait, expiration, plafond). + [`payment_service.py:129`](../../services/payment_service.py#L129) + +- Comptage par session (webhook + sync = une seule fois, meme en JSON). + [`payment_service.py:212`](../../services/payment_service.py#L212) + +- Creation Coupon+PromotionCode avec suppression du coupon orphelin en cas d'echec. + [`payment_service.py:279`](../../services/payment_service.py#L279) + +**Emails generes par IA** + +- Nettoyage durci du HTML renvoye par le modele. + [`admin_routes.py:3010`](../../routes/admin_routes.py#L3010) + +- Generation : prompt = plan marketing + brief ; plan absent refuse, promo inconnue refusee. + [`admin_routes.py:3061`](../../routes/admin_routes.py#L3061) + +- Creation de code promo cote admin. + [`admin_routes.py:3251`](../../routes/admin_routes.py#L3251) + +**Points de contact paiement** + +- `promo_code` accepte au checkout (validation AVANT Stripe). + [`auth_routes.py:72`](../../routes/auth_routes.py#L72) + +**Interfaces** + +- Constante unique des canaux cote client. + [`constants.ts:25`](../../frontend/src/app/admin/constants.ts#L25) + +- Page Marketing : generation IA + reductions. + [`page.tsx:112`](../../frontend/src/app/admin/marketing/page.tsx#L112) + +- Page Tarifs : champ code promo, lecture `?promo=`, validation avant paiement. + [`page.tsx:280`](../../frontend/src/app/pricing/page.tsx#L280) + +**Peripheriques** + +- Tests : routage, promos, generation. + [`test_admin_promos.py:1`](../../tests/test_admin_promos.py#L1) diff --git a/frontend/src/app/admin/constants.ts b/frontend/src/app/admin/constants.ts index 1eeedb2..013250a 100644 --- a/frontend/src/app/admin/constants.ts +++ b/frontend/src/app/admin/constants.ts @@ -17,3 +17,20 @@ export const adminNavItems: AdminNavItem[] = [ { labelKey: 'admin.nav.system', href: '/admin/system', icon: Settings }, { labelKey: 'admin.nav.logs', href: '/admin/logs', icon: FileText }, ]; + +/* ── Canaux LLM (source unique côté client) ──────────────────────────────── + * Miroir de services/providers/config.py::LLM_CHANNELS. Utilisé par la page + * « Modèles & abonnements » (routes de palier) et la page Marketing + * (génération d'emails par IA) — ne pas dupliquer ailleurs. */ +export const LLM_CHANNEL_OPTIONS: { id: string; labelKey: string }[] = [ + { id: "openrouter", labelKey: "admin.models.channelOpenrouter" }, + { id: "openrouter_premium", labelKey: "admin.models.channelOpenrouterPremium" }, + { id: "deepseek", labelKey: "admin.models.channelDeepseek" }, + { id: "zhipu", labelKey: "admin.models.channelZhipu" }, + { id: "minimax", labelKey: "admin.models.channelMinimax" }, + { id: "openai", labelKey: "admin.models.channelOpenai" }, + { id: "zai", labelKey: "admin.models.channelZai" }, +]; + +export const channelLabelKey = (id: string): string => + LLM_CHANNEL_OPTIONS.find((c) => c.id === id)?.labelKey ?? id; diff --git a/frontend/src/app/admin/marketing/page.tsx b/frontend/src/app/admin/marketing/page.tsx index 24e4df6..7a7ea56 100644 --- a/frontend/src/app/admin/marketing/page.tsx +++ b/frontend/src/app/admin/marketing/page.tsx @@ -10,6 +10,11 @@ import { AlertCircle, CheckCircle2, Eye, + Sparkles, + Tag, + Ban, + Link2, + Trash2, } from "lucide-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -22,10 +27,15 @@ import { useNotification } from "@/components/ui/notification"; import { useTranslationStore } from "@/lib/store"; import { API_BASE } from "@/lib/config"; import { useI18n } from "@/lib/i18n"; +import { LLM_CHANNEL_OPTIONS } from "../constants"; import type { MarketingAudiencesResponse, + MarketingEmailGenerateRequest, + MarketingEmailGenerateResponse, MarketingEmailHistoryEntry, MarketingEmailSendResponse, + PromoCode, + PromoListResponse, } from "../types"; const getToken = () => useTranslationStore.getState().settings.adminToken ?? ""; @@ -42,6 +52,33 @@ const AUDIENCE_LABEL_KEYS: Record<string, string> = { all_users: "admin.marketing.audienceAllUsers", }; +/** Langues proposées pour l'email généré. */ +const EMAIL_LANGUAGES: { id: string; labelKey: string }[] = [ + { id: "Français", labelKey: "admin.marketing.langFr" }, + { id: "English", labelKey: "admin.marketing.langEn" }, + { id: "Deutsch", labelKey: "admin.marketing.langDe" }, + { id: "Español", labelKey: "admin.marketing.langEs" }, + { id: "Italiano", labelKey: "admin.marketing.langIt" }, + { id: "Português", labelKey: "admin.marketing.langPt" }, + { id: "Nederlands", labelKey: "admin.marketing.langNl" }, + { id: "العربية", labelKey: "admin.marketing.langAr" }, + { id: "فارسی", labelKey: "admin.marketing.langFa" }, + { id: "日本語", labelKey: "admin.marketing.langJa" }, + { id: "한국어", labelKey: "admin.marketing.langKo" }, + { id: "Русский", labelKey: "admin.marketing.langRu" }, + { id: "中文", labelKey: "admin.marketing.langZh" }, +]; + +const TONES: { id: string; labelKey: string }[] = [ + { id: "friendly", labelKey: "admin.marketing.toneFriendly" }, + { id: "professional", labelKey: "admin.marketing.toneProfessional" }, + { id: "urgent", labelKey: "admin.marketing.toneUrgent" }, + { id: "festive", labelKey: "admin.marketing.toneFestive" }, +]; + +/** Forfaits éligibles à un code promo (le forfait gratuit n'est pas éligible). */ +const PROMO_PLANS = ["all", "starter", "pro", "business", "enterprise"]; + export default function AdminMarketingPage() { const { t } = useI18n(); const { success, error } = useNotification(); @@ -62,6 +99,28 @@ export default function AdminMarketingPage() { * l'envoi reste possible : le serveur valide l'envoi test). */ const [hashSupported, setHashSupported] = useState(true); + // ── Génération par IA ── + const [genProvider, setGenProvider] = useState("openrouter"); + const [genModel, setGenModel] = useState(""); + const [brief, setBrief] = useState(""); + const [genLanguage, setGenLanguage] = useState("Français"); + const [genTone, setGenTone] = useState("friendly"); + const [genPromoCode, setGenPromoCode] = useState(""); + const [isGenerating, setIsGenerating] = useState(false); + + // ── Réductions ── + const [promos, setPromos] = useState<PromoCode[]>([]); + const [promoCode, setPromoCode] = useState(""); + const [promoType, setPromoType] = useState<"percent" | "amount">("percent"); + const [promoValue, setPromoValue] = useState("10"); + const [promoPlans, setPromoPlans] = useState<string[]>(["all"]); + const [promoMaxRedemptions, setPromoMaxRedemptions] = useState(""); + const [promoExpiresAt, setPromoExpiresAt] = useState(""); + const [isCreatingPromo, setIsCreatingPromo] = useState(false); + const [deactivatingCode, setDeactivatingCode] = useState<string | null>(null); + const [deletingCode, setDeletingCode] = useState<string | null>(null); + const [linkingCode, setLinkingCode] = useState<string | null>(null); + useEffect(() => { if (!crypto?.subtle) { setHashSupported(false); @@ -105,9 +164,18 @@ export default function AdminMarketingPage() { setHistory(json.data ?? []); }; + const loadPromos = async () => { + const response = await fetch(`${API_BASE}/api/v1/admin/marketing/promos`, { + headers: { Authorization: `Bearer ${getToken()}` }, + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const json: PromoListResponse = await response.json(); + setPromos(json.data.promos ?? []); + }; + useEffect(() => { setIsLoading(true); - Promise.all([loadAudiences(), loadHistory()]) + Promise.all([loadAudiences(), loadHistory(), loadPromos()]) .catch((e) => error({ title: t("admin.marketing.loadErrorTitle"), description: String(e.message ?? e) }) ) @@ -120,6 +188,11 @@ export default function AdminMarketingPage() { return labelKey ? t(labelKey) : key; }; + const channelLabel = (id: string): string => { + const entry = LLM_CHANNEL_OPTIONS.find((c) => c.id === id); + return entry ? t(entry.labelKey) : id; + }; + const recipientCount = audiences[audience] ?? 0; /** Simple indicateur (non bloquant) : un test réussi de ce contenu exact @@ -131,6 +204,52 @@ export default function AdminMarketingPage() { (h) => h.test_mode && h.sent_count > 0 && h.content_hash === contentHash ); + // ── Génération IA : remplit sujet + HTML, n'envoie RIEN ── + const generateEmail = async () => { + if (!brief.trim()) { + error({ title: t("admin.marketing.generateFailedTitle"), description: t("admin.marketing.briefRequired") }); + return; + } + setIsGenerating(true); + try { + const payload: MarketingEmailGenerateRequest = { + provider: genProvider, + model: genModel.trim() || undefined, + brief: brief.trim(), + language: genLanguage, + tone: genTone || undefined, + promo_code: genPromoCode || undefined, + }; + const response = await fetch(`${API_BASE}/api/v1/admin/marketing/email/generate`, { + method: "POST", + headers: { + Authorization: `Bearer ${getToken()}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + const json = await response.json().catch(() => null); + const d = json?.data as MarketingEmailGenerateResponse["data"] | undefined; + if (response.ok && d?.subject && d?.html) { + setSubject(d.subject); + setHtml(d.html); + success({ title: t("admin.marketing.generatedTitle"), description: t("admin.marketing.generatedDesc", { provider: channelLabel(d.provider) }) }); + } else if (response.ok) { + // 200 sans contenu exploitable : message d'erreur, jamais d'exception. + error({ title: t("admin.marketing.generateFailedTitle"), description: t("admin.marketing.generateEmpty") }); + } else { + error({ + title: t("admin.marketing.generateFailedTitle"), + description: json?.message || `HTTP ${response.status}`, + }); + } + } catch { + error({ title: t("admin.marketing.generateFailedTitle"), description: t("admin.marketing.networkDesc") }); + } finally { + setIsGenerating(false); + } + }; + const sendTest = async () => { if (!subject.trim() || !html.trim()) { error({ title: t("admin.marketing.incompleteTitle"), description: t("admin.marketing.incompleteDesc") }); @@ -224,6 +343,153 @@ export default function AdminMarketingPage() { } }; + // ── Réductions ── + const createPromo = async () => { + const code = promoCode.trim().toUpperCase(); + const value = parseFloat(promoValue); + if (!code || !Number.isFinite(value)) { + error({ title: t("admin.marketing.promoCreateFailedTitle"), description: t("admin.marketing.promoInvalidInput") }); + return; + } + setIsCreatingPromo(true); + try { + const body: Record<string, unknown> = { + code, + type: promoType, + value, + plans: promoPlans.length ? promoPlans : ["all"], + }; + if (promoMaxRedemptions.trim()) body.max_redemptions = parseInt(promoMaxRedemptions, 10); + if (promoExpiresAt.trim()) { + // « jusqu'au 10 » = valable tout le 10 : 23:59:59 UTC du jour choisi. + body.expires_at = new Date(`${promoExpiresAt.trim()}T23:59:59Z`).toISOString(); + } + const response = await fetch(`${API_BASE}/api/v1/admin/marketing/promos`, { + method: "POST", + headers: { + Authorization: `Bearer ${getToken()}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + const json = await response.json().catch(() => ({})); + if (response.ok) { + success({ title: t("admin.marketing.promoCreatedTitle"), description: t("admin.marketing.promoCreatedDesc", { code }) }); + setPromoCode(""); + setPromoMaxRedemptions(""); + setPromoExpiresAt(""); + await loadPromos(); + } else { + error({ + title: t("admin.marketing.promoCreateFailedTitle"), + description: json.message || `HTTP ${response.status}`, + }); + } + } catch { + error({ title: t("admin.marketing.promoCreateFailedTitle"), description: t("admin.marketing.networkDesc") }); + } finally { + setIsCreatingPromo(false); + } + }; + + const deactivatePromo = async (code: string) => { + setDeactivatingCode(code); + try { + const response = await fetch( + `${API_BASE}/api/v1/admin/marketing/promos/${encodeURIComponent(code)}/deactivate`, + { + method: "POST", + headers: { Authorization: `Bearer ${getToken()}` }, + } + ); + const json = await response.json().catch(() => ({})); + if (response.ok) { + success({ title: t("admin.marketing.promoDeactivatedTitle"), description: t("admin.marketing.promoDeactivatedDesc", { code }) }); + await loadPromos(); + } else { + error({ + title: t("admin.marketing.promoDeactivateFailedTitle"), + description: json.message || `HTTP ${response.status}`, + }); + } + } catch { + error({ title: t("admin.marketing.promoDeactivateFailedTitle"), description: t("admin.marketing.networkDesc") }); + } finally { + setDeactivatingCode(null); + } + }; + + const deletePromo = async (code: string) => { + if (!window.confirm(t("admin.marketing.promoDeleteConfirm", { code }))) return; + setDeletingCode(code); + try { + const response = await fetch( + `${API_BASE}/api/v1/admin/marketing/promos/${encodeURIComponent(code)}`, + { + method: "DELETE", + headers: { Authorization: `Bearer ${getToken()}` }, + } + ); + const json = await response.json().catch(() => ({})); + if (response.ok) { + success({ title: t("admin.marketing.promoDeletedTitle"), description: t("admin.marketing.promoDeletedDesc", { code }) }); + await loadPromos(); + } else { + error({ + title: t("admin.marketing.promoDeleteFailedTitle"), + description: json.message || `HTTP ${response.status}`, + }); + } + } catch { + error({ title: t("admin.marketing.promoDeleteFailedTitle"), description: t("admin.marketing.networkDesc") }); + } finally { + setDeletingCode(null); + } + }; + + const linkPromoToStripe = async (code: string) => { + setLinkingCode(code); + try { + const response = await fetch( + `${API_BASE}/api/v1/admin/marketing/promos/${encodeURIComponent(code)}/link-stripe`, + { + method: "POST", + headers: { Authorization: `Bearer ${getToken()}` }, + } + ); + const json = await response.json().catch(() => ({})); + if (response.ok) { + success({ title: t("admin.marketing.promoLinkedTitle"), description: t("admin.marketing.promoLinkedDesc", { code }) }); + await loadPromos(); + } else { + error({ + title: t("admin.marketing.promoLinkFailedTitle"), + description: json.message || `HTTP ${response.status}`, + }); + } + } catch { + error({ title: t("admin.marketing.promoLinkFailedTitle"), description: t("admin.marketing.networkDesc") }); + } finally { + setLinkingCode(null); + } + }; + + const togglePromoPlan = (plan: string) => { + setPromoPlans((prev) => { + if (plan === "all") return prev.includes("all") ? [] : ["all"]; + const next = prev.filter((p) => p !== "all"); + return next.includes(plan) ? next.filter((p) => p !== plan) : [...next, plan]; + }); + }; + + const activePromos = useMemo(() => promos.filter((p) => p.active), [promos]); + + const promoLabel = (p: PromoCode): string => { + const value = + p.type === "percent" ? `-${p.value} %` : `-${p.value} €`; + return `${p.code} (${value})`; + }; + const previewDoc = useMemo( () => html.trim() || @@ -250,176 +516,490 @@ export default function AdminMarketingPage() { <Loader2 className="size-8 animate-spin text-muted-foreground" /> </div> ) : ( - <div className="grid gap-6 lg:grid-cols-3"> - {/* ── Composition ── */} - <Card className="lg:col-span-2 overflow-visible"> - <CardHeader> - <CardTitle className="text-base">{t("admin.marketing.newCampaign")}</CardTitle> - <CardDescription> - {t("admin.marketing.unsubscribedNote", { count: unsubscribedCount })} - </CardDescription> - </CardHeader> - <CardContent className="space-y-4"> - <div className="grid gap-4 sm:grid-cols-2"> - <div className="space-y-2"> - <Label>{t("admin.marketing.audience")}</Label> - <Select value={audience} onValueChange={setAudience}> - <SelectTrigger> - <SelectValue placeholder={t("admin.marketing.audiencePlaceholder")} /> - </SelectTrigger> - <SelectContent> - {Object.keys(audiences) - .sort((a, b) => (audiences[b] ?? 0) - (audiences[a] ?? 0)) - .map((key) => ( - <SelectItem key={key} value={key}> - {audienceLabel(key)} ({audiences[key]}) - </SelectItem> - ))} - </SelectContent> - </Select> - <p className="text-xs text-muted-foreground"> - {t("admin.marketing.recipientsAvailable", { count: recipientCount })} - </p> - </div> - <div className="space-y-2"> - <Label htmlFor="marketing-test-email">{t("admin.marketing.testEmail")}</Label> - <Input - id="marketing-test-email" - type="email" - placeholder={t("admin.marketing.testEmailPlaceholder")} - value={testEmail} - onChange={(e) => setTestEmail(e.target.value)} - /> - </div> - </div> - - <div className="space-y-2"> - <Label htmlFor="marketing-subject">{t("admin.marketing.subject")}</Label> - <Input - id="marketing-subject" - placeholder={t("admin.marketing.subjectPlaceholder")} - value={subject} - onChange={(e) => setSubject(e.target.value)} - maxLength={200} - /> - </div> - - <div className="space-y-2"> - <div className="flex items-center justify-between"> - <Label htmlFor="marketing-html">{t("admin.marketing.html")}</Label> - <Button - variant="ghost" - size="sm" - className="h-7 gap-1.5 text-xs" - onClick={() => setShowPreview((v) => !v)} - > - <Eye className="size-3" /> - {showPreview ? t("admin.marketing.hidePreview") : t("admin.marketing.preview")} - </Button> - </div> - <Textarea - id="marketing-html" - className="min-h-48 font-mono text-xs" - placeholder={t("admin.marketing.htmlPlaceholder")} - value={html} - onChange={(e) => setHtml(e.target.value)} - /> - <p className="text-xs text-muted-foreground">{t("admin.marketing.footerNote")}</p> - {showPreview && ( - <iframe - title={t("admin.marketing.previewTitle")} - srcDoc={previewDoc} - className="h-72 w-full rounded-lg border border-border bg-white" - sandbox="" - /> - )} - </div> - - <div className="flex flex-wrap items-center gap-3 border-t border-border/60 pt-4"> - <Button variant="outline" onClick={sendTest} disabled={isSendingTest} className="gap-2"> - {isSendingTest ? ( - <Loader2 className="size-4 animate-spin" /> - ) : ( - <FlaskConical className="size-4" /> - )} - {t("admin.marketing.sendTest")} - </Button> - <Button - onClick={sendReal} - disabled={isSending || recipientCount === 0} - className="gap-2" - > - {isSending ? ( - <Loader2 className="size-4 animate-spin" /> - ) : ( - <Send className="size-4" /> - )} - {t("admin.marketing.sendReal", { count: recipientCount })} - </Button> - {!hashSupported && ( - <span className="flex items-center gap-1.5 text-xs text-muted-foreground"> - <AlertCircle className="size-3.5" /> - {t("admin.marketing.localCheckUnavailable")} - </span> - )} - {hashSupported && testSeenInHistory && ( - <span className="flex items-center gap-1.5 text-xs text-green-500"> - <CheckCircle2 className="size-3.5" /> - {t("admin.marketing.testValidated")} - </span> - )} - {hashSupported && !testSeenInHistory && ( - <span className="flex items-center gap-1.5 text-xs text-amber-500"> - <AlertCircle className="size-3.5" /> - {t("admin.marketing.testRequiredHint")} - </span> - )} - </div> - </CardContent> - </Card> - - {/* ── Historique ── */} - <Card> - <CardHeader className="flex flex-row items-center gap-2 pb-3"> - <History className="size-4 text-muted-foreground" /> - <CardTitle className="text-base">{t("admin.marketing.history")}</CardTitle> - </CardHeader> - <CardContent className="space-y-3"> - {history.length === 0 && ( - <p className="text-sm text-muted-foreground">{t("admin.marketing.noHistory")}</p> - )} - {history.map((h) => ( - <div key={h.id} className="rounded-lg border border-border/60 p-3 text-xs"> - <div className="mb-1 flex flex-wrap items-center gap-2"> - {h.test_mode ? ( - <Badge variant="outline" className="text-[10px]"> - {t("admin.marketing.badgeTest")} - </Badge> - ) : ( - <Badge className="bg-accent/10 text-accent border-accent/30 text-[10px]"> - {t("admin.marketing.badgeReal")} - </Badge> - )} - <span className="text-muted-foreground"> - {new Date(h.sent_at).toLocaleString("fr-FR")} - </span> + <> + <div className="grid gap-6 lg:grid-cols-3"> + {/* ── Composition ── */} + <Card className="lg:col-span-2 overflow-visible"> + <CardHeader> + <CardTitle className="text-base">{t("admin.marketing.newCampaign")}</CardTitle> + <CardDescription> + {t("admin.marketing.unsubscribedNote", { count: unsubscribedCount })} + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + {/* ── Générer avec l'IA ── */} + <div className="rounded-lg border border-border/60 bg-muted/30 p-4 space-y-3"> + <div className="flex items-center gap-2"> + <Sparkles className="size-4 text-accent" /> + <span className="text-sm font-semibold">{t("admin.marketing.generateTitle")}</span> + </div> + <p className="text-xs text-muted-foreground">{t("admin.marketing.generateDesc")}</p> + <div className="grid gap-3 sm:grid-cols-3"> + <div className="space-y-1.5"> + <Label htmlFor="gen-provider">{t("admin.marketing.channel")}</Label> + <Select value={genProvider} onValueChange={setGenProvider}> + <SelectTrigger id="gen-provider"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {LLM_CHANNEL_OPTIONS.map((c) => ( + <SelectItem key={c.id} value={c.id}> + {t(c.labelKey)} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + <div className="space-y-1.5"> + <Label htmlFor="gen-model">{t("admin.marketing.model")}</Label> + <Input + id="gen-model" + placeholder={t("admin.marketing.modelPlaceholder")} + value={genModel} + onChange={(e) => setGenModel(e.target.value)} + /> + </div> + <div className="space-y-1.5"> + <Label htmlFor="gen-promo">{t("admin.marketing.promoOptional")}</Label> + <Select value={genPromoCode || "none"} onValueChange={(v) => setGenPromoCode(v === "none" ? "" : v)}> + <SelectTrigger id="gen-promo"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="none">{t("admin.marketing.promoNone")}</SelectItem> + {activePromos.map((p) => ( + <SelectItem key={p.code} value={p.code}> + {promoLabel(p)} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + <div className="space-y-1.5"> + <Label htmlFor="gen-brief">{t("admin.marketing.brief")}</Label> + <Textarea + id="gen-brief" + className="min-h-20" + placeholder={t("admin.marketing.briefPlaceholder")} + value={brief} + onChange={(e) => setBrief(e.target.value)} + maxLength={4000} + /> + </div> + <div className="grid gap-3 sm:grid-cols-3"> + <div className="space-y-1.5"> + <Label htmlFor="gen-language">{t("admin.marketing.language")}</Label> + <Select value={genLanguage} onValueChange={setGenLanguage}> + <SelectTrigger id="gen-language"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {EMAIL_LANGUAGES.map((l) => ( + <SelectItem key={l.id} value={l.id}> + {t(l.labelKey)} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + <div className="space-y-1.5"> + <Label htmlFor="gen-tone">{t("admin.marketing.tone")}</Label> + <Select value={genTone || "none"} onValueChange={(v) => setGenTone(v === "none" ? "" : v)}> + <SelectTrigger id="gen-tone"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="none">{t("admin.marketing.toneNone")}</SelectItem> + {TONES.map((tone) => ( + <SelectItem key={tone.id} value={tone.id}> + {t(tone.labelKey)} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + <div className="flex items-end"> + <Button + variant="outline" + onClick={generateEmail} + disabled={isGenerating || !brief.trim()} + className="gap-2 w-full" + > + {isGenerating ? ( + <Loader2 className="size-4 animate-spin" /> + ) : ( + <Sparkles className="size-4" /> + )} + {isGenerating ? t("admin.marketing.generating") : t("admin.marketing.generateBtn")} + </Button> + </div> </div> - <p className="mb-1 font-medium text-foreground">{h.subject}</p> - <p className="text-muted-foreground"> - {audienceLabel(h.audience)} —{" "} - {t("admin.marketing.historySent", { - sent: h.sent_count, - total: h.recipients_total, - })} - {h.failed_count > 0 - ? t("admin.marketing.historyFailedSuffix", { count: h.failed_count }) - : ""} - </p> </div> - ))} - </CardContent> - </Card> - </div> + + <div className="grid gap-4 sm:grid-cols-2"> + <div className="space-y-2"> + <Label>{t("admin.marketing.audience")}</Label> + <Select value={audience} onValueChange={setAudience}> + <SelectTrigger> + <SelectValue placeholder={t("admin.marketing.audiencePlaceholder")} /> + </SelectTrigger> + <SelectContent> + {Object.keys(audiences) + .sort((a, b) => (audiences[b] ?? 0) - (audiences[a] ?? 0)) + .map((key) => ( + <SelectItem key={key} value={key}> + {audienceLabel(key)} ({audiences[key]}) + </SelectItem> + ))} + </SelectContent> + </Select> + <p className="text-xs text-muted-foreground"> + {t("admin.marketing.recipientsAvailable", { count: recipientCount })} + </p> + </div> + <div className="space-y-2"> + <Label htmlFor="marketing-test-email">{t("admin.marketing.testEmail")}</Label> + <Input + id="marketing-test-email" + type="email" + placeholder={t("admin.marketing.testEmailPlaceholder")} + value={testEmail} + onChange={(e) => setTestEmail(e.target.value)} + /> + </div> + </div> + + <div className="space-y-2"> + <Label htmlFor="marketing-subject">{t("admin.marketing.subject")}</Label> + <Input + id="marketing-subject" + placeholder={t("admin.marketing.subjectPlaceholder")} + value={subject} + onChange={(e) => setSubject(e.target.value)} + maxLength={200} + /> + </div> + + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <Label htmlFor="marketing-html">{t("admin.marketing.html")}</Label> + <Button + variant="ghost" + size="sm" + className="h-7 gap-1.5 text-xs" + onClick={() => setShowPreview((v) => !v)} + > + <Eye className="size-3" /> + {showPreview ? t("admin.marketing.hidePreview") : t("admin.marketing.preview")} + </Button> + </div> + <Textarea + id="marketing-html" + className="min-h-48 font-mono text-xs" + placeholder={t("admin.marketing.htmlPlaceholder")} + value={html} + onChange={(e) => setHtml(e.target.value)} + /> + <p className="text-xs text-muted-foreground">{t("admin.marketing.footerNote")}</p> + {showPreview && ( + <iframe + title={t("admin.marketing.previewTitle")} + srcDoc={previewDoc} + className="h-72 w-full rounded-lg border border-border bg-white" + sandbox="" + /> + )} + </div> + + <div className="flex flex-wrap items-center gap-3 border-t border-border/60 pt-4"> + <Button variant="outline" onClick={sendTest} disabled={isSendingTest} className="gap-2"> + {isSendingTest ? ( + <Loader2 className="size-4 animate-spin" /> + ) : ( + <FlaskConical className="size-4" /> + )} + {t("admin.marketing.sendTest")} + </Button> + <Button + onClick={sendReal} + disabled={isSending || recipientCount === 0} + className="gap-2" + > + {isSending ? ( + <Loader2 className="size-4 animate-spin" /> + ) : ( + <Send className="size-4" /> + )} + {t("admin.marketing.sendReal", { count: recipientCount })} + </Button> + {!hashSupported && ( + <span className="flex items-center gap-1.5 text-xs text-muted-foreground"> + <AlertCircle className="size-3.5" /> + {t("admin.marketing.localCheckUnavailable")} + </span> + )} + {hashSupported && testSeenInHistory && ( + <span className="flex items-center gap-1.5 text-xs text-green-500"> + <CheckCircle2 className="size-3.5" /> + {t("admin.marketing.testValidated")} + </span> + )} + {hashSupported && !testSeenInHistory && ( + <span className="flex items-center gap-1.5 text-xs text-amber-500"> + <AlertCircle className="size-3.5" /> + {t("admin.marketing.testRequiredHint")} + </span> + )} + </div> + </CardContent> + </Card> + + {/* ── Historique ── */} + <Card> + <CardHeader className="flex flex-row items-center gap-2 pb-3"> + <History className="size-4 text-muted-foreground" /> + <CardTitle className="text-base">{t("admin.marketing.history")}</CardTitle> + </CardHeader> + <CardContent className="space-y-3"> + {history.length === 0 && ( + <p className="text-sm text-muted-foreground">{t("admin.marketing.noHistory")}</p> + )} + {history.map((h) => ( + <div key={h.id} className="rounded-lg border border-border/60 p-3 text-xs"> + <div className="mb-1 flex flex-wrap items-center gap-2"> + {h.test_mode ? ( + <Badge variant="outline" className="text-[10px]"> + {t("admin.marketing.badgeTest")} + </Badge> + ) : ( + <Badge className="bg-accent/10 text-accent border-accent/30 text-[10px]"> + {t("admin.marketing.badgeReal")} + </Badge> + )} + <span className="text-muted-foreground"> + {new Date(h.sent_at).toLocaleString("fr-FR")} + </span> + </div> + <p className="mb-1 font-medium text-foreground">{h.subject}</p> + <p className="text-muted-foreground"> + {audienceLabel(h.audience)} —{" "} + {t("admin.marketing.historySent", { + sent: h.sent_count, + total: h.recipients_total, + })} + {h.failed_count > 0 + ? t("admin.marketing.historyFailedSuffix", { count: h.failed_count }) + : ""} + </p> + </div> + ))} + </CardContent> + </Card> + </div> + + {/* ── Réductions (codes promo) ── */} + <div className="grid gap-6 lg:grid-cols-3"> + <Card className="lg:col-span-2"> + <CardHeader className="flex flex-row items-center gap-2 pb-3"> + <Tag className="size-4 text-muted-foreground" /> + <div> + <CardTitle className="text-base">{t("admin.marketing.promoTitle")}</CardTitle> + <CardDescription>{t("admin.marketing.promoDesc")}</CardDescription> + </div> + </CardHeader> + <CardContent className="space-y-3"> + {promos.length === 0 && ( + <p className="text-sm text-muted-foreground">{t("admin.marketing.promoEmpty")}</p> + )} + {promos.map((p) => ( + <div key={p.code} className="rounded-lg border border-border/60 p-3 text-xs"> + <div className="mb-1 flex flex-wrap items-center gap-2"> + <span className="font-mono font-semibold text-foreground">{p.code}</span> + <Badge variant="secondary" className="text-[10px]"> + {p.type === "percent" ? `-${p.value} %` : `-${p.value} €`} + </Badge> + {p.active ? ( + <Badge className="bg-green-500/10 text-green-600 border-green-500/30 text-[10px]"> + {t("admin.marketing.promoActive")} + </Badge> + ) : ( + <Badge variant="outline" className="text-[10px]"> + {t("admin.marketing.promoInactive")} + </Badge> + )} + {p.stripe_mode === "local_only" && ( + <Badge variant="outline" className="text-[10px] text-amber-500 border-amber-500/40"> + {t("admin.marketing.promoLocalOnly")} + </Badge> + )} + {p.active && p.stripe_mode === "local_only" && ( + <Button + variant="ghost" + size="sm" + className="ms-auto h-6 gap-1 px-2 text-[11px] text-blue-500" + onClick={() => linkPromoToStripe(p.code)} + disabled={linkingCode === p.code} + > + {linkingCode === p.code ? ( + <Loader2 className="size-3 animate-spin" /> + ) : ( + <Link2 className="size-3" /> + )} + {t("admin.marketing.promoLinkStripe")} + </Button> + )} + {p.active && ( + <Button + variant="ghost" + size="sm" + className={"h-6 gap-1 px-2 text-[11px] text-red-500 " + (p.stripe_mode === "local_only" ? "" : "ms-auto")} + onClick={() => deactivatePromo(p.code)} + disabled={deactivatingCode === p.code} + > + {deactivatingCode === p.code ? ( + <Loader2 className="size-3 animate-spin" /> + ) : ( + <Ban className="size-3" /> + )} + {t("admin.marketing.promoDeactivate")} + </Button> + )} + <Button + variant="ghost" + size="sm" + className="h-6 w-6 p-0 text-red-500" + onClick={() => deletePromo(p.code)} + disabled={deletingCode === p.code} + aria-label={t("admin.marketing.promoDelete")} + > + {deletingCode === p.code ? ( + <Loader2 className="size-3 animate-spin" /> + ) : ( + <Trash2 className="size-3" /> + )} + </Button> + </div> + <p className="text-muted-foreground"> + {t("admin.marketing.promoUsage", { + used: p.times_used, + max: p.max_redemptions ?? "∞", + })} + {" · "} + {t("admin.marketing.promoPlansLabel")}{" "} + {(p.plans ?? []).map((plan) => + plan === "all" ? t("admin.marketing.promoAllPlans") : plan + ).join(", ")} + {" · "} + {p.expires_at + ? t("admin.marketing.promoExpiresOn", { + date: new Date(p.expires_at).toLocaleDateString("fr-FR"), + }) + : t("admin.marketing.promoNeverExpires")} + </p> + </div> + ))} + </CardContent> + </Card> + + <Card className="overflow-visible"> + <CardHeader className="pb-3"> + <CardTitle className="text-base">{t("admin.marketing.promoCreateTitle")}</CardTitle> + <CardDescription>{t("admin.marketing.promoCreateDesc")}</CardDescription> + </CardHeader> + <CardContent className="space-y-3"> + <div className="space-y-1.5"> + <Label htmlFor="promo-code">{t("admin.marketing.promoCode")}</Label> + <Input + id="promo-code" + placeholder="LANCEMENT10" + value={promoCode} + onChange={(e) => setPromoCode(e.target.value.toUpperCase())} + maxLength={24} + /> + </div> + <div className="grid grid-cols-2 gap-2"> + <div className="space-y-1.5"> + <Label htmlFor="promo-type">{t("admin.marketing.promoType")}</Label> + <Select value={promoType} onValueChange={(v) => setPromoType(v as "percent" | "amount")}> + <SelectTrigger id="promo-type"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="percent">{t("admin.marketing.promoTypePercent")}</SelectItem> + <SelectItem value="amount">{t("admin.marketing.promoTypeAmount")}</SelectItem> + </SelectContent> + </Select> + </div> + <div className="space-y-1.5"> + <Label htmlFor="promo-value">{t("admin.marketing.promoValue")}</Label> + <Input + id="promo-value" + type="number" + min={promoType === "percent" ? 1 : 0.01} + max={promoType === "percent" ? 100 : 10000} + value={promoValue} + onChange={(e) => setPromoValue(e.target.value)} + /> + </div> + </div> + <div className="space-y-1.5"> + <Label>{t("admin.marketing.promoPlans")}</Label> + <div className="flex flex-wrap gap-1.5"> + {PROMO_PLANS.map((plan) => ( + <button + key={plan} + type="button" + onClick={() => togglePromoPlan(plan)} + className={ + "rounded-full border px-2.5 py-1 text-[11px] transition-colors " + + (promoPlans.includes(plan) + ? "border-accent bg-accent/10 text-accent" + : "border-border text-muted-foreground hover:text-foreground") + } + > + {plan === "all" ? t("admin.marketing.promoAllPlans") : plan} + </button> + ))} + </div> + </div> + <div className="grid grid-cols-2 gap-2"> + <div className="space-y-1.5"> + <Label htmlFor="promo-max">{t("admin.marketing.promoMaxRedemptions")}</Label> + <Input + id="promo-max" + type="number" + min={1} + placeholder={t("admin.marketing.promoMaxPlaceholder")} + value={promoMaxRedemptions} + onChange={(e) => setPromoMaxRedemptions(e.target.value)} + /> + </div> + <div className="space-y-1.5"> + <Label htmlFor="promo-expires">{t("admin.marketing.promoExpires")}</Label> + <Input + id="promo-expires" + type="date" + value={promoExpiresAt} + onChange={(e) => setPromoExpiresAt(e.target.value)} + /> + <p className="text-[11px] text-muted-foreground"> + {t("admin.marketing.promoExpiryHint")} + </p> + </div> + </div> + <Button onClick={createPromo} disabled={isCreatingPromo || !promoCode.trim()} className="w-full gap-2"> + {isCreatingPromo ? ( + <Loader2 className="size-4 animate-spin" /> + ) : ( + <Tag className="size-4" /> + )} + {isCreatingPromo ? t("admin.marketing.promoCreating") : t("admin.marketing.promoCreate")} + </Button> + </CardContent> + </Card> + </div> + </> )} </div> ); diff --git a/frontend/src/app/admin/models/page.tsx b/frontend/src/app/admin/models/page.tsx index c10ab93..398b409 100644 --- a/frontend/src/app/admin/models/page.tsx +++ b/frontend/src/app/admin/models/page.tsx @@ -5,12 +5,14 @@ import { Cpu, Save, Loader2, ArrowUp, ArrowDown, X, Plus, RefreshCw } from "luci import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { useNotification } from "@/components/ui/notification"; import { ModelCombobox, ModelOption } from "@/components/ui/model-combobox"; import { useTranslationStore } from "@/lib/store"; import { API_BASE } from "@/lib/config"; import { useI18n } from "@/lib/i18n"; -import type { AiTierSettings, AiTiersSettings } from "../types"; +import { LLM_CHANNEL_OPTIONS, channelLabelKey } from "../constants"; +import type { AiTierRoute, AiTierSettings, AiTiersSettings } from "../types"; interface FullSettings { [key: string]: unknown; @@ -18,7 +20,33 @@ interface FullSettings { const getToken = () => useTranslationStore.getState().settings.adminToken ?? ""; -/** Éditeur d'un palier : liste ordonnée (ordre = priorité de secours) + radio « par défaut ». */ +/** L'ancien format (chaînes OpenRouter) est converti en routes à l'affichage. */ +function toRoutes(tier: AiTierSettings): AiTierRoute[] { + if (tier.routes && tier.routes.length > 0) return tier.routes; + const legacy: AiTierRoute[] = []; + if (tier.default_model) legacy.push({ provider: "openrouter", model: tier.default_model }); + for (const m of tier.models || []) { + if (m && !legacy.some((r) => r.model === m)) legacy.push({ provider: "openrouter", model: m }); + } + return legacy; +} + +/** À la sauvegarde : routes multi-canaux + miroir hérité (modèles openrouter). */ +function fromRoutes(routes: AiTierRoute[], previous: AiTierSettings): AiTierSettings { + const openrouterModels = routes.filter((r) => r.provider === "openrouter").map((r) => r.model); + const defaultRoute = routes[0]; + const legacyDefault = + defaultRoute?.provider === "openrouter" + ? defaultRoute.model + : (previous.default_model || ""); + return { + routes, + models: openrouterModels, + default_model: legacyDefault, + }; +} + +/** Éditeur d'un palier : liste ordonnée de routes (ordre = priorité de secours). */ function TierEditor({ tier, onChange, @@ -33,42 +61,63 @@ function TierEditor({ catalogLoading: boolean; }) { const { t } = useI18n(); + const { error } = useNotification(); const [adding, setAdding] = useState(false); + const [newProvider, setNewProvider] = useState("openrouter"); + const [newModel, setNewModel] = useState(""); + + const routes = toRoutes(tier); + + const setRoutes = (next: AiTierRoute[]) => onChange(fromRoutes(next, tier)); + + /** Vrai si un couple canal+modèle existe déjà ailleurs dans la liste. */ + const isDuplicate = (provider: string, model: string, exceptIndex = -1): boolean => + routes.some( + (r, i) => + i !== exceptIndex && r.provider === provider && r.model === model + ); const move = (index: number, dir: -1 | 1) => { - const models = [...tier.models]; + const next = [...routes]; const target = index + dir; - if (target < 0 || target >= models.length) return; - [models[index], models[target]] = [models[target], models[index]]; - onChange({ ...tier, models }); + if (target < 0 || target >= next.length) return; + [next[index], next[target]] = [next[target], next[index]]; + setRoutes(next); }; const remove = (index: number) => { - const models = tier.models.filter((_, i) => i !== index); - onChange({ ...tier, models }); + setRoutes(routes.filter((_, i) => i !== index)); }; - const add = (id: string) => { - const idClean = id.trim(); - if (!idClean || tier.models.includes(idClean)) { - setAdding(false); + /** Par défaut = première route essayée : cliquer le radio met la route en tête. */ + const setDefault = (index: number) => { + if (index === 0) return; + const next = [...routes]; + const [route] = next.splice(index, 1); + setRoutes([route, ...next]); + }; + + const add = () => { + const model = newModel.trim(); + if (!model) return; + if (isDuplicate(newProvider, model)) { + error({ title: t("admin.models.duplicateRouteTitle"), description: t("admin.models.duplicateRouteDesc") }); return; } - onChange({ ...tier, models: [...tier.models, idClean] }); + setRoutes([...routes, { provider: newProvider, model }]); + setNewModel(""); setAdding(false); }; - const defaultModel = tier.default_model || tier.models[0] || ""; - return ( <div className="space-y-2"> - {tier.models.map((model, i) => { - const isDefault = model === defaultModel; + {routes.map((route, i) => { + const isDefault = i === 0; return ( <div - key={model} + key={`${route.provider}/${route.model}`} className={ - "flex items-center gap-2 rounded-lg border px-3 py-2 " + + "flex flex-wrap items-center gap-2 rounded-lg border px-3 py-2 " + (isDefault ? "border-accent/50 bg-accent/5" : "border-border/60 bg-card") } > @@ -76,8 +125,8 @@ function TierEditor({ type="button" role="radio" aria-checked={isDefault} - aria-label={t("admin.models.setDefault", { model })} - onClick={() => onChange({ ...tier, default_model: model })} + aria-label={t("admin.models.setDefault", { model: `${route.provider}/${route.model}` })} + onClick={() => setDefault(i)} className={ "flex size-4 shrink-0 items-center justify-center rounded-full border " + (isDefault ? "border-accent" : "border-muted-foreground/40") @@ -85,13 +134,37 @@ function TierEditor({ > {isDefault && <span className="size-2 rounded-full bg-accent" />} </button> - <span className="flex-1 cursor-pointer font-mono text-xs" onClick={() => onChange({ ...tier, default_model: model })}> - {model} + <Select + value={route.provider} + onValueChange={(value) => { + if (isDuplicate(value, route.model, i)) { + error({ title: t("admin.models.duplicateRouteTitle"), description: t("admin.models.duplicateRouteDesc") }); + return; + } + const next = [...routes]; + next[i] = { ...route, provider: value }; + setRoutes(next); + }} + > + <SelectTrigger className="h-7 w-44 shrink-0 text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {LLM_CHANNEL_OPTIONS.map((p) => ( + <SelectItem key={p.id} value={p.id} className="text-xs"> + {t(p.labelKey)} + </SelectItem> + ))} + </SelectContent> + </Select> + <span className="flex-1 cursor-pointer font-mono text-xs" onClick={() => setDefault(i)}> + {route.model} {isDefault ? ( <Badge variant="secondary" className="ms-2 font-sans text-[10px]"> {t("admin.models.defaultBadge")} </Badge> - ) : i === 0 ? ( + ) : null} + {isDefault ? ( <span className="ms-2 font-sans text-[10px] font-bold uppercase tracking-wider text-muted-foreground"> {t("admin.models.fallbackFirst")} </span> @@ -112,7 +185,7 @@ function TierEditor({ size="sm" className="h-6 w-6 p-0" onClick={() => move(i, 1)} - disabled={i === tier.models.length - 1} + disabled={i === routes.length - 1} aria-label={t("admin.models.moveDown")} > <ArrowDown className="size-3" /> @@ -130,24 +203,56 @@ function TierEditor({ ); })} - {tier.models.length === 0 && ( + {routes.length === 0 && ( <p className="text-xs text-muted-foreground">{t("admin.models.emptyTier")}</p> )} {adding ? ( <div className="space-y-2 rounded-lg border border-dashed border-border p-3"> - <ModelCombobox - value="" - onChange={add} - models={catalog} - isLoading={catalogLoading} - onFetchModels={onLoadCatalog} - providerLabel="OpenRouter" - placeholder="provider/nom-du-modèle" - /> - <Button variant="ghost" size="sm" onClick={() => setAdding(false)}> - {t("admin.models.cancel")} - </Button> + <div className="grid gap-2 sm:grid-cols-[auto_1fr]"> + <Select value={newProvider} onValueChange={setNewProvider}> + <SelectTrigger className="w-44 text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {LLM_CHANNEL_OPTIONS.map((p) => ( + <SelectItem key={p.id} value={p.id} className="text-xs"> + {t(p.labelKey)} + </SelectItem> + ))} + </SelectContent> + </Select> + {newProvider === "openrouter" ? ( + <ModelCombobox + value={newModel} + onChange={setNewModel} + models={catalog} + isLoading={catalogLoading} + onFetchModels={onLoadCatalog} + providerLabel="OpenRouter" + placeholder="provider/nom-du-modèle" + /> + ) : ( + <ModelCombobox + value={newModel} + onChange={setNewModel} + models={[]} + isLoading={false} + onFetchModels={() => {}} + providerLabel={t(channelLabelKey(newProvider))} + placeholder={t("admin.models.customModelPlaceholder")} + /> + )} + </div> + <p className="text-xs text-muted-foreground">{t("admin.models.routeHint")}</p> + <div className="flex gap-2"> + <Button variant="outline" size="sm" onClick={add} disabled={!newModel.trim()}> + {t("admin.models.addRouteConfirm")} + </Button> + <Button variant="ghost" size="sm" onClick={() => setAdding(false)}> + {t("admin.models.cancel")} + </Button> + </div> </div> ) : ( <div className="flex items-center gap-2 pt-1"> @@ -200,7 +305,10 @@ export default function AdminModelsPage() { const payload = envelope.data ?? envelope; setFullSettings(payload); if (payload.ai_tiers) { - setAiTiers(payload.ai_tiers); + setAiTiers({ + essential: { models: [], default_model: "", routes: [], ...payload.ai_tiers.essential }, + premium: { models: [], default_model: "", routes: [], ...payload.ai_tiers.premium }, + }); } } else { error({ @@ -256,7 +364,12 @@ export default function AdminModelsPage() { if (response.ok) { const envelope = await response.json(); const payload = envelope.data ?? envelope; - if (payload?.ai_tiers) setAiTiers(payload.ai_tiers); + if (payload?.ai_tiers) { + setAiTiers({ + essential: { models: [], default_model: "", routes: [], ...payload.ai_tiers.essential }, + premium: { models: [], default_model: "", routes: [], ...payload.ai_tiers.premium }, + }); + } success({ title: t("admin.models.savedTitle"), description: t("admin.models.savedDesc"), diff --git a/frontend/src/app/admin/settings/page.tsx b/frontend/src/app/admin/settings/page.tsx index 85c7dc5..ec9e68e 100644 --- a/frontend/src/app/admin/settings/page.tsx +++ b/frontend/src/app/admin/settings/page.tsx @@ -1,18 +1,18 @@ "use client"; import { useState, useEffect } from "react"; -import { Settings, Save, Loader2, CheckCircle, XCircle, RefreshCw, FlaskConical, KeyRound, Mail } from "lucide-react"; +import { Settings, Save, Loader2, CheckCircle, XCircle, FlaskConical, KeyRound, Mail, Languages, Cpu, Network, ScanText } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { useNotification } from "@/components/ui/notification"; import { ModelCombobox, ModelOption } from "@/components/ui/model-combobox"; +import { useNotification } from "@/components/ui/notification"; import { useTranslationStore } from "@/lib/store"; import { API_BASE } from "@/lib/config"; +import { useI18n } from "@/lib/i18n"; import type { AiTiersSettings } from "../types"; interface ProviderConfig { @@ -38,7 +38,9 @@ interface SettingsConfig { google: ProviderConfig; google_cloud: ProviderConfig; openai: ProviderConfig; - ollama: ProviderConfig; + deepseek: ProviderConfig; + zhipu: ProviderConfig; + minimax: ProviderConfig; openrouter: ProviderConfig; openrouter_premium: ProviderConfig; zai: ProviderConfig; @@ -55,24 +57,22 @@ interface EnvInfo { openai: boolean; openrouter: boolean; openrouter_premium: boolean; + deepseek: boolean; + zhipu: boolean; + minimax: boolean; zai: boolean; mistral: boolean; - ollama: boolean; google_cloud: boolean; smtp: boolean; } -interface OllamaModel { - name: string; - size: number; - modified_at: string; -} - const defaultConfig: SettingsConfig = { google: { enabled: true, timeout: 30, max_retries: 3 }, google_cloud: { 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" }, + deepseek: { enabled: false, api_key: "", model: "deepseek-chat", base_url: "https://api.deepseek.com/v1" }, + zhipu: { enabled: false, api_key: "", model: "glm-5.3", base_url: "https://api.z.ai/api/paas/v4" }, + minimax: { enabled: false, api_key: "", model: "MiniMax-M3", base_url: "https://api.minimax.io/v1" }, + openai: { enabled: false, api_key: "", model: "gpt-4o-mini" }, openrouter: { enabled: false, api_key: "", model: "deepseek/deepseek-v4-flash" }, openrouter_premium: { enabled: false, api_key: "", model: "anthropic/claude-sonnet-5" }, zai: { enabled: false, api_key: "", base_url: "https://api.x.ai/v1", model: "grok-2-1212" }, @@ -87,22 +87,51 @@ const defaultEnvInfo: EnvInfo = { openai: false, openrouter: false, openrouter_premium: false, + deepseek: false, + zhipu: false, + minimax: false, zai: false, mistral: false, - ollama: false, google_cloud: false, smtp: false, }; +/** En-tête de section de la page Fournisseurs. */ +function ProviderSection({ + icon, + title, + description, + children, +}: { + icon: React.ReactNode; + title: string; + description: string; + children: React.ReactNode; +}) { + return ( + <section className="space-y-3"> + <div className="flex items-center gap-2"> + <span className="flex size-8 items-center justify-center rounded-lg bg-muted text-foreground/70"> + {icon} + </span> + <div> + <h2 className="text-base font-semibold text-foreground">{title}</h2> + <p className="text-xs text-muted-foreground">{description}</p> + </div> + </div> + <div className="grid gap-4">{children}</div> + </section> + ); +} + export default function AdminSettingsPage() { + const { t } = useI18n(); const [config, setConfig] = useState<SettingsConfig>(defaultConfig); const [envInfo, setEnvInfo] = useState<EnvInfo>(defaultEnvInfo); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [testResults, setTestResults] = useState<Record<string, "ok" | "error" | "testing" | "idle">>({}); const [testMessages, setTestMessages] = useState<Record<string, string>>({}); - const [ollamaModels, setOllamaModels] = useState<OllamaModel[]>([]); - const [isLoadingModels, setIsLoadingModels] = useState(false); const [openaiModels, setOpenaiModels] = useState<ModelOption[]>([]); const [zaiModels, setZaiModels] = useState<ModelOption[]>([]); const [loadingModelsProvider, setLoadingModelsProvider] = useState<string | null>(null); @@ -133,10 +162,10 @@ export default function AdminSettingsPage() { setEnvInfo({ ...defaultEnvInfo, ...envelope.env_info }); } } else { - error({ title: "Erreur de chargement", description: `HTTP ${response.status} — vérifiez votre token admin.` }); + error({ title: t("admin.settings.loadErrorTitle"), description: t("admin.settings.loadErrorDesc", { status: response.status }) }); } } catch (e) { - error({ title: "Erreur réseau", description: "Impossible de contacter le backend." }); + error({ title: t("admin.settings.networkErrorTitle"), description: t("admin.settings.networkErrorDesc") }); console.error("Failed to load settings:", e); } finally { setIsLoading(false); @@ -158,13 +187,13 @@ export default function AdminSettingsPage() { body: JSON.stringify(config), }); if (response.ok) { - success({ title: "✅ Configuration sauvegardée", description: "Les paramètres ont été enregistrés avec succès." }); + success({ title: t("admin.settings.savedTitle"), description: t("admin.settings.savedDesc") }); } else { const body = await response.json().catch(() => ({})); - error({ title: "Erreur de sauvegarde", description: body.detail || `HTTP ${response.status}` }); + error({ title: t("admin.settings.saveErrorTitle"), description: body.detail || `HTTP ${response.status}` }); } } catch (e) { - error({ title: "Erreur réseau", description: "Impossible de contacter le backend pour la sauvegarde." }); + error({ title: t("admin.settings.networkErrorTitle"), description: t("admin.settings.networkSaveErrorDesc") }); } finally { setIsSaving(false); } @@ -192,43 +221,22 @@ export default function AdminSettingsPage() { const data = await response.json(); if (data.available) { setTestResults((prev) => ({ ...prev, [provider]: "ok" })); - const detail = data.test_result || data.usage || data.models_count !== undefined - ? `Connexion OK${data.models_count !== undefined ? ` — ${data.models_count} modèles` : ""}${data.test_result ? ` — "${data.test_result}"` : ""}` - : "Connexion OK"; + let detail: string; + if (data.models_count !== undefined) { + detail = t("admin.settings.modelsFound", { count: data.models_count, provider }); + } else if (data.test_result) { + detail = `${t("admin.settings.connectionOk")} — "${data.test_result}"`; + } else { + detail = t("admin.settings.connectionOk"); + } setTestMessages((prev) => ({ ...prev, [provider]: detail })); } else { setTestResults((prev) => ({ ...prev, [provider]: "error" })); - setTestMessages((prev) => ({ ...prev, [provider]: data.error || "Échec" })); + setTestMessages((prev) => ({ ...prev, [provider]: data.error || t("admin.settings.failure") })); } } catch (e) { setTestResults((prev) => ({ ...prev, [provider]: "error" })); - setTestMessages((prev) => ({ ...prev, [provider]: "Erreur réseau" })); - } - }; - - const fetchOllamaModels = async () => { - setIsLoadingModels(true); - try { - const url = config.ollama.base_url - ? `${API_BASE}/api/v1/admin/providers/ollama/models?base_url=${encodeURIComponent(config.ollama.base_url)}` - : `${API_BASE}/api/v1/admin/providers/ollama/models`; - const response = await fetch(url, { - headers: { Authorization: `Bearer ${getToken()}` }, - }); - if (response.ok) { - const data = await response.json(); - setOllamaModels(data.data || []); - if (data.data?.length > 0 && !config.ollama.model) { - updateProvider("ollama", { model: data.data[0].name }); - } - info({ title: `${data.data?.length || 0} modèles Ollama trouvés` }); - } else { - error({ title: "Ollama inaccessible", description: "Vérifiez que Ollama tourne sur l'URL configurée." }); - } - } catch (e) { - error({ title: "Erreur Ollama", description: "Impossible de contacter Ollama." }); - } finally { - setIsLoadingModels(false); + setTestMessages((prev) => ({ ...prev, [provider]: t("admin.settings.networkErrorDesc") })); } }; @@ -244,19 +252,19 @@ export default function AdminSettingsPage() { const models: ModelOption[] = data.data || []; if (provider === "openai") setOpenaiModels(models); else if (provider === "zai") setZaiModels(models); - info({ title: `${models.length} modèles ${provider} trouvés` }); + info({ title: t("admin.settings.modelsFound", { count: models.length, provider }) }); } else { const body = await response.json().catch(() => ({})); - error({ title: `Erreur ${provider}`, description: body.message || `HTTP ${response.status}` }); + error({ title: t("admin.settings.providerError", { provider }), description: body.message || `HTTP ${response.status}` }); } } catch (e) { - error({ title: `Erreur ${provider}`, description: "Impossible de contacter le serveur." }); + error({ title: t("admin.settings.providerError", { provider }), description: t("admin.settings.networkErrorDesc") }); } finally { setLoadingModelsProvider(null); } }; - type ProviderKey = keyof Omit<SettingsConfig, "fallback_chain" | "fallback_chain_classic" | "fallback_chain_llm" | "smtp">; + type ProviderKey = "google" | "google_cloud" | "openai" | "deepseek" | "zhipu" | "minimax" | "openrouter" | "openrouter_premium" | "zai" | "mistral"; const updateProvider = (provider: ProviderKey, updates: Partial<ProviderConfig>) => { setConfig((prev) => ({ ...prev, @@ -290,14 +298,14 @@ export default function AdminSettingsPage() { const data = await response.json(); if (data.available) { setTestEmailResult("ok"); - setTestEmailMessage(data.test_result || "Email envoyé avec succès"); + setTestEmailMessage(data.test_result || t("admin.settings.smtp.sent")); } else { setTestEmailResult("error"); - setTestEmailMessage(data.error || "Échec de l'envoi"); + setTestEmailMessage(data.error || t("admin.settings.smtp.sendFailed")); } } catch { setTestEmailResult("error"); - setTestEmailMessage("Erreur réseau"); + setTestEmailMessage(t("admin.settings.networkErrorDesc")); } finally { setIsSendingTestEmail(false); } @@ -311,84 +319,210 @@ export default function AdminSettingsPage() { ); } + const envPlaceholder = (envSet: boolean) => + envSet ? t("admin.settings.envKeyPlaceholder") : ""; + return ( - <div className="space-y-6"> + <div className="space-y-8"> <div className="flex items-center gap-3"> <div className="w-10 h-10 bg-purple-600/20 rounded-lg flex items-center justify-center"> <Settings className="w-5 h-5 text-purple-400" /> </div> <div> - <h1 className="text-xl font-semibold text-foreground">Paramètres des providers</h1> - <p className="text-sm text-muted-foreground"> - Configurez les clés API. Les clés peuvent aussi être définies dans le fichier <code>.env</code>. - </p> + <h1 className="text-xl font-semibold text-foreground">{t("admin.settings.title")}</h1> + <p className="text-sm text-muted-foreground">{t("admin.settings.subtitle")}</p> </div> </div> - <div className="grid gap-4"> + {/* ── Traduction classique ── */} + <ProviderSection + icon={<Languages className="size-4" />} + title={t("admin.settings.sectionClassic")} + description={t("admin.settings.sectionClassicDesc")} + > <ProviderCard - title="Google Translate" - description="Accès web non officiel via deep_translator. Aucune clé requise, usage raisonnable recommandé." + title={t("admin.settings.google.title")} + description={t("admin.settings.google.desc")} enabled={config.google.enabled} onToggle={(enabled) => updateProvider("google", { enabled })} onTest={() => testProvider("google")} testResult={testResults.google ?? "idle"} testMessage={testMessages.google} noApiKey + t={t} /> <ProviderCard - title="Google Cloud Translation (officiel)" - description="API officielle Google Cloud. 500 000 car./mois offerts, puis ~$20/M car. Clé API sur console.cloud.google.com" + title={t("admin.settings.googleCloud.title")} + description={t("admin.settings.googleCloud.desc")} enabled={config.google_cloud.enabled} onToggle={(enabled) => updateProvider("google_cloud", { enabled })} onTest={() => testProvider("google_cloud")} testResult={testResults.google_cloud ?? "idle"} testMessage={testMessages.google_cloud} envKeySet={envInfo.google_cloud} + t={t} > <div className="space-y-2"> - <Label htmlFor="google-cloud-key">Clé API</Label> + <Label htmlFor="google-cloud-key">{t("admin.settings.apiKey")}</Label> <Input id="google-cloud-key" type="password" - placeholder={ - envInfo.google_cloud - ? "Clé configurée dans .env (laisser vide pour l'utiliser)" - : "AIza..." - } + placeholder={envPlaceholder(envInfo.google_cloud) || "AIza..."} value={config.google_cloud.api_key || ""} onChange={(e) => updateProvider("google_cloud", { api_key: e.target.value })} /> <p className="text-xs text-muted-foreground"> - Activez <code>Cloud Translation API</code> dans Google Cloud Console, puis créez une clé API restreinte à cette API. + {t("admin.settings.googleCloud.hint")} + </p> + </div> + </ProviderCard> + </ProviderSection> + + {/* ── Fournisseurs IA directs ── */} + <ProviderSection + icon={<Cpu className="size-4" />} + title={t("admin.settings.sectionDirect")} + description={t("admin.settings.sectionDirectDesc")} + > + <ProviderCard + title={t("admin.settings.deepseek.title")} + description={t("admin.settings.deepseek.desc")} + enabled={config.deepseek.enabled} + onToggle={(enabled) => updateProvider("deepseek", { enabled })} + onTest={() => testProvider("deepseek")} + testResult={testResults.deepseek ?? "idle"} + testMessage={testMessages.deepseek} + envKeySet={envInfo.deepseek} + t={t} + > + <div className="grid gap-4 sm:grid-cols-2"> + <div className="space-y-2"> + <Label htmlFor="deepseek-key">{t("admin.settings.apiKey")}</Label> + <Input + id="deepseek-key" + type="password" + placeholder={envPlaceholder(envInfo.deepseek) || "sk-..."} + value={config.deepseek.api_key || ""} + onChange={(e) => updateProvider("deepseek", { api_key: e.target.value })} + /> + </div> + <div className="space-y-2"> + <Label htmlFor="deepseek-model">{t("admin.settings.model")}</Label> + <Input + id="deepseek-model" + placeholder="deepseek-chat" + value={config.deepseek.model || ""} + onChange={(e) => updateProvider("deepseek", { model: e.target.value })} + /> + </div> + </div> + </ProviderCard> + + <ProviderCard + title={t("admin.settings.zhipu.title")} + description={t("admin.settings.zhipu.desc")} + enabled={config.zhipu.enabled} + onToggle={(enabled) => updateProvider("zhipu", { enabled })} + onTest={() => testProvider("zhipu")} + testResult={testResults.zhipu ?? "idle"} + testMessage={testMessages.zhipu} + envKeySet={envInfo.zhipu} + t={t} + > + <div className="grid gap-4 sm:grid-cols-2"> + <div className="space-y-2"> + <Label htmlFor="zhipu-key">{t("admin.settings.apiKey")}</Label> + <Input + id="zhipu-key" + type="password" + placeholder={envPlaceholder(envInfo.zhipu) || "xxxxxxxxxxxxxxxx"} + value={config.zhipu.api_key || ""} + onChange={(e) => updateProvider("zhipu", { api_key: e.target.value })} + /> + </div> + <div className="space-y-2"> + <Label htmlFor="zhipu-model">{t("admin.settings.model")}</Label> + <Input + id="zhipu-model" + placeholder="glm-5.3" + value={config.zhipu.model || ""} + onChange={(e) => updateProvider("zhipu", { model: e.target.value })} + /> + </div> + </div> + <div className="mt-3 space-y-2"> + <Label htmlFor="zhipu-url">{t("admin.settings.baseUrl")}</Label> + <Input + id="zhipu-url" + placeholder="https://api.z.ai/api/paas/v4" + value={config.zhipu.base_url || ""} + onChange={(e) => updateProvider("zhipu", { base_url: e.target.value })} + /> + <p className="text-xs text-muted-foreground"> + {t("admin.settings.zhipu.urlHint")} </p> </div> </ProviderCard> <ProviderCard - title="OpenAI" - description="Traductions GPT-4. Obtenez une clé sur platform.openai.com" + title={t("admin.settings.minimax.title")} + description={t("admin.settings.minimax.desc")} + enabled={config.minimax.enabled} + onToggle={(enabled) => updateProvider("minimax", { enabled })} + onTest={() => testProvider("minimax")} + testResult={testResults.minimax ?? "idle"} + testMessage={testMessages.minimax} + envKeySet={envInfo.minimax} + t={t} + > + <div className="grid gap-4 sm:grid-cols-2"> + <div className="space-y-2"> + <Label htmlFor="minimax-key">{t("admin.settings.apiKey")}</Label> + <Input + id="minimax-key" + type="password" + placeholder={envPlaceholder(envInfo.minimax) || "eyJ..."} + value={config.minimax.api_key || ""} + onChange={(e) => updateProvider("minimax", { api_key: e.target.value })} + /> + </div> + <div className="space-y-2"> + <Label htmlFor="minimax-model">{t("admin.settings.model")}</Label> + <Input + id="minimax-model" + placeholder="MiniMax-M3" + value={config.minimax.model || ""} + onChange={(e) => updateProvider("minimax", { model: e.target.value })} + /> + </div> + </div> + </ProviderCard> + + <ProviderCard + title={t("admin.settings.openai.title")} + description={t("admin.settings.openai.desc")} enabled={config.openai.enabled} onToggle={(enabled) => updateProvider("openai", { enabled })} onTest={() => testProvider("openai")} testResult={testResults.openai ?? "idle"} testMessage={testMessages.openai} envKeySet={envInfo.openai} + t={t} > <div className="grid gap-4 sm:grid-cols-2"> <div className="space-y-2"> - <Label htmlFor="openai-key">Clé API</Label> + <Label htmlFor="openai-key">{t("admin.settings.apiKey")}</Label> <Input id="openai-key" type="password" - placeholder={envInfo.openai ? "Clé configurée dans .env (laisser vide pour l'utiliser)" : "sk-..."} + placeholder={envPlaceholder(envInfo.openai) || "sk-..."} value={config.openai.api_key || ""} onChange={(e) => updateProvider("openai", { api_key: e.target.value })} /> </div> <div className="space-y-2"> - <Label htmlFor="openai-model">Modèle</Label> + <Label htmlFor="openai-model">{t("admin.settings.model")}</Label> <ModelCombobox value={config.openai.model || ""} onChange={(v) => updateProvider("openai", { model: v })} @@ -403,166 +537,29 @@ export default function AdminSettingsPage() { </ProviderCard> <ProviderCard - title="Ollama" - description="LLM local. Nécessite Ollama en cours d'exécution." - enabled={config.ollama.enabled} - onToggle={(enabled) => updateProvider("ollama", { enabled })} - onTest={() => testProvider("ollama")} - testResult={testResults.ollama ?? "idle"} - testMessage={testMessages.ollama} - envKeySet={envInfo.ollama} - > - <div className="space-y-4"> - <div className="space-y-2"> - <Label htmlFor="ollama-url">URL de base</Label> - <Input - id="ollama-url" - placeholder={envInfo.ollama ? "URL configurée dans .env" : "http://localhost:11434"} - value={config.ollama.base_url || ""} - onChange={(e) => updateProvider("ollama", { base_url: e.target.value })} - /> - </div> - <div className="space-y-2"> - <div className="flex items-center justify-between"> - <Label htmlFor="ollama-model">Modèle</Label> - <Button - variant="ghost" - size="sm" - onClick={fetchOllamaModels} - disabled={isLoadingModels} - className="h-7 px-2 text-xs" - > - {isLoadingModels ? ( - <Loader2 className="size-3 animate-spin" /> - ) : ( - <RefreshCw className="size-3" /> - )} - <span className="ms-1">Récupérer les modèles</span> - </Button> - </div> - {ollamaModels.length > 0 ? ( - <Select - value={config.ollama.model || ""} - onValueChange={(value) => updateProvider("ollama", { model: value })} - > - <SelectTrigger> - <SelectValue placeholder="Sélectionnez un modèle" /> - </SelectTrigger> - <SelectContent> - {ollamaModels.map((model) => ( - <SelectItem key={model.name} value={model.name}> - {model.name} - {model.size > 0 && ( - <span className="ms-2 text-xs text-muted-foreground"> - ({(model.size / 1e9).toFixed(1)} GB) - </span> - )} - </SelectItem> - ))} - </SelectContent> - </Select> - ) : ( - <Input - id="ollama-model" - placeholder="llama3" - value={config.ollama.model || ""} - onChange={(e) => updateProvider("ollama", { model: e.target.value })} - /> - )} - {ollamaModels.length === 0 && ( - <p className="text-xs text-muted-foreground"> - Cliquez sur "Récupérer les modèles" pour charger la liste depuis Ollama. - </p> - )} - </div> - </div> - </ProviderCard> - - <ProviderCard - title="Traduction IA Essentielle" - description="Palier Essentielle (forfait Pro). Le modèle réellement routé se règle dans « Modèles & abonnements ». Gamme : deepseek/deepseek-v4-flash, z-ai/glm-5.3-flash, minimax/minimax-m3. Clé API : openrouter.ai" - enabled={config.openrouter.enabled} - onToggle={(enabled) => updateProvider("openrouter", { enabled })} - onTest={() => testProvider("openrouter")} - testResult={testResults.openrouter ?? "idle"} - testMessage={testMessages.openrouter} - envKeySet={envInfo.openrouter} - > - <div className="grid gap-4 sm:grid-cols-2"> - <div className="space-y-2"> - <Label htmlFor="openrouter-key">Clé API OpenRouter</Label> - <Input - id="openrouter-key" - type="password" - placeholder={envInfo.openrouter ? "Clé configurée dans .env (partagée avec Premium)" : "sk-or-..."} - value={config.openrouter.api_key || ""} - onChange={(e) => updateProvider("openrouter", { api_key: e.target.value })} - /> - </div> - <div className="space-y-2"> - <Label htmlFor="openrouter-model">Modèle Essentiel (lecture seule)</Label> - <Input - id="openrouter-model" - value={config.openrouter.model || ""} - disabled - readOnly - placeholder="deepseek/deepseek-v4-flash" - /> - <p className="text-xs text-amber-500"> - Piloté par la page <strong>Modèles & abonnements</strong> (matrice par forfait). - </p> - </div> - </div> - </ProviderCard> - - <ProviderCard - title="Traduction IA Premium" - description="Palier Premium (forfait Business). Le modèle réellement routé se règle dans « Modèles & abonnements ». Gamme : anthropic/claude-sonnet-5, deepseek/deepseek-v4-pro, z-ai/glm-5.3. Partage la même clé OpenRouter." - enabled={config.openrouter_premium.enabled} - onToggle={(enabled) => updateProvider("openrouter_premium", { enabled })} - onTest={() => testProvider("openrouter_premium")} - testResult={testResults.openrouter_premium ?? "idle"} - testMessage={testMessages.openrouter_premium} - envKeySet={envInfo.openrouter_premium} - > - <div className="space-y-2"> - <Label htmlFor="openrouter-premium-model">Modèle Premium (lecture seule)</Label> - <Input - id="openrouter-premium-model" - value={config.openrouter_premium.model || ""} - disabled - readOnly - placeholder="anthropic/claude-sonnet-5" - /> - <p className="text-xs text-amber-500"> - Piloté par la page <strong>Modèles & abonnements</strong> (matrice par forfait). - </p> - </div> - </ProviderCard> - - <ProviderCard - title="z.AI / xAI Grok" - description="Modèles Grok par xAI. API compatible OpenAI. Obtenez votre clé sur x.ai" + title={t("admin.settings.zai.title")} + description={t("admin.settings.zai.desc")} enabled={config.zai.enabled} onToggle={(enabled) => updateProvider("zai", { enabled })} onTest={() => testProvider("zai")} testResult={testResults.zai ?? "idle"} testMessage={testMessages.zai} envKeySet={envInfo.zai} + t={t} > <div className="grid gap-4 sm:grid-cols-2"> <div className="space-y-2"> - <Label htmlFor="zai-key">Clé API</Label> + <Label htmlFor="zai-key">{t("admin.settings.apiKey")}</Label> <Input id="zai-key" type="password" - placeholder={envInfo.zai ? "Clé configurée dans .env (laisser vide pour l'utiliser)" : "xai-..."} + placeholder={envPlaceholder(envInfo.zai) || "xai-..."} value={config.zai.api_key || ""} onChange={(e) => updateProvider("zai", { api_key: e.target.value })} /> </div> <div className="space-y-2"> - <Label htmlFor="zai-model">Modèle</Label> + <Label htmlFor="zai-model">{t("admin.settings.model")}</Label> <ModelCombobox value={config.zai.model || ""} onChange={(v) => updateProvider("zai", { model: v })} @@ -575,7 +572,7 @@ export default function AdminSettingsPage() { </div> </div> <div className="mt-3 space-y-2"> - <Label htmlFor="zai-url">URL de base</Label> + <Label htmlFor="zai-url">{t("admin.settings.baseUrl")}</Label> <Input id="zai-url" placeholder="https://api.x.ai/v1" @@ -583,34 +580,93 @@ export default function AdminSettingsPage() { onChange={(e) => updateProvider("zai", { base_url: e.target.value })} /> <p className="text-xs text-muted-foreground"> - Par défaut : <code>https://api.x.ai/v1</code> — à changer uniquement si vous utilisez un proxy. + {t("admin.settings.zai.urlHint")} + </p> + </div> + </ProviderCard> + </ProviderSection> + + {/* ── Passerelles multi-modèles ── */} + <ProviderSection + icon={<Network className="size-4" />} + title={t("admin.settings.sectionGateway")} + description={t("admin.settings.sectionGatewayDesc")} + > + <ProviderCard + title={t("admin.settings.openrouter.title")} + description={t("admin.settings.openrouter.desc")} + enabled={config.openrouter.enabled} + onToggle={(enabled) => updateProvider("openrouter", { enabled })} + onTest={() => testProvider("openrouter")} + testResult={testResults.openrouter ?? "idle"} + testMessage={testMessages.openrouter} + envKeySet={envInfo.openrouter} + t={t} + > + <div className="space-y-2"> + <Label htmlFor="openrouter-key">{t("admin.settings.openrouter.keyLabel")}</Label> + <Input + id="openrouter-key" + type="password" + placeholder={envInfo.openrouter ? t("admin.settings.openrouter.envSharedPlaceholder") : "sk-or-..."} + value={config.openrouter.api_key || ""} + onChange={(e) => updateProvider("openrouter", { api_key: e.target.value })} + /> + <p className="text-xs text-amber-500"> + {t("admin.settings.openrouter.reminder")} </p> </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" + title={t("admin.settings.openrouterPremium.title")} + description={t("admin.settings.openrouterPremium.desc")} + enabled={config.openrouter_premium.enabled} + onToggle={(enabled) => updateProvider("openrouter_premium", { enabled })} + onTest={() => testProvider("openrouter_premium")} + testResult={testResults.openrouter_premium ?? "idle"} + testMessage={testMessages.openrouter_premium} + envKeySet={envInfo.openrouter_premium} + t={t} + > + <div className="space-y-2"> + <p className="text-xs text-amber-500"> + {t("admin.settings.openrouterPremium.reminder")} + </p> + </div> + </ProviderCard> + </ProviderSection> + + {/* ── OCR ── */} + <ProviderSection + icon={<ScanText className="size-4" />} + title={t("admin.settings.sectionOcr")} + description={t("admin.settings.sectionOcrDesc")} + > + <ProviderCard + title={t("admin.settings.mistral.title")} + description={t("admin.settings.mistral.desc")} enabled={config.mistral.enabled} onToggle={(enabled) => updateProvider("mistral", { enabled })} onTest={() => testProvider("mistral")} testResult={testResults.mistral ?? "idle"} testMessage={testMessages.mistral} envKeySet={envInfo.mistral} + t={t} > <div className="grid gap-4 sm:grid-cols-2"> <div className="space-y-2"> - <Label htmlFor="mistral-key">Clé API Mistral</Label> + <Label htmlFor="mistral-key">{t("admin.settings.mistral.keyLabel")}</Label> <Input id="mistral-key" type="password" - placeholder={envInfo.mistral ? "Clé configurée dans .env (laisser vide pour l'utiliser)" : "Clé console.mistral.ai"} + placeholder={envPlaceholder(envInfo.mistral) || "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> + <Label htmlFor="mistral-model">{t("admin.settings.mistral.modelLabel")}</Label> <Input id="mistral-model" placeholder="mistral-ocr-latest" @@ -618,79 +674,49 @@ export default function AdminSettingsPage() { onChange={(e) => updateProvider("mistral", { model: e.target.value })} /> <p className="text-xs text-muted-foreground"> - Recommandé : <code>mistral-ocr-latest</code> + {t("admin.settings.mistral.modelHint")} </p> </div> </div> </ProviderCard> + </ProviderSection> - <Card> - <CardHeader> - <CardTitle className="text-base">Chaîne de fallback</CardTitle> - <CardDescription>Ordre de priorité pour la sélection des providers</CardDescription> - </CardHeader> - <CardContent className="space-y-4"> - <div className="space-y-2"> - <Label>Mode classique (Google)</Label> - <Input - value={config.fallback_chain_classic} - onChange={(e) => setConfig((prev) => ({ ...prev, fallback_chain_classic: e.target.value }))} - placeholder="google,google_cloud" - /> - </div> - <div className="space-y-2"> - <Label>Mode LLM (Ollama/OpenAI)</Label> - <Input - value={config.fallback_chain_llm} - onChange={(e) => setConfig((prev) => ({ ...prev, fallback_chain_llm: e.target.value }))} - placeholder="openrouter,openai,deepseek,zai" - /> - </div> - </CardContent> - </Card> - + {/* ── Emails ── */} + <ProviderSection + icon={<Mail className="size-4" />} + title={t("admin.settings.sectionEmails")} + description={t("admin.settings.sectionEmailsDesc")} + > <Card className={config.smtp.enabled ? "border-primary/30 overflow-visible" : "overflow-visible"}> <CardHeader className="pb-3"> <div className="flex items-center justify-between"> <div className="flex items-center gap-3"> - <div className="w-8 h-8 bg-blue-600/20 rounded-md flex items-center justify-center"> - <Mail className="w-4 h-4 text-blue-400" /> - </div> - <CardTitle className="text-base">Email SMTP</CardTitle> + <CardTitle className="text-base">{t("admin.settings.smtp.title")}</CardTitle> <Badge variant={config.smtp.enabled ? "default" : "secondary"} className="text-xs"> - {config.smtp.enabled ? "Activé" : "Désactivé"} + {config.smtp.enabled ? t("admin.settings.enabled") : t("admin.settings.disabled")} </Badge> {envInfo.smtp && ( <Badge variant="outline" className="text-xs gap-1 border-green-500/40 text-green-400"> <KeyRound className="size-3" /> - Config dans .env + {t("admin.settings.envBadge")} </Badge> )} </div> <div className="flex items-center gap-3"> - <Button - variant="outline" - size="sm" - onClick={() => testProvider("smtp")} - disabled={testResults.smtp === "testing"} - className="h-8" - > - {testResults.smtp === "testing" ? ( - <><Loader2 className="size-3 animate-spin me-1" />Test...</> - ) : testResults.smtp === "ok" ? ( - <><CheckCircle className="size-3 text-green-500 me-1" />OK</> - ) : testResults.smtp === "error" ? ( - <><XCircle className="size-3 text-red-500 me-1" />Erreur</> - ) : ( - <><FlaskConical className="size-3 me-1" />Tester</> - )} - </Button> + <TestButton + result={testResults.smtp ?? "idle"} + onTest={() => testProvider("smtp")} + labels={{ + test: t("admin.settings.test"), + testing: t("admin.settings.testing"), + ok: t("admin.settings.ok"), + error: t("admin.settings.error"), + }} + /> <Switch checked={config.smtp.enabled} onCheckedChange={(enabled) => updateSmtp({ enabled })} /> </div> </div> - <CardDescription> - Configuration du serveur SMTP pour l'envoi d'emails (mot de passe oublié, notifications, etc.) - </CardDescription> + <CardDescription>{t("admin.settings.smtp.desc")}</CardDescription> {testMessages.smtp && ( <p className={`text-xs mt-1 ${testResults.smtp === "ok" ? "text-green-400" : "text-red-400"}`}> {testMessages.smtp} @@ -700,16 +726,16 @@ export default function AdminSettingsPage() { <CardContent className="pt-0 space-y-4"> <div className="grid gap-4 sm:grid-cols-2"> <div className="space-y-2"> - <Label htmlFor="smtp-host">Hôte SMTP</Label> + <Label htmlFor="smtp-host">{t("admin.settings.smtp.host")}</Label> <Input id="smtp-host" - placeholder={envInfo.smtp ? "Configuré dans .env" : "smtp.example.com"} + placeholder={envInfo.smtp ? t("admin.settings.envConfigured") : "smtp.example.com"} value={config.smtp.host || ""} onChange={(e) => updateSmtp({ host: e.target.value })} /> </div> <div className="space-y-2"> - <Label htmlFor="smtp-port">Port</Label> + <Label htmlFor="smtp-port">{t("admin.settings.smtp.port")}</Label> <Input id="smtp-port" type="number" @@ -721,16 +747,16 @@ export default function AdminSettingsPage() { </div> <div className="grid gap-4 sm:grid-cols-2"> <div className="space-y-2"> - <Label htmlFor="smtp-username">Nom d'utilisateur</Label> + <Label htmlFor="smtp-username">{t("admin.settings.smtp.username")}</Label> <Input id="smtp-username" - placeholder={envInfo.smtp ? "Configuré dans .env" : "user@example.com"} + placeholder={envInfo.smtp ? t("admin.settings.envConfigured") : "user@example.com"} value={config.smtp.username || ""} onChange={(e) => updateSmtp({ username: e.target.value })} /> </div> <div className="space-y-2"> - <Label htmlFor="smtp-password">Mot de passe</Label> + <Label htmlFor="smtp-password">{t("admin.settings.smtp.password")}</Label> <Input id="smtp-password" type="password" @@ -742,11 +768,11 @@ export default function AdminSettingsPage() { </div> <div className="grid gap-4 sm:grid-cols-2"> <div className="space-y-2"> - <Label htmlFor="smtp-from">Adresse d'expédition</Label> + <Label htmlFor="smtp-from">{t("admin.settings.smtp.fromEmail")}</Label> <Input id="smtp-from" type="email" - placeholder={envInfo.smtp ? "Configuré dans .env" : "noreply@example.com"} + placeholder={envInfo.smtp ? t("admin.settings.envConfigured") : "noreply@example.com"} value={config.smtp.from_email || ""} onChange={(e) => updateSmtp({ from_email: e.target.value })} /> @@ -756,7 +782,7 @@ export default function AdminSettingsPage() { checked={config.smtp.use_tls} onCheckedChange={(checked) => updateSmtp({ use_tls: checked })} /> - <Label>Utiliser TLS</Label> + <Label>{t("admin.settings.smtp.useTls")}</Label> </div> </div> <div className="flex items-center gap-3 pt-2"> @@ -768,13 +794,13 @@ export default function AdminSettingsPage() { className="h-8" > {isSendingTestEmail ? ( - <><Loader2 className="size-3 animate-spin me-1" />Envoi...</> + <><Loader2 className="size-3 animate-spin me-1" />{t("admin.settings.sending")}</> ) : testEmailResult === "ok" ? ( - <><CheckCircle className="size-3 text-green-500 me-1" />Envoyé</> + <><CheckCircle className="size-3 text-green-500 me-1" />{t("admin.settings.smtp.sent")}</> ) : testEmailResult === "error" ? ( - <><XCircle className="size-3 text-red-500 me-1" />Échec</> + <><XCircle className="size-3 text-red-500 me-1" />{t("admin.settings.failure")}</> ) : ( - <><Mail className="size-3 me-1" />Envoyer un email de test</> + <><Mail className="size-3 me-1" />{t("admin.settings.smtp.sendTest")}</> )} </Button> {testEmailMessage && ( @@ -785,19 +811,48 @@ export default function AdminSettingsPage() { </div> </CardContent> </Card> - </div> + </ProviderSection> + + {/* ── Chaîne de secours ── */} + <ProviderSection + icon={<Network className="size-4" />} + title={t("admin.settings.fallback.title")} + description={t("admin.settings.fallback.desc")} + > + <Card> + <CardContent className="space-y-4"> + <div className="space-y-2"> + <Label>{t("admin.settings.fallback.classic")}</Label> + <Input + value={config.fallback_chain_classic} + onChange={(e) => setConfig((prev) => ({ ...prev, fallback_chain_classic: e.target.value }))} + placeholder="google,google_cloud" + /> + </div> + <div className="space-y-2"> + <Label>{t("admin.settings.fallback.llm")}</Label> + <Input + value={config.fallback_chain_llm} + onChange={(e) => setConfig((prev) => ({ ...prev, fallback_chain_llm: e.target.value }))} + placeholder="openrouter,openai,deepseek,zai" + /> + </div> + <p className="text-xs text-amber-500">{t("admin.settings.fallback.note")}</p> + </CardContent> + </Card> + </ProviderSection> <div className="flex justify-end"> <Button onClick={saveConfig} disabled={isSaving} size="lg"> {isSaving ? ( <> <Loader2 className="me-2 size-4 animate-spin" /> - Sauvegarde... + {t("admin.settings.saving")} </> ) : ( <> <Save className="me-2 size-4" /> - Sauvegarder la configuration + {t("admin.settings.save")} </> )} </Button> @@ -806,6 +861,32 @@ export default function AdminSettingsPage() { ); } +type TestState = "ok" | "error" | "testing" | "idle"; + +function TestButton({ + result, + onTest, + labels, +}: { + result: TestState; + onTest: () => void; + labels: { test: string; testing: string; ok: string; error: string }; +}) { + return ( + <Button variant="outline" size="sm" onClick={onTest} disabled={result === "testing"} className="h-8"> + {result === "testing" ? ( + <><Loader2 className="size-3 animate-spin me-1" />{labels.testing}</> + ) : result === "ok" ? ( + <><CheckCircle className="size-3 text-green-500 me-1" />{labels.ok}</> + ) : result === "error" ? ( + <><XCircle className="size-3 text-red-500 me-1" />{labels.error}</> + ) : ( + <><FlaskConical className="size-3 me-1" />{labels.test}</> + )} + </Button> + ); +} + function ProviderCard({ title, description, @@ -816,6 +897,7 @@ function ProviderCard({ testMessage, noApiKey = false, envKeySet = false, + t, children, }: { title: string; @@ -823,10 +905,11 @@ function ProviderCard({ enabled: boolean; onToggle: (enabled: boolean) => void; onTest: () => void; - testResult: "ok" | "error" | "testing" | "idle"; + testResult: TestState; testMessage?: string; noApiKey?: boolean; envKeySet?: boolean; + t: (key: string, params?: Record<string, string | number>) => string; children?: React.ReactNode; }) { return ( @@ -836,33 +919,26 @@ function ProviderCard({ <div className="flex items-center gap-3"> <CardTitle className="text-base">{title}</CardTitle> <Badge variant={enabled ? "default" : "secondary"} className="text-xs"> - {enabled ? "Activé" : "Désactivé"} + {enabled ? t("admin.settings.enabled") : t("admin.settings.disabled")} </Badge> {envKeySet && !noApiKey && ( <Badge variant="outline" className="text-xs gap-1 border-green-500/40 text-green-400"> <KeyRound className="size-3" /> - Clé dans .env + {t("admin.settings.envBadge")} </Badge> )} </div> <div className="flex items-center gap-3"> - <Button - variant="outline" - size="sm" - onClick={onTest} - disabled={testResult === "testing"} - className="h-8" - > - {testResult === "testing" ? ( - <><Loader2 className="size-3 animate-spin me-1" />Test...</> - ) : testResult === "ok" ? ( - <><CheckCircle className="size-3 text-green-500 me-1" />OK</> - ) : testResult === "error" ? ( - <><XCircle className="size-3 text-red-500 me-1" />Erreur</> - ) : ( - <><FlaskConical className="size-3 me-1" />Tester</> - )} - </Button> + <TestButton + result={testResult} + onTest={onTest} + labels={{ + test: t("admin.settings.test"), + testing: t("admin.settings.testing"), + ok: t("admin.settings.ok"), + error: t("admin.settings.error"), + }} + /> <Switch checked={enabled} onCheckedChange={onToggle} /> </div> </div> diff --git a/frontend/src/app/admin/types.ts b/frontend/src/app/admin/types.ts index e33b77f..77f07ec 100644 --- a/frontend/src/app/admin/types.ts +++ b/frontend/src/app/admin/types.ts @@ -81,11 +81,20 @@ export interface TranslationStatsResponse { /* ── Modèles & abonnements (paliers IA) ─────────────────────── */ +/** Route d'un palier : un modèle via un canal donné (multi-canaux). */ +export interface AiTierRoute { + provider: string; // openrouter | openrouter_premium | deepseek | zhipu | minimax | openai | zai + model: string; + label?: string; +} + export interface AiTierSettings { - /** Modèles actifs — l'ordre est la priorité de secours. */ + /** Ancien format : modèles OpenRouter (l'ordre est la priorité de secours). */ models: string[]; /** Modèle par défaut ; vide = premier de la liste = défaut du plan. */ default_model: string; + /** Routes multi-canaux {provider, model} — priment sur models/default_model. */ + routes: AiTierRoute[]; } export interface AiTiersSettings { @@ -165,3 +174,48 @@ export interface MarketingEmailSendResponse { }; meta: Record<string, unknown>; } + +/* ── Marketing : génération IA + réductions ─────────────────── */ + +export interface MarketingEmailGenerateRequest { + provider: string; + model?: string; + brief: string; + language?: string; + tone?: string; + promo_code?: string; +} + +export interface MarketingEmailGenerateResponse { + data: { + subject: string; + html: string; + provider: string; + model: string; + language: string; + }; + meta: Record<string, unknown>; +} + +/** Code promo (réduction) — miroir de data/promo_codes.json côté serveur. */ +export interface PromoCode { + code: string; + active: boolean; + type: "percent" | "amount"; + value: number; + plans: string[]; + max_redemptions: number | null; + times_used: number; + expires_at: string | null; + stripe_coupon_id: string | null; + stripe_promotion_code_id: string | null; + stripe_mode: "test" | "live" | "local_only"; + created_at: string; + deactivated_at: string | null; + last_used_at?: string; +} + +export interface PromoListResponse { + data: { promos: PromoCode[] }; + meta: Record<string, unknown>; +} diff --git a/frontend/src/app/pricing/page.tsx b/frontend/src/app/pricing/page.tsx index d57da4a..86a4181 100644 --- a/frontend/src/app/pricing/page.tsx +++ b/frontend/src/app/pricing/page.tsx @@ -11,6 +11,8 @@ import { } from "lucide-react"; 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 { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; @@ -275,7 +277,15 @@ export default function PricingPage() { const searchParams = useSearchParams(); const planFromUrl = searchParams.get("plan"); const billingFromUrl = searchParams.get("billing"); + const promoFromUrl = (searchParams.get("promo") || "").toUpperCase(); const [isYearly, setIsYearly] = useState(billingFromUrl === "yearly"); + /** Code promo (lu depuis ?promo=, modifiable dans la confirmation). */ + const [promoCode, setPromoCode] = useState(promoFromUrl); + /** Résultat de la vérification du code (GET /promos/validate). */ + const [promoStatus, setPromoStatus] = useState<{ + state: "idle" | "checking" | "valid" | "invalid"; + text?: string; + }>({ state: "idle" }); const [plans, setPlans] = useState<Plan[]>(STATIC_PLANS); const [credits, setCredits] = useState<CreditPackage[]>(STATIC_CREDITS); const [currentPlan, setCurrentPlan] = useState<string | null>(null); @@ -375,6 +385,7 @@ export default function PricingPage() { body: JSON.stringify({ plan: planId, billing_period: isYearly ? "yearly" : "monthly", + promo_code: promoCode.trim() || undefined, }), }); @@ -383,10 +394,15 @@ export default function PricingPage() { try { data = await res.json(); } catch { /* ignore */ } const url = data.data?.url ?? data.url; + const promoReason = data.promo_reason ?? data.data?.promo_reason; const backendMessage = data.message ?? data.error ?? data.data?.error; if (!res.ok) { - throw new Error(backendMessage || t('pricing.error.server', { status: res.status })); + // Refus d'un code promo : cause traduite si elle est connue. + const message = promoReason + ? (t(`pricing.promoError.${promoReason}`) || backendMessage) + : (backendMessage || t('pricing.error.server', { status: res.status })); + throw new Error(message); } if (url) { @@ -406,8 +422,39 @@ export default function PricingPage() { } }; - const handleBuyCredits = async (packageIndex: number) => { - const token = localStorage.getItem("token"); + /** Vérifie le code promo AVANT l'ouverture de Stripe (endpoint public). */ + const applyPromoCode = async () => { + const code = promoCode.trim(); + if (!code || !confirmPlan) return; + setPromoStatus({ state: "checking" }); + try { + const res = await fetch( + `${API_BASE}/api/v1/promos/validate?code=${encodeURIComponent(code)}&plan=${encodeURIComponent(confirmPlan.id)}`, + { cache: "no-store" } + ); + const json = await res.json().catch(() => null); + const d = json?.data; + if (d?.valid) { + const text = + typeof d.percent_off === "number" + ? `−${d.percent_off} %` + : typeof d.amount_off === "number" + ? `−${d.amount_off} €` + : t('pricing.promo.valid'); + setPromoStatus({ state: "valid", text }); + } else { + const reason = d?.reason_code; + setPromoStatus({ + state: "invalid", + text: (reason ? t(`pricing.promoError.${reason}`) : "") || d?.message || t('pricing.promoError.unknown'), + }); + } + } catch { + setPromoStatus({ state: "invalid", text: t('pricing.toast.networkError') }); + } + }; + + const handleBuyCredits = async (packageIndex: number) => { const token = localStorage.getItem("token"); if (!token) { router.push(`/auth/login?redirect=/pricing`); return; @@ -499,6 +546,41 @@ export default function PricingPage() { {t('pricing.confirm.monthlyEquivalent', { price: (confirmPlan.price_yearly / 12).toFixed(2) })} </p> )} + {/* Code promo : validé côté serveur avant l'ouverture de Stripe. */} + <div className="space-y-1.5"> + <Label htmlFor="promo-code-input">{t('pricing.promo.label')}</Label> + <div className="flex gap-2"> + <Input + id="promo-code-input" + className="uppercase" + placeholder={t('pricing.promo.placeholder')} + value={promoCode} + onChange={(e) => { + setPromoCode(e.target.value.toUpperCase()); + if (promoStatus.state !== "idle") setPromoStatus({ state: "idle" }); + }} + maxLength={24} + /> + <Button + variant="outline" + onClick={applyPromoCode} + disabled={!promoCode.trim() || promoStatus.state === "checking"} + > + {promoStatus.state === "checking" + ? t('pricing.promo.checking') + : t('pricing.promo.apply')} + </Button> + </div> + {promoStatus.state === "valid" && promoStatus.text && ( + <p className="text-xs font-medium text-emerald-600">{promoStatus.text}</p> + )} + {promoStatus.state === "invalid" && promoStatus.text && ( + <p className="text-xs text-red-500">{promoStatus.text}</p> + )} + <p className="text-xs text-muted-foreground"> + {t('pricing.promo.subscriptionOnly')} + </p> + </div> <p className="text-xs text-muted-foreground leading-relaxed"> {t('pricing.confirm.secureNote')} </p> diff --git a/frontend/src/lib/i18n/messages/ar/admin.json b/frontend/src/lib/i18n/messages/ar/admin.json index c9b830a..2c74ad5 100644 --- a/frontend/src/lib/i18n/messages/ar/admin.json +++ b/frontend/src/lib/i18n/messages/ar/admin.json @@ -145,5 +145,180 @@ "admin.stats.tiersSub": "أساسي / متميز · كلاسيكي {classic} · آخر {other}", "admin.stats.unavailable": "إحصائيات الأعمال غير متاحة ({error}).", "admin.stats.waitlist": "قائمة الانتظار", - "admin.stats.waitlistSub": "مُدرجون في قائمة الانتظار" + "admin.stats.waitlistSub": "مُدرجون في قائمة الانتظار", + "admin.settings.title": "المزودون", + "admin.settings.subtitle": "اضبط صلاحيات المزودين. يمكن أيضًا تعيين مفاتيح API في ملف .env.", + "admin.settings.sectionClassic": "الترجمة الكلاسيكية", + "admin.settings.sectionClassicDesc": "محركات Google للترجمة العادية.", + "admin.settings.sectionDirect": "مزودو الذكاء الاصطناعي المباشرون", + "admin.settings.sectionDirectDesc": "وصول مباشر متوافق مع OpenAI — يُستخدم كقناة لمستوى الذكاء الاصطناعي (صفحة «النماذج والاشتراكات») أو لتوليد الرسائل.", + "admin.settings.sectionGateway": "بوابات متعددة النماذج", + "admin.settings.sectionGatewayDesc": "مفتاح واحد ومئات النماذج. تُدار النماذج لكل مستوى من صفحة «النماذج والاشتراكات».", + "admin.settings.sectionOcr": "التعرف الضوئي (PDF ممسوحة)", + "admin.settings.sectionOcrDesc": "استخراج النص من صفحات الصور قبل الترجمة.", + "admin.settings.sectionEmails": "الرسائل", + "admin.settings.sectionEmailsDesc": "خادم الإرسال للإشعارات ورسائل المتابعة.", + "admin.settings.enabled": "مفعّل", + "admin.settings.disabled": "معطّل", + "admin.settings.envBadge": "المفتاح في .env", + "admin.settings.envConfigured": "مُعيَّن في .env", + "admin.settings.envKeyPlaceholder": "مفتاح معيَّن في .env (اتركه فارغًا لاستخدامه)", + "admin.settings.apiKey": "مفتاح API", + "admin.settings.model": "النموذج", + "admin.settings.baseUrl": "العنوان الأساسي", + "admin.settings.test": "اختبار", + "admin.settings.testing": "جارٍ الاختبار...", + "admin.settings.ok": "تم", + "admin.settings.error": "خطأ", + "admin.settings.failure": "فشل", + "admin.settings.sending": "جارٍ الإرسال...", + "admin.settings.save": "حفظ الإعدادات", + "admin.settings.saving": "جارٍ الحفظ...", + "admin.settings.savedTitle": "✅ تم حفظ الإعدادات", + "admin.settings.savedDesc": "تم حفظ الإعدادات بنجاح.", + "admin.settings.saveErrorTitle": "خطأ في الحفظ", + "admin.settings.loadErrorTitle": "خطأ في التحميل", + "admin.settings.loadErrorDesc": "HTTP {status} — تحقق من رمز المدير.", + "admin.settings.networkErrorTitle": "خطأ في الشبكة", + "admin.settings.networkErrorDesc": "لا يمكن الوصول إلى الخادم الخلفي.", + "admin.settings.networkSaveErrorDesc": "لا يمكن الوصول إلى الخادم الخلفي للحفظ.", + "admin.settings.connectionOk": "الاتصال ناجح", + "admin.settings.modelsFound": "تم العثور على {count} نماذج لـ {provider}", + "admin.settings.providerError": "خطأ {provider}", + "admin.settings.google.title": "Google Translate", + "admin.settings.google.desc": "وصول ويب غير رسمي عبر deep_translator. لا يحتاج مفتاحًا؛ يُنصح بالاستخدام المعقول.", + "admin.settings.googleCloud.title": "Google Cloud Translation (رسمي)", + "admin.settings.googleCloud.desc": "واجهة Google Cloud الرسمية. 500 ألف حرف/شهر مجانًا ثم نحو 20 $ لكل مليون حرف. مفتاح API من console.cloud.google.com.", + "admin.settings.googleCloud.hint": "فعّل Cloud Translation API في Google Cloud Console ثم أنشئ مفتاح API مقيَّدًا بهذه الواجهة.", + "admin.settings.deepseek.title": "DeepSeek (مباشر)", + "admin.settings.deepseek.desc": "واجهة DeepSeek المباشرة المتوافقة مع OpenAI. المفتاح من platform.deepseek.com — صالح كقناة لمستوى الذكاء الاصطناعي.", + "admin.settings.zhipu.title": "z.ai / Zhipu (GLM)", + "admin.settings.zhipu.desc": "وصول مباشر إلى نماذج GLM من z.ai، متوافق مع OpenAI. المفتاح من z.ai — صالح كقناة لمستوى الذكاء الاصطناعي.", + "admin.settings.zhipu.urlHint": "الافتراضي: https://api.z.ai/api/paas/v4 — غيّره فقط إذا كنت تستخدم وسيطًا.", + "admin.settings.minimax.title": "MiniMax (مباشر)", + "admin.settings.minimax.desc": "واجهة MiniMax العامة المتوافقة مع OpenAI. المفتاح من minimax.io — صالح كقناة لمستوى الذكاء الاصطناعي.", + "admin.settings.openai.title": "OpenAI", + "admin.settings.openai.desc": "ترجمات GPT. احصل على مفتاح من platform.openai.com.", + "admin.settings.zai.title": "xAI (Grok)", + "admin.settings.zai.desc": "نماذج Grok من xAI. واجهة متوافقة مع OpenAI. احصل على مفتاحك من x.ai.", + "admin.settings.zai.urlHint": "الافتراضي: https://api.x.ai/v1 — غيّره فقط إذا كنت تستخدم وسيطًا.", + "admin.settings.openrouter.title": "OpenRouter — مفتاح البوابة", + "admin.settings.openrouter.desc": "المستوى الأساسي (خطة Pro). المجموعة الحالية: deepseek/deepseek-v4-flash، z-ai/glm-5.3-flash، minimax/minimax-m3.", + "admin.settings.openrouter.keyLabel": "مفتاح OpenRouter API (مشترك مع Premium)", + "admin.settings.openrouter.envSharedPlaceholder": "مفتاح معيَّن في .env (مشترك مع Premium)", + "admin.settings.openrouter.reminder": "النماذج تُدار من صفحة «النماذج والاشتراكات».", + "admin.settings.openrouterPremium.title": "OpenRouter Premium — مفتاح البوابة", + "admin.settings.openrouterPremium.desc": "المستوى المتقدم (خطة Business). المجموعة الحالية: anthropic/claude-sonnet-5، deepseek/deepseek-v4-pro، z-ai/glm-5.3.", + "admin.settings.openrouterPremium.reminder": "النماذج تُدار من صفحة «النماذج والاشتراكات». يشارك نفس مفتاح OpenRouter.", + "admin.settings.mistral.title": "OCR Mistral (PDF ممسوحة)", + "admin.settings.mistral.desc": "يستخرج النص من ملفات PDF الممسوحة (صفحات الصور) قبل الترجمة. بدون مفتاح تُرفض هذه الملفات برسالة واضحة. نحو 1 € / 1000 صفحة. المفتاح: console.mistral.ai.", + "admin.settings.mistral.keyLabel": "مفتاح API الخاص بـ Mistral", + "admin.settings.mistral.modelLabel": "نموذج OCR", + "admin.settings.mistral.modelHint": "الموصى به: mistral-ocr-latest", + "admin.settings.smtp.title": "البريد عبر SMTP", + "admin.settings.smtp.desc": "إعدادات خادم SMTP لإرسال الرسائل (استعادة كلمة المرور، الإشعارات، رسائل المتابعة).", + "admin.settings.smtp.host": "خادم SMTP", + "admin.settings.smtp.port": "المنفذ", + "admin.settings.smtp.username": "اسم المستخدم", + "admin.settings.smtp.password": "كلمة المرور", + "admin.settings.smtp.fromEmail": "عنوان المرسِل", + "admin.settings.smtp.useTls": "استخدام TLS", + "admin.settings.smtp.sendTest": "إرسال رسالة تجريبية", + "admin.settings.smtp.sent": "أُرسلت", + "admin.settings.smtp.sendFailed": "فشل الإرسال", + "admin.models.channelOpenrouter": "OpenRouter", + "admin.models.channelOpenrouterPremium": "OpenRouter (Premium)", + "admin.models.channelDeepseek": "DeepSeek (مباشر)", + "admin.models.channelZhipu": "z.ai (GLM)", + "admin.models.channelMinimax": "MiniMax (مباشر)", + "admin.models.channelOpenai": "OpenAI", + "admin.models.channelZai": "xAI (Grok)", + "admin.models.customModelPlaceholder": "معرّف-النموذج", + "admin.models.routeHint": "القناة تختار الوصول المباشر أو البوابة؛ معرّفات النماذج تختلف من قناة إلى أخرى.", + "admin.models.addRouteConfirm": "إضافة", + "admin.marketing.generateTitle": "التوليد بالذكاء الاصطناعي", + "admin.marketing.generateDesc": "يُكتب النص استنادًا إلى الخطة التسويقية (التموضع والأرقام المسموح بها). لا يُرسل شيء تلقائيًا: راجع ثم أرسل.", + "admin.marketing.channel": "القناة", + "admin.marketing.model": "النموذج", + "admin.marketing.modelPlaceholder": "افتراضي القناة", + "admin.marketing.promoOptional": "خصم (اختياري)", + "admin.marketing.promoNone": "بدون", + "admin.marketing.brief": "الملخص", + "admin.marketing.briefPlaceholder": "مثال: متابعة المسجلين في قائمة الانتظار والتنبيه لمزايا خطة Pro.", + "admin.marketing.language": "لغة الرسالة", + "admin.marketing.tone": "النبرة", + "admin.marketing.toneNone": "محايدة", + "admin.marketing.generateBtn": "توليد", + "admin.marketing.generating": "جارٍ التوليد...", + "admin.marketing.generatedTitle": "تم توليد الرسالة", + "admin.marketing.generatedDesc": "أُدرج الموضوع وHTML في النموذج — راجعه قبل الإرسال ({provider}).", + "admin.marketing.generateFailedTitle": "تعذّر التوليد", + "admin.marketing.briefRequired": "صِف أولًا المحتوى المطلوب في الملخص.", + "admin.marketing.langFr": "الفرنسية", + "admin.marketing.langEn": "الإنجليزية", + "admin.marketing.langDe": "الألمانية", + "admin.marketing.langEs": "الإسبانية", + "admin.marketing.langIt": "الإيطالية", + "admin.marketing.langPt": "البرتغالية", + "admin.marketing.langNl": "الهولندية", + "admin.marketing.langAr": "العربية", + "admin.marketing.langFa": "الفارسية", + "admin.marketing.langJa": "اليابانية", + "admin.marketing.langKo": "الكورية", + "admin.marketing.langRu": "الروسية", + "admin.marketing.langZh": "الصينية", + "admin.marketing.toneFriendly": "ودّية", + "admin.marketing.toneProfessional": "مهنية", + "admin.marketing.toneUrgent": "عاجلة", + "admin.marketing.toneFestive": "احتفالية", + "admin.marketing.promoTitle": "الخصومات", + "admin.marketing.promoDesc": "أكواد الخصم تُطبَّق على الدفع عبر Stripe؛ يرتفع العداد بعد اكتمال الاشتراك فعليًا.", + "admin.marketing.promoEmpty": "لا توجد أكواد خصم بعد.", + "admin.marketing.promoActive": "نشط", + "admin.marketing.promoInactive": "معطّل", + "admin.marketing.promoLocalOnly": "خارج Stripe", + "admin.marketing.promoDeactivate": "تعطيل", + "admin.marketing.promoUsage": "{used}/{max} استخدامًا", + "admin.marketing.promoPlansLabel": "الخطط:", + "admin.marketing.promoAllPlans": "الكل", + "admin.marketing.promoExpiresOn": "تنتهي في {date}", + "admin.marketing.promoNeverExpires": "بدون انتهاء", + "admin.marketing.promoCreateTitle": "إنشاء خصم", + "admin.marketing.promoCreateDesc": "يُنشأ الكود أيضًا في Stripe (كوبون + كود ترويجي) عندما يكون Stripe مضبوطًا.", + "admin.marketing.promoCode": "الكود", + "admin.marketing.promoType": "النوع", + "admin.marketing.promoTypePercent": "نسبة مئوية", + "admin.marketing.promoTypeAmount": "مبلغ (EUR)", + "admin.marketing.promoValue": "القيمة", + "admin.marketing.promoPlans": "الخطط المشمولة", + "admin.marketing.promoMaxRedemptions": "حد الاستخدام", + "admin.marketing.promoMaxPlaceholder": "غير محدود", + "admin.marketing.promoExpires": "تاريخ الانتهاء", + "admin.marketing.promoCreate": "إنشاء الكود", + "admin.marketing.promoCreating": "جارٍ الإنشاء...", + "admin.marketing.promoCreatedTitle": "تم إنشاء كود الخصم", + "admin.marketing.promoCreatedDesc": "الكود {code} جاهز.", + "admin.marketing.promoCreateFailedTitle": "رُفض الإنشاء", + "admin.marketing.promoInvalidInput": "تحقق من الكود والقيمة.", + "admin.marketing.promoDeactivatedTitle": "تم تعطيل الكود", + "admin.marketing.promoDeactivatedDesc": "الكود {code} لم يعد صالحًا.", + "admin.settings.fallback.title": "سلسلة الاحتياط", + "admin.settings.fallback.desc": "ترتيب الأولوية للاختيار التلقائي للمزودين.", + "admin.settings.fallback.classic": "الوضع الكلاسيكي (Google)", + "admin.settings.fallback.llm": "وضع LLM (البوابات والوصول المباشر)", + "admin.settings.fallback.note": "القنوات المسجلة فقط (google وopenai وdeepseek وminimax) تشارك في هذه السلسلة؛ مستويات الذكاء الاصطناعي والقنوات الأخرى لا تشارك.", + "admin.models.duplicateRouteTitle": "رُفض التكرار", + "admin.models.duplicateRouteDesc": "هذا الثنائي قناة + نموذج موجود في القائمة بالفعل.", + "admin.marketing.promoDeactivateFailedTitle": "رُفض التعطيل", + "admin.marketing.promoDelete": "حذف", + "admin.marketing.promoDeleteConfirm": "حذف الكود {code} نهائيًا؟", + "admin.marketing.promoDeletedTitle": "تم حذف الكود", + "admin.marketing.promoDeletedDesc": "تم حذف الكود {code}.", + "admin.marketing.promoDeleteFailedTitle": "رُفض الحذف", + "admin.marketing.promoLinkStripe": "ربط بـ Stripe", + "admin.marketing.promoLinkedTitle": "تم ربط الكود بـ Stripe", + "admin.marketing.promoLinkedDesc": "الكود {code} صار قابلًا للتطبيق على الدفع.", + "admin.marketing.promoLinkFailedTitle": "تعذّر الربط", + "admin.marketing.promoExpiryHint": "الكود «حتى العاشر» يبقى صالحًا طوال اليوم العاشر (حتى 23:59:59 بالتوقيت العالمي).", + "admin.marketing.generateEmpty": "كان رد الخادم فارغًا أو غير قابل للاستخدام." } diff --git a/frontend/src/lib/i18n/messages/ar/pricing.json b/frontend/src/lib/i18n/messages/ar/pricing.json index fbb0739..c0ddf57 100644 --- a/frontend/src/lib/i18n/messages/ar/pricing.json +++ b/frontend/src/lib/i18n/messages/ar/pricing.json @@ -167,5 +167,17 @@ "pricing.header.titleBase": "خطة لكل", "pricing.header.titleAccent": "احتياج", "pricing.toast.close": "إغلاق", - "pricing.aiModels.premium.price": "2 $ / 10 $ لكل مليون رمز" + "pricing.aiModels.premium.price": "2 $ / 10 $ لكل مليون رمز", + "pricing.promo.label": "كود الخصم", + "pricing.promo.placeholder": "كودك", + "pricing.promo.apply": "تطبيق", + "pricing.promo.checking": "جارٍ التحقق...", + "pricing.promo.valid": "كود صالح", + "pricing.promo.subscriptionOnly": "صالح على الاشتراكات، لا على الرصيد.", + "pricing.promoError.unknown": "كود الخصم هذا غير موجود.", + "pricing.promoError.inactive": "كود الخصم هذا لم يعد نشطًا.", + "pricing.promoError.expired": "انتهت صلاحية كود الخصم هذا.", + "pricing.promoError.exhausted": "بلغ كود الخصم هذا حده الأقصى للاستخدام.", + "pricing.promoError.plan": "كود الخصم هذا لا ينطبق على هذه الخطة.", + "pricing.promoError.local_only": "كود الخصم هذا غير مرتبط بالدفع عبر الإنترنت." } diff --git a/frontend/src/lib/i18n/messages/de/admin.json b/frontend/src/lib/i18n/messages/de/admin.json index c499253..5e1d970 100644 --- a/frontend/src/lib/i18n/messages/de/admin.json +++ b/frontend/src/lib/i18n/messages/de/admin.json @@ -145,5 +145,180 @@ "admin.stats.tiersSub": "Basis / Premium · klassisch {classic} · sonstige {other}", "admin.stats.unavailable": "Geschäftszahlen nicht verfügbar ({error}).", "admin.stats.waitlist": "Warteliste", - "admin.stats.waitlistSub": "Einträge auf der Warteliste" + "admin.stats.waitlistSub": "Einträge auf der Warteliste", + "admin.settings.title": "Anbieter", + "admin.settings.subtitle": "Anbieterzugänge konfigurieren. API-Schlüssel können auch in der .env-Datei hinterlegt werden.", + "admin.settings.sectionClassic": "Klassische Übersetzung", + "admin.settings.sectionClassicDesc": "Google-Maschinen für die Standardübersetzung.", + "admin.settings.sectionDirect": "Direkte KI-Anbieter", + "admin.settings.sectionDirectDesc": "Direkte OpenAI-kompatible Zugänge — als Kanal einer KI-Stufe (Seite „Modelle & Abos“) oder zum Erstellen von E-Mails nutzbar.", + "admin.settings.sectionGateway": "Multi-Modell-Gateways", + "admin.settings.sectionGatewayDesc": "Ein Schlüssel, hunderte Modelle. Die Modelle pro Stufe werden auf der Seite „Modelle & Abos“ verwaltet.", + "admin.settings.sectionOcr": "OCR (gescannte PDFs)", + "admin.settings.sectionOcrDesc": "Text aus Bildseiten vor der Übersetzung erfassen.", + "admin.settings.sectionEmails": "E-Mails", + "admin.settings.sectionEmailsDesc": "Versandserver für Benachrichtigungen und Erinnerungen.", + "admin.settings.enabled": "Aktiviert", + "admin.settings.disabled": "Deaktiviert", + "admin.settings.envBadge": "Schlüssel in .env", + "admin.settings.envConfigured": "In .env konfiguriert", + "admin.settings.envKeyPlaceholder": "Schlüssel in .env konfiguriert (leer lassen, um ihn zu nutzen)", + "admin.settings.apiKey": "API-Schlüssel", + "admin.settings.model": "Modell", + "admin.settings.baseUrl": "Basis-URL", + "admin.settings.test": "Testen", + "admin.settings.testing": "Test...", + "admin.settings.ok": "OK", + "admin.settings.error": "Fehler", + "admin.settings.failure": "Fehlgeschlagen", + "admin.settings.sending": "Wird gesendet...", + "admin.settings.save": "Konfiguration speichern", + "admin.settings.saving": "Speichern...", + "admin.settings.savedTitle": "✅ Konfiguration gespeichert", + "admin.settings.savedDesc": "Die Einstellungen wurden erfolgreich gespeichert.", + "admin.settings.saveErrorTitle": "Fehler beim Speichern", + "admin.settings.loadErrorTitle": "Fehler beim Laden", + "admin.settings.loadErrorDesc": "HTTP {status} — prüfen Sie Ihren Admin-Token.", + "admin.settings.networkErrorTitle": "Netzwerkfehler", + "admin.settings.networkErrorDesc": "Backend nicht erreichbar.", + "admin.settings.networkSaveErrorDesc": "Backend zum Speichern nicht erreichbar.", + "admin.settings.connectionOk": "Verbindung OK", + "admin.settings.modelsFound": "{count} {provider}-Modelle gefunden", + "admin.settings.providerError": "Fehler {provider}", + "admin.settings.google.title": "Google Translate", + "admin.settings.google.desc": "Inoffizieller Webzugriff über deep_translator. Kein Schlüssel nötig, maßvolle Nutzung empfohlen.", + "admin.settings.googleCloud.title": "Google Cloud Translation (offiziell)", + "admin.settings.googleCloud.desc": "Offizielle Google-Cloud-API. 500.000 Zeichen/Monat kostenlos, danach ca. 20 $ pro Million Zeichen. API-Schlüssel auf console.cloud.google.com.", + "admin.settings.googleCloud.hint": "Aktivieren Sie die Cloud Translation API in der Google Cloud Console und erstellen Sie einen darauf beschränkten API-Schlüssel.", + "admin.settings.deepseek.title": "DeepSeek (direkt)", + "admin.settings.deepseek.desc": "Direkte DeepSeek-API, OpenAI-kompatibel. Schlüssel auf platform.deepseek.com — als Kanal einer KI-Stufe nutzbar.", + "admin.settings.zhipu.title": "z.ai / Zhipu (GLM)", + "admin.settings.zhipu.desc": "Direkter Zugriff auf die GLM-Modelle von z.ai, OpenAI-kompatibel. Schlüssel auf z.ai — als Kanal einer KI-Stufe nutzbar.", + "admin.settings.zhipu.urlHint": "Standard: https://api.z.ai/api/paas/v4 — nur bei Nutzung eines Proxys ändern.", + "admin.settings.minimax.title": "MiniMax (direkt)", + "admin.settings.minimax.desc": "Öffentliche MiniMax-API, OpenAI-kompatibel. Schlüssel auf minimax.io — als Kanal einer KI-Stufe nutzbar.", + "admin.settings.openai.title": "OpenAI", + "admin.settings.openai.desc": "GPT-Übersetzungen. Schlüssel auf platform.openai.com erhalten.", + "admin.settings.zai.title": "xAI (Grok)", + "admin.settings.zai.desc": "Grok-Modelle von xAI. OpenAI-kompatible API. Schlüssel auf x.ai erhalten.", + "admin.settings.zai.urlHint": "Standard: https://api.x.ai/v1 — nur bei Nutzung eines Proxys ändern.", + "admin.settings.openrouter.title": "OpenRouter — Gateway-Schlüssel", + "admin.settings.openrouter.desc": "Essential-Stufe (Pro-Abo). Aktuelle Auswahl: deepseek/deepseek-v4-flash, z-ai/glm-5.3-flash, minimax/minimax-m3.", + "admin.settings.openrouter.keyLabel": "OpenRouter-API-Schlüssel (mit Premium geteilt)", + "admin.settings.openrouter.envSharedPlaceholder": "Schlüssel in .env konfiguriert (mit Premium geteilt)", + "admin.settings.openrouter.reminder": "Modelle werden auf der Seite „Modelle & Abos“ verwaltet.", + "admin.settings.openrouterPremium.title": "OpenRouter Premium — Gateway-Schlüssel", + "admin.settings.openrouterPremium.desc": "Premium-Stufe (Business-Abo). Aktuelle Auswahl: anthropic/claude-sonnet-5, deepseek/deepseek-v4-pro, z-ai/glm-5.3.", + "admin.settings.openrouterPremium.reminder": "Modelle werden auf der Seite „Modelle & Abos“ verwaltet. Nutzt denselben OpenRouter-Schlüssel.", + "admin.settings.mistral.title": "Mistral OCR (gescannte PDFs)", + "admin.settings.mistral.desc": "Extrahiert Text aus gescannten PDFs (Bildseiten) vor der Übersetzung. Ohne Schlüssel werden diese Dateien mit klarer Meldung abgelehnt. Ca. 1 € / 1.000 Seiten. Schlüssel: console.mistral.ai.", + "admin.settings.mistral.keyLabel": "Mistral-API-Schlüssel", + "admin.settings.mistral.modelLabel": "OCR-Modell", + "admin.settings.mistral.modelHint": "Empfohlen: mistral-ocr-latest", + "admin.settings.smtp.title": "SMTP-E-Mail", + "admin.settings.smtp.desc": "SMTP-Servereinstellungen für den E-Mail-Versand (Passwort vergessen, Benachrichtigungen, Erinnerungen).", + "admin.settings.smtp.host": "SMTP-Host", + "admin.settings.smtp.port": "Port", + "admin.settings.smtp.username": "Benutzername", + "admin.settings.smtp.password": "Passwort", + "admin.settings.smtp.fromEmail": "Absenderadresse", + "admin.settings.smtp.useTls": "TLS verwenden", + "admin.settings.smtp.sendTest": "Test-E-Mail senden", + "admin.settings.smtp.sent": "Gesendet", + "admin.settings.smtp.sendFailed": "Senden fehlgeschlagen", + "admin.models.channelOpenrouter": "OpenRouter", + "admin.models.channelOpenrouterPremium": "OpenRouter (Premium)", + "admin.models.channelDeepseek": "DeepSeek (direkt)", + "admin.models.channelZhipu": "z.ai (GLM)", + "admin.models.channelMinimax": "MiniMax (direkt)", + "admin.models.channelOpenai": "OpenAI", + "admin.models.channelZai": "xAI (Grok)", + "admin.models.customModelPlaceholder": "modell-id", + "admin.models.routeHint": "Der Kanal wählt direkten Zugang oder Gateway; Modell-IDs unterscheiden sich je Kanal.", + "admin.models.addRouteConfirm": "Hinzufügen", + "admin.marketing.generateTitle": "Mit KI erstellen", + "admin.marketing.generateDesc": "Der Text wird anhand des Marketingplans verfasst (Positionierung und zugelassene Zahlen). Nichts wird automatisch gesendet: erst prüfen, dann senden.", + "admin.marketing.channel": "Kanal", + "admin.marketing.model": "Modell", + "admin.marketing.modelPlaceholder": "Kanal-Standard", + "admin.marketing.promoOptional": "Rabatt (optional)", + "admin.marketing.promoNone": "Keine", + "admin.marketing.brief": "Briefing", + "admin.marketing.briefPlaceholder": "Z. B.: Wartelisten-Anmeldungen an die Vorteile des Pro-Abos erinnern.", + "admin.marketing.language": "E-Mail-Sprache", + "admin.marketing.tone": "Tonalität", + "admin.marketing.toneNone": "Neutral", + "admin.marketing.generateBtn": "Erstellen", + "admin.marketing.generating": "Wird erstellt...", + "admin.marketing.generatedTitle": "E-Mail erstellt", + "admin.marketing.generatedDesc": "Betreff und HTML ins Formular eingefügt — vor dem Versand prüfen ({provider}).", + "admin.marketing.generateFailedTitle": "Erstellung fehlgeschlagen", + "admin.marketing.briefRequired": "Beschreiben Sie zuerst den gewünschten Inhalt im Briefing.", + "admin.marketing.langFr": "Französisch", + "admin.marketing.langEn": "Englisch", + "admin.marketing.langDe": "Deutsch", + "admin.marketing.langEs": "Spanisch", + "admin.marketing.langIt": "Italienisch", + "admin.marketing.langPt": "Portugiesisch", + "admin.marketing.langNl": "Niederländisch", + "admin.marketing.langAr": "Arabisch", + "admin.marketing.langFa": "Persisch", + "admin.marketing.langJa": "Japanisch", + "admin.marketing.langKo": "Koreanisch", + "admin.marketing.langRu": "Russisch", + "admin.marketing.langZh": "Chinesisch", + "admin.marketing.toneFriendly": "Freundlich", + "admin.marketing.toneProfessional": "Professionell", + "admin.marketing.toneUrgent": "Dringend", + "admin.marketing.toneFestive": "Festlich", + "admin.marketing.promoTitle": "Rabatte", + "admin.marketing.promoDesc": "Promo-Codes werden auf Stripe-Zahlungen angewendet; der Zähler steigt erst, wenn das Abo wirklich abgeschlossen ist.", + "admin.marketing.promoEmpty": "Noch keine Promo-Codes.", + "admin.marketing.promoActive": "Aktiv", + "admin.marketing.promoInactive": "Deaktiviert", + "admin.marketing.promoLocalOnly": "Ohne Stripe", + "admin.marketing.promoDeactivate": "Deaktivieren", + "admin.marketing.promoUsage": "{used}/{max} Einlösungen", + "admin.marketing.promoPlansLabel": "Abos:", + "admin.marketing.promoAllPlans": "Alle", + "admin.marketing.promoExpiresOn": "läuft am {date} ab", + "admin.marketing.promoNeverExpires": "ohne Ablauf", + "admin.marketing.promoCreateTitle": "Rabatt erstellen", + "admin.marketing.promoCreateDesc": "Der Code wird auch in Stripe angelegt (Coupon + Promo-Code), wenn Stripe konfiguriert ist.", + "admin.marketing.promoCode": "Code", + "admin.marketing.promoType": "Typ", + "admin.marketing.promoTypePercent": "Prozent", + "admin.marketing.promoTypeAmount": "Betrag (EUR)", + "admin.marketing.promoValue": "Wert", + "admin.marketing.promoPlans": "Berechtigte Abos", + "admin.marketing.promoMaxRedemptions": "Einlöse-Limit", + "admin.marketing.promoMaxPlaceholder": "Unbegrenzt", + "admin.marketing.promoExpires": "Ablauf", + "admin.marketing.promoCreate": "Code erstellen", + "admin.marketing.promoCreating": "Wird erstellt...", + "admin.marketing.promoCreatedTitle": "Promo-Code erstellt", + "admin.marketing.promoCreatedDesc": "Der Code {code} ist bereit.", + "admin.marketing.promoCreateFailedTitle": "Erstellung abgelehnt", + "admin.marketing.promoInvalidInput": "Prüfen Sie Code und Wert.", + "admin.marketing.promoDeactivatedTitle": "Code deaktiviert", + "admin.marketing.promoDeactivatedDesc": "Der Code {code} gilt nicht mehr.", + "admin.settings.fallback.title": "Fallback-Kette", + "admin.settings.fallback.desc": "Prioritätsreihenfolge für die automatische Anbieterauswahl.", + "admin.settings.fallback.classic": "Klassischer Modus (Google)", + "admin.settings.fallback.llm": "LLM-Modus (Gateways und direkte Zugänge)", + "admin.settings.fallback.note": "Nur registrierte Kanäle (google, openai, deepseek, minimax) nehmen an dieser Kette teil; KI-Stufen und andere Kanäle nicht.", + "admin.models.duplicateRouteTitle": "Duplikat abgelehnt", + "admin.models.duplicateRouteDesc": "Dieses Kanal-+-Modell-Paar steht bereits in der Liste.", + "admin.marketing.promoDeactivateFailedTitle": "Deaktivierung abgelehnt", + "admin.marketing.promoDelete": "Löschen", + "admin.marketing.promoDeleteConfirm": "Code {code} endgültig löschen?", + "admin.marketing.promoDeletedTitle": "Code gelöscht", + "admin.marketing.promoDeletedDesc": "Der Code {code} wurde gelöscht.", + "admin.marketing.promoDeleteFailedTitle": "Löschen abgelehnt", + "admin.marketing.promoLinkStripe": "Mit Stripe verknüpfen", + "admin.marketing.promoLinkedTitle": "Code mit Stripe verknüpft", + "admin.marketing.promoLinkedDesc": "Der Code {code} ist jetzt beim Bezahlvorgang anwendbar.", + "admin.marketing.promoLinkFailedTitle": "Verknüpfung fehlgeschlagen", + "admin.marketing.promoExpiryHint": "Ein Code „bis zum 10.“ gilt den ganzen 10. (Ende: 23:59:59 UTC).", + "admin.marketing.generateEmpty": "Die Antwort des Servers war leer oder unbrauchbar." } diff --git a/frontend/src/lib/i18n/messages/de/pricing.json b/frontend/src/lib/i18n/messages/de/pricing.json index 8ff2e3e..5a9dbf1 100644 --- a/frontend/src/lib/i18n/messages/de/pricing.json +++ b/frontend/src/lib/i18n/messages/de/pricing.json @@ -167,5 +167,17 @@ "pricing.header.titleBase": "Ein Plan für", "pricing.header.titleAccent": "jeden Bedarf", "pricing.toast.close": "Schließen", - "pricing.aiModels.premium.price": "2 $ / 10 $ pro 1M Token" + "pricing.aiModels.premium.price": "2 $ / 10 $ pro 1M Token", + "pricing.promo.label": "Promo-Code", + "pricing.promo.placeholder": "DEINCODE", + "pricing.promo.apply": "Anwenden", + "pricing.promo.checking": "Prüfung...", + "pricing.promo.valid": "Code gültig", + "pricing.promo.subscriptionOnly": "Nur für Abos gültig, nicht für Credits.", + "pricing.promoError.unknown": "Dieser Promo-Code existiert nicht.", + "pricing.promoError.inactive": "Dieser Promo-Code ist nicht mehr aktiv.", + "pricing.promoError.expired": "Dieser Promo-Code ist abgelaufen.", + "pricing.promoError.exhausted": "Dieser Promo-Code hat sein Einlöse-Limit erreicht.", + "pricing.promoError.plan": "Dieser Promo-Code gilt nicht für dieses Abo.", + "pricing.promoError.local_only": "Dieser Promo-Code ist nicht für die Online-Zahlung freigeschaltet." } diff --git a/frontend/src/lib/i18n/messages/en/admin.json b/frontend/src/lib/i18n/messages/en/admin.json index abacd23..c1ab8db 100644 --- a/frontend/src/lib/i18n/messages/en/admin.json +++ b/frontend/src/lib/i18n/messages/en/admin.json @@ -145,5 +145,180 @@ "admin.stats.tiersSub": "Essential / Premium · classic {classic} · other {other}", "admin.stats.unavailable": "Business statistics unavailable ({error}).", "admin.stats.waitlist": "Waitlist", - "admin.stats.waitlistSub": "people waiting" + "admin.stats.waitlistSub": "people waiting", + "admin.settings.title": "Providers", + "admin.settings.subtitle": "Configure provider access. API keys can also be set in the .env file.", + "admin.settings.sectionClassic": "Classic translation", + "admin.settings.sectionClassicDesc": "Google engines for standard translation.", + "admin.settings.sectionDirect": "Direct AI providers", + "admin.settings.sectionDirectDesc": "Direct OpenAI-compatible endpoints — usable as an AI tier channel (see the “Models & subscriptions” page) or for email generation.", + "admin.settings.sectionGateway": "Multi-model gateways", + "admin.settings.sectionGatewayDesc": "One key, hundreds of models. Per-tier models are managed on the “Models & subscriptions” page.", + "admin.settings.sectionOcr": "OCR (scanned PDFs)", + "admin.settings.sectionOcrDesc": "Extract text from image-only pages before translation.", + "admin.settings.sectionEmails": "Emails", + "admin.settings.sectionEmailsDesc": "Sending server for notifications and follow-up emails.", + "admin.settings.enabled": "Enabled", + "admin.settings.disabled": "Disabled", + "admin.settings.envBadge": "Key in .env", + "admin.settings.envConfigured": "Configured in .env", + "admin.settings.envKeyPlaceholder": "Key configured in .env (leave empty to use it)", + "admin.settings.apiKey": "API key", + "admin.settings.model": "Model", + "admin.settings.baseUrl": "Base URL", + "admin.settings.test": "Test", + "admin.settings.testing": "Testing...", + "admin.settings.ok": "OK", + "admin.settings.error": "Error", + "admin.settings.failure": "Failed", + "admin.settings.sending": "Sending...", + "admin.settings.save": "Save configuration", + "admin.settings.saving": "Saving...", + "admin.settings.savedTitle": "✅ Configuration saved", + "admin.settings.savedDesc": "Your settings have been saved successfully.", + "admin.settings.saveErrorTitle": "Save failed", + "admin.settings.loadErrorTitle": "Loading error", + "admin.settings.loadErrorDesc": "HTTP {status} — check your admin token.", + "admin.settings.networkErrorTitle": "Network error", + "admin.settings.networkErrorDesc": "Cannot reach the backend.", + "admin.settings.networkSaveErrorDesc": "Cannot reach the backend to save.", + "admin.settings.connectionOk": "Connection OK", + "admin.settings.modelsFound": "{count} {provider} models found", + "admin.settings.providerError": "{provider} error", + "admin.settings.google.title": "Google Translate", + "admin.settings.google.desc": "Unofficial web access via deep_translator. No key required, reasonable use recommended.", + "admin.settings.googleCloud.title": "Google Cloud Translation (official)", + "admin.settings.googleCloud.desc": "Official Google Cloud API. 500,000 characters/month free, then about $20 per million characters. API key from console.cloud.google.com.", + "admin.settings.googleCloud.hint": "Enable the Cloud Translation API in Google Cloud Console, then create an API key restricted to that API.", + "admin.settings.deepseek.title": "DeepSeek (direct)", + "admin.settings.deepseek.desc": "Direct DeepSeek API, OpenAI-compatible. Key from platform.deepseek.com — usable as an AI tier channel.", + "admin.settings.zhipu.title": "z.ai / Zhipu (GLM)", + "admin.settings.zhipu.desc": "Direct access to z.ai GLM models, OpenAI-compatible. Key from z.ai — usable as an AI tier channel.", + "admin.settings.zhipu.urlHint": "Default: https://api.z.ai/api/paas/v4 — change only if you use a proxy.", + "admin.settings.minimax.title": "MiniMax (direct)", + "admin.settings.minimax.desc": "Public MiniMax API, OpenAI-compatible. Key from minimax.io — usable as an AI tier channel.", + "admin.settings.openai.title": "OpenAI", + "admin.settings.openai.desc": "GPT translations. Get a key at platform.openai.com.", + "admin.settings.zai.title": "xAI (Grok)", + "admin.settings.zai.desc": "Grok models by xAI. OpenAI-compatible API. Get your key at x.ai.", + "admin.settings.zai.urlHint": "Default: https://api.x.ai/v1 — change only if you use a proxy.", + "admin.settings.openrouter.title": "OpenRouter — gateway key", + "admin.settings.openrouter.desc": "Essential tier (Pro plan). Current range: deepseek/deepseek-v4-flash, z-ai/glm-5.3-flash, minimax/minimax-m3.", + "admin.settings.openrouter.keyLabel": "OpenRouter API key (shared with Premium)", + "admin.settings.openrouter.envSharedPlaceholder": "Key configured in .env (shared with Premium)", + "admin.settings.openrouter.reminder": "Models are managed on the “Models & subscriptions” page.", + "admin.settings.openrouterPremium.title": "OpenRouter Premium — gateway key", + "admin.settings.openrouterPremium.desc": "Premium tier (Business plan). Current range: anthropic/claude-sonnet-5, deepseek/deepseek-v4-pro, z-ai/glm-5.3.", + "admin.settings.openrouterPremium.reminder": "Models are managed on the “Models & subscriptions” page. Shares the same OpenRouter key.", + "admin.settings.mistral.title": "Mistral OCR (scanned PDFs)", + "admin.settings.mistral.desc": "Extracts text from scanned PDFs (image pages) before translation. Without a key, such files are rejected with a clear message. About €1 / 1,000 pages. Key: console.mistral.ai.", + "admin.settings.mistral.keyLabel": "Mistral API key", + "admin.settings.mistral.modelLabel": "OCR model", + "admin.settings.mistral.modelHint": "Recommended: mistral-ocr-latest", + "admin.settings.smtp.title": "SMTP email", + "admin.settings.smtp.desc": "SMTP server settings for sending emails (password reset, notifications, follow-ups).", + "admin.settings.smtp.host": "SMTP host", + "admin.settings.smtp.port": "Port", + "admin.settings.smtp.username": "Username", + "admin.settings.smtp.password": "Password", + "admin.settings.smtp.fromEmail": "From address", + "admin.settings.smtp.useTls": "Use TLS", + "admin.settings.smtp.sendTest": "Send a test email", + "admin.settings.smtp.sent": "Sent", + "admin.settings.smtp.sendFailed": "Send failed", + "admin.models.channelOpenrouter": "OpenRouter", + "admin.models.channelOpenrouterPremium": "OpenRouter (Premium)", + "admin.models.channelDeepseek": "DeepSeek (direct)", + "admin.models.channelZhipu": "z.ai (GLM)", + "admin.models.channelMinimax": "MiniMax (direct)", + "admin.models.channelOpenai": "OpenAI", + "admin.models.channelZai": "xAI (Grok)", + "admin.models.customModelPlaceholder": "model-id", + "admin.models.routeHint": "The channel picks a direct access or the gateway; model IDs differ from one channel to another.", + "admin.models.addRouteConfirm": "Add", + "admin.marketing.generateTitle": "Generate with AI", + "admin.marketing.generateDesc": "The copy is written from the marketing plan (positioning and approved figures). Nothing is sent automatically: review, then send.", + "admin.marketing.channel": "Channel", + "admin.marketing.model": "Model", + "admin.marketing.modelPlaceholder": "Channel default", + "admin.marketing.promoOptional": "Discount (optional)", + "admin.marketing.promoNone": "None", + "admin.marketing.brief": "Brief", + "admin.marketing.briefPlaceholder": "E.g.: follow up with waitlist sign-ups, reminding them of the Pro plan benefits.", + "admin.marketing.language": "Email language", + "admin.marketing.tone": "Tone", + "admin.marketing.toneNone": "Neutral", + "admin.marketing.generateBtn": "Generate", + "admin.marketing.generating": "Generating...", + "admin.marketing.generatedTitle": "Email generated", + "admin.marketing.generatedDesc": "Subject and HTML inserted into the form — review before sending ({provider}).", + "admin.marketing.generateFailedTitle": "Generation failed", + "admin.marketing.briefRequired": "Describe the desired content in the brief first.", + "admin.marketing.langFr": "French", + "admin.marketing.langEn": "English", + "admin.marketing.langDe": "German", + "admin.marketing.langEs": "Spanish", + "admin.marketing.langIt": "Italian", + "admin.marketing.langPt": "Portuguese", + "admin.marketing.langNl": "Dutch", + "admin.marketing.langAr": "Arabic", + "admin.marketing.langFa": "Persian", + "admin.marketing.langJa": "Japanese", + "admin.marketing.langKo": "Korean", + "admin.marketing.langRu": "Russian", + "admin.marketing.langZh": "Chinese", + "admin.marketing.toneFriendly": "Friendly", + "admin.marketing.toneProfessional": "Professional", + "admin.marketing.toneUrgent": "Urgent", + "admin.marketing.toneFestive": "Festive", + "admin.marketing.promoTitle": "Discounts", + "admin.marketing.promoDesc": "Promo codes applied to Stripe payments; the counter increases once the subscription is actually completed.", + "admin.marketing.promoEmpty": "No promo codes yet.", + "admin.marketing.promoActive": "Active", + "admin.marketing.promoInactive": "Deactivated", + "admin.marketing.promoLocalOnly": "Outside Stripe", + "admin.marketing.promoDeactivate": "Deactivate", + "admin.marketing.promoUsage": "{used}/{max} redemptions", + "admin.marketing.promoPlansLabel": "Plans:", + "admin.marketing.promoAllPlans": "All", + "admin.marketing.promoExpiresOn": "expires {date}", + "admin.marketing.promoNeverExpires": "no expiry", + "admin.marketing.promoCreateTitle": "Create a discount", + "admin.marketing.promoCreateDesc": "The code is also created in Stripe (coupon + promotion code) when Stripe is configured.", + "admin.marketing.promoCode": "Code", + "admin.marketing.promoType": "Type", + "admin.marketing.promoTypePercent": "Percentage", + "admin.marketing.promoTypeAmount": "Amount (EUR)", + "admin.marketing.promoValue": "Value", + "admin.marketing.promoPlans": "Eligible plans", + "admin.marketing.promoMaxRedemptions": "Redemption limit", + "admin.marketing.promoMaxPlaceholder": "Unlimited", + "admin.marketing.promoExpires": "Expiry", + "admin.marketing.promoCreate": "Create code", + "admin.marketing.promoCreating": "Creating...", + "admin.marketing.promoCreatedTitle": "Promo code created", + "admin.marketing.promoCreatedDesc": "Code {code} is ready.", + "admin.marketing.promoCreateFailedTitle": "Creation refused", + "admin.marketing.promoInvalidInput": "Check the code and the value.", + "admin.marketing.promoDeactivatedTitle": "Code deactivated", + "admin.marketing.promoDeactivatedDesc": "Code {code} no longer applies.", + "admin.settings.fallback.title": "Fallback chain", + "admin.settings.fallback.desc": "Priority order for automatic provider selection.", + "admin.settings.fallback.classic": "Classic mode (Google)", + "admin.settings.fallback.llm": "LLM mode (gateways and direct accesses)", + "admin.settings.fallback.note": "Only registered engines (google, openai, deepseek, minimax) take part in this chain; AI tiers and other channels do not.", + "admin.models.duplicateRouteTitle": "Duplicate refused", + "admin.models.duplicateRouteDesc": "This channel + model pair is already in the list.", + "admin.marketing.promoDeactivateFailedTitle": "Deactivation refused", + "admin.marketing.promoDelete": "Delete", + "admin.marketing.promoDeleteConfirm": "Permanently delete code {code}?", + "admin.marketing.promoDeletedTitle": "Code deleted", + "admin.marketing.promoDeletedDesc": "Code {code} has been deleted.", + "admin.marketing.promoDeleteFailedTitle": "Deletion refused", + "admin.marketing.promoLinkStripe": "Link to Stripe", + "admin.marketing.promoLinkedTitle": "Code linked to Stripe", + "admin.marketing.promoLinkedDesc": "Code {code} can now be applied at checkout.", + "admin.marketing.promoLinkFailedTitle": "Linking failed", + "admin.marketing.promoExpiryHint": "A code “until the 10th” stays valid through the 10th (ends 23:59:59 UTC).", + "admin.marketing.generateEmpty": "The server response was empty or unusable." } diff --git a/frontend/src/lib/i18n/messages/en/pricing.json b/frontend/src/lib/i18n/messages/en/pricing.json index 749f622..6cc3795 100644 --- a/frontend/src/lib/i18n/messages/en/pricing.json +++ b/frontend/src/lib/i18n/messages/en/pricing.json @@ -167,5 +167,17 @@ "pricing.aiModels.essential.price": "From $0.09 per million tokens", "pricing.aiModels.premium.modelName": "Claude 5", "pricing.aiModels.premium.alternatives": "Alternatives: DeepSeek V4 Pro and GLM-5.3", - "pricing.aiModels.premium.price": "$2 / $10 per 1M tokens" + "pricing.aiModels.premium.price": "$2 / $10 per 1M tokens", + "pricing.promo.label": "Promo code", + "pricing.promo.placeholder": "YOURCODE", + "pricing.promo.apply": "Apply", + "pricing.promo.checking": "Checking...", + "pricing.promo.valid": "Valid code", + "pricing.promo.subscriptionOnly": "Valid on subscriptions only, not on credits.", + "pricing.promoError.unknown": "This promo code does not exist.", + "pricing.promoError.inactive": "This promo code is no longer active.", + "pricing.promoError.expired": "This promo code has expired.", + "pricing.promoError.exhausted": "This promo code has reached its redemption limit.", + "pricing.promoError.plan": "This promo code does not apply to this plan.", + "pricing.promoError.local_only": "This promo code is not valid for online payment." } diff --git a/frontend/src/lib/i18n/messages/es/admin.json b/frontend/src/lib/i18n/messages/es/admin.json index 0582ad4..ee064d8 100644 --- a/frontend/src/lib/i18n/messages/es/admin.json +++ b/frontend/src/lib/i18n/messages/es/admin.json @@ -145,5 +145,180 @@ "admin.stats.tiersSub": "Esencial / Premium · clásico {classic} · otro {other}", "admin.stats.unavailable": "Estadísticas de negocio no disponibles ({error}).", "admin.stats.waitlist": "Lista de espera", - "admin.stats.waitlistSub": "inscritos en espera" + "admin.stats.waitlistSub": "inscritos en espera", + "admin.settings.title": "Proveedores", + "admin.settings.subtitle": "Configura los accesos de proveedores. Las claves API también pueden definirse en el archivo .env.", + "admin.settings.sectionClassic": "Traducción clásica", + "admin.settings.sectionClassicDesc": "Motores de Google para la traducción estándar.", + "admin.settings.sectionDirect": "Proveedores de IA directos", + "admin.settings.sectionDirectDesc": "Accesos directos compatibles con OpenAI — utilizables como canal de un nivel de IA (página «Modelos y suscripciones») o para generar correos.", + "admin.settings.sectionGateway": "Pasarelas multiplano", + "admin.settings.sectionGatewayDesc": "Una sola clave, cientos de modelos. Los modelos por nivel se gestionan en la página «Modelos y suscripciones».", + "admin.settings.sectionOcr": "OCR (PDF escaneados)", + "admin.settings.sectionOcrDesc": "Extracción del texto de las páginas de imagen antes de traducir.", + "admin.settings.sectionEmails": "Correos", + "admin.settings.sectionEmailsDesc": "Servidor de envío para notificaciones y reenganches.", + "admin.settings.enabled": "Activado", + "admin.settings.disabled": "Desactivado", + "admin.settings.envBadge": "Clave en .env", + "admin.settings.envConfigured": "Configurado en .env", + "admin.settings.envKeyPlaceholder": "Clave configurada en .env (déjela vacía para usarla)", + "admin.settings.apiKey": "Clave API", + "admin.settings.model": "Modelo", + "admin.settings.baseUrl": "URL base", + "admin.settings.test": "Probar", + "admin.settings.testing": "Prueba...", + "admin.settings.ok": "OK", + "admin.settings.error": "Error", + "admin.settings.failure": "Fallo", + "admin.settings.sending": "Enviando...", + "admin.settings.save": "Guardar la configuración", + "admin.settings.saving": "Guardando...", + "admin.settings.savedTitle": "✅ Configuración guardada", + "admin.settings.savedDesc": "Los parámetros se han guardado correctamente.", + "admin.settings.saveErrorTitle": "Error al guardar", + "admin.settings.loadErrorTitle": "Error de carga", + "admin.settings.loadErrorDesc": "HTTP {status} — verifique su token de administrador.", + "admin.settings.networkErrorTitle": "Error de red", + "admin.settings.networkErrorDesc": "No se puede contactar con el backend.", + "admin.settings.networkSaveErrorDesc": "No se puede contactar con el backend para guardar.", + "admin.settings.connectionOk": "Conexión correcta", + "admin.settings.modelsFound": "{count} modelos de {provider} encontrados", + "admin.settings.providerError": "Error {provider}", + "admin.settings.google.title": "Google Translate", + "admin.settings.google.desc": "Acceso web no oficial vía deep_translator. No requiere clave; se recomienda un uso razonable.", + "admin.settings.googleCloud.title": "Google Cloud Translation (oficial)", + "admin.settings.googleCloud.desc": "API oficial de Google Cloud. 500 000 caracteres/mes gratis, luego unos 20 $ por millón de caracteres. Clave API en console.cloud.google.com.", + "admin.settings.googleCloud.hint": "Active Cloud Translation API en Google Cloud Console y cree una clave API restringida a esa API.", + "admin.settings.deepseek.title": "DeepSeek (directo)", + "admin.settings.deepseek.desc": "API directa de DeepSeek, compatible con OpenAI. Clave en platform.deepseek.com — utilizable como canal de un nivel de IA.", + "admin.settings.zhipu.title": "z.ai / Zhipu (GLM)", + "admin.settings.zhipu.desc": "Acceso directo a los modelos GLM de z.ai, compatible con OpenAI. Clave en z.ai — utilizable como canal de un nivel de IA.", + "admin.settings.zhipu.urlHint": "Por defecto: https://api.z.ai/api/paas/v4 — cámbielo solo si usa un proxy.", + "admin.settings.minimax.title": "MiniMax (directo)", + "admin.settings.minimax.desc": "API pública de MiniMax, compatible con OpenAI. Clave en minimax.io — utilizable como canal de un nivel de IA.", + "admin.settings.openai.title": "OpenAI", + "admin.settings.openai.desc": "Traducciones GPT. Obtenga una clave en platform.openai.com.", + "admin.settings.zai.title": "xAI (Grok)", + "admin.settings.zai.desc": "Modelos Grok de xAI. API compatible con OpenAI. Obtenga su clave en x.ai.", + "admin.settings.zai.urlHint": "Por defecto: https://api.x.ai/v1 — cámbielo solo si usa un proxy.", + "admin.settings.openrouter.title": "OpenRouter — clave de pasarela", + "admin.settings.openrouter.desc": "Nivel Esencial (plan Pro). Gama actual: deepseek/deepseek-v4-flash, z-ai/glm-5.3-flash, minimax/minimax-m3.", + "admin.settings.openrouter.keyLabel": "Clave API de OpenRouter (compartida con Premium)", + "admin.settings.openrouter.envSharedPlaceholder": "Clave configurada en .env (compartida con Premium)", + "admin.settings.openrouter.reminder": "Modelos gestionados en la página «Modelos y suscripciones».", + "admin.settings.openrouterPremium.title": "OpenRouter Premium — clave de pasarela", + "admin.settings.openrouterPremium.desc": "Nivel Premium (plan Business). Gama actual: anthropic/claude-sonnet-5, deepseek/deepseek-v4-pro, z-ai/glm-5.3.", + "admin.settings.openrouterPremium.reminder": "Modelos gestionados en la página «Modelos y suscripciones». Comparte la misma clave de OpenRouter.", + "admin.settings.mistral.title": "OCR Mistral (PDF escaneados)", + "admin.settings.mistral.desc": "Extrae el texto de los PDF escaneados (páginas de imagen) antes de traducir. Sin clave, estos archivos se rechazan con un mensaje claro. Unos 1 € / 1 000 páginas. Clave: console.mistral.ai.", + "admin.settings.mistral.keyLabel": "Clave API de Mistral", + "admin.settings.mistral.modelLabel": "Modelo OCR", + "admin.settings.mistral.modelHint": "Recomendado: mistral-ocr-latest", + "admin.settings.smtp.title": "Correo SMTP", + "admin.settings.smtp.desc": "Configuración del servidor SMTP para el envío de correos (contraseña olvidada, notificaciones, reenganches).", + "admin.settings.smtp.host": "Host SMTP", + "admin.settings.smtp.port": "Puerto", + "admin.settings.smtp.username": "Nombre de usuario", + "admin.settings.smtp.password": "Contraseña", + "admin.settings.smtp.fromEmail": "Dirección de envío", + "admin.settings.smtp.useTls": "Usar TLS", + "admin.settings.smtp.sendTest": "Enviar un correo de prueba", + "admin.settings.smtp.sent": "Enviado", + "admin.settings.smtp.sendFailed": "Fallo en el envío", + "admin.models.channelOpenrouter": "OpenRouter", + "admin.models.channelOpenrouterPremium": "OpenRouter (Premium)", + "admin.models.channelDeepseek": "DeepSeek (directo)", + "admin.models.channelZhipu": "z.ai (GLM)", + "admin.models.channelMinimax": "MiniMax (directo)", + "admin.models.channelOpenai": "OpenAI", + "admin.models.channelZai": "xAI (Grok)", + "admin.models.customModelPlaceholder": "id-del-modelo", + "admin.models.routeHint": "El canal elige el acceso directo o la pasarela; los identificadores de modelos difieren según el canal.", + "admin.models.addRouteConfirm": "Añadir", + "admin.marketing.generateTitle": "Generar con IA", + "admin.marketing.generateDesc": "El texto se redacta a partir del plan de marketing (posicionamiento y cifras autorizadas). No se envía nada automáticamente: revise y envíe.", + "admin.marketing.channel": "Canal", + "admin.marketing.model": "Modelo", + "admin.marketing.modelPlaceholder": "Predeterminado del canal", + "admin.marketing.promoOptional": "Descuento (opcional)", + "admin.marketing.promoNone": "Ninguno", + "admin.marketing.brief": "Brief", + "admin.marketing.briefPlaceholder": "Ej.: reenganchear a los inscritos en la lista de espera recordando las ventajas del plan Pro.", + "admin.marketing.language": "Idioma del correo", + "admin.marketing.tone": "Tono", + "admin.marketing.toneNone": "Neutro", + "admin.marketing.generateBtn": "Generar", + "admin.marketing.generating": "Generando...", + "admin.marketing.generatedTitle": "Correo generado", + "admin.marketing.generatedDesc": "Asunto y HTML insertados en el formulario — revise antes de enviar ({provider}).", + "admin.marketing.generateFailedTitle": "No se pudo generar", + "admin.marketing.briefRequired": "Describa primero el contenido deseado en el brief.", + "admin.marketing.langFr": "Francés", + "admin.marketing.langEn": "Inglés", + "admin.marketing.langDe": "Alemán", + "admin.marketing.langEs": "Español", + "admin.marketing.langIt": "Italiano", + "admin.marketing.langPt": "Portugués", + "admin.marketing.langNl": "Neerlandés", + "admin.marketing.langAr": "Árabe", + "admin.marketing.langFa": "Persa", + "admin.marketing.langJa": "Japonés", + "admin.marketing.langKo": "Coreano", + "admin.marketing.langRu": "Ruso", + "admin.marketing.langZh": "Chino", + "admin.marketing.toneFriendly": "Cercano", + "admin.marketing.toneProfessional": "Profesional", + "admin.marketing.toneUrgent": "Urgente", + "admin.marketing.toneFestive": "Festivo", + "admin.marketing.promoTitle": "Descuentos", + "admin.marketing.promoDesc": "Códigos promo aplicados al pago mediante Stripe; el contador aumenta cuando la suscripción se completa de verdad.", + "admin.marketing.promoEmpty": "Aún no hay códigos promo.", + "admin.marketing.promoActive": "Activo", + "admin.marketing.promoInactive": "Desactivado", + "admin.marketing.promoLocalOnly": "Fuera de Stripe", + "admin.marketing.promoDeactivate": "Desactivar", + "admin.marketing.promoUsage": "{used}/{max} usos", + "admin.marketing.promoPlansLabel": "Planes:", + "admin.marketing.promoAllPlans": "Todos", + "admin.marketing.promoExpiresOn": "caduca el {date}", + "admin.marketing.promoNeverExpires": "sin caducidad", + "admin.marketing.promoCreateTitle": "Crear un descuento", + "admin.marketing.promoCreateDesc": "El código también se crea en Stripe (cupón + código promocional) cuando Stripe está configurado.", + "admin.marketing.promoCode": "Código", + "admin.marketing.promoType": "Tipo", + "admin.marketing.promoTypePercent": "Porcentaje", + "admin.marketing.promoTypeAmount": "Importe (EUR)", + "admin.marketing.promoValue": "Valor", + "admin.marketing.promoPlans": "Planes elegibles", + "admin.marketing.promoMaxRedemptions": "Límite de usos", + "admin.marketing.promoMaxPlaceholder": "Ilimitado", + "admin.marketing.promoExpires": "Caducidad", + "admin.marketing.promoCreate": "Crear el código", + "admin.marketing.promoCreating": "Creando...", + "admin.marketing.promoCreatedTitle": "Código promo creado", + "admin.marketing.promoCreatedDesc": "El código {code} está listo.", + "admin.marketing.promoCreateFailedTitle": "Creación rechazada", + "admin.marketing.promoInvalidInput": "Verifique el código y el valor.", + "admin.marketing.promoDeactivatedTitle": "Código desactivado", + "admin.marketing.promoDeactivatedDesc": "El código {code} ya no se aplica.", + "admin.settings.fallback.title": "Cadena de respaldo", + "admin.settings.fallback.desc": "Orden de prioridad para la selección automática de proveedores.", + "admin.settings.fallback.classic": "Modo clásico (Google)", + "admin.settings.fallback.llm": "Modo LLM (pasarelas y accesos directos)", + "admin.settings.fallback.note": "Solo los canales registrados (google, openai, deepseek, minimax) participan en esta cadena; los niveles de IA y los demás canales no.", + "admin.models.duplicateRouteTitle": "Duplicado rechazado", + "admin.models.duplicateRouteDesc": "Ese par canal + modelo ya está en la lista.", + "admin.marketing.promoDeactivateFailedTitle": "Desactivación rechazada", + "admin.marketing.promoDelete": "Eliminar", + "admin.marketing.promoDeleteConfirm": "¿Eliminar definitivamente el código {code}?", + "admin.marketing.promoDeletedTitle": "Código eliminado", + "admin.marketing.promoDeletedDesc": "El código {code} ha sido eliminado.", + "admin.marketing.promoDeleteFailedTitle": "Eliminación rechazada", + "admin.marketing.promoLinkStripe": "Vincular a Stripe", + "admin.marketing.promoLinkedTitle": "Código vinculado a Stripe", + "admin.marketing.promoLinkedDesc": "El código {code} ya se puede aplicar al pago.", + "admin.marketing.promoLinkFailedTitle": "No se pudo vincular", + "admin.marketing.promoExpiryHint": "Un código «hasta el 10» sigue siendo válido todo el 10 (fin: 23:59:59 UTC).", + "admin.marketing.generateEmpty": "La respuesta del servidor estaba vacía o no era utilizable." } diff --git a/frontend/src/lib/i18n/messages/es/pricing.json b/frontend/src/lib/i18n/messages/es/pricing.json index 978630c..7031258 100644 --- a/frontend/src/lib/i18n/messages/es/pricing.json +++ b/frontend/src/lib/i18n/messages/es/pricing.json @@ -167,5 +167,17 @@ "pricing.header.titleBase": "Un plan para", "pricing.header.titleAccent": "cada necesidad", "pricing.toast.close": "Cerrar", - "pricing.aiModels.premium.price": "2 $ / 10 $ por 1M de tokens" + "pricing.aiModels.premium.price": "2 $ / 10 $ por 1M de tokens", + "pricing.promo.label": "Código promo", + "pricing.promo.placeholder": "TUCODIGO", + "pricing.promo.apply": "Aplicar", + "pricing.promo.checking": "Verificando...", + "pricing.promo.valid": "Código válido", + "pricing.promo.subscriptionOnly": "Válido en suscripciones, no en créditos.", + "pricing.promoError.unknown": "Este código promo no existe.", + "pricing.promoError.inactive": "Este código promo ya no está activo.", + "pricing.promoError.expired": "Este código promo ha caducado.", + "pricing.promoError.exhausted": "Este código promo ha alcanzado su límite de usos.", + "pricing.promoError.plan": "Este código promo no es válido para este plan.", + "pricing.promoError.local_only": "Este código promo no está habilitado para el pago en línea." } diff --git a/frontend/src/lib/i18n/messages/fa/admin.json b/frontend/src/lib/i18n/messages/fa/admin.json index 9afe0dd..7f20ef3 100644 --- a/frontend/src/lib/i18n/messages/fa/admin.json +++ b/frontend/src/lib/i18n/messages/fa/admin.json @@ -145,5 +145,180 @@ "admin.stats.tiersSub": "پایه / پیشرفته · کلاسیک {classic} · سایر {other}", "admin.stats.unavailable": "آمار کسب‌وکار در دسترس نیست ({error}).", "admin.stats.waitlist": "فهرست انتظار", - "admin.stats.waitlistSub": "نفر در انتظار" + "admin.stats.waitlistSub": "نفر در انتظار", + "admin.settings.title": "ارائه‌دهندگان", + "admin.settings.subtitle": "دسترسی ارائه‌دهندگان را تنظیم کنید. کلیدهای API را می‌توان در فایل .env نیز قرار داد.", + "admin.settings.sectionClassic": "ترجمه کلاسیک", + "admin.settings.sectionClassicDesc": "موتورهای Google برای ترجمه استاندارد.", + "admin.settings.sectionDirect": "ارائه‌دهندگان مستقیم هوش مصنوعی", + "admin.settings.sectionDirectDesc": "دسترسی‌های مستقیم سازگار با OpenAI — به‌عنوان کانال یک سطح هوش مصنوعی (صفحه «مدل‌ها و اشتراک‌ها») یا برای تولید ایمیل قابل استفاده‌اند.", + "admin.settings.sectionGateway": "درگاه‌های چندمدلی", + "admin.settings.sectionGatewayDesc": "یک کلید، صدها مدل. مدل‌های هر سطح در صفحه «مدل‌ها و اشتراک‌ها» مدیریت می‌شوند.", + "admin.settings.sectionOcr": "OCR (PDFهای اسکن‌شده)", + "admin.settings.sectionOcrDesc": "استخراج متن صفحات تصویری پیش از ترجمه.", + "admin.settings.sectionEmails": "ایمیل‌ها", + "admin.settings.sectionEmailsDesc": "سرور ارسال برای اعلان‌ها و ایمیل‌های پیگیری.", + "admin.settings.enabled": "فعال", + "admin.settings.disabled": "غیرفعال", + "admin.settings.envBadge": "کلید در .env", + "admin.settings.envConfigured": "تنظیم‌شده در .env", + "admin.settings.envKeyPlaceholder": "کلید در .env تنظیم شده (برای استفاده خالی بگذارید)", + "admin.settings.apiKey": "کلید API", + "admin.settings.model": "مدل", + "admin.settings.baseUrl": "آدرس پایه", + "admin.settings.test": "آزمایش", + "admin.settings.testing": "در حال آزمایش...", + "admin.settings.ok": "انجام شد", + "admin.settings.error": "خطا", + "admin.settings.failure": "ناموفق", + "admin.settings.sending": "در حال ارسال...", + "admin.settings.save": "ذخیره پیکربندی", + "admin.settings.saving": "در حال ذخیره...", + "admin.settings.savedTitle": "✅ پیکربندی ذخیره شد", + "admin.settings.savedDesc": "تنظیمات با موفقیت ذخیره شدند.", + "admin.settings.saveErrorTitle": "خطا در ذخیره", + "admin.settings.loadErrorTitle": "خطا در بارگیری", + "admin.settings.loadErrorDesc": "HTTP {status} — توکن مدیر خود را بررسی کنید.", + "admin.settings.networkErrorTitle": "خطای شبکه", + "admin.settings.networkErrorDesc": "دسترسی به سرور ممکن نیست.", + "admin.settings.networkSaveErrorDesc": "برای ذخیره، دسترسی به سرور ممکن نیست.", + "admin.settings.connectionOk": "اتصال برقرار است", + "admin.settings.modelsFound": "{count} مدل {provider} یافت شد", + "admin.settings.providerError": "خطای {provider}", + "admin.settings.google.title": "Google Translate", + "admin.settings.google.desc": "دسترسی وب غیررسمی از طریق deep_translator. کلید لازم ندارد؛ استفاده معقول توصیه می‌شود.", + "admin.settings.googleCloud.title": "Google Cloud Translation (رسمی)", + "admin.settings.googleCloud.desc": "رسمی Google Cloud. ۵۰۰٬۰۰۰ نویسه/ماه رایگان، سپس حدود ۲۰ دلار برای هر میلیون نویسه. کلید API از console.cloud.google.com.", + "admin.settings.googleCloud.hint": "در Google Cloud Console سرویس Cloud Translation API را فعال کنید و یک کلید API محدود به همان سرویس بسازید.", + "admin.settings.deepseek.title": "DeepSeek (مستقیم)", + "admin.settings.deepseek.desc": "API مستقیم DeepSeek، سازگار با OpenAI. کلید از platform.deepseek.com — به‌عنوان کانال سطح هوش مصنوعی قابل استفاده است.", + "admin.settings.zhipu.title": "z.ai / Zhipu (GLM)", + "admin.settings.zhipu.desc": "دسترسی مستقیم به مدل‌های GLM از z.ai، سازگار با OpenAI. کلید از z.ai — به‌عنوان کانال سطح هوش مصنوعی قابل استفاده است.", + "admin.settings.zhipu.urlHint": "پیش‌فرض: https://api.z.ai/api/paas/v4 — فقط در صورت استفاده از پروکسی تغییر دهید.", + "admin.settings.minimax.title": "MiniMax (مستقیم)", + "admin.settings.minimax.desc": "API عمومی MiniMax، سازگار با OpenAI. کلید از minimax.io — به‌عنوان کانال سطح هوش مصنوعی قابل استفاده است.", + "admin.settings.openai.title": "OpenAI", + "admin.settings.openai.desc": "ترجمه‌های GPT. کلید خود را از platform.openai.com بگیرید.", + "admin.settings.zai.title": "xAI (Grok)", + "admin.settings.zai.desc": "مدل‌های Grok از xAI. API سازگار با OpenAI. کلید خود را از x.ai بگیرید.", + "admin.settings.zai.urlHint": "پیش‌فرض: https://api.x.ai/v1 — فقط در صورت استفاده از پروکسی تغییر دهید.", + "admin.settings.openrouter.title": "OpenRouter — کلید درگاه", + "admin.settings.openrouter.desc": "سطح Essential (طرح Pro). فهرست کنونی: deepseek/deepseek-v4-flash، z-ai/glm-5.3-flash، minimax/minimax-m3.", + "admin.settings.openrouter.keyLabel": "کلید API OpenRouter (مشترک با Premium)", + "admin.settings.openrouter.envSharedPlaceholder": "کلید در .env تنظیم شده (مشترک با Premium)", + "admin.settings.openrouter.reminder": "مدل‌ها در صفحه «مدل‌ها و اشتراک‌ها» مدیریت می‌شوند.", + "admin.settings.openrouterPremium.title": "OpenRouter Premium — کلید درگاه", + "admin.settings.openrouterPremium.desc": "سطح Premium (طرح Business). فهرست کنونی: anthropic/claude-sonnet-5، deepseek/deepseek-v4-pro، z-ai/glm-5.3.", + "admin.settings.openrouterPremium.reminder": "مدل‌ها در صفحه «مدل‌ها و اشتراک‌ها» مدیریت می‌شوند. همان کلید OpenRouter را به اشتراک می‌گذارد.", + "admin.settings.mistral.title": "OCR Mistral (PDFهای اسکن‌شده)", + "admin.settings.mistral.desc": "متن PDFهای اسکن‌شده (صفحات تصویری) را پیش از ترجمه استخراج می‌کند. بدون کلید، این فایل‌ها با پیامی روشن رد می‌شوند. حدود ۱ یورو / ۱۰۰۰ صفحه. کلید: console.mistral.ai.", + "admin.settings.mistral.keyLabel": "کلید API Mistral", + "admin.settings.mistral.modelLabel": "مدل OCR", + "admin.settings.mistral.modelHint": "پیشنهادی: mistral-ocr-latest", + "admin.settings.smtp.title": "ایمیل SMTP", + "admin.settings.smtp.desc": "تنظیمات سرور SMTP برای ارسال ایمیل (فراموشی رمز، اعلان‌ها، ایمیل‌های پیگیری).", + "admin.settings.smtp.host": "میزبان SMTP", + "admin.settings.smtp.port": "درگاه", + "admin.settings.smtp.username": "نام کاربری", + "admin.settings.smtp.password": "رمز عبور", + "admin.settings.smtp.fromEmail": "نشانی فرستنده", + "admin.settings.smtp.useTls": "استفاده از TLS", + "admin.settings.smtp.sendTest": "ارسال ایمیل آزمایشی", + "admin.settings.smtp.sent": "ارسال شد", + "admin.settings.smtp.sendFailed": "ارسال ناموفق", + "admin.models.channelOpenrouter": "OpenRouter", + "admin.models.channelOpenrouterPremium": "OpenRouter (Premium)", + "admin.models.channelDeepseek": "DeepSeek (مستقیم)", + "admin.models.channelZhipu": "z.ai (GLM)", + "admin.models.channelMinimax": "MiniMax (مستقیم)", + "admin.models.channelOpenai": "OpenAI", + "admin.models.channelZai": "xAI (Grok)", + "admin.models.customModelPlaceholder": "شناسه-مدل", + "admin.models.routeHint": "کانال، دسترسی مستقیم یا درگاه را برمی‌گزیند؛ شناسه مدل‌ها در هر کانال متفاوت است.", + "admin.models.addRouteConfirm": "افزودن", + "admin.marketing.generateTitle": "تولید با هوش مصنوعی", + "admin.marketing.generateDesc": "متن بر اساس برنامه بازاریابی نوشته می‌شود (جایگاه و ارقام مجاز). هیچ‌چیز به‌طور خودکار ارسال نمی‌شود: بازبینی کنید و سپس بفرستید.", + "admin.marketing.channel": "کانال", + "admin.marketing.model": "مدل", + "admin.marketing.modelPlaceholder": "پیش‌فرض کانال", + "admin.marketing.promoOptional": "تخفیف (اختیاری)", + "admin.marketing.promoNone": "بدون تخفیف", + "admin.marketing.brief": "شرح درخواست", + "admin.marketing.briefPlaceholder": "مثال: یادآوری به ثبت‌نام‌شدگان لیست انتظار درباره مزایای طرح Pro.", + "admin.marketing.language": "زبان ایمیل", + "admin.marketing.tone": "لحن", + "admin.marketing.toneNone": "بی‌طرف", + "admin.marketing.generateBtn": "تولید", + "admin.marketing.generating": "در حال تولید...", + "admin.marketing.generatedTitle": "ایمیل تولید شد", + "admin.marketing.generatedDesc": "موضوع و HTML در فرم درج شدند — پیش از ارسال بازبینی کنید ({provider}).", + "admin.marketing.generateFailedTitle": "تولید ناموفق بود", + "admin.marketing.briefRequired": "نخست محتوای موردنظر را در شرح درخواست بنویسید.", + "admin.marketing.langFr": "فرانسوی", + "admin.marketing.langEn": "انگلیسی", + "admin.marketing.langDe": "آلمانی", + "admin.marketing.langEs": "اسپانیایی", + "admin.marketing.langIt": "ایتالیایی", + "admin.marketing.langPt": "پرتغالی", + "admin.marketing.langNl": "هلندی", + "admin.marketing.langAr": "عربی", + "admin.marketing.langFa": "فارسی", + "admin.marketing.langJa": "ژاپنی", + "admin.marketing.langKo": "کره‌ای", + "admin.marketing.langRu": "روسی", + "admin.marketing.langZh": "چینی", + "admin.marketing.toneFriendly": "صمیمی", + "admin.marketing.toneProfessional": "حرفه‌ای", + "admin.marketing.toneUrgent": "فوری", + "admin.marketing.toneFestive": "جشنواره‌ای", + "admin.marketing.promoTitle": "تخفیف‌ها", + "admin.marketing.promoDesc": "کدهای تخفیف از طریق Stripe بر پرداخت اعمال می‌شوند؛ شمارنده پس از تکمیل واقعی اشتراک بالا می‌رود.", + "admin.marketing.promoEmpty": "هنوز کد تخفیفی وجود ندارد.", + "admin.marketing.promoActive": "فعال", + "admin.marketing.promoInactive": "غیرفعال", + "admin.marketing.promoLocalOnly": "خارج از Stripe", + "admin.marketing.promoDeactivate": "غیرفعال‌سازی", + "admin.marketing.promoUsage": "{used}/{max} بار استفاده", + "admin.marketing.promoPlansLabel": "طرح‌ها:", + "admin.marketing.promoAllPlans": "همه", + "admin.marketing.promoExpiresOn": "تاریخ انقضا {date}", + "admin.marketing.promoNeverExpires": "بدون انقضا", + "admin.marketing.promoCreateTitle": "ایجاد تخفیف", + "admin.marketing.promoCreateDesc": "اگر Stripe تنظیم شده باشد، کد در Stripe نیز ساخته می‌شود (کوپن + کد تبلیغاتی).", + "admin.marketing.promoCode": "کد", + "admin.marketing.promoType": "نوع", + "admin.marketing.promoTypePercent": "درصد", + "admin.marketing.promoTypeAmount": "مبلغ (EUR)", + "admin.marketing.promoValue": "مقدار", + "admin.marketing.promoPlans": "طرح‌های واجد شرایط", + "admin.marketing.promoMaxRedemptions": "سقف استفاده", + "admin.marketing.promoMaxPlaceholder": "نامحدود", + "admin.marketing.promoExpires": "انقضا", + "admin.marketing.promoCreate": "ایجاد کد", + "admin.marketing.promoCreating": "در حال ایجاد...", + "admin.marketing.promoCreatedTitle": "کد تخفیف ایجاد شد", + "admin.marketing.promoCreatedDesc": "کد {code} آماده است.", + "admin.marketing.promoCreateFailedTitle": "ایجاد رد شد", + "admin.marketing.promoInvalidInput": "کد و مقدار را بررسی کنید.", + "admin.marketing.promoDeactivatedTitle": "کد غیرفعال شد", + "admin.marketing.promoDeactivatedDesc": "کد {code} دیگر اعمال نمی‌شود.", + "admin.settings.fallback.title": "زنجیره پشتیبان", + "admin.settings.fallback.desc": "ترتیب اولویت برای انتخاب خودکار ارائه‌دهندگان.", + "admin.settings.fallback.classic": "حالت کلاسیک (Google)", + "admin.settings.fallback.llm": "حالت LLM (درگاه‌ها و دسترسی مستقیم)", + "admin.settings.fallback.note": "فقط کانال‌های ثبت‌شده (google، openai، deepseek، minimax) در این زنجیره شرکت می‌کنند؛ سطوح هوش مصنوعی و کانال‌های دیگر نه.", + "admin.models.duplicateRouteTitle": "تکرار رد شد", + "admin.models.duplicateRouteDesc": "این جفت کانال + مدل از قبل در فهرست هست.", + "admin.marketing.promoDeactivateFailedTitle": "غیرفعال‌سازی رد شد", + "admin.marketing.promoDelete": "حذف", + "admin.marketing.promoDeleteConfirm": "کد {code} برای همیشه حذف شود؟", + "admin.marketing.promoDeletedTitle": "کد حذف شد", + "admin.marketing.promoDeletedDesc": "کد {code} حذف شد.", + "admin.marketing.promoDeleteFailedTitle": "حذف رد شد", + "admin.marketing.promoLinkStripe": "پیوند به Stripe", + "admin.marketing.promoLinkedTitle": "کد به Stripe پیوند خورد", + "admin.marketing.promoLinkedDesc": "کد {code} اکنون در پرداخت قابل اعمال است.", + "admin.marketing.promoLinkFailedTitle": "پیوند ناموفق بود", + "admin.marketing.promoExpiryHint": "کد «تا ۱۰» تمام روز دهم معتبر است (پایان: ۲۳:۵۹:۵۹ UTC).", + "admin.marketing.generateEmpty": "پاسخ سرور خالی یا غیرقابل استفاده بود." } diff --git a/frontend/src/lib/i18n/messages/fa/pricing.json b/frontend/src/lib/i18n/messages/fa/pricing.json index f899c14..3d21785 100644 --- a/frontend/src/lib/i18n/messages/fa/pricing.json +++ b/frontend/src/lib/i18n/messages/fa/pricing.json @@ -167,5 +167,17 @@ "pricing.header.titleBase": "برنامه‌ای برای", "pricing.header.titleAccent": "هر نیاز", "pricing.toast.close": "بستن", - "pricing.aiModels.premium.price": "۲ دلار / ۱۰ دلار به ازای هر میلیون توکن" + "pricing.aiModels.premium.price": "۲ دلار / ۱۰ دلار به ازای هر میلیون توکن", + "pricing.promo.label": "کد تخفیف", + "pricing.promo.placeholder": "کدشما", + "pricing.promo.apply": "اعمال", + "pricing.promo.checking": "در حال بررسی...", + "pricing.promo.valid": "کد معتبر است", + "pricing.promo.subscriptionOnly": "فقط روی اشتراک‌ها معتبر است، نه روی اعتبارها.", + "pricing.promoError.unknown": "این کد تخفیف وجود ندارد.", + "pricing.promoError.inactive": "این کد تخفیف دیگر فعال نیست.", + "pricing.promoError.expired": "این کد تخفیف منقضی شده است.", + "pricing.promoError.exhausted": "این کد تخفیف به سقف استفاده رسیده است.", + "pricing.promoError.plan": "این کد تخفیف برای این طرح معتبر نیست.", + "pricing.promoError.local_only": "این کد تخفیف برای پرداخت آنلاین فعال نشده است." } diff --git a/frontend/src/lib/i18n/messages/fr/admin.json b/frontend/src/lib/i18n/messages/fr/admin.json index 066cac1..fd758f7 100644 --- a/frontend/src/lib/i18n/messages/fr/admin.json +++ b/frontend/src/lib/i18n/messages/fr/admin.json @@ -145,5 +145,180 @@ "admin.stats.tiersSub": "Essentielle / Premium · classique {classic} · autre {other}", "admin.stats.unavailable": "Statistiques business indisponibles ({error}).", "admin.stats.waitlist": "Liste d'attente", - "admin.stats.waitlistSub": "inscrits en attente" + "admin.stats.waitlistSub": "inscrits en attente", + "admin.settings.title": "Fournisseurs", + "admin.settings.subtitle": "Configurez les accès fournisseurs. Les clés API peuvent aussi être définies dans le fichier .env.", + "admin.settings.sectionClassic": "Traduction classique", + "admin.settings.sectionClassicDesc": "Moteurs Google pour la traduction standard.", + "admin.settings.sectionDirect": "Fournisseurs IA directs", + "admin.settings.sectionDirectDesc": "Accès directs compatibles OpenAI — utilisables comme canal d'un palier IA (page « Modèles & abonnements ») ou pour la génération d'emails.", + "admin.settings.sectionGateway": "Passerelles multi-modèles", + "admin.settings.sectionGatewayDesc": "Une seule clé, des centaines de modèles. Les modèles par palier se règlent dans « Modèles & abonnements ».", + "admin.settings.sectionOcr": "OCR (PDF scannés)", + "admin.settings.sectionOcrDesc": "Extraction du texte des pages image avant traduction.", + "admin.settings.sectionEmails": "Emails", + "admin.settings.sectionEmailsDesc": "Serveur d'envoi pour les notifications et les relances.", + "admin.settings.enabled": "Activé", + "admin.settings.disabled": "Désactivé", + "admin.settings.envBadge": "Clé dans .env", + "admin.settings.envConfigured": "Configuré dans .env", + "admin.settings.envKeyPlaceholder": "Clé configurée dans .env (laisser vide pour l'utiliser)", + "admin.settings.apiKey": "Clé API", + "admin.settings.model": "Modèle", + "admin.settings.baseUrl": "URL de base", + "admin.settings.test": "Tester", + "admin.settings.testing": "Test...", + "admin.settings.ok": "OK", + "admin.settings.error": "Erreur", + "admin.settings.failure": "Échec", + "admin.settings.sending": "Envoi...", + "admin.settings.save": "Sauvegarder la configuration", + "admin.settings.saving": "Sauvegarde...", + "admin.settings.savedTitle": "✅ Configuration sauvegardée", + "admin.settings.savedDesc": "Les paramètres ont été enregistrés avec succès.", + "admin.settings.saveErrorTitle": "Erreur de sauvegarde", + "admin.settings.loadErrorTitle": "Erreur de chargement", + "admin.settings.loadErrorDesc": "HTTP {status} — vérifiez votre token admin.", + "admin.settings.networkErrorTitle": "Erreur réseau", + "admin.settings.networkErrorDesc": "Impossible de contacter le backend.", + "admin.settings.networkSaveErrorDesc": "Impossible de contacter le backend pour la sauvegarde.", + "admin.settings.connectionOk": "Connexion OK", + "admin.settings.modelsFound": "{count} modèles {provider} trouvés", + "admin.settings.providerError": "Erreur {provider}", + "admin.settings.google.title": "Google Translate", + "admin.settings.google.desc": "Accès web non officiel via deep_translator. Aucune clé requise, usage raisonnable recommandé.", + "admin.settings.googleCloud.title": "Google Cloud Translation (officiel)", + "admin.settings.googleCloud.desc": "API officielle Google Cloud. 500 000 caractères/mois offerts, puis environ 20 $ par million de caractères. Clé API sur console.cloud.google.com.", + "admin.settings.googleCloud.hint": "Activez Cloud Translation API dans Google Cloud Console, puis créez une clé API restreinte à cette API.", + "admin.settings.deepseek.title": "DeepSeek (accès direct)", + "admin.settings.deepseek.desc": "API DeepSeek directe, compatible OpenAI. Clé sur platform.deepseek.com — utilisable comme canal d'un palier IA.", + "admin.settings.zhipu.title": "z.ai / Zhipu (GLM)", + "admin.settings.zhipu.desc": "Accès direct aux modèles GLM de z.ai, compatible OpenAI. Clé sur z.ai — utilisable comme canal d'un palier IA.", + "admin.settings.zhipu.urlHint": "Par défaut : https://api.z.ai/api/paas/v4 — à changer uniquement si vous utilisez un proxy.", + "admin.settings.minimax.title": "MiniMax (accès direct)", + "admin.settings.minimax.desc": "API publique MiniMax, compatible OpenAI. Clé sur minimax.io — utilisable comme canal d'un palier IA.", + "admin.settings.openai.title": "OpenAI", + "admin.settings.openai.desc": "Traductions GPT. Obtenez une clé sur platform.openai.com.", + "admin.settings.zai.title": "xAI (Grok)", + "admin.settings.zai.desc": "Modèles Grok par xAI. API compatible OpenAI. Obtenez votre clé sur x.ai.", + "admin.settings.zai.urlHint": "Par défaut : https://api.x.ai/v1 — à changer uniquement si vous utilisez un proxy.", + "admin.settings.openrouter.title": "OpenRouter — clé de passerelle", + "admin.settings.openrouter.desc": "Palier Essentielle (forfait Pro). Gamme actuelle : deepseek/deepseek-v4-flash, z-ai/glm-5.3-flash, minimax/minimax-m3.", + "admin.settings.openrouter.keyLabel": "Clé API OpenRouter (partagée avec Premium)", + "admin.settings.openrouter.envSharedPlaceholder": "Clé configurée dans .env (partagée avec Premium)", + "admin.settings.openrouter.reminder": "Modèles gérés dans la page « Modèles & abonnements ».", + "admin.settings.openrouterPremium.title": "OpenRouter Premium — clé de passerelle", + "admin.settings.openrouterPremium.desc": "Palier Premium (forfait Business). Gamme actuelle : anthropic/claude-sonnet-5, deepseek/deepseek-v4-pro, z-ai/glm-5.3.", + "admin.settings.openrouterPremium.reminder": "Modèles gérés dans la page « Modèles & abonnements ». Partage la même clé OpenRouter.", + "admin.settings.mistral.title": "OCR Mistral (PDF scannés)", + "admin.settings.mistral.desc": "Extraction du texte des PDF scannés (pages image) avant traduction. Sans clé, ces fichiers sont refusés avec un message clair. Environ 1 € / 1 000 pages. Clé : console.mistral.ai.", + "admin.settings.mistral.keyLabel": "Clé API Mistral", + "admin.settings.mistral.modelLabel": "Modèle OCR", + "admin.settings.mistral.modelHint": "Recommandé : mistral-ocr-latest", + "admin.settings.smtp.title": "Email SMTP", + "admin.settings.smtp.desc": "Configuration du serveur SMTP pour l'envoi d'emails (mot de passe oublié, notifications, relances).", + "admin.settings.smtp.host": "Hôte SMTP", + "admin.settings.smtp.port": "Port", + "admin.settings.smtp.username": "Nom d'utilisateur", + "admin.settings.smtp.password": "Mot de passe", + "admin.settings.smtp.fromEmail": "Adresse d'expédition", + "admin.settings.smtp.useTls": "Utiliser TLS", + "admin.settings.smtp.sendTest": "Envoyer un email de test", + "admin.settings.smtp.sent": "Envoyé", + "admin.settings.smtp.sendFailed": "Échec de l'envoi", + "admin.models.channelOpenrouter": "OpenRouter", + "admin.models.channelOpenrouterPremium": "OpenRouter (Premium)", + "admin.models.channelDeepseek": "DeepSeek (accès direct)", + "admin.models.channelZhipu": "z.ai (GLM)", + "admin.models.channelMinimax": "MiniMax (accès direct)", + "admin.models.channelOpenai": "OpenAI", + "admin.models.channelZai": "xAI (Grok)", + "admin.models.customModelPlaceholder": "identifiant-du-modèle", + "admin.models.routeHint": "Le canal choisit l'accès direct ou la passerelle ; les identifiants de modèles diffèrent d'un canal à l'autre.", + "admin.models.addRouteConfirm": "Ajouter", + "admin.marketing.generateTitle": "Générer avec l'IA", + "admin.marketing.generateDesc": "Le texte est rédigé à partir du plan marketing (positionnement et chiffres autorisés). Rien n'est envoyé automatiquement : relisez puis envoyez.", + "admin.marketing.channel": "Canal", + "admin.marketing.model": "Modèle", + "admin.marketing.modelPlaceholder": "Par défaut du canal", + "admin.marketing.promoOptional": "Réduction (facultatif)", + "admin.marketing.promoNone": "Aucune", + "admin.marketing.brief": "Brief", + "admin.marketing.briefPlaceholder": "Ex. : relancer les inscrits de la liste d'attente avec un rappel des avantages du forfait Pro.", + "admin.marketing.language": "Langue de l'email", + "admin.marketing.tone": "Ton", + "admin.marketing.toneNone": "Neutre", + "admin.marketing.generateBtn": "Générer", + "admin.marketing.generating": "Génération...", + "admin.marketing.generatedTitle": "Email généré", + "admin.marketing.generatedDesc": "Sujet et HTML insérés dans le formulaire — relisez avant envoi ({provider}).", + "admin.marketing.generateFailedTitle": "Génération impossible", + "admin.marketing.briefRequired": "Décrivez d'abord le contenu voulu dans le brief.", + "admin.marketing.langFr": "Français", + "admin.marketing.langEn": "Anglais", + "admin.marketing.langDe": "Allemand", + "admin.marketing.langEs": "Espagnol", + "admin.marketing.langIt": "Italien", + "admin.marketing.langPt": "Portugais", + "admin.marketing.langNl": "Néerlandais", + "admin.marketing.langAr": "Arabe", + "admin.marketing.langFa": "Persan", + "admin.marketing.langJa": "Japonais", + "admin.marketing.langKo": "Coréen", + "admin.marketing.langRu": "Russe", + "admin.marketing.langZh": "Chinois", + "admin.marketing.toneFriendly": "Amical", + "admin.marketing.toneProfessional": "Professionnel", + "admin.marketing.toneUrgent": "Urgent", + "admin.marketing.toneFestive": "Festif", + "admin.marketing.promoTitle": "Réductions", + "admin.marketing.promoDesc": "Codes promo appliqués au paiement par Stripe ; le compteur augmente quand l'abonnement est réellement souscrit.", + "admin.marketing.promoEmpty": "Aucun code promo pour le moment.", + "admin.marketing.promoActive": "Actif", + "admin.marketing.promoInactive": "Désactivé", + "admin.marketing.promoLocalOnly": "Hors Stripe", + "admin.marketing.promoDeactivate": "Désactiver", + "admin.marketing.promoUsage": "{used}/{max} utilisations", + "admin.marketing.promoPlansLabel": "Forfaits :", + "admin.marketing.promoAllPlans": "Tous", + "admin.marketing.promoExpiresOn": "expire le {date}", + "admin.marketing.promoNeverExpires": "sans expiration", + "admin.marketing.promoCreateTitle": "Créer une réduction", + "admin.marketing.promoCreateDesc": "Le code est créé aussi chez Stripe (coupon + code promotionnel) quand Stripe est configuré.", + "admin.marketing.promoCode": "Code", + "admin.marketing.promoType": "Type", + "admin.marketing.promoTypePercent": "Pourcentage", + "admin.marketing.promoTypeAmount": "Montant (EUR)", + "admin.marketing.promoValue": "Valeur", + "admin.marketing.promoPlans": "Forfaits éligibles", + "admin.marketing.promoMaxRedemptions": "Plafond d'utilisations", + "admin.marketing.promoMaxPlaceholder": "Illimité", + "admin.marketing.promoExpires": "Expiration", + "admin.marketing.promoCreate": "Créer le code", + "admin.marketing.promoCreating": "Création...", + "admin.marketing.promoCreatedTitle": "Code promo créé", + "admin.marketing.promoCreatedDesc": "Le code {code} est prêt.", + "admin.marketing.promoCreateFailedTitle": "Création refusée", + "admin.marketing.promoInvalidInput": "Vérifiez le code et la valeur.", + "admin.marketing.promoDeactivatedTitle": "Code désactivé", + "admin.marketing.promoDeactivatedDesc": "Le code {code} n'est plus applicable.", + "admin.settings.fallback.title": "Chaîne de secours", + "admin.settings.fallback.desc": "Ordre de priorité pour la sélection automatique des fournisseurs.", + "admin.settings.fallback.classic": "Mode classique (Google)", + "admin.settings.fallback.llm": "Mode LLM (passerelles et accès directs)", + "admin.settings.fallback.note": "Seuls les canaux enregistrés (google, openai, deepseek, minimax) participent à cette chaîne ; les paliers IA et les autres canaux n'y participent pas.", + "admin.models.duplicateRouteTitle": "Doublon refusé", + "admin.models.duplicateRouteDesc": "Ce couple canal + modèle existe déjà dans la liste.", + "admin.marketing.promoDeactivateFailedTitle": "Désactivation refusée", + "admin.marketing.promoDelete": "Supprimer", + "admin.marketing.promoDeleteConfirm": "Supprimer définitivement le code {code} ?", + "admin.marketing.promoDeletedTitle": "Code supprimé", + "admin.marketing.promoDeletedDesc": "Le code {code} a été supprimé.", + "admin.marketing.promoDeleteFailedTitle": "Suppression refusée", + "admin.marketing.promoLinkStripe": "Relier à Stripe", + "admin.marketing.promoLinkedTitle": "Code relié à Stripe", + "admin.marketing.promoLinkedDesc": "Le code {code} est maintenant applicable au paiement.", + "admin.marketing.promoLinkFailedTitle": "Reliage impossible", + "admin.marketing.promoExpiryHint": "Un code « jusqu'au 10 » reste valable tout le 10 (fin : 23:59:59 UTC).", + "admin.marketing.generateEmpty": "La réponse du serveur était vide ou inexploitable." } diff --git a/frontend/src/lib/i18n/messages/fr/pricing.json b/frontend/src/lib/i18n/messages/fr/pricing.json index f261a41..62adda6 100644 --- a/frontend/src/lib/i18n/messages/fr/pricing.json +++ b/frontend/src/lib/i18n/messages/fr/pricing.json @@ -167,5 +167,17 @@ "pricing.aiModels.essential.price": "À partir de 0,09 $ par million de jetons", "pricing.aiModels.premium.modelName": "Claude 5", "pricing.aiModels.premium.alternatives": "Alternatives : DeepSeek V4 Pro et GLM-5.3", - "pricing.aiModels.premium.price": "2 $ / 10 $ par 1M de jetons" + "pricing.aiModels.premium.price": "2 $ / 10 $ par 1M de jetons", + "pricing.promo.label": "Code promo", + "pricing.promo.placeholder": "VOTRCODE", + "pricing.promo.apply": "Appliquer", + "pricing.promo.checking": "Vérification...", + "pricing.promo.valid": "Code valable", + "pricing.promo.subscriptionOnly": "Valable sur les abonnements, pas sur les crédits.", + "pricing.promoError.unknown": "Ce code promo n'existe pas.", + "pricing.promoError.inactive": "Ce code promo n'est plus actif.", + "pricing.promoError.expired": "Ce code promo a expiré.", + "pricing.promoError.exhausted": "Ce code promo a atteint sa limite d'utilisations.", + "pricing.promoError.plan": "Ce code promo n'est pas valable pour ce forfait.", + "pricing.promoError.local_only": "Ce code promo n'est pas relié au paiement en ligne." } diff --git a/frontend/src/lib/i18n/messages/it/admin.json b/frontend/src/lib/i18n/messages/it/admin.json index 4605c2f..991338d 100644 --- a/frontend/src/lib/i18n/messages/it/admin.json +++ b/frontend/src/lib/i18n/messages/it/admin.json @@ -145,5 +145,180 @@ "admin.stats.tiersSub": "Essenziale / Premium · classico {classic} · altro {other}", "admin.stats.unavailable": "Statistiche business non disponibili ({error}).", "admin.stats.waitlist": "Lista d'attesa", - "admin.stats.waitlistSub": "iscritti in attesa" + "admin.stats.waitlistSub": "iscritti in attesa", + "admin.settings.title": "Fornitori", + "admin.settings.subtitle": "Configura gli accessi ai fornitori. Le chiavi API possono anche essere definite nel file .env.", + "admin.settings.sectionClassic": "Traduzione classica", + "admin.settings.sectionClassicDesc": "Motori Google per la traduzione standard.", + "admin.settings.sectionDirect": "Fornitori IA diretti", + "admin.settings.sectionDirectDesc": "Accessi diretti compatibili OpenAI — utilizzabili come canale di un livello IA (pagina «Modelli e abbonamenti») o per la generazione delle email.", + "admin.settings.sectionGateway": "Gateway multi-modello", + "admin.settings.sectionGatewayDesc": "Una sola chiave, centinaia di modelli. I modelli per livello si gestiscono nella pagina «Modelli e abbonamenti».", + "admin.settings.sectionOcr": "OCR (PDF scansionati)", + "admin.settings.sectionOcrDesc": "Estrazione del testo dalle pagine immagine prima della traduzione.", + "admin.settings.sectionEmails": "Email", + "admin.settings.sectionEmailsDesc": "Server di invio per notifiche e promemoria.", + "admin.settings.enabled": "Attivato", + "admin.settings.disabled": "Disattivato", + "admin.settings.envBadge": "Chiave in .env", + "admin.settings.envConfigured": "Configurato in .env", + "admin.settings.envKeyPlaceholder": "Chiave configurata in .env (lasciare vuoto per usarla)", + "admin.settings.apiKey": "Chiave API", + "admin.settings.model": "Modello", + "admin.settings.baseUrl": "URL di base", + "admin.settings.test": "Prova", + "admin.settings.testing": "Test...", + "admin.settings.ok": "OK", + "admin.settings.error": "Errore", + "admin.settings.failure": "Fallito", + "admin.settings.sending": "Invio...", + "admin.settings.save": "Salva la configurazione", + "admin.settings.saving": "Salvataggio...", + "admin.settings.savedTitle": "✅ Configurazione salvata", + "admin.settings.savedDesc": "Le impostazioni sono state salvate correttamente.", + "admin.settings.saveErrorTitle": "Errore di salvataggio", + "admin.settings.loadErrorTitle": "Errore di caricamento", + "admin.settings.loadErrorDesc": "HTTP {status} — verifica il tuo token amministratore.", + "admin.settings.networkErrorTitle": "Errore di rete", + "admin.settings.networkErrorDesc": "Impossibile contattare il backend.", + "admin.settings.networkSaveErrorDesc": "Impossibile contattare il backend per salvare.", + "admin.settings.connectionOk": "Connessione OK", + "admin.settings.modelsFound": "{count} modelli {provider} trovati", + "admin.settings.providerError": "Errore {provider}", + "admin.settings.google.title": "Google Translate", + "admin.settings.google.desc": "Accesso web non ufficiale tramite deep_translator. Nessuna chiave richiesta, uso ragionevole consigliato.", + "admin.settings.googleCloud.title": "Google Cloud Translation (ufficiale)", + "admin.settings.googleCloud.desc": "API ufficiale di Google Cloud. 500 000 caratteri/mese gratuiti, poi circa 20 $ per milione di caratteri. Chiave API su console.cloud.google.com.", + "admin.settings.googleCloud.hint": "Attiva la Cloud Translation API nella Google Cloud Console, poi crea una chiave API limitata a questa API.", + "admin.settings.deepseek.title": "DeepSeek (diretto)", + "admin.settings.deepseek.desc": "API diretta DeepSeek, compatibile OpenAI. Chiave su platform.deepseek.com — utilizzabile come canale di un livello IA.", + "admin.settings.zhipu.title": "z.ai / Zhipu (GLM)", + "admin.settings.zhipu.desc": "Accesso diretto ai modelli GLM di z.ai, compatibile OpenAI. Chiave su z.ai — utilizzabile come canale di un livello IA.", + "admin.settings.zhipu.urlHint": "Predefinito: https://api.z.ai/api/paas/v4 — da cambiare solo se usi un proxy.", + "admin.settings.minimax.title": "MiniMax (diretto)", + "admin.settings.minimax.desc": "API pubblica MiniMax, compatibile OpenAI. Chiave su minimax.io — utilizzabile come canale di un livello IA.", + "admin.settings.openai.title": "OpenAI", + "admin.settings.openai.desc": "Traduzioni GPT. Ottieni una chiave su platform.openai.com.", + "admin.settings.zai.title": "xAI (Grok)", + "admin.settings.zai.desc": "Modelli Grok di xAI. API compatibile OpenAI. Ottieni la chiave su x.ai.", + "admin.settings.zai.urlHint": "Predefinito: https://api.x.ai/v1 — da cambiare solo se usi un proxy.", + "admin.settings.openrouter.title": "OpenRouter — chiave gateway", + "admin.settings.openrouter.desc": "Livello Essenziale (piano Pro). Gamma attuale: deepseek/deepseek-v4-flash, z-ai/glm-5.3-flash, minimax/minimax-m3.", + "admin.settings.openrouter.keyLabel": "Chiave API OpenRouter (condivisa con Premium)", + "admin.settings.openrouter.envSharedPlaceholder": "Chiave configurata in .env (condivisa con Premium)", + "admin.settings.openrouter.reminder": "Modelli gestiti nella pagina «Modelli e abbonamenti».", + "admin.settings.openrouterPremium.title": "OpenRouter Premium — chiave gateway", + "admin.settings.openrouterPremium.desc": "Livello Premium (piano Business). Gamma attuale: anthropic/claude-sonnet-5, deepseek/deepseek-v4-pro, z-ai/glm-5.3.", + "admin.settings.openrouterPremium.reminder": "Modelli gestiti nella pagina «Modelli e abbonamenti». Condivide la stessa chiave OpenRouter.", + "admin.settings.mistral.title": "OCR Mistral (PDF scansionati)", + "admin.settings.mistral.desc": "Estrae il testo dai PDF scansionati (pagine immagine) prima della traduzione. Senza chiave questi file vengono rifiutati con un messaggio chiaro. Circa 1 € / 1 000 pagine. Chiave: console.mistral.ai.", + "admin.settings.mistral.keyLabel": "Chiave API Mistral", + "admin.settings.mistral.modelLabel": "Modello OCR", + "admin.settings.mistral.modelHint": "Consigliato: mistral-ocr-latest", + "admin.settings.smtp.title": "Email SMTP", + "admin.settings.smtp.desc": "Configurazione del server SMTP per l'invio delle email (password dimenticata, notifiche, promemoria).", + "admin.settings.smtp.host": "Host SMTP", + "admin.settings.smtp.port": "Porta", + "admin.settings.smtp.username": "Nome utente", + "admin.settings.smtp.password": "Password", + "admin.settings.smtp.fromEmail": "Indirizzo mittente", + "admin.settings.smtp.useTls": "Usa TLS", + "admin.settings.smtp.sendTest": "Invia un'email di prova", + "admin.settings.smtp.sent": "Inviata", + "admin.settings.smtp.sendFailed": "Invio non riuscito", + "admin.models.channelOpenrouter": "OpenRouter", + "admin.models.channelOpenrouterPremium": "OpenRouter (Premium)", + "admin.models.channelDeepseek": "DeepSeek (diretto)", + "admin.models.channelZhipu": "z.ai (GLM)", + "admin.models.channelMinimax": "MiniMax (diretto)", + "admin.models.channelOpenai": "OpenAI", + "admin.models.channelZai": "xAI (Grok)", + "admin.models.customModelPlaceholder": "id-del-modello", + "admin.models.routeHint": "Il canale sceglie l'accesso diretto o il gateway; gli identificativi dei modelli cambiano da un canale all'altro.", + "admin.models.addRouteConfirm": "Aggiungi", + "admin.marketing.generateTitle": "Genera con l'IA", + "admin.marketing.generateDesc": "Il testo è scritto a partire dal piano marketing (posizionamento e cifre autorizzate). Nulla viene inviato automaticamente: rileggi e poi invia.", + "admin.marketing.channel": "Canale", + "admin.marketing.model": "Modello", + "admin.marketing.modelPlaceholder": "Predefinito del canale", + "admin.marketing.promoOptional": "Sconto (facoltativo)", + "admin.marketing.promoNone": "Nessuno", + "admin.marketing.brief": "Brief", + "admin.marketing.briefPlaceholder": "Es.: ricontatta gli iscritti alla lista d'attente ricordando i vantaggi del piano Pro.", + "admin.marketing.language": "Lingua dell'email", + "admin.marketing.tone": "Tono", + "admin.marketing.toneNone": "Neutro", + "admin.marketing.generateBtn": "Genera", + "admin.marketing.generating": "Generazione...", + "admin.marketing.generatedTitle": "Email generata", + "admin.marketing.generatedDesc": "Oggetto e HTML inseriti nel modulo — rileggi prima di inviare ({provider}).", + "admin.marketing.generateFailedTitle": "Generazione non riuscita", + "admin.marketing.briefRequired": "Descrivi prima il contenuto desiderato nel brief.", + "admin.marketing.langFr": "Francese", + "admin.marketing.langEn": "Inglese", + "admin.marketing.langDe": "Tedesco", + "admin.marketing.langEs": "Spagnolo", + "admin.marketing.langIt": "Italiano", + "admin.marketing.langPt": "Portoghese", + "admin.marketing.langNl": "Olandese", + "admin.marketing.langAr": "Arabo", + "admin.marketing.langFa": "Persiano", + "admin.marketing.langJa": "Giapponese", + "admin.marketing.langKo": "Coreano", + "admin.marketing.langRu": "Russo", + "admin.marketing.langZh": "Cinese", + "admin.marketing.toneFriendly": "Amichevole", + "admin.marketing.toneProfessional": "Professionale", + "admin.marketing.toneUrgent": "Urgente", + "admin.marketing.toneFestive": "Festivo", + "admin.marketing.promoTitle": "Sconti", + "admin.marketing.promoDesc": "Codici promo applicati al pagamento tramite Stripe; il contatore aumenta quando l'abbonamento è davvero completato.", + "admin.marketing.promoEmpty": "Ancora nessun codice promo.", + "admin.marketing.promoActive": "Attivo", + "admin.marketing.promoInactive": "Disattivato", + "admin.marketing.promoLocalOnly": "Fuori Stripe", + "admin.marketing.promoDeactivate": "Disattiva", + "admin.marketing.promoUsage": "{used}/{max} utilizzi", + "admin.marketing.promoPlansLabel": "Piani:", + "admin.marketing.promoAllPlans": "Tutti", + "admin.marketing.promoExpiresOn": "scade il {date}", + "admin.marketing.promoNeverExpires": "senza scadenza", + "admin.marketing.promoCreateTitle": "Crea uno sconto", + "admin.marketing.promoCreateDesc": "Il codice viene creato anche su Stripe (coupon + codice promozionale) quando Stripe è configurato.", + "admin.marketing.promoCode": "Codice", + "admin.marketing.promoType": "Tipo", + "admin.marketing.promoTypePercent": "Percentuale", + "admin.marketing.promoTypeAmount": "Importo (EUR)", + "admin.marketing.promoValue": "Valore", + "admin.marketing.promoPlans": "Piani idonei", + "admin.marketing.promoMaxRedemptions": "Limite di utilizzi", + "admin.marketing.promoMaxPlaceholder": "Illimitato", + "admin.marketing.promoExpires": "Scadenza", + "admin.marketing.promoCreate": "Crea il codice", + "admin.marketing.promoCreating": "Creazione...", + "admin.marketing.promoCreatedTitle": "Codice promo creato", + "admin.marketing.promoCreatedDesc": "Il codice {code} è pronto.", + "admin.marketing.promoCreateFailedTitle": "Creazione rifiutata", + "admin.marketing.promoInvalidInput": "Verifica il codice e il valore.", + "admin.marketing.promoDeactivatedTitle": "Codice disattivato", + "admin.marketing.promoDeactivatedDesc": "Il codice {code} non è più applicabile.", + "admin.settings.fallback.title": "Catena di soccorso", + "admin.settings.fallback.desc": "Ordine di priorità per la selezione automatica dei fornitori.", + "admin.settings.fallback.classic": "Modalità classica (Google)", + "admin.settings.fallback.llm": "Modalità LLM (gateway e accessi diretti)", + "admin.settings.fallback.note": "Solo i canali registrati (google, openai, deepseek, minimax) partecipano a questa catena; i livelli IA e gli altri canali no.", + "admin.models.duplicateRouteTitle": "Duplicato rifiutato", + "admin.models.duplicateRouteDesc": "Questa coppia canale + modello è già nell'elenco.", + "admin.marketing.promoDeactivateFailedTitle": "Disattivazione rifiutata", + "admin.marketing.promoDelete": "Elimina", + "admin.marketing.promoDeleteConfirm": "Eliminare definitivamente il codice {code}?", + "admin.marketing.promoDeletedTitle": "Codice eliminato", + "admin.marketing.promoDeletedDesc": "Il codice {code} è stato eliminato.", + "admin.marketing.promoDeleteFailedTitle": "Eliminazione rifiutata", + "admin.marketing.promoLinkStripe": "Collega a Stripe", + "admin.marketing.promoLinkedTitle": "Codice collegato a Stripe", + "admin.marketing.promoLinkedDesc": "Il codice {code} ora è applicabile al pagamento.", + "admin.marketing.promoLinkFailedTitle": "Collegamento non riuscito", + "admin.marketing.promoExpiryHint": "Un codice «fino al 10» resta valido tutto il 10 (fine: 23:59:59 UTC).", + "admin.marketing.generateEmpty": "La risposta del server era vuota o inutilizzabile." } diff --git a/frontend/src/lib/i18n/messages/it/pricing.json b/frontend/src/lib/i18n/messages/it/pricing.json index 2ff32bf..10fa367 100644 --- a/frontend/src/lib/i18n/messages/it/pricing.json +++ b/frontend/src/lib/i18n/messages/it/pricing.json @@ -167,5 +167,17 @@ "pricing.header.titleBase": "Un piano per", "pricing.header.titleAccent": "ogni esigenza", "pricing.toast.close": "Chiudi", - "pricing.aiModels.premium.price": "2 $ / 10 $ per 1M di token" + "pricing.aiModels.premium.price": "2 $ / 10 $ per 1M di token", + "pricing.promo.label": "Codice promo", + "pricing.promo.placeholder": "TUOCODICE", + "pricing.promo.apply": "Applica", + "pricing.promo.checking": "Verifica...", + "pricing.promo.valid": "Codice valido", + "pricing.promo.subscriptionOnly": "Valido sugli abbonamenti, non sui crediti.", + "pricing.promoError.unknown": "Questo codice promo non esiste.", + "pricing.promoError.inactive": "Questo codice promo non è più attivo.", + "pricing.promoError.expired": "Questo codice promo è scaduto.", + "pricing.promoError.exhausted": "Questo codice promo ha raggiunto il limite di utilizzi.", + "pricing.promoError.plan": "Questo codice promo non è valido per questo piano.", + "pricing.promoError.local_only": "Questo codice promo non è abilitato al pagamento online." } diff --git a/frontend/src/lib/i18n/messages/ja/admin.json b/frontend/src/lib/i18n/messages/ja/admin.json index ea84fa8..73edbc8 100644 --- a/frontend/src/lib/i18n/messages/ja/admin.json +++ b/frontend/src/lib/i18n/messages/ja/admin.json @@ -145,5 +145,180 @@ "admin.stats.tiersSub": "エッセンシャル / プレミアム · クラシック {classic} · その他 {other}", "admin.stats.unavailable": "ビジネス統計を利用できません({error})。", "admin.stats.waitlist": "ウェイトリスト", - "admin.stats.waitlistSub": "待機中の登録者" + "admin.stats.waitlistSub": "待機中の登録者", + "admin.settings.title": "プロバイダー", + "admin.settings.subtitle": "プロバイダーへのアクセスを設定します。APIキーは.envファイルにも設定できます。", + "admin.settings.sectionClassic": "クラシック翻訳", + "admin.settings.sectionClassicDesc": "標準翻訳用のGoogleエンジン。", + "admin.settings.sectionDirect": "直接AIプロバイダー", + "admin.settings.sectionDirectDesc": "OpenAI互換の直接アクセス — AIティアのチャネル(「モデルとサブスクリプション」ページ)やメール生成に使用できます。", + "admin.settings.sectionGateway": "マルチモデルゲートウェイ", + "admin.settings.sectionGatewayDesc": "鍵は1つで、モデルは数百。ティアごとのモデルは「モデルとサブスクリプション」ページで管理します。", + "admin.settings.sectionOcr": "OCR(スキャンPDF)", + "admin.settings.sectionOcrDesc": "翻訳前に画像ページからテキストを抽出します。", + "admin.settings.sectionEmails": "メール", + "admin.settings.sectionEmailsDesc": "通知やフォローアップメールを送るサーバー。", + "admin.settings.enabled": "有効", + "admin.settings.disabled": "無効", + "admin.settings.envBadge": ".envのキー", + "admin.settings.envConfigured": ".envに設定済み", + "admin.settings.envKeyPlaceholder": ".envに設定済みのキー(使う場合は空のまま)", + "admin.settings.apiKey": "APIキー", + "admin.settings.model": "モデル", + "admin.settings.baseUrl": "ベースURL", + "admin.settings.test": "テスト", + "admin.settings.testing": "テスト中...", + "admin.settings.ok": "OK", + "admin.settings.error": "エラー", + "admin.settings.failure": "失敗", + "admin.settings.sending": "送信中...", + "admin.settings.save": "設定を保存", + "admin.settings.saving": "保存中...", + "admin.settings.savedTitle": "✅ 設定を保存しました", + "admin.settings.savedDesc": "設定は正常に保存されました。", + "admin.settings.saveErrorTitle": "保存エラー", + "admin.settings.loadErrorTitle": "読み込みエラー", + "admin.settings.loadErrorDesc": "HTTP {status} — 管理者トークンを確認してください。", + "admin.settings.networkErrorTitle": "ネットワークエラー", + "admin.settings.networkErrorDesc": "バックエンドに接続できません。", + "admin.settings.networkSaveErrorDesc": "保存のためバックエンドに接続できません。", + "admin.settings.connectionOk": "接続OK", + "admin.settings.modelsFound": "{provider}のモデルが{count}件見つかりました", + "admin.settings.providerError": "{provider}エラー", + "admin.settings.google.title": "Google Translate", + "admin.settings.google.desc": "deep_translator経由の非公式ウェブアクセス。キー不要、適度な利用を推奨。", + "admin.settings.googleCloud.title": "Google Cloud Translation(公式)", + "admin.settings.googleCloud.desc": "Google Cloudの公式API。毎月50万文字まで無料、その後は約100万文字あたり20ドル。キーはconsole.cloud.google.comで取得。", + "admin.settings.googleCloud.hint": "Google Cloud ConsoleでCloud Translation APIを有効にし、そのAPIに限定したAPIキーを作成してください。", + "admin.settings.deepseek.title": "DeepSeek(直接)", + "admin.settings.deepseek.desc": "OpenAI互換のDeepSeek直接API。キーはplatform.deepseek.comで取得 — AIティアのチャネルとして使用可能。", + "admin.settings.zhipu.title": "z.ai / Zhipu(GLM)", + "admin.settings.zhipu.desc": "z.aiのGLMモデルへの直接アクセス、OpenAI互換。キーはz.aiで取得 — AIティアのチャネルとして使用可能。", + "admin.settings.zhipu.urlHint": "既定:https://api.z.ai/api/paas/v4 — プロキシを使う場合のみ変更。", + "admin.settings.minimax.title": "MiniMax(直接)", + "admin.settings.minimax.desc": "OpenAI互換のMiniMax公開API。キーはminimax.ioで取得 — AIティアのチャネルとして使用可能。", + "admin.settings.openai.title": "OpenAI", + "admin.settings.openai.desc": "GPT翻訳。platform.openai.comでキーを取得してください。", + "admin.settings.zai.title": "xAI(Grok)", + "admin.settings.zai.desc": "xAIのGrokモデル。OpenAI互換API。キーはx.aiで取得してください。", + "admin.settings.zai.urlHint": "既定:https://api.x.ai/v1 — プロキシを使う場合のみ変更。", + "admin.settings.openrouter.title": "OpenRouter — ゲートウェイキー", + "admin.settings.openrouter.desc": "Essentialティア(Proプラン)。現在のラインナップ:deepseek/deepseek-v4-flash、z-ai/glm-5.3-flash、minimax/minimax-m3。", + "admin.settings.openrouter.keyLabel": "OpenRouter APIキー(Premiumと共有)", + "admin.settings.openrouter.envSharedPlaceholder": ".envに設定済みのキー(Premiumと共有)", + "admin.settings.openrouter.reminder": "モデルは「モデルとサブスクリプション」ページで管理します。", + "admin.settings.openrouterPremium.title": "OpenRouter Premium — ゲートウェイキー", + "admin.settings.openrouterPremium.desc": "Premiumティア(Businessプラン)。現在のラインナップ:anthropic/claude-sonnet-5、deepseek/deepseek-v4-pro、z-ai/glm-5.3。", + "admin.settings.openrouterPremium.reminder": "モデルは「モデルとサブスクリプション」ページで管理します。同じOpenRouterキーを共有します。", + "admin.settings.mistral.title": "Mistral OCR(スキャンPDF)", + "admin.settings.mistral.desc": "スキャンPDF(画像ページ)から翻訳前にテキストを抽出します。キーがない場合、これらのファイルは明確なメッセージ付きで拒否されます。約1ユーロ/1,000ページ。キー:console.mistral.ai。", + "admin.settings.mistral.keyLabel": "Mistral APIキー", + "admin.settings.mistral.modelLabel": "OCRモデル", + "admin.settings.mistral.modelHint": "推奨:mistral-ocr-latest", + "admin.settings.smtp.title": "SMTPメール", + "admin.settings.smtp.desc": "メール送信のためのSMTPサーバー設定(パスワード再設定、通知、フォローアップ)。", + "admin.settings.smtp.host": "SMTPホスト", + "admin.settings.smtp.port": "ポート", + "admin.settings.smtp.username": "ユーザー名", + "admin.settings.smtp.password": "パスワード", + "admin.settings.smtp.fromEmail": "送信元アドレス", + "admin.settings.smtp.useTls": "TLSを使用", + "admin.settings.smtp.sendTest": "テストメールを送信", + "admin.settings.smtp.sent": "送信済み", + "admin.settings.smtp.sendFailed": "送信に失敗しました", + "admin.models.channelOpenrouter": "OpenRouter", + "admin.models.channelOpenrouterPremium": "OpenRouter(Premium)", + "admin.models.channelDeepseek": "DeepSeek(直接)", + "admin.models.channelZhipu": "z.ai(GLM)", + "admin.models.channelMinimax": "MiniMax(直接)", + "admin.models.channelOpenai": "OpenAI", + "admin.models.channelZai": "xAI(Grok)", + "admin.models.customModelPlaceholder": "モデルID", + "admin.models.routeHint": "チャネルで直接アクセスかゲートウェイかを選びます。モデルIDはチャネルごとに異なります。", + "admin.models.addRouteConfirm": "追加", + "admin.marketing.generateTitle": "AIで生成", + "admin.marketing.generateDesc": "文章はマーケティングプラン(ポジショニングと許可された数値)に基づいて書かれます。自動送信は一切ありません。確認してから送信してください。", + "admin.marketing.channel": "チャネル", + "admin.marketing.model": "モデル", + "admin.marketing.modelPlaceholder": "チャネルの既定", + "admin.marketing.promoOptional": "割引(任意)", + "admin.marketing.promoNone": "なし", + "admin.marketing.brief": "概要", + "admin.marketing.briefPlaceholder": "例:ウェイティングリスト登録者にProプランの特典を思い出してもらう。", + "admin.marketing.language": "メールの言語", + "admin.marketing.tone": "トーン", + "admin.marketing.toneNone": "標準", + "admin.marketing.generateBtn": "生成", + "admin.marketing.generating": "生成中...", + "admin.marketing.generatedTitle": "メールを生成しました", + "admin.marketing.generatedDesc": "件名とHTMLをフォームに挿入しました — 送信前に確認してください({provider})。", + "admin.marketing.generateFailedTitle": "生成できませんでした", + "admin.marketing.briefRequired": "まず概要に希望する内容を記入してください。", + "admin.marketing.langFr": "フランス語", + "admin.marketing.langEn": "英語", + "admin.marketing.langDe": "ドイツ語", + "admin.marketing.langEs": "スペイン語", + "admin.marketing.langIt": "イタリア語", + "admin.marketing.langPt": "ポルトガル語", + "admin.marketing.langNl": "オランダ語", + "admin.marketing.langAr": "アラビア語", + "admin.marketing.langFa": "ペルシャ語", + "admin.marketing.langJa": "日本語", + "admin.marketing.langKo": "韓国語", + "admin.marketing.langRu": "ロシア語", + "admin.marketing.langZh": "中国語", + "admin.marketing.toneFriendly": "親しみやすく", + "admin.marketing.toneProfessional": "プロフェッショナル", + "admin.marketing.toneUrgent": "緊急", + "admin.marketing.toneFestive": "お祝い", + "admin.marketing.promoTitle": "割引", + "admin.marketing.promoDesc": "プロモコードはStripeの支払いに適用されます。カウンターはサブスクリプションが実際に完了したときだけ増えます。", + "admin.marketing.promoEmpty": "プロモコードはまだありません。", + "admin.marketing.promoActive": "有効", + "admin.marketing.promoInactive": "無効", + "admin.marketing.promoLocalOnly": "Stripe外", + "admin.marketing.promoDeactivate": "無効化", + "admin.marketing.promoUsage": "{used}/{max}回使用", + "admin.marketing.promoPlansLabel": "プラン:", + "admin.marketing.promoAllPlans": "すべて", + "admin.marketing.promoExpiresOn": "{date}に期限切れ", + "admin.marketing.promoNeverExpires": "期限なし", + "admin.marketing.promoCreateTitle": "割引を作成", + "admin.marketing.promoCreateDesc": "Stripeが設定されている場合、コードはStripeにも作成されます(クーポン+プロモコード)。", + "admin.marketing.promoCode": "コード", + "admin.marketing.promoType": "種類", + "admin.marketing.promoTypePercent": "パーセント", + "admin.marketing.promoTypeAmount": "金額(EUR)", + "admin.marketing.promoValue": "値", + "admin.marketing.promoPlans": "対象プラン", + "admin.marketing.promoMaxRedemptions": "使用上限", + "admin.marketing.promoMaxPlaceholder": "無制限", + "admin.marketing.promoExpires": "有効期限", + "admin.marketing.promoCreate": "コードを作成", + "admin.marketing.promoCreating": "作成中...", + "admin.marketing.promoCreatedTitle": "プロモコードを作成しました", + "admin.marketing.promoCreatedDesc": "コード{code}は準備できました。", + "admin.marketing.promoCreateFailedTitle": "作成できませんでした", + "admin.marketing.promoInvalidInput": "コードと値を確認してください。", + "admin.marketing.promoDeactivatedTitle": "コードを無効化しました", + "admin.marketing.promoDeactivatedDesc": "コード{code}はもう適用されません。", + "admin.settings.fallback.title": "フォールバックチェーン", + "admin.settings.fallback.desc": "プロバイダー自動選択の優先順位。", + "admin.settings.fallback.classic": "クラシックモード(Google)", + "admin.settings.fallback.llm": "LLMモード(ゲートウェイと直接アクセス)", + "admin.settings.fallback.note": "このチェーンに参加するのは登録済みチャネル(google、openai、deepseek、minimax)のみです。AIティアや他のチャネルは参加しません。", + "admin.models.duplicateRouteTitle": "重複は拒否されました", + "admin.models.duplicateRouteDesc": "そのチャネル+モデルの組み合わせは既にリストにあります。", + "admin.marketing.promoDeactivateFailedTitle": "無効化できませんでした", + "admin.marketing.promoDelete": "削除", + "admin.marketing.promoDeleteConfirm": "コード{code}を完全に削除しますか?", + "admin.marketing.promoDeletedTitle": "コードを削除しました", + "admin.marketing.promoDeletedDesc": "コード{code}を削除しました。", + "admin.marketing.promoDeleteFailedTitle": "削除できませんでした", + "admin.marketing.promoLinkStripe": "Stripeにリンク", + "admin.marketing.promoLinkedTitle": "コードをStripeにリンクしました", + "admin.marketing.promoLinkedDesc": "コード{code}は決済で使えるようになりました。", + "admin.marketing.promoLinkFailedTitle": "リンクできませんでした", + "admin.marketing.promoExpiryHint": "「10日まで」のコードは10日いっぱい(23:59:59 UTCまで)有効です。", + "admin.marketing.generateEmpty": "サーバーの応答が空か、利用できませんでした。" } diff --git a/frontend/src/lib/i18n/messages/ja/pricing.json b/frontend/src/lib/i18n/messages/ja/pricing.json index aace9c5..f0cf8e5 100644 --- a/frontend/src/lib/i18n/messages/ja/pricing.json +++ b/frontend/src/lib/i18n/messages/ja/pricing.json @@ -167,5 +167,17 @@ "pricing.header.titleBase": "あらゆるニーズに", "pricing.header.titleAccent": "ぴったりのプラン", "pricing.toast.close": "閉じる", - "pricing.aiModels.premium.price": "100万トークンあたり $2 / $10" + "pricing.aiModels.premium.price": "100万トークンあたり $2 / $10", + "pricing.promo.label": "プロモコード", + "pricing.promo.placeholder": "あなたのコード", + "pricing.promo.apply": "適用", + "pricing.promo.checking": "確認中...", + "pricing.promo.valid": "コードは有効です", + "pricing.promo.subscriptionOnly": "サブスクリプションのみ有効。クレジットには使えません。", + "pricing.promoError.unknown": "このプロモコードは存在しません。", + "pricing.promoError.inactive": "このプロモコードは無効になっています。", + "pricing.promoError.expired": "このプロモコードは期限切れです。", + "pricing.promoError.exhausted": "このプロモコードは使用上限に達しました。", + "pricing.promoError.plan": "このプロモコードはこのプランには使えません。", + "pricing.promoError.local_only": "このプロモコードはオンライン決済に対応していません。" } diff --git a/frontend/src/lib/i18n/messages/ko/admin.json b/frontend/src/lib/i18n/messages/ko/admin.json index 9c23a00..db16e7a 100644 --- a/frontend/src/lib/i18n/messages/ko/admin.json +++ b/frontend/src/lib/i18n/messages/ko/admin.json @@ -145,5 +145,180 @@ "admin.stats.tiersSub": "에센셜 / 프리미엄 · 클래식 {classic} · 기타 {other}", "admin.stats.unavailable": "비즈니스 통계를 사용할 수 없습니다 ({error}).", "admin.stats.waitlist": "대기 명단", - "admin.stats.waitlistSub": "대기 중인 등록자" + "admin.stats.waitlistSub": "대기 중인 등록자", + "admin.settings.title": "공급자", + "admin.settings.subtitle": "공급자 접근을 설정합니다. API 키는 .env 파일에서도 설정할 수 있습니다.", + "admin.settings.sectionClassic": "클래식 번역", + "admin.settings.sectionClassicDesc": "표준 번역을 위한 Google 엔진.", + "admin.settings.sectionDirect": "직접 AI 공급자", + "admin.settings.sectionDirectDesc": "OpenAI 호환 직접 접근 — AI 등급의 채널(「모델 및 구독」 페이지)이나 이메일 생성에 사용할 수 있습니다.", + "admin.settings.sectionGateway": "멀티 모델 게이트웨이", + "admin.settings.sectionGatewayDesc": "키 하나로 수백 가지 모델. 등급별 모델은 「모델 및 구독」 페이지에서 관리합니다.", + "admin.settings.sectionOcr": "OCR (스캔 PDF)", + "admin.settings.sectionOcrDesc": "번역 전에 이미지 페이지에서 텍스트를 추출합니다.", + "admin.settings.sectionEmails": "이메일", + "admin.settings.sectionEmailsDesc": "알림과 후속 이메일을 보내는 서버.", + "admin.settings.enabled": "사용 함", + "admin.settings.disabled": "사용 안 함", + "admin.settings.envBadge": ".env의 키", + "admin.settings.envConfigured": ".env에 설정됨", + "admin.settings.envKeyPlaceholder": ".env에 설정된 키(사용하려면 비워 두세요)", + "admin.settings.apiKey": "API 키", + "admin.settings.model": "모델", + "admin.settings.baseUrl": "기본 URL", + "admin.settings.test": "테스트", + "admin.settings.testing": "테스트 중...", + "admin.settings.ok": "확인", + "admin.settings.error": "오류", + "admin.settings.failure": "실패", + "admin.settings.sending": "보내는 중...", + "admin.settings.save": "설정 저장", + "admin.settings.saving": "저장 중...", + "admin.settings.savedTitle": "✅ 설정이 저장되었습니다", + "admin.settings.savedDesc": "설정이 성공적으로 저장되었습니다.", + "admin.settings.saveErrorTitle": "저장 오류", + "admin.settings.loadErrorTitle": "불러오기 오류", + "admin.settings.loadErrorDesc": "HTTP {status} — 관리자 토큰을 확인하세요.", + "admin.settings.networkErrorTitle": "네트워크 오류", + "admin.settings.networkErrorDesc": "백엔드에 연결할 수 없습니다.", + "admin.settings.networkSaveErrorDesc": "저장을 위해 백엔드에 연결할 수 없습니다.", + "admin.settings.connectionOk": "연결 정상", + "admin.settings.modelsFound": "{provider} 모델 {count}개를 찾았습니다", + "admin.settings.providerError": "{provider} 오류", + "admin.settings.google.title": "Google Translate", + "admin.settings.google.desc": "deep_translator를 통한 비공식 웹 접근. 키가 필요 없으며 적당한 사용을 권장합니다.", + "admin.settings.googleCloud.title": "Google Cloud Translation (공식)", + "admin.settings.googleCloud.desc": "Google Cloud 공식 API. 매월 50만 자 무료, 이후 백만 자당 약 20달러. 키는 console.cloud.google.com에서 발급.", + "admin.settings.googleCloud.hint": "Google Cloud Console에서 Cloud Translation API를 활성화하고 해당 API로 제한된 API 키를 만드세요.", + "admin.settings.deepseek.title": "DeepSeek (직접)", + "admin.settings.deepseek.desc": "OpenAI 호환 DeepSeek 직접 API. 키는 platform.deepseek.com에서 — AI 등급 채널로 사용 가능.", + "admin.settings.zhipu.title": "z.ai / Zhipu (GLM)", + "admin.settings.zhipu.desc": "z.ai의 GLM 모델 직접 접근, OpenAI 호환. 키는 z.ai에서 — AI 등급 채널로 사용 가능.", + "admin.settings.zhipu.urlHint": "기본값: https://api.z.ai/api/paas/v4 — 프록시를 사용할 때만 변경하세요.", + "admin.settings.minimax.title": "MiniMax (직접)", + "admin.settings.minimax.desc": "OpenAI 호환 MiniMax 공개 API. 키는 minimax.io에서 — AI 등급 채널로 사용 가능.", + "admin.settings.openai.title": "OpenAI", + "admin.settings.openai.desc": "GPT 번역. platform.openai.com에서 키를 받으세요.", + "admin.settings.zai.title": "xAI (Grok)", + "admin.settings.zai.desc": "xAI의 Grok 모델. OpenAI 호환 API. x.ai에서 키를 받으세요.", + "admin.settings.zai.urlHint": "기본값: https://api.x.ai/v1 — 프록시를 사용할 때만 변경하세요.", + "admin.settings.openrouter.title": "OpenRouter — 게이트웨이 키", + "admin.settings.openrouter.desc": "Essential 등급 (Pro 플랜). 현재 라인업: deepseek/deepseek-v4-flash, z-ai/glm-5.3-flash, minimax/minimax-m3.", + "admin.settings.openrouter.keyLabel": "OpenRouter API 키 (Premium과 공유)", + "admin.settings.openrouter.envSharedPlaceholder": ".env에 설정된 키(Premium과 공유)", + "admin.settings.openrouter.reminder": "모델은 「모델 및 구독」 페이지에서 관리합니다.", + "admin.settings.openrouterPremium.title": "OpenRouter Premium — 게이트웨이 키", + "admin.settings.openrouterPremium.desc": "Premium 등급 (Business 플랜). 현재 라인업: anthropic/claude-sonnet-5, deepseek/deepseek-v4-pro, z-ai/glm-5.3.", + "admin.settings.openrouterPremium.reminder": "모델은 「모델 및 구독」 페이지에서 관리합니다. 같은 OpenRouter 키를 공유합니다.", + "admin.settings.mistral.title": "Mistral OCR (스캔 PDF)", + "admin.settings.mistral.desc": "번역 전에 스캔된 PDF(이미지 페이지)에서 텍스트를 추출합니다. 키가 없으면 이런 파일은 명확한 안내와 함께 거부됩니다. 약 1유로/1,000페이지. 키: console.mistral.ai.", + "admin.settings.mistral.keyLabel": "Mistral API 키", + "admin.settings.mistral.modelLabel": "OCR 모델", + "admin.settings.mistral.modelHint": "권장: mistral-ocr-latest", + "admin.settings.smtp.title": "SMTP 이메일", + "admin.settings.smtp.desc": "이메일 발송을 위한 SMTP 서버 설정(비밀번호 찾기, 알림, 후속 이메일).", + "admin.settings.smtp.host": "SMTP 호스트", + "admin.settings.smtp.port": "포트", + "admin.settings.smtp.username": "사용자 이름", + "admin.settings.smtp.password": "비밀번호", + "admin.settings.smtp.fromEmail": "발신 주소", + "admin.settings.smtp.useTls": "TLS 사용", + "admin.settings.smtp.sendTest": "테스트 이메일 보내기", + "admin.settings.smtp.sent": "보냄", + "admin.settings.smtp.sendFailed": "보내기 실패", + "admin.models.channelOpenrouter": "OpenRouter", + "admin.models.channelOpenrouterPremium": "OpenRouter (Premium)", + "admin.models.channelDeepseek": "DeepSeek (직접)", + "admin.models.channelZhipu": "z.ai (GLM)", + "admin.models.channelMinimax": "MiniMax (직접)", + "admin.models.channelOpenai": "OpenAI", + "admin.models.channelZai": "xAI (Grok)", + "admin.models.customModelPlaceholder": "모델 ID", + "admin.models.routeHint": "채널은 직접 접근과 게이트웨이 중 하나를 고릅니다. 모델 ID는 채널마다 다릅니다.", + "admin.models.addRouteConfirm": "추가", + "admin.marketing.generateTitle": "AI로 생성", + "admin.marketing.generateDesc": "문안은 마케팅 계획(포지셔닝과 허용된 수치)에 따라 작성됩니다. 아무것도 자동 발송되지 않습니다. 확인 후 보내세요.", + "admin.marketing.channel": "채널", + "admin.marketing.model": "모델", + "admin.marketing.modelPlaceholder": "채널 기본값", + "admin.marketing.promoOptional": "할인 (선택)", + "admin.marketing.promoNone": "없음", + "admin.marketing.brief": "브리프", + "admin.marketing.briefPlaceholder": "예: 대기자 명단 등록자에게 Pro 플랜의 장점을 상기시키기.", + "admin.marketing.language": "이메일 언어", + "admin.marketing.tone": "어조", + "admin.marketing.toneNone": "중립", + "admin.marketing.generateBtn": "생성", + "admin.marketing.generating": "생성 중...", + "admin.marketing.generatedTitle": "이메일이 생성되었습니다", + "admin.marketing.generatedDesc": "제목과 HTML이 폼에 삽입되었습니다 — 보내기 전에 확인하세요({provider}).", + "admin.marketing.generateFailedTitle": "생성 실패", + "admin.marketing.briefRequired": "먼저 브리프에 원하는 내용을 적어주세요.", + "admin.marketing.langFr": "프랑스어", + "admin.marketing.langEn": "영어", + "admin.marketing.langDe": "독일어", + "admin.marketing.langEs": "스페인어", + "admin.marketing.langIt": "이탈리아어", + "admin.marketing.langPt": "포르투갈어", + "admin.marketing.langNl": "네덜란드어", + "admin.marketing.langAr": "아랍어", + "admin.marketing.langFa": "페르시아어", + "admin.marketing.langJa": "일본어", + "admin.marketing.langKo": "한국어", + "admin.marketing.langRu": "러시아어", + "admin.marketing.langZh": "중국어", + "admin.marketing.toneFriendly": "친근함", + "admin.marketing.toneProfessional": "전문적", + "admin.marketing.toneUrgent": "긴급", + "admin.marketing.toneFestive": "축제", + "admin.marketing.promoTitle": "할인", + "admin.marketing.promoDesc": "프로모 코드는 Stripe 결제에 적용됩니다. 카운터는 구독이 실제로 완료되었을 때만 올라갑니다.", + "admin.marketing.promoEmpty": "아직 프로모 코드가 없습니다.", + "admin.marketing.promoActive": "활성", + "admin.marketing.promoInactive": "비활성", + "admin.marketing.promoLocalOnly": "Stripe 외부", + "admin.marketing.promoDeactivate": "비활성화", + "admin.marketing.promoUsage": "{used}/{max}회 사용", + "admin.marketing.promoPlansLabel": "플랜:", + "admin.marketing.promoAllPlans": "전체", + "admin.marketing.promoExpiresOn": "{date}에 만료", + "admin.marketing.promoNeverExpires": "만료 없음", + "admin.marketing.promoCreateTitle": "할인 만들기", + "admin.marketing.promoCreateDesc": "Stripe가 설정되어 있으면 코드는 Stripe에도 생성됩니다(쿠폰 + 프로모 코드).", + "admin.marketing.promoCode": "코드", + "admin.marketing.promoType": "유형", + "admin.marketing.promoTypePercent": "비율", + "admin.marketing.promoTypeAmount": "금액 (EUR)", + "admin.marketing.promoValue": "값", + "admin.marketing.promoPlans": "대상 플랜", + "admin.marketing.promoMaxRedemptions": "사용 한도", + "admin.marketing.promoMaxPlaceholder": "무제한", + "admin.marketing.promoExpires": "만료", + "admin.marketing.promoCreate": "코드 만들기", + "admin.marketing.promoCreating": "만드는 중...", + "admin.marketing.promoCreatedTitle": "프로모 코드 생성됨", + "admin.marketing.promoCreatedDesc": "코드 {code}이(가) 준비되었습니다.", + "admin.marketing.promoCreateFailedTitle": "생성이 거부되었습니다", + "admin.marketing.promoInvalidInput": "코드와 값을 확인하세요.", + "admin.marketing.promoDeactivatedTitle": "코드가 비활성화되었습니다", + "admin.marketing.promoDeactivatedDesc": "코드 {code}은(는) 더 이상 적용되지 않습니다.", + "admin.settings.fallback.title": "폴백 체인", + "admin.settings.fallback.desc": "공급자 자동 선택의 우선순위.", + "admin.settings.fallback.classic": "클래식 모드 (Google)", + "admin.settings.fallback.llm": "LLM 모드 (게이트웨이 및 직접 접근)", + "admin.settings.fallback.note": "등록된 채널(google, openai, deepseek, minimax)만 이 체인에 참여합니다. AI 등급과 다른 채널은 참여하지 않습니다.", + "admin.models.duplicateRouteTitle": "중복 거부됨", + "admin.models.duplicateRouteDesc": "그 채널 + 모델 조합은 이미 목록에 있습니다.", + "admin.marketing.promoDeactivateFailedTitle": "비활성화 거부됨", + "admin.marketing.promoDelete": "삭제", + "admin.marketing.promoDeleteConfirm": "코드 {code}을(를) 완전히 삭제할까요?", + "admin.marketing.promoDeletedTitle": "코드 삭제됨", + "admin.marketing.promoDeletedDesc": "코드 {code}이(가) 삭제되었습니다.", + "admin.marketing.promoDeleteFailedTitle": "삭제 거부됨", + "admin.marketing.promoLinkStripe": "Stripe에 연결", + "admin.marketing.promoLinkedTitle": "코드가 Stripe에 연결됨", + "admin.marketing.promoLinkedDesc": "코드 {code}은(는) 이제 결제에 적용할 수 있습니다.", + "admin.marketing.promoLinkFailedTitle": "연결 실패", + "admin.marketing.promoExpiryHint": "«10일까지» 코드는 10일 하루 종일 유효합니다(23:59:59 UTC 종료).", + "admin.marketing.generateEmpty": "서버 응답이 비어 있거나 사용할 수 없습니다." } diff --git a/frontend/src/lib/i18n/messages/ko/pricing.json b/frontend/src/lib/i18n/messages/ko/pricing.json index a749137..0c4895d 100644 --- a/frontend/src/lib/i18n/messages/ko/pricing.json +++ b/frontend/src/lib/i18n/messages/ko/pricing.json @@ -167,5 +167,17 @@ "pricing.header.titleBase": "모든 필요를 위한", "pricing.header.titleAccent": "하나의 플랜", "pricing.toast.close": "닫기", - "pricing.aiModels.premium.price": "백만 토큰당 $2 / $10" + "pricing.aiModels.premium.price": "백만 토큰당 $2 / $10", + "pricing.promo.label": "프로모 코드", + "pricing.promo.placeholder": "내코드", + "pricing.promo.apply": "적용", + "pricing.promo.checking": "확인 중...", + "pricing.promo.valid": "유효한 코드", + "pricing.promo.subscriptionOnly": "구독에만 유효하며 크레딧에는 사용할 수 없습니다.", + "pricing.promoError.unknown": "이 프로모 코드는 존재하지 않습니다.", + "pricing.promoError.inactive": "이 프로모 코드는 더 이상 활성 상태가 아닙니다.", + "pricing.promoError.expired": "이 프로모 코드는 만료되었습니다.", + "pricing.promoError.exhausted": "이 프로모 코드는 사용 한도에 도달했습니다.", + "pricing.promoError.plan": "이 프로모 코드는 이 플랜에 적용되지 않습니다.", + "pricing.promoError.local_only": "이 프로모 코드는 온라인 결제에 연결되어 있지 않습니다." } diff --git a/frontend/src/lib/i18n/messages/nl/admin.json b/frontend/src/lib/i18n/messages/nl/admin.json index fe58eee..bd75aa8 100644 --- a/frontend/src/lib/i18n/messages/nl/admin.json +++ b/frontend/src/lib/i18n/messages/nl/admin.json @@ -145,5 +145,180 @@ "admin.stats.tiersSub": "Basis / Premium · klassiek {classic} · overig {other}", "admin.stats.unavailable": "Bedrijfsstatistieken niet beschikbaar ({error}).", "admin.stats.waitlist": "Wachtlijst", - "admin.stats.waitlistSub": "inschrijvingen op de wachtlijst" + "admin.stats.waitlistSub": "inschrijvingen op de wachtlijst", + "admin.settings.title": "Aanbieders", + "admin.settings.subtitle": "Configureer de aanbieders Toegangen. API-sleutels kunnen ook in het .env-bestand worden gezet.", + "admin.settings.sectionClassic": "Klassieke vertaling", + "admin.settings.sectionClassicDesc": "Google-motoren voor de standaardvertaling.", + "admin.settings.sectionDirect": "Directe AI-aanbieders", + "admin.settings.sectionDirectDesc": "Directe OpenAI-compatibele toegangen — bruikbaar als kanaal van een AI-niveau (pagina «Modellen & abonnementen») of voor het genereren van e-mails.", + "admin.settings.sectionGateway": "Multi-model gateways", + "admin.settings.sectionGatewayDesc": "Eén sleutel, honderden modellen. De modellen per niveau worden beheerd op de pagina «Modellen & abonnementen».", + "admin.settings.sectionOcr": "OCR (gescande PDF's)", + "admin.settings.sectionOcrDesc": "Tekst uit afbeeldingspagina's halen vóór de vertaling.", + "admin.settings.sectionEmails": "E-mails", + "admin.settings.sectionEmailsDesc": "Verzendserver voor meldingen en opvolgmails.", + "admin.settings.enabled": "Ingeschakeld", + "admin.settings.disabled": "Uitgeschakeld", + "admin.settings.envBadge": "Sleutel in .env", + "admin.settings.envConfigured": "Ingesteld in .env", + "admin.settings.envKeyPlaceholder": "Sleutel ingesteld in .env (leeg laten om te gebruiken)", + "admin.settings.apiKey": "API-sleutel", + "admin.settings.model": "Model", + "admin.settings.baseUrl": "Basis-URL", + "admin.settings.test": "Testen", + "admin.settings.testing": "Testen...", + "admin.settings.ok": "OK", + "admin.settings.error": "Fout", + "admin.settings.failure": "Mislukt", + "admin.settings.sending": "Verzenden...", + "admin.settings.save": "Configuratie opslaan", + "admin.settings.saving": "Opslaan...", + "admin.settings.savedTitle": "✅ Configuratie opgeslagen", + "admin.settings.savedDesc": "De instellingen zijn succesvol opgeslagen.", + "admin.settings.saveErrorTitle": "Fout bij opslaan", + "admin.settings.loadErrorTitle": "Laadfout", + "admin.settings.loadErrorDesc": "HTTP {status} — controleer uw admin-token.", + "admin.settings.networkErrorTitle": "Netwerkfout", + "admin.settings.networkErrorDesc": "Kan de backend niet bereiken.", + "admin.settings.networkSaveErrorDesc": "Kan de backend niet bereiken om op te slaan.", + "admin.settings.connectionOk": "Verbinding OK", + "admin.settings.modelsFound": "{count} {provider}-modellen gevonden", + "admin.settings.providerError": "Fout {provider}", + "admin.settings.google.title": "Google Translate", + "admin.settings.google.desc": "Officiële webtoegang via deep_translator. Geen sleutel nodig, gematigd gebruik aanbevolen.", + "admin.settings.googleCloud.title": "Google Cloud Translation (officieel)", + "admin.settings.googleCloud.desc": "Officiële Google Cloud API. 500.000 tekens/maand gratis, daarna ca. $20 per miljoen tekens. API-sleutel op console.cloud.google.com.", + "admin.settings.googleCloud.hint": "Schakel de Cloud Translation API in Google Cloud Console in en maak een API-sleutel die tot die API beperkt is.", + "admin.settings.deepseek.title": "DeepSeek (direct)", + "admin.settings.deepseek.desc": "Directe DeepSeek-API, OpenAI-compatibel. Sleutel op platform.deepseek.com — bruikbaar als kanaal van een AI-niveau.", + "admin.settings.zhipu.title": "z.ai / Zhipu (GLM)", + "admin.settings.zhipu.desc": "Directe toegang tot de GLM-modellen van z.ai, OpenAI-compatibel. Sleutel op z.ai — bruikbaar als kanaal van een AI-niveau.", + "admin.settings.zhipu.urlHint": "Standaard: https://api.z.ai/api/paas/v4 — wijzig dit alleen als u een proxy gebruikt.", + "admin.settings.minimax.title": "MiniMax (direct)", + "admin.settings.minimax.desc": "Publieke MiniMax-API, OpenAI-compatibel. Sleutel op minimax.io — bruikbaar als kanaal van een AI-niveau.", + "admin.settings.openai.title": "OpenAI", + "admin.settings.openai.desc": "GPT-vertalingen. Vraag een sleutel aan op platform.openai.com.", + "admin.settings.zai.title": "xAI (Grok)", + "admin.settings.zai.desc": "Grok-modellen van xAI. OpenAI-compatibele API. Vraag uw sleutel aan op x.ai.", + "admin.settings.zai.urlHint": "Standaard: https://api.x.ai/v1 — wijzig dit alleen als u een proxy gebruikt.", + "admin.settings.openrouter.title": "OpenRouter — gateway-sleutel", + "admin.settings.openrouter.desc": "Essential-niveau (Pro-abonnement). Huidige reeks: deepseek/deepseek-v4-flash, z-ai/glm-5.3-flash, minimax/minimax-m3.", + "admin.settings.openrouter.keyLabel": "OpenRouter API-sleutel (gedeeld met Premium)", + "admin.settings.openrouter.envSharedPlaceholder": "Sleutel ingesteld in .env (gedeeld met Premium)", + "admin.settings.openrouter.reminder": "Modellen worden beheerd op de pagina «Modellen & abonnementen».", + "admin.settings.openrouterPremium.title": "OpenRouter Premium — gateway-sleutel", + "admin.settings.openrouterPremium.desc": "Premium-niveau (Business-abonnement). Huidige reeks: anthropic/claude-sonnet-5, deepseek/deepseek-v4-pro, z-ai/glm-5.3.", + "admin.settings.openrouterPremium.reminder": "Modellen worden beheerd op de pagina «Modellen & abonnementen». Deelt dezelfde OpenRouter-sleutel.", + "admin.settings.mistral.title": "Mistral OCR (gescande PDF's)", + "admin.settings.mistral.desc": "Haalt tekst uit gescande PDF's (afbeeldingspagina's) vóór de vertaling. Zonder sleutel worden deze bestanden geweigerd met een duidelijke melding. Ca. €1 / 1.000 pagina's. Sleutel: console.mistral.ai.", + "admin.settings.mistral.keyLabel": "Mistral API-sleutel", + "admin.settings.mistral.modelLabel": "OCR-model", + "admin.settings.mistral.modelHint": "Aanbevolen: mistral-ocr-latest", + "admin.settings.smtp.title": "SMTP-e-mail", + "admin.settings.smtp.desc": "SMTP-serverinstellingen voor het versturen van e-mails (wachtwoord vergeten, meldingen, opvolgmails).", + "admin.settings.smtp.host": "SMTP-host", + "admin.settings.smtp.port": "Poort", + "admin.settings.smtp.username": "Gebruikersnaam", + "admin.settings.smtp.password": "Wachtwoord", + "admin.settings.smtp.fromEmail": "Afzenderadres", + "admin.settings.smtp.useTls": "TLS gebruiken", + "admin.settings.smtp.sendTest": "Stuur een testmail", + "admin.settings.smtp.sent": "Verzonden", + "admin.settings.smtp.sendFailed": "Verzenden mislukt", + "admin.models.channelOpenrouter": "OpenRouter", + "admin.models.channelOpenrouterPremium": "OpenRouter (Premium)", + "admin.models.channelDeepseek": "DeepSeek (direct)", + "admin.models.channelZhipu": "z.ai (GLM)", + "admin.models.channelMinimax": "MiniMax (direct)", + "admin.models.channelOpenai": "OpenAI", + "admin.models.channelZai": "xAI (Grok)", + "admin.models.customModelPlaceholder": "model-id", + "admin.models.routeHint": "Het kanaal kiest directe toegang of gateway; model-id's verschillen per kanaal.", + "admin.models.addRouteConfirm": "Toevoegen", + "admin.marketing.generateTitle": "Genereren met AI", + "admin.marketing.generateDesc": "De tekst wordt geschreven op basis van het marketingplan (positionering en toegestane cijfers). Niets wordt automatisch verstuurd: eerst lezen, dan versturen.", + "admin.marketing.channel": "Kanaal", + "admin.marketing.model": "Model", + "admin.marketing.modelPlaceholder": "Standaard van het kanaal", + "admin.marketing.promoOptional": "Korting (optioneel)", + "admin.marketing.promoNone": "Geen", + "admin.marketing.brief": "Briefing", + "admin.marketing.briefPlaceholder": "Bijv.: wachtlijstaanmeldingen herinneren aan de voordelen van het Pro-abonnement.", + "admin.marketing.language": "Taal van de e-mail", + "admin.marketing.tone": "Toon", + "admin.marketing.toneNone": "Neutraal", + "admin.marketing.generateBtn": "Genereren", + "admin.marketing.generating": "Genereren...", + "admin.marketing.generatedTitle": "E-mail gegenereerd", + "admin.marketing.generatedDesc": "Onderwerp en HTML in het formulier geplaatst — eerst controleren voordat u verstuurt ({provider}).", + "admin.marketing.generateFailedTitle": "Genereren mislukt", + "admin.marketing.briefRequired": "Beschrijf eerst de gewenste inhoud in de briefing.", + "admin.marketing.langFr": "Frans", + "admin.marketing.langEn": "Engels", + "admin.marketing.langDe": "Duits", + "admin.marketing.langEs": "Spaans", + "admin.marketing.langIt": "Italiaans", + "admin.marketing.langPt": "Portugees", + "admin.marketing.langNl": "Nederlands", + "admin.marketing.langAr": "Arabisch", + "admin.marketing.langFa": "Perzisch", + "admin.marketing.langJa": "Japans", + "admin.marketing.langKo": "Koreaans", + "admin.marketing.langRu": "Russisch", + "admin.marketing.langZh": "Chinees", + "admin.marketing.toneFriendly": "Vriendelijk", + "admin.marketing.toneProfessional": "Professioneel", + "admin.marketing.toneUrgent": "Dringend", + "admin.marketing.toneFestive": "Festief", + "admin.marketing.promoTitle": "Kortingen", + "admin.marketing.promoDesc": "Promocodes worden via Stripe op de betaling toegepast; de teller stijgt pas als het abonnement echt is afgerond.", + "admin.marketing.promoEmpty": "Nog geen promocodes.", + "admin.marketing.promoActive": "Actief", + "admin.marketing.promoInactive": "Uitgeschakeld", + "admin.marketing.promoLocalOnly": "Buiten Stripe", + "admin.marketing.promoDeactivate": "Uitschakelen", + "admin.marketing.promoUsage": "{used}/{max} keer gebruikt", + "admin.marketing.promoPlansLabel": "Abonnementen:", + "admin.marketing.promoAllPlans": "Alle", + "admin.marketing.promoExpiresOn": "vervalt op {date}", + "admin.marketing.promoNeverExpires": "geen vervaldatum", + "admin.marketing.promoCreateTitle": "Korting aanmaken", + "admin.marketing.promoCreateDesc": "De code wordt ook in Stripe aangemaakt (coupon + promotiecode) wanneer Stripe is ingesteld.", + "admin.marketing.promoCode": "Code", + "admin.marketing.promoType": "Type", + "admin.marketing.promoTypePercent": "Percentage", + "admin.marketing.promoTypeAmount": "Bedrag (EUR)", + "admin.marketing.promoValue": "Waarde", + "admin.marketing.promoPlans": "Geschikte abonnementen", + "admin.marketing.promoMaxRedemptions": "Inwissellimiet", + "admin.marketing.promoMaxPlaceholder": "Onbeperkt", + "admin.marketing.promoExpires": "Vervaldatum", + "admin.marketing.promoCreate": "Code aanmaken", + "admin.marketing.promoCreating": "Aanmaken...", + "admin.marketing.promoCreatedTitle": "Promocode aangemaakt", + "admin.marketing.promoCreatedDesc": "De code {code} is klaar.", + "admin.marketing.promoCreateFailedTitle": "Aanmaak geweigerd", + "admin.marketing.promoInvalidInput": "Controleer de code en de waarde.", + "admin.marketing.promoDeactivatedTitle": "Code uitgeschakeld", + "admin.marketing.promoDeactivatedDesc": "De code {code} geldt niet meer.", + "admin.settings.fallback.title": "Fallback-keten", + "admin.settings.fallback.desc": "Prioriteitsvolgorde voor de automatische selectie van aanbieders.", + "admin.settings.fallback.classic": "Klassieke modus (Google)", + "admin.settings.fallback.llm": "LLM-modus (gateways en directe toegangen)", + "admin.settings.fallback.note": "Alleen geregistreerde kanalen (google, openai, deepseek, minimax) doen mee aan deze keten; AI-niveaus en andere kanalen niet.", + "admin.models.duplicateRouteTitle": "Duplicaat geweigerd", + "admin.models.duplicateRouteDesc": "Dit kanaal-+-model paar staat al in de lijst.", + "admin.marketing.promoDeactivateFailedTitle": "Uitschakelen geweigerd", + "admin.marketing.promoDelete": "Verwijderen", + "admin.marketing.promoDeleteConfirm": "Code {code} definitief verwijderen?", + "admin.marketing.promoDeletedTitle": "Code verwijderd", + "admin.marketing.promoDeletedDesc": "De code {code} is verwijderd.", + "admin.marketing.promoDeleteFailedTitle": "Verwijderen geweigerd", + "admin.marketing.promoLinkStripe": "Koppelen aan Stripe", + "admin.marketing.promoLinkedTitle": "Code gekoppeld aan Stripe", + "admin.marketing.promoLinkedDesc": "De code {code} is nu toepasbaar bij de betaling.", + "admin.marketing.promoLinkFailedTitle": "Koppelen mislukt", + "admin.marketing.promoExpiryHint": "Een code «tot en met de 10e» blijft de hele 10e geldig (einde: 23:59:59 UTC).", + "admin.marketing.generateEmpty": "Het antwoord van de server was leeg of onbruikbaar." } diff --git a/frontend/src/lib/i18n/messages/nl/pricing.json b/frontend/src/lib/i18n/messages/nl/pricing.json index 5e41bcf..29347a8 100644 --- a/frontend/src/lib/i18n/messages/nl/pricing.json +++ b/frontend/src/lib/i18n/messages/nl/pricing.json @@ -167,5 +167,17 @@ "pricing.header.titleBase": "Een plan voor", "pricing.header.titleAccent": "elke behoefte", "pricing.toast.close": "Sluiten", - "pricing.aiModels.premium.price": "$2 / $10 per 1M tokens" + "pricing.aiModels.premium.price": "$2 / $10 per 1M tokens", + "pricing.promo.label": "Promocode", + "pricing.promo.placeholder": "JOUWCODE", + "pricing.promo.apply": "Toepassen", + "pricing.promo.checking": "Controleren...", + "pricing.promo.valid": "Code geldig", + "pricing.promo.subscriptionOnly": "Geldig op abonnementen, niet op credits.", + "pricing.promoError.unknown": "Deze promocode bestaat niet.", + "pricing.promoError.inactive": "Deze promocode is niet meer actief.", + "pricing.promoError.expired": "Deze promocode is verlopen.", + "pricing.promoError.exhausted": "Deze promocode heeft zijn inwissellimiet bereikt.", + "pricing.promoError.plan": "Deze promocode geldt niet voor dit abonnement.", + "pricing.promoError.local_only": "Deze promocode is niet geschakeld voor online betaling." } diff --git a/frontend/src/lib/i18n/messages/pt/admin.json b/frontend/src/lib/i18n/messages/pt/admin.json index 492b0de..58a3a37 100644 --- a/frontend/src/lib/i18n/messages/pt/admin.json +++ b/frontend/src/lib/i18n/messages/pt/admin.json @@ -145,5 +145,180 @@ "admin.stats.tiersSub": "Essencial / Premium · clássico {classic} · outro {other}", "admin.stats.unavailable": "Estatísticas de negócio indisponíveis ({error}).", "admin.stats.waitlist": "Lista de espera", - "admin.stats.waitlistSub": "inscritos na espera" + "admin.stats.waitlistSub": "inscritos na espera", + "admin.settings.title": "Fornecedores", + "admin.settings.subtitle": "Configure os acessos dos fornecedores. As chaves API também podem ser definidas no ficheiro .env.", + "admin.settings.sectionClassic": "Tradução clássica", + "admin.settings.sectionClassicDesc": "Motores Google para a tradução padrão.", + "admin.settings.sectionDirect": "Fornecedores de IA diretos", + "admin.settings.sectionDirectDesc": "Acessos diretos compatíveis com OpenAI — utilizáveis como canal de um nível de IA (página «Modelos e assinaturas») ou para gerar emails.", + "admin.settings.sectionGateway": "Gateways multi-modelos", + "admin.settings.sectionGatewayDesc": "Uma chave, centenas de modelos. Os modelos por nível gerem-se na página «Modelos e assinaturas».", + "admin.settings.sectionOcr": "OCR (PDF digitalizados)", + "admin.settings.sectionOcrDesc": "Extração do texto das páginas de imagem antes da tradução.", + "admin.settings.sectionEmails": "Emails", + "admin.settings.sectionEmailsDesc": "Servidor de envio para notificações e retomas de contato.", + "admin.settings.enabled": "Ativado", + "admin.settings.disabled": "Desativado", + "admin.settings.envBadge": "Chave no .env", + "admin.settings.envConfigured": "Configurado no .env", + "admin.settings.envKeyPlaceholder": "Chave configurada no .env (deixe vazio para a utilizar)", + "admin.settings.apiKey": "Chave API", + "admin.settings.model": "Modelo", + "admin.settings.baseUrl": "URL de base", + "admin.settings.test": "Testar", + "admin.settings.testing": "Teste...", + "admin.settings.ok": "OK", + "admin.settings.error": "Erro", + "admin.settings.failure": "Falha", + "admin.settings.sending": "A enviar...", + "admin.settings.save": "Guardar a configuração", + "admin.settings.saving": "A guardar...", + "admin.settings.savedTitle": "✅ Configuração guardada", + "admin.settings.savedDesc": "Os parâmetros foram guardados com sucesso.", + "admin.settings.saveErrorTitle": "Erro ao guardar", + "admin.settings.loadErrorTitle": "Erro de carregamento", + "admin.settings.loadErrorDesc": "HTTP {status} — verifique o seu token de administrador.", + "admin.settings.networkErrorTitle": "Erro de rede", + "admin.settings.networkErrorDesc": "Não é possível contactar o backend.", + "admin.settings.networkSaveErrorDesc": "Não é possível contactar o backend para guardar.", + "admin.settings.connectionOk": "Ligação OK", + "admin.settings.modelsFound": "{count} modelos {provider} encontrados", + "admin.settings.providerError": "Erro {provider}", + "admin.settings.google.title": "Google Translate", + "admin.settings.google.desc": "Acesso web não oficial via deep_translator. Não requer chave, uso razoável recomendado.", + "admin.settings.googleCloud.title": "Google Cloud Translation (oficial)", + "admin.settings.googleCloud.desc": "API oficial da Google Cloud. 500 000 carateres/mês grátis, depois cerca de 20 $ por milhão de carateres. Chave API em console.cloud.google.com.", + "admin.settings.googleCloud.hint": "Ative a Cloud Translation API na Google Cloud Console e crie uma chave API restrita a essa API.", + "admin.settings.deepseek.title": "DeepSeek (direto)", + "admin.settings.deepseek.desc": "API direta da DeepSeek, compatível com OpenAI. Chave em platform.deepseek.com — utilizável como canal de um nível de IA.", + "admin.settings.zhipu.title": "z.ai / Zhipu (GLM)", + "admin.settings.zhipu.desc": "Acesso direto aos modelos GLM da z.ai, compatível com OpenAI. Chave em z.ai — utilizável como canal de um nível de IA.", + "admin.settings.zhipu.urlHint": "Por omissão: https://api.z.ai/api/paas/v4 — altere apenas se usar um proxy.", + "admin.settings.minimax.title": "MiniMax (direto)", + "admin.settings.minimax.desc": "API pública da MiniMax, compatível com OpenAI. Chave em minimax.io — utilizável como canal de um nível de IA.", + "admin.settings.openai.title": "OpenAI", + "admin.settings.openai.desc": "Traduções GPT. Obtenha uma chave em platform.openai.com.", + "admin.settings.zai.title": "xAI (Grok)", + "admin.settings.zai.desc": "Modelos Grok da xAI. API compatível com OpenAI. Obtenha a chave em x.ai.", + "admin.settings.zai.urlHint": "Por omissão: https://api.x.ai/v1 — altere apenas se usar um proxy.", + "admin.settings.openrouter.title": "OpenRouter — chave de gateway", + "admin.settings.openrouter.desc": "Nível Essencial (plano Pro). Gama atual: deepseek/deepseek-v4-flash, z-ai/glm-5.3-flash, minimax/minimax-m3.", + "admin.settings.openrouter.keyLabel": "Chave API OpenRouter (partilhada com Premium)", + "admin.settings.openrouter.envSharedPlaceholder": "Chave configurada no .env (partilhada com Premium)", + "admin.settings.openrouter.reminder": "Modelos geridos na página «Modelos e assinaturas».", + "admin.settings.openrouterPremium.title": "OpenRouter Premium — chave de gateway", + "admin.settings.openrouterPremium.desc": "Nível Premium (plano Business). Gama atual: anthropic/claude-sonnet-5, deepseek/deepseek-v4-pro, z-ai/glm-5.3.", + "admin.settings.openrouterPremium.reminder": "Modelos geridos na página «Modelos e assinaturas». Partilha a mesma chave OpenRouter.", + "admin.settings.mistral.title": "OCR Mistral (PDF digitalizados)", + "admin.settings.mistral.desc": "Extrai o texto dos PDF digitalizados (páginas de imagem) antes da tradução. Sem chave, esses ficheiros são recusados com uma mensagem clara. Cerca de 1 € / 1 000 páginas. Chave: console.mistral.ai.", + "admin.settings.mistral.keyLabel": "Chave API Mistral", + "admin.settings.mistral.modelLabel": "Modelo OCR", + "admin.settings.mistral.modelHint": "Recomendado: mistral-ocr-latest", + "admin.settings.smtp.title": "Email SMTP", + "admin.settings.smtp.desc": "Configuração do servidor SMTP para o envio de emails (palavra-passe esquecida, notificações, retomas de contato).", + "admin.settings.smtp.host": "Anfitrião SMTP", + "admin.settings.smtp.port": "Porta", + "admin.settings.smtp.username": "Nome de utilizador", + "admin.settings.smtp.password": "Palavra-passe", + "admin.settings.smtp.fromEmail": "Endereço de envio", + "admin.settings.smtp.useTls": "Utilizar TLS", + "admin.settings.smtp.sendTest": "Enviar um email de teste", + "admin.settings.smtp.sent": "Enviado", + "admin.settings.smtp.sendFailed": "Falha no envio", + "admin.models.channelOpenrouter": "OpenRouter", + "admin.models.channelOpenrouterPremium": "OpenRouter (Premium)", + "admin.models.channelDeepseek": "DeepSeek (direto)", + "admin.models.channelZhipu": "z.ai (GLM)", + "admin.models.channelMinimax": "MiniMax (direto)", + "admin.models.channelOpenai": "OpenAI", + "admin.models.channelZai": "xAI (Grok)", + "admin.models.customModelPlaceholder": "id-do-modelo", + "admin.models.routeHint": "O canal escolhe o acesso direto ou o gateway; os identificadores de modelos diferem de canal para canal.", + "admin.models.addRouteConfirm": "Adicionar", + "admin.marketing.generateTitle": "Gerar com IA", + "admin.marketing.generateDesc": "O texto é redigido a partir do plano de marketing (posicionamento e números autorizados). Nada é enviado automaticamente: reveja e depois envie.", + "admin.marketing.channel": "Canal", + "admin.marketing.model": "Modelo", + "admin.marketing.modelPlaceholder": "Predefinição do canal", + "admin.marketing.promoOptional": "Desconto (facultativo)", + "admin.marketing.promoNone": "Nenhum", + "admin.marketing.brief": "Briefing", + "admin.marketing.briefPlaceholder": "Ex.: retomar os inscritos na lista de espera lembrando as vantagens do plano Pro.", + "admin.marketing.language": "Língua do email", + "admin.marketing.tone": "Tom", + "admin.marketing.toneNone": "Neutro", + "admin.marketing.generateBtn": "Gerar", + "admin.marketing.generating": "A gerar...", + "admin.marketing.generatedTitle": "Email gerado", + "admin.marketing.generatedDesc": "Assunto e HTML inseridos no formulário — reveja antes de enviar ({provider}).", + "admin.marketing.generateFailedTitle": "Geração impossível", + "admin.marketing.briefRequired": "Descreva primeiro o conteúdo pretendido no briefing.", + "admin.marketing.langFr": "Francês", + "admin.marketing.langEn": "Inglês", + "admin.marketing.langDe": "Alemão", + "admin.marketing.langEs": "Espanhol", + "admin.marketing.langIt": "Italiano", + "admin.marketing.langPt": "Português", + "admin.marketing.langNl": "Neerlandês", + "admin.marketing.langAr": "Árabe", + "admin.marketing.langFa": "Persa", + "admin.marketing.langJa": "Japonês", + "admin.marketing.langKo": "Coreano", + "admin.marketing.langRu": "Russo", + "admin.marketing.langZh": "Chinês", + "admin.marketing.toneFriendly": "Amigável", + "admin.marketing.toneProfessional": "Profissional", + "admin.marketing.toneUrgent": "Urgente", + "admin.marketing.toneFestive": "Festivo", + "admin.marketing.promoTitle": "Descontos", + "admin.marketing.promoDesc": "Códigos promo aplicados ao pagamento via Stripe; o contador aumenta quando a assinatura é realmente concluída.", + "admin.marketing.promoEmpty": "Ainda não há códigos promo.", + "admin.marketing.promoActive": "Ativo", + "admin.marketing.promoInactive": "Desativado", + "admin.marketing.promoLocalOnly": "Fora do Stripe", + "admin.marketing.promoDeactivate": "Desativar", + "admin.marketing.promoUsage": "{used}/{max} utilizações", + "admin.marketing.promoPlansLabel": "Planos:", + "admin.marketing.promoAllPlans": "Todos", + "admin.marketing.promoExpiresOn": "expira em {date}", + "admin.marketing.promoNeverExpires": "sem expiração", + "admin.marketing.promoCreateTitle": "Criar um desconto", + "admin.marketing.promoCreateDesc": "O código também é criado no Stripe (cupão + código promocional) quando o Stripe está configurado.", + "admin.marketing.promoCode": "Código", + "admin.marketing.promoType": "Tipo", + "admin.marketing.promoTypePercent": "Percentagem", + "admin.marketing.promoTypeAmount": "Montante (EUR)", + "admin.marketing.promoValue": "Valor", + "admin.marketing.promoPlans": "Planos elegíveis", + "admin.marketing.promoMaxRedemptions": "Limite de utilizações", + "admin.marketing.promoMaxPlaceholder": "Ilimitado", + "admin.marketing.promoExpires": "Expiração", + "admin.marketing.promoCreate": "Criar o código", + "admin.marketing.promoCreating": "A criar...", + "admin.marketing.promoCreatedTitle": "Código promo criado", + "admin.marketing.promoCreatedDesc": "O código {code} está pronto.", + "admin.marketing.promoCreateFailedTitle": "Criação recusada", + "admin.marketing.promoInvalidInput": "Verifique o código e o valor.", + "admin.marketing.promoDeactivatedTitle": "Código desativado", + "admin.marketing.promoDeactivatedDesc": "O código {code} já não é aplicável.", + "admin.settings.fallback.title": "Cadeia de recurso", + "admin.settings.fallback.desc": "Ordem de prioridade para a seleção automática de fornecedores.", + "admin.settings.fallback.classic": "Modo clássico (Google)", + "admin.settings.fallback.llm": "Modo LLM (gateways e acessos diretos)", + "admin.settings.fallback.note": "Apenas os canais registados (google, openai, deepseek, minimax) participam nesta cadeia; os níveis de IA e os outros canais não.", + "admin.models.duplicateRouteTitle": "Duplicado recusado", + "admin.models.duplicateRouteDesc": "Esse par canal + modelo já está na lista.", + "admin.marketing.promoDeactivateFailedTitle": "Desativação recusada", + "admin.marketing.promoDelete": "Eliminar", + "admin.marketing.promoDeleteConfirm": "Eliminar definitivamente o código {code}?", + "admin.marketing.promoDeletedTitle": "Código eliminado", + "admin.marketing.promoDeletedDesc": "O código {code} foi eliminado.", + "admin.marketing.promoDeleteFailedTitle": "Eliminação recusada", + "admin.marketing.promoLinkStripe": "Ligar ao Stripe", + "admin.marketing.promoLinkedTitle": "Código ligado ao Stripe", + "admin.marketing.promoLinkedDesc": "O código {code} já é aplicável ao pagamento.", + "admin.marketing.promoLinkFailedTitle": "Ligação impossível", + "admin.marketing.promoExpiryHint": "Um código «até dia 10» continua válido todo o dia 10 (fim: 23:59:59 UTC).", + "admin.marketing.generateEmpty": "A resposta do servidor estava vazia ou era inutilizável." } diff --git a/frontend/src/lib/i18n/messages/pt/pricing.json b/frontend/src/lib/i18n/messages/pt/pricing.json index a85e15f..d68fdf8 100644 --- a/frontend/src/lib/i18n/messages/pt/pricing.json +++ b/frontend/src/lib/i18n/messages/pt/pricing.json @@ -167,5 +167,17 @@ "pricing.header.titleBase": "Um plano para", "pricing.header.titleAccent": "cada necessidade", "pricing.toast.close": "Fechar", - "pricing.aiModels.premium.price": "2 US$ / 10 US$ por 1M de tokens" + "pricing.aiModels.premium.price": "2 US$ / 10 US$ por 1M de tokens", + "pricing.promo.label": "Código promo", + "pricing.promo.placeholder": "SEUCODIGO", + "pricing.promo.apply": "Aplicar", + "pricing.promo.checking": "A verificar...", + "pricing.promo.valid": "Código válido", + "pricing.promo.subscriptionOnly": "Válido em assinaturas, não em créditos.", + "pricing.promoError.unknown": "Este código promo não existe.", + "pricing.promoError.inactive": "Este código promo já não está ativo.", + "pricing.promoError.expired": "Este código promo expirou.", + "pricing.promoError.exhausted": "Este código promo atingiu o limite de utilizações.", + "pricing.promoError.plan": "Este código promo não é válido para este plano.", + "pricing.promoError.local_only": "Este código promo não está habilitado para o pagamento online." } diff --git a/frontend/src/lib/i18n/messages/ru/admin.json b/frontend/src/lib/i18n/messages/ru/admin.json index 39a1c51..6bf04bd 100644 --- a/frontend/src/lib/i18n/messages/ru/admin.json +++ b/frontend/src/lib/i18n/messages/ru/admin.json @@ -145,5 +145,180 @@ "admin.stats.tiersSub": "Базовый / Премиум · классика {classic} · прочее {other}", "admin.stats.unavailable": "Бизнес-статистика недоступна ({error}).", "admin.stats.waitlist": "Лист ожидания", - "admin.stats.waitlistSub": "записей в листе ожидания" + "admin.stats.waitlistSub": "записей в листе ожидания", + "admin.settings.title": "Поставщики", + "admin.settings.subtitle": "Настройте доступы поставщиков. Ключи API также можно задать в файле .env.", + "admin.settings.sectionClassic": "Классический перевод", + "admin.settings.sectionClassicDesc": "Механизмы Google для стандартного перевода.", + "admin.settings.sectionDirect": "Прямые ИИ-поставщики", + "admin.settings.sectionDirectDesc": "Прямые совместимые с OpenAI доступы — используются как канал ИИ-уровня (страница «Модели и подписки») или для генерации писем.", + "admin.settings.sectionGateway": "Мульти модельные шлюзы", + "admin.settings.sectionGatewayDesc": "Один ключ, сотни моделей. Модели по уровням настраиваются на странице «Модели и подписки».", + "admin.settings.sectionOcr": "OCR (сканированные PDF)", + "admin.settings.sectionOcrDesc": "Извлечение текста со страниц-изображений перед переводом.", + "admin.settings.sectionEmails": "Письма", + "admin.settings.sectionEmailsDesc": "Сервер отправки для уведомлений и повторных писем.", + "admin.settings.enabled": "Включено", + "admin.settings.disabled": "Отключено", + "admin.settings.envBadge": "Ключ в .env", + "admin.settings.envConfigured": "Задано в .env", + "admin.settings.envKeyPlaceholder": "Ключ задан в .env (оставьте пустым, чтобы использовать)", + "admin.settings.apiKey": "Ключ API", + "admin.settings.model": "Модель", + "admin.settings.baseUrl": "Базовый URL", + "admin.settings.test": "Проверить", + "admin.settings.testing": "Проверка...", + "admin.settings.ok": "OK", + "admin.settings.error": "Ошибка", + "admin.settings.failure": "Сбой", + "admin.settings.sending": "Отправка...", + "admin.settings.save": "Сохранить конфигурацию", + "admin.settings.saving": "Сохранение...", + "admin.settings.savedTitle": "✅ Конфигурация сохранена", + "admin.settings.savedDesc": "Параметры успешно сохранены.", + "admin.settings.saveErrorTitle": "Ошибка сохранения", + "admin.settings.loadErrorTitle": "Ошибка загрузки", + "admin.settings.loadErrorDesc": "HTTP {status} — проверьте административный токен.", + "admin.settings.networkErrorTitle": "Сетевая ошибка", + "admin.settings.networkErrorDesc": "Не удалось связаться с сервером.", + "admin.settings.networkSaveErrorDesc": "Не удалось связаться с сервером для сохранения.", + "admin.settings.connectionOk": "Соединение в порядке", + "admin.settings.modelsFound": "Найдено моделей {provider}: {count}", + "admin.settings.providerError": "Ошибка {provider}", + "admin.settings.google.title": "Google Translate", + "admin.settings.google.desc": "Неофициальный веб-доступ через deep_translator. Ключ не нужен, разумное использование приветствуется.", + "admin.settings.googleCloud.title": "Google Cloud Translation (официальный)", + "admin.settings.googleCloud.desc": "Официальный API Google Cloud. 500 000 знаков/месяц бесплатно, далее около 20 $ за миллион знаков. Ключ API на console.cloud.google.com.", + "admin.settings.googleCloud.hint": "Включите Cloud Translation API в Google Cloud Console и создайте ключ API с ограничением этим API.", + "admin.settings.deepseek.title": "DeepSeek (напрямую)", + "admin.settings.deepseek.desc": "Прямой API DeepSeek, совместим с OpenAI. Ключ на platform.deepseek.com — подходит как канал ИИ-уровня.", + "admin.settings.zhipu.title": "z.ai / Zhipu (GLM)", + "admin.settings.zhipu.desc": "Прямой доступ к моделям GLM от z.ai, совместим с OpenAI. Ключ на z.ai — подходит как канал ИИ-уровня.", + "admin.settings.zhipu.urlHint": "По умолчанию: https://api.z.ai/api/paas/v4 — меняйте только при использовании прокси.", + "admin.settings.minimax.title": "MiniMax (напрямую)", + "admin.settings.minimax.desc": "Публичный API MiniMax, совместим с OpenAI. Ключ на minimax.io — подходит как канал ИИ-уровня.", + "admin.settings.openai.title": "OpenAI", + "admin.settings.openai.desc": "Переводы GPT. Получите ключ на platform.openai.com.", + "admin.settings.zai.title": "xAI (Grok)", + "admin.settings.zai.desc": "Модели Grok от xAI. API совместим с OpenAI. Получите ключ на x.ai.", + "admin.settings.zai.urlHint": "По умолчанию: https://api.x.ai/v1 — меняйте только при использовании прокси.", + "admin.settings.openrouter.title": "OpenRouter — ключ шлюза", + "admin.settings.openrouter.desc": "Уровень Essential (тариф Pro). Текущая линейка: deepseek/deepseek-v4-flash, z-ai/glm-5.3-flash, minimax/minimax-m3.", + "admin.settings.openrouter.keyLabel": "Ключ API OpenRouter (общий с Premium)", + "admin.settings.openrouter.envSharedPlaceholder": "Ключ задан в .env (общий с Premium)", + "admin.settings.openrouter.reminder": "Модели управляются на странице «Модели и подписки».", + "admin.settings.openrouterPremium.title": "OpenRouter Premium — ключ шлюза", + "admin.settings.openrouterPremium.desc": "Уровень Premium (тариф Business). Текущая линейка: anthropic/claude-sonnet-5, deepseek/deepseek-v4-pro, z-ai/glm-5.3.", + "admin.settings.openrouterPremium.reminder": "Модели управляются на странице «Модели и подписки». Используется тот же ключ OpenRouter.", + "admin.settings.mistral.title": "OCR Mistral (сканированные PDF)", + "admin.settings.mistral.desc": "Извлекает текст из сканированных PDF (страницы-изображения) перед переводом. Без ключа такие файлы отклоняются с понятным сообщением. Около 1 € / 1000 страниц. Ключ: console.mistral.ai.", + "admin.settings.mistral.keyLabel": "Ключ API Mistral", + "admin.settings.mistral.modelLabel": "OCR-модель", + "admin.settings.mistral.modelHint": "Рекомендуется: mistral-ocr-latest", + "admin.settings.smtp.title": "Почта SMTP", + "admin.settings.smtp.desc": "Настройки SMTP-сервера для отправки писем (восстановление пароля, уведомления, повторные письма).", + "admin.settings.smtp.host": "SMTP-сервер", + "admin.settings.smtp.port": "Порт", + "admin.settings.smtp.username": "Имя пользователя", + "admin.settings.smtp.password": "Пароль", + "admin.settings.smtp.fromEmail": "Адрес отправителя", + "admin.settings.smtp.useTls": "Использовать TLS", + "admin.settings.smtp.sendTest": "Отправить тестовое письмо", + "admin.settings.smtp.sent": "Отправлено", + "admin.settings.smtp.sendFailed": "Не удалось отправить", + "admin.models.channelOpenrouter": "OpenRouter", + "admin.models.channelOpenrouterPremium": "OpenRouter (Premium)", + "admin.models.channelDeepseek": "DeepSeek (напрямую)", + "admin.models.channelZhipu": "z.ai (GLM)", + "admin.models.channelMinimax": "MiniMax (напрямую)", + "admin.models.channelOpenai": "OpenAI", + "admin.models.channelZai": "xAI (Grok)", + "admin.models.customModelPlaceholder": "идентификатор-модели", + "admin.models.routeHint": "Канал выбирает прямой доступ или шлюз; идентификаторы моделей различаются между каналами.", + "admin.models.addRouteConfirm": "Добавить", + "admin.marketing.generateTitle": "Создать с помощью ИИ", + "admin.marketing.generateDesc": "Текст составляется по маркетинговому плану (позиционирование и разрешённые цифры). Ничего не отправляется автоматически: проверьте и отправьте.", + "admin.marketing.channel": "Канал", + "admin.marketing.model": "Модель", + "admin.marketing.modelPlaceholder": "По умолчанию канала", + "admin.marketing.promoOptional": "Скидка (необязательно)", + "admin.marketing.promoNone": "Без скидки", + "admin.marketing.brief": "Задание", + "admin.marketing.briefPlaceholder": "Напр.: напомнить записавшимся в лист ожидания о преимуществах тарифа Pro.", + "admin.marketing.language": "Язык письма", + "admin.marketing.tone": "Тон", + "admin.marketing.toneNone": "Нейтральный", + "admin.marketing.generateBtn": "Создать", + "admin.marketing.generating": "Создание...", + "admin.marketing.generatedTitle": "Письмо создано", + "admin.marketing.generatedDesc": "Тема и HTML вставлены в форму — проверьте перед отправкой ({provider}).", + "admin.marketing.generateFailedTitle": "Не удалось создать", + "admin.marketing.briefRequired": "Сначала опишите желаемое содержание в задании.", + "admin.marketing.langFr": "Французский", + "admin.marketing.langEn": "Английский", + "admin.marketing.langDe": "Немецкий", + "admin.marketing.langEs": "Испанский", + "admin.marketing.langIt": "Итальянский", + "admin.marketing.langPt": "Португальский", + "admin.marketing.langNl": "Нидерландский", + "admin.marketing.langAr": "Арабский", + "admin.marketing.langFa": "Персидский", + "admin.marketing.langJa": "Японский", + "admin.marketing.langKo": "Корейский", + "admin.marketing.langRu": "Русский", + "admin.marketing.langZh": "Китайский", + "admin.marketing.toneFriendly": "Дружелюбный", + "admin.marketing.toneProfessional": "Профессиональный", + "admin.marketing.toneUrgent": "Срочный", + "admin.marketing.toneFestive": "Праздничный", + "admin.marketing.promoTitle": "Скидки", + "admin.marketing.promoDesc": "Промокоды применяются к оплате через Stripe; счётчик растёт только после реального оформления подписки.", + "admin.marketing.promoEmpty": "Промокодов пока нет.", + "admin.marketing.promoActive": "Активен", + "admin.marketing.promoInactive": "Отключён", + "admin.marketing.promoLocalOnly": "Вне Stripe", + "admin.marketing.promoDeactivate": "Отключить", + "admin.marketing.promoUsage": "использований: {used}/{max}", + "admin.marketing.promoPlansLabel": "Тарифы:", + "admin.marketing.promoAllPlans": "Все", + "admin.marketing.promoExpiresOn": "истекает {date}", + "admin.marketing.promoNeverExpires": "без срока", + "admin.marketing.promoCreateTitle": "Создать скидку", + "admin.marketing.promoCreateDesc": "Код также создаётся в Stripe (купон + промокод), если Stripe настроен.", + "admin.marketing.promoCode": "Код", + "admin.marketing.promoType": "Тип", + "admin.marketing.promoTypePercent": "Процент", + "admin.marketing.promoTypeAmount": "Сумма (EUR)", + "admin.marketing.promoValue": "Значение", + "admin.marketing.promoPlans": "Подходящие тарифы", + "admin.marketing.promoMaxRedemptions": "Лимит применений", + "admin.marketing.promoMaxPlaceholder": "Без лимита", + "admin.marketing.promoExpires": "Срок действия", + "admin.marketing.promoCreate": "Создать код", + "admin.marketing.promoCreating": "Создание...", + "admin.marketing.promoCreatedTitle": "Промокод создан", + "admin.marketing.promoCreatedDesc": "Код {code} готов.", + "admin.marketing.promoCreateFailedTitle": "Создание отклонено", + "admin.marketing.promoInvalidInput": "Проверьте код и значение.", + "admin.marketing.promoDeactivatedTitle": "Код отключён", + "admin.marketing.promoDeactivatedDesc": "Код {code} больше не применяется.", + "admin.settings.fallback.title": "Цепочка переключения", + "admin.settings.fallback.desc": "Порядок приоритета для автоматического выбора поставщиков.", + "admin.settings.fallback.classic": "Классический режим (Google)", + "admin.settings.fallback.llm": "Режим LLM (шлюзы и прямые доступы)", + "admin.settings.fallback.note": "В этой цепочке участвуют только зарегистрированные каналы (google, openai, deepseek, minimax); ИИ-уровни и прочие каналы — нет.", + "admin.models.duplicateRouteTitle": "Дубль отклонён", + "admin.models.duplicateRouteDesc": "Такая пара канал + модель уже есть в списке.", + "admin.marketing.promoDeactivateFailedTitle": "Отключение отклонено", + "admin.marketing.promoDelete": "Удалить", + "admin.marketing.promoDeleteConfirm": "Окончательно удалить код {code}?", + "admin.marketing.promoDeletedTitle": "Код удалён", + "admin.marketing.promoDeletedDesc": "Код {code} удалён.", + "admin.marketing.promoDeleteFailedTitle": "Удаление отклонено", + "admin.marketing.promoLinkStripe": "Привязать к Stripe", + "admin.marketing.promoLinkedTitle": "Код привязан к Stripe", + "admin.marketing.promoLinkedDesc": "Код {code} теперь применим к оплате.", + "admin.marketing.promoLinkFailedTitle": "Не удалось привязать", + "admin.marketing.promoExpiryHint": "Код «до 10-го» действует весь 10-й день (до 23:59:59 UTC).", + "admin.marketing.generateEmpty": "Ответ сервера был пустым или непригодным." } diff --git a/frontend/src/lib/i18n/messages/ru/pricing.json b/frontend/src/lib/i18n/messages/ru/pricing.json index f76236e..b12f95e 100644 --- a/frontend/src/lib/i18n/messages/ru/pricing.json +++ b/frontend/src/lib/i18n/messages/ru/pricing.json @@ -167,5 +167,17 @@ "pricing.header.titleBase": "Тариф для", "pricing.header.titleAccent": "каждой задачи", "pricing.toast.close": "Закрыть", - "pricing.aiModels.premium.price": "$2 / $10 за 1 млн токенов" + "pricing.aiModels.premium.price": "$2 / $10 за 1 млн токенов", + "pricing.promo.label": "Промокод", + "pricing.promo.placeholder": "ВАШКОД", + "pricing.promo.apply": "Применить", + "pricing.promo.checking": "Проверка...", + "pricing.promo.valid": "Код действителен", + "pricing.promo.subscriptionOnly": "Действует на подписки, но не на кредиты.", + "pricing.promoError.unknown": "Такого промокода не существует.", + "pricing.promoError.inactive": "Этот промокод больше не активен.", + "pricing.promoError.expired": "Срок действия этого промокода истёк.", + "pricing.promoError.exhausted": "Этот промокод достиг лимита применений.", + "pricing.promoError.plan": "Этот промокод не действует для этого тарифа.", + "pricing.promoError.local_only": "Этот промокод не привязан к онлайн-оплате." } diff --git a/frontend/src/lib/i18n/messages/zh/admin.json b/frontend/src/lib/i18n/messages/zh/admin.json index 289cbba..f9b2c22 100644 --- a/frontend/src/lib/i18n/messages/zh/admin.json +++ b/frontend/src/lib/i18n/messages/zh/admin.json @@ -145,5 +145,180 @@ "admin.stats.tiersSub": "基础 / 高级 · 经典 {classic} · 其他 {other}", "admin.stats.unavailable": "业务统计不可用({error})。", "admin.stats.waitlist": "等候名单", - "admin.stats.waitlistSub": "位用户等候中" + "admin.stats.waitlistSub": "位用户等候中", + "admin.settings.title": "服务商", + "admin.settings.subtitle": "配置服务商的访问。API 密钥也可以在 .env 文件中设置。", + "admin.settings.sectionClassic": "经典翻译", + "admin.settings.sectionClassicDesc": "用于标准翻译的 Google 引擎。", + "admin.settings.sectionDirect": "直连 AI 服务商", + "admin.settings.sectionDirectDesc": "兼容 OpenAI 的直连接入 —— 可作为 AI 层级的通道(见「模型与订阅」页面)或用于生成邮件。", + "admin.settings.sectionGateway": "多模型网关", + "admin.settings.sectionGatewayDesc": "一个密钥,数百个模型。每个层级的模型在「模型与订阅」页面管理。", + "admin.settings.sectionOcr": "OCR(扫描版 PDF)", + "admin.settings.sectionOcrDesc": "翻译前从图片页面提取文字。", + "admin.settings.sectionEmails": "邮件", + "admin.settings.sectionEmailsDesc": "用于通知和跟进邮件的发送服务器。", + "admin.settings.enabled": "已启用", + "admin.settings.disabled": "已停用", + "admin.settings.envBadge": "密钥在 .env", + "admin.settings.envConfigured": "已在 .env 中配置", + "admin.settings.envKeyPlaceholder": "密钥已在 .env 中配置(留空即可使用)", + "admin.settings.apiKey": "API 密钥", + "admin.settings.model": "模型", + "admin.settings.baseUrl": "基础 URL", + "admin.settings.test": "测试", + "admin.settings.testing": "测试中...", + "admin.settings.ok": "正常", + "admin.settings.error": "错误", + "admin.settings.failure": "失败", + "admin.settings.sending": "发送中...", + "admin.settings.save": "保存配置", + "admin.settings.saving": "保存中...", + "admin.settings.savedTitle": "✅ 配置已保存", + "admin.settings.savedDesc": "设置已成功保存。", + "admin.settings.saveErrorTitle": "保存出错", + "admin.settings.loadErrorTitle": "加载出错", + "admin.settings.loadErrorDesc": "HTTP {status} — 请检查您的管理员令牌。", + "admin.settings.networkErrorTitle": "网络错误", + "admin.settings.networkErrorDesc": "无法连接后端。", + "admin.settings.networkSaveErrorDesc": "无法连接后端进行保存。", + "admin.settings.connectionOk": "连接正常", + "admin.settings.modelsFound": "找到 {count} 个 {provider} 模型", + "admin.settings.providerError": "{provider} 错误", + "admin.settings.google.title": "Google 翻译", + "admin.settings.google.desc": "通过 deep_translator 的非官方网页访问。无需密钥,建议适度使用。", + "admin.settings.googleCloud.title": "Google Cloud 翻译(官方)", + "admin.settings.googleCloud.desc": "Google Cloud 官方 API。每月 50 万字符免费,之后约每百万字符 20 美元。密钥来自 console.cloud.google.com。", + "admin.settings.googleCloud.hint": "在 Google Cloud Console 中启用 Cloud Translation API,然后创建仅限该 API 的密钥。", + "admin.settings.deepseek.title": "DeepSeek(直连)", + "admin.settings.deepseek.desc": "DeepSeek 直连 API,兼容 OpenAI。密钥来自 platform.deepseek.com —— 可作为 AI 层级通道。", + "admin.settings.zhipu.title": "z.ai / Zhipu(GLM)", + "admin.settings.zhipu.desc": "直连 z.ai 的 GLM 模型,兼容 OpenAI。密钥来自 z.ai —— 可作为 AI 层级通道。", + "admin.settings.zhipu.urlHint": "默认:https://api.z.ai/api/paas/v4 — 仅在使用代理时修改。", + "admin.settings.minimax.title": "MiniMax(直连)", + "admin.settings.minimax.desc": "MiniMax 公开 API,兼容 OpenAI。密钥来自 minimax.io —— 可作为 AI 层级通道。", + "admin.settings.openai.title": "OpenAI", + "admin.settings.openai.desc": "GPT 翻译。请在 platform.openai.com 获取密钥。", + "admin.settings.zai.title": "xAI(Grok)", + "admin.settings.zai.desc": "xAI 的 Grok 模型。兼容 OpenAI 的 API。请在 x.ai 获取密钥。", + "admin.settings.zai.urlHint": "默认:https://api.x.ai/v1 — 仅在使用代理时修改。", + "admin.settings.openrouter.title": "OpenRouter — 网关密钥", + "admin.settings.openrouter.desc": "Essential 层(Pro 套餐)。当前型号:deepseek/deepseek-v4-flash、z-ai/glm-5.3-flash、minimax/minimax-m3。", + "admin.settings.openrouter.keyLabel": "OpenRouter API 密钥(与 Premium 共用)", + "admin.settings.openrouter.envSharedPlaceholder": "密钥已在 .env 中配置(与 Premium 共用)", + "admin.settings.openrouter.reminder": "模型在「模型与订阅」页面管理。", + "admin.settings.openrouterPremium.title": "OpenRouter Premium — 网关密钥", + "admin.settings.openrouterPremium.desc": "Premium 层(Business 套餐)。当前型号:anthropic/claude-sonnet-5、deepseek/deepseek-v4-pro、z-ai/glm-5.3。", + "admin.settings.openrouterPremium.reminder": "模型在「模型与订阅」页面管理。共用同一个 OpenRouter 密钥。", + "admin.settings.mistral.title": "Mistral OCR(扫描版 PDF)", + "admin.settings.mistral.desc": "翻译前从扫描版 PDF(图片页面)提取文字。没有密钥时,这类文件会被明确提示后拒绝。约 1 欧元 / 1000 页。密钥:console.mistral.ai。", + "admin.settings.mistral.keyLabel": "Mistral API 密钥", + "admin.settings.mistral.modelLabel": "OCR 模型", + "admin.settings.mistral.modelHint": "推荐:mistral-ocr-latest", + "admin.settings.smtp.title": "SMTP 邮件", + "admin.settings.smtp.desc": "用于发送邮件的 SMTP 服务器设置(忘记密码、通知、跟进邮件)。", + "admin.settings.smtp.host": "SMTP 主机", + "admin.settings.smtp.port": "端口", + "admin.settings.smtp.username": "用户名", + "admin.settings.smtp.password": "密码", + "admin.settings.smtp.fromEmail": "发件地址", + "admin.settings.smtp.useTls": "使用 TLS", + "admin.settings.smtp.sendTest": "发送测试邮件", + "admin.settings.smtp.sent": "已发送", + "admin.settings.smtp.sendFailed": "发送失败", + "admin.models.channelOpenrouter": "OpenRouter", + "admin.models.channelOpenrouterPremium": "OpenRouter(Premium)", + "admin.models.channelDeepseek": "DeepSeek(直连)", + "admin.models.channelZhipu": "z.ai(GLM)", + "admin.models.channelMinimax": "MiniMax(直连)", + "admin.models.channelOpenai": "OpenAI", + "admin.models.channelZai": "xAI(Grok)", + "admin.models.customModelPlaceholder": "模型 ID", + "admin.models.routeHint": "通道决定使用直连接入还是网关;模型标识因通道而异。", + "admin.models.addRouteConfirm": "添加", + "admin.marketing.generateTitle": "用 AI 生成", + "admin.marketing.generateDesc": "文案依据营销方案撰写(定位与允许使用的数字)。不会自动发送任何内容:请先审阅再发送。", + "admin.marketing.channel": "通道", + "admin.marketing.model": "模型", + "admin.marketing.modelPlaceholder": "通道默认", + "admin.marketing.promoOptional": "折扣(可选)", + "admin.marketing.promoNone": "无", + "admin.marketing.brief": "内容简报", + "admin.marketing.briefPlaceholder": "例如:提醒等候名单中的注册用户 Pro 套餐的优势。", + "admin.marketing.language": "邮件语言", + "admin.marketing.tone": "语气", + "admin.marketing.toneNone": "中性", + "admin.marketing.generateBtn": "生成", + "admin.marketing.generating": "生成中...", + "admin.marketing.generatedTitle": "邮件已生成", + "admin.marketing.generatedDesc": "主题和 HTML 已填入表单 —— 发送前请审阅({provider})。", + "admin.marketing.generateFailedTitle": "无法生成", + "admin.marketing.briefRequired": "请先在内容简报中描述所需内容。", + "admin.marketing.langFr": "法语", + "admin.marketing.langEn": "英语", + "admin.marketing.langDe": "德语", + "admin.marketing.langEs": "西班牙语", + "admin.marketing.langIt": "意大利语", + "admin.marketing.langPt": "葡萄牙语", + "admin.marketing.langNl": "荷兰语", + "admin.marketing.langAr": "阿拉伯语", + "admin.marketing.langFa": "波斯语", + "admin.marketing.langJa": "日语", + "admin.marketing.langKo": "韩语", + "admin.marketing.langRu": "俄语", + "admin.marketing.langZh": "中文", + "admin.marketing.toneFriendly": "亲切", + "admin.marketing.toneProfessional": "专业", + "admin.marketing.toneUrgent": "紧迫", + "admin.marketing.toneFestive": "喜庆", + "admin.marketing.promoTitle": "折扣", + "admin.marketing.promoDesc": "优惠码通过 Stripe 应用于付款;计数器只在订阅真正完成后增加。", + "admin.marketing.promoEmpty": "暂无优惠码。", + "admin.marketing.promoActive": "启用", + "admin.marketing.promoInactive": "已停用", + "admin.marketing.promoLocalOnly": "不在 Stripe", + "admin.marketing.promoDeactivate": "停用", + "admin.marketing.promoUsage": "已使用 {used}/{max} 次", + "admin.marketing.promoPlansLabel": "套餐:", + "admin.marketing.promoAllPlans": "全部", + "admin.marketing.promoExpiresOn": "{date} 到期", + "admin.marketing.promoNeverExpires": "不过期", + "admin.marketing.promoCreateTitle": "创建折扣", + "admin.marketing.promoCreateDesc": "如果配置了 Stripe,优惠码也会在 Stripe 中创建(优惠券 + 促销码)。", + "admin.marketing.promoCode": "代码", + "admin.marketing.promoType": "类型", + "admin.marketing.promoTypePercent": "百分比", + "admin.marketing.promoTypeAmount": "金额(EUR)", + "admin.marketing.promoValue": "数值", + "admin.marketing.promoPlans": "适用套餐", + "admin.marketing.promoMaxRedemptions": "使用上限", + "admin.marketing.promoMaxPlaceholder": "不限", + "admin.marketing.promoExpires": "到期日", + "admin.marketing.promoCreate": "创建代码", + "admin.marketing.promoCreating": "创建中...", + "admin.marketing.promoCreatedTitle": "优惠码已创建", + "admin.marketing.promoCreatedDesc": "代码 {code} 已就绪。", + "admin.marketing.promoCreateFailedTitle": "创建被拒绝", + "admin.marketing.promoInvalidInput": "请检查代码和数值。", + "admin.marketing.promoDeactivatedTitle": "代码已停用", + "admin.marketing.promoDeactivatedDesc": "代码 {code} 不再适用。", + "admin.settings.fallback.title": "备用链", + "admin.settings.fallback.desc": "自动选择服务商的优先顺序。", + "admin.settings.fallback.classic": "经典模式(Google)", + "admin.settings.fallback.llm": "LLM 模式(网关与直连接入)", + "admin.settings.fallback.note": "只有已注册的通道(google、openai、deepseek、minimax)参与这条链;AI 层级和其他通道不参与。", + "admin.models.duplicateRouteTitle": "已拒绝重复项", + "admin.models.duplicateRouteDesc": "该通道 + 模型组合已在列表中。", + "admin.marketing.promoDeactivateFailedTitle": "停用被拒绝", + "admin.marketing.promoDelete": "删除", + "admin.marketing.promoDeleteConfirm": "确定永久删除代码 {code} 吗?", + "admin.marketing.promoDeletedTitle": "代码已删除", + "admin.marketing.promoDeletedDesc": "代码 {code} 已删除。", + "admin.marketing.promoDeleteFailedTitle": "删除被拒绝", + "admin.marketing.promoLinkStripe": "关联到 Stripe", + "admin.marketing.promoLinkedTitle": "代码已关联到 Stripe", + "admin.marketing.promoLinkedDesc": "代码 {code} 现在可用于付款。", + "admin.marketing.promoLinkFailedTitle": "关联失败", + "admin.marketing.promoExpiryHint": "「至 10 日」的代码在整个 10 日当天有效(至 23:59:59 UTC)。", + "admin.marketing.generateEmpty": "服务器返回的内容为空或无法使用。" } diff --git a/frontend/src/lib/i18n/messages/zh/pricing.json b/frontend/src/lib/i18n/messages/zh/pricing.json index ec2c777..143c20d 100644 --- a/frontend/src/lib/i18n/messages/zh/pricing.json +++ b/frontend/src/lib/i18n/messages/zh/pricing.json @@ -167,5 +167,17 @@ "pricing.header.titleBase": "总有一款套餐适合", "pricing.header.titleAccent": "您的需求", "pricing.toast.close": "关闭", - "pricing.aiModels.premium.price": "每百万 Token $2 / $10" + "pricing.aiModels.premium.price": "每百万 Token $2 / $10", + "pricing.promo.label": "优惠码", + "pricing.promo.placeholder": "您的代码", + "pricing.promo.apply": "应用", + "pricing.promo.checking": "检查中...", + "pricing.promo.valid": "代码有效", + "pricing.promo.subscriptionOnly": "仅适用于订阅,不适用于积分。", + "pricing.promoError.unknown": "该优惠码不存在。", + "pricing.promoError.inactive": "该优惠码已停用。", + "pricing.promoError.expired": "该优惠码已过期。", + "pricing.promoError.exhausted": "该优惠码已达到使用上限。", + "pricing.promoError.plan": "该优惠码不适用于此套餐。", + "pricing.promoError.local_only": "该优惠码未关联在线支付。" } diff --git a/routes/admin_routes.py b/routes/admin_routes.py index 4fbab74..fce43cb 100644 --- a/routes/admin_routes.py +++ b/routes/admin_routes.py @@ -31,6 +31,18 @@ from models.subscription import ( DEFAULT_AI_MODELS_PREMIUM, ) from services import pricing_config as pricing_cfg +from services.payment_service import ( + DuplicatePromoError, + PromoValidationError, + StripePromoCreationError, + create_promo_code, + deactivate_promo_code, + delete_promo_code, + link_promo_to_stripe, + load_promo_codes, + save_promo_codes, +) +from services.providers.config import LLM_CHANNELS pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @@ -1042,14 +1054,37 @@ class SmtpSettings(BaseModel): use_tls: bool = True -class AiTierSettings(BaseModel): - """Palier IA : modèles actifs (l'ordre = priorité de secours) + modèle par défaut. +# Fournisseurs (canaux) utilisables dans une route de palier : source unique = +# LLM_CHANNELS (services.providers.config). Les canaux « openrouter »/ +# « openrouter_premium » sont des passerelles multi-modèles ; les autres sont +# des accès directs compatibles OpenAI. +AI_TIER_PROVIDERS = tuple(LLM_CHANNELS.keys()) - ``default_model`` vide → premier de ``models`` → défaut du plan (PLANS). - Source des valeurs par défaut : la gamme officielle de ``models/subscription.py``. + +class AiTierRoute(BaseModel): + """Route d'un palier : un modèle via un canal donné. + + ``provider`` = canal (openrouter, deepseek, zhipu, minimax, openai, zai…), + ``model`` = identifiant du modèle chez ce canal, ``label`` = libellé libre. + """ + provider: str = "openrouter" + model: str = "" + label: str = "" + + +class AiTierSettings(BaseModel): + """Palier IA : routes actives (l'ordre = priorité de secours) + modèle par défaut. + + Deux écritures acceptées : + - ``routes`` : liste de routes ``{provider, model, label}`` (multi-canaux) ; + - ``models``/``default_model`` : ancien format (chaînes OpenRouter), + encore accepté et routé via openrouter comme auparavant. + ``routes`` vide → repli sur ``default_model`` → premier de ``models`` → + défaut du plan (PLANS). """ models: List[str] = Field(default_factory=list) default_model: str = "" + routes: List[AiTierRoute] = Field(default_factory=list) class AiTiersSettings(BaseModel): @@ -1076,6 +1111,7 @@ class SettingsConfig(BaseModel): openrouter: ProviderSettings = ProviderSettings() # "Traduction IA Essentielle" openrouter_premium: ProviderSettings = ProviderSettings() # "Traduction IA Premium" deepseek: ProviderSettings = ProviderSettings() + zhipu: ProviderSettings = ProviderSettings() # z.ai / Zhipu (GLM, direct) minimax: ProviderSettings = ProviderSettings() zai: ProviderSettings = ProviderSettings() mistral: ProviderSettings = ProviderSettings() # OCR Mistral (PDF scannés) @@ -1089,29 +1125,66 @@ class SettingsConfig(BaseModel): def normalize_ai_tiers(ai_tiers: AiTiersSettings) -> AiTiersSettings: """Déduplique, nettoie et complète un bloc ai_tiers avec la gamme officielle. + Routes : fournisseur connu obligatoire, modèle non vide, dédoublonnage + (canal + modèle) — l'ordre est conservé (ordre = priorité de secours). Lève ValueError si le défaut d'un palier appartient à la gamme de l'autre palier (un défaut Essentielle doit rester un modèle Essentielle, et inversement) : un utilisateur Pro ne doit jamais déclencher un modèle - Premium, et l'inverse serait une erreur de configuration. + Premium, et l'inverse serait une erreur de configuration. La même garde + s'applique aux routes openrouter (les modèles des autres canaux ont des + identifiants propres, le croisement n'a pas de sens pour eux). """ essential_official = list(DEFAULT_AI_MODELS_ESSENTIAL) premium_official = list(DEFAULT_AI_MODELS_PREMIUM) + def _clean_routes(tier: AiTierSettings, tier_name: str, other_official: List[str], + other_name: str) -> List[AiTierRoute]: + routes: List[AiTierRoute] = [] + seen: set = set() + for route in (tier.routes or []): + provider = (route.provider or "").strip().lower() + model = (route.model or "").strip() + if not provider and not model: + continue + if provider not in AI_TIER_PROVIDERS: + raise ValueError( + f"ai_tiers.{tier_name}.routes : fournisseur « {provider} » inconnu " + f"(canaux acceptés : {', '.join(AI_TIER_PROVIDERS)})." + ) + if not model: + raise ValueError( + f"ai_tiers.{tier_name}.routes : une route du canal « {provider} » " + "n'a pas de modèle." + ) + key = (provider, model) + if key in seen: + continue + if provider == "openrouter" and model in other_official: + raise ValueError( + f"ai_tiers.{tier_name}.routes : « {model} » appartient à la gamme " + f"{other_name} : choisissez un modèle de la gamme {tier_name}." + ) + seen.add(key) + routes.append(AiTierRoute(provider=provider, model=model, + label=(route.label or "").strip())) + return routes + def _fill(tier: AiTierSettings, official: List[str], other_official: List[str], tier_name: str, other_name: str) -> AiTierSettings: + routes = _clean_routes(tier, tier_name, other_official, other_name) models = [m.strip() for m in (tier.models or []) if m and m.strip()] models = list(dict.fromkeys(models)) # dédoublonnage, ordre conservé - if not models: + if not models and not routes: models = list(official) default = (tier.default_model or "").strip() - if default and default not in models: + if default and default not in models and not routes: models.insert(0, default) if default in other_official: raise ValueError( f"ai_tiers.{tier_name}.default_model « {default} » appartient à la " f"gamme {other_name} : choisissez un modèle de la gamme {tier_name}." ) - return AiTierSettings(models=models, default_model=default) + return AiTierSettings(models=models, default_model=default, routes=routes) return AiTiersSettings( essential=_fill(ai_tiers.essential, essential_official, premium_official, @@ -1182,7 +1255,8 @@ async def get_settings(admin_id: str = Depends(require_admin)): payload["openrouter_premium"] = _merge_env(settings.openrouter_premium, key_env="OPENROUTER_API_KEY", model_env="OPENROUTER_PREMIUM_MODEL", default_model="anthropic/claude-sonnet-5") payload["openai"] = _merge_env(settings.openai, key_env="OPENAI_API_KEY", model_env="OPENAI_MODEL", default_model="gpt-4o-mini") payload["deepseek"] = _merge_env(settings.deepseek, key_env="DEEPSEEK_API_KEY", model_env="DEEPSEEK_MODEL", default_model="deepseek-chat") - payload["minimax"] = _merge_env(settings.minimax, key_env="MINIMAX_API_KEY", model_env="MINIMAX_MODEL", default_model="abab6.5s-chat") + payload["zhipu"] = _merge_env(settings.zhipu, key_env="ZHIPU_API_KEY", model_env="ZHIPU_MODEL", url_env="ZHIPU_BASE_URL", default_model="glm-5.3", default_url="https://api.z.ai/api/paas/v4") + payload["minimax"] = _merge_env(settings.minimax, key_env="MINIMAX_API_KEY", model_env="MINIMAX_MODEL", default_model="MiniMax-M3") payload["zai"] = _merge_env(settings.zai, key_env="ZAI_API_KEY", model_env="ZAI_MODEL", url_env="ZAI_BASE_URL", default_model="grok-2-1212", default_url="https://api.x.ai/v1") payload["google_cloud"] = _merge_env(settings.google_cloud, key_env="GOOGLE_CLOUD_API_KEY") @@ -1216,6 +1290,7 @@ async def get_settings(admin_id: str = Depends(require_admin)): "openrouter": has_openrouter, "openrouter_premium": has_openrouter, # same key, different model "deepseek": bool(os.getenv("DEEPSEEK_API_KEY", "").strip()), + "zhipu": bool(os.getenv("ZHIPU_API_KEY", "").strip()), "minimax": bool(os.getenv("MINIMAX_API_KEY", "").strip()), "zai": bool(os.getenv("ZAI_API_KEY", "").strip()), @@ -1231,12 +1306,15 @@ async def get_settings(admin_id: str = Depends(require_admin)): def _ai_tiers_is_pristine(ai_tiers: AiTiersSettings) -> bool: """True si le bloc ai_tiers reçu est le défaut vierge (client qui ne gère - pas les paliers : liste vide/par défaut et aucun modèle imposé).""" + pas les paliers : liste vide/par défaut, aucune route et aucun modèle imposé).""" def _is_default(tier: AiTierSettings, official: List[str]) -> bool: models = [m.strip() for m in (tier.models or []) if m and m.strip()] - return (not models or models == list(official)) and not ( - tier.default_model or "" - ).strip() + routes = [r for r in (tier.routes or []) if (r.model or "").strip()] + return ( + (not models or models == list(official)) + and not routes + and not (tier.default_model or "").strip() + ) return _is_default(ai_tiers.essential, list(DEFAULT_AI_MODELS_ESSENTIAL)) and _is_default( ai_tiers.premium, list(DEFAULT_AI_MODELS_PREMIUM) @@ -1413,14 +1491,6 @@ async def test_provider( ) - elif provider == "openrouter_premium": - current.openrouter_premium = _update_provider(current.openrouter_premium, update_data) - elif provider == "deepseek": - current.deepseek = _update_provider(current.deepseek, update_data) - elif provider == "minimax": - current.minimax = _update_provider(current.minimax, update_data) - elif provider == "zai": - current.zai = _update_provider(current.zai, update_data) elif provider == "openai": api_key = _key(provider_config.api_key, "OPENAI_API_KEY") if not api_key: @@ -1430,13 +1500,57 @@ async def test_provider( ) import openai as _openai - client = _openai.OpenAI(api_key=api_key) + client = _openai.OpenAI(api_key=api_key, timeout=15) models = list(client.models.list()) return JSONResponse( status_code=200, content={"available": True, "models_count": len(models)}, ) + elif provider in ("deepseek", "minimax", "zhipu"): + # Fournisseurs directs compatibles OpenAI : la liste des modèles + # sert de test de connexion (clé + URL de base). + key_env = { + "deepseek": "DEEPSEEK_API_KEY", + "minimax": "MINIMAX_API_KEY", + "zhipu": "ZHIPU_API_KEY", + }[provider] + api_key = _key(provider_config.api_key, key_env) + if not api_key: + return JSONResponse( + status_code=400, + content={ + "available": False, + "error": f"Aucune clé API trouvée pour {provider} (JSON ou .env)", + }, + ) + import openai as _openai + + base_url = (provider_config.base_url or "").strip() + if not base_url: + from services.providers.config import LLM_CHANNELS + + url_env = LLM_CHANNELS.get(provider, {}).get("url_env", "") + base_url = (os.getenv(url_env, "").strip() if url_env else "") or LLM_CHANNELS[provider]["base_url"] + client = _openai.OpenAI(api_key=api_key, base_url=base_url, timeout=15) + try: + models = list(client.models.list()) + return JSONResponse( + status_code=200, + content={ + "available": True, + "models_count": len(models), + "sample_models": [m.id for m in models[:5]], + }, + ) + except Exception as api_err: + return JSONResponse( + status_code=502, + content={ + "available": False, + "error": f"{provider} injoignable ({str(api_err)[:200]})", + }, + ) elif provider in ("openrouter", "openrouter_premium"): api_key = _key(provider_config.api_key, "OPENROUTER_API_KEY") @@ -1475,7 +1589,7 @@ async def test_provider( import openai as _openai base_url = (provider_config.base_url or "").strip() or os.getenv("ZAI_BASE_URL", "https://api.x.ai/v1") - client = _openai.OpenAI(api_key=api_key, base_url=base_url) + client = _openai.OpenAI(api_key=api_key, base_url=base_url, timeout=15) try: models = list(client.models.list()) return JSONResponse( @@ -2731,6 +2845,614 @@ async def _execute_bulk_send( ) +# ============================================================ +# Génération d'email par IA (page Marketing) — jamais d'envoi +# automatique : le serveur ne fait que produire {subject, html}. +# ============================================================ + +MARKETING_PLAN_FILE = config.BASE_DIR / "MARKETING_PLAN.md" +MARKETING_PLAN_PROMPT_MAX_CHARS = 4000 +GENERATED_SUBJECT_MAX_CHARS = 200 +GENERATED_HTML_MAX_CHARS = 100_000 +GENERATION_TIMEOUT_SECONDS = 90 + + +def _load_marketing_plan_excerpt() -> str: + """Extrait du plan marketing pour le prompt : positionnement (§1) et + forfaits/chiffres autorisés (§2), tronqué (le prompt doit rester léger).""" + try: + text = MARKETING_PLAN_FILE.read_text(encoding="utf-8") + except Exception as e: + logger.warning("Lecture MARKETING_PLAN.md impossible: %s", e) + return "" + match = re.search(r"^## 1\..*?(?=^## 3\.|\Z)", text, re.MULTILINE | re.DOTALL) + excerpt = (match.group(0) if match else text).strip() + if len(excerpt) > MARKETING_PLAN_PROMPT_MAX_CHARS: + excerpt = excerpt[:MARKETING_PLAN_PROMPT_MAX_CHARS] + "\n[…]" + return excerpt + + +def _generation_model_for(admin_cfg, provider: str, requested: str) -> str: + """Modèle du canal : demande explicite > réglage admin > défaut du canal.""" + requested = (requested or "").strip() + if requested: + return requested + provider_cfg = getattr(admin_cfg, provider, None) + admin_model = (getattr(provider_cfg, "model", "") or "").strip() if provider_cfg else "" + return admin_model or LLM_CHANNELS[provider]["default_model"] + + +async def _call_llm_chat( + admin_cfg, provider: str, model: str, system_prompt: str, user_prompt: str +) -> str: + """Appel chat/completions (API compatible OpenAI) sur le canal choisi. + + Délai d'attente et erreurs propagées : l'appelant renvoie une erreur 502 + claire (jamais d'HTTP 500 nu). + """ + import httpx + + from routes.translate_routes import _channel_api_key, _channel_base_url + + api_key = _channel_api_key(admin_cfg, provider) + if not api_key: + raise RuntimeError(f"Aucune clé API configurée pour le canal « {provider} ».") + base_url = _channel_base_url(admin_cfg, provider).rstrip("/") + payload = { + "model": model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "temperature": 0.7, + "max_tokens": 4000, + } + async with httpx.AsyncClient(timeout=GENERATION_TIMEOUT_SECONDS) as client: + resp = await client.post( + f"{base_url}/chat/completions", + headers={"Authorization": f"Bearer {api_key}"}, + json=payload, + ) + resp.raise_for_status() + data = resp.json() + try: + return data["choices"][0]["message"]["content"] or "" + except (KeyError, IndexError, TypeError) as e: + raise RuntimeError(f"Réponse LLM inattendue: {e}") from e + + +def _build_generation_system_prompt( + language: str, tone: str, promo_line: str, promo_code: str = "" +) -> str: + plan_excerpt = _load_marketing_plan_excerpt() + tone_line = f"Ton demandé : {tone}. " if tone else "" + promo_block = "" + if promo_line: + site_url = os.getenv("FRONTEND_URL", "http://localhost:3000").rstrip("/") + promo_block = ( + f"\nOffre à mettre en avant :\n{promo_line}\n" + f"Termine l'email par un bouton d'appel à l'action bien visible " + f"(styles en ligne) dont le lien pointe exactement vers " + f"{site_url}/pricing?promo={promo_code} " + "— utilise ce lien tel quel, sans en inventer un autre.\n" + ) + if _language_is_rtl(language): + promo_block += ( + "Cette langue se lit de droite à gauche : mets dir=\"rtl\" sur le " + "conteneur principal du HTML et aligne le texte à droite.\n" + ) + return ( + "Tu es le rédacteur marketing de Wordly.art (Office Translator), un " + "service de traduction de documents qui préserve la mise en page.\n" + "Rédige un email de relance en respectant STRICTEMENT le plan marketing " + "ci-dessous : n'invente AUCUN chiffre, prix, pourcentage ou date absent " + "du plan ; si une réduction est fournie, cite-la exactement.\n" + f"Langue imposée de l'email : {language}. {tone_line}" + "Le HTML doit rester simple : structure avec des div et des styles en " + "ligne, largeur maximale ~600px, pas d'image externe, pas de " + "JavaScript, pas de CSS externe. Ne mets ni pied de page de " + "désabonnement ni coordonnées serveur : ils sont ajoutés par le " + "système au moment de l'envoi.\n" + f"{promo_block}" + "\n--- Extrait du plan marketing (source des chiffres autorisés) ---\n" + f"{plan_excerpt}\n" + "--- Fin de l'extrait ---\n\n" + 'Réponds UNIQUEMENT avec un objet JSON : {"subject": "<sujet court>", ' + '"html": "<email HTML complet>"}' + ) + + +def _parse_generated_email(raw: str) -> Optional[tuple]: + """Analyse robuste de la sortie du modèle : JSON direct, JSON dans un bloc + de code, premier objet {...}, puis repli <title>/corps. Renvoie + (subject, html) ou None si rien d'exploitable.""" + raw = (raw or "").strip() + if not raw: + return None + + def _from_json(text: str) -> Optional[tuple]: + try: + data = json.loads(text) + except (ValueError, TypeError): + return None + if not isinstance(data, dict): + return None + subject = str(data.get("subject") or "").strip() + html = str(data.get("html") or "").strip() + if subject and html: + return (subject, html) + return None + + # 1. JSON direct, 2. bloc de code markdown, 3. premier objet {...} + parsed = _from_json(raw) + if parsed: + return parsed + stripped = re.sub(r"^```[a-zA-Z]*\s*|\s*```$", "", raw, flags=re.MULTILINE).strip() + parsed = _from_json(stripped) + if parsed: + return parsed + brace = re.search(r"\{.*\}", raw, re.DOTALL) + if brace: + parsed = _from_json(brace.group(0)) + if parsed: + return parsed + + # 4. Repli texte : <title> = sujet, le reste = HTML. + title_match = re.search(r"<title[^>]*>(.*?)", raw, re.IGNORECASE | re.DOTALL) + if title_match: + subject = title_match.group(1).strip() + html = raw + if html.strip(): + return (subject, html) + return None + + +def _sanitize_generated_html(html: str) -> str: + """Nettoyage basique du HTML généré : pas de script (fermé ou non), pas + d'iframe/object/embed/form, pas de gestionnaires d'événements, pas d'URL + ``javascript:`` ni ``data:`` (casse mixte et espaces intrusive neutralisés).""" + # Scripts : balise fermée, balise orpheline (pas de ) et casse mixte. + html = re.sub( + r"]*>.*?(?:|$)", + "", + html, + flags=re.IGNORECASE | re.DOTALL, + ) + html = re.sub(r"]*>", "", html, flags=re.IGNORECASE) + # Éléments à contenu retirés en entier (avec repli si la balise fermante manque). + for tag in ("iframe", "object", "embed", "form"): + html = re.sub( + rf"<{tag}\b[^>]*>.*?(?:|$)", + "", + html, + flags=re.IGNORECASE | re.DOTALL, + ) + html = re.sub(rf"]*>", "", html, flags=re.IGNORECASE) + # Gestionnaires d'événements (onclick, onerror…). + html = re.sub( + r"\son\w+\s*=\s*(\"[^\"]*\"|'[^']*'|[^\s>]+)", "", html, flags=re.IGNORECASE + ) + # URL dangereuses : javascript: quel que soit le style (espaces insérés, + # casse mixte) et data: (data:text/html…). + html = re.sub(r"(?i)j\s*a\s*v\s*a\s*s\s*c\s*r\s*i\s*p\s*t\s*:", "", html) + html = re.sub(r"(?i)d\s*a\s*t\s*a\s*:", "", html) + return html + + +_RTL_GENERATION_LANGUAGES = { + "ar", "fa", "arabe", "arabic", "العربية", "فارسی", "persan", "persian", +} + + +def _language_is_rtl(language: str) -> bool: + return (language or "").strip().lower() in _RTL_GENERATION_LANGUAGES + + +class MarketingEmailGenerateRequest(BaseModel): + provider: str = "openrouter" # canal LLM (voir LLM_CHANNELS) + model: str = Field(default="", max_length=120) # vide = modèle par défaut du canal + brief: str = Field(..., min_length=1, max_length=4000) + language: str = Field(default="Français", max_length=50) + tone: str = Field(default="", max_length=50) + promo_code: str = Field(default="", max_length=24) + + +@router.post("/marketing/email/generate") +async def marketing_email_generate( + body: MarketingEmailGenerateRequest, admin_id: str = Depends(require_admin) +): + """Génère un email de relance par IA (sujet + HTML), sans aucun envoi. + + Le prompt système est construit depuis le plan marketing (positionnement, + forfaits, chiffres autorisés) ; la sortie du modèle est analysée avec + repli (JSON → /corps) et nettoyée (pas de script). Une sortie non + analysable renvoie 502 AI_GENERATION_FAILED — l'admin relit et envoie + depuis la page Marketing, rien n'est envoyé automatiquement. + """ + provider = (body.provider or "").strip().lower() + if provider not in LLM_CHANNELS: + return JSONResponse( + status_code=400, + content={ + "error": "UNKNOWN_CHANNEL", + "message": ( + f"Canal « {provider} » inconnu (canaux : " + f"{', '.join(LLM_CHANNELS)})." + ), + }, + ) + + admin_cfg = load_settings() + from routes.translate_routes import _channel_has_key + + if not _channel_has_key(admin_cfg, provider): + return JSONResponse( + status_code=400, + content={ + "error": "PROVIDER_NOT_CONFIGURED", + "message": ( + f"Aucune clé API configurée pour le canal « {provider} » " + "(réglages Fournisseurs ou .env)." + ), + }, + ) + + # Sans plan marketing lisible, l'IA inventerait des chiffres : on refuse. + if not _load_marketing_plan_excerpt().strip(): + return JSONResponse( + status_code=503, + content={ + "error": "MARKETING_PLAN_MISSING", + "message": ( + "MARKETING_PLAN.md est introuvable ou vide à la racine du " + "projet : la génération est refusée pour ne pas diffuser de " + "chiffres inventés." + ), + }, + ) + + promo_line = "" + promo_wanted = (body.promo_code or "").strip().upper() + if promo_wanted: + promo = next( + (p for p in load_promo_codes() if (p.get("code") or "").upper() == promo_wanted), + None, + ) + if not promo: + return JSONResponse( + status_code=400, + content={ + "error": "PROMO_NOT_FOUND", + "message": f"Le code promo « {promo_wanted} » n'existe pas : " + "corrigez ou videz le sélecteur de réduction.", + }, + ) + if not promo.get("active", False): + return JSONResponse( + status_code=400, + content={ + "error": "PROMO_NOT_FOUND", + "message": f"Le code promo « {promo_wanted} » n'est plus actif : " + "choisissez une autre réduction.", + }, + ) + if promo.get("type") == "percent": + value_desc = f"-{promo.get('value')} %" + else: + value_desc = f"-{promo.get('value')} €" + promo_line = ( + f"Code {promo.get('code')} ({value_desc}) — à citer exactement, " + f"valable pour : {', '.join(promo.get('plans') or ['all'])}." + ) + + system_prompt = _build_generation_system_prompt( + (body.language or "Français").strip() or "Français", + (body.tone or "").strip(), + promo_line, + promo_wanted, + ) + user_prompt = ( + f"Brief de l'email :\n{body.brief.strip()}\n\n" + "Produis l'objet et le HTML de l'email au format JSON demandé." + ) + model = _generation_model_for(admin_cfg, provider, body.model) + + try: + raw = await _call_llm_chat(admin_cfg, provider, model, system_prompt, user_prompt) + except Exception as e: + logger.error("marketing email generation failed (%s/%s): %s", provider, model, e) + return JSONResponse( + status_code=502, + content={ + "error": "AI_GENERATION_FAILED", + "message": f"La génération a échoué ({str(e)[:300]}). Aucun email envoyé.", + }, + ) + + parsed = _parse_generated_email(raw) + if not parsed: + logger.warning("marketing email generation unparseable output (%s/%s)", provider, model) + return JSONResponse( + status_code=502, + content={ + "error": "AI_GENERATION_FAILED", + "message": ( + "La réponse du modèle n'a pas pu être analysée (sujet + HTML " + "attendus). Aucun email envoyé — réessayez." + ), + }, + ) + + subject, html = parsed + subject = re.sub(r"\s+", " ", subject).strip()[:GENERATED_SUBJECT_MAX_CHARS] + html = _sanitize_generated_html(html).strip() + # Langues de droite à gauche : on garantit l'enveloppe dir="rtl" même si + # le modèle l'a oubliée. + if _language_is_rtl((body.language or "").strip()) and 'dir="rtl"' not in html.lower(): + html = f'<div dir="rtl">{html}</div>' + if not subject or not html or len(html) > GENERATED_HTML_MAX_CHARS: + return JSONResponse( + status_code=502, + content={ + "error": "AI_GENERATION_FAILED", + "message": ( + "Le contenu généré est vide ou dépasse la longueur maximale. " + "Aucun email envoyé — réessayez." + ), + }, + ) + + logger.info( + "marketing email generated channel=%s model=%s by=%s", provider, model, admin_id + ) + return JSONResponse( + status_code=200, + content={ + "data": { + "subject": subject, + "html": html, + "provider": provider, + "model": model, + "language": body.language, + }, + "meta": {}, + }, + ) + + +# ============================================================ +# Réductions : codes promo (data/promo_codes.json) + Stripe +# ============================================================ + +PROMO_CODE_PATTERN = re.compile(r"^[A-Z0-9-]{3,24}$") +_VALID_PROMO_PLANS = {"all"} | {pt.value for pt in PlanType} + + +class PromoCreateRequest(BaseModel): + code: str + type: Literal["percent", "amount"] = "percent" + value: float + plans: List[str] = Field(default_factory=lambda: ["all"]) + max_redemptions: Optional[int] = None + expires_at: Optional[str] = None # ISO (date ou date-heure), futur obligatoire + + +@router.get("/marketing/promos") +async def marketing_promos_list(admin_id: str = Depends(require_admin)): + """Liste des codes promo (compteur d'utilisations inclus, clés Stripe + internes incluses : identifiants d'objets, jamais de valeur sensible).""" + return JSONResponse( + status_code=200, + content={"data": {"promos": load_promo_codes()}, "meta": {}}, + ) + + +@router.post("/marketing/promos") +async def marketing_promos_create( + body: PromoCreateRequest, admin_id: str = Depends(require_admin) +): + """Crée un code promo : Coupon + Promotion Code Stripe si Stripe est + configuré, sinon un code « local_only » (refusé au checkout avec un + message clair). Plafonds posés côté local ET côté Stripe.""" + code = (body.code or "").strip().upper() + if not PROMO_CODE_PATTERN.match(code): + return JSONResponse( + status_code=400, + content={ + "error": "INVALID_PROMO_CODE", + "message": "Le code doit contenir 3 à 24 caractères : lettres majuscules, chiffres ou tirets.", + }, + ) + if any((p.get("code") or "").upper() == code for p in load_promo_codes()): + return JSONResponse( + status_code=409, + content={ + "error": "PROMO_ALREADY_EXISTS", + "message": f"Le code promo « {code} » existe déjà.", + }, + ) + + if body.type == "percent": + if not (0 < float(body.value) <= 100): + return JSONResponse( + status_code=400, + content={ + "error": "INVALID_PROMO_VALUE", + "message": "Pour un pourcentage, la valeur doit être entre 1 et 100.", + }, + ) + else: + if not (0.01 <= float(body.value) <= 10000): + return JSONResponse( + status_code=400, + content={ + "error": "INVALID_PROMO_VALUE", + "message": "Pour un montant, la valeur doit être entre 0,01 et 10 000 EUR.", + }, + ) + + plans = [p.strip().lower() for p in (body.plans or []) if p and p.strip()] + if not plans: + plans = ["all"] + if "all" in plans: + plans = ["all"] + invalid = [p for p in plans if p not in _VALID_PROMO_PLANS] + if invalid: + return JSONResponse( + status_code=400, + content={ + "error": "INVALID_PROMO_PLAN", + "message": f"Forfaits inconnus : {', '.join(invalid)}.", + }, + ) + if "free" in plans: + return JSONResponse( + status_code=400, + content={ + "error": "INVALID_PROMO_PLAN", + "message": "Le forfait Gratuit n'est pas éligible aux codes promo " + "(un code s'applique aux abonnements payants uniquement).", + }, + ) + + if body.max_redemptions is not None and int(body.max_redemptions) < 1: + return JSONResponse( + status_code=400, + content={ + "error": "INVALID_PROMO_LIMIT", + "message": "Le plafond d'utilisations doit être au moins 1.", + }, + ) + + expires_at = None + if body.expires_at and body.expires_at.strip(): + try: + exp = datetime.fromisoformat(body.expires_at.strip()) + if exp.tzinfo is None: + exp = exp.replace(tzinfo=timezone.utc) + if exp <= datetime.now(timezone.utc): + return JSONResponse( + status_code=400, + content={ + "error": "INVALID_PROMO_EXPIRY", + "message": "La date d'expiration doit être dans le futur.", + }, + ) + expires_at = exp.isoformat() + except ValueError: + return JSONResponse( + status_code=400, + content={ + "error": "INVALID_PROMO_EXPIRY", + "message": "Date d'expiration illisible (format ISO attendu).", + }, + ) + + try: + entry = create_promo_code( + code=code, + type=body.type, + value=float(body.value), + plans=plans, + max_redemptions=body.max_redemptions, + expires_at=expires_at, + ) + except DuplicatePromoError: + # Course perdue contre une création simultanée du même code : la + # vérification faisant foi est celle sous le verrou (service). + return JSONResponse( + status_code=409, + content={ + "error": "PROMO_ALREADY_EXISTS", + "message": f"Le code promo « {code} » existe déjà.", + }, + ) + except StripePromoCreationError as e: + return JSONResponse( + status_code=502, + content={ + "error": "STRIPE_PROMO_FAILED", + "message": f"Stripe a refusé la création du code « {code} » : {str(e)[:200]}. " + "Rien n'a été enregistré.", + }, + ) + logger.info("admin_promo_created code=%s by=%s", code, admin_id) + return JSONResponse(status_code=201, content={"data": entry, "meta": {}}) + + +@router.delete("/marketing/promos/{code}") +async def marketing_promos_delete(code: str, admin_id: str = Depends(require_admin)): + """Supprime un code promo (localement, et la promotion code chez Stripe).""" + promo = delete_promo_code(code) + if not promo: + return JSONResponse( + status_code=404, + content={ + "error": "PROMO_NOT_FOUND", + "message": f"Le code promo « {code} » n'existe pas.", + }, + ) + logger.info("admin_promo_deleted code=%s by=%s", code, admin_id) + return JSONResponse(status_code=200, content={"data": promo, "meta": {}}) + + +@router.post("/marketing/promos/{code}/link-stripe") +async def marketing_promos_link_stripe( + code: str, admin_id: str = Depends(require_admin) +): + """Relie un code « local_only » à Stripe : crée le Coupon + Promotion Code + et met à jour l'enregistrement (le code devient applicable au checkout).""" + try: + promo = link_promo_to_stripe(code) + except StripePromoCreationError as e: + return JSONResponse( + status_code=502, + content={ + "error": "STRIPE_PROMO_FAILED", + "message": f"Stripe a refusé la création pour « {code} » : {str(e)[:200]}.", + }, + ) + except PromoValidationError as e: + return JSONResponse( + status_code=400, + content={"error": "PROMO_INACTIVE", "message": e.message}, + ) + except RuntimeError as e: + return JSONResponse( + status_code=400, + content={"error": "STRIPE_NOT_CONFIGURED", "message": str(e)}, + ) + if not promo: + return JSONResponse( + status_code=404, + content={ + "error": "PROMO_NOT_FOUND", + "message": f"Le code promo « {code} » n'existe pas.", + }, + ) + logger.info("admin_promo_linked_stripe code=%s by=%s", code, admin_id) + return JSONResponse(status_code=200, content={"data": promo, "meta": {}}) + + +@router.post("/marketing/promos/{code}/deactivate") +async def marketing_promos_deactivate( + code: str, admin_id: str = Depends(require_admin) +): + """Désactive un code promo localement ET chez Stripe (la réduction + n'est plus applicable au checkout).""" + promo = deactivate_promo_code(code) + if not promo: + return JSONResponse( + status_code=404, + content={ + "error": "PROMO_NOT_FOUND", + "message": f"Le code promo « {code} » n'existe pas.", + }, + ) + logger.info("admin_promo_deactivated code=%s by=%s", code, admin_id) + return JSONResponse(status_code=200, content={"data": promo, "meta": {}}) + + marketing_public_router = APIRouter(prefix="/api/v1/marketing", tags=["Marketing"]) diff --git a/routes/api_v1_router.py b/routes/api_v1_router.py index ba101bf..950ced0 100644 --- a/routes/api_v1_router.py +++ b/routes/api_v1_router.py @@ -10,6 +10,7 @@ router = APIRouter(tags=["API v1"]) from routes.translate_routes import router_v1 as translate_router from routes.auth_routes import router_v1 as auth_router +from routes.auth_routes import promos_public_router from routes.api_key_routes import router as api_key_router from routes.admin_routes import router as admin_router from routes.admin_routes import marketing_public_router @@ -22,6 +23,7 @@ from routes.workspace_routes import router as workspace_router router.include_router(translate_router, tags=["Translation"]) router.include_router(auth_router, tags=["Authentication"]) +router.include_router(promos_public_router, tags=["Promotions"]) router.include_router(api_key_router, tags=["API Keys"]) router.include_router(admin_router, tags=["Admin"]) router.include_router(marketing_public_router, tags=["Marketing"]) diff --git a/routes/auth_routes.py b/routes/auth_routes.py index 0a6149e..4e1d519 100644 --- a/routes/auth_routes.py +++ b/routes/auth_routes.py @@ -72,6 +72,9 @@ class GoogleAuthRequest(BaseModel): class CheckoutRequest(BaseModel): plan: PlanType billing_period: str = "monthly" + # Code promo optionnel — validé localement AVANT toute création de session + # Stripe (actif, forfait, expiration, plafond). + promo_code: Optional[str] = None class CreditsCheckoutRequest(BaseModel): @@ -844,13 +847,19 @@ async def create_checkout_v1(request: CheckoutRequest, user=Depends(require_user user_id=user.id, plan=request.plan, billing_period=request.billing_period, + promo_code=(request.promo_code or "").strip(), ) if "error" in result and not result.get("demo_mode"): return JSONResponse( status_code=400, content={ - "error": "CHECKOUT_FAILED", + "error": result.get("error_code") or "CHECKOUT_FAILED", "message": result["error"], + **( + {"promo_reason": result["promo_reason"]} + if result.get("promo_reason") + else {} + ), }, ) return JSONResponse(status_code=200, content={"data": result, "meta": {}}) @@ -934,6 +943,56 @@ async def get_billing_portal_v1(user=Depends(require_user)): return JSONResponse(status_code=200, content={"data": {"url": url}, "meta": {}}) +# ============== Codes promo (validation publique avant paiement) ============== + +promos_public_router = APIRouter(prefix="/api/v1/promos", tags=["Promotions"]) + + +@promos_public_router.get( + "/validate", + summary="Valider un code promo", + description=( + "Vérifie un code promo pour un forfait donné AVANT l'ouverture du " + "paiement : renvoie valid, reason_code (cause explicite) et le montant " + "de la remise. Aucune authentification requise." + ), +) +async def validate_promo_v1( + code: str = Query(..., min_length=1, max_length=24), + plan: str = Query("pro", min_length=1, max_length=32), +): + from services.payment_service import PromoValidationError, validate_promo_code + + wanted = (code or "").strip().upper() + plan_id = (plan or "").strip().lower() + try: + promo = validate_promo_code(wanted, plan_id) + except PromoValidationError as e: + return JSONResponse( + status_code=200, + content={ + "data": { + "valid": False, + "reason_code": e.reason, + "message": e.message, + }, + "meta": {}, + }, + ) + discount: Dict[str, Any] = {} + if promo.get("type") == "percent": + discount["percent_off"] = promo.get("value") + else: + discount["amount_off"] = promo.get("value") + return JSONResponse( + status_code=200, + content={ + "data": {"valid": True, "reason_code": None, **discount}, + "meta": {}, + }, + ) + + # ============== Forgot / Reset password ============== diff --git a/routes/legacy_routes.py b/routes/legacy_routes.py index 62da0f0..f926eb4 100644 --- a/routes/legacy_routes.py +++ b/routes/legacy_routes.py @@ -88,7 +88,9 @@ async def get_available_providers( # AI Essentielle (OpenRouter — cheap model / Eco) if _is_enabled("openrouter", key_var="OPENROUTER_API_KEY"): - model = _resolve_tier_model(settings, user_plan, premium=False) + # La résolution renvoie la route complète (canal, modèle) — le canal + # réel (z.ai direct, DeepSeek direct…) peut différer d'openrouter. + _prov, model = _resolve_tier_model(settings, user_plan, premium=False) available.append({ "id": "openrouter", "label": "Traduction IA Éco", @@ -117,7 +119,7 @@ async def get_available_providers( # AI Premium (OpenRouter — premium model) if _is_enabled("openrouter_premium", key_var="OPENROUTER_API_KEY") or _is_enabled("openrouter", key_var="OPENROUTER_API_KEY"): - model = _resolve_tier_model(settings, user_plan, premium=True) + _prov, model = _resolve_tier_model(settings, user_plan, premium=True) available.append({ "id": "openrouter_premium", "label": "Traduction IA Premium", diff --git a/routes/translate_routes.py b/routes/translate_routes.py index 7c7e630..7d3897b 100644 --- a/routes/translate_routes.py +++ b/routes/translate_routes.py @@ -217,52 +217,190 @@ def _plan_ai_models(plan: PlanType, premium: bool) -> list: return models -def _resolve_tier_model(admin_cfg: Any, user_plan: Optional[str], premium: bool) -> str: - """Modèle réellement routé pour le palier IA du plan. +def _tier_route_candidates(admin_cfg: Any, premium: bool) -> tuple: + """Routes candidates d'un palier : (routes_multi_canaux, réglage_hérité). - Ordre de résolution (spec « Routage réel par abonnement ») : - 1. réglages admin ``ai_tiers.<palier>.default_model`` (changement sans redéploiement) ; - 2. premier modèle de ``ai_tiers.<palier>.models`` (ordre = priorité de secours) ; - 3. défaut du plan (``PLANS[plan]["ai_models_essential"|"ai_models_premium"][0]``). - Garde d'exécution : un modèle résolu dans la gamme de l'autre palier est - rejeté et remplacé par le défaut du plan — un utilisateur Pro (moteur - openrouter = Essentielle) ne peut pas déclencher un modèle Premium. + - ``routes`` multi-canaux ``{provider, model}`` — l'ordre est la priorité ; + - réglage hérité ``default_model`` puis ``models`` (chaînes OpenRouter), + conservé pour compatibilité — routé via openrouter comme avant. + Une chaîne simple (« z-ai/glm-5.3-flash ») vaut une route + ``{"provider": "openrouter", "model": …}`` (compatibilité ascendante). """ - from models.subscription import ( - DEFAULT_AI_MODELS_ESSENTIAL, - DEFAULT_AI_MODELS_PREMIUM, + tier_cfg = getattr( + getattr(admin_cfg, "ai_tiers", None), "premium" if premium else "essential", None ) - - tier_cfg = getattr(getattr(admin_cfg, "ai_tiers", None), "premium" if premium else "essential", None) - resolved = "" + routes: list = [] + legacy: list = [] if tier_cfg is not None: + for route in (getattr(tier_cfg, "routes", None) or []): + provider = (getattr(route, "provider", "") or "").strip().lower() + model = (getattr(route, "model", "") or "").strip() + if provider and model: + routes.append((provider, model)) default = (getattr(tier_cfg, "default_model", "") or "").strip() if default: - resolved = default - else: - tier_models = [m.strip() for m in (getattr(tier_cfg, "models", None) or []) if m and m.strip()] - if tier_models: - resolved = tier_models[0] - if not resolved: - resolved = _plan_ai_models(_plan_from_str(user_plan), premium)[0] + legacy.append(("openrouter", default)) + for model in (getattr(tier_cfg, "models", None) or []): + model = (model or "").strip() + if model: + legacy.append(("openrouter", model)) + return routes, legacy - # Garde : le modèle résolu doit appartenir au palier demandé. - if premium and resolved in DEFAULT_AI_MODELS_ESSENTIAL: + +def _resolve_tier_model(admin_cfg: Any, user_plan: Optional[str], premium: bool) -> tuple: + """Route réellement utilisée pour le palier IA du plan : ``(provider, model)``. + + Fonction de résolution UNIQUE, utilisée par le worker ET par l'affichage + public (/providers/available) : elle tient compte des clés disponibles, + une route sans clé API étant sautée au profit de la suivante. + + Ordre de résolution (spec « Routage réel par abonnement ») : + 1. routes multi-canaux ``ai_tiers.<palier>.routes`` (openrouter, deepseek, + zhipu, minimax, openai, zai) ; + 2. ancien réglage hérité ``default_model``/``models`` (= openrouter, + compatibilité ascendante) ; + 3. défaut du plan (``PLANS[plan]["ai_models_essential"|"ai_models_premium"][0]``). + Garde d'exécution inchangée : un modèle openrouter résolu dans la gamme de + l'autre palier est rejeté — un utilisateur Pro (moteur openrouter = + Essentielle) ne peut pas déclencher un modèle Premium. Les canaux directs + ont des identifiants propres : la garde ne s'applique pas à eux. + Repli final : ``(openrouter, défaut du plan)`` même sans clé — c'est la + référence d'affichage ; à l'exécution, l'instanciation échouera plus loin + (moteur indisponible), comme avant. + """ + routes, legacy = _tier_route_candidates(admin_cfg, premium) + + for provider, model in list(routes) + list(legacy): + if provider == "openrouter" and _model_in_other_range(model, premium): + logger.warning( + "ai_tiers.%s route %s belongs to the other model range — skipped.", + "premium" if premium else "essential", model, + ) + continue + if _channel_has_key(admin_cfg, provider): + return (provider, model) logger.warning( - "ai_tiers.premium resolved to an Essentielle model (%s) — using the " - "plan Premium default instead.", - resolved, + "ai_tiers.%s route %s/%s has no API key — falling back to the next route.", + "premium" if premium else "essential", provider, model, ) - resolved = _plan_ai_models(_plan_from_str(user_plan), premium=True)[0] - elif not premium and resolved in DEFAULT_AI_MODELS_PREMIUM: - logger.warning( - "ai_tiers.essential resolved to a Premium model (%s) — refusing: a " - "Pro subscription must never trigger a Premium model. Using the " - "plan Essentielle default instead.", - resolved, + + return ("openrouter", _plan_ai_models(_plan_from_str(user_plan), premium)[0]) + + +def _model_in_other_range(model: str, premium: bool) -> bool: + """True si un modèle openrouter appartient à la gamme de l'autre palier.""" + from models.subscription import DEFAULT_AI_MODELS_ESSENTIAL, DEFAULT_AI_MODELS_PREMIUM + + return model in (DEFAULT_AI_MODELS_ESSENTIAL if premium else DEFAULT_AI_MODELS_PREMIUM) + + +def _channel_has_key(admin_cfg: Any, provider: str) -> bool: + """Clé API disponible pour un canal (réglages admin, sinon variable d'env) ?""" + import os as _os + + from services.providers.config import LLM_CHANNELS + + channel = LLM_CHANNELS.get(provider) + if not channel: + return False + admin_val = "" + provider_cfg = getattr(admin_cfg, provider, None) + if provider_cfg is not None: + admin_val = (getattr(provider_cfg, "api_key", "") or "").strip() + env_val = (_os.getenv(channel["key_env"], "") or "").strip() + # openrouter_premium partage la clé openrouter. + if not admin_val and not env_val and provider == "openrouter_premium": + return _channel_has_key(admin_cfg, "openrouter") + return bool(admin_val or env_val) + + +def _channel_api_key(admin_cfg: Any, provider: str) -> str: + """Clé API d'un canal : réglages admin > variable d'environnement. + + ``openrouter_premium`` hérite de la clé openrouter (même passerelle). + """ + from services.providers.config import LLM_CHANNELS + + channel = LLM_CHANNELS.get(provider) + if not channel: + return "" + provider_cfg = getattr(admin_cfg, provider, None) + admin_val = (getattr(provider_cfg, "api_key", "") or "").strip() if provider_cfg else "" + if admin_val: + return admin_val + env_val = (os.getenv(channel["key_env"], "") or "").strip() + if env_val: + return env_val + if provider == "openrouter_premium": + return _channel_api_key(admin_cfg, "openrouter") + return "" + + +def _channel_base_url(admin_cfg: Any, provider: str) -> str: + """URL de base d'un canal : réglages admin > variable d'env > défaut.""" + from services.providers.config import LLM_CHANNELS + + channel = LLM_CHANNELS.get(provider) or {} + provider_cfg = getattr(admin_cfg, provider, None) + admin_val = (getattr(provider_cfg, "base_url", "") or "").strip() if provider_cfg else "" + if admin_val: + return admin_val + url_env = channel.get("url_env", "") + env_val = (os.getenv(url_env, "") or "").strip() if url_env else "" + return env_val or channel.get("base_url", "") + + +def _instantiate_channel_provider(admin_cfg: Any, provider: str, model: str) -> Any: + """Instancie le fournisseur d'un canal donné. + + deepseek → ``DeepSeekTranslationProvider`` ; tous les autres canaux + (openrouter, openrouter_premium, zhipu, minimax, zai, openai) sont des + API compatibles OpenAI → ``OpenAITranslationProvider`` avec leur base_url. + Renvoie None si la clé du canal est absente. + """ + api_key = _channel_api_key(admin_cfg, provider) + if not api_key: + return None + base_url = _channel_base_url(admin_cfg, provider) + timeout = int(os.getenv(f"{provider.upper()}_TIMEOUT", "60") or 60) + + if provider == "deepseek": + from services.providers.deepseek_provider import DeepSeekTranslationProvider + + return DeepSeekTranslationProvider( + api_key=api_key, model=model, base_url=base_url, timeout=timeout ) - resolved = _plan_ai_models(_plan_from_str(user_plan), premium=False)[0] - return resolved + from services.providers.openai_provider import OpenAITranslationProvider + + return OpenAITranslationProvider( + api_key=api_key, model=model, base_url=base_url, timeout=timeout + ) + + +def _instantiate_tier_routes( + admin_cfg: Any, user_plan: Optional[str], premium: bool, job_id: str = "" +) -> tuple: + """Choisit la route du palier via la résolution unique (clé-consciente, + ``_resolve_tier_model``) et instancie son fournisseur. + + Renvoie ``(provider_instance, channel, model)`` ou ``(None, None, None)`` + si la route retenue n'a pas de clé utilisable (le job échouera alors + comme avant, avec le message « moteur indisponible »). + """ + provider, model = _resolve_tier_model(admin_cfg, user_plan, premium) + instance = _instantiate_channel_provider(admin_cfg, provider, model) + tier_name = "premium" if premium else "essential" + if instance is not None: + logger.info( + "Job %s: ai_tiers.%s routed via %s/%s", + job_id, tier_name, provider, model, + ) + return instance, provider, model + logger.warning( + "Job %s: ai_tiers.%s route %s/%s has no usable API key — engine unavailable.", + job_id, tier_name, provider, model, + ) + return None, None, None def _image_translation_allowed_for_plan(plan: PlanType) -> bool: @@ -642,15 +780,23 @@ def _provider_model(provider: Any) -> str: ) -def _compute_cost_factor(provider: Any, provider_name: str = "") -> int: - """Billing cost factor (1 = standard, 5 = premium). +def _compute_cost_factor( + provider: Any, provider_name: str = "", premium: Optional[bool] = None +) -> int: + """Billing cost factor (1 = standard/Essentielle, 5 = premium). - Premium models (Claude, GPT-4 family, etc.) cost more and are billed at a - higher factor. Cheap variants are explicitly downgraded to 1 (``haiku``, - and the small GPT-4 models such as ``gpt-4o-mini`` / ``gpt-4o-nano``). - The provider may be passed by instance (model read off it) or only by - name (e.g. the ``openrouter_premium`` alias). + La facturation suit le PALIER, pas le canal : Premium = 5, Essentielle = 1, + quel que soit le canal réellement utilisé (openrouter, z.ai direct, DeepSeek + direct…). ``premium`` est imposé par l'appelant quand le palier est connu ; + sans information de palier (``premium=None``, moteurs explicites hors + palier), l'ancienne heuristique par nom de modèle s'applique (Claude, + GPT-4 → 5 ; variantes « mini »/« nano »/« haiku » → 1). """ + if premium is True: + return 5 + if premium is False: + return 1 + model_lower = _provider_model(provider).lower() provider_lower = (provider_name or "").lower() @@ -1358,9 +1504,8 @@ async def _run_translation_job( return (admin_val or "").strip() or os.getenv(env_var, default) api_key = _cfg(_admin_cfg.openrouter.api_key, "OPENROUTER_API_KEY") - # Routage réel par abonnement : le modèle vient du palier IA du plan - # (réglages admin « ai_tiers » > défaut du plan) — voir _resolve_tier_model. - model = _resolve_tier_model(_admin_cfg, user_plan, premium=False) + # Routage réel par abonnement : la route (canal + modèle) vient du + # palier IA du plan — voir _resolve_tier_model / _instantiate_tier_routes. # Story 3.10: Retrieve and format glossary terms for LLM prompt glossary_terms = None @@ -1437,26 +1582,18 @@ async def _run_translation_job( ) logger.info("google_provider_using_legacy", extra={"job_id": job_id}) - elif _p in ("openrouter", "llm") and api_key: - translation_provider = OpenAITranslationProvider( - api_key=api_key, - model=model, - base_url="https://openrouter.ai/api/v1", - timeout=int(os.getenv("OPENROUTER_TIMEOUT", "60")), + elif _p in ("openrouter", "llm"): + # Palier Essentielle : routes multi-canaux (z.ai direct, DeepSeek + # direct…) avec repli automatique sur la route suivante si la clé + # manque, puis sur l'ancien réglage hérité / le défaut du plan. + translation_provider, _channel, _model = _instantiate_tier_routes( + _admin_cfg, user_plan, premium=False, job_id=job_id ) elif _p == "openrouter_premium": - premium_key = _cfg(_admin_cfg.openrouter_premium.api_key, "OPENROUTER_API_KEY") - # Palier Premium : résolu par abonnement (défaut anthropic/claude-sonnet-5). - premium_model = _resolve_tier_model(_admin_cfg, user_plan, premium=True) - if not premium_key: - premium_key = api_key # fall back to main openrouter key - if premium_key: - translation_provider = OpenAITranslationProvider( - api_key=premium_key, - model=premium_model, - base_url="https://openrouter.ai/api/v1", - timeout=int(os.getenv("OPENROUTER_TIMEOUT", "60")), - ) + # Palier Premium : mêmes routes multi-canaux, gamme Premium. + translation_provider, _channel, _model = _instantiate_tier_routes( + _admin_cfg, user_plan, premium=True, job_id=job_id + ) elif _p == "openai": openai_key = _cfg(_admin_cfg.openai.api_key, "OPENAI_API_KEY") openai_model = _cfg(_admin_cfg.openai.model, "OPENAI_MODEL", "gpt-4o-mini") @@ -1484,7 +1621,7 @@ async def _run_translation_job( ) elif _p == "minimax": mm_key = _cfg(getattr(_admin_cfg, "minimax", None) and _admin_cfg.minimax.api_key, "MINIMAX_API_KEY") - mm_model = _cfg(getattr(_admin_cfg, "minimax", None) and _admin_cfg.minimax.model, "MINIMAX_MODEL", "MiniMax-M1") + mm_model = _cfg(getattr(_admin_cfg, "minimax", None) and _admin_cfg.minimax.model, "MINIMAX_MODEL", "MiniMax-M3") if mm_key: translation_provider = MinimaxTranslationProvider( api_key=mm_key, @@ -1947,10 +2084,17 @@ async def _run_translation_job( logger.warning(f"Job {job_id}: bilingual output failed: {bi_err}") if user_id: - # Determine cost factor based on selected provider and model. + # Determine cost factor based on the subscription TIER (Premium = 5, + # Essentielle/classique = 1), never on the channel name — a tier + # routed via z.ai or DeepSeek direct is billed like its tier. # _compute_cost_factor reads the model off the provider robustly # (new-style providers store it in ``_model``, legacy in ``model``). - cost_factor = _compute_cost_factor(translation_provider, provider or "") + if _p in ("openrouter", "llm"): + cost_factor = _compute_cost_factor(translation_provider, provider or "", premium=False) + elif _p == "openrouter_premium": + cost_factor = _compute_cost_factor(translation_provider, provider or "", premium=True) + else: + cost_factor = _compute_cost_factor(translation_provider, provider or "") # Persist monthly usage counters in PostgreSQL (docs + pages) pages = await asyncio.to_thread( diff --git a/services/payment_service.py b/services/payment_service.py index 474f985..1a15b5c 100644 --- a/services/payment_service.py +++ b/services/payment_service.py @@ -1,9 +1,13 @@ """ Stripe payment integration for subscriptions and credits """ +import json import os import logging -from typing import Optional, Dict, Any +import re +import threading +from pathlib import Path +from typing import Optional, Dict, List, Any, Tuple from datetime import datetime, timezone logger = logging.getLogger(__name__) @@ -21,6 +25,7 @@ from services.auth_service import get_user_by_id, update_user, add_credits from services.pricing_config import ( get_subscription_line_amount_eur, stripe_price_ids_for_plan, + stripe_mode, ) @@ -50,24 +55,389 @@ def is_stripe_configured() -> bool: return _ensure_stripe_client_configured() +# ═══════════════════════════════════════════════════════════════════════════ +# Codes promo (réductions) — stockés dans data/promo_codes.json (comme +# pricing_overrides.json : JSON géré par l'admin, pas de table en base). +# +# Chaque entrée : +# code, active, type ("percent"|"amount"), value, plans (["all"] ou ids), +# max_redemptions (None = illimité), times_used, expires_at (ISO ou None), +# stripe_coupon_id / stripe_promotion_code_id (None si créé hors Stripe), +# stripe_mode ("test"|"live"|"local_only"), created_at, deactivated_at. +# +# Double plafond : le compteur local (times_used) est incrémenté uniquement à +# la complétion du paiement, ET Stripe applique son propre max_redemptions. +# ═══════════════════════════════════════════════════════════════════════════ + +try: + from config import config as _app_config + + PROMO_CODES_FILE: Path = _app_config.BASE_DIR / "data" / "promo_codes.json" +except Exception: # pragma: no cover — config toujours disponible en pratique + PROMO_CODES_FILE = Path("data") / "promo_codes.json" + +PROMO_CODE_RE = re.compile(r"^[A-Z0-9-]{3,24}$") +_promo_lock = threading.Lock() + + +class PromoValidationError(Exception): + """Code promo inutilisable au checkout — la cause est explicite.""" + + def __init__(self, reason: str, message: str): + self.reason = reason # unknown|inactive|expired|exhausted|plan|local_only + self.message = message + super().__init__(message) + + +class DuplicatePromoError(Exception): + """Un code identique existe déjà (vérification faite SOUS le verrou).""" + + +class StripePromoCreationError(Exception): + """Stripe a refusé la création du Coupon/Promotion Code (rien n'est persisté).""" + + +def load_promo_codes() -> List[dict]: + try: + if PROMO_CODES_FILE.exists(): + with open(PROMO_CODES_FILE, encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, list) else [] + except Exception as e: + logger.warning("Lecture promo_codes impossible: %s", e) + return [] + + +def save_promo_codes(entries: List[dict]) -> None: + """Écriture atomique : fichier temporaire puis renommage (os.replace) — + un plantage pendant l'écriture ne peut pas laisser un JSON tronqué.""" + PROMO_CODES_FILE.parent.mkdir(parents=True, exist_ok=True) + tmp_path = PROMO_CODES_FILE.with_name(PROMO_CODES_FILE.name + ".tmp") + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(entries, f, indent=2, ensure_ascii=False) + os.replace(tmp_path, PROMO_CODES_FILE) + + +def _find_promo(entries: List[dict], code: str) -> Optional[dict]: + wanted = (code or "").strip().upper() + for e in entries: + if (e.get("code") or "").strip().upper() == wanted: + return e + return None + + +def validate_promo_code( + code: str, plan: str, entries: Optional[List[dict]] = None +) -> dict: + """Valide un code promo AVANT tout appel Stripe — lève PromoValidationError. + + Causes explicites : inconnu, désactivé, expiré, épuisé, forfait non couvert, + ou code créé « local_only » (sans Stripe, donc inapplicable au checkout). + Une limite ou un compteur illisible (JSON corrompu) est traité par prudence + comme un code épuisé, jamais comme illimité. + """ + wanted = (code or "").strip().upper() + if not wanted: + raise PromoValidationError("unknown", "Aucun code promo fourni.") + promo = _find_promo(entries if entries is not None else load_promo_codes(), wanted) + if not promo: + raise PromoValidationError("unknown", f"Le code promo « {wanted} » n'existe pas.") + if not promo.get("active", False): + raise PromoValidationError("inactive", f"Le code promo « {wanted} » n'est plus actif.") + + expires_at = promo.get("expires_at") + if expires_at: + try: + exp = datetime.fromisoformat(str(expires_at)) + if exp.tzinfo is None: + exp = exp.replace(tzinfo=timezone.utc) + if datetime.now(timezone.utc) >= exp: + raise PromoValidationError( + "expired", f"Le code promo « {wanted} » a expiré." + ) + except PromoValidationError: + raise + except (TypeError, ValueError): + logger.warning("promo %s: expires_at illisible (%s)", wanted, expires_at) + + max_redemptions = promo.get("max_redemptions") + if max_redemptions is not None: + try: + limit = int(max_redemptions) + used = int(promo.get("times_used", 0) or 0) + except (TypeError, ValueError): + raise PromoValidationError( + "exhausted", + f"Le code promo « {wanted} » est inutilisable : sa limite " + "d'utilisations est illisible.", + ) + if used >= limit: + raise PromoValidationError( + "exhausted", + f"Le code promo « {wanted} » a atteint sa limite d'utilisations.", + ) + + plans = promo.get("plans") or ["all"] + if "all" not in plans and plan not in plans: + raise PromoValidationError( + "plan", + f"Le code promo « {wanted} » n'est pas valable pour le forfait « {plan} ».", + ) + + if not promo.get("stripe_promotion_code_id"): + raise PromoValidationError( + "local_only", + f"Le code promo « {wanted} » n'est pas relié à Stripe et ne peut pas " + "être appliqué au paiement.", + ) + return promo + + +def increment_promo_usage(code: str, session_id: str = "") -> bool: + """Incrémente le compteur local d'un code promo (à la complétion SEULEMENT). + + ``session_id`` (identifiant de session Stripe de checkout) protège du double + comptage : un webhook suivi d'une synchronisation manuelle du MÊME paiement + ne compte qu'une fois, y compris en mode JSON (sans base). + """ + wanted = (code or "").strip().upper() + if not wanted: + return False + sid = (session_id or "").strip() + with _promo_lock: + entries = load_promo_codes() + promo = _find_promo(entries, wanted) + if not promo: + return False + counted = promo.get("counted_sessions") or [] + if sid and sid in counted: + logger.info( + "promo %s: checkout session %s déjà comptée — incrément ignoré", + wanted, sid, + ) + return False + try: + promo["times_used"] = int(promo.get("times_used", 0) or 0) + 1 + except (TypeError, ValueError): + promo["times_used"] = 1 + promo["last_used_at"] = datetime.now(timezone.utc).isoformat() + if sid: + promo["counted_sessions"] = list(counted) + [sid] + save_promo_codes(entries) + logger.info("promo_code usage incremented: %s → %s", wanted, promo["times_used"]) + return True + + +def deactivate_promo_code(code: str) -> Optional[dict]: + """Désactive un code localement ET chez Stripe (promotion code désactivée).""" + wanted = (code or "").strip().upper() + with _promo_lock: + entries = load_promo_codes() + promo = _find_promo(entries, wanted) + if not promo: + return None + promo["active"] = False + promo["deactivated_at"] = datetime.now(timezone.utc).isoformat() + if promo.get("stripe_promotion_code_id") and is_stripe_configured(): + try: + stripe.PromotionCode.modify(promo["stripe_promotion_code_id"], active=False) + except Exception as e: + # Stripe peut être injoignable : la désactivation locale reste + # effective et fait foi pour le checkout. + logger.error("Stripe PromotionCode.deactivate failed for %s: %s", wanted, e) + save_promo_codes(entries) + return promo + + +def delete_promo_code(code: str) -> Optional[dict]: + """Supprime un code promo (admin). La promotion code Stripe associée est + supprimée aussi (repli : désactivée si la suppression est refusée).""" + wanted = (code or "").strip().upper() + with _promo_lock: + entries = load_promo_codes() + promo = _find_promo(entries, wanted) + if not promo: + return None + if promo.get("stripe_promotion_code_id") and is_stripe_configured(): + try: + stripe.PromotionCode.delete(promo["stripe_promotion_code_id"]) + except Exception as e: + logger.warning( + "Stripe PromotionCode.delete failed for %s (%s) — fallback: deactivate", + wanted, e, + ) + try: + stripe.PromotionCode.modify(promo["stripe_promotion_code_id"], active=False) + except Exception as e2: + logger.error("Stripe PromotionCode deactivate fallback failed: %s", e2) + entries = [e for e in entries if e is not promo] + save_promo_codes(entries) + logger.info("promo %s supprimé", wanted) + return promo + + +def _create_stripe_refs( + code: str, type: str, value: float, + max_redemptions: Optional[int], expires_at: Optional[str], +) -> tuple: + """Crée le Coupon + Promotion Code Stripe. En cas d'échec Stripe, supprime + un coupon déjà créé (pas d'objet orphelin) et lève StripePromoCreationError.""" + coupon = None + try: + coupon_params: Dict[str, Any] = { + "duration": "once", + "name": f"Reduction {code}", + } + if type == "percent": + coupon_params["percent_off"] = round(float(value), 2) + else: + coupon_params["amount_off"] = int(round(float(value) * 100)) + coupon_params["currency"] = "eur" + coupon = stripe.Coupon.create(**coupon_params) + + pc_params: Dict[str, Any] = {"coupon": coupon.id} + if max_redemptions: + pc_params["max_redemptions"] = int(max_redemptions) + if expires_at: + try: + exp = datetime.fromisoformat(str(expires_at)) + pc_params["expires_at"] = int( + exp.replace(tzinfo=exp.tzinfo or timezone.utc).timestamp() + ) + except (TypeError, ValueError): + logger.warning("promo %s: expires_at illisible, ignoré côté Stripe", code) + promo_code_obj = stripe.PromotionCode.create(**pc_params) + return coupon.id, promo_code_obj.id + except Exception as e: + stripe_err = getattr(stripe, "error", None) if stripe is not None else None + if stripe_err is not None and isinstance(e, stripe_err.StripeError): + if coupon: + try: + stripe.Coupon.delete(coupon.id) + logger.info("coupon orphelin %s supprimé après échec Stripe", coupon.id) + except Exception as del_err: + logger.error("suppression du coupon orphelin %s impossible: %s", coupon.id, del_err) + raise StripePromoCreationError(str(e)) from e + raise + + +def create_promo_code( + code: str, + type: str, + value: float, + plans: Optional[List[str]] = None, + max_redemptions: Optional[int] = None, + expires_at: Optional[str] = None, +) -> dict: + """Crée un code promo localement et, si Stripe est configuré, le Coupon + + Promotion Code Stripe associés (double plafond local ET Stripe). + + Sans Stripe, le code est créé en « local_only » : il reste visible dans + l'admin mais sera refusé au checkout avec un message clair. + Le doublon est vérifié SOUS le verrou : deux créations simultanées du même + code aboutissent à un seul enregistrement. + """ + wanted = (code or "").strip().upper() + now_iso = datetime.now(timezone.utc).isoformat() + + with _promo_lock: + entries = load_promo_codes() + if _find_promo(entries, wanted): + raise DuplicatePromoError( + f"Le code promo « {wanted} » existe déjà." + ) + + entry: Dict[str, Any] = { + "code": wanted, + "active": True, + "type": type, + "value": float(value), + "plans": plans or ["all"], + "max_redemptions": int(max_redemptions) if max_redemptions else None, + "times_used": 0, + "counted_sessions": [], + "expires_at": expires_at or None, + "stripe_coupon_id": None, + "stripe_promotion_code_id": None, + "stripe_mode": "local_only", + "created_at": now_iso, + "deactivated_at": None, + } + + if is_stripe_configured(): + coupon_id, promo_code_id = _create_stripe_refs( + wanted, type, value, max_redemptions, expires_at + ) + entry["stripe_coupon_id"] = coupon_id + entry["stripe_promotion_code_id"] = promo_code_id + entry["stripe_mode"] = stripe_mode() if stripe_mode() != "unknown" else "test" + logger.info("promo %s créé sur Stripe (coupon=%s)", wanted, coupon_id) + else: + logger.info("promo %s créé en local_only (Stripe non configuré)", wanted) + + entries.append(entry) + save_promo_codes(entries) + return entry + + +def link_promo_to_stripe(code: str) -> Optional[dict]: + """Relie un code « local_only » à Stripe : crée le Coupon + Promotion Code + et met à jour l'enregistrement local. Renvoie None si le code est inconnu.""" + wanted = (code or "").strip().upper() + with _promo_lock: + entries = load_promo_codes() + promo = _find_promo(entries, wanted) + if not promo: + return None + if not promo.get("active", False): + raise PromoValidationError( + "inactive", f"Le code promo « {wanted} » est désactivé : réactivez-le d'abord." + ) + if promo.get("stripe_promotion_code_id"): + return promo # déjà relié — rien à faire + if not is_stripe_configured(): + raise RuntimeError("Stripe n'est pas configuré (clé secrète absente).") + coupon_id, promo_code_id = _create_stripe_refs( + wanted, promo.get("type", "percent"), float(promo.get("value", 0)), + promo.get("max_redemptions"), promo.get("expires_at"), + ) + promo["stripe_coupon_id"] = coupon_id + promo["stripe_promotion_code_id"] = promo_code_id + promo["stripe_mode"] = stripe_mode() if stripe_mode() != "unknown" else "test" + save_promo_codes(entries) + logger.info("promo %s relié à Stripe (coupon=%s)", wanted, coupon_id) + return promo + + async def create_checkout_session( user_id: str, plan: PlanType, billing_period: str = "monthly", # monthly or yearly success_url: str = "", cancel_url: str = "", + promo_code: str = "", ) -> Optional[Dict[str, Any]]: """Create a Stripe checkout session for subscription""" if not is_stripe_configured(): return {"error": "Stripe not configured", "demo_mode": True} - + user = get_user_by_id(user_id) if not user: return {"error": "User not found"} - + plan_config = PLANS[plan] plan_id = plan.value + # Réduction : validation locale AVANT Stripe (actif, forfait, expiration, + # plafond local). Un code invalide n'entraîne aucune session Stripe. + promo_entry: Optional[dict] = None + promo_wanted = (promo_code or "").strip().upper() + if promo_wanted: + try: + promo_entry = validate_promo_code(promo_wanted, plan_id) + except PromoValidationError as e: + return {"error": e.message, "error_code": "PROMO_INVALID", "promo_reason": e.reason} + mid, yid = stripe_price_ids_for_plan(plan_id) price_id = mid if billing_period == "monthly" else yid @@ -112,22 +482,38 @@ async def create_checkout_session( update_user(user_id, {"stripe_customer_id": customer_id}) # Create checkout session - session = stripe.checkout.Session.create( - customer=customer_id, - mode="subscription", - payment_method_types=["card"], - line_items=[line_item], - success_url=success_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/checkout/success?session_id={{CHECKOUT_SESSION_ID}}", - cancel_url=cancel_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/pricing", - metadata={"user_id": user_id, "plan": plan.value}, - subscription_data={ - "metadata": {"user_id": user_id, "plan": plan.value} - } - ) - + session_kwargs: Dict[str, Any] = { + "customer": customer_id, + "mode": "subscription", + "payment_method_types": ["card"], + "line_items": [line_item], + "success_url": success_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/checkout/success?session_id={{CHECKOUT_SESSION_ID}}", + "cancel_url": cancel_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/pricing", + "metadata": { + "user_id": user_id, + "plan": plan.value, + **({"promo_code": promo_wanted} if promo_entry else {}), + }, + "subscription_data": { + "metadata": { + "user_id": user_id, + "plan": plan.value, + **({"promo_code": promo_wanted} if promo_entry else {}), + } + }, + } + if promo_entry: + # Remise Stripe : la Promotion Code créée par l'admin porte les + # plafonds Stripe (max_redemptions / expires_at). + session_kwargs["discounts"] = [ + {"promotion_code": promo_entry["stripe_promotion_code_id"]} + ] + session = stripe.checkout.Session.create(**session_kwargs) + return { "session_id": session.id, - "url": session.url + "url": session.url, + **({"promo_code": promo_wanted} if promo_entry else {}), } except Exception as e: return {"error": str(e)} @@ -415,6 +801,17 @@ async def handle_checkout_completed(session: Dict): }) logger.info("Checkout synced: user %s → plan=%s tier=%s sub=%s", user_id, plan, tier, subscription_id) + # Réduction : le compteur local du code promo n'augmente qu'à la + # complétion réelle du paiement (webhook OU synchronisation manuelle), + # via la métadonnée posée à la création de la session. Les crédits ne + # supportent pas de code promo. + promo_used = (metadata.get("promo_code") or "").strip() + if promo_used: + try: + increment_promo_usage(promo_used, session_id=session_id or "") + except Exception as promo_err: + logger.error("promo usage increment failed for %s: %s", promo_used, promo_err) + # Log to PaymentHistory if db_available and session_id: try: diff --git a/services/providers/config.py b/services/providers/config.py index 05c5e5f..ebbb163 100644 --- a/services/providers/config.py +++ b/services/providers/config.py @@ -5,7 +5,7 @@ Loads API keys, URLs, and enable/disable flags from environment variables. """ import os -from typing import List, Optional +from typing import Dict, List, Optional from pydantic import BaseModel @@ -100,6 +100,16 @@ class ProvidersConfig: MINIMAX_MAX_RETRIES: int = int(os.getenv("MINIMAX_MAX_RETRIES", "3")) MINIMAX_RETRY_DELAY: float = float(os.getenv("MINIMAX_RETRY_DELAY", "1.0")) + # z.ai / Zhipu (modèles GLM — API directe compatible OpenAI) + # Clé : https://z.ai/manage-apikey/apikey-list — base par défaut https://api.z.ai/api/paas/v4 + ZHIPU_ENABLED: bool = os.getenv("ZHIPU_ENABLED", "false").lower() == "true" + ZHIPU_API_KEY: str = os.getenv("ZHIPU_API_KEY", "") + ZHIPU_MODEL: str = os.getenv("ZHIPU_MODEL", "glm-5.3") + ZHIPU_BASE_URL: str = os.getenv("ZHIPU_BASE_URL", "https://api.z.ai/api/paas/v4") + ZHIPU_TIMEOUT: int = int(os.getenv("ZHIPU_TIMEOUT", "60")) + ZHIPU_MAX_RETRIES: int = int(os.getenv("ZHIPU_MAX_RETRIES", "3")) + ZHIPU_RETRY_DELAY: float = float(os.getenv("ZHIPU_RETRY_DELAY", "1.0")) + # Fallback chain configuration # # IMPORTANT: the registry-based fallback (translate_with_fallback) only @@ -199,6 +209,12 @@ class ProvidersConfig: base_url=cls.MINIMAX_BASE_URL, model=cls.MINIMAX_MODEL, ), + "zhipu": ProviderSettings( + enabled=cls.ZHIPU_ENABLED, + api_key=cls.ZHIPU_API_KEY if cls.ZHIPU_API_KEY else None, + base_url=cls.ZHIPU_BASE_URL, + model=cls.ZHIPU_MODEL, + ), } return settings_map.get(provider_name.lower(), ProviderSettings()) @@ -219,7 +235,7 @@ class ProvidersConfig: return False # Providers requiring API keys - providers_requiring_key = {"openai", "openrouter", "google_cloud", "deepseek", "minimax"} + providers_requiring_key = {"openai", "openrouter", "google_cloud", "deepseek", "minimax", "zhipu"} if provider_name.lower() in providers_requiring_key: return bool(settings.api_key) @@ -238,3 +254,59 @@ class ProvidersConfig: providers_config = ProvidersConfig() + + +# Canaux LLM (tous compatibles OpenAI) utilisables dans les routes de palier +# « ai_tiers » et par la génération d'emails marketing. Pour chaque canal : +# base_url par défaut, variable d'environnement de la clé API, variable du +# modèle et modèle par défaut. Les réglages admin (provider_settings.json) +# priment sur les variables d'environnement. +LLM_CHANNELS: Dict[str, Dict[str, str]] = { + "openrouter": { + "base_url": "https://openrouter.ai/api/v1", + "key_env": "OPENROUTER_API_KEY", + "model_env": "OPENROUTER_MODEL", + "default_model": "deepseek/deepseek-v4-flash", + }, + "openrouter_premium": { + "base_url": "https://openrouter.ai/api/v1", + "key_env": "OPENROUTER_API_KEY", + "model_env": "OPENROUTER_PREMIUM_MODEL", + "default_model": "anthropic/claude-sonnet-5", + }, + "deepseek": { + "base_url": "https://api.deepseek.com/v1", + "key_env": "DEEPSEEK_API_KEY", + "model_env": "DEEPSEEK_MODEL", + "url_env": "DEEPSEEK_BASE_URL", + "default_model": "deepseek-chat", + }, + "zhipu": { + "base_url": "https://api.z.ai/api/paas/v4", + "key_env": "ZHIPU_API_KEY", + "model_env": "ZHIPU_MODEL", + "url_env": "ZHIPU_BASE_URL", + "default_model": "glm-5.3", + }, + "minimax": { + "base_url": "https://api.minimax.io/v1", + "key_env": "MINIMAX_API_KEY", + "model_env": "MINIMAX_MODEL", + "url_env": "MINIMAX_BASE_URL", + "default_model": "MiniMax-M3", + }, + "openai": { + "base_url": "https://api.openai.com/v1", + "key_env": "OPENAI_API_KEY", + "model_env": "OPENAI_MODEL", + "url_env": "OPENAI_BASE_URL", + "default_model": "gpt-4o-mini", + }, + "zai": { + "base_url": "https://api.x.ai/v1", + "key_env": "ZAI_API_KEY", + "model_env": "ZAI_MODEL", + "url_env": "ZAI_BASE_URL", + "default_model": "grok-2-1212", + }, +} diff --git a/tests/test_admin_promos.py b/tests/test_admin_promos.py new file mode 100644 index 0000000..e1d3f1a --- /dev/null +++ b/tests/test_admin_promos.py @@ -0,0 +1,570 @@ +""" +Réductions : codes promo (spec « Fournisseur par palier IA »). + +- création locale (data/promo_codes.json) + Coupon/Promotion Code Stripe + quand Stripe est configuré (sinon « local_only ») ; +- validation AU CHECKOUT AVANT tout appel Stripe (actif, forfait, expiration, + plafond) avec une cause explicite ; +- incrément du compteur uniquement à la complétion du paiement. +""" + +import json +import pytest +from pathlib import Path +from types import SimpleNamespace +from datetime import datetime, timedelta, timezone + +import services.payment_service as payment_svc + +ADMIN_LOGIN_URL = "/api/v1/admin/login" +PROMOS_URL = "/api/v1/admin/marketing/promos" + + +def _write_json(path: Path, data) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _future(days: int = 30) -> str: + return (datetime.now(timezone.utc) + timedelta(days=days)).isoformat() + + +@pytest.fixture +def promo_file(tmp_path: Path, monkeypatch) -> Path: + path = tmp_path / "promo_codes.json" + monkeypatch.setattr(payment_svc, "PROMO_CODES_FILE", path) + return path + + +@pytest.fixture +def client(promo_file, monkeypatch, tmp_path: Path): + """TestClient avec authentification JSON (sans base de données).""" + import services.auth_service as auth_svc + from middleware.rate_limiting import RateLimitManager + + monkeypatch.setattr(auth_svc, "USERS_FILE", tmp_path / "users.json") + monkeypatch.setattr(auth_svc, "USE_DATABASE", False) + monkeypatch.setattr(auth_svc, "DATABASE_AVAILABLE", False) + + async def _check_request_allow(self, request): + return True, "ok", "test" + + async def _check_translation_allow(self, request, file_size_mb=0): + return True, "ok" + + monkeypatch.setattr(RateLimitManager, "check_request", _check_request_allow) + monkeypatch.setattr(RateLimitManager, "check_translation", _check_translation_allow) + + from fastapi.testclient import TestClient + from main import app + + return TestClient(app, raise_server_exceptions=True) + + +@pytest.fixture +def admin_headers(client, monkeypatch): + import routes.admin_routes as admin_routes_mod + + monkeypatch.setattr(admin_routes_mod, "ADMIN_USERNAME", "admin") + monkeypatch.setattr(admin_routes_mod, "ADMIN_PASSWORD", "admin-secret") + monkeypatch.setattr(admin_routes_mod, "ADMIN_PASSWORD_HASH", None) + r = client.post(ADMIN_LOGIN_URL, json={"password": "admin-secret"}) + assert r.status_code == 200, r.text + token = r.json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +# --------------------------------------------------------------------------- +# Création / listage / désactivation (endpoints admin) +# --------------------------------------------------------------------------- +def test_create_promo_without_stripe_is_local_only(client, admin_headers, promo_file): + r = client.post( + PROMOS_URL, + json={"code": "lancement-10", "type": "percent", "value": 10, + "max_redemptions": 10, "expires_at": _future()}, + headers=admin_headers, + ) + assert r.status_code == 201, r.text + entry = r.json()["data"] + assert entry["code"] == "LANCEMENT-10" # normalisé en majuscules + assert entry["stripe_mode"] == "local_only" + assert entry["stripe_promotion_code_id"] is None + assert entry["times_used"] == 0 + + r2 = client.get(PROMOS_URL, headers=admin_headers) + assert r2.status_code == 200 + assert [p["code"] for p in r2.json()["data"]["promos"]] == ["LANCEMENT-10"] + + +def test_create_promo_with_stripe_creates_coupon_and_code( + client, admin_headers, promo_file, monkeypatch +): + monkeypatch.setattr(payment_svc, "is_stripe_configured", lambda: True) + + class _FakeObj(dict): + def __getattr__(self, name): + try: + return self[name] + except KeyError as e: # pragma: no cover + raise AttributeError(name) from e + + def _fake_coupon(**kwargs): + assert kwargs["percent_off"] == 15 + assert kwargs["duration"] == "once" + return _FakeObj(id="coupon_1") + + def _fake_promo_code(**kwargs): + assert kwargs["coupon"] == "coupon_1" + assert kwargs["max_redemptions"] == 5 + assert "expires_at" in kwargs + return _FakeObj(id="promo_1") + + monkeypatch.setattr(payment_svc.stripe.Coupon, "create", _fake_coupon) + monkeypatch.setattr(payment_svc.stripe.PromotionCode, "create", _fake_promo_code) + monkeypatch.setattr(payment_svc, "stripe_mode", lambda: "test") + + r = client.post( + PROMOS_URL, + json={"code": "PROMO15", "type": "percent", "value": 15, + "plans": ["pro", "business"], "max_redemptions": 5, + "expires_at": _future()}, + headers=admin_headers, + ) + assert r.status_code == 201, r.text + entry = r.json()["data"] + assert entry["stripe_coupon_id"] == "coupon_1" + assert entry["stripe_promotion_code_id"] == "promo_1" + assert entry["stripe_mode"] == "test" + + +def test_create_promo_rejects_invalid_input(client, admin_headers, promo_file): + # code trop court / minuscules après normalisation impossible (pattern) + r = client.post(PROMOS_URL, json={"code": "ab", "type": "percent", "value": 10}, + headers=admin_headers) + assert r.status_code == 400 + assert r.json()["error"] == "INVALID_PROMO_CODE" + + # code avec caractères interdits + r = client.post(PROMOS_URL, json={"code": "MAUVAIS CODE!", "type": "percent", "value": 10}, + headers=admin_headers) + assert r.status_code == 400 + + # pourcentage hors bornes + r = client.post(PROMOS_URL, json={"code": "GRAND", "type": "percent", "value": 150}, + headers=admin_headers) + assert r.status_code == 400 + assert r.json()["error"] == "INVALID_PROMO_VALUE" + + # montant nul + r = client.post(PROMOS_URL, json={"code": "ZERO", "type": "amount", "value": 0}, + headers=admin_headers) + assert r.status_code == 400 + + # forfait inconnu + r = client.post(PROMOS_URL, json={"code": "PLANX", "type": "percent", "value": 10, + "plans": ["platine"]}, headers=admin_headers) + assert r.status_code == 400 + assert r.json()["error"] == "INVALID_PROMO_PLAN" + + # expiration dans le passé + r = client.post(PROMOS_URL, json={"code": "HIER", "type": "percent", "value": 10, + "expires_at": "2020-01-01T00:00:00+00:00"}, + headers=admin_headers) + assert r.status_code == 400 + assert r.json()["error"] == "INVALID_PROMO_EXPIRY" + + # plafond inférieur à 1 + r = client.post(PROMOS_URL, json={"code": "PLAFOND", "type": "percent", "value": 10, + "max_redemptions": 0}, headers=admin_headers) + assert r.status_code == 400 + + +def test_create_duplicate_promo_refused(client, admin_headers, promo_file): + body = {"code": "DUP", "type": "percent", "value": 10} + assert client.post(PROMOS_URL, json=body, headers=admin_headers).status_code == 201 + r = client.post(PROMOS_URL, json=body, headers=admin_headers) + assert r.status_code == 409 + assert r.json()["error"] == "PROMO_ALREADY_EXISTS" + + +def test_deactivate_promo(client, admin_headers, promo_file, monkeypatch): + client.post(PROMOS_URL, json={"code": "BYE", "type": "percent", "value": 5}, + headers=admin_headers) + r = client.post(f"{PROMOS_URL}/BYE/deactivate", headers=admin_headers) + assert r.status_code == 200 + assert r.json()["data"]["active"] is False + + r2 = client.post(f"{PROMOS_URL}/INCONNU/deactivate", headers=admin_headers) + assert r2.status_code == 404 + + +def test_promos_require_admin(client): + assert client.get(PROMOS_URL).status_code == 401 + + +# --------------------------------------------------------------------------- +# Validation au checkout — AVANT tout appel Stripe +# --------------------------------------------------------------------------- +@pytest.fixture +def checkout_mocks(monkeypatch, promo_file): + """Stripe « configuré » mais sous surveillance : toute création de session + est capturée (et aurait levé si appelée à tort).""" + calls: dict = {"session": 0, "kwargs": None} + + def _fake_session_create(**kwargs): + calls["session"] += 1 + calls["kwargs"] = kwargs + return SimpleNamespace(id="cs_test_1", url="https://stripe.test/checkout") + + monkeypatch.setattr(payment_svc, "is_stripe_configured", lambda: True) + monkeypatch.setattr(payment_svc.stripe.checkout.Session, "create", _fake_session_create) + monkeypatch.setattr(payment_svc, "stripe_price_ids_for_plan", lambda p: ("price_m", "price_y")) + + user = SimpleNamespace( + id="u1", email="buyer@example.com", name="Buyer", + stripe_customer_id="cus_1", stripe_subscription_id=None, + subscription_status="active", plan="free", cancel_at_period_end=False, + docs_translated_this_month=0, pages_translated_this_month=0, + api_calls_this_month=0, extra_credits=0, subscription_ends_at=None, + ) + monkeypatch.setattr(payment_svc, "get_user_by_id", lambda uid: user if uid == "u1" else None) + monkeypatch.setattr(payment_svc, "update_user", lambda uid, data: None) + + # Pas de base de données dans les tests : la vérification d'antidoublon + # (PaymentHistory) doit échouer proprement — la fonction la tolère. + import database.connection as db_conn + + def _no_db(): + raise RuntimeError("pas de base de données dans les tests") + + monkeypatch.setattr(db_conn, "get_sync_session", _no_db) + return calls + + +@pytest.mark.asyncio +async def test_checkout_with_valid_promo_applies_discount( + checkout_mocks, promo_file, monkeypatch +): + monkeypatch.setattr(payment_svc, "stripe_mode", lambda: "test") + monkeypatch.setattr( + payment_svc.stripe.Coupon, "create", lambda **kw: SimpleNamespace(id="coupon_9") + ) + monkeypatch.setattr( + payment_svc.stripe.PromotionCode, + "create", + lambda **kw: SimpleNamespace(id="promo_9"), + ) + payment_svc.create_promo_code( + code="SOIF10", type="percent", value=10, plans=["pro"], + max_redemptions=10, expires_at=_future(), + ) + + from models.subscription import PlanType + + result = await payment_svc.create_checkout_session( + user_id="u1", plan=PlanType.PRO, promo_code="soif10" + ) + assert "error" not in result, result + assert result["promo_code"] == "SOIF10" + kwargs = checkout_mocks["kwargs"] + assert kwargs["discounts"] == [{"promotion_code": "promo_9"}] + assert kwargs["metadata"]["promo_code"] == "SOIF10" + assert kwargs["subscription_data"]["metadata"]["promo_code"] == "SOIF10" + + +@pytest.mark.asyncio +async def test_checkout_rejects_expired_promo_before_stripe( + checkout_mocks, promo_file +): + payment_svc.save_promo_codes([{ + "code": "VIEUX", "active": True, "type": "percent", "value": 10, + "plans": ["all"], "max_redemptions": None, "times_used": 0, + "expires_at": "2020-01-01T00:00:00+00:00", + "stripe_coupon_id": "c", "stripe_promotion_code_id": "p", + "stripe_mode": "test", "created_at": "t", "deactivated_at": None, + }]) + from models.subscription import PlanType + + result = await payment_svc.create_checkout_session( + user_id="u1", plan=PlanType.PRO, promo_code="VIEUX" + ) + assert result.get("error_code") == "PROMO_INVALID" + assert result.get("promo_reason") == "expired" + assert checkout_mocks["session"] == 0 # aucune session Stripe créée + + +@pytest.mark.asyncio +async def test_checkout_rejects_exhausted_promo(checkout_mocks, promo_file): + """Un code à 10/10 utilisations est refusé avant tout appel Stripe.""" + payment_svc.save_promo_codes([{ + "code": "FINI", "active": True, "type": "percent", "value": 10, + "plans": ["all"], "max_redemptions": 10, "times_used": 10, + "expires_at": None, "stripe_coupon_id": "c", + "stripe_promotion_code_id": "p", "stripe_mode": "test", + "created_at": "t", "deactivated_at": None, + }]) + from models.subscription import PlanType + + result = await payment_svc.create_checkout_session( + user_id="u1", plan=PlanType.FREE, promo_code="FINI" + ) + assert result.get("promo_reason") == "exhausted" + assert checkout_mocks["session"] == 0 + + +@pytest.mark.asyncio +async def test_checkout_rejects_promo_for_other_plan(checkout_mocks, promo_file): + payment_svc.save_promo_codes([{ + "code": "PROONLY", "active": True, "type": "percent", "value": 10, + "plans": ["pro"], "max_redemptions": None, "times_used": 0, + "expires_at": None, "stripe_coupon_id": "c", + "stripe_promotion_code_id": "p", "stripe_mode": "test", + "created_at": "t", "deactivated_at": None, + }]) + from models.subscription import PlanType + + result = await payment_svc.create_checkout_session( + user_id="u1", plan=PlanType.STARTER, promo_code="PROONLY" + ) + assert result.get("promo_reason") == "plan" + assert checkout_mocks["session"] == 0 + + +@pytest.mark.asyncio +async def test_checkout_rejects_unknown_and_local_only_promos( + checkout_mocks, promo_file +): + from models.subscription import PlanType + + result = await payment_svc.create_checkout_session( + user_id="u1", plan=PlanType.PRO, promo_code="NUL" + ) + assert result.get("promo_reason") == "unknown" + + payment_svc.save_promo_codes([{ + "code": "LOCAL", "active": True, "type": "percent", "value": 10, + "plans": ["all"], "max_redemptions": None, "times_used": 0, + "expires_at": None, "stripe_coupon_id": None, + "stripe_promotion_code_id": None, "stripe_mode": "local_only", + "created_at": "t", "deactivated_at": None, + }]) + result2 = await payment_svc.create_checkout_session( + user_id="u1", plan=PlanType.PRO, promo_code="LOCAL" + ) + assert result2.get("promo_reason") == "local_only" + assert checkout_mocks["session"] == 0 + + +@pytest.mark.asyncio +async def test_completion_increments_promo_counter_once_per_session( + checkout_mocks, promo_file, monkeypatch +): + """Le compteur local n'augmente qu'à la complétion ; un webhook suivi d'une + synchronisation du MÊME paiement ne compte qu'une fois, même en mode JSON.""" + monkeypatch.setattr(payment_svc, "stripe_mode", lambda: "test") + monkeypatch.setattr( + payment_svc.stripe.Coupon, "create", lambda **kw: SimpleNamespace(id="c") + ) + monkeypatch.setattr( + payment_svc.stripe.PromotionCode, "create", lambda **kw: SimpleNamespace(id="p") + ) + payment_svc.create_promo_code( + code="COMPTE", type="percent", value=10, max_redemptions=10 + ) + assert payment_svc.load_promo_codes()[0]["times_used"] == 0 + + session = { + "id": "cs_1", + "metadata": {"user_id": "u1", "plan": "pro", "promo_code": "COMPTE"}, + "subscription": {"id": "sub_1", "current_period_end": None}, + "customer": "cus_1", + "payment_intent": None, + "amount_total": 1900, + "currency": "eur", + } + # La vérification d'antidoublon touche la base : indifférent ici, la + # fonction tolère son échec. + await payment_svc.handle_checkout_completed(session) + await payment_svc.handle_checkout_completed(session) # re-livraison du webhook + + # Synchronisation manuelle du MÊME paiement (même session Stripe) : 0 incrément. + await payment_svc.handle_checkout_completed(session) + + promo = payment_svc.load_promo_codes()[0] + assert promo["times_used"] == 1 + assert promo["counted_sessions"] == ["cs_1"] + assert promo["last_used_at"] + + # Un AUTRE paiement (autre session) compte bien une seconde fois. + session2 = dict(session, id="cs_2") + await payment_svc.handle_checkout_completed(session2) + assert payment_svc.load_promo_codes()[0]["times_used"] == 2 + + +@pytest.mark.asyncio +async def test_checkout_without_promo_has_no_discount(checkout_mocks, promo_file): + from models.subscription import PlanType + + result = await payment_svc.create_checkout_session( + user_id="u1", plan=PlanType.PRO, promo_code="" + ) + assert "error" not in result + assert "discounts" not in checkout_mocks["kwargs"] + assert "promo_code" not in checkout_mocks["kwargs"]["metadata"] + + +# --------------------------------------------------------------------------- +# Robustesse (relecture) : doublon sous verrou, Stripe en erreur, JSON corrompu +# --------------------------------------------------------------------------- +def test_duplicate_checked_under_lock_two_creates_one_409(promo_file): + """Deux créations du même code : la seconde est rejetée par la vérification + SOUS le verrou du service (une seule entrée persistée).""" + payment_svc.create_promo_code(code="RACE1", type="percent", value=10) + with pytest.raises(payment_svc.DuplicatePromoError): + payment_svc.create_promo_code(code="RACE1", type="percent", value=10) + assert [p["code"] for p in payment_svc.load_promo_codes()] == ["RACE1"] + + +def test_stripe_failure_cleans_orphan_coupon_and_persists_nothing( + promo_file, monkeypatch +): + """Coupon créé puis PromotionCode refusé : le coupon orphelin est supprimé, + l'erreur est typée et RIEN n'est écrit dans promo_codes.json.""" + monkeypatch.setattr(payment_svc, "is_stripe_configured", lambda: True) + monkeypatch.setattr( + payment_svc.stripe.Coupon, "create", lambda **kw: SimpleNamespace(id="coupon_X") + ) + deleted: list = [] + monkeypatch.setattr( + payment_svc.stripe.Coupon, "delete", lambda cid: deleted.append(cid) + ) + + def _boom(**kw): + raise payment_svc.stripe.error.StripeError("card_error test") + + monkeypatch.setattr(payment_svc.stripe.PromotionCode, "create", _boom) + + with pytest.raises(payment_svc.StripePromoCreationError): + payment_svc.create_promo_code(code="RATEE", type="percent", value=10) + assert deleted == ["coupon_X"] + assert payment_svc.load_promo_codes() == [] + + +def test_unreadable_limit_or_counter_means_exhausted(promo_file): + """max_redemptions / times_used illisibles (JSON corrompu) : le code est + traité comme épuisé avec un message, jamais comme illimité.""" + base = { + "code": "CORROMPU", "active": True, "type": "percent", "value": 10, + "plans": ["all"], "expires_at": None, "stripe_coupon_id": "c", + "stripe_promotion_code_id": "p", "stripe_mode": "test", + "created_at": "t", "deactivated_at": None, + } + payment_svc.save_promo_codes([dict(base, max_redemptions="n/a", times_used=0)]) + with pytest.raises(payment_svc.PromoValidationError) as err: + payment_svc.validate_promo_code("CORROMPU", "pro") + assert err.value.reason == "exhausted" + + payment_svc.save_promo_codes([dict(base, max_redemptions=10, times_used="beaucoup")]) + with pytest.raises(payment_svc.PromoValidationError) as err2: + payment_svc.validate_promo_code("CORROMPU", "pro") + assert err2.value.reason == "exhausted" + + +def test_save_is_atomic_no_temp_file_left(promo_file): + payment_svc.save_promo_codes([{ + "code": "ATOM", "active": True, "type": "percent", "value": 5, + }]) + assert payment_svc.PROMO_CODES_FILE.exists() + assert not payment_svc.PROMO_CODES_FILE.with_name( + payment_svc.PROMO_CODES_FILE.name + ".tmp" + ).exists() + # le JSON reste lisible après écriture atomique + assert payment_svc.load_promo_codes()[0]["code"] == "ATOM" + + +def test_create_promo_for_free_plan_refused(client, admin_headers, promo_file): + r = client.post( + PROMOS_URL, + json={"code": "FREEBIE", "type": "percent", "value": 10, "plans": ["free"]}, + headers=admin_headers, + ) + assert r.status_code == 400 + assert r.json()["error"] == "INVALID_PROMO_PLAN" + assert "Gratuit" in r.json()["message"] + + +def test_delete_promo_endpoint(client, admin_headers, promo_file, monkeypatch): + client.post(PROMOS_URL, json={"code": "ADIEU", "type": "percent", "value": 5}, + headers=admin_headers) + r = client.delete(f"{PROMOS_URL}/ADIEU", headers=admin_headers) + assert r.status_code == 200 + assert payment_svc.load_promo_codes() == [] + + r2 = client.delete(f"{PROMOS_URL}/ADIEU", headers=admin_headers) + assert r2.status_code == 404 + + +def test_link_local_only_promo_to_stripe(client, admin_headers, promo_file, monkeypatch): + monkeypatch.setattr(payment_svc, "is_stripe_configured", lambda: True) + monkeypatch.setattr(payment_svc, "stripe_mode", lambda: "test") + monkeypatch.setattr( + payment_svc.stripe.Coupon, "create", lambda **kw: SimpleNamespace(id="coupon_L") + ) + monkeypatch.setattr( + payment_svc.stripe.PromotionCode, + "create", + lambda **kw: SimpleNamespace(id="promo_L"), + ) + + client.post(PROMOS_URL, json={"code": "LOCAL1", "type": "amount", "value": 5}, + headers=admin_headers) + r = client.post(f"{PROMOS_URL}/LOCAL1/link-stripe", headers=admin_headers) + assert r.status_code == 200, r.text + entry = r.json()["data"] + assert entry["stripe_coupon_id"] == "coupon_L" + assert entry["stripe_promotion_code_id"] == "promo_L" + assert entry["stripe_mode"] == "test" + + +# --------------------------------------------------------------------------- +# Point de contact public : GET /api/v1/promos/validate +# --------------------------------------------------------------------------- +def test_public_validate_endpoint(promo_file, client): + payment_svc.save_promo_codes([{ + "code": "VALID10", "active": True, "type": "percent", "value": 10, + "plans": ["all"], "max_redemptions": None, "times_used": 0, + "expires_at": None, "stripe_coupon_id": "c", + "stripe_promotion_code_id": "p", "stripe_mode": "test", + "created_at": "t", "deactivated_at": None, + }]) + r = client.get("/api/v1/promos/validate", params={"code": "valid10", "plan": "pro"}) + assert r.status_code == 200 + data = r.json()["data"] + assert data["valid"] is True + assert data["percent_off"] == 10 + + # montant en euros + payment_svc.save_promo_codes(payment_svc.load_promo_codes() + [{ + "code": "EUROS5", "active": True, "type": "amount", "value": 5, + "plans": ["pro"], "max_redemptions": None, "times_used": 0, + "expires_at": None, "stripe_coupon_id": "c", + "stripe_promotion_code_id": "p", "stripe_mode": "test", + "created_at": "t", "deactivated_at": None, + }]) + r2 = client.get("/api/v1/promos/validate", params={"code": "EUROS5", "plan": "pro"}) + assert r2.json()["data"]["amount_off"] == 5 + + # causes explicites + r3 = client.get("/api/v1/promos/validate", params={"code": "INCONNU", "plan": "pro"}) + assert r3.json()["data"]["valid"] is False + assert r3.json()["data"]["reason_code"] == "unknown" + + payment_svc.save_promo_codes([dict( + code="EXPIRE1", active=True, type="percent", value=10, + plans=["all"], max_redemptions=None, times_used=0, + expires_at="2020-01-01T00:00:00+00:00", stripe_coupon_id="c", + stripe_promotion_code_id="p", stripe_mode="test", + created_at="t", deactivated_at=None, + )]) + r4 = client.get("/api/v1/promos/validate", params={"code": "EXPIRE1", "plan": "pro"}) + assert r4.json()["data"]["reason_code"] == "expired" diff --git a/tests/test_ai_tier_routes.py b/tests/test_ai_tier_routes.py new file mode 100644 index 0000000..a00eed9 --- /dev/null +++ b/tests/test_ai_tier_routes.py @@ -0,0 +1,490 @@ +""" +Routage multi-canaux des paliers IA (spec « Fournisseur par palier IA »). + +- Chaque route d'un palier est un couple {provider, model} : une route peut + partir par z.ai direct (zhipu), DeepSeek direct, etc. ; +- une route dont la clé API manque est sautée au profit de la suivante ; +- l'ancien réglage à chaînes reste routé via openrouter (compatibilité) ; +- la facturation suit le PALIER (Premium = 5, Essentielle = 1), pas le canal. +""" + +import asyncio +import pytest +from pathlib import Path + +import routes.translate_routes as tr + + +# --------------------------------------------------------------------------- +# Résolution unitaire : (provider, model) +# --------------------------------------------------------------------------- +def _settings(ai_tiers: dict | None = None, **providers): + from routes.admin_routes import SettingsConfig + + payload: dict = {"ai_tiers": ai_tiers} if ai_tiers is not None else {} + for provider, cfg in providers.items(): + payload[provider] = cfg + return SettingsConfig.model_validate(payload) + + +def test_route_with_configured_key_is_used(monkeypatch): + monkeypatch.delenv("ZHIPU_API_KEY", raising=False) + cfg = _settings( + { + "essential": { + "models": [], + "default_model": "", + "routes": [{"provider": "zhipu", "model": "glm-5.3-flash"}], + }, + "premium": {"models": [], "default_model": ""}, + }, + zhipu={"api_key": "zk-test"}, + ) + assert tr._resolve_tier_model(cfg, "pro", premium=False) == ( + "zhipu", + "glm-5.3-flash", + ) + + +def test_route_without_key_falls_back_to_next(monkeypatch): + monkeypatch.delenv("ZHIPU_API_KEY", raising=False) + monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False) + cfg = _settings( + { + "essential": { + "models": [], + "default_model": "", + "routes": [ + {"provider": "zhipu", "model": "glm-5.3-flash"}, + {"provider": "openrouter", "model": "deepseek/deepseek-v4-flash"}, + ], + }, + "premium": {"models": [], "default_model": ""}, + }, + openrouter={"api_key": "sk-or-test"}, + ) + assert tr._resolve_tier_model(cfg, "pro", premium=False) == ( + "openrouter", + "deepseek/deepseek-v4-flash", + ) + + +def test_legacy_string_models_still_route_via_openrouter(monkeypatch): + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + cfg = _settings( + { + "essential": {"models": ["deepseek/deepseek-v4-flash"], "default_model": ""}, + "premium": {"models": [], "default_model": ""}, + }, + openrouter={"api_key": "sk-or-test"}, + ) + provider, model = tr._resolve_tier_model(cfg, "pro", premium=False) + assert (provider, model) == ("openrouter", "deepseek/deepseek-v4-flash") + + +def test_premium_route_resolves_premium_tier(monkeypatch): + monkeypatch.delenv("ZHIPU_API_KEY", raising=False) + cfg = _settings( + { + "essential": {"models": [], "default_model": ""}, + "premium": { + "models": [], + "default_model": "", + "routes": [{"provider": "deepseek", "model": "deepseek-reasoner"}], + }, + }, + deepseek={"api_key": "dk-test"}, + ) + assert tr._resolve_tier_model(cfg, "business", premium=True) == ( + "deepseek", + "deepseek-reasoner", + ) + + +# --------------------------------------------------------------------------- +# Facturation par palier (pas par canal) +# --------------------------------------------------------------------------- +def test_cost_factor_follows_tier_not_channel(): + assert tr._compute_cost_factor(None, "zhipu", premium=True) == 5 + assert tr._compute_cost_factor(None, "deepseek", premium=True) == 5 + assert tr._compute_cost_factor(None, "zhipu", premium=False) == 1 + assert tr._compute_cost_factor(None, "openrouter_premium", premium=False) == 1 + + +def test_cost_factor_legacy_heuristic_kept_without_tier(): + class _P: + _model = "anthropic/claude-sonnet-5" + + assert tr._compute_cost_factor(_P(), "zai") == 5 + assert tr._compute_cost_factor(None, "openrouter_premium") == 5 + assert tr._compute_cost_factor(None, "openrouter") == 1 + + +# --------------------------------------------------------------------------- +# Validation admin des routes (normalize_ai_tiers) +# --------------------------------------------------------------------------- +def test_normalize_rejects_unknown_route_provider(): + from routes.admin_routes import normalize_ai_tiers + + cfg = _settings( + { + "essential": { + "models": [], + "default_model": "", + "routes": [{"provider": "anthropic", "model": "claude"}], + }, + "premium": {"models": [], "default_model": ""}, + } + ) + with pytest.raises(ValueError, match="inconnu"): + normalize_ai_tiers(cfg.ai_tiers) + + +def test_normalize_rejects_route_without_model(): + from routes.admin_routes import normalize_ai_tiers + + cfg = _settings( + { + "essential": { + "models": [], + "default_model": "", + "routes": [{"provider": "zhipu", "model": " "}], + }, + "premium": {"models": [], "default_model": ""}, + } + ) + with pytest.raises(ValueError, match="modèle"): + normalize_ai_tiers(cfg.ai_tiers) + + +def test_normalize_deduplicates_routes_and_keeps_order(): + from routes.admin_routes import normalize_ai_tiers + + cfg = _settings( + { + "essential": { + "models": [], + "default_model": "", + "routes": [ + {"provider": "zhipu", "model": "glm-5.3-flash"}, + {"provider": "zhipu", "model": "glm-5.3-flash", "label": "doublon"}, + {"provider": "openrouter", "model": "deepseek/deepseek-v4-flash"}, + ], + }, + "premium": {"models": [], "default_model": ""}, + } + ) + normalized = normalize_ai_tiers(cfg.ai_tiers) + assert [(r.provider, r.model) for r in normalized.essential.routes] == [ + ("zhipu", "glm-5.3-flash"), + ("openrouter", "deepseek/deepseek-v4-flash"), + ] + + +def test_normalize_route_cross_tier_guard(): + """Une route openrouter pointant un modèle Premium dans le palier + Essentielle est refusée (anti-croisement inchangé).""" + from routes.admin_routes import normalize_ai_tiers + + cfg = _settings( + { + "essential": { + "models": [], + "default_model": "", + "routes": [{"provider": "openrouter", "model": "anthropic/claude-sonnet-5"}], + }, + "premium": {"models": [], "default_model": ""}, + } + ) + with pytest.raises(ValueError, match="Premium"): + normalize_ai_tiers(cfg.ai_tiers) + + +def test_normalize_empty_routes_and_models_fills_official_range(): + from routes.admin_routes import normalize_ai_tiers + from models.subscription import DEFAULT_AI_MODELS_ESSENTIAL + + cfg = _settings( + { + "essential": {"models": [], "default_model": ""}, + "premium": {"models": [], "default_model": ""}, + } + ) + normalized = normalize_ai_tiers(cfg.ai_tiers) + assert normalized.essential.models == list(DEFAULT_AI_MODELS_ESSENTIAL) + assert normalized.essential.routes == [] + + +# --------------------------------------------------------------------------- +# Intégration worker : le canal choisi instancie le bon fournisseur +# --------------------------------------------------------------------------- +@pytest.fixture +def minimal_xlsx(tmp_path): + try: + import openpyxl + + wb = openpyxl.Workbook() + wb.active["A1"] = "Hello" + p = tmp_path / "minimal.xlsx" + wb.save(p) + return p + except ImportError: + pytest.skip("openpyxl required") + + +def _register_job(job_id: str) -> None: + tr._translation_jobs[job_id] = { + "id": job_id, + "status": "processing", + "progress_percent": 0, + "current_step": "", + "provider": "openrouter", + "created_at": "2026-09-05T00:00:00+00:00", + } + + +def _run_job(monkeypatch, tmp_path, minimal_xlsx, admin_settings, provider: str, + user_plan: str, user_id=None, cost_capture: dict | None = None) -> list: + """Lance _run_translation_job avec un faux fournisseur et renvoie les + instanciations capturées (model, base_url, api_key). + + ``user_id`` + ``cost_capture`` : enregistre le facteur de facturation + transmis à ``record_usage`` (substitué — aucun accès à la base).""" + import routes.admin_routes as admin_routes_mod + import services.providers.openai_provider as openai_provider_mod + import services.providers.deepseek_provider as deepseek_provider_mod + + captured: list = [] + + class FakeProvider: + def __init__(self, api_key=None, model=None, base_url=None, timeout=60, **kwargs): + self.model = model + self._model = model + self.base_url = base_url + captured.append({"model": model, "base_url": base_url, "api_key": api_key}) + + class FakeExcelTranslator: + def __init__(self, provider=None): + self.provider = provider + + def translate_file(self, input_path, output_path, target_lang, + source_lang="auto", progress_callback=None, + translate_images=False, **kwargs): + out = Path(output_path) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_bytes(minimal_xlsx.read_bytes()) + + def get_translation_stats(self): + return {"attempted": 1, "changed": 1} + + def _fake_record_usage(uid, pages, cost_factor, reserved_docs=1): + if cost_capture is not None: + cost_capture["cost_factor"] = cost_factor + + monkeypatch.setattr(openai_provider_mod, "OpenAITranslationProvider", FakeProvider) + monkeypatch.setattr(deepseek_provider_mod, "DeepSeekTranslationProvider", FakeProvider) + monkeypatch.setattr(tr, "ExcelTranslator", FakeExcelTranslator) + monkeypatch.setattr(tr, "record_usage", _fake_record_usage) + monkeypatch.setattr( + admin_routes_mod, "load_settings", lambda: admin_settings, raising=True + ) + + _register_job("job-routes") + try: + asyncio.run( + tr._run_translation_job( + job_id="job-routes", + input_path=Path(minimal_xlsx), + file_extension=".xlsx", + target_lang="fr", + source_lang="en", + provider=provider, + user_id=user_id, + custom_prompt=None, + glossary_id=None, + prompt_id=None, + webhook_url=None, + user_plan=user_plan, + ) + ) + finally: + tr._translation_jobs.pop("job-routes", None) + return captured + + +def test_worker_routes_zhipu_channel(monkeypatch, tmp_path, minimal_xlsx): + monkeypatch.delenv("ZHIPU_API_KEY", raising=False) + settings = _settings( + { + "essential": { + "models": [], + "default_model": "", + "routes": [{"provider": "zhipu", "model": "glm-5.3-flash"}], + }, + "premium": {"models": [], "default_model": ""}, + }, + zhipu={"api_key": "zk-test"}, + ) + captured = _run_job(monkeypatch, tmp_path, minimal_xlsx, settings, + provider="openrouter", user_plan="PlanType.PRO") + assert captured, "le fournisseur n'a pas été instancié" + assert captured[0]["model"] == "glm-5.3-flash" + assert captured[0]["base_url"] == "https://api.z.ai/api/paas/v4" + + +def test_worker_skips_route_without_key(monkeypatch, tmp_path, minimal_xlsx): + """Clé zhipu absente → route suivante (openrouter) comme le veut la spec.""" + monkeypatch.delenv("ZHIPU_API_KEY", raising=False) + settings = _settings( + { + "essential": { + "models": [], + "default_model": "", + "routes": [ + {"provider": "zhipu", "model": "glm-5.3-flash"}, + {"provider": "openrouter", "model": "deepseek/deepseek-v4-flash"}, + ], + }, + "premium": {"models": [], "default_model": ""}, + }, + openrouter={"api_key": "sk-or-test"}, + ) + captured = _run_job(monkeypatch, tmp_path, minimal_xlsx, settings, + provider="openrouter", user_plan="PlanType.PRO") + assert captured[0]["base_url"] == "https://openrouter.ai/api/v1" + assert captured[0]["model"] == "deepseek/deepseek-v4-flash" + + +def test_worker_legacy_settings_keep_openrouter(monkeypatch, tmp_path, minimal_xlsx): + """Ancien réglage (chaînes) : routé via openrouter comme aujourd'hui.""" + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + settings = _settings( + { + "essential": {"models": ["deepseek/deepseek-v4-flash"], "default_model": ""}, + "premium": {"models": [], "default_model": ""}, + }, + openrouter={"api_key": "sk-or-test"}, + ) + captured = _run_job(monkeypatch, tmp_path, minimal_xlsx, settings, + provider="openrouter", user_plan="PlanType.PRO") + assert captured[0]["base_url"] == "https://openrouter.ai/api/v1" + assert captured[0]["model"] == "deepseek/deepseek-v4-flash" + + +def test_worker_deepseek_route_uses_deepseek_provider(monkeypatch, tmp_path, minimal_xlsx): + monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False) + settings = _settings( + { + "essential": { + "models": [], + "default_model": "", + "routes": [{"provider": "deepseek", "model": "deepseek-chat"}], + }, + "premium": {"models": [], "default_model": ""}, + }, + deepseek={"api_key": "dk-test"}, + ) + captured = _run_job(monkeypatch, tmp_path, minimal_xlsx, settings, + provider="openrouter", user_plan="PlanType.PRO") + assert captured[0]["base_url"] == "https://api.deepseek.com/v1" + assert captured[0]["api_key"] == "dk-test" + + +# --------------------------------------------------------------------------- +# Facturation exécutée dans le worker : palier Essentielle = 1, Premium = 5 +# --------------------------------------------------------------------------- +def test_worker_bills_essential_tier_at_cost_1(monkeypatch, tmp_path, minimal_xlsx): + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + settings = _settings( + { + "essential": {"models": ["deepseek/deepseek-v4-flash"], "default_model": ""}, + "premium": {"models": [], "default_model": ""}, + }, + openrouter={"api_key": "sk-or-test"}, + ) + cost: dict = {} + _run_job(monkeypatch, tmp_path, minimal_xlsx, settings, + provider="openrouter", user_plan="PlanType.PRO", + user_id="u1", cost_capture=cost) + assert cost["cost_factor"] == 1 + + +def test_worker_bills_premium_tier_at_cost_5(monkeypatch, tmp_path, minimal_xlsx): + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + settings = _settings( + { + "essential": {"models": [], "default_model": ""}, + "premium": {"models": ["anthropic/claude-sonnet-5"], "default_model": ""}, + }, + openrouter={"api_key": "sk-or-test"}, + ) + cost: dict = {} + _run_job(monkeypatch, tmp_path, minimal_xlsx, settings, + provider="openrouter_premium", user_plan="PlanType.BUSINESS", + user_id="u1", cost_capture=cost) + assert cost["cost_factor"] == 5 + + +# --------------------------------------------------------------------------- +# Première sauvegarde depuis l'éditeur : migration fidèle hérité → routes +# --------------------------------------------------------------------------- +def test_first_editor_save_migrates_legacy_settings_without_loss(monkeypatch): + """Des réglages à chaînes (models/default_model) convertis en routes par + l'éditeur, puis normalisés, doivent résoudre EXACTEMENT les mêmes + canaux/modèles qu'avant la migration.""" + from routes.admin_routes import SettingsConfig, normalize_ai_tiers + + legacy = _settings( + { + "essential": { + "models": ["z-ai/glm-5.3-flash", "deepseek/deepseek-v4-flash"], + "default_model": "z-ai/glm-5.3-flash", + }, + "premium": { + "models": ["anthropic/claude-sonnet-5", "deepseek/deepseek-v4-pro"], + "default_model": "anthropic/claude-sonnet-5", + }, + }, + openrouter={"api_key": "sk-or-test"}, + ) + before_essential = tr._resolve_tier_model(legacy, "pro", premium=False) + before_premium = tr._resolve_tier_model(legacy, "business", premium=True) + + def _editor_payload(tier): + """Miroir de la conversion faite par la page Modèles : le défaut en + tête, puis le reste de la liste — le tout en routes openrouter, avec + le miroir hérité conservé (models/default_model).""" + ordered = [tier.default_model] + [ + m for m in tier.models if m != tier.default_model + ] + routes = [{"provider": "openrouter", "model": m} for m in ordered] + return {"models": ordered, "default_model": tier.default_model, "routes": routes} + + payload = SettingsConfig.model_validate( + { + "openrouter": {"api_key": "sk-or-test"}, + "ai_tiers": { + "essential": _editor_payload(legacy.ai_tiers.essential), + "premium": _editor_payload(legacy.ai_tiers.premium), + }, + } + ) + normalized = normalize_ai_tiers(payload.ai_tiers) + migrated = SettingsConfig.model_validate( + {"openrouter": {"api_key": "sk-or-test"}, "ai_tiers": normalized.model_dump()} + ) + + after_essential = tr._resolve_tier_model(migrated, "pro", premium=False) + after_premium = tr._resolve_tier_model(migrated, "business", premium=True) + + assert after_essential == before_essential == ("openrouter", "z-ai/glm-5.3-flash") + assert after_premium == before_premium == ("openrouter", "anthropic/claude-sonnet-5") + # Aucune perte : tous les modèles d'origine restent résolubles dans l'ordre. + assert [r.model for r in normalized.essential.routes] == [ + "z-ai/glm-5.3-flash", + "deepseek/deepseek-v4-flash", + ] + assert [r.model for r in normalized.premium.routes] == [ + "anthropic/claude-sonnet-5", + "deepseek/deepseek-v4-pro", + ] diff --git a/tests/test_ai_tier_routing.py b/tests/test_ai_tier_routing.py index 2378c2f..9c3dc8e 100644 --- a/tests/test_ai_tier_routing.py +++ b/tests/test_ai_tier_routing.py @@ -52,9 +52,12 @@ def client(monkeypatch, tmp_path: Path): def _settings(ai_tiers: dict | None = None): from routes.admin_routes import SettingsConfig - if ai_tiers is None: - return SettingsConfig() - return SettingsConfig.model_validate({"ai_tiers": ai_tiers}) + # La résolution est clé-consciente : les tests de résolution partent avec + # une clé OpenRouter disponible (comme un serveur configuré). + payload = {"openrouter": {"api_key": "sk-or-test"}} + if ai_tiers is not None: + payload["ai_tiers"] = ai_tiers + return SettingsConfig.model_validate(payload) # --------------------------------------------------------------------------- @@ -83,17 +86,17 @@ def test_old_single_model_keys_removed(): # --------------------------------------------------------------------------- -# Résolution par palier +# Résolution par palier — renvoie la route complète (provider, model) # --------------------------------------------------------------------------- def test_pro_resolves_essential_plan_default(): assert _resolve_tier_model(_settings(), "pro", premium=False) == ( - "deepseek/deepseek-v4-flash" + ("openrouter", "deepseek/deepseek-v4-flash") ) def test_business_resolves_premium_plan_default(): assert _resolve_tier_model(_settings(), "business", premium=True) == ( - "anthropic/claude-sonnet-5" + ("openrouter", "anthropic/claude-sonnet-5") ) @@ -101,7 +104,9 @@ def test_admin_tier_default_overrides_plan_default(): cfg = _settings( {"essential": {"models": [], "default_model": "z-ai/glm-5.3-flash"}} ) - assert _resolve_tier_model(cfg, "pro", premium=False) == "z-ai/glm-5.3-flash" + assert _resolve_tier_model(cfg, "pro", premium=False) == ( + "openrouter", "z-ai/glm-5.3-flash" + ) def test_admin_tier_models_first_used_when_default_empty(): @@ -113,7 +118,9 @@ def test_admin_tier_models_first_used_when_default_empty(): } } ) - assert _resolve_tier_model(cfg, "pro", premium=False) == "minimax/minimax-m3" + assert _resolve_tier_model(cfg, "pro", premium=False) == ( + "openrouter", "minimax/minimax-m3" + ) def test_pro_never_resolves_premium_model(): @@ -124,7 +131,8 @@ def test_pro_never_resolves_premium_model(): } ) for plan_str in ("pro", "PlanType.PRO"): - model = _resolve_tier_model(cfg, plan_str, premium=False) + provider, model = _resolve_tier_model(cfg, plan_str, premium=False) + assert provider == "openrouter" assert model in DEFAULT_AI_MODELS_ESSENTIAL assert model not in DEFAULT_AI_MODELS_PREMIUM @@ -155,3 +163,68 @@ def test_get_plans_exposes_model_lists(client): ] assert plans["business"]["ai_models_premium"][0] == "anthropic/claude-sonnet-5" assert "ai_model_essential" not in plans["pro"] + + +# --------------------------------------------------------------------------- +# Point de contact : GET /providers/available affiche le modèle (chaîne simple) +# --------------------------------------------------------------------------- +def test_available_providers_model_is_a_string(monkeypatch, tmp_path): + """``_resolve_tier_model`` renvoie désormais (provider, model) : le champ + « model » de /providers/available doit rester une chaîne, égale au modèle + attendu pour les deux paliers (appelé ici avec un utilisateur Business, + car la liste est filtrée par forfait).""" + import routes.legacy_routes as legacy_mod + import routes.admin_routes as admin_routes_mod + + settings = _settings( + { + "essential": {"models": [], "default_model": "z-ai/glm-5.3-flash"}, + "premium": {"models": [], "default_model": "anthropic/claude-sonnet-5"}, + } + ) + # Les deux passerelles sont affichées dès lors qu'elles sont actives côté + # réglages (indépendamment des variables d'environnement de la machine). + settings.openrouter.enabled = True + settings.openrouter_premium.enabled = True + monkeypatch.setattr(admin_routes_mod, "load_settings", lambda: settings, raising=True) + + from fastapi.testclient import TestClient + from middleware.rate_limiting import RateLimitManager + from models.subscription import PlanType + + async def _check_request_allow(self, request): + return True, "ok", "test" + + async def _check_translation_allow(self, request, file_size_mb=0): + return True, "ok" + + monkeypatch.setattr(RateLimitManager, "check_request", _check_request_allow) + monkeypatch.setattr(RateLimitManager, "check_translation", _check_translation_allow) + + import services.auth_service as auth_svc + + monkeypatch.setattr(auth_svc, "USERS_FILE", tmp_path / "users.json") + monkeypatch.setattr(auth_svc, "USE_DATABASE", False) + monkeypatch.setattr(auth_svc, "DATABASE_AVAILABLE", False) + + # Utilisateur Business factice (les moteurs Premium sont filtrés par plan). + business_user = type("U", (), {"plan": PlanType.BUSINESS, "id": "u-biz"})() + + async def _fake_user(): + return business_user + + from main import app + + app.dependency_overrides[legacy_mod.get_authenticated_user] = _fake_user + try: + with TestClient(app, raise_server_exceptions=True) as tc: + r = tc.get("/api/v1/providers/available") + finally: + app.dependency_overrides.pop(legacy_mod.get_authenticated_user, None) + + assert r.status_code == 200, r.text + engines = {e["id"]: e for e in r.json()["providers"]} + assert isinstance(engines["openrouter"]["model"], str) + assert engines["openrouter"]["model"] == "z-ai/glm-5.3-flash" + assert isinstance(engines["openrouter_premium"]["model"], str) + assert engines["openrouter_premium"]["model"] == "anthropic/claude-sonnet-5" diff --git a/tests/test_marketing_email_generation.py b/tests/test_marketing_email_generation.py new file mode 100644 index 0000000..0d4f533 --- /dev/null +++ b/tests/test_marketing_email_generation.py @@ -0,0 +1,460 @@ +""" +Génération d'email par IA dans la page Marketing (spec « Fournisseur par +palier IA ») : prompt construit depuis MARKETING_PLAN.md, analyse robuste de +la sortie, nettoyage basique du HTML, aucun envoi automatique. +""" + +import json +import pytest +from pathlib import Path + +import routes.admin_routes as admin_routes_mod + +ADMIN_LOGIN_URL = "/api/v1/admin/login" +GENERATE_URL = "/api/v1/admin/marketing/email/generate" + +FAKE_PLAN = ( + "# Plan Marketing — Office Translator (Wordly.art)\n\n" + "## 1. Positionnement & Proposition de Valeur\n" + "Office Translator (marque Wordly.art) traduit Word, Excel, PowerPoint et " + "PDF en préservant la mise en page. USP : « Traduit en place. Zéro perte " + "de mise en page. »\n\n" + "## 2. Tarification\n" + "| Plan | Mensuel |\n| Free | 0 € |\n| Starter | 9 € |\n| Pro | 19 € |\n\n" + "## 3. Actifs Marketing en Place\n" + "(cette section ne doit PAS figurer dans le prompt)\n" +) + + +@pytest.fixture +def promo_file(tmp_path: Path, monkeypatch) -> Path: + import services.payment_service as payment_svc + + path = tmp_path / "promo_codes.json" + monkeypatch.setattr(payment_svc, "PROMO_CODES_FILE", path) + return path + + +@pytest.fixture +def gen_env(tmp_path: Path, monkeypatch, promo_file): + """Plan marketing isolé + aucune clé par défaut (chaque test choisit).""" + plan_file = tmp_path / "MARKETING_PLAN.md" + plan_file.write_text(FAKE_PLAN, encoding="utf-8") + monkeypatch.setattr(admin_routes_mod, "MARKETING_PLAN_FILE", plan_file) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.delenv("ZHIPU_API_KEY", raising=False) + monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False) + return {"plan_file": plan_file} + + +@pytest.fixture +def client(gen_env, monkeypatch, tmp_path: Path): + import services.auth_service as auth_svc + from middleware.rate_limiting import RateLimitManager + + monkeypatch.setattr(auth_svc, "USERS_FILE", tmp_path / "users.json") + monkeypatch.setattr(auth_svc, "USE_DATABASE", False) + monkeypatch.setattr(auth_svc, "DATABASE_AVAILABLE", False) + + async def _check_request_allow(self, request): + return True, "ok", "test" + + async def _check_translation_allow(self, request, file_size_mb=0): + return True, "ok" + + monkeypatch.setattr(RateLimitManager, "check_request", _check_request_allow) + monkeypatch.setattr(RateLimitManager, "check_translation", _check_translation_allow) + + from fastapi.testclient import TestClient + from main import app + + return TestClient(app, raise_server_exceptions=True) + + +@pytest.fixture +def admin_headers(client, monkeypatch): + monkeypatch.setattr(admin_routes_mod, "ADMIN_USERNAME", "admin") + monkeypatch.setattr(admin_routes_mod, "ADMIN_PASSWORD", "admin-secret") + monkeypatch.setattr(admin_routes_mod, "ADMIN_PASSWORD_HASH", None) + r = client.post(ADMIN_LOGIN_URL, json={"password": "admin-secret"}) + assert r.status_code == 200, r.text + return {"Authorization": f"Bearer {r.json()['access_token']}"} + + +def _with_openrouter_key(monkeypatch): + from routes.admin_routes import SettingsConfig + + monkeypatch.setattr( + admin_routes_mod, "load_settings", + lambda: SettingsConfig.model_validate({"openrouter": {"api_key": "sk-or-test"}}), + raising=True, + ) + + +# --------------------------------------------------------------------------- +def test_generate_requires_known_channel(client, admin_headers, gen_env): + r = client.post( + GENERATE_URL, + json={"provider": "mystery", "brief": "Relance d'activation."}, + headers=admin_headers, + ) + assert r.status_code == 400 + assert r.json()["error"] == "UNKNOWN_CHANNEL" + + +def test_generate_requires_configured_key(client, admin_headers, gen_env, monkeypatch): + monkeypatch.setattr( + admin_routes_mod, "load_settings", + lambda: admin_routes_mod.SettingsConfig(), raising=True, + ) + r = client.post( + GENERATE_URL, + json={"provider": "openrouter", "brief": "Relance d'activation."}, + headers=admin_headers, + ) + assert r.status_code == 400 + assert r.json()["error"] == "PROVIDER_NOT_CONFIGURED" + + +def test_generate_builds_prompt_from_marketing_plan(client, admin_headers, gen_env, monkeypatch): + """Le prompt système embarque le plan marketing (positionnement + tarifs) + et le brief ; les sections suivantes du plan n'y figurent pas.""" + _with_openrouter_key(monkeypatch) + captured: dict = {} + + async def _fake_llm(admin_cfg, provider, model, system_prompt, user_prompt): + captured["system"] = system_prompt + captured["user"] = user_prompt + captured["provider"], captured["model"] = provider, model + return json.dumps({ + "subject": "Votre document vous attend", + "html": "<html><body><p>Terminez votre traduction.</p></body></html>", + }) + + monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm) + r = client.post( + GENERATE_URL, + json={"provider": "openrouter", "brief": "Relance des inscrits de la liste d'attente."}, + headers=admin_headers, + ) + assert r.status_code == 200, r.text + assert "Wordly.art" in captured["system"] + assert "Traduit en place" in captured["system"] + assert "19 €" in captured["system"] + assert "Actifs Marketing" not in captured["system"] + assert "liste d'attente" in captured["user"] + assert captured["model"] # modèle par défaut du canal + + +def test_generate_parses_fenced_json(client, admin_headers, gen_env, monkeypatch): + _with_openrouter_key(monkeypatch) + + async def _fake_llm(admin_cfg, provider, model, system_prompt, user_prompt): + return ( + "```json\n" + '{"subject": "Offre de lancement", "html": "<p>Bonjour !</p>"}\n' + "```" + ) + + monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm) + r = client.post( + GENERATE_URL, + json={"provider": "openrouter", "brief": "b"}, + headers=admin_headers, + ) + assert r.status_code == 200 + data = r.json()["data"] + assert data["subject"] == "Offre de lancement" + assert data["html"] == "<p>Bonjour !</p>" + + +def test_generate_falls_back_to_title(client, admin_headers, gen_env, monkeypatch): + """Sortie non JSON : repli <title> = sujet, corps = HTML.""" + _with_openrouter_key(monkeypatch) + + async def _fake_llm(admin_cfg, provider, model, system_prompt, user_prompt): + return ( + "<html><head><title>Relance" + "

Reprenez votre traduction.

" + ) + + monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm) + r = client.post( + GENERATE_URL, + json={"provider": "openrouter", "brief": "b"}, + headers=admin_headers, + ) + assert r.status_code == 200 + data = r.json()["data"] + assert data["subject"] == "Relance" + assert "Reprenez votre traduction." in data["html"] + + +def test_generate_unparseable_output_returns_502(client, admin_headers, gen_env, monkeypatch): + _with_openrouter_key(monkeypatch) + + async def _fake_llm(admin_cfg, provider, model, system_prompt, user_prompt): + return "Je ne peux pas répondre à cela." + + monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm) + r = client.post( + GENERATE_URL, + json={"provider": "openrouter", "brief": "b"}, + headers=admin_headers, + ) + assert r.status_code == 502 + assert r.json()["error"] == "AI_GENERATION_FAILED" + + +def test_generate_llm_error_returns_502(client, admin_headers, gen_env, monkeypatch): + _with_openrouter_key(monkeypatch) + + async def _fake_llm(admin_cfg, provider, model, system_prompt, user_prompt): + raise RuntimeError("upstream timeout") + + monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm) + r = client.post( + GENERATE_URL, + json={"provider": "openrouter", "brief": "b"}, + headers=admin_headers, + ) + assert r.status_code == 502 + assert r.json()["error"] == "AI_GENERATION_FAILED" + + +def test_generate_sanitizes_html(client, admin_headers, gen_env, monkeypatch): + """Nettoyage basique : pas de " + "x", + }) + + monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm) + r = client.post( + GENERATE_URL, + json={"provider": "openrouter", "brief": "b"}, + headers=admin_headers, + ) + assert r.status_code == 200 + html = r.json()["data"]["html"] + assert "x

"}) + + monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm) + r = client.post( + GENERATE_URL, + json={"provider": "openrouter", "brief": "b", "promo_code": "rentree10"}, + headers=admin_headers, + ) + assert r.status_code == 200 + assert "RENTREE10" in captured["system"] + assert "-10 %" in captured["system"] + + +def test_generate_uses_requested_channel_and_model(client, admin_headers, gen_env, monkeypatch): + from routes.admin_routes import SettingsConfig + + monkeypatch.setattr( + admin_routes_mod, "load_settings", + lambda: SettingsConfig.model_validate({"zhipu": {"api_key": "zk"}}), + raising=True, + ) + captured: dict = {} + + async def _fake_llm(admin_cfg, provider, model, system_prompt, user_prompt): + captured["provider"], captured["model"] = provider, model + return json.dumps({"subject": "s", "html": "

x

"}) + + monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm) + r = client.post( + GENERATE_URL, + json={"provider": "zhipu", "model": "glm-5.3-flash", "brief": "b"}, + headers=admin_headers, + ) + assert r.status_code == 200 + assert captured["provider"] == "zhipu" + assert captured["model"] == "glm-5.3-flash" + + +def test_generate_requires_admin(client, gen_env): + r = client.post(GENERATE_URL, json={"provider": "openrouter", "brief": "b"}) + assert r.status_code == 401 + + +# --------------------------------------------------------------------------- +# Helpers d'analyse (tests unitaires directs) +# --------------------------------------------------------------------------- +def test_parse_generated_email_variants(): + parse = admin_routes_mod._parse_generated_email + assert parse('{"subject":"a","html":"

b

"}') == ("a", "

b

") + assert parse('Blabla\n{"subject":"a","html":"

b

"}\nFin') == ("a", "

b

") + assert parse("pas de contenu utile") is None + assert parse("") is None + # JSON sans html → None (sujet ET html requis) + assert parse('{"subject":"a"}') is None + + +# --------------------------------------------------------------------------- +# Correctifs de relecture : plan manquant, promo inconnue, CTA, RTL, bornes +# --------------------------------------------------------------------------- +def test_generate_without_marketing_plan_is_refused(client, admin_headers, gen_env, monkeypatch): + """Plan marketing absent : erreur claire, jamais de chiffres inventés.""" + _with_openrouter_key(monkeypatch) + monkeypatch.setattr( + admin_routes_mod, "MARKETING_PLAN_FILE", gen_env["plan_file"].with_suffix(".absent") + ) + r = client.post( + GENERATE_URL, + json={"provider": "openrouter", "brief": "b"}, + headers=admin_headers, + ) + assert r.status_code == 503 + assert r.json()["error"] == "MARKETING_PLAN_MISSING" + + +def test_generate_unknown_promo_returns_promo_not_found( + client, admin_headers, gen_env, monkeypatch +): + """Code promo inexistant ou inactif : 400 explicite, pas de génération + silencieuse sans offre.""" + _with_openrouter_key(monkeypatch) + + async def _fake_llm(*a, **kw): # ne doit PAS être appelé + raise AssertionError("l'appel LLM ne doit pas avoir lieu") + + monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm) + r = client.post( + GENERATE_URL, + json={"provider": "openrouter", "brief": "b", "promo_code": "NEXISTEPAS"}, + headers=admin_headers, + ) + assert r.status_code == 400 + assert r.json()["error"] == "PROMO_NOT_FOUND" + + +def test_generate_prompt_contains_pricing_cta_link( + client, admin_headers, gen_env, monkeypatch +): + """Avec une réduction : le prompt impose un bouton vers /pricing?promo=CODE.""" + _with_openrouter_key(monkeypatch) + import services.payment_service as payment_svc + + payment_svc.save_promo_codes([{ + "code": "RENTREE10", "active": True, "type": "percent", "value": 10, + "plans": ["all"], "max_redemptions": None, "times_used": 0, + "expires_at": None, "stripe_coupon_id": None, + "stripe_promotion_code_id": None, "stripe_mode": "local_only", + "created_at": "t", "deactivated_at": None, + }]) + monkeypatch.setenv("FRONTEND_URL", "https://wordly.art") + + captured: dict = {} + + async def _fake_llm(admin_cfg, provider, model, system_prompt, user_prompt): + captured["system"] = system_prompt + return json.dumps({"subject": "s", "html": "

x

"}) + + monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm) + r = client.post( + GENERATE_URL, + json={"provider": "openrouter", "brief": "b", "promo_code": "rentree10"}, + headers=admin_headers, + ) + assert r.status_code == 200 + assert "https://wordly.art/pricing?promo=RENTREE10" in captured["system"] + + +def test_generate_wraps_rtl_language_in_dir_rtl( + client, admin_headers, gen_env, monkeypatch +): + """Arabe et persan : le HTML généré est enveloppé dans
.""" + _with_openrouter_key(monkeypatch) + + async def _fake_llm(admin_cfg, provider, model, system_prompt, user_prompt): + assert 'dir="rtl"' in system_prompt # consigne donnée au modèle + return json.dumps({"subject": "s", "html": "

مرحبا

"}) + + monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm) + for language in ("العربية", "فارسی"): + r = client.post( + GENERATE_URL, + json={"provider": "openrouter", "brief": "b", "language": language}, + headers=admin_headers, + ) + assert r.status_code == 200 + html = r.json()["data"]["html"] + assert html.startswith('
') + assert html.endswith("
") + + +def test_generate_field_bounds_are_enforced(client, admin_headers, gen_env): + # Les bornes Pydantic sont appliquées (422 brut ou 400 via le gestionnaire + # global RequestValidationError de l'application). + r = client.post( + GENERATE_URL, + json={"provider": "openrouter", "brief": "b", "tone": "x" * 60}, + headers=admin_headers, + ) + assert r.status_code in (400, 422) + r2 = client.post( + GENERATE_URL, + json={"provider": "openrouter", "brief": "b", "model": "m" * 130}, + headers=admin_headers, + ) + assert r2.status_code in (400, 422) + + +# --------------------------------------------------------------------------- +# Durcissement du nettoyage HTML (casse mixte, balises orphelines, data:) +# --------------------------------------------------------------------------- +def test_sanitize_generated_html_hardened(): + sanitize = admin_routes_mod._sanitize_generated_html + # script orphelin (jamais fermé) et casse mixte + assert "ok