feat(abonnements): paliers LLM Essentielle/Premium, admin modeles par forfait, statistiques revenus, emails marketing
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m51s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m51s
- Gamme officielle : Essentielle (Pro) = deepseek-v4-flash, glm-5.3-flash, minimax-m3 ; Premium (Business) = claude-sonnet-5, deepseek-v4-pro, glm-5.3 - Routage reel : le modele employe suit le palier IA du forfait (reglages admin > defaut du plan), garde anti-croisement de palier - Nouvelle page admin « Modeles & abonnements » (matrice forfaits/paliers, modele par defaut, catalogue OpenRouter) - Statistiques enrichies : revenus (30 j + total), MRR estime, credits, liste d'attente, paliers utilises - Nouvelle page admin « Marketing » : audiences avec compteurs, apercu, envoi test obligatoire, historique, desabonnement public + en-tetes List-Unsubscribe ; .gitignore pour les donnees d'execution - Interface (accueil + tarifs) alignee sur la gamme, 13 langues completes - Tests : routage par palier (fonction + tache), marketing, revenus en base
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -59,6 +59,8 @@ data/users.json
|
||||
data/*.db
|
||||
data/*.sqlite
|
||||
data/waitlist.json
|
||||
data/marketing_emails.json
|
||||
data/unsubscribes.json
|
||||
|
||||
# Keep these in git (templates/defaults only)
|
||||
# data/provider_settings.json → commiter uniquement si pas de clés dedans
|
||||
|
||||
@@ -58,8 +58,8 @@ Contrairement à Google Translate (qui aplatit le fichier en texte brut) ou à D
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| **Free** | 0 € | — | 2 | 10 | 5 Mo | Google | Non (documents filigranés) | Non |
|
||||
| **Starter** | 9 € | 7,20 €/mois | 50 | 50 | 10 Mo | Google | Non | Non |
|
||||
| **Pro** ★ populaire | 19 € | 15,20 €/mois | 200 | 200 | 25 Mo | + Google Cloud, OpenRouter | Essentielle (DeepSeek) | Non |
|
||||
| **Business** | 49 € | 39,20 €/mois | 1 000 | 500 | 50 Mo | + OpenAI, Grok (xAI), premium | Premium (Claude/Gemini) | Oui, 10 000 appels/mois, 5 sièges |
|
||||
| **Pro** ★ populaire | 19 € | 15,20 €/mois | 200 | 200 | 25 Mo | + Google Cloud, OpenRouter | Essentielle : DeepSeek V4 Flash, GLM-5.3 Flash, MiniMax M3 | Non |
|
||||
| **Business** | 49 € | 39,20 €/mois | 1 000 | 500 | 50 Mo | + OpenAI, Grok (xAI), premium | Premium : Claude 5 (alternatives DeepSeek V4 Pro et GLM 5.3) | Oui, 10 000 appels/mois, 5 sièges |
|
||||
| **Enterprise** | sur demande | sur demande | illimité | illimité | illimité | + custom | custom | illimitable |
|
||||
|
||||
**Paquets de crédits** (au-delà des quotas, sans changer d'abonnement) : 50 cr. 5 € · 100 cr. 9 € · 250 cr. 20 € · 500 cr. 35 € · 1 000 cr. 60 € (0,06–0,10 €/page).
|
||||
|
||||
@@ -55,3 +55,19 @@ Reportés de la revue marketing du 2026-09-05 (spec-hermes-marketing-execution.m
|
||||
- source_spec: `spec-hermes-marketing-execution.md`
|
||||
summary: Définir une convention d'attribution par canal (paramètres de source dans les liens de Product Hunt, Show HN, X, Reddit, e-mails) avant le jour J.
|
||||
evidence: sans paramètre d'attribution, le rapport hebdomadaire ne pourra pas dire quel canal amène les inscriptions ; Vercel Analytics ne fournit que les référents.
|
||||
|
||||
- source_spec: `_bmad-output/implementation-artifacts/spec-strategie-llm-abonnements-admin.md`
|
||||
summary: Les emails marketing et la page publique de désabonnement sont en français uniquement, sans adaptation de langue.
|
||||
evidence: Les modèles d'emails d'abonnement existants (services/email_service.py) sont déjà monolingues français ; le multilinguisme des emails est un projet à part (détection de langue par destinataire, templates par langue).
|
||||
- source_spec: `_bmad-output/implementation-artifacts/spec-strategie-llm-abonnements-admin.md`
|
||||
summary: Le désabonnement se fait par simple lien GET sans jeton ni confirmation (risque de désabonnement par préchargement des clients de messagerie).
|
||||
evidence: Constat des relectures ; la solution complète passe par un envoi en un clic conforme RFC 8058 (POST List-Unsubscribe-Post) plus une page de confirmation, à concevoir séparément.
|
||||
- source_spec: `_bmad-output/implementation-artifacts/spec-strategie-llm-abonnements-admin.md`
|
||||
summary: GET /api/v1/admin/settings renvoie les clés API en clair alors que le commentaire du code affirme le contraire ; la nouvelle page Modèles repose sur cet aller-retour pour ne pas effacer les clés à l'enregistrement.
|
||||
evidence: Comportement préexistant à ce chantier, constaté par le relecteur « lacunes de vérification » ; toute correction (masquage) doit prévoir la préservation côté PUT, comme pour le mot de passe SMTP.
|
||||
- source_spec: `_bmad-output/implementation-artifacts/spec-strategie-llm-abonnements-admin.md`
|
||||
summary: La branche base de données du calcul des audiences marketing n'est pas testée et retombe silencieusement sur le fichier JSON en cas d'échec.
|
||||
evidence: Relectures ; conséquence bornée (un repli vide refuse l'envoi plutôt que d'envoyer de travers), mais un test en mode base éviterait les mauvaises surprises.
|
||||
- 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.
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
title: 'Stratégie LLM par abonnement + admin (modèles, statistiques, emails marketing)'
|
||||
type: 'feature'
|
||||
created: '2026-09-05'
|
||||
status: 'done'
|
||||
baseline_commit: '4e6850d6b12f2f97e9ec91a821b51a3b324d8f71'
|
||||
review_loop_iteration: 0
|
||||
context: []
|
||||
---
|
||||
|
||||
<frozen-after-approval reason="human-owned intent — do not modify unless human renegotiates">
|
||||
|
||||
## Intent
|
||||
|
||||
**Problem :** Les modèles IA affichés sur la page d'accueil et la page tarifs ne correspondent plus à la gamme réelle (Claude Sonnet 4.6, « Claude Haiku », Gemini) ; aucun modèle n'est réellement routé par abonnement (le modèle employé vient des réglages fournisseur) ; l'admin ne peut ni gérer les modèles par type d'abonnement, ni voir de statistiques de revenus, ni envoyer d'emails de relance marketing.
|
||||
|
||||
**Approach :** (1) Nouvelle gamme officielle — IA Essentielle (plan Pro) : `deepseek/deepseek-v4-flash`, `z-ai/glm-5.3-flash`, `minimax/minimax-m3` ; IA Premium (plan Business) : `anthropic/claude-sonnet-5` (remplace le 4.6, $2/$10 /1M), alternatives `deepseek/deepseek-v4-pro` et `z-ai/glm-5.3`. (2) Routage réel : le modèle utilisé par traduction est résolu par le palier IA du plan (réglages admin > défauts des plans). (3) Page admin dédiée « Modèles & abonnements » (matrice plan → palier → modèles actifs + modèle par défaut). (4) Statistiques enrichies : revenus (encaissé, MRR estimé, crédits), conversion, liste d'attente. (5) Page admin « Marketing » : envoi d'emails de relance par audience (liste d'attente, inactifs, par plan) avec aperçu, envoi test, historique et désabonnement — bonnes pratiques type Brevo/Mailchimp.
|
||||
|
||||
## Boundaries & Constraints
|
||||
|
||||
**Always :** Prix/textes en français correct dans l'interface ; les noms de modèles restent en latin tel quel dans les 13 langues ; ne jamais exposer de clé API dans les réponses admin ; un utilisateur Pro ne doit jamais déclencher un modèle Premium ; SMTP existant (`services/email_service.py`) réutilisé, pas de nouveau fournisseur.
|
||||
|
||||
**Ask First :** Changer les prix des plans (19 €/49 €) ; créer une table de BDD nouvelle (préférer les fichiers JSON du dossier `data/` comme le font waitlist et réglages) ; toucher à la chaîne de secours (`FALLBACK_CHAIN`).
|
||||
|
||||
**Never :** Modifier le dossier `office-translator-landing-page/` (prototype hors production) ni `.claude/`/`.agent/` ; supprimer les anciennes clés `ai_model_essential`/`ai_model_premium` utilisées ailleurs ; Stripe en mode live ; envoyer un email en masse sans passer par l'envoi test ; citer DeepL comme moteur.
|
||||
|
||||
## I/O & Edge-Case Matrix
|
||||
|
||||
| Scenario | Input / State | Expected Output / Behavior | Error Handling |
|
||||
|----------|--------------|---------------------------|----------------|
|
||||
| Traduction Pro, moteur openrouter | plan=pro, réglages admin essential.default=`z-ai/glm-5.3-flash` | Job exécuté avec ce modèle, facteur coût 1 | Si vide → défaut du plan (`deepseek/deepseek-v4-flash`) |
|
||||
| Traduction Business, premium | plan=business, openrouter_premium | Modèle = premium.default (`anthropic/claude-sonnet-5`) | Si clé absente → échec gracieux existant |
|
||||
| Envoi massif partiel | 200 destinataires, 3 échecs SMTP | Réponse : envoyés/échoués détaillés ; historique journalisé | Continuer après échec individuel, jamais d'HTTP 500 |
|
||||
| Désabonné | email présent dans `data/unsubscribes.json` | Exclu de tout envoi d'audience | N/A |
|
||||
|
||||
</frozen-after-approval>
|
||||
|
||||
## Code Map
|
||||
|
||||
- `models/subscription.py` -- PLANS (l.38-187) : clés `ai_model_essential`/`ai_model_premium` à remplacer par listes `ai_models_essential`/`ai_models_premium` (PRO l.100, BUSINESS l.130-131) ; commentaire d'en-tête l.34-37.
|
||||
- `routes/translate_routes.py` -- résolution modèle l.1277-1280 (openrouter, défaut `google/gemini-3.5-flash` + remap ancien IDs) et l.1364-1375 (openrouter_premium, défaut `anthropic/claude-sonnet-4.6`) ; `_compute_cost_factor` l.562 ; `_allowed_providers_for_plan` l.179. C'est ici que le routage par palier s'applique (via `load_settings` déjà importé l.1269).
|
||||
- `routes/admin_routes.py` -- `SettingsConfig` l.914-928 (ajouter section `ai_tiers`) ; GET/PUT `/settings` l.956-1054 ; `GET /stats` l.562 (usage seul, à enrichir) ; catalogue modèles OpenRouter `GET /providers/openrouter/models` existant (réutiliser pour la combobox admin) ; `POST /providers/smtp/test-send` l.1332 (exemple d'envoi).
|
||||
- `services/email_service.py` -- `send_email_async(to, subject, body)` l.94 ; `is_smtp_configured()` l.53. Réutiliser tel quel.
|
||||
- `routes/waitlist_routes.py` -- `data/waitlist.json` (email, interest, created_at) : audience marketing n°1, motif de fichier JSON à copier (verrou `_write_lock`) pour historique d'envois et désabonnements.
|
||||
- `database/models.py` -- `PaymentHistory` l.293 (amount_cents, payment_type, status, created_at) et `Translation` l.149 (created_at, provider) : sources des stats de revenus ; `repositories.py` a les accesseurs.
|
||||
- `services/pricing_config.py` -- `get_effective_monthly_yearly` pour le MRR estimé (prix effectifs, overrides inclus).
|
||||
- `routes/auth_routes.py` l.763-811 -- `GET /plans` : exposer les listes de modèles (`ai_models_essential`/`premium`) au frontend.
|
||||
- `frontend/src/components/landing/landing-page.tsx` l.440-582 -- PricingSection : textes via i18n uniquement.
|
||||
- `frontend/src/app/pricing/page.tsx` -- `STATIC_PLANS` l.62-198 (business.feat3 « Claude Haiku », enterprise.feat2 « Claude Opus 4.6 ») ; section aiModels textée via i18n.
|
||||
- `frontend/src/lib/i18n/messages/<lang>/{landing,pricing,admin}.json` (13 langues : fr,en,de,es,it,pt,nl,ar,fa,ja,ko,ru,zh) -- clés à mettre à jour ; structure identique entre langues.
|
||||
- `frontend/src/app/admin/` -- `constants.ts` (nav, ajouter 2 entrées), `settings/page.tsx` (motif de page avec combobox modèles), `stats/page.tsx` + `useTranslationStats.ts` (motif graphiques), `types.ts`. Nouvelles pages : `models/page.tsx`, `marketing/page.tsx`.
|
||||
- `tests/` -- pytest, motif `test_admin_*.py`.
|
||||
|
||||
## Tasks & Acceptance
|
||||
|
||||
**Execution :**
|
||||
- [x] `models/subscription.py` -- remplacer les deux clés modèle par les listes de gamme (Essentielle/Premium ci-dessus) -- source de vérité.
|
||||
- [x] `routes/auth_routes.py` -- exposer `ai_models_essential`/`ai_models_premium` dans `GET /plans` -- affichage public.
|
||||
- [x] `routes/translate_routes.py` -- résoudre le modèle par palier : réglages admin (`ai_tiers`) > défaut du plan ; défauts premium → `anthropic/claude-sonnet-5` ; nettoyer le remap l.1279 -- routage réel par abonnement.
|
||||
- [x] `routes/admin_routes.py` -- `SettingsConfig.ai_tiers` (essential/premium : models[], default_model) avec valeurs par défaut issues de PLANS ; enrichir `GET /stats` (revenus encaissés 30 j + total via PaymentHistory, MRR estimé par plan, crédits achetés, compteur liste d'attente, répartition paliers IA utilisés) ; nouveaux endpoints `GET/POST /marketing/audiences` (comptes par audience), `POST /marketing/email/send` (audience, sujet, html, test_mode ; exclude désabonnés ; historique dans `data/marketing_emails.json`), `GET /marketing/email/history`, et public `GET /api/v1/marketing/unsubscribe` -- gestion + stats + relances.
|
||||
- [x] `services/email_service.py` -- ne rien changer sauf si un helper pied-de-page désabonnement s'avère nécessaire.
|
||||
- [x] `frontend/src/app/admin/models/page.tsx` + `marketing/page.tsx` + `constants.ts` + `types.ts` -- deux pages admin (matrice modèles par plan/palier avec défaut radio + combobox du catalogue OpenRouter ; marketing : audience + compteur, sujet/HTML avec aperçu, envoi test, envoi réel, historique) -- l'exigence de Sepehr.
|
||||
- [x] `frontend/src/app/admin/stats/` -- cartes et graphiques des nouvelles métriques (revenus, MRR, liste d'attente) -- visibilité business.
|
||||
- [x] `frontend/src/app/pricing/page.tsx` + `components/landing/landing-page.tsx` -- textes statiques à jour (Claude 5, gamme) -- cohérence marketing.
|
||||
- [ ] i18n `landing.json`/`pricing.json`/`admin.json` (13 langues) -- clés modifiées/nouvelles traduites ; noms de modèles inchangés -- aucune chaîne manquante.
|
||||
- [x] `tests/` -- tests : routage palier (pro→essential, business→premium, défaut admin), refus modèle premium pour plan pro, endpoint marketing (envoi test, désabonné exclu, historique), stats revenus.
|
||||
|
||||
**Acceptance Criteria :**
|
||||
- Given un utilisateur Pro, when il traduit avec le moteur openrouter, then le modèle employé est celui du palier Essentielle (jamais un modèle Premium) et la page tarifs affiche la gamme exacte.
|
||||
- Given l'admin, when il change le modèle par défaut Essentielle puis traduit, then le nouveau modèle est utilisé sans redéploiement.
|
||||
- Given un désabonné, when un envoi marketing part, then il ne reçoit rien et l'historique trace envoyés/échecs.
|
||||
- Given `npm run build` et `pytest`, when exécutés, then succès sans erreur ni chaîne i18n manquante.
|
||||
|
||||
## Design Notes
|
||||
|
||||
Matrice admin (bonne pratique type LiteLLM/OpenRouter) : lignes = plans Pro/Business, colonnes = palier, contenu = modèles actifs (ordre = priorité de secours) + radio « par défaut ». Emails : audience = `waitlist` | `inactive_30` | `plan:pro` | `all_users` ; envoi séquentiel 0,2 s d'intervalle dans une tâche de fond ; pied de page avec lien `/api/v1/marketing/unsubscribe?email=`. Chiffres marketing autorisés : Essentielle « à partir de 0,09 $/M jetons (≈3× moins cher qu'annoncé auparavant) », contexte jusqu'à 1,3 M (GLM-5.3 Flash) ; Premium « Claude 5, 1M de contexte ».
|
||||
|
||||
## Verification
|
||||
|
||||
**Commands :**
|
||||
- `cd frontend && npm run build` -- expected: compilation sans erreur
|
||||
- `cd frontend && npm run test:run` -- expected: tests vitest verts
|
||||
- `pytest -x -q` -- expected: nouveaux tests + régression verts
|
||||
- `node -e "…vérif clés i18n identiques fr/en/…"` -- expected: aucune clé manquante (script de comparaison)
|
||||
|
||||
**Manual checks (if no CLI):**
|
||||
- Page d'accueil + /pricing : gamme affichée = deepseek-v4-flash, glm-5.3-flash, minimax-m3 / Claude 5, deepseek-v4-pro, glm-5.3 ; plus aucun « Claude Haiku » ni « Sonnet 4.6 ».
|
||||
- Admin : pages Modèles et Marketing fonctionnelles ; envoi test reçu.
|
||||
|
||||
## Spec Change Log
|
||||
|
||||
## Suggested Review Order
|
||||
|
||||
**Gamme officielle et exposition publique**
|
||||
|
||||
- La source de vérité : les deux gammes officielles (Essentielle Pro / Premium Business) et les défauts.
|
||||
[`subscription.py:41`](../../models/subscription.py#L41)
|
||||
|
||||
- L'endpoint public des forfaits expose les listes de modèles aux pages tarifs.
|
||||
[`auth_routes.py:786`](../../routes/auth_routes.py#L786)
|
||||
|
||||
**Routage réel par palier**
|
||||
|
||||
- Résolution du modèle : réglages admin > premier de la liste > défaut du plan, avec garde anti-croisement de palier.
|
||||
[`translate_routes.py:220`](../../routes/translate_routes.py#L220)
|
||||
|
||||
- Le point d'entrée historique affiche désormais le modèle réellement routé.
|
||||
[`legacy_routes.py:91`](../../routes/legacy_routes.py#L91)
|
||||
|
||||
**Côté admin : réglages et protections**
|
||||
|
||||
- Bloc `ai_tiers` : défauts, normalisation (dédoublonnage, refus de croisement de gamme).
|
||||
[`admin_routes.py:1055`](../../routes/admin_routes.py#L1055)
|
||||
|
||||
- Garde « pristine » : une sauvegarde depuis la page Fournisseurs n'écrase plus les paliers personnalisés.
|
||||
[`admin_routes.py:1232`](../../routes/admin_routes.py#L1232)
|
||||
|
||||
**Marketing : audiences, envoi, désabonnement**
|
||||
|
||||
- En-têtes de délivrabilité et lien de désabonnement par destinataire.
|
||||
[`admin_routes.py:2388`](../../routes/admin_routes.py#L2388)
|
||||
|
||||
- Audiences avec compteurs (liste d'attente, inactifs 30 jours, par forfait).
|
||||
[`admin_routes.py:2417`](../../routes/admin_routes.py#L2417)
|
||||
|
||||
- Envoi : test obligatoire (empreinte), historique « running » avant la boucle, verrou anti-campagne simultanée.
|
||||
[`admin_routes.py:2499`](../../routes/admin_routes.py#L2499)
|
||||
|
||||
**Interfaces**
|
||||
|
||||
- Page « Modèles & abonnements » : matrice forfaits → paliers, défaut radio, catalogue OpenRouter.
|
||||
[`page.tsx:1`](../../frontend/src/app/admin/models/page.tsx#L1)
|
||||
|
||||
- Page « Marketing » : audience, aperçu, envoi test, historique (textes 100 % i18n).
|
||||
[`page.tsx:1`](../../frontend/src/app/admin/marketing/page.tsx#L1)
|
||||
|
||||
- Cartes de revenus / MRR / liste d'attente des statistiques.
|
||||
[`RevenueOverview.tsx:22`](../../frontend/src/app/admin/RevenueOverview.tsx#L22)
|
||||
|
||||
- Listes de modèles côté client alignées sur la gamme.
|
||||
[`store.ts:10`](../../frontend/src/lib/store.ts#L10)
|
||||
|
||||
**Périphériques**
|
||||
|
||||
- En-têtes optionnels du service SMTP existant (aucun changement pour les appels en place).
|
||||
[`email_service.py:63`](../../services/email_service.py#L63)
|
||||
|
||||
- Test d'intégration du routage dans la tâche de traduction (faux fournisseur capturant le modèle).
|
||||
[`test_worker_tier_routing.py:114`](../../tests/test_worker_tier_routing.py#L114)
|
||||
|
||||
- Tests marketing (désabonnement en cours de campagne, campagne simultanée, échappement) et revenus en base.
|
||||
[`test_admin_marketing.py:1`](../../tests/test_admin_marketing.py#L1)
|
||||
159
frontend/src/app/admin/RevenueOverview.tsx
Normal file
159
frontend/src/app/admin/RevenueOverview.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Euro, TrendingUp, Users, Cpu, Loader2 } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { AdminStatsResponse } from "./types";
|
||||
|
||||
const getToken = () => useTranslationStore.getState().settings.adminToken ?? "";
|
||||
|
||||
function formatMoney(value: number, currency: string): string {
|
||||
const currencyLabel = currency === "EUR" ? "€" : currency === "USD" ? "$" : currency;
|
||||
return `${value.toLocaleString("fr-FR", { maximumFractionDigits: 2 })} ${currencyLabel}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cartes business (revenus, MRR estimé, crédits, liste d'attente, paliers IA)
|
||||
* alimentées par GET /api/v1/admin/stats (champs revenue, mrr_estimated,
|
||||
* waitlist_count, ai_tier_usage).
|
||||
*/
|
||||
export function RevenueOverview() {
|
||||
const { t } = useI18n();
|
||||
const [stats, setStats] = useState<AdminStatsResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/admin/stats`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const json = await response.json();
|
||||
if (!cancelled) {
|
||||
setStats(json.data ?? json);
|
||||
setError(null);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
const interval = setInterval(load, 30000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isLoading && !stats) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-8 w-20 animate-pulse rounded bg-muted" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !stats) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-2 py-4 text-sm text-red-500">
|
||||
<Cpu className="size-4" /> {t("admin.stats.unavailable", { error: error ?? "" })}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const currency = stats.revenue?.currency ?? "EUR";
|
||||
const tierTotal =
|
||||
(stats.ai_tier_usage?.essential ?? 0) +
|
||||
(stats.ai_tier_usage?.premium ?? 0) +
|
||||
(stats.ai_tier_usage?.classic ?? 0) +
|
||||
(stats.ai_tier_usage?.other ?? 0);
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: t("admin.stats.revenue30"),
|
||||
icon: <Euro className="h-4 w-4 text-muted-foreground" />,
|
||||
value: formatMoney(stats.revenue?.collected_30d ?? 0, currency),
|
||||
sub: t("admin.stats.payments30", { count: stats.revenue?.payments_30d ?? 0 }),
|
||||
},
|
||||
{
|
||||
title: t("admin.stats.revenueTotal"),
|
||||
icon: <TrendingUp className="h-4 w-4 text-muted-foreground" />,
|
||||
value: formatMoney(stats.revenue?.collected_total ?? 0, currency),
|
||||
sub: t("admin.stats.includingCredits", {
|
||||
amount: formatMoney(stats.revenue?.credits_purchased ?? 0, currency),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: t("admin.stats.mrr"),
|
||||
icon: <TrendingUp className="h-4 w-4 text-muted-foreground" />,
|
||||
value: formatMoney(stats.mrr_estimated?.total ?? 0, currency),
|
||||
sub:
|
||||
Object.entries(stats.mrr_estimated?.by_plan ?? {})
|
||||
.map(([plan, mrr]) => `${plan} : ${formatMoney(mrr, currency)}`)
|
||||
.join(" · ") || "—",
|
||||
},
|
||||
{
|
||||
title: t("admin.stats.waitlist"),
|
||||
icon: <Users className="h-4 w-4 text-muted-foreground" />,
|
||||
value: String(stats.waitlist_count ?? 0),
|
||||
sub: t("admin.stats.waitlistSub"),
|
||||
},
|
||||
{
|
||||
title: t("admin.stats.aiTiers"),
|
||||
icon: <Cpu className="h-4 w-4 text-muted-foreground" />,
|
||||
value: `${stats.ai_tier_usage?.essential ?? 0} / ${stats.ai_tier_usage?.premium ?? 0}`,
|
||||
sub: t("admin.stats.tiersSub", {
|
||||
classic: stats.ai_tier_usage?.classic ?? 0,
|
||||
other: stats.ai_tier_usage?.other ?? 0,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
{cards.map((c) => (
|
||||
<Card key={c.title}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{c.title}</CardTitle>
|
||||
{c.icon}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{c.value}</div>
|
||||
<p className="truncate text-xs text-muted-foreground" title={c.sub}>
|
||||
{c.sub}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{tierTotal === 0 && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">{t("admin.stats.noTranslationsYet")}</p>
|
||||
)}
|
||||
{isLoading && (
|
||||
<p className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" /> {t("admin.stats.refreshing")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CreditCard, LayoutDashboard, Settings, FileText, Users, type LucideIcon } from 'lucide-react';
|
||||
import { CreditCard, LayoutDashboard, Settings, FileText, Users, Cpu, Megaphone, BarChart3, type LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface AdminNavItem {
|
||||
labelKey: string;
|
||||
@@ -8,9 +8,12 @@ export interface AdminNavItem {
|
||||
|
||||
export const adminNavItems: AdminNavItem[] = [
|
||||
{ labelKey: 'admin.nav.dashboard', href: '/admin', icon: LayoutDashboard },
|
||||
{ labelKey: 'admin.nav.stats', href: '/admin/stats', icon: BarChart3 },
|
||||
{ labelKey: 'admin.nav.users', href: '/admin/users', icon: Users },
|
||||
{ labelKey: 'admin.nav.pricing', href: '/admin/pricing', icon: CreditCard },
|
||||
{ labelKey: 'admin.nav.models', href: '/admin/models', icon: Cpu },
|
||||
{ labelKey: 'admin.nav.providers', href: '/admin/settings', icon: Settings },
|
||||
{ labelKey: 'admin.nav.marketing', href: '/admin/marketing', icon: Megaphone },
|
||||
{ labelKey: 'admin.nav.system', href: '/admin/system', icon: Settings },
|
||||
{ labelKey: 'admin.nav.logs', href: '/admin/logs', icon: FileText },
|
||||
];
|
||||
|
||||
426
frontend/src/app/admin/marketing/page.tsx
Normal file
426
frontend/src/app/admin/marketing/page.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Megaphone,
|
||||
Loader2,
|
||||
Send,
|
||||
FlaskConical,
|
||||
History,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useNotification } from "@/components/ui/notification";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type {
|
||||
MarketingAudiencesResponse,
|
||||
MarketingEmailHistoryEntry,
|
||||
MarketingEmailSendResponse,
|
||||
} from "../types";
|
||||
|
||||
const getToken = () => useTranslationStore.getState().settings.adminToken ?? "";
|
||||
|
||||
/** Clés i18n par identifiant d'audience (l'identifiant part en base/historique). */
|
||||
const AUDIENCE_LABEL_KEYS: Record<string, string> = {
|
||||
waitlist: "admin.marketing.audienceWaitlist",
|
||||
inactive_30: "admin.marketing.audienceInactive",
|
||||
"plan:pro": "admin.marketing.audiencePlanPro",
|
||||
"plan:starter": "admin.marketing.audiencePlanStarter",
|
||||
"plan:business": "admin.marketing.audiencePlanBusiness",
|
||||
"plan:enterprise": "admin.marketing.audiencePlanEnterprise",
|
||||
"plan:free": "admin.marketing.audiencePlanFree",
|
||||
all_users: "admin.marketing.audienceAllUsers",
|
||||
};
|
||||
|
||||
export default function AdminMarketingPage() {
|
||||
const { t } = useI18n();
|
||||
const { success, error } = useNotification();
|
||||
const [audiences, setAudiences] = useState<Record<string, number>>({});
|
||||
const [unsubscribedCount, setUnsubscribedCount] = useState(0);
|
||||
const [audience, setAudience] = useState("waitlist");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [html, setHtml] = useState("");
|
||||
const [testEmail, setTestEmail] = useState("");
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSendingTest, setIsSendingTest] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [history, setHistory] = useState<MarketingEmailHistoryEntry[]>([]);
|
||||
/** Empreinte SHA-256 du contenu courant (même calcul que le backend). */
|
||||
const [contentHash, setContentHash] = useState<string | null>(null);
|
||||
/** false si le navigateur n'expose pas crypto.subtle (indicateur absent,
|
||||
* l'envoi reste possible : le serveur valide l'envoi test). */
|
||||
const [hashSupported, setHashSupported] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!crypto?.subtle) {
|
||||
setHashSupported(false);
|
||||
setContentHash(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const data = new TextEncoder().encode(`${subject.trim()}\n${html.trim()}`);
|
||||
crypto.subtle
|
||||
.digest("SHA-256", data)
|
||||
.then((buf) => {
|
||||
if (cancelled) return;
|
||||
const hex = Array.from(new Uint8Array(buf))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
setContentHash(hex);
|
||||
})
|
||||
.catch(() => setContentHash(null));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [subject, html]);
|
||||
|
||||
const loadAudiences = async () => {
|
||||
const response = await fetch(`${API_BASE}/api/v1/admin/marketing/audiences`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const json: MarketingAudiencesResponse = await response.json();
|
||||
setAudiences(json.data.audiences);
|
||||
setUnsubscribedCount(json.data.unsubscribed_count);
|
||||
};
|
||||
|
||||
const loadHistory = async () => {
|
||||
const response = await fetch(`${API_BASE}/api/v1/admin/marketing/email/history`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const json = await response.json();
|
||||
setHistory(json.data ?? []);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
Promise.all([loadAudiences(), loadHistory()])
|
||||
.catch((e) =>
|
||||
error({ title: t("admin.marketing.loadErrorTitle"), description: String(e.message ?? e) })
|
||||
)
|
||||
.finally(() => setIsLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const audienceLabel = (key: string): string => {
|
||||
const labelKey = AUDIENCE_LABEL_KEYS[key];
|
||||
return labelKey ? t(labelKey) : key;
|
||||
};
|
||||
|
||||
const recipientCount = audiences[audience] ?? 0;
|
||||
|
||||
/** Simple indicateur (non bloquant) : un test réussi de ce contenu exact
|
||||
* apparaît dans l'historique chargé. La décision d'autoriser l'envoi réel
|
||||
* appartient au serveur (l'historique affiché est tronqué à 100 entrées). */
|
||||
const testSeenInHistory =
|
||||
!!contentHash &&
|
||||
history.some(
|
||||
(h) => h.test_mode && h.sent_count > 0 && h.content_hash === contentHash
|
||||
);
|
||||
|
||||
const sendTest = async () => {
|
||||
if (!subject.trim() || !html.trim()) {
|
||||
error({ title: t("admin.marketing.incompleteTitle"), description: t("admin.marketing.incompleteDesc") });
|
||||
return;
|
||||
}
|
||||
setIsSendingTest(true);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/admin/marketing/email/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${getToken()}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
audience,
|
||||
subject: subject.trim(),
|
||||
html: html.trim(),
|
||||
test_mode: true,
|
||||
test_email: testEmail.trim() || null,
|
||||
}),
|
||||
});
|
||||
const json = await response.json().catch(() => ({}));
|
||||
if (response.ok) {
|
||||
const d: MarketingEmailSendResponse["data"] = json.data;
|
||||
success({
|
||||
title: t("admin.marketing.testDoneTitle"),
|
||||
description: t("admin.marketing.testDoneDesc", { email: d.recipient ?? "" }),
|
||||
});
|
||||
await loadHistory();
|
||||
} else {
|
||||
error({
|
||||
title: t("admin.marketing.testFailedTitle"),
|
||||
description: json.message || `HTTP ${response.status}`,
|
||||
});
|
||||
await loadHistory();
|
||||
}
|
||||
} catch {
|
||||
error({ title: t("admin.marketing.loadErrorTitle"), description: t("admin.marketing.networkDesc") });
|
||||
} finally {
|
||||
setIsSendingTest(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sendReal = async () => {
|
||||
if (!subject.trim() || !html.trim()) {
|
||||
error({ title: t("admin.marketing.incompleteTitle"), description: t("admin.marketing.incompleteDesc") });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!window.confirm(
|
||||
t("admin.marketing.confirmSend", { count: recipientCount, audience: audienceLabel(audience) })
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setIsSending(true);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/admin/marketing/email/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${getToken()}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
audience,
|
||||
subject: subject.trim(),
|
||||
html: html.trim(),
|
||||
test_mode: false,
|
||||
}),
|
||||
});
|
||||
const json = await response.json().catch(() => ({}));
|
||||
if (response.ok) {
|
||||
const d: MarketingEmailSendResponse["data"] = json.data;
|
||||
success({
|
||||
title: t("admin.marketing.sendingTitle"),
|
||||
description: t("admin.marketing.sendingDesc", { count: d.queued ?? 0 }),
|
||||
});
|
||||
setTimeout(() => {
|
||||
loadHistory().catch(() => {});
|
||||
}, 3000);
|
||||
} else {
|
||||
error({
|
||||
title: t("admin.marketing.sendRefusedTitle"),
|
||||
description: json.message || `HTTP ${response.status}`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
error({ title: t("admin.marketing.loadErrorTitle"), description: t("admin.marketing.networkDesc") });
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const previewDoc = useMemo(
|
||||
() =>
|
||||
html.trim() ||
|
||||
`<html><body style='font-family:sans-serif;color:#94a3b8;padding:32px;text-align:center;'>${t(
|
||||
"admin.marketing.previewEmpty"
|
||||
)}</body></html>`,
|
||||
[html, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-600/20 rounded-lg flex items-center justify-center">
|
||||
<Megaphone className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">{t("admin.marketing.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("admin.marketing.subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
375
frontend/src/app/admin/models/page.tsx
Normal file
375
frontend/src/app/admin/models/page.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Cpu, Save, Loader2, ArrowUp, ArrowDown, X, Plus, RefreshCw } from "lucide-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { 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";
|
||||
|
||||
interface FullSettings {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const getToken = () => useTranslationStore.getState().settings.adminToken ?? "";
|
||||
|
||||
/** Éditeur d'un palier : liste ordonnée (ordre = priorité de secours) + radio « par défaut ». */
|
||||
function TierEditor({
|
||||
tier,
|
||||
onChange,
|
||||
catalog,
|
||||
onLoadCatalog,
|
||||
catalogLoading,
|
||||
}: {
|
||||
tier: AiTierSettings;
|
||||
onChange: (next: AiTierSettings) => void;
|
||||
catalog: ModelOption[];
|
||||
onLoadCatalog: () => void;
|
||||
catalogLoading: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const move = (index: number, dir: -1 | 1) => {
|
||||
const models = [...tier.models];
|
||||
const target = index + dir;
|
||||
if (target < 0 || target >= models.length) return;
|
||||
[models[index], models[target]] = [models[target], models[index]];
|
||||
onChange({ ...tier, models });
|
||||
};
|
||||
|
||||
const remove = (index: number) => {
|
||||
const models = tier.models.filter((_, i) => i !== index);
|
||||
onChange({ ...tier, models });
|
||||
};
|
||||
|
||||
const add = (id: string) => {
|
||||
const idClean = id.trim();
|
||||
if (!idClean || tier.models.includes(idClean)) {
|
||||
setAdding(false);
|
||||
return;
|
||||
}
|
||||
onChange({ ...tier, models: [...tier.models, idClean] });
|
||||
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;
|
||||
return (
|
||||
<div
|
||||
key={model}
|
||||
className={
|
||||
"flex items-center gap-2 rounded-lg border px-3 py-2 " +
|
||||
(isDefault ? "border-accent/50 bg-accent/5" : "border-border/60 bg-card")
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isDefault}
|
||||
aria-label={t("admin.models.setDefault", { model })}
|
||||
onClick={() => onChange({ ...tier, default_model: model })}
|
||||
className={
|
||||
"flex size-4 shrink-0 items-center justify-center rounded-full border " +
|
||||
(isDefault ? "border-accent" : "border-muted-foreground/40")
|
||||
}
|
||||
>
|
||||
{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}
|
||||
{isDefault ? (
|
||||
<Badge variant="secondary" className="ms-2 font-sans text-[10px]">
|
||||
{t("admin.models.defaultBadge")}
|
||||
</Badge>
|
||||
) : i === 0 ? (
|
||||
<span className="ms-2 font-sans text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t("admin.models.fallbackFirst")}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => move(i, -1)}
|
||||
disabled={i === 0}
|
||||
aria-label={t("admin.models.moveUp")}
|
||||
>
|
||||
<ArrowUp className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => move(i, 1)}
|
||||
disabled={i === tier.models.length - 1}
|
||||
aria-label={t("admin.models.moveDown")}
|
||||
>
|
||||
<ArrowDown className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-red-500"
|
||||
onClick={() => remove(i)}
|
||||
aria-label={t("admin.models.remove")}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{tier.models.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>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button variant="outline" size="sm" onClick={() => setAdding(true)} className="gap-1.5">
|
||||
<Plus className="size-3" /> {t("admin.models.addModel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onLoadCatalog}
|
||||
disabled={catalogLoading}
|
||||
className="h-7 gap-1.5 px-2 text-xs text-muted-foreground"
|
||||
>
|
||||
{catalogLoading ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3" />
|
||||
)}
|
||||
{t("admin.models.catalog")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminModelsPage() {
|
||||
const { t } = useI18n();
|
||||
const { success, error } = useNotification();
|
||||
const [aiTiers, setAiTiers] = useState<AiTiersSettings | null>(null);
|
||||
const [fullSettings, setFullSettings] = useState<FullSettings>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [catalog, setCatalog] = useState<ModelOption[]>([]);
|
||||
const [catalogLoading, setCatalogLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const loadSettings = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/admin/settings`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const envelope = await response.json();
|
||||
const payload = envelope.data ?? envelope;
|
||||
setFullSettings(payload);
|
||||
if (payload.ai_tiers) {
|
||||
setAiTiers(payload.ai_tiers);
|
||||
}
|
||||
} else {
|
||||
error({
|
||||
title: t("admin.models.loadErrorTitle"),
|
||||
description: t("admin.models.loadErrorDesc", { status: response.status }),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
error({ title: t("admin.models.networkTitle"), description: t("admin.models.networkDesc") });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCatalog = async () => {
|
||||
setCatalogLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/admin/providers/openrouter/models`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCatalog(data.data || []);
|
||||
} else {
|
||||
error({
|
||||
title: t("admin.models.catalogErrorTitle"),
|
||||
description: t("admin.models.loadErrorDesc", { status: response.status }),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
error({
|
||||
title: t("admin.models.catalogErrorTitle"),
|
||||
description: t("admin.models.catalogErrorDesc"),
|
||||
});
|
||||
} finally {
|
||||
setCatalogLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!aiTiers) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const body = { ...fullSettings, ai_tiers: aiTiers };
|
||||
const response = await fetch(`${API_BASE}/api/v1/admin/settings`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${getToken()}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (response.ok) {
|
||||
const envelope = await response.json();
|
||||
const payload = envelope.data ?? envelope;
|
||||
if (payload?.ai_tiers) setAiTiers(payload.ai_tiers);
|
||||
success({
|
||||
title: t("admin.models.savedTitle"),
|
||||
description: t("admin.models.savedDesc"),
|
||||
});
|
||||
} else {
|
||||
const detail = await response.json().catch(() => ({}));
|
||||
error({
|
||||
title: t("admin.models.saveErrorTitle"),
|
||||
description: detail.detail || detail.message || t("admin.models.loadErrorDesc", { status: response.status }),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
error({ title: t("admin.models.networkTitle"), description: t("admin.models.saveNetworkDesc") });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!aiTiers) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t("admin.models.missingConfig")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-600/20 rounded-lg flex items-center justify-center">
|
||||
<Cpu className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">{t("admin.models.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("admin.models.subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={save} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="me-2 size-4 animate-spin" /> {t("admin.models.saving")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="me-2 size-4" /> {t("admin.models.save")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-visible">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("admin.models.matrixTitle")}</CardTitle>
|
||||
<CardDescription>{t("admin.models.matrixDesc")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Forfait Pro — palier Essentielle */}
|
||||
<div className="rounded-xl border border-border/60 p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Badge className="bg-accent/10 text-accent border-accent/30">{t("admin.models.proBadge")}</Badge>
|
||||
<Badge variant="secondary">{t("admin.models.essentialBadge")}</Badge>
|
||||
<span className="text-xs text-muted-foreground">{t("admin.models.essentialCost")}</span>
|
||||
</div>
|
||||
<TierEditor
|
||||
tier={aiTiers.essential}
|
||||
onChange={(next) => setAiTiers({ ...aiTiers, essential: next })}
|
||||
catalog={catalog}
|
||||
onLoadCatalog={loadCatalog}
|
||||
catalogLoading={catalogLoading}
|
||||
/>
|
||||
<p className="mt-3 text-xs text-muted-foreground">{t("admin.models.sharedTierNote")}</p>
|
||||
</div>
|
||||
|
||||
{/* Forfait Business — palier Premium */}
|
||||
<div className="rounded-xl border border-accent/30 p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Badge className="bg-blue-500/10 text-blue-600 border-blue-500/30 dark:text-blue-300">
|
||||
{t("admin.models.businessBadge")}
|
||||
</Badge>
|
||||
<Badge variant="secondary">{t("admin.models.premiumBadge")}</Badge>
|
||||
<span className="text-xs text-muted-foreground">{t("admin.models.premiumCost")}</span>
|
||||
</div>
|
||||
<TierEditor
|
||||
tier={aiTiers.premium}
|
||||
onChange={(next) => setAiTiers({ ...aiTiers, premium: next })}
|
||||
catalog={catalog}
|
||||
onLoadCatalog={loadCatalog}
|
||||
catalogLoading={catalogLoading}
|
||||
/>
|
||||
<p className="mt-3 text-xs text-muted-foreground">{t("admin.models.premiumReservedNote")}</p>
|
||||
</div>
|
||||
|
||||
{/* Forfait Enterprise — lecture seule */}
|
||||
<div className="rounded-xl border border-border/40 p-4">
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{t("admin.models.enterpriseBadge")}</Badge>
|
||||
<Badge variant="secondary">{t("admin.models.customBadge")}</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("admin.models.customNote")}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ 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 type { AiTiersSettings } from "../types";
|
||||
|
||||
interface ProviderConfig {
|
||||
enabled: boolean;
|
||||
@@ -43,6 +44,8 @@ interface SettingsConfig {
|
||||
zai: ProviderConfig;
|
||||
mistral: ProviderConfig;
|
||||
smtp: SmtpConfig;
|
||||
/** Paliers IA « Modèles & abonnements » — renvoyé tel quel à la sauvegarde. */
|
||||
ai_tiers?: AiTiersSettings;
|
||||
fallback_chain: string;
|
||||
fallback_chain_classic: string;
|
||||
fallback_chain_llm: string;
|
||||
@@ -70,8 +73,8 @@ const defaultConfig: SettingsConfig = {
|
||||
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" },
|
||||
openrouter: { enabled: false, api_key: "", model: "deepseek/deepseek-chat" },
|
||||
openrouter_premium: { enabled: false, api_key: "", model: "openai/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" },
|
||||
mistral: { enabled: false, api_key: "", model: "mistral-ocr-latest", timeout: 180 },
|
||||
smtp: { enabled: false, host: "", port: 587, username: "", password: "", from_email: "", use_tls: true },
|
||||
@@ -101,7 +104,6 @@ export default function AdminSettingsPage() {
|
||||
const [ollamaModels, setOllamaModels] = useState<OllamaModel[]>([]);
|
||||
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
||||
const [openaiModels, setOpenaiModels] = useState<ModelOption[]>([]);
|
||||
const [openrouterModels, setOpenrouterModels] = useState<ModelOption[]>([]);
|
||||
const [zaiModels, setZaiModels] = useState<ModelOption[]>([]);
|
||||
const [loadingModelsProvider, setLoadingModelsProvider] = useState<string | null>(null);
|
||||
const [isSendingTestEmail, setIsSendingTestEmail] = useState(false);
|
||||
@@ -144,6 +146,9 @@ export default function AdminSettingsPage() {
|
||||
const saveConfig = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// `config` inclut `ai_tiers` tel que chargé (voir loadConfig) : la
|
||||
// sauvegarde des fournisseurs ne doit jamais réinitialiser les paliers
|
||||
// personnalisés de la page « Modèles & abonnements ».
|
||||
const response = await fetch(`${API_BASE}/api/v1/admin/settings`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
@@ -238,7 +243,6 @@ export default function AdminSettingsPage() {
|
||||
const data = await response.json();
|
||||
const models: ModelOption[] = data.data || [];
|
||||
if (provider === "openai") setOpenaiModels(models);
|
||||
else if (provider === "openrouter") setOpenrouterModels(models);
|
||||
else if (provider === "zai") setZaiModels(models);
|
||||
info({ title: `${models.length} modèles ${provider} trouvés` });
|
||||
} else {
|
||||
@@ -476,7 +480,7 @@ export default function AdminSettingsPage() {
|
||||
|
||||
<ProviderCard
|
||||
title="Traduction IA Essentielle"
|
||||
description="Affichée aux utilisateurs comme 'Traduction IA Essentielle'. Modèles économiques recommandés : deepseek/deepseek-chat, google/gemini-2.0-flash, meta-llama/llama-3.3-70b-instruct. Clé API : openrouter.ai"
|
||||
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")}
|
||||
@@ -496,24 +500,24 @@ export default function AdminSettingsPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="openrouter-model">Modèle Essentiel</Label>
|
||||
<ModelCombobox
|
||||
<Label htmlFor="openrouter-model">Modèle Essentiel (lecture seule)</Label>
|
||||
<Input
|
||||
id="openrouter-model"
|
||||
value={config.openrouter.model || ""}
|
||||
onChange={(v) => updateProvider("openrouter", { model: v })}
|
||||
models={openrouterModels}
|
||||
isLoading={loadingModelsProvider === "openrouter"}
|
||||
onFetchModels={() => fetchModels("openrouter")}
|
||||
providerLabel="OpenRouter"
|
||||
placeholder="deepseek/deepseek-chat"
|
||||
disabled
|
||||
readOnly
|
||||
placeholder="deepseek/deepseek-v4-flash"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Recommandé : <code>deepseek/deepseek-chat</code> (~€0.04/doc)</p>
|
||||
<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="Affichée aux utilisateurs comme 'Traduction IA Premium'. Modèles haute qualité : openai/gpt-4o, anthropic/claude-sonnet-4.6, google/gemini-3.5-pro. Partage la même clé OpenRouter."
|
||||
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")}
|
||||
@@ -521,21 +525,19 @@ export default function AdminSettingsPage() {
|
||||
testMessage={testMessages.openrouter_premium}
|
||||
envKeySet={envInfo.openrouter_premium}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="openrouter-premium-model">Modèle Premium</Label>
|
||||
<ModelCombobox
|
||||
value={config.openrouter_premium.model || ""}
|
||||
onChange={(v) => updateProvider("openrouter_premium", { model: v })}
|
||||
models={openrouterModels}
|
||||
isLoading={loadingModelsProvider === "openrouter"}
|
||||
onFetchModels={() => fetchModels("openrouter")}
|
||||
providerLabel="OpenRouter"
|
||||
placeholder="anthropic/claude-sonnet-4.6"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recommandé : <code>anthropic/claude-sonnet-4.6</code> (~€0.20/doc) ou <code>openai/gpt-4o</code> (~€0.30/doc)
|
||||
</p>
|
||||
</div>
|
||||
<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
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { BarChart3, RefreshCw, Loader2, AlertCircle, Info } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { StatsOverview } from "../StatsOverview";
|
||||
import { RevenueOverview } from "../RevenueOverview";
|
||||
import { TopUsersTable } from "../TopUsersTable";
|
||||
import { ProviderBreakdownChart } from "../ProviderBreakdownChart";
|
||||
import { FormatBreakdownChart } from "../FormatBreakdownChart";
|
||||
@@ -33,10 +34,10 @@ export default function StatsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
Statistiques de Traduction
|
||||
Statistiques
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Analyse des traductions et patterns d'utilisation
|
||||
Revenus, MRR, traductions et patterns d'utilisation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,6 +75,8 @@ export default function StatsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RevenueOverview />
|
||||
|
||||
<StatsOverview data={data} isLoading={isLoading} />
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
|
||||
@@ -78,3 +78,90 @@ export interface TranslationStatsResponse {
|
||||
generated_at: string;
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Modèles & abonnements (paliers IA) ─────────────────────── */
|
||||
|
||||
export interface AiTierSettings {
|
||||
/** Modèles actifs — 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;
|
||||
}
|
||||
|
||||
export interface AiTiersSettings {
|
||||
essential: AiTierSettings;
|
||||
premium: AiTierSettings;
|
||||
}
|
||||
|
||||
/* ── Statistiques enrichies (revenus, MRR, liste d'attente) ── */
|
||||
|
||||
export interface AdminStatsResponse {
|
||||
users: {
|
||||
total: number;
|
||||
active_this_month: number;
|
||||
by_plan: Record<string, number>;
|
||||
};
|
||||
translations: {
|
||||
docs_this_month: number;
|
||||
pages_this_month: number;
|
||||
};
|
||||
revenue: {
|
||||
collected_total: number;
|
||||
collected_30d: number;
|
||||
credits_purchased: number;
|
||||
payments_30d: number;
|
||||
currency: string;
|
||||
};
|
||||
mrr_estimated: {
|
||||
total: number;
|
||||
by_plan: Record<string, number>;
|
||||
};
|
||||
ai_tier_usage: {
|
||||
essential: number;
|
||||
premium: number;
|
||||
classic: number;
|
||||
other: number;
|
||||
};
|
||||
waitlist_count: number;
|
||||
cache: Record<string, unknown>;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/* ── Marketing (relances email) ─────────────────────────────── */
|
||||
|
||||
export interface MarketingAudiences {
|
||||
audiences: Record<string, number>;
|
||||
unsubscribed_count: number;
|
||||
}
|
||||
|
||||
export interface MarketingAudiencesResponse {
|
||||
data: MarketingAudiences;
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MarketingEmailHistoryEntry {
|
||||
id: string;
|
||||
sent_at: string;
|
||||
completed_at?: string;
|
||||
audience: string;
|
||||
subject: string;
|
||||
test_mode: boolean;
|
||||
test_recipient?: string;
|
||||
recipients_total: number;
|
||||
sent_count: number;
|
||||
failed_count: number;
|
||||
failed: { email: string; error: string }[];
|
||||
content_hash?: string;
|
||||
}
|
||||
|
||||
export interface MarketingEmailSendResponse {
|
||||
data: {
|
||||
test_mode: boolean;
|
||||
sent?: number;
|
||||
failed?: number;
|
||||
recipient?: string;
|
||||
queued?: number;
|
||||
audience?: string;
|
||||
};
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -922,7 +922,7 @@ export default function PricingPage() {
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<span className="px-2 py-1 bg-muted rounded">{t('pricing.aiModels.essential.context')}</span>
|
||||
<span className="px-2 py-1 bg-muted rounded">$0.25/$0.38 per 1M</span>
|
||||
<span className="px-2 py-1 bg-muted rounded">{t('pricing.aiModels.essential.price')}</span>
|
||||
<span className="px-2 py-1 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 rounded">{t('pricing.aiModels.essential.value')}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -933,11 +933,11 @@ export default function PricingPage() {
|
||||
<Badge className="ml-auto text-xs bg-accent/10 text-accent border-accent/30">{t('pricing.aiModels.premium.plan')}</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mb-3">
|
||||
{t('pricing.aiModels.premium.descPrefix')} <strong className="text-foreground">Claude Sonnet 4.6</strong> {t('pricing.aiModels.premium.descSuffix')}
|
||||
{t('pricing.aiModels.premium.descPrefix')} <strong className="text-foreground">{t('pricing.aiModels.premium.modelName')}</strong> {t('pricing.aiModels.premium.descSuffix')}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<span className="px-2 py-1 bg-muted rounded">{t('pricing.aiModels.premium.context')}</span>
|
||||
<span className="px-2 py-1 bg-muted rounded">$3.00/$15.00 per 1M</span>
|
||||
<span className="px-2 py-1 bg-muted rounded">{t('pricing.aiModels.premium.alternatives')}</span>
|
||||
<span className="px-2 py-1 bg-accent/10 text-accent rounded">{t('pricing.aiModels.premium.precision')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"admin.nav.providers": "المزوّدون",
|
||||
"admin.nav.system": "النظام",
|
||||
"admin.nav.logs": "السجلات",
|
||||
"admin.nav.stats": "الإحصائيات",
|
||||
"admin.users.title": "إدارة المستخدمين",
|
||||
"admin.users.subtitle": "عرض حسابات المستخدمين وإدارتها",
|
||||
"admin.users.planUpdated": "تم تحديث الخطة",
|
||||
@@ -44,5 +45,105 @@
|
||||
"admin.system.waitingData": "في انتظار البيانات...",
|
||||
"admin.system.purging": "جارٍ الحذف...",
|
||||
"admin.system.clean": "تنظيف",
|
||||
"admin.system.purge": "حذف"
|
||||
"admin.system.purge": "حذف",
|
||||
"admin.nav.models": "النماذج والاشتراكات",
|
||||
"admin.nav.marketing": "التسويق",
|
||||
"admin.marketing.audience": "الجمهور",
|
||||
"admin.marketing.audienceAllUsers": "جميع الحسابات",
|
||||
"admin.marketing.audienceInactive": "حسابات غير نشطة منذ 30 يومًا",
|
||||
"admin.marketing.audiencePlaceholder": "اختر جمهورًا",
|
||||
"admin.marketing.audiencePlanBusiness": "خطة Business",
|
||||
"admin.marketing.audiencePlanEnterprise": "خطة Enterprise",
|
||||
"admin.marketing.audiencePlanFree": "الخطة المجانية",
|
||||
"admin.marketing.audiencePlanPro": "خطة Pro",
|
||||
"admin.marketing.audiencePlanStarter": "خطة Starter",
|
||||
"admin.marketing.audienceWaitlist": "قائمة الانتظار",
|
||||
"admin.marketing.badgeReal": "فعلي",
|
||||
"admin.marketing.badgeTest": "اختباري",
|
||||
"admin.marketing.confirmSend": "إرسال هذه الرسالة إلى {count} مستلم («{audience}»)؟",
|
||||
"admin.marketing.footerNote": "يُضاف تلقائيًا تذييل يحتوي على رابط إلغاء الاشتراك عند الإرسال.",
|
||||
"admin.marketing.hidePreview": "إخفاء المعاينة",
|
||||
"admin.marketing.history": "سجل الإرسالات",
|
||||
"admin.marketing.historyFailedSuffix": "، {count} فاشلة",
|
||||
"admin.marketing.historySent": "{sent}/{total} مُرسلة",
|
||||
"admin.marketing.html": "محتوى HTML",
|
||||
"admin.marketing.htmlPlaceholder": "<html><body>…</body></html>",
|
||||
"admin.marketing.incompleteDesc": "أدخل الموضوع وHTML.",
|
||||
"admin.marketing.incompleteTitle": "محتوى غير مكتمل",
|
||||
"admin.marketing.loadErrorTitle": "خطأ في التحميل",
|
||||
"admin.marketing.localCheckUnavailable": "التحقق المحلي غير متاح في هذا المتصفح: سيتحقق الخادم من الإرسال الاختباري المسبق.",
|
||||
"admin.marketing.networkDesc": "لا يمكن الوصول إلى الخادم الخلفي.",
|
||||
"admin.marketing.newCampaign": "حملة جديدة",
|
||||
"admin.marketing.noHistory": "لا توجد إرسالات مسجلة.",
|
||||
"admin.marketing.preview": "معاينة",
|
||||
"admin.marketing.previewEmpty": "معاينة: الصق HTML بجانب هذه اللوحة.",
|
||||
"admin.marketing.previewTitle": "معاينة البريد",
|
||||
"admin.marketing.recipientsAvailable": "{count} مستلم متاح بعد استثناء الملغين لاشتراكهم.",
|
||||
"admin.marketing.sendReal": "الإرسال إلى {count} مستلم",
|
||||
"admin.marketing.sendRefusedTitle": "تم رفض الإرسال",
|
||||
"admin.marketing.sendTest": "إرسال اختباري",
|
||||
"admin.marketing.sendingDesc": "{count} رسالة في قائمة الإرسال (فاصل 0,2 ثانية). الملغون لاشتراكهم مستثنون.",
|
||||
"admin.marketing.sendingTitle": "الإرسال جارٍ",
|
||||
"admin.marketing.subject": "الموضوع",
|
||||
"admin.marketing.subjectPlaceholder": "مثال: ترجمتك بانتظارك — خصم 20٪ لمدة 7 أيام",
|
||||
"admin.marketing.subtitle": "إرسال رسائل إلى جمهور محدد، مع إرسال اختباري إلزامي ورابط إلغاء اشتراك تلقائي وسجل كامل.",
|
||||
"admin.marketing.testDoneDesc": "وصل البريد إلى {email}",
|
||||
"admin.marketing.testDoneTitle": "تم الإرسال الاختباري",
|
||||
"admin.marketing.testEmail": "بريد اختباري (اختياري)",
|
||||
"admin.marketing.testEmailPlaceholder": "وإلا: عنوان المرسل SMTP",
|
||||
"admin.marketing.testFailedTitle": "فشل الإرسال الاختباري",
|
||||
"admin.marketing.testRequiredHint": "يتطلب الإرسال الفعلي إرسالًا اختباريًا مسبقًا لنفس هذا المحتوى (يتحقق منه الخادم).",
|
||||
"admin.marketing.testValidated": "تم التحقق من الإرسال الاختباري لهذا المحتوى.",
|
||||
"admin.marketing.title": "التسويق — حملات البريد الإلكتروني",
|
||||
"admin.marketing.unsubscribedNote": "سيتم استثناء {count} من الملغين لاشتراكهم من كل إرسال.",
|
||||
"admin.models.addModel": "إضافة نموذج",
|
||||
"admin.models.businessBadge": "خطة Business",
|
||||
"admin.models.cancel": "إلغاء",
|
||||
"admin.models.catalog": "كتالوج OpenRouter",
|
||||
"admin.models.catalogErrorDesc": "تعذر تحميل كتالوج OpenRouter.",
|
||||
"admin.models.catalogErrorTitle": "الكتالوج غير متاح",
|
||||
"admin.models.customBadge": "مستوى مخصص",
|
||||
"admin.models.customNote": "نماذج حسب الطلب، تُحدد حالة بحالة مع العميل.",
|
||||
"admin.models.defaultBadge": "افتراضي",
|
||||
"admin.models.emptyTier": "لا نماذج: ستُستخدم المجموعة الرسمية للخطة.",
|
||||
"admin.models.enterpriseBadge": "خطة Enterprise",
|
||||
"admin.models.essentialBadge": "مستوى الذكاء الاصطناعي الأساسي",
|
||||
"admin.models.essentialCost": "— يُحتسب بمعامل تكلفة 1",
|
||||
"admin.models.fallbackFirst": "(احتياط رقم 1)",
|
||||
"admin.models.loadErrorDesc": "HTTP {status} — تحقق من رمز المسؤول.",
|
||||
"admin.models.loadErrorTitle": "خطأ في التحميل",
|
||||
"admin.models.matrixDesc": "النماذج النشطة لكل مستوى — ترتيب القائمة هو أولوية الاحتياط. يختار زر الراديو النموذج الافتراضي، ويُطبق فورًا دون إعادة نشر.",
|
||||
"admin.models.matrixTitle": "مصفوفة النماذج حسب الخطة",
|
||||
"admin.models.missingConfig": "لم يتم العثور على إعدادات مستويات الذكاء الاصطناعي. أعد تحميل الصفحة.",
|
||||
"admin.models.moveDown": "تحريك لأسفل",
|
||||
"admin.models.moveUp": "تحريك لأعلى",
|
||||
"admin.models.networkDesc": "لا يمكن الوصول إلى الخادم الخلفي.",
|
||||
"admin.models.networkTitle": "خطأ في الشبكة",
|
||||
"admin.models.premiumBadge": "مستوى الذكاء الاصطناعي المتميز",
|
||||
"admin.models.premiumCost": "— يُحتسب بمعامل تكلفة 5",
|
||||
"admin.models.premiumReservedNote": "محصور في خطتي Business وEnterprise (محرك «openrouter_premium»).",
|
||||
"admin.models.proBadge": "خطة Pro",
|
||||
"admin.models.remove": "إزالة",
|
||||
"admin.models.save": "حفظ",
|
||||
"admin.models.saveErrorTitle": "خطأ في الحفظ",
|
||||
"admin.models.saveNetworkDesc": "تعذر حفظ الإعدادات.",
|
||||
"admin.models.savedDesc": "يُستخدم النموذج الافتراضي الجديد بدءًا من الترجمة التالية، دون إعادة نشر.",
|
||||
"admin.models.savedTitle": "تم حفظ النماذج",
|
||||
"admin.models.saving": "جارٍ الحفظ...",
|
||||
"admin.models.setDefault": "تعيين {model} كنموذج افتراضي",
|
||||
"admin.models.sharedTierNote": "مستوى مشترك: تستخدم خطة Business المستوى الأساسي أيضًا لمحركها «openrouter» — القائمة أعلاه تنطبق على الخطتين.",
|
||||
"admin.models.subtitle": "مصفوفة الخطط → مستويات الذكاء الاصطناعي. النموذج المستخدم فعليًا في كل ترجمة هو نموذج مستوى الخطة: خطة Pro لا يمكنها أبدًا تشغيل نموذج Premium.",
|
||||
"admin.models.title": "النماذج والاشتراكات",
|
||||
"admin.stats.aiTiers": "مستويات الذكاء الاصطناعي (30 يومًا)",
|
||||
"admin.stats.includingCredits": "منها {amount} من الرصيد الإضافي",
|
||||
"admin.stats.mrr": "الإيراد الشهري المتكرر المقدر",
|
||||
"admin.stats.noTranslationsYet": "لا توجد ترجمات مسجلة خلال آخر 30 يومًا: ستكتمل توزيعات المستويات مع الترجمات القادمة.",
|
||||
"admin.stats.payments30": "{count} دفعة خلال 30 يومًا",
|
||||
"admin.stats.refreshing": "جارٍ التحديث...",
|
||||
"admin.stats.revenue30": "الإيرادات (30 يومًا)",
|
||||
"admin.stats.revenueTotal": "إجمالي الإيرادات المحصلة",
|
||||
"admin.stats.tiersSub": "أساسي / متميز · كلاسيكي {classic} · آخر {other}",
|
||||
"admin.stats.unavailable": "إحصائيات الأعمال غير متاحة ({error}).",
|
||||
"admin.stats.waitlist": "قائمة الانتظار",
|
||||
"admin.stats.waitlistSub": "مُدرجون في قائمة الانتظار"
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
"landing.pricing.pro.desc": "للمحترفين المتميزين",
|
||||
"landing.pricing.pro.f1": "200 مستند / شهر",
|
||||
"landing.pricing.pro.f2": "حتى 200 صفحة لكل مستند",
|
||||
"landing.pricing.pro.f3": "ترجمة بالذكاء الاصطناعي",
|
||||
"landing.pricing.pro.f3": "ذكاء اصطناعي أساسي: DeepSeek V4 Flash، GLM-5.3 Flash، MiniMax M3",
|
||||
"landing.pricing.pro.f4": "Google مشمولان",
|
||||
"landing.pricing.pro.f5": "قواميس وأوامر مخصصة",
|
||||
"landing.pricing.pro.f6": "دعم ذو أولوية",
|
||||
@@ -109,7 +109,7 @@
|
||||
"landing.pricing.business.desc": "للفرق ذات الاحتياجات الكبيرة",
|
||||
"landing.pricing.business.f1": "1,000 مستند / شهر",
|
||||
"landing.pricing.business.f2": "حتى 500 صفحة لكل مستند",
|
||||
"landing.pricing.business.f3": "ذكاء اصطناعي متميز (Claude)",
|
||||
"landing.pricing.business.f3": "ذكاء اصطناعي متميز (Claude 5)",
|
||||
"landing.pricing.business.f4": "جميع المزودين + واجهة برمجة التطبيقات",
|
||||
"landing.pricing.business.f5": "خطاطف الويب والأتمتة",
|
||||
"landing.pricing.business.f6": "5 مقاعد للفريق",
|
||||
@@ -142,5 +142,31 @@
|
||||
"landing.translate.supportedFormats": "ملفات DOCX, XLSX, PPTX أو PDF مدعومة",
|
||||
"landing.translate.aiAnalysis": "تحليل AI نشط",
|
||||
"landing.translate.processing": "جاري المعالجة",
|
||||
"landing.translate.preservingLayout": "جاري الحفاظ على التنسيق"
|
||||
"landing.translate.preservingLayout": "جاري الحفاظ على التنسيق",
|
||||
"landing.beforeAfter.seal": "نفس التخطيط، كلمة بكلمة",
|
||||
"landing.beforeAfter.proof1": "مخططات SmartArt يُعاد بناؤها، وليس تسطيحها",
|
||||
"landing.beforeAfter.proof2": "سلاسل الرسوم البيانية ومحاورها مترجمة",
|
||||
"landing.beforeAfter.proof3": "جداول المحتويات يُعاد إنشاؤها باللغة الهدف",
|
||||
"landing.beforeAfter.targetTitle": "المواصفات الفنية — معالجة الهواء",
|
||||
"landing.beforeAfter.targetPara1": "يجب تشغيل نظام التهوية قبل نهاية الربع الثاني.",
|
||||
"landing.beforeAfter.targetPara2Before": "",
|
||||
"landing.beforeAfter.targetTerm": "وحدة معالجة الهواء",
|
||||
"landing.beforeAfter.targetPara2After": "يجب أن تتوافق مع فئة الترشيح F7 وفقًا للجدول.",
|
||||
"landing.beforeAfter.targetItem1": "الحمل الحراري: 42 كيلوواط عند التدفق الاسمي",
|
||||
"landing.beforeAfter.targetItem2": "مستوى الصوت أقل من 45 dB(A) على مسافة 3 أمتار",
|
||||
"landing.formats.pill": "التوافق",
|
||||
"landing.hero.visualCaption": "نفس التخطيط، لغة جديدة — لا شيء آخر يتغير",
|
||||
"landing.pricing.free.name": "مجاني",
|
||||
"landing.pricing.free.desc": "مثالي لاكتشاف التطبيق",
|
||||
"landing.pricing.free.cta": "اختر هذه الخطة",
|
||||
"landing.pricing.enterprise.name": "المؤسسات",
|
||||
"landing.pricing.enterprise.desc": "حلول مخصصة للمؤسسات الكبيرة",
|
||||
"landing.pricing.enterprise.cta": "اتصل بنا",
|
||||
"landing.beforeAfter.sourceTitle": "Cahier des charges — Traitement d'air",
|
||||
"landing.beforeAfter.sourcePara1": "L'installation de ventilation doit être mise en service avant la fin du deuxième trimestre.",
|
||||
"landing.beforeAfter.sourcePara2Before": "Le",
|
||||
"landing.beforeAfter.sourceTerm": "groupe de traitement d'air",
|
||||
"landing.beforeAfter.sourcePara2After": "doit respecter la classe de filtration F7 conformément au planning.",
|
||||
"landing.beforeAfter.sourceItem1": "Charge thermique : 42 kW au débit nominal",
|
||||
"landing.beforeAfter.sourceItem2": "Niveau sonore inférieur à 45 dB(A) à 3 m"
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"pricing.plans.pro.feat8": "سجل 90 يومًا",
|
||||
"pricing.plans.business.feat1": "1 000 مستند / شهر",
|
||||
"pricing.plans.business.feat2": "حتى 500 صفحة لكل مستند",
|
||||
"pricing.plans.business.feat3": "AI أساسية + متميزة (Claude Haiku)",
|
||||
"pricing.plans.business.feat3": "الذكاء الاصطناعي الأساسي + المتميز (Claude 5)",
|
||||
"pricing.plans.business.feat4": "جميع مزوّدي الترجمة",
|
||||
"pricing.plans.business.feat5": "ملفات حتى 50 ميغابايت",
|
||||
"pricing.plans.business.feat6": "وصول API (10 000 استدعاء/شهر)",
|
||||
@@ -50,7 +50,7 @@
|
||||
"pricing.plans.business.feat9": "سجل سنة واحدة",
|
||||
"pricing.plans.business.feat10": "تحليلات متقدمة",
|
||||
"pricing.plans.enterprise.feat1": "مستندات بلا حدود",
|
||||
"pricing.plans.enterprise.feat2": "جميع نماذج AI (GPT-5, Claude Opus 4.6…)",
|
||||
"pricing.plans.enterprise.feat2": "جميع نماذج AI (Claude 5، DeepSeek V4 Pro، GLM-5.3…)",
|
||||
"pricing.plans.enterprise.feat3": "نشر محلي أو سحابة مخصصة",
|
||||
"pricing.plans.enterprise.feat4": "SLA 99.9% مضمون",
|
||||
"pricing.plans.enterprise.feat5": "دعم مخصص على مدار الساعة",
|
||||
@@ -108,19 +108,19 @@
|
||||
"pricing.trust.parallel.sub": "ذكاء اصطناعي متعدد المسارات فائق السرعة",
|
||||
"pricing.trust.availability.title": "متاح على مدار الساعة",
|
||||
"pricing.trust.availability.sub": "ضمان وقت تشغيل 99.9%",
|
||||
"pricing.aiModels.title": "نماذج الذكاء الاصطناعي — مارس 2026",
|
||||
"pricing.aiModels.title": "نماذج الذكاء الاصطناعي — سبتمبر 2026",
|
||||
"pricing.aiModels.essential.title": "ترجمة AI أساسية",
|
||||
"pricing.aiModels.essential.plan": "خطة Pro",
|
||||
"pricing.aiModels.essential.descPrefix": "مبني على",
|
||||
"pricing.aiModels.essential.descSuffix": "— أنموذج الذكاء الاصطناعي الأكثر فعالية من حيث التكلفة لعام 2026. جودة مماثلة للنماذج المتقدمة بتكلفة أقل بكثير.",
|
||||
"pricing.aiModels.essential.modelName": "نموذج الذكاء الاصطناعي الأساسي",
|
||||
"pricing.aiModels.essential.context": "163K رمز سياقي",
|
||||
"pricing.aiModels.essential.value": "قيمة ممتازة مقابل المال",
|
||||
"pricing.aiModels.essential.descSuffix": "— نماذج الذكاء الاصطناعي الأفضل قيمة مقابل المال لعام 2026.",
|
||||
"pricing.aiModels.essential.modelName": "DeepSeek V4 Flash وGLM-5.3 Flash وMiniMax M3",
|
||||
"pricing.aiModels.essential.context": "سياق يصل إلى 1.3 مليون رمز (GLM-5.3 Flash)",
|
||||
"pricing.aiModels.essential.value": "أفضل قيمة مقابل السعر",
|
||||
"pricing.aiModels.premium.title": "ترجمة AI متميزة",
|
||||
"pricing.aiModels.premium.plan": "خطة Business",
|
||||
"pricing.aiModels.premium.descPrefix": "مبني على",
|
||||
"pricing.aiModels.premium.descSuffix": "من Anthropic — دقة عالية في المستندات القانونية والطبية والتقنية المعقدة.",
|
||||
"pricing.aiModels.premium.context": "200K رمز سياقي",
|
||||
"pricing.aiModels.premium.context": "مليون رمز سياقي",
|
||||
"pricing.aiModels.premium.precision": "أعلى دقة",
|
||||
"pricing.faq.title": "الأسئلة الشائعة",
|
||||
"pricing.faq.q1": "هل يمكنني تغيير خطتي في أي وقت؟",
|
||||
@@ -128,7 +128,7 @@
|
||||
"pricing.faq.q2": "ما هي «ترجمة AI الأساسية»؟",
|
||||
"pricing.faq.a2": "إنه محرك الذكاء الاصطناعي الخاص بنا. يفهم سياق مستنداتك، ويحافظ على التخطيط، ويتعامل مع المصطلحات التقنية بشكل أفضل بكثير من الترجمة الكلاسيكية.",
|
||||
"pricing.faq.q3": "ما الفرق بين AI الأساسية وAI المتميزة؟",
|
||||
"pricing.faq.a3": "الذكاء الاصطناعي الأساسي يستخدم نموذجًا محسّنًا (قيمة ممتازة مقابل المال). الذكاء الاصطناعي المتميز يستخدم Claude 3.5 Haiku من Anthropic، أكثر دقة في المستندات القانونية والطبية والتقنية المعقدة.",
|
||||
"pricing.faq.a3": "الذكاء الاصطناعي الأساسي يعمل بنماذج DeepSeek V4 Flash وGLM-5.3 Flash وMiniMax M3 (قيمة ممتازة مقابل المال). الذكاء الاصطناعي المتميز يستخدم Claude 5 من Anthropic، أكثر دقة في المستندات القانونية والطبية والتقنية المعقدة.",
|
||||
"pricing.faq.q4": "هل تُحفظ مستنداتي بعد الترجمة؟",
|
||||
"pricing.faq.a4": "الملفات المترجمة متاحة حسب خطتك (30 يومًا لـ Starter، 90 يومًا لـ Pro، سنة واحدة لـ Business). وهي مشفرة أثناء التخزين والنقل.",
|
||||
"pricing.faq.q5": "ماذا يحدث إذا تجاوزت حصتي الشهرية؟",
|
||||
@@ -146,5 +146,26 @@
|
||||
"pricing.toast.paymentError": "خطأ أثناء إنشاء الدفع.",
|
||||
"pricing.dashboard": "لوحة التحكم",
|
||||
"pricing.okSymbol": "✓",
|
||||
"pricing.errSymbol": "✕"
|
||||
"pricing.errSymbol": "✕",
|
||||
"pricing.aiModels.essential.price": "ابتداءً من 0.09 دولار لكل مليون رمز",
|
||||
"pricing.aiModels.premium.modelName": "Claude 5",
|
||||
"pricing.aiModels.premium.alternatives": "بدائل: DeepSeek V4 Pro وGLM-5.3",
|
||||
"pricing.confirm.title": "أكّد اشتراكك",
|
||||
"pricing.confirm.subtitle": "سيتم تحويلك إلى مزود الدفع الآمن لدينا.",
|
||||
"pricing.confirm.cancel": "إلغاء",
|
||||
"pricing.confirm.cta": "المتابعة إلى الدفع",
|
||||
"pricing.confirm.year": "سنة",
|
||||
"pricing.confirm.month": "شهر",
|
||||
"pricing.confirm.monthlyEquivalent": "فوترة سنوية — أي {price} € / شهر.",
|
||||
"pricing.confirm.secureNote": "يمكنك الإلغاء في أي وقت من ملفك الشخصي. تتم معالجة الدفع عبر Stripe؛ ولا تمر بيانات بطاقتك أبدًا عبر خوادمنا.",
|
||||
"pricing.enterprise.subject": "عرض المؤسسات",
|
||||
"pricing.enterpriseBand.cta": "اتصل بنا",
|
||||
"pricing.enterpriseBand.text": "حجم كبير، محركات مخصصة، خيارات تثبيت داخلي — لنتحدث.",
|
||||
"pricing.error.server": "خطأ في الخادم {status}",
|
||||
"pricing.freeBand.cta": "ابدأ مجانًا",
|
||||
"pricing.freeBand.text": "تريد التجربة فقط؟ ابدأ مجانًا — 5 مستندات شهريًا، بدون بطاقة.",
|
||||
"pricing.header.titleBase": "خطة لكل",
|
||||
"pricing.header.titleAccent": "احتياج",
|
||||
"pricing.toast.close": "إغلاق",
|
||||
"pricing.aiModels.premium.price": "2 $ / 10 $ لكل مليون رمز"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"providerTheme.openrouter.desc": "وصول موحد إلى أفضل النماذج مفتوحة المصدر المحسّنة للترجمة.",
|
||||
"providerTheme.openrouter_premium.badge": "فائق",
|
||||
"providerTheme.openrouter_premium.subBadge": "السياق الأقصى",
|
||||
"providerTheme.openrouter_premium.desc": "مدعوم من أحدث النماذج (GPT-4o، Claude Sonnet 4.6) للمستندات الطويلة.",
|
||||
"providerTheme.openrouter_premium.desc": "مدعوم من أحدث النماذج (GPT-4o، Claude 5) للمستندات الطويلة.",
|
||||
"providerTheme.zai.badge": "متخصص",
|
||||
"providerTheme.zai.subBadge": "المال والقانون",
|
||||
"providerTheme.zai.desc": "نموذج مضبوط بدقة للمصطلحات التجارية المتطلبة (قانونية، مالية).",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"admin.nav.providers": "Anbieter",
|
||||
"admin.nav.system": "System",
|
||||
"admin.nav.logs": "Protokolle",
|
||||
"admin.nav.stats": "Statistiken",
|
||||
"admin.users.title": "Benutzerverwaltung",
|
||||
"admin.users.subtitle": "Benutzerkonten anzeigen und verwalten",
|
||||
"admin.users.planUpdated": "Plan aktualisiert",
|
||||
@@ -44,5 +45,105 @@
|
||||
"admin.system.waitingData": "Warte auf Daten...",
|
||||
"admin.system.purging": "Bereinigung läuft...",
|
||||
"admin.system.clean": "Bereinigen",
|
||||
"admin.system.purge": "Bereinigen"
|
||||
"admin.system.purge": "Bereinigen",
|
||||
"admin.nav.models": "Modelle & Abonnements",
|
||||
"admin.nav.marketing": "Marketing",
|
||||
"admin.marketing.audience": "Zielgruppe",
|
||||
"admin.marketing.audienceAllUsers": "Alle Konten",
|
||||
"admin.marketing.audienceInactive": "Seit 30 Tagen inaktive Konten",
|
||||
"admin.marketing.audiencePlaceholder": "Zielgruppe wählen",
|
||||
"admin.marketing.audiencePlanBusiness": "Business-Tarif",
|
||||
"admin.marketing.audiencePlanEnterprise": "Enterprise-Tarif",
|
||||
"admin.marketing.audiencePlanFree": "Kostenlos-Tarif",
|
||||
"admin.marketing.audiencePlanPro": "Pro-Tarif",
|
||||
"admin.marketing.audiencePlanStarter": "Starter-Tarif",
|
||||
"admin.marketing.audienceWaitlist": "Warteliste",
|
||||
"admin.marketing.badgeReal": "echt",
|
||||
"admin.marketing.badgeTest": "Test",
|
||||
"admin.marketing.confirmSend": "Diese E-Mail an {count} Empfänger senden („{audience}“)?",
|
||||
"admin.marketing.footerNote": "Eine Fußzeile mit dem Abmeldelink wird beim Senden automatisch ergänzt.",
|
||||
"admin.marketing.hidePreview": "Vorschau ausblenden",
|
||||
"admin.marketing.history": "Sendungsverlauf",
|
||||
"admin.marketing.historyFailedSuffix": ", {count} Fehler",
|
||||
"admin.marketing.historySent": "{sent}/{total} gesendet",
|
||||
"admin.marketing.html": "HTML-Inhalt",
|
||||
"admin.marketing.htmlPlaceholder": "<html><body>…</body></html>",
|
||||
"admin.marketing.incompleteDesc": "Bitte Betreff und HTML ausfüllen.",
|
||||
"admin.marketing.incompleteTitle": "Unvollständiger Inhalt",
|
||||
"admin.marketing.loadErrorTitle": "Ladefehler",
|
||||
"admin.marketing.localCheckUnavailable": "Lokale Prüfung in diesem Browser nicht möglich: Der Server kontrolliert die vorherige Testsendung.",
|
||||
"admin.marketing.networkDesc": "Backend nicht erreichbar.",
|
||||
"admin.marketing.newCampaign": "Neue Kampagne",
|
||||
"admin.marketing.noHistory": "Keine Sendung erfasst.",
|
||||
"admin.marketing.preview": "Vorschau",
|
||||
"admin.marketing.previewEmpty": "Vorschau: HTML daneben einfügen.",
|
||||
"admin.marketing.previewTitle": "E-Mail-Vorschau",
|
||||
"admin.marketing.recipientsAvailable": "{count} Empfänger nach Ausschluss der Abmeldungen verfügbar.",
|
||||
"admin.marketing.sendReal": "An {count} Empfänger senden",
|
||||
"admin.marketing.sendRefusedTitle": "Sendung abgelehnt",
|
||||
"admin.marketing.sendTest": "Testsendung",
|
||||
"admin.marketing.sendingDesc": "{count} E-Mail(s) in der Warteschlange (0,2 s Abstand). Abmeldungen werden ausgeschlossen.",
|
||||
"admin.marketing.sendingTitle": "Sendung läuft",
|
||||
"admin.marketing.subject": "Betreff",
|
||||
"admin.marketing.subjectPlaceholder": "z. B.: Ihre Übersetzung wartet — 20 % für 7 Tage",
|
||||
"admin.marketing.subtitle": "E-Mails an eine Zielgruppe senden, mit Pflicht-Testsendung, automatischem Abmeldelink und vollständigem Verlauf.",
|
||||
"admin.marketing.testDoneDesc": "E-Mail erhalten an {email}",
|
||||
"admin.marketing.testDoneTitle": "Testsendung erfolgt",
|
||||
"admin.marketing.testEmail": "Test-E-Mail (optional)",
|
||||
"admin.marketing.testEmailPlaceholder": "Sonst: SMTP-Absenderadresse",
|
||||
"admin.marketing.testFailedTitle": "Testsendung fehlgeschlagen",
|
||||
"admin.marketing.testRequiredHint": "Eine echte Sendung erfordert eine vorherige Testsendung exakt dieses Inhalts (vom Server geprüft).",
|
||||
"admin.marketing.testValidated": "Testsendung für diesen Inhalt bestätigt.",
|
||||
"admin.marketing.title": "Marketing — E-Mail-Kampagnen",
|
||||
"admin.marketing.unsubscribedNote": "{count} abgemeldete Kontakte werden von jeder Sendung ausgeschlossen.",
|
||||
"admin.models.addModel": "Modell hinzufügen",
|
||||
"admin.models.businessBadge": "Business-Tarif",
|
||||
"admin.models.cancel": "Abbrechen",
|
||||
"admin.models.catalog": "OpenRouter-Katalog",
|
||||
"admin.models.catalogErrorDesc": "OpenRouter-Katalog konnte nicht geladen werden.",
|
||||
"admin.models.catalogErrorTitle": "Katalog nicht verfügbar",
|
||||
"admin.models.customBadge": "Individuelle Stufe",
|
||||
"admin.models.customNote": "Sondermodelle, im Einzelfall mit dem Kunden vereinbart.",
|
||||
"admin.models.defaultBadge": "Standard",
|
||||
"admin.models.emptyTier": "Kein Modell: Die offizielle Modellrange des Tarifs wird verwendet.",
|
||||
"admin.models.enterpriseBadge": "Enterprise-Tarif",
|
||||
"admin.models.essentialBadge": "KI-Stufe Basis",
|
||||
"admin.models.essentialCost": "— Abrechnungsfaktor 1",
|
||||
"admin.models.fallbackFirst": "(Fallback Nr. 1)",
|
||||
"admin.models.loadErrorDesc": "HTTP {status} — prüfen Sie Ihr Admin-Token.",
|
||||
"admin.models.loadErrorTitle": "Ladefehler",
|
||||
"admin.models.matrixDesc": "Aktive Modelle pro Stufe — die Reihenfolge der Liste ist die Fallback-Priorität. Der Radioknopf wählt das Standardmodell, sofort und ohne neues Deployment wirksam.",
|
||||
"admin.models.matrixTitle": "Modellmatrix pro Tarif",
|
||||
"admin.models.missingConfig": "KI-Stufen-Konfiguration nicht gefunden. Bitte Seite neu laden.",
|
||||
"admin.models.moveDown": "Nach unten",
|
||||
"admin.models.moveUp": "Nach oben",
|
||||
"admin.models.networkDesc": "Backend nicht erreichbar.",
|
||||
"admin.models.networkTitle": "Netzwerkfehler",
|
||||
"admin.models.premiumBadge": "KI-Stufe Premium",
|
||||
"admin.models.premiumCost": "— Abrechnungsfaktor 5",
|
||||
"admin.models.premiumReservedNote": "Den Tarifen Business und Enterprise vorbehalten (Engine „openrouter_premium“).",
|
||||
"admin.models.proBadge": "Pro-Tarif",
|
||||
"admin.models.remove": "Entfernen",
|
||||
"admin.models.save": "Speichern",
|
||||
"admin.models.saveErrorTitle": "Speicherfehler",
|
||||
"admin.models.saveNetworkDesc": "Konfiguration konnte nicht gespeichert werden.",
|
||||
"admin.models.savedDesc": "Das neue Standardmodell wird ab der nächsten Übersetzung verwendet — ohne neues Deployment.",
|
||||
"admin.models.savedTitle": "Modelle gespeichert",
|
||||
"admin.models.saving": "Speichern...",
|
||||
"admin.models.setDefault": "{model} als Standardmodell festlegen",
|
||||
"admin.models.sharedTierNote": "Gemeinsame Stufe: Der Business-Tarif nutzt die Basis-Stufe ebenfalls für seine Engine „openrouter“ — die obige Liste gilt für beide Tarife.",
|
||||
"admin.models.subtitle": "Matrix Tarife → KI-Stufen. Das tatsächlich pro Übersetzung verwendete Modell ist das der KI-Stufe des Tarifs: Ein Pro-Tarif kann nie ein Premium-Modell auslösen.",
|
||||
"admin.models.title": "Modelle & Abonnements",
|
||||
"admin.stats.aiTiers": "KI-Stufen (30 T)",
|
||||
"admin.stats.includingCredits": "davon {amount} Credits",
|
||||
"admin.stats.mrr": "Geschätzter MRR",
|
||||
"admin.stats.noTranslationsYet": "Keine Übersetzungen der letzten 30 Tage in der Datenbank: Die Stufenverteilung füllt sich mit den nächsten Übersetzungen.",
|
||||
"admin.stats.payments30": "{count} Zahlung(en) in 30 Tagen",
|
||||
"admin.stats.refreshing": "Aktualisierung...",
|
||||
"admin.stats.revenue30": "Umsatz (30 Tage)",
|
||||
"admin.stats.revenueTotal": "Eingenommener Umsatz (gesamt)",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
"landing.pricing.pro.desc": "Für anspruchsvolle Profis",
|
||||
"landing.pricing.pro.f1": "200 Dokumente / Monat",
|
||||
"landing.pricing.pro.f2": "Bis zu 200 Seiten pro Dokument",
|
||||
"landing.pricing.pro.f3": "KI-gestützte Übersetzung",
|
||||
"landing.pricing.pro.f3": "Basis-KI: DeepSeek V4 Flash, GLM-5.3 Flash, MiniMax M3",
|
||||
"landing.pricing.pro.f4": "Google inklusive",
|
||||
"landing.pricing.pro.f5": "Individuelle Glossare & Prompts",
|
||||
"landing.pricing.pro.f6": "Prioritäts-Support",
|
||||
@@ -109,7 +109,7 @@
|
||||
"landing.pricing.business.desc": "Für Teams mit hohem Bedarf",
|
||||
"landing.pricing.business.f1": "1 000 Dokumente / Monat",
|
||||
"landing.pricing.business.f2": "Bis zu 500 Seiten pro Dokument",
|
||||
"landing.pricing.business.f3": "Premium-KI (Claude)",
|
||||
"landing.pricing.business.f3": "Premium-KI (Claude 5)",
|
||||
"landing.pricing.business.f4": "Alle Anbieter + API-Zugang",
|
||||
"landing.pricing.business.f5": "Webhooks & Automatisierung",
|
||||
"landing.pricing.business.f6": "5 Teamplätze",
|
||||
@@ -142,5 +142,31 @@
|
||||
"landing.translate.supportedFormats": "DOCX, XLSX, PPTX oder PDF Dateien unterstützt",
|
||||
"landing.translate.aiAnalysis": "KI-Analyse aktiv",
|
||||
"landing.translate.processing": "Verarbeitung läuft",
|
||||
"landing.translate.preservingLayout": "Ihr Layout wird beibehalten"
|
||||
"landing.translate.preservingLayout": "Ihr Layout wird beibehalten",
|
||||
"landing.beforeAfter.seal": "Gleiches Layout, Wort für Wort",
|
||||
"landing.beforeAfter.proof1": "SmartArt-Diagramme werden neu aufgebaut, nicht abgeflacht",
|
||||
"landing.beforeAfter.proof2": "Diagrammreihen und Achsen übersetzt",
|
||||
"landing.beforeAfter.proof3": "Verzeichnisse in der Zielsprache neu erstellt",
|
||||
"landing.beforeAfter.targetTitle": "Technisches Datenblatt — Luftaufbereitung",
|
||||
"landing.beforeAfter.targetPara1": "Die Lüftungsanlage muss vor Ende des zweiten Quartals in Betrieb genommen werden.",
|
||||
"landing.beforeAfter.targetPara2Before": "Die",
|
||||
"landing.beforeAfter.targetTerm": "Lüftungseinheit",
|
||||
"landing.beforeAfter.targetPara2After": "muss gemäß Planung der Filterklasse F7 entsprechen.",
|
||||
"landing.beforeAfter.targetItem1": "Wärmebelastung: 42 kW bei Nenndurchfluss",
|
||||
"landing.beforeAfter.targetItem2": "Schallpegel unter 45 dB(A) bei 3 m",
|
||||
"landing.formats.pill": "KOMPATIBILITÄT",
|
||||
"landing.hero.visualCaption": "Gleiches Layout, neue Sprache — nichts anderes verändert sich",
|
||||
"landing.pricing.free.name": "Kostenlos",
|
||||
"landing.pricing.free.desc": "Ideal zum Entdecken der App",
|
||||
"landing.pricing.free.cta": "Diesen Plan wählen",
|
||||
"landing.pricing.enterprise.name": "Enterprise",
|
||||
"landing.pricing.enterprise.desc": "Maßgeschneiderte Lösungen für große Organisationen",
|
||||
"landing.pricing.enterprise.cta": "Kontakt aufnehmen",
|
||||
"landing.beforeAfter.sourceTitle": "Cahier des charges — Traitement d'air",
|
||||
"landing.beforeAfter.sourcePara1": "L'installation de ventilation doit être mise en service avant la fin du deuxième trimestre.",
|
||||
"landing.beforeAfter.sourcePara2Before": "Le",
|
||||
"landing.beforeAfter.sourceTerm": "groupe de traitement d'air",
|
||||
"landing.beforeAfter.sourcePara2After": "doit respecter la classe de filtration F7 conformément au planning.",
|
||||
"landing.beforeAfter.sourceItem1": "Charge thermique : 42 kW au débit nominal",
|
||||
"landing.beforeAfter.sourceItem2": "Niveau sonore inférieur à 45 dB(A) à 3 m"
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"pricing.plans.pro.feat8": "90 Tage Verlauf",
|
||||
"pricing.plans.business.feat1": "1.000 Dokumente / Monat",
|
||||
"pricing.plans.business.feat2": "Bis zu 500 Seiten pro Dokument",
|
||||
"pricing.plans.business.feat3": "Basis- + Premium-KI (Claude Haiku)",
|
||||
"pricing.plans.business.feat3": "Basis-KI + Premium-KI (Claude 5)",
|
||||
"pricing.plans.business.feat4": "Alle Übersetzungsanbieter",
|
||||
"pricing.plans.business.feat5": "Dateien bis zu 50 MB",
|
||||
"pricing.plans.business.feat6": "API-Zugang (10.000 Aufrufe/Monat)",
|
||||
@@ -50,7 +50,7 @@
|
||||
"pricing.plans.business.feat9": "1 Jahr Verlauf",
|
||||
"pricing.plans.business.feat10": "Erweiterte Analysen",
|
||||
"pricing.plans.enterprise.feat1": "Unbegrenzte Dokumente",
|
||||
"pricing.plans.enterprise.feat2": "Alle KI-Modelle (GPT-5, Claude Opus 4.6…)",
|
||||
"pricing.plans.enterprise.feat2": "Alle KI-Modelle (Claude 5, DeepSeek V4 Pro, GLM-5.3…)",
|
||||
"pricing.plans.enterprise.feat3": "On-Premise oder dedizierte Cloud",
|
||||
"pricing.plans.enterprise.feat4": "99,9 % SLA garantiert",
|
||||
"pricing.plans.enterprise.feat5": "24/7 dedizierter Support",
|
||||
@@ -108,19 +108,19 @@
|
||||
"pricing.trust.parallel.sub": "Ultraschnelle Multi-Thread-KI",
|
||||
"pricing.trust.availability.title": "24/7 verfügbar",
|
||||
"pricing.trust.availability.sub": "99,9 % garantierte Verfügbarkeit",
|
||||
"pricing.aiModels.title": "Unsere KI-Modelle — März 2026",
|
||||
"pricing.aiModels.title": "Unsere KI-Modelle — September 2026",
|
||||
"pricing.aiModels.essential.title": "KI-Basisübersetzung",
|
||||
"pricing.aiModels.essential.plan": "Pro-Plan",
|
||||
"pricing.aiModels.essential.descPrefix": "Basierend auf",
|
||||
"pricing.aiModels.essential.descSuffix": "— dem kosteneffizientesten KI-Modell 2026. Qualität vergleichbar mit Frontier-Modellen zu einem Bruchteil der Kosten.",
|
||||
"pricing.aiModels.essential.modelName": "unser Essentielles KI-Modell",
|
||||
"pricing.aiModels.essential.context": "163K Token Kontext",
|
||||
"pricing.aiModels.essential.value": "Hervorragendes Preis-Leistungs-Verhältnis",
|
||||
"pricing.aiModels.essential.descSuffix": "— die KI-Modelle mit dem besten Preis-Leistungs-Verhältnis 2026.",
|
||||
"pricing.aiModels.essential.modelName": "DeepSeek V4 Flash, GLM-5.3 Flash und MiniMax M3",
|
||||
"pricing.aiModels.essential.context": "Bis zu 1,3 M Kontext (GLM-5.3 Flash)",
|
||||
"pricing.aiModels.essential.value": "Das beste Preis-Leistungs-Verhältnis",
|
||||
"pricing.aiModels.premium.title": "KI-Premiumübersetzung",
|
||||
"pricing.aiModels.premium.plan": "Business-Plan",
|
||||
"pricing.aiModels.premium.descPrefix": "Basierend auf",
|
||||
"pricing.aiModels.premium.descSuffix": "von Anthropic — präzise bei juristischen, medizinischen und komplexen technischen Dokumenten.",
|
||||
"pricing.aiModels.premium.context": "200K Token Kontext",
|
||||
"pricing.aiModels.premium.context": "1M Kontext",
|
||||
"pricing.aiModels.premium.precision": "Höchste Genauigkeit",
|
||||
"pricing.faq.title": "Häufig gestellte Fragen",
|
||||
"pricing.faq.q1": "Kann ich jederzeit den Plan wechseln?",
|
||||
@@ -128,7 +128,7 @@
|
||||
"pricing.faq.q2": "Was ist die «KI-Basisübersetzung»?",
|
||||
"pricing.faq.a2": "Unser KI-Motor versteht den Kontext Ihrer Dokumente, erhält das Layout und verarbeitet Fachbegriffe deutlich besser als klassische Übersetzungen.",
|
||||
"pricing.faq.q3": "Was ist der Unterschied zwischen Basis- und Premium-KI?",
|
||||
"pricing.faq.a3": "Die Basis-KI nutzt ein optimiertes Modell (hervorragendes Preis-Leistungs-Verhältnis). Die Premium-KI verwendet Claude 3.5 Haiku von Anthropic und ist genauer bei juristischen, medizinischen und komplexen technischen Dokumenten.",
|
||||
"pricing.faq.a3": "Die Basis-KI nutzt DeepSeek V4 Flash, GLM-5.3 Flash und MiniMax M3 (hervorragendes Preis-Leistungs-Verhältnis). Die Premium-KI verwendet Claude 5 von Anthropic und ist genauer bei juristischen, medizinischen und komplexen technischen Dokumenten.",
|
||||
"pricing.faq.q4": "Werden meine Dokumente nach der Übersetzung gespeichert?",
|
||||
"pricing.faq.a4": "Übersetzte Dateien sind je nach Plan verfügbar (30 Tage Starter, 90 Tage Pro, 1 Jahr Business). Sie sind im Ruhezustand und bei der Übertragung verschlüsselt.",
|
||||
"pricing.faq.q5": "Was passiert, wenn ich mein monatliches Kontingent überschreite?",
|
||||
@@ -146,5 +146,26 @@
|
||||
"pricing.toast.paymentError": "Fehler beim Erstellen der Zahlung.",
|
||||
"pricing.dashboard": "Dashboard",
|
||||
"pricing.okSymbol": "✓",
|
||||
"pricing.errSymbol": "✕"
|
||||
"pricing.errSymbol": "✕",
|
||||
"pricing.aiModels.essential.price": "Ab 0,09 $ pro Million Token",
|
||||
"pricing.aiModels.premium.modelName": "Claude 5",
|
||||
"pricing.aiModels.premium.alternatives": "Alternativen: DeepSeek V4 Pro und GLM-5.3",
|
||||
"pricing.confirm.title": "Bestätigen Sie Ihr Abonnement",
|
||||
"pricing.confirm.subtitle": "Sie werden zu unserem sicheren Zahlungsanbieter weitergeleitet.",
|
||||
"pricing.confirm.cancel": "Abbrechen",
|
||||
"pricing.confirm.cta": "Weiter zur Zahlung",
|
||||
"pricing.confirm.year": "Jahr",
|
||||
"pricing.confirm.month": "Monat",
|
||||
"pricing.confirm.monthlyEquivalent": "Jährliche Abrechnung — entspricht {price} € / Monat.",
|
||||
"pricing.confirm.secureNote": "Jederzeit über Ihr Profil kündbar. Die Zahlung wird von Stripe abgewickelt; Ihre Kartendaten erreichen nie unsere Server.",
|
||||
"pricing.enterprise.subject": "Enterprise-Angebot",
|
||||
"pricing.enterpriseBand.cta": "Kontakt aufnehmen",
|
||||
"pricing.enterpriseBand.text": "Volumen, dedizierte Engines, On-Premise-Optionen — sprechen wir darüber.",
|
||||
"pricing.error.server": "Serverfehler {status}",
|
||||
"pricing.freeBand.cta": "Kostenlos starten",
|
||||
"pricing.freeBand.text": "Nur ausprobieren? Kostenlos starten — 5 Dokumente pro Monat, ohne Karte.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"providerTheme.openrouter.desc": "Einheitlicher Zugriff auf die besten Open-Source-Modelle, optimiert für Übersetzung.",
|
||||
"providerTheme.openrouter_premium.badge": "Ultra",
|
||||
"providerTheme.openrouter_premium.subBadge": "Maximaler Kontext",
|
||||
"providerTheme.openrouter_premium.desc": "Unterstützt durch modernste Modelle (GPT-4o, Claude Sonnet 4.6) für lange Dokumente.",
|
||||
"providerTheme.openrouter_premium.desc": "Unterstützt durch modernste Modelle (GPT-4o, Claude 5) für lange Dokumente.",
|
||||
"providerTheme.zai.badge": "Spezialisiert",
|
||||
"providerTheme.zai.subBadge": "Finanzen & Recht",
|
||||
"providerTheme.zai.desc": "Modell feinabgestimmt auf anspruchsvolle Geschäftsterminologien (Recht, Finanzen).",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"admin.nav.providers": "Providers",
|
||||
"admin.nav.system": "System",
|
||||
"admin.nav.logs": "Logs",
|
||||
"admin.nav.stats": "Statistics",
|
||||
"admin.users.title": "User Management",
|
||||
"admin.users.subtitle": "View and manage user accounts",
|
||||
"admin.users.planUpdated": "Plan updated",
|
||||
@@ -44,5 +45,105 @@
|
||||
"admin.system.waitingData": "Waiting for data...",
|
||||
"admin.system.purging": "Purging...",
|
||||
"admin.system.clean": "Clean",
|
||||
"admin.system.purge": "Purge"
|
||||
"admin.system.purge": "Purge",
|
||||
"admin.nav.models": "Models & subscriptions",
|
||||
"admin.nav.marketing": "Marketing",
|
||||
"admin.marketing.audience": "Audience",
|
||||
"admin.marketing.audienceAllUsers": "All accounts",
|
||||
"admin.marketing.audienceInactive": "Accounts inactive for 30 days",
|
||||
"admin.marketing.audiencePlaceholder": "Choose an audience",
|
||||
"admin.marketing.audiencePlanBusiness": "Business plan",
|
||||
"admin.marketing.audiencePlanEnterprise": "Enterprise plan",
|
||||
"admin.marketing.audiencePlanFree": "Free plan",
|
||||
"admin.marketing.audiencePlanPro": "Pro plan",
|
||||
"admin.marketing.audiencePlanStarter": "Starter plan",
|
||||
"admin.marketing.audienceWaitlist": "Waitlist",
|
||||
"admin.marketing.badgeReal": "real",
|
||||
"admin.marketing.badgeTest": "test",
|
||||
"admin.marketing.confirmSend": "Send this email to {count} recipient(s) (“{audience}”)?",
|
||||
"admin.marketing.footerNote": "A footer with the unsubscribe link is automatically added when sending.",
|
||||
"admin.marketing.hidePreview": "Hide preview",
|
||||
"admin.marketing.history": "Send history",
|
||||
"admin.marketing.historyFailedSuffix": ", {count} failed",
|
||||
"admin.marketing.historySent": "{sent}/{total} sent",
|
||||
"admin.marketing.html": "HTML content",
|
||||
"admin.marketing.htmlPlaceholder": "<html><body>…</body></html>",
|
||||
"admin.marketing.incompleteDesc": "Please fill in the subject and the HTML.",
|
||||
"admin.marketing.incompleteTitle": "Incomplete content",
|
||||
"admin.marketing.loadErrorTitle": "Loading error",
|
||||
"admin.marketing.localCheckUnavailable": "Local check unavailable in this browser: the server will verify the prior test send.",
|
||||
"admin.marketing.networkDesc": "Cannot reach the backend.",
|
||||
"admin.marketing.newCampaign": "New campaign",
|
||||
"admin.marketing.noHistory": "No send recorded.",
|
||||
"admin.marketing.preview": "Preview",
|
||||
"admin.marketing.previewEmpty": "Preview: paste your HTML next to this panel.",
|
||||
"admin.marketing.previewTitle": "Email preview",
|
||||
"admin.marketing.recipientsAvailable": "{count} recipient(s) available after excluding unsubscribed contacts.",
|
||||
"admin.marketing.sendReal": "Send to {count} recipient(s)",
|
||||
"admin.marketing.sendRefusedTitle": "Send refused",
|
||||
"admin.marketing.sendTest": "Send test",
|
||||
"admin.marketing.sendingDesc": "{count} email(s) queued for sending (0.2 s interval). Unsubscribed contacts are excluded.",
|
||||
"admin.marketing.sendingTitle": "Send in progress",
|
||||
"admin.marketing.subject": "Subject",
|
||||
"admin.marketing.subjectPlaceholder": "E.g.: Your translation is waiting — 20% off for 7 days",
|
||||
"admin.marketing.subtitle": "Send emails to an audience, with a mandatory test send, automatic unsubscribe link and full history.",
|
||||
"admin.marketing.testDoneDesc": "Email received at {email}",
|
||||
"admin.marketing.testDoneTitle": "Test send done",
|
||||
"admin.marketing.testEmail": "Test email (optional)",
|
||||
"admin.marketing.testEmailPlaceholder": "Otherwise: SMTP sender address",
|
||||
"admin.marketing.testFailedTitle": "Test send failed",
|
||||
"admin.marketing.testRequiredHint": "A real send requires a prior test send of this exact content (verified by the server).",
|
||||
"admin.marketing.testValidated": "Test send validated for this content.",
|
||||
"admin.marketing.title": "Marketing — email campaigns",
|
||||
"admin.marketing.unsubscribedNote": "{count} unsubscribed contact(s) will be excluded from any send.",
|
||||
"admin.models.addModel": "Add a model",
|
||||
"admin.models.businessBadge": "Business plan",
|
||||
"admin.models.cancel": "Cancel",
|
||||
"admin.models.catalog": "OpenRouter catalog",
|
||||
"admin.models.catalogErrorDesc": "Failed to load the OpenRouter catalog.",
|
||||
"admin.models.catalogErrorTitle": "Catalog unavailable",
|
||||
"admin.models.customBadge": "Custom tier",
|
||||
"admin.models.customNote": "Bespoke models, agreed case by case with the customer.",
|
||||
"admin.models.defaultBadge": "default",
|
||||
"admin.models.emptyTier": "No model: the plan's official range will be used.",
|
||||
"admin.models.enterpriseBadge": "Enterprise plan",
|
||||
"admin.models.essentialBadge": "Essential AI tier",
|
||||
"admin.models.essentialCost": "— billed at cost factor 1",
|
||||
"admin.models.fallbackFirst": "(fallback #1)",
|
||||
"admin.models.loadErrorDesc": "HTTP {status} — check your admin token.",
|
||||
"admin.models.loadErrorTitle": "Loading error",
|
||||
"admin.models.matrixDesc": "Active models per tier — the list order is the fallback priority. The radio button picks the default model, applied immediately without redeployment.",
|
||||
"admin.models.matrixTitle": "Model matrix by plan",
|
||||
"admin.models.missingConfig": "AI tier configuration not found. Reload the page.",
|
||||
"admin.models.moveDown": "Move down",
|
||||
"admin.models.moveUp": "Move up",
|
||||
"admin.models.networkDesc": "Cannot reach the backend.",
|
||||
"admin.models.networkTitle": "Network error",
|
||||
"admin.models.premiumBadge": "Premium AI tier",
|
||||
"admin.models.premiumCost": "— billed at cost factor 5",
|
||||
"admin.models.premiumReservedNote": "Reserved for Business and Enterprise plans (“openrouter_premium” engine).",
|
||||
"admin.models.proBadge": "Pro plan",
|
||||
"admin.models.remove": "Remove",
|
||||
"admin.models.save": "Save",
|
||||
"admin.models.saveErrorTitle": "Save error",
|
||||
"admin.models.saveNetworkDesc": "Could not save the configuration.",
|
||||
"admin.models.savedDesc": "The new default model applies from the next translation, without redeployment.",
|
||||
"admin.models.savedTitle": "Models saved",
|
||||
"admin.models.saving": "Saving...",
|
||||
"admin.models.setDefault": "Set {model} as default model",
|
||||
"admin.models.sharedTierNote": "Shared tier: the Business plan also uses the Essential tier for its “openrouter” engine — the list above applies to both plans.",
|
||||
"admin.models.subtitle": "Plans → AI tiers matrix. The model actually used per translation is the plan's tier model: a Pro plan can never trigger a Premium model.",
|
||||
"admin.models.title": "Models & subscriptions",
|
||||
"admin.stats.aiTiers": "AI tiers (30 d)",
|
||||
"admin.stats.includingCredits": "including {amount} in credits",
|
||||
"admin.stats.mrr": "Estimated MRR",
|
||||
"admin.stats.noTranslationsYet": "No translation stored in the last 30 days: the AI tier breakdown will fill in with upcoming translations.",
|
||||
"admin.stats.payments30": "{count} payment(s) in 30 days",
|
||||
"admin.stats.refreshing": "Refreshing...",
|
||||
"admin.stats.revenue30": "Revenue (30 days)",
|
||||
"admin.stats.revenueTotal": "Total collected revenue",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
"landing.pricing.pro.desc": "For demanding professionals",
|
||||
"landing.pricing.pro.f1": "200 documents / month",
|
||||
"landing.pricing.pro.f2": "Up to 200 pages per doc",
|
||||
"landing.pricing.pro.f3": "AI-powered translation",
|
||||
"landing.pricing.pro.f3": "Essential AI: DeepSeek V4 Flash, GLM-5.3 Flash, MiniMax M3",
|
||||
"landing.pricing.pro.f4": "Google included",
|
||||
"landing.pricing.pro.f5": "Custom glossaries & prompts",
|
||||
"landing.pricing.pro.f6": "Priority support",
|
||||
@@ -109,7 +109,7 @@
|
||||
"landing.pricing.business.desc": "For teams with high-volume needs",
|
||||
"landing.pricing.business.f1": "1,000 documents / month",
|
||||
"landing.pricing.business.f2": "Up to 500 pages per doc",
|
||||
"landing.pricing.business.f3": "Premium AI (Claude)",
|
||||
"landing.pricing.business.f3": "Premium AI (Claude 5)",
|
||||
"landing.pricing.business.f4": "All providers + API access",
|
||||
"landing.pricing.business.f5": "Webhooks & automation",
|
||||
"landing.pricing.business.f6": "5 team seats",
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"pricing.plans.pro.feat8": "90-day history",
|
||||
"pricing.plans.business.feat1": "1,000 documents / month",
|
||||
"pricing.plans.business.feat2": "Up to 500 pages per document",
|
||||
"pricing.plans.business.feat3": "Essential + Premium AI (Claude Haiku)",
|
||||
"pricing.plans.business.feat3": "Essential AI + Premium AI (Claude 5)",
|
||||
"pricing.plans.business.feat4": "All translation providers",
|
||||
"pricing.plans.business.feat5": "Files up to 50 MB",
|
||||
"pricing.plans.business.feat6": "API access (10,000 calls/month)",
|
||||
@@ -50,7 +50,7 @@
|
||||
"pricing.plans.business.feat9": "1-year history",
|
||||
"pricing.plans.business.feat10": "Advanced analytics",
|
||||
"pricing.plans.enterprise.feat1": "Unlimited documents",
|
||||
"pricing.plans.enterprise.feat2": "All AI models (GPT-5, Claude Opus 4.6…)",
|
||||
"pricing.plans.enterprise.feat2": "All AI models (Claude 5, DeepSeek V4 Pro, GLM-5.3…)",
|
||||
"pricing.plans.enterprise.feat3": "On-premise or dedicated cloud deployment",
|
||||
"pricing.plans.enterprise.feat4": "99.9% SLA guaranteed",
|
||||
"pricing.plans.enterprise.feat5": "24/7 dedicated support",
|
||||
@@ -108,19 +108,19 @@
|
||||
"pricing.trust.parallel.sub": "Ultra-fast multi-threaded AI",
|
||||
"pricing.trust.availability.title": "Available 24/7",
|
||||
"pricing.trust.availability.sub": "99.9% guaranteed uptime",
|
||||
"pricing.aiModels.title": "Our AI Models — March 2026",
|
||||
"pricing.aiModels.title": "Our AI Models — September 2026",
|
||||
"pricing.aiModels.essential.title": "Essential AI Translation",
|
||||
"pricing.aiModels.essential.plan": "Pro Plan",
|
||||
"pricing.aiModels.essential.descPrefix": "Based on",
|
||||
"pricing.aiModels.essential.descSuffix": "— the most cost-effective AI model of 2026. Quality comparable to frontier models at a fraction of the cost.",
|
||||
"pricing.aiModels.essential.modelName": "our Essential AI model",
|
||||
"pricing.aiModels.essential.context": "163K tokens of context",
|
||||
"pricing.aiModels.essential.value": "Excellent value for money",
|
||||
"pricing.aiModels.essential.descSuffix": "— the most cost-effective AI models of 2026.",
|
||||
"pricing.aiModels.essential.modelName": "DeepSeek V4 Flash, GLM-5.3 Flash and MiniMax M3",
|
||||
"pricing.aiModels.essential.context": "Up to 1.3M context (GLM-5.3 Flash)",
|
||||
"pricing.aiModels.essential.value": "The best value for money",
|
||||
"pricing.aiModels.premium.title": "Premium AI Translation",
|
||||
"pricing.aiModels.premium.plan": "Business Plan",
|
||||
"pricing.aiModels.premium.descPrefix": "Based on",
|
||||
"pricing.aiModels.premium.descSuffix": "by Anthropic — accurate on legal, medical and complex technical documents.",
|
||||
"pricing.aiModels.premium.context": "200K tokens of context",
|
||||
"pricing.aiModels.premium.context": "1M context",
|
||||
"pricing.aiModels.premium.precision": "Best accuracy",
|
||||
"pricing.faq.title": "Frequently asked questions",
|
||||
"pricing.faq.q1": "Can I change plans at any time?",
|
||||
@@ -128,7 +128,7 @@
|
||||
"pricing.faq.q2": "What is \\\"Essential AI Translation\\\"?",
|
||||
"pricing.faq.a2": "It's our AI engine. It understands your documents' context, preserves layout and handles technical terms much better than classic translation.",
|
||||
"pricing.faq.q3": "What's the difference between Essential and Premium AI?",
|
||||
"pricing.faq.a3": "Essential AI uses an optimized model (excellent value for money). Premium AI uses Anthropic's Claude Sonnet 4.6, more accurate on legal, medical and complex technical documents.",
|
||||
"pricing.faq.a3": "Essential AI runs on DeepSeek V4 Flash, GLM-5.3 Flash and MiniMax M3 (excellent value for money). Premium AI uses Anthropic's Claude 5, more accurate on legal, medical and complex technical documents.",
|
||||
"pricing.faq.q4": "Are my documents kept after translation?",
|
||||
"pricing.faq.a4": "Translated files are available according to your plan (30 days Starter, 90 days Pro, 1 year Business). They are encrypted at rest and in transit.",
|
||||
"pricing.faq.q5": "What happens if I exceed my monthly quota?",
|
||||
@@ -163,5 +163,9 @@
|
||||
"pricing.freeBand.text": "Just want to try? Start free — 5 documents per month, no card required.",
|
||||
"pricing.freeBand.cta": "Start free",
|
||||
"pricing.enterpriseBand.text": "Volume, dedicated engines, on-premise options — let's talk.",
|
||||
"pricing.enterpriseBand.cta": "Contact us"
|
||||
"pricing.enterpriseBand.cta": "Contact us",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"providerTheme.openrouter.desc": "Unified access to the best open-source models optimized for translation.",
|
||||
"providerTheme.openrouter_premium.badge": "Ultra",
|
||||
"providerTheme.openrouter_premium.subBadge": "Maximum context",
|
||||
"providerTheme.openrouter_premium.desc": "Assisted by state-of-the-art models (GPT-4o, Claude Sonnet 4.6) for long documents.",
|
||||
"providerTheme.openrouter_premium.desc": "Assisted by state-of-the-art models (GPT-4o, Claude 5) for long documents.",
|
||||
"providerTheme.zai.badge": "Specialized",
|
||||
"providerTheme.zai.subBadge": "Finance & Law",
|
||||
"providerTheme.zai.desc": "Model fine-tuned for demanding business terminologies (legal, finance).",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"admin.nav.providers": "Proveedores",
|
||||
"admin.nav.system": "Sistema",
|
||||
"admin.nav.logs": "Registros",
|
||||
"admin.nav.stats": "Estadísticas",
|
||||
"admin.users.title": "Gestión de Usuarios",
|
||||
"admin.users.subtitle": "Ver y gestionar cuentas de usuario",
|
||||
"admin.users.planUpdated": "Plan actualizado",
|
||||
@@ -44,5 +45,105 @@
|
||||
"admin.system.waitingData": "Esperando datos...",
|
||||
"admin.system.purging": "Purgando...",
|
||||
"admin.system.clean": "Limpiar",
|
||||
"admin.system.purge": "Purgar"
|
||||
"admin.system.purge": "Purgar",
|
||||
"admin.nav.models": "Modelos y suscripciones",
|
||||
"admin.nav.marketing": "Marketing",
|
||||
"admin.marketing.audience": "Audiencia",
|
||||
"admin.marketing.audienceAllUsers": "Todas las cuentas",
|
||||
"admin.marketing.audienceInactive": "Cuentas inactivas desde hace 30 días",
|
||||
"admin.marketing.audiencePlaceholder": "Elegir una audiencia",
|
||||
"admin.marketing.audiencePlanBusiness": "Plan Business",
|
||||
"admin.marketing.audiencePlanEnterprise": "Plan Enterprise",
|
||||
"admin.marketing.audiencePlanFree": "Plan Gratuito",
|
||||
"admin.marketing.audiencePlanPro": "Plan Pro",
|
||||
"admin.marketing.audiencePlanStarter": "Plan Starter",
|
||||
"admin.marketing.audienceWaitlist": "Lista de espera",
|
||||
"admin.marketing.badgeReal": "real",
|
||||
"admin.marketing.badgeTest": "prueba",
|
||||
"admin.marketing.confirmSend": "¿Enviar este correo a {count} destinatario(s) («{audience}»)?",
|
||||
"admin.marketing.footerNote": "Un pie de página con el enlace de baja se añade automáticamente al enviar.",
|
||||
"admin.marketing.hidePreview": "Ocultar vista previa",
|
||||
"admin.marketing.history": "Historial de envíos",
|
||||
"admin.marketing.historyFailedSuffix": ", {count} fallos",
|
||||
"admin.marketing.historySent": "{sent}/{total} enviados",
|
||||
"admin.marketing.html": "Contenido HTML",
|
||||
"admin.marketing.htmlPlaceholder": "<html><body>…</body></html>",
|
||||
"admin.marketing.incompleteDesc": "Rellena el asunto y el HTML.",
|
||||
"admin.marketing.incompleteTitle": "Contenido incompleto",
|
||||
"admin.marketing.loadErrorTitle": "Error de carga",
|
||||
"admin.marketing.localCheckUnavailable": "Verificación local no disponible en este navegador: el servidor controlará el envío de prueba previo.",
|
||||
"admin.marketing.networkDesc": "No se puede contactar con el backend.",
|
||||
"admin.marketing.newCampaign": "Nuevo envío",
|
||||
"admin.marketing.noHistory": "Ningún envío registrado.",
|
||||
"admin.marketing.preview": "Vista previa",
|
||||
"admin.marketing.previewEmpty": "Vista previa: pega tu HTML al lado.",
|
||||
"admin.marketing.previewTitle": "Vista previa del correo",
|
||||
"admin.marketing.recipientsAvailable": "{count} destinatario(s) disponibles tras excluir las bajas.",
|
||||
"admin.marketing.sendReal": "Enviar a {count} destinatario(s)",
|
||||
"admin.marketing.sendRefusedTitle": "Envío rechazado",
|
||||
"admin.marketing.sendTest": "Envío de prueba",
|
||||
"admin.marketing.sendingDesc": "{count} correo(s) en cola de envío (intervalo de 0,2 s). Las bajas quedan excluidas.",
|
||||
"admin.marketing.sendingTitle": "Envío en curso",
|
||||
"admin.marketing.subject": "Asunto",
|
||||
"admin.marketing.subjectPlaceholder": "Ej.: Tu traducción te espera — 20 % durante 7 días",
|
||||
"admin.marketing.subtitle": "Envío de correos a una audiencia, con envío de prueba obligatorio, enlace de baja automático e historial completo.",
|
||||
"admin.marketing.testDoneDesc": "Correo recibido en {email}",
|
||||
"admin.marketing.testDoneTitle": "Envío de prueba realizado",
|
||||
"admin.marketing.testEmail": "Correo de prueba (opcional)",
|
||||
"admin.marketing.testEmailPlaceholder": "Si no: dirección remitente SMTP",
|
||||
"admin.marketing.testFailedTitle": "Fallo del envío de prueba",
|
||||
"admin.marketing.testRequiredHint": "El envío real exige un envío de prueba previo de este contenido exacto (verificado por el servidor).",
|
||||
"admin.marketing.testValidated": "Envío de prueba validado para este contenido.",
|
||||
"admin.marketing.title": "Marketing — envíos de correo",
|
||||
"admin.marketing.unsubscribedNote": "{count} contacto(s) de baja serán excluidos de todo envío.",
|
||||
"admin.models.addModel": "Añadir un modelo",
|
||||
"admin.models.businessBadge": "Plan Business",
|
||||
"admin.models.cancel": "Cancelar",
|
||||
"admin.models.catalog": "Catálogo de OpenRouter",
|
||||
"admin.models.catalogErrorDesc": "No se pudo cargar el catálogo de OpenRouter.",
|
||||
"admin.models.catalogErrorTitle": "Catálogo no disponible",
|
||||
"admin.models.customBadge": "Nivel personalizado",
|
||||
"admin.models.customNote": "Modelos a medida, definidos caso por caso con el cliente.",
|
||||
"admin.models.defaultBadge": "por defecto",
|
||||
"admin.models.emptyTier": "Sin modelos: se usará la gama oficial del plan.",
|
||||
"admin.models.enterpriseBadge": "Plan Enterprise",
|
||||
"admin.models.essentialBadge": "Nivel de IA Esencial",
|
||||
"admin.models.essentialCost": "— facturado con factor de coste 1",
|
||||
"admin.models.fallbackFirst": "(respaldo n.º 1)",
|
||||
"admin.models.loadErrorDesc": "HTTP {status} — comprueba tu token de administrador.",
|
||||
"admin.models.loadErrorTitle": "Error de carga",
|
||||
"admin.models.matrixDesc": "Modelos activos por nivel — el orden de la lista es la prioridad de respaldo. El botón de opción elige el modelo por defecto, aplicado al instante sin redespliegue.",
|
||||
"admin.models.matrixTitle": "Matriz de modelos por plan",
|
||||
"admin.models.missingConfig": "Configuración de niveles de IA no encontrada. Recarga la página.",
|
||||
"admin.models.moveDown": "Bajar",
|
||||
"admin.models.moveUp": "Subir",
|
||||
"admin.models.networkDesc": "No se puede contactar con el backend.",
|
||||
"admin.models.networkTitle": "Error de red",
|
||||
"admin.models.premiumBadge": "Nivel de IA Premium",
|
||||
"admin.models.premiumCost": "— facturado con factor de coste 5",
|
||||
"admin.models.premiumReservedNote": "Reservado a los planes Business y Enterprise (motor «openrouter_premium»).",
|
||||
"admin.models.proBadge": "Plan Pro",
|
||||
"admin.models.remove": "Quitar",
|
||||
"admin.models.save": "Guardar",
|
||||
"admin.models.saveErrorTitle": "Error al guardar",
|
||||
"admin.models.saveNetworkDesc": "No se pudo guardar la configuración.",
|
||||
"admin.models.savedDesc": "El nuevo modelo por defecto se usa desde la siguiente traducción, sin redespliegue.",
|
||||
"admin.models.savedTitle": "Modelos guardados",
|
||||
"admin.models.saving": "Guardando...",
|
||||
"admin.models.setDefault": "Definir {model} como modelo por defecto",
|
||||
"admin.models.sharedTierNote": "Nivel compartido: el plan Business también usa el nivel Esencial con su motor «openrouter» — la lista de arriba se aplica a ambos planes.",
|
||||
"admin.models.subtitle": "Matriz planes → niveles de IA. El modelo realmente usado en cada traducción es el del nivel del plan: un plan Pro nunca puede disparar un modelo Premium.",
|
||||
"admin.models.title": "Modelos y suscripciones",
|
||||
"admin.stats.aiTiers": "Niveles de IA (30 d)",
|
||||
"admin.stats.includingCredits": "incluidos {amount} en créditos",
|
||||
"admin.stats.mrr": "MRR estimado",
|
||||
"admin.stats.noTranslationsYet": "Sin traducciones registradas en los últimos 30 días: el reparto por niveles se completará con las próximas traducciones.",
|
||||
"admin.stats.payments30": "{count} pago(s) en 30 días",
|
||||
"admin.stats.refreshing": "Actualizando...",
|
||||
"admin.stats.revenue30": "Ingresos (30 días)",
|
||||
"admin.stats.revenueTotal": "Ingresos cobrados (total)",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
"landing.pricing.pro.desc": "Para profesionales exigentes",
|
||||
"landing.pricing.pro.f1": "200 documentos / mes",
|
||||
"landing.pricing.pro.f2": "Hasta 200 páginas por doc",
|
||||
"landing.pricing.pro.f3": "Traducción con IA",
|
||||
"landing.pricing.pro.f3": "IA Esencial: DeepSeek V4 Flash, GLM-5.3 Flash, MiniMax M3",
|
||||
"landing.pricing.pro.f4": "Google incluidos",
|
||||
"landing.pricing.pro.f5": "Glosarios y prompts personalizados",
|
||||
"landing.pricing.pro.f6": "Soporte prioritario",
|
||||
@@ -109,7 +109,7 @@
|
||||
"landing.pricing.business.desc": "Para equipos con grandes necesidades",
|
||||
"landing.pricing.business.f1": "1 000 documentos / mes",
|
||||
"landing.pricing.business.f2": "Hasta 500 páginas por doc",
|
||||
"landing.pricing.business.f3": "IA Premium (Claude)",
|
||||
"landing.pricing.business.f3": "IA Premium (Claude 5)",
|
||||
"landing.pricing.business.f4": "Todos los proveedores + API",
|
||||
"landing.pricing.business.f5": "Webhooks y automatización",
|
||||
"landing.pricing.business.f6": "5 puestos de equipo",
|
||||
@@ -142,5 +142,31 @@
|
||||
"landing.translate.supportedFormats": "Archivos DOCX, XLSX, PPTX o PDF compatibles",
|
||||
"landing.translate.aiAnalysis": "Análisis IA Activo",
|
||||
"landing.translate.processing": "Procesando",
|
||||
"landing.translate.preservingLayout": "Tu diseño se está preservando"
|
||||
"landing.translate.preservingLayout": "Tu diseño se está preservando",
|
||||
"landing.beforeAfter.seal": "El mismo diseño, palabra por palabra",
|
||||
"landing.beforeAfter.proof1": "Los diagramas SmartArt se reconstruyen, no se aplanan",
|
||||
"landing.beforeAfter.proof2": "Series y ejes de los gráficos traducidos",
|
||||
"landing.beforeAfter.proof3": "Índices regenerados en el idioma de destino",
|
||||
"landing.beforeAfter.targetTitle": "Pliego técnico — Tratamiento de aire",
|
||||
"landing.beforeAfter.targetPara1": "La instalación de ventilación debe ponerse en servicio antes de finales del segundo trimestre.",
|
||||
"landing.beforeAfter.targetPara2Before": "La",
|
||||
"landing.beforeAfter.targetTerm": "unidad de tratamiento de aire",
|
||||
"landing.beforeAfter.targetPara2After": "debe cumplir la clase de filtro F7 según el calendario.",
|
||||
"landing.beforeAfter.targetItem1": "Carga térmica: 42 kW a caudal nominal",
|
||||
"landing.beforeAfter.targetItem2": "Nivel sonoro inferior a 45 dB(A) a 3 m",
|
||||
"landing.formats.pill": "COMPATIBILIDAD",
|
||||
"landing.hero.visualCaption": "El mismo diseño, otro idioma — nada más cambia",
|
||||
"landing.pricing.free.name": "Gratis",
|
||||
"landing.pricing.free.desc": "Ideal para descubrir la aplicación",
|
||||
"landing.pricing.free.cta": "Elegir este plan",
|
||||
"landing.pricing.enterprise.name": "Empresas",
|
||||
"landing.pricing.enterprise.desc": "Soluciones a medida para grandes organizaciones",
|
||||
"landing.pricing.enterprise.cta": "Contáctenos",
|
||||
"landing.beforeAfter.sourceTitle": "Cahier des charges — Traitement d'air",
|
||||
"landing.beforeAfter.sourcePara1": "L'installation de ventilation doit être mise en service avant la fin du deuxième trimestre.",
|
||||
"landing.beforeAfter.sourcePara2Before": "Le",
|
||||
"landing.beforeAfter.sourceTerm": "groupe de traitement d'air",
|
||||
"landing.beforeAfter.sourcePara2After": "doit respecter la classe de filtration F7 conformément au planning.",
|
||||
"landing.beforeAfter.sourceItem1": "Charge thermique : 42 kW au débit nominal",
|
||||
"landing.beforeAfter.sourceItem2": "Niveau sonore inférieur à 45 dB(A) à 3 m"
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"pricing.plans.pro.feat8": "Historial de 90 días",
|
||||
"pricing.plans.business.feat1": "1 000 documentos / mes",
|
||||
"pricing.plans.business.feat2": "Hasta 500 páginas por documento",
|
||||
"pricing.plans.business.feat3": "IA Esencial + Premium (Claude Haiku)",
|
||||
"pricing.plans.business.feat3": "IA Esencial + IA Premium (Claude 5)",
|
||||
"pricing.plans.business.feat4": "Todos los proveedores de traducción",
|
||||
"pricing.plans.business.feat5": "Archivos de hasta 50 MB",
|
||||
"pricing.plans.business.feat6": "Acceso API (10 000 llamadas/mes)",
|
||||
@@ -50,7 +50,7 @@
|
||||
"pricing.plans.business.feat9": "Historial de 1 año",
|
||||
"pricing.plans.business.feat10": "Analíticas avanzadas",
|
||||
"pricing.plans.enterprise.feat1": "Documentos ilimitados",
|
||||
"pricing.plans.enterprise.feat2": "Todos los modelos de IA (GPT-5, Claude Opus 4.6…)",
|
||||
"pricing.plans.enterprise.feat2": "Todos los modelos de IA (Claude 5, DeepSeek V4 Pro, GLM-5.3…)",
|
||||
"pricing.plans.enterprise.feat3": "Implementación local o en nube dedicada",
|
||||
"pricing.plans.enterprise.feat4": "SLA 99,9 % garantizado",
|
||||
"pricing.plans.enterprise.feat5": "Soporte dedicado 24/7",
|
||||
@@ -108,19 +108,19 @@
|
||||
"pricing.trust.parallel.sub": "IA multiproceso ultrarrápida",
|
||||
"pricing.trust.availability.title": "Disponible 24/7",
|
||||
"pricing.trust.availability.sub": "Disponibilidad garantizada del 99,9 %",
|
||||
"pricing.aiModels.title": "Nuestros modelos de IA — Marzo 2026",
|
||||
"pricing.aiModels.title": "Nuestros modelos de IA — Septiembre 2026",
|
||||
"pricing.aiModels.essential.title": "Traducción IA Esencial",
|
||||
"pricing.aiModels.essential.plan": "Plan Pro",
|
||||
"pricing.aiModels.essential.descPrefix": "Basado en",
|
||||
"pricing.aiModels.essential.descSuffix": "— el modelo de IA más rentable de 2026. Calidad comparable a los modelos frontier a una fracción del coste.",
|
||||
"pricing.aiModels.essential.modelName": "nuestro modelo IA Esencial",
|
||||
"pricing.aiModels.essential.context": "163K tokens de contexto",
|
||||
"pricing.aiModels.essential.value": "Excelente relación calidad-precio",
|
||||
"pricing.aiModels.essential.descSuffix": "— los modelos de IA con la mejor relación calidad-precio de 2026.",
|
||||
"pricing.aiModels.essential.modelName": "DeepSeek V4 Flash, GLM-5.3 Flash y MiniMax M3",
|
||||
"pricing.aiModels.essential.context": "Hasta 1,3 M de contexto (GLM-5.3 Flash)",
|
||||
"pricing.aiModels.essential.value": "La mejor relación calidad-precio",
|
||||
"pricing.aiModels.premium.title": "Traducción IA Premium",
|
||||
"pricing.aiModels.premium.plan": "Plan Business",
|
||||
"pricing.aiModels.premium.descPrefix": "Basado en",
|
||||
"pricing.aiModels.premium.descSuffix": "de Anthropic — preciso en documentos jurídicos, médicos y técnicos complejos.",
|
||||
"pricing.aiModels.premium.context": "200K tokens de contexto",
|
||||
"pricing.aiModels.premium.context": "1M de contexto",
|
||||
"pricing.aiModels.premium.precision": "Máxima precisión",
|
||||
"pricing.faq.title": "Preguntas frecuentes",
|
||||
"pricing.faq.q1": "¿Puedo cambiar de plan en cualquier momento?",
|
||||
@@ -128,7 +128,7 @@
|
||||
"pricing.faq.q2": "¿Qué es la «Traducción IA Esencial»?",
|
||||
"pricing.faq.a2": "Es nuestro motor de IA. Comprende el contexto de tus documentos, conserva el diseño y maneja términos técnicos mucho mejor que una traducción clásica.",
|
||||
"pricing.faq.q3": "¿Cuál es la diferencia entre IA Esencial e IA Premium?",
|
||||
"pricing.faq.a3": "La IA Esencial usa un modelo optimizado (excelente relación calidad-precio). La IA Premium usa Claude 3.5 Haiku de Anthropic, más precisa en documentos jurídicos, médicos y técnicos complejos.",
|
||||
"pricing.faq.a3": "La IA Esencial se apoya en DeepSeek V4 Flash, GLM-5.3 Flash y MiniMax M3 (excelente relación calidad-precio). La IA Premium usa Claude 5 de Anthropic, más precisa en documentos jurídicos, médicos y técnicos complejos.",
|
||||
"pricing.faq.q4": "¿Se conservan mis documentos después de la traducción?",
|
||||
"pricing.faq.a4": "Los archivos traducidos están disponibles según tu plan (30 días Starter, 90 días Pro, 1 año Business). Están cifrados en reposo y en tránsito.",
|
||||
"pricing.faq.q5": "¿Qué ocurre si supero mi cuota mensual?",
|
||||
@@ -146,5 +146,26 @@
|
||||
"pricing.toast.paymentError": "Error al crear el pago.",
|
||||
"pricing.dashboard": "Panel",
|
||||
"pricing.okSymbol": "✓",
|
||||
"pricing.errSymbol": "✕"
|
||||
"pricing.errSymbol": "✕",
|
||||
"pricing.aiModels.essential.price": "Desde 0,09 $ por millón de tokens",
|
||||
"pricing.aiModels.premium.modelName": "Claude 5",
|
||||
"pricing.aiModels.premium.alternatives": "Alternativas: DeepSeek V4 Pro y GLM-5.3",
|
||||
"pricing.confirm.title": "Confirma tu suscripción",
|
||||
"pricing.confirm.subtitle": "Serás redirigido a nuestro proveedor de pago seguro.",
|
||||
"pricing.confirm.cancel": "Cancelar",
|
||||
"pricing.confirm.cta": "Continuar al pago",
|
||||
"pricing.confirm.year": "año",
|
||||
"pricing.confirm.month": "mes",
|
||||
"pricing.confirm.monthlyEquivalent": "Facturación anual — equivalente a {price} € / mes.",
|
||||
"pricing.confirm.secureNote": "Cancelable en cualquier momento desde tu perfil. El pago lo procesa Stripe; los datos de tu tarjeta nunca pasan por nuestros servidores.",
|
||||
"pricing.enterprise.subject": "Consulta Enterprise",
|
||||
"pricing.enterpriseBand.cta": "Contáctenos",
|
||||
"pricing.enterpriseBand.text": "Volumen, motores dedicados, opciones on-premise — hablemos.",
|
||||
"pricing.error.server": "Error del servidor {status}",
|
||||
"pricing.freeBand.cta": "Empieza gratis",
|
||||
"pricing.freeBand.text": "¿Solo quieres probar? Empieza gratis — 5 documentos al mes, sin tarjeta.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"providerTheme.openrouter.desc": "Acceso unificado a los mejores modelos de código abierto optimizados para traducción.",
|
||||
"providerTheme.openrouter_premium.badge": "Ultra",
|
||||
"providerTheme.openrouter_premium.subBadge": "Contexto máximo",
|
||||
"providerTheme.openrouter_premium.desc": "Asistido por modelos de última generación (GPT-4o, Claude Sonnet 4.6) para documentos largos.",
|
||||
"providerTheme.openrouter_premium.desc": "Asistido por modelos de última generación (GPT-4o, Claude 5) para documentos largos.",
|
||||
"providerTheme.zai.badge": "Especializada",
|
||||
"providerTheme.zai.subBadge": "Finanzas y Derecho",
|
||||
"providerTheme.zai.desc": "Modelo ajustado para terminologías empresariales exigentes (legal, finanzas).",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"admin.nav.providers": "ارائهدهندگان",
|
||||
"admin.nav.system": "سیستم",
|
||||
"admin.nav.logs": "گزارشها",
|
||||
"admin.nav.stats": "آمار",
|
||||
"admin.users.title": "مدیریت کاربران",
|
||||
"admin.users.subtitle": "مشاهده و مدیریت حسابهای کاربری",
|
||||
"admin.users.planUpdated": "طرح بهروز شد",
|
||||
@@ -44,5 +45,105 @@
|
||||
"admin.system.waitingData": "در انتظار داده...",
|
||||
"admin.system.purging": "در حال پاکسازی...",
|
||||
"admin.system.clean": "پاکسازی",
|
||||
"admin.system.purge": "حذف کامل"
|
||||
"admin.system.purge": "حذف کامل",
|
||||
"admin.nav.models": "مدلها و اشتراکها",
|
||||
"admin.nav.marketing": "بازاریابی",
|
||||
"admin.marketing.audience": "مخاطب",
|
||||
"admin.marketing.audienceAllUsers": "همه حسابها",
|
||||
"admin.marketing.audienceInactive": "حسابهای غیرفعال از ۳۰ روز پیش",
|
||||
"admin.marketing.audiencePlaceholder": "انتخاب مخاطب",
|
||||
"admin.marketing.audiencePlanBusiness": "اشتراک Business",
|
||||
"admin.marketing.audiencePlanEnterprise": "اشتراک Enterprise",
|
||||
"admin.marketing.audiencePlanFree": "اشتراک رایگان",
|
||||
"admin.marketing.audiencePlanPro": "اشتراک Pro",
|
||||
"admin.marketing.audiencePlanStarter": "اشتراک Starter",
|
||||
"admin.marketing.audienceWaitlist": "فهرست انتظار",
|
||||
"admin.marketing.badgeReal": "واقعی",
|
||||
"admin.marketing.badgeTest": "آزمایشی",
|
||||
"admin.marketing.confirmSend": "این ایمیل به {count} گیرنده ارسال شود («{audience}»)؟",
|
||||
"admin.marketing.footerNote": "یک پانوشت با پیوند لغو اشتراک بهطور خودکار به ارسال افزوده میشود.",
|
||||
"admin.marketing.hidePreview": "پنهان کردن پیشنمایش",
|
||||
"admin.marketing.history": "تاریخچه ارسالها",
|
||||
"admin.marketing.historyFailedSuffix": "، {count} ناموفق",
|
||||
"admin.marketing.historySent": "{sent}/{total} ارسالشده",
|
||||
"admin.marketing.html": "محتوای HTML",
|
||||
"admin.marketing.htmlPlaceholder": "<html><body>…</body></html>",
|
||||
"admin.marketing.incompleteDesc": "موضوع و HTML را پر کنید.",
|
||||
"admin.marketing.incompleteTitle": "محتوای ناقص",
|
||||
"admin.marketing.loadErrorTitle": "خطا در بارگذاری",
|
||||
"admin.marketing.localCheckUnavailable": "بررسی محلی در این مرورگر در دسترس نیست: سرور ارسال آزمایشی قبلی را کنترل خواهد کرد.",
|
||||
"admin.marketing.networkDesc": "دسترسی به بکاند ممکن نیست.",
|
||||
"admin.marketing.newCampaign": "کمپین جدید",
|
||||
"admin.marketing.noHistory": "هیچ ارسالی ثبت نشده است.",
|
||||
"admin.marketing.preview": "پیشنمایش",
|
||||
"admin.marketing.previewEmpty": "پیشنمایش: HTML خود را در کنار این بوم بچسبانید.",
|
||||
"admin.marketing.previewTitle": "پیشنمایش ایمیل",
|
||||
"admin.marketing.recipientsAvailable": "{count} گیرنده پس از کنارگذاشتن لغواشتراکها در دسترس است.",
|
||||
"admin.marketing.sendReal": "ارسال به {count} گیرنده",
|
||||
"admin.marketing.sendRefusedTitle": "ارسال رد شد",
|
||||
"admin.marketing.sendTest": "ارسال آزمایشی",
|
||||
"admin.marketing.sendingDesc": "{count} ایمیل در صف ارسال (فاصله ۰٫۲ ثانیه). لغواشتراکها مستثنی هستند.",
|
||||
"admin.marketing.sendingTitle": "ارسال در جریان است",
|
||||
"admin.marketing.subject": "موضوع",
|
||||
"admin.marketing.subjectPlaceholder": "مثال: ترجمه شما منتظر است — ۲۰٪ به مدت ۷ روز",
|
||||
"admin.marketing.subtitle": "ارسال ایمیل به یک مخاطب، با ارسال آزمایشی الزامی، پیوند لغو اشتراک خودکار و تاریخچه کامل.",
|
||||
"admin.marketing.testDoneDesc": "ایمیل به {email} رسید",
|
||||
"admin.marketing.testDoneTitle": "ارسال آزمایشی انجام شد",
|
||||
"admin.marketing.testEmail": "ایمیل آزمایشی (اختیاری)",
|
||||
"admin.marketing.testEmailPlaceholder": "در غیر این صورت: آدرس فرستنده SMTP",
|
||||
"admin.marketing.testFailedTitle": "ارسال آزمایشی ناموفق بود",
|
||||
"admin.marketing.testRequiredHint": "ارسال واقعی نیازمند ارسال آزمایشی قبلی همین محتوا است (تأییدشده توسط سرور).",
|
||||
"admin.marketing.testValidated": "ارسال آزمایشی این محتوا تأیید شده است.",
|
||||
"admin.marketing.title": "بازاریابی — کمپینهای ایمیلی",
|
||||
"admin.marketing.unsubscribedNote": "{count} لغواشتراک از هر ارسالی مستثنی خواهند شد.",
|
||||
"admin.models.addModel": "افزودن مدل",
|
||||
"admin.models.businessBadge": "اشتراک Business",
|
||||
"admin.models.cancel": "لغو",
|
||||
"admin.models.catalog": "کاتالوگ OpenRouter",
|
||||
"admin.models.catalogErrorDesc": "بارگذاری کاتالوگ OpenRouter ممکن نشد.",
|
||||
"admin.models.catalogErrorTitle": "کاتالوگ در دسترس نیست",
|
||||
"admin.models.customBadge": "سطح سفارشی",
|
||||
"admin.models.customNote": "مدلهای سفارشی، مورد به مورد با مشتری توافق میشود.",
|
||||
"admin.models.defaultBadge": "پیشفرض",
|
||||
"admin.models.emptyTier": "بدون مدل: مجموعه رسمی اشتراک استفاده خواهد شد.",
|
||||
"admin.models.enterpriseBadge": "اشتراک Enterprise",
|
||||
"admin.models.essentialBadge": "سطح هوش مصنوعی پایه",
|
||||
"admin.models.essentialCost": "— محاسبه با ضریب هزینه ۱",
|
||||
"admin.models.fallbackFirst": "(پشتیبان شماره ۱)",
|
||||
"admin.models.loadErrorDesc": "HTTP {status} — توکن مدیر را بررسی کنید.",
|
||||
"admin.models.loadErrorTitle": "خطا در بارگذاری",
|
||||
"admin.models.matrixDesc": "مدلهای فعال هر سطح — ترتیب فهرست، اولویت پشتیبان است. دکمه رادیویی مدل پیشفرض را انتخاب میکند که بلافاصله و بدون استقرار مجدد اعمال میشود.",
|
||||
"admin.models.matrixTitle": "ماتریس مدلها به تفکیک اشتراک",
|
||||
"admin.models.missingConfig": "پیکربندی سطوح هوش مصنوعی یافت نشد. صفحه را دوباره بارگذاری کنید.",
|
||||
"admin.models.moveDown": "انتقال به پایین",
|
||||
"admin.models.moveUp": "انتقال به بالا",
|
||||
"admin.models.networkDesc": "دسترسی به بکاند ممکن نیست.",
|
||||
"admin.models.networkTitle": "خطای شبکه",
|
||||
"admin.models.premiumBadge": "سطح هوش مصنوعی پیشرفته",
|
||||
"admin.models.premiumCost": "— محاسبه با ضریب هزینه ۵",
|
||||
"admin.models.premiumReservedNote": "مخصوص اشتراکهای Business و Enterprise (موتور «openrouter_premium»).",
|
||||
"admin.models.proBadge": "اشتراک Pro",
|
||||
"admin.models.remove": "حذف",
|
||||
"admin.models.save": "ذخیره",
|
||||
"admin.models.saveErrorTitle": "خطا در ذخیره",
|
||||
"admin.models.saveNetworkDesc": "ذخیره پیکربندی ممکن نشد.",
|
||||
"admin.models.savedDesc": "مدل پیشفرض جدید از ترجمه بعدی استفاده میشود، بدون استقرار مجدد.",
|
||||
"admin.models.savedTitle": "مدلها ذخیره شدند",
|
||||
"admin.models.saving": "در حال ذخیره...",
|
||||
"admin.models.setDefault": "تنظیم {model} بهعنوان مدل پیشفرض",
|
||||
"admin.models.sharedTierNote": "سطح مشترک: اشتراک Business نیز برای موتور «openrouter» از سطح پایه استفاده میکند — فهرست بالا برای هر دو اشتراک اعمال میشود.",
|
||||
"admin.models.subtitle": "ماتریس plans → سطوح هوش مصنوعی. مدل واقعی استفادهشده در هر ترجمه، مدل سطحِ همان اشتراک است: اشتراک Pro هرگز نمیتواند مدل Premium را فعال کند.",
|
||||
"admin.models.title": "مدلها و اشتراکها",
|
||||
"admin.stats.aiTiers": "سطوح هوش مصنوعی (۳۰ روز)",
|
||||
"admin.stats.includingCredits": "شامل {amount} اعتبار",
|
||||
"admin.stats.mrr": "درآمد ماهانه تکرارشونده تخمینی",
|
||||
"admin.stats.noTranslationsYet": "هیچ ترجمهای در ۳۰ روز گذشته ثبت نشده است: تفکیک سطوح با ترجمههای بعدی کامل میشود.",
|
||||
"admin.stats.payments30": "{count} پرداخت در ۳۰ روز",
|
||||
"admin.stats.refreshing": "در حال بهروزرسانی...",
|
||||
"admin.stats.revenue30": "درآمد (۳۰ روز)",
|
||||
"admin.stats.revenueTotal": "کل درآمد دریافتشده",
|
||||
"admin.stats.tiersSub": "پایه / پیشرفته · کلاسیک {classic} · سایر {other}",
|
||||
"admin.stats.unavailable": "آمار کسبوکار در دسترس نیست ({error}).",
|
||||
"admin.stats.waitlist": "فهرست انتظار",
|
||||
"admin.stats.waitlistSub": "نفر در انتظار"
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
"landing.pricing.pro.desc": "برای متخصصان حرفهای",
|
||||
"landing.pricing.pro.f1": "۲۰۰ سند / ماه",
|
||||
"landing.pricing.pro.f2": "تا ۲۰۰ صفحه برای هر سند",
|
||||
"landing.pricing.pro.f3": "ترجمه مبتنی بر هوش مصنوعی",
|
||||
"landing.pricing.pro.f3": "هوش مصنوعی پایه: DeepSeek V4 Flash، GLM-5.3 Flash، MiniMax M3",
|
||||
"landing.pricing.pro.f4": "Google شامل است",
|
||||
"landing.pricing.pro.f5": "واژهنامه و پرامپت سفارشی",
|
||||
"landing.pricing.pro.f6": "پشتیبانی اولویتدار",
|
||||
@@ -109,7 +109,7 @@
|
||||
"landing.pricing.business.desc": "برای تیمها با نیاز بالا",
|
||||
"landing.pricing.business.f1": "۱,۰۰۰ سند / ماه",
|
||||
"landing.pricing.business.f2": "تا ۵۰۰ صفحه برای هر سند",
|
||||
"landing.pricing.business.f3": "هوش مصنوعی پیشرفته (Claude)",
|
||||
"landing.pricing.business.f3": "هوش مصنوعی پیشرفته (Claude 5)",
|
||||
"landing.pricing.business.f4": "همه ارائهدهندگان + API",
|
||||
"landing.pricing.business.f5": "وبهوک و خودکارسازی",
|
||||
"landing.pricing.business.f6": "۵ صندلی تیمی",
|
||||
@@ -142,5 +142,31 @@
|
||||
"landing.translate.supportedFormats": "فایلهای DOCX, XLSX, PPTX یا PDF پشتیبانی میشوند",
|
||||
"landing.translate.aiAnalysis": "تحلیل AI فعال",
|
||||
"landing.translate.processing": "در حال پردازش",
|
||||
"landing.translate.preservingLayout": "طرحبندی شما حفظ میشود"
|
||||
"landing.translate.preservingLayout": "طرحبندی شما حفظ میشود",
|
||||
"landing.beforeAfter.seal": "همان چیدمان، کلمه به کلمه",
|
||||
"landing.beforeAfter.proof1": "نمودارهای SmartArt بازسازی میشوند، نه تختشده",
|
||||
"landing.beforeAfter.proof2": "سریها و محورهای نمودار ترجمه میشوند",
|
||||
"landing.beforeAfter.proof3": "فهرست مطالب به زبان مقصد بازسازی میشود",
|
||||
"landing.beforeAfter.targetTitle": "مشخصات فنی — تهویه هوا",
|
||||
"landing.beforeAfter.targetPara1": "سیستم تهویه باید پیش از پایان سهماهه دوم راهاندازی شود.",
|
||||
"landing.beforeAfter.targetPara2Before": "",
|
||||
"landing.beforeAfter.targetTerm": "هواساز مرکزی",
|
||||
"landing.beforeAfter.targetPara2After": "باید مطابق برنامهریزی کلاس فیلتر F7 را رعایت کند.",
|
||||
"landing.beforeAfter.targetItem1": "بار حرارتی: ۴۲ کیلووات در جریان اسمی",
|
||||
"landing.beforeAfter.targetItem2": "سطح صدا کمتر از ۴۵ دسیبل (A) در فاصله ۳ متری",
|
||||
"landing.formats.pill": "سازگاری",
|
||||
"landing.hero.visualCaption": "همان چیدمان، زبان جدید — هیچ چیز دیگری تغییر نمیکند",
|
||||
"landing.pricing.free.name": "رایگان",
|
||||
"landing.pricing.free.desc": "برای آشنایی با اپلیکیشن ایدهآل است",
|
||||
"landing.pricing.free.cta": "انتخاب این برنامه",
|
||||
"landing.pricing.enterprise.name": "سازمانی",
|
||||
"landing.pricing.enterprise.desc": "راهکارهای سفارشی برای سازمانهای بزرگ",
|
||||
"landing.pricing.enterprise.cta": "تماس با ما",
|
||||
"landing.beforeAfter.sourceTitle": "Cahier des charges — Traitement d'air",
|
||||
"landing.beforeAfter.sourcePara1": "L'installation de ventilation doit être mise en service avant la fin du deuxième trimestre.",
|
||||
"landing.beforeAfter.sourcePara2Before": "Le",
|
||||
"landing.beforeAfter.sourceTerm": "groupe de traitement d'air",
|
||||
"landing.beforeAfter.sourcePara2After": "doit respecter la classe de filtration F7 conformément au planning.",
|
||||
"landing.beforeAfter.sourceItem1": "Charge thermique : 42 kW au débit nominal",
|
||||
"landing.beforeAfter.sourceItem2": "Niveau sonore inférieur à 45 dB(A) à 3 m"
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"pricing.plans.pro.feat8": "تاریخچه ۹۰ روزه",
|
||||
"pricing.plans.business.feat1": "۱,۰۰۰ سند / ماه",
|
||||
"pricing.plans.business.feat2": "تا ۵۰۰ صفحه برای هر سند",
|
||||
"pricing.plans.business.feat3": "هوش مصنوعی پایه + پیشرفته (Claude Haiku)",
|
||||
"pricing.plans.business.feat3": "هوش مصنوعی پایه + پیشرفته (Claude 5)",
|
||||
"pricing.plans.business.feat4": "تمام ارائهدهندههای ترجمه",
|
||||
"pricing.plans.business.feat5": "فایلها تا ۵۰ مگابایت",
|
||||
"pricing.plans.business.feat6": "دسترسی API (۱۰,۰۰۰ فراخوان/ماه)",
|
||||
@@ -50,7 +50,7 @@
|
||||
"pricing.plans.business.feat9": "تاریخچه ۱ ساله",
|
||||
"pricing.plans.business.feat10": "تحلیل پیشرفته",
|
||||
"pricing.plans.enterprise.feat1": "اسناد نامحدود",
|
||||
"pricing.plans.enterprise.feat2": "تمام مدلهای هوش مصنوعی (GPT-5, Claude Opus 4.6…)",
|
||||
"pricing.plans.enterprise.feat2": "تمام مدلهای هوش مصنوعی (Claude 5، DeepSeek V4 Pro، GLM-5.3…)",
|
||||
"pricing.plans.enterprise.feat3": "استقرار محلی یا ابری اختصاصی",
|
||||
"pricing.plans.enterprise.feat4": "SLA 99.9% تضمینشده",
|
||||
"pricing.plans.enterprise.feat5": "پشتیبانی اختصاصی ۲۴/۷",
|
||||
@@ -108,19 +108,19 @@
|
||||
"pricing.trust.parallel.sub": "هوش مصنوعی چندنخی فوقسریع",
|
||||
"pricing.trust.availability.title": "۲۴/۷ در دسترس",
|
||||
"pricing.trust.availability.sub": "۹۹.۹٪ زمان فعالیت تضمینشده",
|
||||
"pricing.aiModels.title": "مدلهای هوش مصنوعی ما — مارس ۲۰۲۶",
|
||||
"pricing.aiModels.title": "مدلهای هوش مصنوعی ما — سپتامبر ۲۰۲۶",
|
||||
"pricing.aiModels.essential.title": "ترجمه هوش مصنوعی پایه",
|
||||
"pricing.aiModels.essential.plan": "طرح حرفهای",
|
||||
"pricing.aiModels.essential.descPrefix": "مبتنی بر",
|
||||
"pricing.aiModels.essential.descSuffix": "— مقرونبهصرفهترین مدل هوش مصنوعی سال ۲۰۲۶. کیفیت قابل مقایسه با مدلهای پیشرو با کسر هزینه.",
|
||||
"pricing.aiModels.essential.modelName": "مدل هوش مصنوعی Essential",
|
||||
"pricing.aiModels.essential.context": "۱۶۳هزار توکن زمینه",
|
||||
"pricing.aiModels.essential.value": "ارزش عالی نسبت به قیمت",
|
||||
"pricing.aiModels.essential.descSuffix": "— مقرونبهصرفهترین مدلهای هوش مصنوعی سال ۲۰۲۶.",
|
||||
"pricing.aiModels.essential.modelName": "DeepSeek V4 Flash، GLM-5.3 Flash و MiniMax M3",
|
||||
"pricing.aiModels.essential.context": "زمینه تا ۱٫۳ میلیون توکن (GLM-5.3 Flash)",
|
||||
"pricing.aiModels.essential.value": "بهترین نسبت کیفیت به قیمت",
|
||||
"pricing.aiModels.premium.title": "ترجمه هوش مصنوعی پیشرفته",
|
||||
"pricing.aiModels.premium.plan": "طرح سازمانی",
|
||||
"pricing.aiModels.premium.descPrefix": "مبتنی بر",
|
||||
"pricing.aiModels.premium.descSuffix": "توسط Anthropic — دقیق در اسناد حقوقی، پزشکی و فنی پیچیده.",
|
||||
"pricing.aiModels.premium.context": "۲۰۰هزار توکن زمینه",
|
||||
"pricing.aiModels.premium.context": "یک میلیون توکن زمینه",
|
||||
"pricing.aiModels.premium.precision": "بالاترین دقت",
|
||||
"pricing.faq.title": "سوالات متداول",
|
||||
"pricing.faq.q1": "آیا میتوانم طرح را هر زمان بخواهم تغییر دهم؟",
|
||||
@@ -128,7 +128,7 @@
|
||||
"pricing.faq.q2": "\\\"ترجمه هوش مصنوعی پایه\\\" چیست؟",
|
||||
"pricing.faq.a2": "این موتور هوش مصنوعی ماست. زمینه اسناد شما را درک میکند، طرحبندی را حفظ میکند و اصطلاحات فنی را بسیار بهتر از ترجمه کلاسیک مدیریت میکند.",
|
||||
"pricing.faq.q3": "تفاوت هوش مصنوعی پایه و پیشرفته چیست؟",
|
||||
"pricing.faq.a3": "هوش مصنوعی Essential از یک مدل بهینهشده استفاده میکند (ارزش عالی برای پول). هوش مصنوعی Premium از Claude 3.5 Haiku انترپیک استفاده میکند که در اسناد حقوقی، پزشکی و فنی پیچیده دقیقتر است.",
|
||||
"pricing.faq.a3": "هوش مصنوعی Essential از مدلهای DeepSeek V4 Flash، GLM-5.3 Flash و MiniMax M3 استفاده میکند (ارزش عالی برای پول). هوش مصنوعی Premium از Claude 5 انترپیک استفاده میکند که در اسناد حقوقی، پزشکی و فنی پیچیده دقیقتر است.",
|
||||
"pricing.faq.q4": "آیا اسناد من پس از ترجمه نگهداری میشوند؟",
|
||||
"pricing.faq.a4": "فایلهای ترجمهشده طبق طرح شما در دسترس هستند (۳۰ روز مبتدی، ۹۰ روز حرفهای، ۱ سال سازمانی). آنها در حالت استراحت و هنگام انتقال رمزنگاری میشوند.",
|
||||
"pricing.faq.q5": "اگر از سهمیه ماهانه خود فراتر رویم چه میشود؟",
|
||||
@@ -146,5 +146,26 @@
|
||||
"pricing.toast.paymentError": "خطا در ایجاد پرداخت.",
|
||||
"pricing.dashboard": "داشبورد",
|
||||
"pricing.okSymbol": "✓",
|
||||
"pricing.errSymbol": "✕"
|
||||
"pricing.errSymbol": "✕",
|
||||
"pricing.aiModels.essential.price": "از ۰٫۰۹ دلار به ازای هر میلیون توکن",
|
||||
"pricing.aiModels.premium.modelName": "Claude 5",
|
||||
"pricing.aiModels.premium.alternatives": "جایگزینها: DeepSeek V4 Pro و GLM-5.3",
|
||||
"pricing.confirm.title": "اشتراک خود را تأیید کنید",
|
||||
"pricing.confirm.subtitle": "به درگاه پرداخت امن ما هدایت میشوید.",
|
||||
"pricing.confirm.cancel": "لغو",
|
||||
"pricing.confirm.cta": "ادامه به پرداخت",
|
||||
"pricing.confirm.year": "سال",
|
||||
"pricing.confirm.month": "ماه",
|
||||
"pricing.confirm.monthlyEquivalent": "صورتحساب سالانه — یعنی {price} یورو در ماه.",
|
||||
"pricing.confirm.secureNote": "هر زمان از پروفایل خود قابل لغو است. پرداخت توسط Stripe انجام میشود؛ اطلاعات کارت شما هرگز به سرورهای ما نمیرسد.",
|
||||
"pricing.enterprise.subject": "پیشنهاد سازمانی",
|
||||
"pricing.enterpriseBand.cta": "تماس با ما",
|
||||
"pricing.enterpriseBand.text": "حجم بالا، موتورهای اختصاصی، گزینههای درونسازمانی — صحبت کنیم.",
|
||||
"pricing.error.server": "خطای سرور {status}",
|
||||
"pricing.freeBand.cta": "رایگان شروع کنید",
|
||||
"pricing.freeBand.text": "فقط میخواهید امتحان کنید؟ رایگان شروع کنید — ماهی ۵ سند، بدون کارت.",
|
||||
"pricing.header.titleBase": "برنامهای برای",
|
||||
"pricing.header.titleAccent": "هر نیاز",
|
||||
"pricing.toast.close": "بستن",
|
||||
"pricing.aiModels.premium.price": "۲ دلار / ۱۰ دلار به ازای هر میلیون توکن"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"providerTheme.openrouter.desc": "دسترسی یکپارچه به بهترین مدلهای متنباز بهینهسازیشده برای ترجمه.",
|
||||
"providerTheme.openrouter_premium.badge": "فوقالعاده",
|
||||
"providerTheme.openrouter_premium.subBadge": "حداکثر زمینه",
|
||||
"providerTheme.openrouter_premium.desc": "با کمک مدلهای پیشرفته (GPT-4o، Claude Sonnet 4.6) برای اسناد طولانی.",
|
||||
"providerTheme.openrouter_premium.desc": "با کمک مدلهای پیشرفته (GPT-4o، Claude 5) برای اسناد طولانی.",
|
||||
"providerTheme.zai.badge": "تخصصی",
|
||||
"providerTheme.zai.subBadge": "مالی و حقوقی",
|
||||
"providerTheme.zai.desc": "مدل تنظیمشده برای اصطلاحات تجاری سختگیرانه (حقوقی، مالی).",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"admin.nav.providers": "Fournisseurs",
|
||||
"admin.nav.system": "Système",
|
||||
"admin.nav.logs": "Logs",
|
||||
"admin.nav.stats": "Statistiques",
|
||||
"admin.users.title": "Gestion des Utilisateurs",
|
||||
"admin.users.subtitle": "Visualiser et gérer les comptes utilisateurs",
|
||||
"admin.users.planUpdated": "Plan mis à jour",
|
||||
@@ -44,5 +45,105 @@
|
||||
"admin.system.waitingData": "En attente de données...",
|
||||
"admin.system.purging": "Purge...",
|
||||
"admin.system.clean": "Nettoyer",
|
||||
"admin.system.purge": "Purger"
|
||||
"admin.system.purge": "Purger",
|
||||
"admin.nav.models": "Modèles & abonnements",
|
||||
"admin.nav.marketing": "Marketing",
|
||||
"admin.marketing.audience": "Audience",
|
||||
"admin.marketing.audienceAllUsers": "Tous les comptes",
|
||||
"admin.marketing.audienceInactive": "Comptes inactifs depuis 30 jours",
|
||||
"admin.marketing.audiencePlaceholder": "Choisir une audience",
|
||||
"admin.marketing.audiencePlanBusiness": "Forfait Business",
|
||||
"admin.marketing.audiencePlanEnterprise": "Forfait Enterprise",
|
||||
"admin.marketing.audiencePlanFree": "Forfait Gratuit",
|
||||
"admin.marketing.audiencePlanPro": "Forfait Pro",
|
||||
"admin.marketing.audiencePlanStarter": "Forfait Starter",
|
||||
"admin.marketing.audienceWaitlist": "Liste d'attente",
|
||||
"admin.marketing.badgeReal": "réel",
|
||||
"admin.marketing.badgeTest": "test",
|
||||
"admin.marketing.confirmSend": "Envoyer cet email à {count} destinataire(s) (« {audience} ») ?",
|
||||
"admin.marketing.footerNote": "Un pied de page avec le lien de désabonnement est ajouté automatiquement à l'envoi.",
|
||||
"admin.marketing.hidePreview": "Masquer l'aperçu",
|
||||
"admin.marketing.history": "Historique des envois",
|
||||
"admin.marketing.historyFailedSuffix": ", {count} échec(s)",
|
||||
"admin.marketing.historySent": "{sent}/{total} envoyé(s)",
|
||||
"admin.marketing.html": "Contenu HTML",
|
||||
"admin.marketing.htmlPlaceholder": "<html><body>…</body></html>",
|
||||
"admin.marketing.incompleteDesc": "Renseignez le sujet et le HTML.",
|
||||
"admin.marketing.incompleteTitle": "Contenu incomplet",
|
||||
"admin.marketing.loadErrorTitle": "Erreur de chargement",
|
||||
"admin.marketing.localCheckUnavailable": "Vérification locale indisponible sur ce navigateur : le serveur contrôlera l'envoi test préalable.",
|
||||
"admin.marketing.networkDesc": "Impossible de contacter le backend.",
|
||||
"admin.marketing.newCampaign": "Nouvelle relance",
|
||||
"admin.marketing.noHistory": "Aucun envoi enregistré.",
|
||||
"admin.marketing.preview": "Aperçu",
|
||||
"admin.marketing.previewEmpty": "Aperçu : collez votre HTML ci-contre.",
|
||||
"admin.marketing.previewTitle": "Aperçu de l'email",
|
||||
"admin.marketing.recipientsAvailable": "{count} destinataire(s) disponibles après exclusion des désabonnés.",
|
||||
"admin.marketing.sendReal": "Envoyer à {count} destinataire(s)",
|
||||
"admin.marketing.sendRefusedTitle": "Envoi refusé",
|
||||
"admin.marketing.sendTest": "Envoi test",
|
||||
"admin.marketing.sendingDesc": "{count} email(s) en file d'envoi (0,2 s d'intervalle). Les désabonnés sont exclus.",
|
||||
"admin.marketing.sendingTitle": "Envoi en cours",
|
||||
"admin.marketing.subject": "Sujet",
|
||||
"admin.marketing.subjectPlaceholder": "Ex. : Votre traduction vous attend — 20 % pendant 7 jours",
|
||||
"admin.marketing.subtitle": "Envoi d'emails à une audience, avec envoi test obligatoire, lien de désabonnement automatique et historique complet.",
|
||||
"admin.marketing.testDoneDesc": "Email reçu sur {email}",
|
||||
"admin.marketing.testDoneTitle": "Envoi test effectué",
|
||||
"admin.marketing.testEmail": "Email de test (optionnel)",
|
||||
"admin.marketing.testEmailPlaceholder": "Sinon : adresse d'expédition SMTP",
|
||||
"admin.marketing.testFailedTitle": "Échec de l'envoi test",
|
||||
"admin.marketing.testRequiredHint": "L'envoi réel exige un envoi test préalable de ce contenu exact (vérifié par le serveur).",
|
||||
"admin.marketing.testValidated": "Envoi test validé pour ce contenu.",
|
||||
"admin.marketing.title": "Marketing — relances par email",
|
||||
"admin.marketing.unsubscribedNote": "{count} désabonné(s) seront exclus de tout envoi.",
|
||||
"admin.models.addModel": "Ajouter un modèle",
|
||||
"admin.models.businessBadge": "Forfait Business",
|
||||
"admin.models.cancel": "Annuler",
|
||||
"admin.models.catalog": "Catalogue OpenRouter",
|
||||
"admin.models.catalogErrorDesc": "Impossible de charger le catalogue OpenRouter.",
|
||||
"admin.models.catalogErrorTitle": "Catalogue indisponible",
|
||||
"admin.models.customBadge": "Palier personnalisé",
|
||||
"admin.models.customNote": "Modèles sur mesure, définis au cas par cas avec le client.",
|
||||
"admin.models.defaultBadge": "par défaut",
|
||||
"admin.models.emptyTier": "Aucun modèle : la gamme officielle du plan sera utilisée.",
|
||||
"admin.models.enterpriseBadge": "Forfait Enterprise",
|
||||
"admin.models.essentialBadge": "Palier IA Essentielle",
|
||||
"admin.models.essentialCost": "— facturation coût 1",
|
||||
"admin.models.fallbackFirst": "(secours n°1)",
|
||||
"admin.models.loadErrorDesc": "HTTP {status} — vérifiez votre token admin.",
|
||||
"admin.models.loadErrorTitle": "Erreur de chargement",
|
||||
"admin.models.matrixDesc": "Modèles actifs par palier — l'ordre de la liste est la priorité de secours. Le bouton radio choisit le modèle par défaut, utilisé immédiatement sans redéploiement.",
|
||||
"admin.models.matrixTitle": "Matrice des modèles par forfait",
|
||||
"admin.models.missingConfig": "Configuration des paliers IA introuvable. Rechargez la page.",
|
||||
"admin.models.moveDown": "Descendre",
|
||||
"admin.models.moveUp": "Monter",
|
||||
"admin.models.networkDesc": "Impossible de contacter le backend.",
|
||||
"admin.models.networkTitle": "Erreur réseau",
|
||||
"admin.models.premiumBadge": "Palier IA Premium",
|
||||
"admin.models.premiumCost": "— facturation coût 5",
|
||||
"admin.models.premiumReservedNote": "Réservé aux forfaits Business et Enterprise (moteur « openrouter_premium »).",
|
||||
"admin.models.proBadge": "Forfait Pro",
|
||||
"admin.models.remove": "Retirer",
|
||||
"admin.models.save": "Enregistrer",
|
||||
"admin.models.saveErrorTitle": "Erreur de sauvegarde",
|
||||
"admin.models.saveNetworkDesc": "Impossible d'enregistrer la configuration.",
|
||||
"admin.models.savedDesc": "Le nouveau modèle par défaut est utilisé dès la prochaine traduction, sans redéploiement.",
|
||||
"admin.models.savedTitle": "Modèles enregistrés",
|
||||
"admin.models.saving": "Enregistrement...",
|
||||
"admin.models.setDefault": "Définir {model} comme modèle par défaut",
|
||||
"admin.models.sharedTierNote": "Palier commun : le forfait Business utilise aussi la Essentielle pour son moteur « openrouter » — la liste ci-dessus s'applique aux deux forfaits.",
|
||||
"admin.models.subtitle": "Matrice forfaits → paliers IA. Le modèle réellement utilisé par traduction est celui du palier du plan : un forfait Pro ne peut jamais déclencher un modèle Premium.",
|
||||
"admin.models.title": "Modèles & abonnements",
|
||||
"admin.stats.aiTiers": "Paliers IA (30 j)",
|
||||
"admin.stats.includingCredits": "dont {amount} de crédits",
|
||||
"admin.stats.mrr": "MRR estimé",
|
||||
"admin.stats.noTranslationsYet": "Aucune traduction en base sur les 30 derniers jours : la répartition des paliers se remplira dès les prochaines traductions.",
|
||||
"admin.stats.payments30": "{count} paiement(s) sur 30 jours",
|
||||
"admin.stats.refreshing": "Actualisation...",
|
||||
"admin.stats.revenue30": "Revenus (30 jours)",
|
||||
"admin.stats.revenueTotal": "Revenus encaissés (total)",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
"landing.pricing.pro.desc": "Pour les professionnels exigeants",
|
||||
"landing.pricing.pro.f1": "200 documents / mois",
|
||||
"landing.pricing.pro.f2": "Jusqu'à 200 pages par doc",
|
||||
"landing.pricing.pro.f3": "Traduction par IA",
|
||||
"landing.pricing.pro.f3": "IA Essentielle : DeepSeek V4 Flash, GLM-5.3 Flash, MiniMax M3",
|
||||
"landing.pricing.pro.f4": "Google inclus",
|
||||
"landing.pricing.pro.f5": "Glossaires et prompts",
|
||||
"landing.pricing.pro.f6": "Support prioritaire",
|
||||
@@ -109,7 +109,7 @@
|
||||
"landing.pricing.business.desc": "Pour les équipes avec des besoins élevés",
|
||||
"landing.pricing.business.f1": "1 000 documents / mois",
|
||||
"landing.pricing.business.f2": "Jusqu'à 500 pages par doc",
|
||||
"landing.pricing.business.f3": "IA Premium (Claude)",
|
||||
"landing.pricing.business.f3": "IA Premium (Claude 5)",
|
||||
"landing.pricing.business.f4": "Tous les fournisseurs + API",
|
||||
"landing.pricing.business.f5": "Webhooks et automatisation",
|
||||
"landing.pricing.business.f6": "5 postes d'équipe",
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"pricing.plans.pro.feat8": "Historique 90 jours",
|
||||
"pricing.plans.business.feat1": "1 000 documents / mois",
|
||||
"pricing.plans.business.feat2": "Jusqu'à 500 pages par document",
|
||||
"pricing.plans.business.feat3": "IA Essentielle + Premium (Claude Haiku)",
|
||||
"pricing.plans.business.feat3": "IA Essentielle + IA Premium (Claude 5)",
|
||||
"pricing.plans.business.feat4": "Tous les fournisseurs de traduction",
|
||||
"pricing.plans.business.feat5": "Fichiers jusqu'à 50 Mo",
|
||||
"pricing.plans.business.feat6": "Accès API (10 000 appels/mois)",
|
||||
@@ -50,7 +50,7 @@
|
||||
"pricing.plans.business.feat9": "Historique 1 an",
|
||||
"pricing.plans.business.feat10": "Analytiques avancées",
|
||||
"pricing.plans.enterprise.feat1": "Documents illimités",
|
||||
"pricing.plans.enterprise.feat2": "Tous les modèles IA (GPT-5, Claude Opus 4.6…)",
|
||||
"pricing.plans.enterprise.feat2": "Tous les modèles IA (Claude 5, DeepSeek V4 Pro, GLM-5.3…)",
|
||||
"pricing.plans.enterprise.feat3": "Déploiement on-premise ou cloud dédié",
|
||||
"pricing.plans.enterprise.feat4": "SLA 99,9 % garanti",
|
||||
"pricing.plans.enterprise.feat5": "Support 24/7 dédié",
|
||||
@@ -108,19 +108,19 @@
|
||||
"pricing.trust.parallel.sub": "IA multi-thread ultra-rapide",
|
||||
"pricing.trust.availability.title": "Disponible 24/7",
|
||||
"pricing.trust.availability.sub": "Uptime garanti 99,9 %",
|
||||
"pricing.aiModels.title": "Nos modèles IA — Mars 2026",
|
||||
"pricing.aiModels.title": "Nos modèles IA — Septembre 2026",
|
||||
"pricing.aiModels.essential.title": "Traduction IA Essentielle",
|
||||
"pricing.aiModels.essential.plan": "Forfait Pro",
|
||||
"pricing.aiModels.essential.descPrefix": "Basée sur",
|
||||
"pricing.aiModels.essential.descSuffix": "— le modèle IA le plus rentable de 2026. Qualité comparable aux modèles frontier à une fraction du coût.",
|
||||
"pricing.aiModels.essential.modelName": "notre modèle IA Essentiel",
|
||||
"pricing.aiModels.essential.context": "163K tokens de contexte",
|
||||
"pricing.aiModels.essential.value": "Excellent rapport qualité/prix",
|
||||
"pricing.aiModels.essential.descSuffix": "— les modèles IA au meilleur rapport qualité/prix de 2026.",
|
||||
"pricing.aiModels.essential.modelName": "DeepSeek V4 Flash, GLM-5.3 Flash et MiniMax M3",
|
||||
"pricing.aiModels.essential.context": "Jusqu'à 1,3 M de contexte (GLM-5.3 Flash)",
|
||||
"pricing.aiModels.essential.value": "Le meilleur rapport qualité/prix",
|
||||
"pricing.aiModels.premium.title": "Traduction IA Premium",
|
||||
"pricing.aiModels.premium.plan": "Forfait Business",
|
||||
"pricing.aiModels.premium.descPrefix": "Basée sur",
|
||||
"pricing.aiModels.premium.descSuffix": "d'Anthropic — précis sur les documents juridiques, médicaux et techniques complexes.",
|
||||
"pricing.aiModels.premium.context": "200K tokens de contexte",
|
||||
"pricing.aiModels.premium.context": "1M de contexte",
|
||||
"pricing.aiModels.premium.precision": "Meilleure précision",
|
||||
"pricing.faq.title": "Questions fréquentes",
|
||||
"pricing.faq.q1": "Puis-je changer de forfait à tout moment ?",
|
||||
@@ -128,7 +128,7 @@
|
||||
"pricing.faq.q2": "Qu'est-ce que la « Traduction IA Essentielle » ?",
|
||||
"pricing.faq.a2": "C'est notre moteur IA. Il comprend le contexte de vos documents, préserve la mise en page et gère les termes techniques bien mieux qu'une traduction classique.",
|
||||
"pricing.faq.q3": "Quelle est la différence entre IA Essentielle et IA Premium ?",
|
||||
"pricing.faq.a3": "L'IA Essentielle utilise un modèle optimisé (excellent rapport qualité/prix). L'IA Premium utilise Claude Sonnet 4.6 d'Anthropic, plus précis sur les documents juridiques, médicaux et techniques complexes.",
|
||||
"pricing.faq.a3": "L'IA Essentielle s'appuie sur DeepSeek V4 Flash, GLM-5.3 Flash et MiniMax M3 (excellent rapport qualité/prix). L'IA Premium utilise Claude 5 d'Anthropic, plus précis sur les documents juridiques, médicaux et techniques complexes.",
|
||||
"pricing.faq.q4": "Mes documents sont-ils conservés après traduction ?",
|
||||
"pricing.faq.a4": "Les fichiers traduits sont disponibles selon votre forfait (30 jours Starter, 90 jours Pro, 1 an Business). Ils sont chiffrés au repos et en transit.",
|
||||
"pricing.faq.q5": "Que se passe-t-il si je dépasse mon quota mensuel ?",
|
||||
@@ -163,5 +163,9 @@
|
||||
"pricing.freeBand.text": "Juste envie d'essayer ? Commencez gratuitement — 5 documents par mois, sans carte.",
|
||||
"pricing.freeBand.cta": "Commencer gratuitement",
|
||||
"pricing.enterpriseBand.text": "Volume, moteurs dédiés, hébergement spécifique — parlons-en.",
|
||||
"pricing.enterpriseBand.cta": "Nous contacter"
|
||||
"pricing.enterpriseBand.cta": "Nous contacter",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"providerTheme.openrouter.desc": "Accès unifié aux meilleurs modèles open-source optimisés pour la traduction.",
|
||||
"providerTheme.openrouter_premium.badge": "Ultra",
|
||||
"providerTheme.openrouter_premium.subBadge": "Maximum Context",
|
||||
"providerTheme.openrouter_premium.desc": "Traduction assistée par les modèles de pointe (GPT-4o, Claude Sonnet 4.6) pour documents longs.",
|
||||
"providerTheme.openrouter_premium.desc": "Traduction assistée par les modèles de pointe (GPT-4o, Claude 5) pour documents longs.",
|
||||
"providerTheme.zai.badge": "Spécialisée",
|
||||
"providerTheme.zai.subBadge": "Finance & Droit",
|
||||
"providerTheme.zai.desc": "Modèle affiné pour les terminologies métiers exigeantes (juridique, finance).",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"admin.nav.providers": "Fornitori",
|
||||
"admin.nav.system": "Sistema",
|
||||
"admin.nav.logs": "Log",
|
||||
"admin.nav.stats": "Statistiche",
|
||||
"admin.users.title": "Gestione Utenti",
|
||||
"admin.users.subtitle": "Visualizza e gestisci gli account utente",
|
||||
"admin.users.planUpdated": "Piano aggiornato",
|
||||
@@ -44,5 +45,105 @@
|
||||
"admin.system.waitingData": "In attesa di dati...",
|
||||
"admin.system.purging": "Pulizia in corso...",
|
||||
"admin.system.clean": "Pulisci",
|
||||
"admin.system.purge": "Pulisci"
|
||||
"admin.system.purge": "Pulisci",
|
||||
"admin.nav.models": "Modelli e abbonamenti",
|
||||
"admin.nav.marketing": "Marketing",
|
||||
"admin.marketing.audience": "Pubblico",
|
||||
"admin.marketing.audienceAllUsers": "Tutti gli account",
|
||||
"admin.marketing.audienceInactive": "Account inattivi da 30 giorni",
|
||||
"admin.marketing.audiencePlaceholder": "Scegli un pubblico",
|
||||
"admin.marketing.audiencePlanBusiness": "Piano Business",
|
||||
"admin.marketing.audiencePlanEnterprise": "Piano Enterprise",
|
||||
"admin.marketing.audiencePlanFree": "Piano Gratuito",
|
||||
"admin.marketing.audiencePlanPro": "Piano Pro",
|
||||
"admin.marketing.audiencePlanStarter": "Piano Starter",
|
||||
"admin.marketing.audienceWaitlist": "Lista d'attesa",
|
||||
"admin.marketing.badgeReal": "reale",
|
||||
"admin.marketing.badgeTest": "prova",
|
||||
"admin.marketing.confirmSend": "Inviare questa email a {count} destinatario/i («{audience}»)?",
|
||||
"admin.marketing.footerNote": "Un piè di pagina con il link di disiscrizione è aggiunto automaticamente all'invio.",
|
||||
"admin.marketing.hidePreview": "Nascondi anteprima",
|
||||
"admin.marketing.history": "Cronologia degli invii",
|
||||
"admin.marketing.historyFailedSuffix": ", {count} falliti",
|
||||
"admin.marketing.historySent": "{sent}/{total} inviati",
|
||||
"admin.marketing.html": "Contenuto HTML",
|
||||
"admin.marketing.htmlPlaceholder": "<html><body>…</body></html>",
|
||||
"admin.marketing.incompleteDesc": "Compila l'oggetto e l'HTML.",
|
||||
"admin.marketing.incompleteTitle": "Contenuto incompleto",
|
||||
"admin.marketing.loadErrorTitle": "Errore di caricamento",
|
||||
"admin.marketing.localCheckUnavailable": "Verifica locale non disponibile in questo browser: il server controllerà l'invio di prova previo.",
|
||||
"admin.marketing.networkDesc": "Impossibile contattare il backend.",
|
||||
"admin.marketing.newCampaign": "Nuovo invio",
|
||||
"admin.marketing.noHistory": "Nessun invio registrato.",
|
||||
"admin.marketing.preview": "Anteprima",
|
||||
"admin.marketing.previewEmpty": "Anteprima: incolla qui il tuo HTML.",
|
||||
"admin.marketing.previewTitle": "Anteprima dell'email",
|
||||
"admin.marketing.recipientsAvailable": "{count} destinatario/i disponibili dopo l'esclusione dei disiscritti.",
|
||||
"admin.marketing.sendReal": "Invia a {count} destinatario/i",
|
||||
"admin.marketing.sendRefusedTitle": "Invio rifiutato",
|
||||
"admin.marketing.sendTest": "Invio di prova",
|
||||
"admin.marketing.sendingDesc": "{count} email in coda di invio (intervallo 0,2 s). I disiscritti sono esclusi.",
|
||||
"admin.marketing.sendingTitle": "Invio in corso",
|
||||
"admin.marketing.subject": "Oggetto",
|
||||
"admin.marketing.subjectPlaceholder": "Es.: La tua traduzione ti aspetta — 20% per 7 giorni",
|
||||
"admin.marketing.subtitle": "Invio di email a un pubblico, con invio di prova obbligatorio, link di disiscrizione automatico e cronologia completa.",
|
||||
"admin.marketing.testDoneDesc": "Email ricevuta su {email}",
|
||||
"admin.marketing.testDoneTitle": "Invio di prova effettuato",
|
||||
"admin.marketing.testEmail": "Email di prova (opzionale)",
|
||||
"admin.marketing.testEmailPlaceholder": "Altrimenti: indirizzo mittente SMTP",
|
||||
"admin.marketing.testFailedTitle": "Fallimento dell'invio di prova",
|
||||
"admin.marketing.testRequiredHint": "L'invio reale richiede un invio di prova previo di questo identico contenuto (verificato dal server).",
|
||||
"admin.marketing.testValidated": "Invio di prova convalidato per questo contenuto.",
|
||||
"admin.marketing.title": "Marketing — invii email",
|
||||
"admin.marketing.unsubscribedNote": "{count} contatti disiscritti saranno esclusi da ogni invio.",
|
||||
"admin.models.addModel": "Aggiungi un modello",
|
||||
"admin.models.businessBadge": "Piano Business",
|
||||
"admin.models.cancel": "Annulla",
|
||||
"admin.models.catalog": "Catalogo OpenRouter",
|
||||
"admin.models.catalogErrorDesc": "Impossibile caricare il catalogo OpenRouter.",
|
||||
"admin.models.catalogErrorTitle": "Catalogo non disponibile",
|
||||
"admin.models.customBadge": "Livello personalizzato",
|
||||
"admin.models.customNote": "Modelli su misura, definiti caso per caso con il cliente.",
|
||||
"admin.models.defaultBadge": "predefinito",
|
||||
"admin.models.emptyTier": "Nessun modello: verrà usata la gamma ufficiale del piano.",
|
||||
"admin.models.enterpriseBadge": "Piano Enterprise",
|
||||
"admin.models.essentialBadge": "Livello IA Essenziale",
|
||||
"admin.models.essentialCost": "— fatturato con fattore di costo 1",
|
||||
"admin.models.fallbackFirst": "(fallback n. 1)",
|
||||
"admin.models.loadErrorDesc": "HTTP {status} — verifica il token amministratore.",
|
||||
"admin.models.loadErrorTitle": "Errore di caricamento",
|
||||
"admin.models.matrixDesc": "Modelli attivi per livello — l'ordine della lista è la priorità di fallback. Il pulsante di opzione sceglie il modello predefinito, attivo subito senza ridistribuzione.",
|
||||
"admin.models.matrixTitle": "Matrice dei modelli per piano",
|
||||
"admin.models.missingConfig": "Configurazione dei livelli IA non trovata. Ricarica la pagina.",
|
||||
"admin.models.moveDown": "Sposta giù",
|
||||
"admin.models.moveUp": "Sposta su",
|
||||
"admin.models.networkDesc": "Impossibile contattare il backend.",
|
||||
"admin.models.networkTitle": "Errore di rete",
|
||||
"admin.models.premiumBadge": "Livello IA Premium",
|
||||
"admin.models.premiumCost": "— fatturato con fattore di costo 5",
|
||||
"admin.models.premiumReservedNote": "Riservato ai piani Business ed Enterprise (motore «openrouter_premium»).",
|
||||
"admin.models.proBadge": "Piano Pro",
|
||||
"admin.models.remove": "Rimuovi",
|
||||
"admin.models.save": "Salva",
|
||||
"admin.models.saveErrorTitle": "Errore di salvataggio",
|
||||
"admin.models.saveNetworkDesc": "Impossibile salvare la configurazione.",
|
||||
"admin.models.savedDesc": "Il nuovo modello predefinito è usato dalla prossima traduzione, senza ridistribuzione.",
|
||||
"admin.models.savedTitle": "Modelli salvati",
|
||||
"admin.models.saving": "Salvataggio...",
|
||||
"admin.models.setDefault": "Imposta {model} come modello predefinito",
|
||||
"admin.models.sharedTierNote": "Livello condiviso: il piano Business usa il livello Essenziale anche per il motore «openrouter» — l'elenco sopra vale per entrambi i piani.",
|
||||
"admin.models.subtitle": "Matrice piani → livelli IA. Il modello realmente usato per ogni traduzione è quello del livello del piano: un piano Pro non può mai attivare un modello Premium.",
|
||||
"admin.models.title": "Modelli e abbonamenti",
|
||||
"admin.stats.aiTiers": "Livelli IA (30 g)",
|
||||
"admin.stats.includingCredits": "di cui {amount} di crediti",
|
||||
"admin.stats.mrr": "MRR stimato",
|
||||
"admin.stats.noTranslationsYet": "Nessuna traduzione registrata negli ultimi 30 giorni: la ripartizione per livelli si completerà con le prossime traduzioni.",
|
||||
"admin.stats.payments30": "{count} pagamento/i in 30 giorni",
|
||||
"admin.stats.refreshing": "Aggiornamento...",
|
||||
"admin.stats.revenue30": "Ricavi (30 giorni)",
|
||||
"admin.stats.revenueTotal": "Ricavi incassati (totale)",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
"landing.pricing.pro.desc": "Per professionisti esigenti",
|
||||
"landing.pricing.pro.f1": "200 documenti / mese",
|
||||
"landing.pricing.pro.f2": "Fino a 200 pagine per documento",
|
||||
"landing.pricing.pro.f3": "Traduzione con IA",
|
||||
"landing.pricing.pro.f3": "IA Essenziale: DeepSeek V4 Flash, GLM-5.3 Flash, MiniMax M3",
|
||||
"landing.pricing.pro.f4": "Google inclusi",
|
||||
"landing.pricing.pro.f5": "Glossari e prompt personalizzati",
|
||||
"landing.pricing.pro.f6": "Supporto prioritario",
|
||||
@@ -109,7 +109,7 @@
|
||||
"landing.pricing.business.desc": "Per team con esigenze elevate",
|
||||
"landing.pricing.business.f1": "1 000 documenti / mese",
|
||||
"landing.pricing.business.f2": "Fino a 500 pagine per documento",
|
||||
"landing.pricing.business.f3": "IA Premium (Claude)",
|
||||
"landing.pricing.business.f3": "IA Premium (Claude 5)",
|
||||
"landing.pricing.business.f4": "Tutti i provider + accesso API",
|
||||
"landing.pricing.business.f5": "Webhook e automazione",
|
||||
"landing.pricing.business.f6": "5 postazioni team",
|
||||
@@ -142,5 +142,31 @@
|
||||
"landing.translate.supportedFormats": "File DOCX, XLSX, PPTX o PDF supportati",
|
||||
"landing.translate.aiAnalysis": "Analisi IA Attiva",
|
||||
"landing.translate.processing": "Elaborazione in corso",
|
||||
"landing.translate.preservingLayout": "Il tuo layout viene preservato"
|
||||
"landing.translate.preservingLayout": "Il tuo layout viene preservato",
|
||||
"landing.beforeAfter.seal": "Stesso layout, parola per parola",
|
||||
"landing.beforeAfter.proof1": "I diagrammi SmartArt vengono ricostruiti, non appiattiti",
|
||||
"landing.beforeAfter.proof2": "Serie e assi dei grafici tradotti",
|
||||
"landing.beforeAfter.proof3": "Indici rigenerati nella lingua di destinazione",
|
||||
"landing.beforeAfter.targetTitle": "Specifiche tecniche — Trattamento aria",
|
||||
"landing.beforeAfter.targetPara1": "L'impianto di ventilazione deve essere messo in servizio entro la fine del secondo trimestre.",
|
||||
"landing.beforeAfter.targetPara2Before": "",
|
||||
"landing.beforeAfter.targetTerm": "L'unità di trattamento aria",
|
||||
"landing.beforeAfter.targetPara2After": "deve rispettare la classe di filtraggio F7 come da programma.",
|
||||
"landing.beforeAfter.targetItem1": "Carico termico: 42 kW alla portata nominale",
|
||||
"landing.beforeAfter.targetItem2": "Livello sonoro inferiore a 45 dB(A) a 3 m",
|
||||
"landing.formats.pill": "COMPATIBILITÀ",
|
||||
"landing.hero.visualCaption": "Stesso layout, nuova lingua — il resto non si tocca",
|
||||
"landing.pricing.free.name": "Gratuito",
|
||||
"landing.pricing.free.desc": "Ideale per scoprire l'app",
|
||||
"landing.pricing.free.cta": "Scegli questo piano",
|
||||
"landing.pricing.enterprise.name": "Enterprise",
|
||||
"landing.pricing.enterprise.desc": "Soluzioni su misura per grandi organizzazioni",
|
||||
"landing.pricing.enterprise.cta": "Contattaci",
|
||||
"landing.beforeAfter.sourceTitle": "Cahier des charges — Traitement d'air",
|
||||
"landing.beforeAfter.sourcePara1": "L'installation de ventilation doit être mise en service avant la fin du deuxième trimestre.",
|
||||
"landing.beforeAfter.sourcePara2Before": "Le",
|
||||
"landing.beforeAfter.sourceTerm": "groupe de traitement d'air",
|
||||
"landing.beforeAfter.sourcePara2After": "doit respecter la classe de filtration F7 conformément au planning.",
|
||||
"landing.beforeAfter.sourceItem1": "Charge thermique : 42 kW au débit nominal",
|
||||
"landing.beforeAfter.sourceItem2": "Niveau sonore inférieur à 45 dB(A) à 3 m"
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"pricing.plans.pro.feat8": "Cronologia di 90 giorni",
|
||||
"pricing.plans.business.feat1": "1 000 documenti / mese",
|
||||
"pricing.plans.business.feat2": "Fino a 500 pagine per documento",
|
||||
"pricing.plans.business.feat3": "IA Essenziale + Premium (Claude Haiku)",
|
||||
"pricing.plans.business.feat3": "IA Essenziale + IA Premium (Claude 5)",
|
||||
"pricing.plans.business.feat4": "Tutti i provider di traduzione",
|
||||
"pricing.plans.business.feat5": "File fino a 50 MB",
|
||||
"pricing.plans.business.feat6": "Accesso API (10 000 chiamate/mese)",
|
||||
@@ -50,7 +50,7 @@
|
||||
"pricing.plans.business.feat9": "Cronologia di 1 anno",
|
||||
"pricing.plans.business.feat10": "Analisi avanzate",
|
||||
"pricing.plans.enterprise.feat1": "Documenti illimitati",
|
||||
"pricing.plans.enterprise.feat2": "Tutti i modelli IA (GPT-5, Claude Opus 4.6…)",
|
||||
"pricing.plans.enterprise.feat2": "Tutti i modelli IA (Claude 5, DeepSeek V4 Pro, GLM-5.3…)",
|
||||
"pricing.plans.enterprise.feat3": "Distribuzione on-premise o cloud dedicato",
|
||||
"pricing.plans.enterprise.feat4": "SLA 99,9 % garantito",
|
||||
"pricing.plans.enterprise.feat5": "Supporto dedicato 24/7",
|
||||
@@ -108,19 +108,19 @@
|
||||
"pricing.trust.parallel.sub": "IA multi-thread ultraveloce",
|
||||
"pricing.trust.availability.title": "Disponibile 24/7",
|
||||
"pricing.trust.availability.sub": "99,9 % di uptime garantito",
|
||||
"pricing.aiModels.title": "I nostri modelli IA — Marzo 2026",
|
||||
"pricing.aiModels.title": "I nostri modelli IA — Settembre 2026",
|
||||
"pricing.aiModels.essential.title": "Traduzione IA Essenziale",
|
||||
"pricing.aiModels.essential.plan": "Piano Pro",
|
||||
"pricing.aiModels.essential.descPrefix": "Basato su",
|
||||
"pricing.aiModels.essential.descSuffix": "— il modello IA più conveniente del 2026. Qualità paragonabile ai modelli frontier a una frazione del costo.",
|
||||
"pricing.aiModels.essential.modelName": "il nostro modello IA Essenziale",
|
||||
"pricing.aiModels.essential.context": "163K token di contesto",
|
||||
"pricing.aiModels.essential.value": "Eccellente rapporto qualità-prezzo",
|
||||
"pricing.aiModels.essential.descSuffix": "— i modelli IA con il miglior rapporto qualità-prezzo del 2026.",
|
||||
"pricing.aiModels.essential.modelName": "DeepSeek V4 Flash, GLM-5.3 Flash e MiniMax M3",
|
||||
"pricing.aiModels.essential.context": "Fino a 1,3 M di contesto (GLM-5.3 Flash)",
|
||||
"pricing.aiModels.essential.value": "Il miglior rapporto qualità-prezzo",
|
||||
"pricing.aiModels.premium.title": "Traduzione IA Premium",
|
||||
"pricing.aiModels.premium.plan": "Piano Business",
|
||||
"pricing.aiModels.premium.descPrefix": "Basato su",
|
||||
"pricing.aiModels.premium.descSuffix": "di Anthropic — preciso su documenti legali, medici e tecnici complessi.",
|
||||
"pricing.aiModels.premium.context": "200K token di contesto",
|
||||
"pricing.aiModels.premium.context": "1M di contesto",
|
||||
"pricing.aiModels.premium.precision": "Massima precisione",
|
||||
"pricing.faq.title": "Domande frequenti",
|
||||
"pricing.faq.q1": "Posso cambiare piano in qualsiasi momento?",
|
||||
@@ -128,7 +128,7 @@
|
||||
"pricing.faq.q2": "Cos'è la «Traduzione IA Essenziale»?",
|
||||
"pricing.faq.a2": "È il nostro motore IA. Comprende il contesto dei vostri documenti, preserva il layout e gestisce i termini tecnici molto meglio della traduzione classica.",
|
||||
"pricing.faq.q3": "Qual è la differenza tra IA Essenziale e IA Premium?",
|
||||
"pricing.faq.a3": "La IA Essenziale usa un modello ottimizzato (eccellente rapporto qualità/prezzo). La IA Premium usa Claude 3.5 Haiku di Anthropic, più precisa su documenti legali, medici e tecnici complessi.",
|
||||
"pricing.faq.a3": "La IA Essenziale si basa su DeepSeek V4 Flash, GLM-5.3 Flash e MiniMax M3 (eccellente rapporto qualità/prezzo). La IA Premium usa Claude 5 di Anthropic, più precisa su documenti legali, medici e tecnici complessi.",
|
||||
"pricing.faq.q4": "I miei documenti vengono conservati dopo la traduzione?",
|
||||
"pricing.faq.a4": "I file tradotti sono disponibili secondo il tuo piano (30 giorni Starter, 90 giorni Pro, 1 anno Business). Sono crittografati a riposo e in transito.",
|
||||
"pricing.faq.q5": "Cosa succede se supero la quota mensile?",
|
||||
@@ -146,5 +146,26 @@
|
||||
"pricing.toast.paymentError": "Errore durante la creazione del pagamento.",
|
||||
"pricing.dashboard": "Dashboard",
|
||||
"pricing.okSymbol": "✓",
|
||||
"pricing.errSymbol": "✕"
|
||||
"pricing.errSymbol": "✕",
|
||||
"pricing.aiModels.essential.price": "Da 0,09 $ per milione di token",
|
||||
"pricing.aiModels.premium.modelName": "Claude 5",
|
||||
"pricing.aiModels.premium.alternatives": "Alternative: DeepSeek V4 Pro e GLM-5.3",
|
||||
"pricing.confirm.title": "Conferma il tuo abbonamento",
|
||||
"pricing.confirm.subtitle": "Verrai reindirizzato al nostro fornitore di pagamento sicuro.",
|
||||
"pricing.confirm.cancel": "Annulla",
|
||||
"pricing.confirm.cta": "Vai al pagamento",
|
||||
"pricing.confirm.year": "anno",
|
||||
"pricing.confirm.month": "mese",
|
||||
"pricing.confirm.monthlyEquivalent": "Fatturazione annuale — ovvero {price} € / mese.",
|
||||
"pricing.confirm.secureNote": "Disdicibile in qualsiasi momento dal tuo profilo. Il pagamento è elaborato da Stripe; i dati della tua carta non passano mai dai nostri server.",
|
||||
"pricing.enterprise.subject": "Richiesta Enterprise",
|
||||
"pricing.enterpriseBand.cta": "Contattaci",
|
||||
"pricing.enterpriseBand.text": "Volume, motori dedicati, opzioni on-premise — parliamone.",
|
||||
"pricing.error.server": "Errore del server {status}",
|
||||
"pricing.freeBand.cta": "Inizia gratis",
|
||||
"pricing.freeBand.text": "Vuoi solo provare? Inizia gratis — 5 documenti al mese, senza carta.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"providerTheme.openrouter.desc": "Accesso unificato ai migliori modelli open-source ottimizzati per la traduzione.",
|
||||
"providerTheme.openrouter_premium.badge": "Ultra",
|
||||
"providerTheme.openrouter_premium.subBadge": "Contesto massimo",
|
||||
"providerTheme.openrouter_premium.desc": "Assistito da modelli all'avanguardia (GPT-4o, Claude Sonnet 4.6) per documenti lunghi.",
|
||||
"providerTheme.openrouter_premium.desc": "Assistito da modelli all'avanguardia (GPT-4o, Claude 5) per documenti lunghi.",
|
||||
"providerTheme.zai.badge": "Specializzata",
|
||||
"providerTheme.zai.subBadge": "Finanza e Diritto",
|
||||
"providerTheme.zai.desc": "Modello ottimizzato per terminologie aziendali impegnative (legale, finanza).",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"admin.nav.providers": "プロバイダー",
|
||||
"admin.nav.system": "システム",
|
||||
"admin.nav.logs": "ログ",
|
||||
"admin.nav.stats": "統計",
|
||||
"admin.users.title": "ユーザー管理",
|
||||
"admin.users.subtitle": "ユーザーアカウントの表示と管理",
|
||||
"admin.users.planUpdated": "プランが更新されました",
|
||||
@@ -44,5 +45,105 @@
|
||||
"admin.system.waitingData": "データを待機中...",
|
||||
"admin.system.purging": "パージ中...",
|
||||
"admin.system.clean": "クリーン",
|
||||
"admin.system.purge": "パージ"
|
||||
"admin.system.purge": "パージ",
|
||||
"admin.nav.models": "モデル & サブスクリプション",
|
||||
"admin.nav.marketing": "マーケティング",
|
||||
"admin.marketing.audience": "対象者",
|
||||
"admin.marketing.audienceAllUsers": "全アカウント",
|
||||
"admin.marketing.audienceInactive": "30日間非アクティブなアカウント",
|
||||
"admin.marketing.audiencePlaceholder": "対象者を選択",
|
||||
"admin.marketing.audiencePlanBusiness": "Businessプラン",
|
||||
"admin.marketing.audiencePlanEnterprise": "Enterpriseプラン",
|
||||
"admin.marketing.audiencePlanFree": "無料プラン",
|
||||
"admin.marketing.audiencePlanPro": "Proプラン",
|
||||
"admin.marketing.audiencePlanStarter": "Starterプラン",
|
||||
"admin.marketing.audienceWaitlist": "ウェイトリスト",
|
||||
"admin.marketing.badgeReal": "本番",
|
||||
"admin.marketing.badgeTest": "テスト",
|
||||
"admin.marketing.confirmSend": "このメールを {count} 名(「{audience}」)に送信しますか?",
|
||||
"admin.marketing.footerNote": "送信時、配信停止リンク付きフッターが自動で追加されます。",
|
||||
"admin.marketing.hidePreview": "プレビューを隠す",
|
||||
"admin.marketing.history": "送信履歴",
|
||||
"admin.marketing.historyFailedSuffix": "、{count} 件失敗",
|
||||
"admin.marketing.historySent": "{sent}/{total} 送信済み",
|
||||
"admin.marketing.html": "HTMLコンテンツ",
|
||||
"admin.marketing.htmlPlaceholder": "<html><body>…</body></html>",
|
||||
"admin.marketing.incompleteDesc": "件名とHTMLを入力してください。",
|
||||
"admin.marketing.incompleteTitle": "内容が不完全です",
|
||||
"admin.marketing.loadErrorTitle": "読み込みエラー",
|
||||
"admin.marketing.localCheckUnavailable": "このブラウザではローカル確認ができないため、サーバーが事前テスト送信を検証します。",
|
||||
"admin.marketing.networkDesc": "バックエンドに接続できません。",
|
||||
"admin.marketing.newCampaign": "新しいキャンペーン",
|
||||
"admin.marketing.noHistory": "送信履歴はありません。",
|
||||
"admin.marketing.preview": "プレビュー",
|
||||
"admin.marketing.previewEmpty": "プレビュー:左側にHTMLを貼り付けてください。",
|
||||
"admin.marketing.previewTitle": "メールプレビュー",
|
||||
"admin.marketing.recipientsAvailable": "配信停止を除いた {count} 名に送信できます。",
|
||||
"admin.marketing.sendReal": "{count} 名に送信",
|
||||
"admin.marketing.sendRefusedTitle": "送信が拒否されました",
|
||||
"admin.marketing.sendTest": "テスト送信",
|
||||
"admin.marketing.sendingDesc": "{count} 件が送信キューに入りました(0.2秒間隔)。配信停止中の宛先は除外されます。",
|
||||
"admin.marketing.sendingTitle": "送信中",
|
||||
"admin.marketing.subject": "件名",
|
||||
"admin.marketing.subjectPlaceholder": "例:翻訳ができあがっています — 7日間20%オフ",
|
||||
"admin.marketing.subtitle": "対象者へのメール送信。テスト送信が必須で、配信停止リンク自動付加、完全な履歴付き。",
|
||||
"admin.marketing.testDoneDesc": "{email} でメールを受信",
|
||||
"admin.marketing.testDoneTitle": "テスト送信が完了しました",
|
||||
"admin.marketing.testEmail": "テストメール(任意)",
|
||||
"admin.marketing.testEmailPlaceholder": "未指定の場合:SMTP送信者アドレス",
|
||||
"admin.marketing.testFailedTitle": "テスト送信に失敗しました",
|
||||
"admin.marketing.testRequiredHint": "本送信には、同一内容の事前テスト送信が必要です(サーバーが検証します)。",
|
||||
"admin.marketing.testValidated": "この内容のテスト送信は検証済みです。",
|
||||
"admin.marketing.title": "マーケティング — メールキャンペーン",
|
||||
"admin.marketing.unsubscribedNote": "配信停止中の {count} 件はすべての送信から除外されます。",
|
||||
"admin.models.addModel": "モデルを追加",
|
||||
"admin.models.businessBadge": "Businessプラン",
|
||||
"admin.models.cancel": "キャンセル",
|
||||
"admin.models.catalog": "OpenRouterカタログ",
|
||||
"admin.models.catalogErrorDesc": "OpenRouterカタログを読み込めませんでした。",
|
||||
"admin.models.catalogErrorTitle": "カタログを利用できません",
|
||||
"admin.models.customBadge": "カスタムティア",
|
||||
"admin.models.customNote": "カスタムモデルはお客様ごとに個別に決定します。",
|
||||
"admin.models.defaultBadge": "既定",
|
||||
"admin.models.emptyTier": "モデルなし:プランの公式ラインナップが使用されます。",
|
||||
"admin.models.enterpriseBadge": "Enterpriseプラン",
|
||||
"admin.models.essentialBadge": "エッセンシャルAIティア",
|
||||
"admin.models.essentialCost": "— コスト係数 1 で課金",
|
||||
"admin.models.fallbackFirst": "(フォールバック第1位)",
|
||||
"admin.models.loadErrorDesc": "HTTP {status} — 管理者トークンを確認してください。",
|
||||
"admin.models.loadErrorTitle": "読み込みエラー",
|
||||
"admin.models.matrixDesc": "ティアごとの有効モデル — リストの順序がフォールバック優先度です。ラジオボタンで既定モデルを選ぶと、再デプロイなしですぐに反映されます。",
|
||||
"admin.models.matrixTitle": "プラン別モデルマトリクス",
|
||||
"admin.models.missingConfig": "AIティアの設定が見つかりません。ページを再読み込みしてください。",
|
||||
"admin.models.moveDown": "下へ移動",
|
||||
"admin.models.moveUp": "上へ移動",
|
||||
"admin.models.networkDesc": "バックエンドに接続できません。",
|
||||
"admin.models.networkTitle": "ネットワークエラー",
|
||||
"admin.models.premiumBadge": "プレミアムAIティア",
|
||||
"admin.models.premiumCost": "— コスト係数 5 で課金",
|
||||
"admin.models.premiumReservedNote": "Business・Enterpriseプラン専用(「openrouter_premium」エンジン)。",
|
||||
"admin.models.proBadge": "Proプラン",
|
||||
"admin.models.remove": "削除",
|
||||
"admin.models.save": "保存",
|
||||
"admin.models.saveErrorTitle": "保存エラー",
|
||||
"admin.models.saveNetworkDesc": "設定を保存できませんでした。",
|
||||
"admin.models.savedDesc": "新しい既定モデルは次の翻訳から使用されます(再デプロイ不要)。",
|
||||
"admin.models.savedTitle": "モデルを保存しました",
|
||||
"admin.models.saving": "保存中...",
|
||||
"admin.models.setDefault": "{model} を既定モデルに設定",
|
||||
"admin.models.sharedTierNote": "共通ティア:Businessプランも「openrouter」エンジンにはエッセンシャルティアを使います — 上のリストは両プランに適用されます。",
|
||||
"admin.models.subtitle": "プラン → AIティアのマトリクス。翻訳ごとに実際に使われるモデルはプランのティアのモデルです:Proプランがプレミアムモデルを呼び出すことは決してありません。",
|
||||
"admin.models.title": "モデル & サブスクリプション",
|
||||
"admin.stats.aiTiers": "AIティア(30日)",
|
||||
"admin.stats.includingCredits": "うちクレジット {amount}",
|
||||
"admin.stats.mrr": "推定MRR",
|
||||
"admin.stats.noTranslationsYet": "過去30日間の翻訳記録がありません:今後の翻訳でティア別内訳が埋まります。",
|
||||
"admin.stats.payments30": "30日間の支払い {count} 件",
|
||||
"admin.stats.refreshing": "更新中...",
|
||||
"admin.stats.revenue30": "売上(30日)",
|
||||
"admin.stats.revenueTotal": "累計売上",
|
||||
"admin.stats.tiersSub": "エッセンシャル / プレミアム · クラシック {classic} · その他 {other}",
|
||||
"admin.stats.unavailable": "ビジネス統計を利用できません({error})。",
|
||||
"admin.stats.waitlist": "ウェイトリスト",
|
||||
"admin.stats.waitlistSub": "待機中の登録者"
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
"landing.pricing.pro.desc": "プロフェッショナル向け",
|
||||
"landing.pricing.pro.f1": "月200ドキュメント",
|
||||
"landing.pricing.pro.f2": "1ドキュメント最大200ページ",
|
||||
"landing.pricing.pro.f3": "AI翻訳",
|
||||
"landing.pricing.pro.f3": "エッセンシャルAI:DeepSeek V4 Flash、GLM-5.3 Flash、MiniMax M3",
|
||||
"landing.pricing.pro.f4": "Google込み",
|
||||
"landing.pricing.pro.f5": "カスタム用語集とプロンプト",
|
||||
"landing.pricing.pro.f6": "優先サポート",
|
||||
@@ -109,7 +109,7 @@
|
||||
"landing.pricing.business.desc": "大量処理が必要なチーム向け",
|
||||
"landing.pricing.business.f1": "月1,000ドキュメント",
|
||||
"landing.pricing.business.f2": "1ドキュメント最大500ページ",
|
||||
"landing.pricing.business.f3": "プレミアムAI(Claude)",
|
||||
"landing.pricing.business.f3": "プレミアムAI(Claude 5)",
|
||||
"landing.pricing.business.f4": "全プロバイダー + API",
|
||||
"landing.pricing.business.f5": "Webhookと自動化",
|
||||
"landing.pricing.business.f6": "チームシート5席",
|
||||
@@ -142,5 +142,31 @@
|
||||
"landing.translate.supportedFormats": "DOCX, XLSX, PPTX, PDFファイル対応",
|
||||
"landing.translate.aiAnalysis": "AI分析アクティブ",
|
||||
"landing.translate.processing": "処理中",
|
||||
"landing.translate.preservingLayout": "レイアウトを保持しています"
|
||||
"landing.translate.preservingLayout": "レイアウトを保持しています",
|
||||
"landing.beforeAfter.seal": "同じレイアウト、一語一句そのまま",
|
||||
"landing.beforeAfter.proof1": "SmartArt図面は平面化されず、再構築されます",
|
||||
"landing.beforeAfter.proof2": "グラフの系列と軸も翻訳",
|
||||
"landing.beforeAfter.proof3": "目次は翻訳先言語で再生成",
|
||||
"landing.beforeAfter.targetTitle": "技術仕様 — 空調処理",
|
||||
"landing.beforeAfter.targetPara1": "換気設備は第2四半期末までに稼働させなければなりません。",
|
||||
"landing.beforeAfter.targetPara2Before": "",
|
||||
"landing.beforeAfter.targetTerm": "空調機",
|
||||
"landing.beforeAfter.targetPara2After": "は計画どおりフィルタクラス F7 を満たす必要があります。",
|
||||
"landing.beforeAfter.targetItem1": "熱負荷:定格流量で42 kW",
|
||||
"landing.beforeAfter.targetItem2": "騒音レベル:3 mで45 dB(A)未満",
|
||||
"landing.formats.pill": "対応フォーマット",
|
||||
"landing.hero.visualCaption": "同じレイアウト、新しい言語 — 他は一切変わりません",
|
||||
"landing.pricing.free.name": "無料",
|
||||
"landing.pricing.free.desc": "アプリのお試しに最適",
|
||||
"landing.pricing.free.cta": "このプランを選択",
|
||||
"landing.pricing.enterprise.name": "エンタープライズ",
|
||||
"landing.pricing.enterprise.desc": "大規模組織向けのカスタムソリューション",
|
||||
"landing.pricing.enterprise.cta": "お問い合わせ",
|
||||
"landing.beforeAfter.sourceTitle": "Cahier des charges — Traitement d'air",
|
||||
"landing.beforeAfter.sourcePara1": "L'installation de ventilation doit être mise en service avant la fin du deuxième trimestre.",
|
||||
"landing.beforeAfter.sourcePara2Before": "Le",
|
||||
"landing.beforeAfter.sourceTerm": "groupe de traitement d'air",
|
||||
"landing.beforeAfter.sourcePara2After": "doit respecter la classe de filtration F7 conformément au planning.",
|
||||
"landing.beforeAfter.sourceItem1": "Charge thermique : 42 kW au débit nominal",
|
||||
"landing.beforeAfter.sourceItem2": "Niveau sonore inférieur à 45 dB(A) à 3 m"
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"pricing.plans.pro.feat8": "90日間の履歴",
|
||||
"pricing.plans.business.feat1": "月1,000ドキュメント",
|
||||
"pricing.plans.business.feat2": "1ドキュメントあたり最大500ページ",
|
||||
"pricing.plans.business.feat3": "エッセンシャル + プレミアムAI(Claude Haiku)",
|
||||
"pricing.plans.business.feat3": "エッセンシャル + プレミアムAI(Claude 5)",
|
||||
"pricing.plans.business.feat4": "全翻訳プロバイダー",
|
||||
"pricing.plans.business.feat5": "最大50 MBのファイル",
|
||||
"pricing.plans.business.feat6": "APIアクセス(月10,000コール)",
|
||||
@@ -50,7 +50,7 @@
|
||||
"pricing.plans.business.feat9": "1年間の履歴",
|
||||
"pricing.plans.business.feat10": "高度な分析",
|
||||
"pricing.plans.enterprise.feat1": "無制限ドキュメント",
|
||||
"pricing.plans.enterprise.feat2": "全AIモデル(GPT-5、Claude Opus 4.6…)",
|
||||
"pricing.plans.enterprise.feat2": "全AIモデル(Claude 5、DeepSeek V4 Pro、GLM-5.3…)",
|
||||
"pricing.plans.enterprise.feat3": "オンプレミスまたは専用クラウド展開",
|
||||
"pricing.plans.enterprise.feat4": "99.9% SLA保証",
|
||||
"pricing.plans.enterprise.feat5": "24/7専任サポート",
|
||||
@@ -108,19 +108,19 @@
|
||||
"pricing.trust.parallel.sub": "超高速マルチスレッドAI",
|
||||
"pricing.trust.availability.title": "24/7利用可能",
|
||||
"pricing.trust.availability.sub": "99.9%稼働率保証",
|
||||
"pricing.aiModels.title": "AIモデル一覧 — 2026年3月",
|
||||
"pricing.aiModels.title": "AIモデル一覧 — 2026年9月",
|
||||
"pricing.aiModels.essential.title": "エッセンシャルAI翻訳",
|
||||
"pricing.aiModels.essential.plan": "Proプラン",
|
||||
"pricing.aiModels.essential.descPrefix": "ベース:",
|
||||
"pricing.aiModels.essential.descSuffix": "— 2026年で最も費用対効果の高いAIモデル。フロンティアモデルと同等の品質をわずかなコストで実現。",
|
||||
"pricing.aiModels.essential.modelName": "Essential AIモデル",
|
||||
"pricing.aiModels.essential.context": "163Kトークンのコンテキスト",
|
||||
"pricing.aiModels.essential.value": "優れた費用対効果",
|
||||
"pricing.aiModels.essential.descSuffix": "— 2026年で最も費用対効果の高いAIモデル群。",
|
||||
"pricing.aiModels.essential.modelName": "DeepSeek V4 Flash、GLM-5.3 Flash、MiniMax M3",
|
||||
"pricing.aiModels.essential.context": "最大130万トークンのコンテキスト(GLM-5.3 Flash)",
|
||||
"pricing.aiModels.essential.value": "最高のコストパフォーマンス",
|
||||
"pricing.aiModels.premium.title": "プレミアムAI翻訳",
|
||||
"pricing.aiModels.premium.plan": "Businessプラン",
|
||||
"pricing.aiModels.premium.descPrefix": "ベース:",
|
||||
"pricing.aiModels.premium.descSuffix": "(Anthropic社製)— 法務、医療、複雑な技術文書に高精度。",
|
||||
"pricing.aiModels.premium.context": "200Kトークンのコンテキスト",
|
||||
"pricing.aiModels.premium.context": "100万トークンのコンテキスト",
|
||||
"pricing.aiModels.premium.precision": "最高精度",
|
||||
"pricing.faq.title": "よくある質問",
|
||||
"pricing.faq.q1": "いつでもプランを変更できますか?",
|
||||
@@ -128,7 +128,7 @@
|
||||
"pricing.faq.q2": "「エッセンシャルAI翻訳」とは何ですか?",
|
||||
"pricing.faq.a2": "独自のAIエンジンです。ドキュメントの文脈を理解し、レイアウトを保持し、技術用語を従来の翻訳よりはるかに適切に処理します。",
|
||||
"pricing.faq.q3": "エッセンシャルAIとプレミアムAIの違いは何ですか?",
|
||||
"pricing.faq.a3": "Essential AIは最適化されたモデルを使用しています(コストパフォーマンスに優れています)。Premium AIはAnthropicのClaude 3.5 Haikuを使用し、法的、医学的、複雑な技術文書により正確です。",
|
||||
"pricing.faq.a3": "Essential AIはDeepSeek V4 Flash、GLM-5.3 Flash、MiniMax M3を使用しています(コストパフォーマンスに優れています)。Premium AIはAnthropicのClaude 5を使用し、法的、医学的、複雑な技術文書により正確です。",
|
||||
"pricing.faq.q4": "翻訳後もドキュメントは保存されますか?",
|
||||
"pricing.faq.a4": "翻訳済みファイルはプランに応じて利用可能です(Starter 30日、Pro 90日、Business 1年)。保存時および通信時は暗号化されています。",
|
||||
"pricing.faq.q5": "月間枠を超えた場合はどうなりますか?",
|
||||
@@ -146,5 +146,26 @@
|
||||
"pricing.toast.paymentError": "支払いの作成中にエラーが発生しました。",
|
||||
"pricing.dashboard": "ダッシュボード",
|
||||
"pricing.okSymbol": "✓",
|
||||
"pricing.errSymbol": "✕"
|
||||
"pricing.errSymbol": "✕",
|
||||
"pricing.aiModels.essential.price": "100万トークンあたり$0.09から",
|
||||
"pricing.aiModels.premium.modelName": "Claude 5",
|
||||
"pricing.aiModels.premium.alternatives": "代替モデル:DeepSeek V4 Pro、GLM-5.3",
|
||||
"pricing.confirm.title": "サブスクリプションの確認",
|
||||
"pricing.confirm.subtitle": "安全な決済プロバイダーへ移動します。",
|
||||
"pricing.confirm.cancel": "キャンセル",
|
||||
"pricing.confirm.cta": "支払いへ進む",
|
||||
"pricing.confirm.year": "年",
|
||||
"pricing.confirm.month": "月",
|
||||
"pricing.confirm.monthlyEquivalent": "年間請求 — 月あたり {price} ユーロ相当。",
|
||||
"pricing.confirm.secureNote": "プロフィールからいつでも解約できます。決済はStripeが処理し、カード情報が当社のサーバーに送信されることはありません。",
|
||||
"pricing.enterprise.subject": "エンタープライズプランについてのお問い合わせ",
|
||||
"pricing.enterpriseBand.cta": "お問い合わせ",
|
||||
"pricing.enterpriseBand.text": "大量処理、専用エンジン、オンプレミス対応 — ご相談ください。",
|
||||
"pricing.error.server": "サーバーエラー {status}",
|
||||
"pricing.freeBand.cta": "無料で始める",
|
||||
"pricing.freeBand.text": "まず試してみたい方へ — 毎月5件まで無料、カード不要。",
|
||||
"pricing.header.titleBase": "あらゆるニーズに",
|
||||
"pricing.header.titleAccent": "ぴったりのプラン",
|
||||
"pricing.toast.close": "閉じる",
|
||||
"pricing.aiModels.premium.price": "100万トークンあたり $2 / $10"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"providerTheme.openrouter.desc": "翻訳に最適化された最高のオープンソースモデルへの統一アクセス。",
|
||||
"providerTheme.openrouter_premium.badge": "ウルトラ",
|
||||
"providerTheme.openrouter_premium.subBadge": "最大コンテキスト",
|
||||
"providerTheme.openrouter_premium.desc": "GPT-4o、Claude Sonnet 4.6 などの最先端モデルによる長文支援。",
|
||||
"providerTheme.openrouter_premium.desc": "GPT-4o、Claude 5 などの最先端モデルによる長文支援。",
|
||||
"providerTheme.zai.badge": "特化型",
|
||||
"providerTheme.zai.subBadge": "金融と法律",
|
||||
"providerTheme.zai.desc": "要求の厳しいビジネス用語 (法務、金融) 向けに微調整されたモデル。",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"admin.nav.providers": "제공업체",
|
||||
"admin.nav.system": "시스템",
|
||||
"admin.nav.logs": "로그",
|
||||
"admin.nav.stats": "통계",
|
||||
"admin.users.title": "사용자 관리",
|
||||
"admin.users.subtitle": "사용자 계정 보기 및 관리",
|
||||
"admin.users.planUpdated": "플랜이 업데이트되었습니다",
|
||||
@@ -44,5 +45,105 @@
|
||||
"admin.system.waitingData": "데이터 대기 중...",
|
||||
"admin.system.purging": "삭제 중...",
|
||||
"admin.system.clean": "정리",
|
||||
"admin.system.purge": "삭제"
|
||||
"admin.system.purge": "삭제",
|
||||
"admin.nav.models": "모델 & 구독",
|
||||
"admin.nav.marketing": "마케팅",
|
||||
"admin.marketing.audience": "대상",
|
||||
"admin.marketing.audienceAllUsers": "전체 계정",
|
||||
"admin.marketing.audienceInactive": "30일간 비활성 계정",
|
||||
"admin.marketing.audiencePlaceholder": "대상 선택",
|
||||
"admin.marketing.audiencePlanBusiness": "Business 플랜",
|
||||
"admin.marketing.audiencePlanEnterprise": "Enterprise 플랜",
|
||||
"admin.marketing.audiencePlanFree": "무료 플랜",
|
||||
"admin.marketing.audiencePlanPro": "Pro 플랜",
|
||||
"admin.marketing.audiencePlanStarter": "Starter 플랜",
|
||||
"admin.marketing.audienceWaitlist": "대기 명단",
|
||||
"admin.marketing.badgeReal": "실제",
|
||||
"admin.marketing.badgeTest": "테스트",
|
||||
"admin.marketing.confirmSend": "이 메일을 {count}명(«{audience}»)에게 보내시겠습니까?",
|
||||
"admin.marketing.footerNote": "발송 시 수신거부 링크가 있는 바닥글이 자동으로 추가됩니다.",
|
||||
"admin.marketing.hidePreview": "미리보기 숨기기",
|
||||
"admin.marketing.history": "발송 이력",
|
||||
"admin.marketing.historyFailedSuffix": ", {count}건 실패",
|
||||
"admin.marketing.historySent": "{sent}/{total} 발송",
|
||||
"admin.marketing.html": "HTML 내용",
|
||||
"admin.marketing.htmlPlaceholder": "<html><body>…</body></html>",
|
||||
"admin.marketing.incompleteDesc": "제목과 HTML을 입력하세요.",
|
||||
"admin.marketing.incompleteTitle": "내용 불완전",
|
||||
"admin.marketing.loadErrorTitle": "불러오기 오류",
|
||||
"admin.marketing.localCheckUnavailable": "이 브라우저에서는 로컬 확인이 불가하여 서버가 사전 테스트 발송을 검증합니다.",
|
||||
"admin.marketing.networkDesc": "백엔드에 연결할 수 없습니다.",
|
||||
"admin.marketing.newCampaign": "새 캠페인",
|
||||
"admin.marketing.noHistory": "발송 기록이 없습니다.",
|
||||
"admin.marketing.preview": "미리보기",
|
||||
"admin.marketing.previewEmpty": "미리보기: 왼쪽에 HTML을 붙여넣으세요.",
|
||||
"admin.marketing.previewTitle": "이메일 미리보기",
|
||||
"admin.marketing.recipientsAvailable": "수신거부 제외 후 {count}명에게 발송할 수 있습니다.",
|
||||
"admin.marketing.sendReal": "{count}명에게 보내기",
|
||||
"admin.marketing.sendRefusedTitle": "발송이 거부되었습니다",
|
||||
"admin.marketing.sendTest": "테스트 발송",
|
||||
"admin.marketing.sendingDesc": "{count}건이 발송 대기열에 있습니다(0.2초 간격). 수신거부는 제외됩니다.",
|
||||
"admin.marketing.sendingTitle": "발송 진행 중",
|
||||
"admin.marketing.subject": "제목",
|
||||
"admin.marketing.subjectPlaceholder": "예: 번역이 기다리고 있습니다 — 7일간 20% 할인",
|
||||
"admin.marketing.subtitle": "대상에게 이메일을 보냅니다. 필수 테스트 발송, 자동 수신거부 링크, 전체 이력이 포함됩니다.",
|
||||
"admin.marketing.testDoneDesc": "{email}에서 메일 수신",
|
||||
"admin.marketing.testDoneTitle": "테스트 발송 완료",
|
||||
"admin.marketing.testEmail": "테스트 이메일 (선택)",
|
||||
"admin.marketing.testEmailPlaceholder": "미입력 시: SMTP 발신 주소",
|
||||
"admin.marketing.testFailedTitle": "테스트 발송 실패",
|
||||
"admin.marketing.testRequiredHint": "실제 발송에는 동일 내용의 사전 테스트 발송이 필요합니다(서버가 검증).",
|
||||
"admin.marketing.testValidated": "이 내용의 테스트 발송이 검증되었습니다.",
|
||||
"admin.marketing.title": "마케팅 — 이메일 캠페인",
|
||||
"admin.marketing.unsubscribedNote": "수신거부 {count}건은 모든 발송에서 제외됩니다.",
|
||||
"admin.models.addModel": "모델 추가",
|
||||
"admin.models.businessBadge": "Business 플랜",
|
||||
"admin.models.cancel": "취소",
|
||||
"admin.models.catalog": "OpenRouter 카탈로그",
|
||||
"admin.models.catalogErrorDesc": "OpenRouter 카탈로그를 불러오지 못했습니다.",
|
||||
"admin.models.catalogErrorTitle": "카탈로그 사용 불가",
|
||||
"admin.models.customBadge": "맞춤 등급",
|
||||
"admin.models.customNote": "맞춤 모델은 고객과 사례별로 정의합니다.",
|
||||
"admin.models.defaultBadge": "기본",
|
||||
"admin.models.emptyTier": "모델 없음: 플랜의 공식 라인업이 사용됩니다.",
|
||||
"admin.models.enterpriseBadge": "Enterprise 플랜",
|
||||
"admin.models.essentialBadge": "에센셜 AI 등급",
|
||||
"admin.models.essentialCost": "— 비용 계수 1로 청구",
|
||||
"admin.models.fallbackFirst": "(폴백 1순위)",
|
||||
"admin.models.loadErrorDesc": "HTTP {status} — 관리자 토큰을 확인하세요.",
|
||||
"admin.models.loadErrorTitle": "불러오기 오류",
|
||||
"admin.models.matrixDesc": "등급별 활성 모델 — 목록 순서가 폴백 우선순위입니다. 라디오 버튼으로 기본 모델을 선택하면 재배포 없이 즉시 적용됩니다.",
|
||||
"admin.models.matrixTitle": "플랜별 모델 매트릭스",
|
||||
"admin.models.missingConfig": "AI 등급 구성을 찾을 수 없습니다. 페이지를 새로 고침하세요.",
|
||||
"admin.models.moveDown": "아래로 이동",
|
||||
"admin.models.moveUp": "위로 이동",
|
||||
"admin.models.networkDesc": "백엔드에 연결할 수 없습니다.",
|
||||
"admin.models.networkTitle": "네트워크 오류",
|
||||
"admin.models.premiumBadge": "프리미엄 AI 등급",
|
||||
"admin.models.premiumCost": "— 비용 계수 5로 청구",
|
||||
"admin.models.premiumReservedNote": "Business·Enterprise 플랜 전용 («openrouter_premium» 엔진).",
|
||||
"admin.models.proBadge": "Pro 플랜",
|
||||
"admin.models.remove": "제거",
|
||||
"admin.models.save": "저장",
|
||||
"admin.models.saveErrorTitle": "저장 오류",
|
||||
"admin.models.saveNetworkDesc": "구성을 저장할 수 없습니다.",
|
||||
"admin.models.savedDesc": "새 기본 모델은 다음 번역부터 재배포 없이 적용됩니다.",
|
||||
"admin.models.savedTitle": "모델 저장됨",
|
||||
"admin.models.saving": "저장 중...",
|
||||
"admin.models.setDefault": "{model}을(를) 기본 모델로 설정",
|
||||
"admin.models.sharedTierNote": "공유 등급: Business 플랜도 «openrouter» 엔진에는 에센셜 등급을 사용합니다 — 위 목록은 두 플랜 모두에 적용됩니다.",
|
||||
"admin.models.subtitle": "플랜 → AI 등급 매트릭스. 번역 시 실제 사용되는 모델은 플랜 등급의 모델입니다: Pro 플랜은 프리미엄 모델을 절대 호출할 수 없습니다.",
|
||||
"admin.models.title": "모델 & 구독",
|
||||
"admin.stats.aiTiers": "AI 등급 (30일)",
|
||||
"admin.stats.includingCredits": "크레딧 {amount} 포함",
|
||||
"admin.stats.mrr": "추정 MRR",
|
||||
"admin.stats.noTranslationsYet": "최근 30일간 번역 기록이 없습니다: 앞으로의 번역으로 등급별 분포가 채워집니다.",
|
||||
"admin.stats.payments30": "30일간 결제 {count}건",
|
||||
"admin.stats.refreshing": "새로 고치는 중...",
|
||||
"admin.stats.revenue30": "매출 (30일)",
|
||||
"admin.stats.revenueTotal": "총 수령 매출",
|
||||
"admin.stats.tiersSub": "에센셜 / 프리미엄 · 클래식 {classic} · 기타 {other}",
|
||||
"admin.stats.unavailable": "비즈니스 통계를 사용할 수 없습니다 ({error}).",
|
||||
"admin.stats.waitlist": "대기 명단",
|
||||
"admin.stats.waitlistSub": "대기 중인 등록자"
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
"landing.pricing.pro.desc": "전문가용",
|
||||
"landing.pricing.pro.f1": "월 200개 문서",
|
||||
"landing.pricing.pro.f2": "문서당 최대 200페이지",
|
||||
"landing.pricing.pro.f3": "AI 번역",
|
||||
"landing.pricing.pro.f3": "에센셜 AI: DeepSeek V4 Flash, GLM-5.3 Flash, MiniMax M3",
|
||||
"landing.pricing.pro.f4": "Google 포함",
|
||||
"landing.pricing.pro.f5": "맞춤 용어집 및 프롬프트",
|
||||
"landing.pricing.pro.f6": "우선 지원",
|
||||
@@ -109,7 +109,7 @@
|
||||
"landing.pricing.business.desc": "대용량이 필요한 팀용",
|
||||
"landing.pricing.business.f1": "월 1,000개 문서",
|
||||
"landing.pricing.business.f2": "문서당 최대 500페이지",
|
||||
"landing.pricing.business.f3": "프리미엄 AI (Claude)",
|
||||
"landing.pricing.business.f3": "프리미엄 AI (Claude 5)",
|
||||
"landing.pricing.business.f4": "모든 제공자 + API",
|
||||
"landing.pricing.business.f5": "웹훅 및 자동화",
|
||||
"landing.pricing.business.f6": "팀 시트 5석",
|
||||
@@ -142,5 +142,31 @@
|
||||
"landing.translate.supportedFormats": "DOCX, XLSX, PPTX 또는 PDF 파일 지원",
|
||||
"landing.translate.aiAnalysis": "AI 분석 활성",
|
||||
"landing.translate.processing": "처리 중",
|
||||
"landing.translate.preservingLayout": "레이아웃이 보존되고 있습니다"
|
||||
"landing.translate.preservingLayout": "레이아웃이 보존되고 있습니다",
|
||||
"landing.beforeAfter.seal": "동일한 레이아웃, 단어 하나까지 그대로",
|
||||
"landing.beforeAfter.proof1": "SmartArt 다이어그램은 병합되지 않고 재구성됩니다",
|
||||
"landing.beforeAfter.proof2": "차트 계열과 축 번역",
|
||||
"landing.beforeAfter.proof3": "목차는 대상 언어로 재생성",
|
||||
"landing.beforeAfter.targetTitle": "기술 사양 — 공기 처리",
|
||||
"landing.beforeAfter.targetPara1": "환기 설비는 2분기 종료 전에 가동되어야 합니다.",
|
||||
"landing.beforeAfter.targetPara2Before": "",
|
||||
"landing.beforeAfter.targetTerm": "공기조화기",
|
||||
"landing.beforeAfter.targetPara2After": "는 일정에 따라 F7 필터 등급을 준수해야 합니다.",
|
||||
"landing.beforeAfter.targetItem1": "열부하: 정격 유량에서 42kW",
|
||||
"landing.beforeAfter.targetItem2": "소음 수준: 3m에서 45dB(A) 미만",
|
||||
"landing.formats.pill": "호환성",
|
||||
"landing.hero.visualCaption": "동일한 레이아웃, 새로운 언어 — 그 외에는 아무것도 바뀌지 않습니다",
|
||||
"landing.pricing.free.name": "무료",
|
||||
"landing.pricing.free.desc": "앱을 경험하기에 적합",
|
||||
"landing.pricing.free.cta": "이 플랜 선택",
|
||||
"landing.pricing.enterprise.name": "엔터프라이즈",
|
||||
"landing.pricing.enterprise.desc": "대규모 조직을 위한 맞춤 솔루션",
|
||||
"landing.pricing.enterprise.cta": "문의하기",
|
||||
"landing.beforeAfter.sourceTitle": "Cahier des charges — Traitement d'air",
|
||||
"landing.beforeAfter.sourcePara1": "L'installation de ventilation doit être mise en service avant la fin du deuxième trimestre.",
|
||||
"landing.beforeAfter.sourcePara2Before": "Le",
|
||||
"landing.beforeAfter.sourceTerm": "groupe de traitement d'air",
|
||||
"landing.beforeAfter.sourcePara2After": "doit respecter la classe de filtration F7 conformément au planning.",
|
||||
"landing.beforeAfter.sourceItem1": "Charge thermique : 42 kW au débit nominal",
|
||||
"landing.beforeAfter.sourceItem2": "Niveau sonore inférieur à 45 dB(A) à 3 m"
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"pricing.plans.pro.feat8": "90일 기록",
|
||||
"pricing.plans.business.feat1": "월 1,000개 문서",
|
||||
"pricing.plans.business.feat2": "문서당 최대 500페이지",
|
||||
"pricing.plans.business.feat3": "에센셜 + 프리미엄 AI (Claude Haiku)",
|
||||
"pricing.plans.business.feat3": "에센셜 + 프리미엄 AI (Claude 5)",
|
||||
"pricing.plans.business.feat4": "모든 번역 제공업체",
|
||||
"pricing.plans.business.feat5": "최대 50 MB 파일",
|
||||
"pricing.plans.business.feat6": "API 액세스 (월 10,000회 호출)",
|
||||
@@ -50,7 +50,7 @@
|
||||
"pricing.plans.business.feat9": "1년 기록",
|
||||
"pricing.plans.business.feat10": "고급 분석",
|
||||
"pricing.plans.enterprise.feat1": "무제한 문서",
|
||||
"pricing.plans.enterprise.feat2": "모든 AI 모델 (GPT-5, Claude Opus 4.6…)",
|
||||
"pricing.plans.enterprise.feat2": "모든 AI 모델 (Claude 5, DeepSeek V4 Pro, GLM-5.3…)",
|
||||
"pricing.plans.enterprise.feat3": "온프레미스 또는 전용 클라우드 배포",
|
||||
"pricing.plans.enterprise.feat4": "99.9% SLA 보장",
|
||||
"pricing.plans.enterprise.feat5": "24/7 전담 지원",
|
||||
@@ -108,19 +108,19 @@
|
||||
"pricing.trust.parallel.sub": "초고속 멀티스레드 AI",
|
||||
"pricing.trust.availability.title": "24/7 이용 가능",
|
||||
"pricing.trust.availability.sub": "99.9% 가동 보장",
|
||||
"pricing.aiModels.title": "AI 모델 — 2026년 3월",
|
||||
"pricing.aiModels.title": "AI 모델 — 2026년 9월",
|
||||
"pricing.aiModels.essential.title": "에센셜 AI 번역",
|
||||
"pricing.aiModels.essential.plan": "Pro 플랜",
|
||||
"pricing.aiModels.essential.descPrefix": "기반:",
|
||||
"pricing.aiModels.essential.descSuffix": "— 2026년 가장 비용 효율적인 AI 모델. 프론티어 모델과 동등한 품질을 극소수의 비용으로 제공.",
|
||||
"pricing.aiModels.essential.modelName": "Essential AI 모델",
|
||||
"pricing.aiModels.essential.context": "163K 토큰 컨텍스트",
|
||||
"pricing.aiModels.essential.value": "우수한 가성비",
|
||||
"pricing.aiModels.essential.descSuffix": "— 2026년 가장 비용 효율적인 AI 모델.",
|
||||
"pricing.aiModels.essential.modelName": "DeepSeek V4 Flash, GLM-5.3 Flash, MiniMax M3",
|
||||
"pricing.aiModels.essential.context": "최대 130만 토큰 컨텍스트 (GLM-5.3 Flash)",
|
||||
"pricing.aiModels.essential.value": "최고의 가성비",
|
||||
"pricing.aiModels.premium.title": "프리미엄 AI 번역",
|
||||
"pricing.aiModels.premium.plan": "Business 플랜",
|
||||
"pricing.aiModels.premium.descPrefix": "기반:",
|
||||
"pricing.aiModels.premium.descSuffix": "Anthropic사 제품 — 법률, 의료 및 복잡한 기술 문서에서 높은 정확도.",
|
||||
"pricing.aiModels.premium.context": "200K 토큰 컨텍스트",
|
||||
"pricing.aiModels.premium.context": "100만 토큰 컨텍스트",
|
||||
"pricing.aiModels.premium.precision": "최고 정확도",
|
||||
"pricing.faq.title": "자주 묻는 질문",
|
||||
"pricing.faq.q1": "언제든지 플랜을 변경할 수 있나요?",
|
||||
@@ -128,7 +128,7 @@
|
||||
"pricing.faq.q2": "「에센셜 AI 번역」이란 무엇인가요?",
|
||||
"pricing.faq.a2": "당사의 AI 엔진입니다. 문서의 맥락을 이해하고, 레이아웃을 보존하며, 기술 용어를 기존 번역보다 훨씬 잘 처리합니다.",
|
||||
"pricing.faq.q3": "에센셜 AI와 프리미엄 AI의 차이점은 무엇인가요?",
|
||||
"pricing.faq.a3": "Essential AI는 최적화된 모델을 사용합니다 (우수한 가성비). Premium AI는 Anthropic의 Claude 3.5 Haiku를 사용하여 법률, 의료 및 복잡한 기술 문서에서 더 정확합니다.",
|
||||
"pricing.faq.a3": "Essential AI는 DeepSeek V4 Flash, GLM-5.3 Flash, MiniMax M3를 사용합니다 (우수한 가성비). Premium AI는 Anthropic의 Claude 5를 사용하여 법률, 의료 및 복잡한 기술 문서에서 더 정확합니다.",
|
||||
"pricing.faq.q4": "번역 후 문서가 보관되나요?",
|
||||
"pricing.faq.a4": "번역된 파일은 플랜에 따라 이용 가능합니다 (Starter 30일, Pro 90일, Business 1년). 저장 시 및 전송 중 암호화됩니다.",
|
||||
"pricing.faq.q5": "월간 할당량을 초과하면 어떻게 되나요?",
|
||||
@@ -146,5 +146,26 @@
|
||||
"pricing.toast.paymentError": "결제 생성 중 오류가 발생했습니다.",
|
||||
"pricing.dashboard": "대시보드",
|
||||
"pricing.okSymbol": "✓",
|
||||
"pricing.errSymbol": "✕"
|
||||
"pricing.errSymbol": "✕",
|
||||
"pricing.aiModels.essential.price": "백만 토큰당 $0.09부터",
|
||||
"pricing.aiModels.premium.modelName": "Claude 5",
|
||||
"pricing.aiModels.premium.alternatives": "대체 모델: DeepSeek V4 Pro, GLM-5.3",
|
||||
"pricing.confirm.title": "구독 확인",
|
||||
"pricing.confirm.subtitle": "안전한 결제 대행사로 이동합니다.",
|
||||
"pricing.confirm.cancel": "취소",
|
||||
"pricing.confirm.cta": "결제로 계속",
|
||||
"pricing.confirm.year": "년",
|
||||
"pricing.confirm.month": "개월",
|
||||
"pricing.confirm.monthlyEquivalent": "연간 결제 — 월 {price}유로 상당.",
|
||||
"pricing.confirm.secureNote": "프로필에서 언제든지 해지할 수 있습니다. 결제는 Stripe에서 처리되며 카드 정보는 당사 서버에 저장되지 않습니다.",
|
||||
"pricing.enterprise.subject": "엔터프라이즈 문의",
|
||||
"pricing.enterpriseBand.cta": "문의하기",
|
||||
"pricing.enterpriseBand.text": "대용량, 전용 엔진, 온프레미스 옵션 — 상담해 보세요.",
|
||||
"pricing.error.server": "서버 오류 {status}",
|
||||
"pricing.freeBand.cta": "무료로 시작",
|
||||
"pricing.freeBand.text": "일단 써보고 싶으신가요? 무료로 시작 — 월 5개 문서, 카드 불필요.",
|
||||
"pricing.header.titleBase": "모든 필요를 위한",
|
||||
"pricing.header.titleAccent": "하나의 플랜",
|
||||
"pricing.toast.close": "닫기",
|
||||
"pricing.aiModels.premium.price": "백만 토큰당 $2 / $10"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"providerTheme.openrouter.desc": "번역에 최적화된 최고의 오픈소스 모델에 대한 통합 액세스.",
|
||||
"providerTheme.openrouter_premium.badge": "울트라",
|
||||
"providerTheme.openrouter_premium.subBadge": "최대 컨텍스트",
|
||||
"providerTheme.openrouter_premium.desc": "최신 모델(GPT-4o, Claude Sonnet 4.6)의 지원을 받는 긴 문서 번역.",
|
||||
"providerTheme.openrouter_premium.desc": "최신 모델(GPT-4o, Claude 5)의 지원을 받는 긴 문서 번역.",
|
||||
"providerTheme.zai.badge": "전문",
|
||||
"providerTheme.zai.subBadge": "금융 및 법률",
|
||||
"providerTheme.zai.desc": "까다로운 비즈니스 용어(법률, 금융)에 맞게 미세 조정된 모델.",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"admin.nav.providers": "Providers",
|
||||
"admin.nav.system": "Systeem",
|
||||
"admin.nav.logs": "Logboeken",
|
||||
"admin.nav.stats": "Statistieken",
|
||||
"admin.users.title": "Gebruikersbeheer",
|
||||
"admin.users.subtitle": "Gebruikersaccounts bekijken en beheren",
|
||||
"admin.users.planUpdated": "Abonnement bijgewerkt",
|
||||
@@ -44,5 +45,105 @@
|
||||
"admin.system.waitingData": "Wachten op gegevens...",
|
||||
"admin.system.purging": "Wissen...",
|
||||
"admin.system.clean": "Opschonen",
|
||||
"admin.system.purge": "Wissen"
|
||||
"admin.system.purge": "Wissen",
|
||||
"admin.nav.models": "Modellen & abonnementen",
|
||||
"admin.nav.marketing": "Marketing",
|
||||
"admin.marketing.audience": "Doelgroep",
|
||||
"admin.marketing.audienceAllUsers": "Alle accounts",
|
||||
"admin.marketing.audienceInactive": "Accounts 30 dagen inactief",
|
||||
"admin.marketing.audiencePlaceholder": "Kies een doelgroep",
|
||||
"admin.marketing.audiencePlanBusiness": "Business-abonnement",
|
||||
"admin.marketing.audiencePlanEnterprise": "Enterprise-abonnement",
|
||||
"admin.marketing.audiencePlanFree": "Gratis-abonnement",
|
||||
"admin.marketing.audiencePlanPro": "Pro-abonnement",
|
||||
"admin.marketing.audiencePlanStarter": "Starter-abonnement",
|
||||
"admin.marketing.audienceWaitlist": "Wachtlijst",
|
||||
"admin.marketing.badgeReal": "echt",
|
||||
"admin.marketing.badgeTest": "test",
|
||||
"admin.marketing.confirmSend": "Deze e-mail versturen naar {count} ontvanger(s) („{audience}”)?",
|
||||
"admin.marketing.footerNote": "Een voettekst met de uitschrijflink wordt automatisch toegevoegd bij verzending.",
|
||||
"admin.marketing.hidePreview": "Voorbeeld verbergen",
|
||||
"admin.marketing.history": "Verzendgeschiedenis",
|
||||
"admin.marketing.historyFailedSuffix": ", {count} mislukt",
|
||||
"admin.marketing.historySent": "{sent}/{total} verzonden",
|
||||
"admin.marketing.html": "HTML-inhoud",
|
||||
"admin.marketing.htmlPlaceholder": "<html><body>…</body></html>",
|
||||
"admin.marketing.incompleteDesc": "Vul het onderwerp en de HTML in.",
|
||||
"admin.marketing.incompleteTitle": "Onvolledige inhoud",
|
||||
"admin.marketing.loadErrorTitle": "Laadfout",
|
||||
"admin.marketing.localCheckUnavailable": "Lokale controle niet beschikbaar in deze browser: de server controleert de voorafgaande testzending.",
|
||||
"admin.marketing.networkDesc": "Kan de backend niet bereiken.",
|
||||
"admin.marketing.newCampaign": "Nieuwe campagne",
|
||||
"admin.marketing.noHistory": "Geen zending geregistreerd.",
|
||||
"admin.marketing.preview": "Voorbeeld",
|
||||
"admin.marketing.previewEmpty": "Voorbeeld: plak je HTML hiernaast.",
|
||||
"admin.marketing.previewTitle": "E-mailvoorbeeld",
|
||||
"admin.marketing.recipientsAvailable": "{count} ontvanger(s) beschikbaar na uitsluiting van uitschrijvingen.",
|
||||
"admin.marketing.sendReal": "Versturen naar {count} ontvanger(s)",
|
||||
"admin.marketing.sendRefusedTitle": "Verzending geweigerd",
|
||||
"admin.marketing.sendTest": "Testzending",
|
||||
"admin.marketing.sendingDesc": "{count} e-mail(s) in de verzendwachtrij (interval 0,2 s). Uitschrijvingen worden uitgesloten.",
|
||||
"admin.marketing.sendingTitle": "Verzending bezig",
|
||||
"admin.marketing.subject": "Onderwerp",
|
||||
"admin.marketing.subjectPlaceholder": "Bijv.: Uw vertaling wacht — 20% gedurende 7 dagen",
|
||||
"admin.marketing.subtitle": "E-mails sturen naar een doelgroep, met verplichte testzending, automatische uitschrijflink en volledige geschiedenis.",
|
||||
"admin.marketing.testDoneDesc": "E-mail ontvangen op {email}",
|
||||
"admin.marketing.testDoneTitle": "Testzending uitgevoerd",
|
||||
"admin.marketing.testEmail": "Test-e-mail (optioneel)",
|
||||
"admin.marketing.testEmailPlaceholder": "Anders: SMTP-afzenderadres",
|
||||
"admin.marketing.testFailedTitle": "Testzending mislukt",
|
||||
"admin.marketing.testRequiredHint": "Een echte zending vereist een voorafgaande testzending van exact deze inhoud (door de server gecontroleerd).",
|
||||
"admin.marketing.testValidated": "Testzending gevalideerd voor deze inhoud.",
|
||||
"admin.marketing.title": "Marketing — e-mailcampagnes",
|
||||
"admin.marketing.unsubscribedNote": "{count} uitgeschreven contact(en) worden van elke zending uitgesloten.",
|
||||
"admin.models.addModel": "Model toevoegen",
|
||||
"admin.models.businessBadge": "Business-abonnement",
|
||||
"admin.models.cancel": "Annuleren",
|
||||
"admin.models.catalog": "OpenRouter-catalogus",
|
||||
"admin.models.catalogErrorDesc": "OpenRouter-catalogus kon niet worden geladen.",
|
||||
"admin.models.catalogErrorTitle": "Catalogus niet beschikbaar",
|
||||
"admin.models.customBadge": "Aangepast niveau",
|
||||
"admin.models.customNote": "Maatwerkmodellen, per geval met de klant afgesproken.",
|
||||
"admin.models.defaultBadge": "standaard",
|
||||
"admin.models.emptyTier": "Geen model: de officiële modellenreeks van het abonnement wordt gebruikt.",
|
||||
"admin.models.enterpriseBadge": "Enterprise-abonnement",
|
||||
"admin.models.essentialBadge": "Basis-AI-niveau",
|
||||
"admin.models.essentialCost": "— gefactoreerd met kostenfactor 1",
|
||||
"admin.models.fallbackFirst": "(fallback nr. 1)",
|
||||
"admin.models.loadErrorDesc": "HTTP {status} — controleer je admin-token.",
|
||||
"admin.models.loadErrorTitle": "Laadfout",
|
||||
"admin.models.matrixDesc": "Actieve modellen per niveau — de volgorde van de lijst is de fallback-prioriteit. De keuzerondje kiest het standaardmodel, direct actief zonder heruitrol.",
|
||||
"admin.models.matrixTitle": "Modelmatrix per abonnement",
|
||||
"admin.models.missingConfig": "AI-niveauconfiguratie niet gevonden. Herlaad de pagina.",
|
||||
"admin.models.moveDown": "Omlaag",
|
||||
"admin.models.moveUp": "Omhoog",
|
||||
"admin.models.networkDesc": "Kan de backend niet bereiken.",
|
||||
"admin.models.networkTitle": "Netwerkfout",
|
||||
"admin.models.premiumBadge": "Premium-AI-niveau",
|
||||
"admin.models.premiumCost": "— gefactoreerd met kostenfactor 5",
|
||||
"admin.models.premiumReservedNote": "Voorbehouden aan Business- en Enterprise-abonnementen (engine «openrouter_premium»).",
|
||||
"admin.models.proBadge": "Pro-abonnement",
|
||||
"admin.models.remove": "Verwijderen",
|
||||
"admin.models.save": "Opslaan",
|
||||
"admin.models.saveErrorTitle": "Opslagfout",
|
||||
"admin.models.saveNetworkDesc": "Configuratie kon niet worden opgeslagen.",
|
||||
"admin.models.savedDesc": "Het nieuwe standaardmodel wordt vanaf de volgende vertaling gebruikt, zonder heruitrol.",
|
||||
"admin.models.savedTitle": "Modellen opgeslagen",
|
||||
"admin.models.saving": "Opslaan...",
|
||||
"admin.models.setDefault": "{model} als standaardmodel instellen",
|
||||
"admin.models.sharedTierNote": "Gedeeld niveau: het Business-abonnement gebruikt het Basis-niveau ook voor zijn «openrouter»-engine — de bovenstaande lijst geldt voor beide abonnementen.",
|
||||
"admin.models.subtitle": "Matrix abonnementen → AI-niveaus. Het model dat per vertaling echt wordt gebruikt is dat van het niveau van het abonnement: een Pro-abonnement kan nooit een Premium-model aanroepen.",
|
||||
"admin.models.title": "Modellen & abonnementen",
|
||||
"admin.stats.aiTiers": "AI-niveaus (30 d)",
|
||||
"admin.stats.includingCredits": "waarvan {amount} aan credits",
|
||||
"admin.stats.mrr": "Geschatte MRR",
|
||||
"admin.stats.noTranslationsYet": "Geen vertalingen van de laatste 30 dagen in de database: de niveauverdeling vult zich aan met de komende vertalingen.",
|
||||
"admin.stats.payments30": "{count} betaling(en) in 30 dagen",
|
||||
"admin.stats.refreshing": "Vernieuwen...",
|
||||
"admin.stats.revenue30": "Omzet (30 dagen)",
|
||||
"admin.stats.revenueTotal": "Totaal ontvangen omzet",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
"landing.pricing.pro.desc": "Voor veeleisende professionals",
|
||||
"landing.pricing.pro.f1": "200 documenten / maand",
|
||||
"landing.pricing.pro.f2": "Tot 200 pagina's per doc",
|
||||
"landing.pricing.pro.f3": "IA-aangedreven vertaling",
|
||||
"landing.pricing.pro.f3": "Basis-AI: DeepSeek V4 Flash, GLM-5.3 Flash, MiniMax M3",
|
||||
"landing.pricing.pro.f4": "Google inbegrepen",
|
||||
"landing.pricing.pro.f5": "Eigen glossaria en prompts",
|
||||
"landing.pricing.pro.f6": "Prioriteitsondersteuning",
|
||||
@@ -109,7 +109,7 @@
|
||||
"landing.pricing.business.desc": "Voor teams met grote behoeften",
|
||||
"landing.pricing.business.f1": "1 000 documenten / maand",
|
||||
"landing.pricing.business.f2": "Tot 500 pagina's per doc",
|
||||
"landing.pricing.business.f3": "Premium AI (Claude)",
|
||||
"landing.pricing.business.f3": "Premium-AI (Claude 5)",
|
||||
"landing.pricing.business.f4": "Alle providers + API-toegang",
|
||||
"landing.pricing.business.f5": "Webhooks en automatisering",
|
||||
"landing.pricing.business.f6": "5 teamplekken",
|
||||
@@ -142,5 +142,31 @@
|
||||
"landing.translate.supportedFormats": "DOCX, XLSX, PPTX of PDF bestanden ondersteund",
|
||||
"landing.translate.aiAnalysis": "Actieve AI-analyse",
|
||||
"landing.translate.processing": "Verwerking bezig",
|
||||
"landing.translate.preservingLayout": "Uw opmaak wordt behouden"
|
||||
"landing.translate.preservingLayout": "Uw opmaak wordt behouden",
|
||||
"landing.beforeAfter.seal": "Zelfde lay-out, woord voor woord",
|
||||
"landing.beforeAfter.proof1": "SmartArt-diagrammen worden herbouwd, niet afgevlakt",
|
||||
"landing.beforeAfter.proof2": "Diagramreeksen en assen vertaald",
|
||||
"landing.beforeAfter.proof3": "Inhoudsopgaven opnieuw gegenereerd in de doeltaal",
|
||||
"landing.beforeAfter.targetTitle": "Technische specificatie — Luchtbehandeling",
|
||||
"landing.beforeAfter.targetPara1": "Het ventilatiesysteem moet vóór het einde van het tweede kwartaal in bedrijf worden gesteld.",
|
||||
"landing.beforeAfter.targetPara2Before": "De",
|
||||
"landing.beforeAfter.targetTerm": "luchtbehandelingsgroep",
|
||||
"landing.beforeAfter.targetPara2After": "moet volgens planning voldoen aan filterklasse F7.",
|
||||
"landing.beforeAfter.targetItem1": "Thermische belasting: 42 kW bij nominale doorstroom",
|
||||
"landing.beforeAfter.targetItem2": "Geluidsniveau onder 45 dB(A) op 3 m",
|
||||
"landing.formats.pill": "COMPATIBILITEIT",
|
||||
"landing.hero.visualCaption": "Zelfde lay-out, nieuwe taal — verder verandert niets",
|
||||
"landing.pricing.free.name": "Gratis",
|
||||
"landing.pricing.free.desc": "Ideaal om de app te ontdekken",
|
||||
"landing.pricing.free.cta": "Dit plan kiezen",
|
||||
"landing.pricing.enterprise.name": "Enterprise",
|
||||
"landing.pricing.enterprise.desc": "Op maat gemaakte oplossingen voor grote organisaties",
|
||||
"landing.pricing.enterprise.cta": "Neem contact op",
|
||||
"landing.beforeAfter.sourceTitle": "Cahier des charges — Traitement d'air",
|
||||
"landing.beforeAfter.sourcePara1": "L'installation de ventilation doit être mise en service avant la fin du deuxième trimestre.",
|
||||
"landing.beforeAfter.sourcePara2Before": "Le",
|
||||
"landing.beforeAfter.sourceTerm": "groupe de traitement d'air",
|
||||
"landing.beforeAfter.sourcePara2After": "doit respecter la classe de filtration F7 conformément au planning.",
|
||||
"landing.beforeAfter.sourceItem1": "Charge thermique : 42 kW au débit nominal",
|
||||
"landing.beforeAfter.sourceItem2": "Niveau sonore inférieur à 45 dB(A) à 3 m"
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"pricing.plans.pro.feat8": "90 dagen geschiedenis",
|
||||
"pricing.plans.business.feat1": "1 000 documenten / maand",
|
||||
"pricing.plans.business.feat2": "Tot 500 pagina's per document",
|
||||
"pricing.plans.business.feat3": "Basis- + Premium-AI (Claude Haiku)",
|
||||
"pricing.plans.business.feat3": "Basis-AI + Premium-AI (Claude 5)",
|
||||
"pricing.plans.business.feat4": "Alle vertaalproviders",
|
||||
"pricing.plans.business.feat5": "Bestanden tot 50 MB",
|
||||
"pricing.plans.business.feat6": "API-toegang (10 000 aanroepen/maand)",
|
||||
@@ -50,7 +50,7 @@
|
||||
"pricing.plans.business.feat9": "1 jaar geschiedenis",
|
||||
"pricing.plans.business.feat10": "Geavanceerde analyses",
|
||||
"pricing.plans.enterprise.feat1": "Onbeperkte documenten",
|
||||
"pricing.plans.enterprise.feat2": "Alle AI-modellen (GPT-5, Claude Opus 4.6…)",
|
||||
"pricing.plans.enterprise.feat2": "Alle AI-modellen (Claude 5, DeepSeek V4 Pro, GLM-5.3…)",
|
||||
"pricing.plans.enterprise.feat3": "On-premise of dedicated cloud",
|
||||
"pricing.plans.enterprise.feat4": "99,9 % SLA gegarandeerd",
|
||||
"pricing.plans.enterprise.feat5": "24/7 dedicated support",
|
||||
@@ -108,19 +108,19 @@
|
||||
"pricing.trust.parallel.sub": "Ultrasnelle multi-threaded AI",
|
||||
"pricing.trust.availability.title": "24/7 beschikbaar",
|
||||
"pricing.trust.availability.sub": "99,9 % gegarandeerde beschikbaarheid",
|
||||
"pricing.aiModels.title": "Onze AI-modellen — maart 2026",
|
||||
"pricing.aiModels.title": "Onze AI-modellen — september 2026",
|
||||
"pricing.aiModels.essential.title": "AI-basisvertaling",
|
||||
"pricing.aiModels.essential.plan": "Pro-abonnement",
|
||||
"pricing.aiModels.essential.descPrefix": "Gebaseerd op",
|
||||
"pricing.aiModels.essential.descSuffix": "— het meest kostenefficiënte AI-model van 2026. Kwaliteit vergelijkbaar met frontier-modellen voor een fractie van de kosten.",
|
||||
"pricing.aiModels.essential.modelName": "ons Essential AI-model",
|
||||
"pricing.aiModels.essential.context": "163K tokens context",
|
||||
"pricing.aiModels.essential.value": "Uitstekende prijs-kwaliteitverhouding",
|
||||
"pricing.aiModels.essential.descSuffix": "— de AI-modellen met de beste prijs-kwaliteitverhouding van 2026.",
|
||||
"pricing.aiModels.essential.modelName": "DeepSeek V4 Flash, GLM-5.3 Flash en MiniMax M3",
|
||||
"pricing.aiModels.essential.context": "Tot 1,3 mln context (GLM-5.3 Flash)",
|
||||
"pricing.aiModels.essential.value": "De beste prijs-kwaliteitverhouding",
|
||||
"pricing.aiModels.premium.title": "AI-premiumvertaling",
|
||||
"pricing.aiModels.premium.plan": "Business-abonnement",
|
||||
"pricing.aiModels.premium.descPrefix": "Gebaseerd op",
|
||||
"pricing.aiModels.premium.descSuffix": "van Anthropic — nauwkeurig bij juridische, medische en complexe technische documenten.",
|
||||
"pricing.aiModels.premium.context": "200K tokens context",
|
||||
"pricing.aiModels.premium.context": "1 mln context",
|
||||
"pricing.aiModels.premium.precision": "Beste nauwkeurigheid",
|
||||
"pricing.faq.title": "Veelgestelde vragen",
|
||||
"pricing.faq.q1": "Kan ik op elk moment van abonnement wisselen?",
|
||||
@@ -128,7 +128,7 @@
|
||||
"pricing.faq.q2": "Wat is «AI-basisvertaling»?",
|
||||
"pricing.faq.a2": "Het is onze IA-engine. Hij begrijpt de context van uw documenten, behoudt de lay-out en behandelt technische termen veel beter dan klassieke vertaling.",
|
||||
"pricing.faq.q3": "Wat is het verschil tussen Basis- en Premium-AI?",
|
||||
"pricing.faq.a3": "Essential IA gebruikt een geoptimaliseerd model (uitstekende prijs-kwaliteitverhouding). Premium IA gebruikt Claude 3.5 Haiku van Anthropic, nauwkeuriger bij juridische, medische en complexe technische documenten.",
|
||||
"pricing.faq.a3": "Basis-AI draait op DeepSeek V4 Flash, GLM-5.3 Flash en MiniMax M3 (uitstekende prijs-kwaliteitverhouding). Premium-AI gebruikt Claude 5 van Anthropic, nauwkeuriger bij juridische, medische en complexe technische documenten.",
|
||||
"pricing.faq.q4": "Worden mijn documenten bewaard na vertaling?",
|
||||
"pricing.faq.a4": "Vertaalde bestanden zijn beschikbaar volgens uw abonnement (30 dagen Starter, 90 dagen Pro, 1 jaar Business). Ze zijn versleuteld at-rest en in transit.",
|
||||
"pricing.faq.q5": "Wat gebeurt er als ik mijn maandelijkse quotum overschrijd?",
|
||||
@@ -146,5 +146,26 @@
|
||||
"pricing.toast.paymentError": "Fout bij aanmaken van de betaling.",
|
||||
"pricing.dashboard": "Dashboard",
|
||||
"pricing.okSymbol": "✓",
|
||||
"pricing.errSymbol": "✕"
|
||||
"pricing.errSymbol": "✕",
|
||||
"pricing.aiModels.essential.price": "Vanaf $0,09 per miljoen tokens",
|
||||
"pricing.aiModels.premium.modelName": "Claude 5",
|
||||
"pricing.aiModels.premium.alternatives": "Alternatieven: DeepSeek V4 Pro en GLM-5.3",
|
||||
"pricing.confirm.title": "Bevestig uw abonnement",
|
||||
"pricing.confirm.subtitle": "U wordt doorgestuurd naar onze veilige betaalprovider.",
|
||||
"pricing.confirm.cancel": "Annuleren",
|
||||
"pricing.confirm.cta": "Verder naar betaling",
|
||||
"pricing.confirm.year": "jaar",
|
||||
"pricing.confirm.month": "maand",
|
||||
"pricing.confirm.monthlyEquivalent": "Jaarlijkse facturering — dus {price} € / maand.",
|
||||
"pricing.confirm.secureNote": "Op elk moment opzegbaar via uw profiel. Betaling wordt verwerkt door Stripe; uw kaartgegevens raken onze servers nooit.",
|
||||
"pricing.enterprise.subject": "Enterprise-aanvraag",
|
||||
"pricing.enterpriseBand.cta": "Neem contact op",
|
||||
"pricing.enterpriseBand.text": "Volume, speciale engines, on-premise opties — laten we praten.",
|
||||
"pricing.error.server": "Serverfout {status}",
|
||||
"pricing.freeBand.cta": "Gratis starten",
|
||||
"pricing.freeBand.text": "Eerst uitproberen? Start gratis — 5 documenten per maand, geen kaart nodig.",
|
||||
"pricing.header.titleBase": "Een plan voor",
|
||||
"pricing.header.titleAccent": "elke behoefte",
|
||||
"pricing.toast.close": "Sluiten",
|
||||
"pricing.aiModels.premium.price": "$2 / $10 per 1M tokens"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"providerTheme.openrouter.desc": "Uniforme toegang tot de beste open-source modellen, geoptimaliseerd voor vertaling.",
|
||||
"providerTheme.openrouter_premium.badge": "Ultra",
|
||||
"providerTheme.openrouter_premium.subBadge": "Maximale context",
|
||||
"providerTheme.openrouter_premium.desc": "Ondersteund door state-of-the-art modellen (GPT-4o, Claude Sonnet 4.6) voor lange documenten.",
|
||||
"providerTheme.openrouter_premium.desc": "Ondersteund door state-of-the-art modellen (GPT-4o, Claude 5) voor lange documenten.",
|
||||
"providerTheme.zai.badge": "Gespecialiseerd",
|
||||
"providerTheme.zai.subBadge": "Financiën & Recht",
|
||||
"providerTheme.zai.desc": "Model afgestemd op veeleisende bedrijfsterminologieën (juridisch, financieel).",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"admin.nav.providers": "Provedores",
|
||||
"admin.nav.system": "Sistema",
|
||||
"admin.nav.logs": "Registros",
|
||||
"admin.nav.stats": "Estatísticas",
|
||||
"admin.users.title": "Gestão de Usuários",
|
||||
"admin.users.subtitle": "Visualizar e gerenciar contas de usuários",
|
||||
"admin.users.planUpdated": "Plano atualizado",
|
||||
@@ -44,5 +45,105 @@
|
||||
"admin.system.waitingData": "Aguardando dados...",
|
||||
"admin.system.purging": "Limpando...",
|
||||
"admin.system.clean": "Limpar",
|
||||
"admin.system.purge": "Limpar"
|
||||
"admin.system.purge": "Limpar",
|
||||
"admin.nav.models": "Modelos e assinaturas",
|
||||
"admin.nav.marketing": "Marketing",
|
||||
"admin.marketing.audience": "Público",
|
||||
"admin.marketing.audienceAllUsers": "Todas as contas",
|
||||
"admin.marketing.audienceInactive": "Contas inativas há 30 dias",
|
||||
"admin.marketing.audiencePlaceholder": "Escolher um público",
|
||||
"admin.marketing.audiencePlanBusiness": "Plano Business",
|
||||
"admin.marketing.audiencePlanEnterprise": "Plano Enterprise",
|
||||
"admin.marketing.audiencePlanFree": "Plano Gratuito",
|
||||
"admin.marketing.audiencePlanPro": "Plano Pro",
|
||||
"admin.marketing.audiencePlanStarter": "Plano Starter",
|
||||
"admin.marketing.audienceWaitlist": "Lista de espera",
|
||||
"admin.marketing.badgeReal": "real",
|
||||
"admin.marketing.badgeTest": "teste",
|
||||
"admin.marketing.confirmSend": "Enviar este email para {count} destinatário(s) («{audience}»)?",
|
||||
"admin.marketing.footerNote": "Um rodapé com o link de descadastro é adicionado automaticamente ao envio.",
|
||||
"admin.marketing.hidePreview": "Ocultar pré-visualização",
|
||||
"admin.marketing.history": "Histórico de envios",
|
||||
"admin.marketing.historyFailedSuffix": ", {count} falhas",
|
||||
"admin.marketing.historySent": "{sent}/{total} enviados",
|
||||
"admin.marketing.html": "Conteúdo HTML",
|
||||
"admin.marketing.htmlPlaceholder": "<html><body>…</body></html>",
|
||||
"admin.marketing.incompleteDesc": "Preencha o assunto e o HTML.",
|
||||
"admin.marketing.incompleteTitle": "Conteúdo incompleto",
|
||||
"admin.marketing.loadErrorTitle": "Erro ao carregar",
|
||||
"admin.marketing.localCheckUnavailable": "Verificação local indisponível neste navegador: o servidor verificará o envio de teste prévio.",
|
||||
"admin.marketing.networkDesc": "Não foi possível contatar o backend.",
|
||||
"admin.marketing.newCampaign": "Novo envio",
|
||||
"admin.marketing.noHistory": "Nenhum envio registrado.",
|
||||
"admin.marketing.preview": "Pré-visualização",
|
||||
"admin.marketing.previewEmpty": "Pré-visualização: cole seu HTML ao lado.",
|
||||
"admin.marketing.previewTitle": "Pré-visualização do email",
|
||||
"admin.marketing.recipientsAvailable": "{count} destinatário(s) disponíveis após excluir os descadastrados.",
|
||||
"admin.marketing.sendReal": "Enviar para {count} destinatário(s)",
|
||||
"admin.marketing.sendRefusedTitle": "Envio recusado",
|
||||
"admin.marketing.sendTest": "Envio de teste",
|
||||
"admin.marketing.sendingDesc": "{count} email(s) na fila de envio (intervalo de 0,2 s). Os descadastrados ficam excluídos.",
|
||||
"admin.marketing.sendingTitle": "Envio em andamento",
|
||||
"admin.marketing.subject": "Assunto",
|
||||
"admin.marketing.subjectPlaceholder": "Ex.: Sua tradução o espera — 20% por 7 dias",
|
||||
"admin.marketing.subtitle": "Envio de emails para um público, com envio de teste obrigatório, link de descadastro automático e histórico completo.",
|
||||
"admin.marketing.testDoneDesc": "Email recebido em {email}",
|
||||
"admin.marketing.testDoneTitle": "Envio de teste realizado",
|
||||
"admin.marketing.testEmail": "Email de teste (opcional)",
|
||||
"admin.marketing.testEmailPlaceholder": "Caso contrário: endereço de remetente SMTP",
|
||||
"admin.marketing.testFailedTitle": "Falha no envio de teste",
|
||||
"admin.marketing.testRequiredHint": "O envio real exige um envio de teste prévio deste conteúdo exato (verificado pelo servidor).",
|
||||
"admin.marketing.testValidated": "Envio de teste validado para este conteúdo.",
|
||||
"admin.marketing.title": "Marketing — envios por email",
|
||||
"admin.marketing.unsubscribedNote": "{count} contato(s) descadastrado(s) serão excluídos de todo envio.",
|
||||
"admin.models.addModel": "Adicionar um modelo",
|
||||
"admin.models.businessBadge": "Plano Business",
|
||||
"admin.models.cancel": "Cancelar",
|
||||
"admin.models.catalog": "Catálogo do OpenRouter",
|
||||
"admin.models.catalogErrorDesc": "Não foi possível carregar o catálogo do OpenRouter.",
|
||||
"admin.models.catalogErrorTitle": "Catálogo indisponível",
|
||||
"admin.models.customBadge": "Nível personalizado",
|
||||
"admin.models.customNote": "Modelos sob medida, definidos caso a caso com o cliente.",
|
||||
"admin.models.defaultBadge": "padrão",
|
||||
"admin.models.emptyTier": "Nenhum modelo: a gama oficial do plano será usada.",
|
||||
"admin.models.enterpriseBadge": "Plano Enterprise",
|
||||
"admin.models.essentialBadge": "Nível de IA Essencial",
|
||||
"admin.models.essentialCost": "— faturado com fator de custo 1",
|
||||
"admin.models.fallbackFirst": "(reserva n.º 1)",
|
||||
"admin.models.loadErrorDesc": "HTTP {status} — verifique seu token de administrador.",
|
||||
"admin.models.loadErrorTitle": "Erro ao carregar",
|
||||
"admin.models.matrixDesc": "Modelos ativos por nível — a ordem da lista é a prioridade de reserva. O botão de opção escolhe o modelo padrão, aplicado imediatamente sem nova implantação.",
|
||||
"admin.models.matrixTitle": "Matriz de modelos por plano",
|
||||
"admin.models.missingConfig": "Configuração dos níveis de IA não encontrada. Recarregue a página.",
|
||||
"admin.models.moveDown": "Mover para baixo",
|
||||
"admin.models.moveUp": "Mover para cima",
|
||||
"admin.models.networkDesc": "Não foi possível contatar o backend.",
|
||||
"admin.models.networkTitle": "Erro de rede",
|
||||
"admin.models.premiumBadge": "Nível de IA Premium",
|
||||
"admin.models.premiumCost": "— faturado com fator de custo 5",
|
||||
"admin.models.premiumReservedNote": "Reservado aos planos Business e Enterprise (motor «openrouter_premium»).",
|
||||
"admin.models.proBadge": "Plano Pro",
|
||||
"admin.models.remove": "Remover",
|
||||
"admin.models.save": "Salvar",
|
||||
"admin.models.saveErrorTitle": "Erro ao salvar",
|
||||
"admin.models.saveNetworkDesc": "Não foi possível salvar a configuração.",
|
||||
"admin.models.savedDesc": "O novo modelo padrão é usado já na próxima tradução, sem nova implantação.",
|
||||
"admin.models.savedTitle": "Modelos salvos",
|
||||
"admin.models.saving": "Salvando...",
|
||||
"admin.models.setDefault": "Definir {model} como modelo padrão",
|
||||
"admin.models.sharedTierNote": "Nível compartilhado: o plano Business também usa o Essencial para o motor «openrouter» — a lista acima vale para os dois planos.",
|
||||
"admin.models.subtitle": "Matriz planos → níveis de IA. O modelo realmente usado em cada tradução é o do nível do plano: um plano Pro nunca pode disparar um modelo Premium.",
|
||||
"admin.models.title": "Modelos e assinaturas",
|
||||
"admin.stats.aiTiers": "Níveis de IA (30 d)",
|
||||
"admin.stats.includingCredits": "incluindo {amount} em créditos",
|
||||
"admin.stats.mrr": "MRR estimado",
|
||||
"admin.stats.noTranslationsYet": "Nenhuma tradução registrada nos últimos 30 dias: a distribuição por níveis se preencherá com as próximas traduções.",
|
||||
"admin.stats.payments30": "{count} pagamento(s) em 30 dias",
|
||||
"admin.stats.refreshing": "Atualizando...",
|
||||
"admin.stats.revenue30": "Receitas (30 dias)",
|
||||
"admin.stats.revenueTotal": "Receitas recebidas (total)",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
"landing.pricing.pro.desc": "Para profissionais exigentes",
|
||||
"landing.pricing.pro.f1": "200 documentos / mês",
|
||||
"landing.pricing.pro.f2": "Até 200 páginas por doc",
|
||||
"landing.pricing.pro.f3": "Tradução com IA",
|
||||
"landing.pricing.pro.f3": "IA Essencial: DeepSeek V4 Flash, GLM-5.3 Flash, MiniMax M3",
|
||||
"landing.pricing.pro.f4": "Google incluídos",
|
||||
"landing.pricing.pro.f5": "Glossários e prompts personalizados",
|
||||
"landing.pricing.pro.f6": "Suporte prioritário",
|
||||
@@ -109,7 +109,7 @@
|
||||
"landing.pricing.business.desc": "Para equipes com alto volume",
|
||||
"landing.pricing.business.f1": "1 000 documentos / mês",
|
||||
"landing.pricing.business.f2": "Até 500 páginas por doc",
|
||||
"landing.pricing.business.f3": "IA Premium (Claude)",
|
||||
"landing.pricing.business.f3": "IA Premium (Claude 5)",
|
||||
"landing.pricing.business.f4": "Todos os provedores + API",
|
||||
"landing.pricing.business.f5": "Webhooks e automação",
|
||||
"landing.pricing.business.f6": "5 assentos de equipe",
|
||||
@@ -142,5 +142,31 @@
|
||||
"landing.translate.supportedFormats": "Ficheiros DOCX, XLSX, PPTX ou PDF suportados",
|
||||
"landing.translate.aiAnalysis": "Análise IA Ativa",
|
||||
"landing.translate.processing": "Em processamento",
|
||||
"landing.translate.preservingLayout": "O seu layout está a ser preservado"
|
||||
"landing.translate.preservingLayout": "O seu layout está a ser preservado",
|
||||
"landing.beforeAfter.seal": "O mesmo layout, palavra por palavra",
|
||||
"landing.beforeAfter.proof1": "Diagramas SmartArt reconstruídos, não achatados",
|
||||
"landing.beforeAfter.proof2": "Séries e eixos dos gráficos traduzidos",
|
||||
"landing.beforeAfter.proof3": "Sumários regenerados no idioma de destino",
|
||||
"landing.beforeAfter.targetTitle": "Especificação técnica — Tratamento de ar",
|
||||
"landing.beforeAfter.targetPara1": "A instalação de ventilação deve ser colocada em serviço antes do fim do segundo trimestre.",
|
||||
"landing.beforeAfter.targetPara2Before": "A",
|
||||
"landing.beforeAfter.targetTerm": "unidade de tratamento de ar",
|
||||
"landing.beforeAfter.targetPara2After": "deve atender à classe de filtro F7 conforme o cronograma.",
|
||||
"landing.beforeAfter.targetItem1": "Carga térmica: 42 kW em vazão nominal",
|
||||
"landing.beforeAfter.targetItem2": "Nível sonoro abaixo de 45 dB(A) a 3 m",
|
||||
"landing.formats.pill": "COMPATIBILIDADE",
|
||||
"landing.hero.visualCaption": "O mesmo layout, novo idioma — nada mais muda",
|
||||
"landing.pricing.free.name": "Grátis",
|
||||
"landing.pricing.free.desc": "Ideal para descobrir a aplicação",
|
||||
"landing.pricing.free.cta": "Escolher este plano",
|
||||
"landing.pricing.enterprise.name": "Empresas",
|
||||
"landing.pricing.enterprise.desc": "Soluções à medida para grandes organizações",
|
||||
"landing.pricing.enterprise.cta": "Fale conosco",
|
||||
"landing.beforeAfter.sourceTitle": "Cahier des charges — Traitement d'air",
|
||||
"landing.beforeAfter.sourcePara1": "L'installation de ventilation doit être mise en service avant la fin du deuxième trimestre.",
|
||||
"landing.beforeAfter.sourcePara2Before": "Le",
|
||||
"landing.beforeAfter.sourceTerm": "groupe de traitement d'air",
|
||||
"landing.beforeAfter.sourcePara2After": "doit respecter la classe de filtration F7 conformément au planning.",
|
||||
"landing.beforeAfter.sourceItem1": "Charge thermique : 42 kW au débit nominal",
|
||||
"landing.beforeAfter.sourceItem2": "Niveau sonore inférieur à 45 dB(A) à 3 m"
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"pricing.plans.pro.feat8": "Histórico de 90 dias",
|
||||
"pricing.plans.business.feat1": "1 000 documentos / mês",
|
||||
"pricing.plans.business.feat2": "Até 500 páginas por documento",
|
||||
"pricing.plans.business.feat3": "IA Essencial + Premium (Claude Haiku)",
|
||||
"pricing.plans.business.feat3": "IA Essencial + IA Premium (Claude 5)",
|
||||
"pricing.plans.business.feat4": "Todos os provedores de tradução",
|
||||
"pricing.plans.business.feat5": "Arquivos de até 50 MB",
|
||||
"pricing.plans.business.feat6": "Acesso à API (10 000 chamadas/mês)",
|
||||
@@ -50,7 +50,7 @@
|
||||
"pricing.plans.business.feat9": "Histórico de 1 ano",
|
||||
"pricing.plans.business.feat10": "Análises avançadas",
|
||||
"pricing.plans.enterprise.feat1": "Documentos ilimitados",
|
||||
"pricing.plans.enterprise.feat2": "Todos os modelos de IA (GPT-5, Claude Opus 4.6…)",
|
||||
"pricing.plans.enterprise.feat2": "Todos os modelos de IA (Claude 5, DeepSeek V4 Pro, GLM-5.3…)",
|
||||
"pricing.plans.enterprise.feat3": "Implantação local ou em nuvem dedicada",
|
||||
"pricing.plans.enterprise.feat4": "SLA 99,9 % garantido",
|
||||
"pricing.plans.enterprise.feat5": "Suporte dedicado 24/7",
|
||||
@@ -108,19 +108,19 @@
|
||||
"pricing.trust.parallel.sub": "IA multi-thread ultrarrápida",
|
||||
"pricing.trust.availability.title": "Disponível 24/7",
|
||||
"pricing.trust.availability.sub": "99,9 % de uptime garantido",
|
||||
"pricing.aiModels.title": "Nossos modelos de IA — Março 2026",
|
||||
"pricing.aiModels.title": "Nossos modelos de IA — Setembro de 2026",
|
||||
"pricing.aiModels.essential.title": "Tradução IA Essencial",
|
||||
"pricing.aiModels.essential.plan": "Plano Pro",
|
||||
"pricing.aiModels.essential.descPrefix": "Baseado em",
|
||||
"pricing.aiModels.essential.descSuffix": "— o modelo de IA mais econômico de 2026. Qualidade comparável a modelos frontier a uma fração do custo.",
|
||||
"pricing.aiModels.essential.modelName": "nosso modelo IA Essencial",
|
||||
"pricing.aiModels.essential.context": "163K tokens de contexto",
|
||||
"pricing.aiModels.essential.value": "Excelente custo-benefício",
|
||||
"pricing.aiModels.essential.descSuffix": "— os modelos de IA com o melhor custo-benefício de 2026.",
|
||||
"pricing.aiModels.essential.modelName": "DeepSeek V4 Flash, GLM-5.3 Flash e MiniMax M3",
|
||||
"pricing.aiModels.essential.context": "Até 1,3 M de contexto (GLM-5.3 Flash)",
|
||||
"pricing.aiModels.essential.value": "O melhor custo-benefício",
|
||||
"pricing.aiModels.premium.title": "Tradução IA Premium",
|
||||
"pricing.aiModels.premium.plan": "Plano Business",
|
||||
"pricing.aiModels.premium.descPrefix": "Baseado em",
|
||||
"pricing.aiModels.premium.descSuffix": "da Anthropic — preciso em documentos jurídicos, médicos e técnicos complexos.",
|
||||
"pricing.aiModels.premium.context": "200K tokens de contexto",
|
||||
"pricing.aiModels.premium.context": "1M de contexto",
|
||||
"pricing.aiModels.premium.precision": "Maior precisão",
|
||||
"pricing.faq.title": "Perguntas frequentes",
|
||||
"pricing.faq.q1": "Posso trocar de plano a qualquer momento?",
|
||||
@@ -128,7 +128,7 @@
|
||||
"pricing.faq.q2": "O que é a «Tradução IA Essencial»?",
|
||||
"pricing.faq.a2": "É nosso motor de IA. Ele compreende o contexto dos seus documentos, preserva a formatação e lida com termos técnicos muito melhor do que a tradução clássica.",
|
||||
"pricing.faq.q3": "Qual a diferença entre IA Essencial e IA Premium?",
|
||||
"pricing.faq.a3": "A IA Essencial usa um modelo otimizado (excelente custo-benefício). A IA Premium usa Claude 3.5 Haiku da Anthropic, mais precisa em documentos jurídicos, médicos e técnicos complexos.",
|
||||
"pricing.faq.a3": "A IA Essencial usa DeepSeek V4 Flash, GLM-5.3 Flash e MiniMax M3 (excelente custo-benefício). A IA Premium usa Claude 5 da Anthropic, mais precisa em documentos jurídicos, médicos e técnicos complexos.",
|
||||
"pricing.faq.q4": "Meus documentos são mantidos após a tradução?",
|
||||
"pricing.faq.a4": "Os arquivos traduzidos ficam disponíveis de acordo com seu plano (30 dias Starter, 90 dias Pro, 1 ano Business). São criptografados em repouso e em trânsito.",
|
||||
"pricing.faq.q5": "O que acontece se eu exceder minha cota mensal?",
|
||||
@@ -146,5 +146,26 @@
|
||||
"pricing.toast.paymentError": "Erro ao criar o pagamento.",
|
||||
"pricing.dashboard": "Painel",
|
||||
"pricing.okSymbol": "✓",
|
||||
"pricing.errSymbol": "✕"
|
||||
"pricing.errSymbol": "✕",
|
||||
"pricing.aiModels.essential.price": "A partir de 0,09 US$ por milhão de tokens",
|
||||
"pricing.aiModels.premium.modelName": "Claude 5",
|
||||
"pricing.aiModels.premium.alternatives": "Alternativas: DeepSeek V4 Pro e GLM-5.3",
|
||||
"pricing.confirm.title": "Confirme a sua assinatura",
|
||||
"pricing.confirm.subtitle": "Você será redirecionado ao nosso provedor de pagamento seguro.",
|
||||
"pricing.confirm.cancel": "Cancelar",
|
||||
"pricing.confirm.cta": "Continuar para o pagamento",
|
||||
"pricing.confirm.year": "ano",
|
||||
"pricing.confirm.month": "mês",
|
||||
"pricing.confirm.monthlyEquivalent": "Faturação anual — ou seja, {price} € / mês.",
|
||||
"pricing.confirm.secureNote": "Cancelável a qualquer momento no seu perfil. O pagamento é processado pelo Stripe; os dados do seu cartão nunca passam pelos nossos servidores.",
|
||||
"pricing.enterprise.subject": "Pedido Enterprise",
|
||||
"pricing.enterpriseBand.cta": "Fale conosco",
|
||||
"pricing.enterpriseBand.text": "Volume, motores dedicados, opções on-premise — vamos conversar.",
|
||||
"pricing.error.server": "Erro do servidor {status}",
|
||||
"pricing.freeBand.cta": "Comece grátis",
|
||||
"pricing.freeBand.text": "Só quer experimentar? Comece grátis — 5 documentos por mês, sem cartão.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"providerTheme.openrouter.desc": "Acesso unificado aos melhores modelos open-source otimizados para tradução.",
|
||||
"providerTheme.openrouter_premium.badge": "Ultra",
|
||||
"providerTheme.openrouter_premium.subBadge": "Contexto máximo",
|
||||
"providerTheme.openrouter_premium.desc": "Assistido pelos modelos mais avançados (GPT-4o, Claude Sonnet 4.6) para documentos longos.",
|
||||
"providerTheme.openrouter_premium.desc": "Assistido pelos modelos mais avançados (GPT-4o, Claude 5) para documentos longos.",
|
||||
"providerTheme.zai.badge": "Especializada",
|
||||
"providerTheme.zai.subBadge": "Finanças e Direito",
|
||||
"providerTheme.zai.desc": "Modelo afinado para terminologias empresariais exigentes (jurídico, financeiro).",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"admin.nav.providers": "Провайдеры",
|
||||
"admin.nav.system": "Система",
|
||||
"admin.nav.logs": "Журналы",
|
||||
"admin.nav.stats": "Статистика",
|
||||
"admin.users.title": "Управление пользователями",
|
||||
"admin.users.subtitle": "Просмотр и управление учётными записями",
|
||||
"admin.users.planUpdated": "План обновлён",
|
||||
@@ -44,5 +45,105 @@
|
||||
"admin.system.waitingData": "Ожидание данных...",
|
||||
"admin.system.purging": "Очистка...",
|
||||
"admin.system.clean": "Очистить",
|
||||
"admin.system.purge": "Очистить"
|
||||
"admin.system.purge": "Очистить",
|
||||
"admin.nav.models": "Модели и подписки",
|
||||
"admin.nav.marketing": "Маркетинг",
|
||||
"admin.marketing.audience": "Аудитория",
|
||||
"admin.marketing.audienceAllUsers": "Все аккаунты",
|
||||
"admin.marketing.audienceInactive": "Аккаунты без активности 30 дней",
|
||||
"admin.marketing.audiencePlaceholder": "Выберите аудиторию",
|
||||
"admin.marketing.audiencePlanBusiness": "Тариф Business",
|
||||
"admin.marketing.audiencePlanEnterprise": "Тариф Enterprise",
|
||||
"admin.marketing.audiencePlanFree": "Бесплатный тариф",
|
||||
"admin.marketing.audiencePlanPro": "Тариф Pro",
|
||||
"admin.marketing.audiencePlanStarter": "Тариф Starter",
|
||||
"admin.marketing.audienceWaitlist": "Лист ожидания",
|
||||
"admin.marketing.badgeReal": "реальная",
|
||||
"admin.marketing.badgeTest": "тест",
|
||||
"admin.marketing.confirmSend": "Отправить это письмо {count} получателю(ям) («{audience}»)?",
|
||||
"admin.marketing.footerNote": "При отправке автоматически добавляется подвал со ссылкой отписки.",
|
||||
"admin.marketing.hidePreview": "Скрыть предпросмотр",
|
||||
"admin.marketing.history": "История рассылок",
|
||||
"admin.marketing.historyFailedSuffix": ", {count} сбоев",
|
||||
"admin.marketing.historySent": "{sent}/{total} отправлено",
|
||||
"admin.marketing.html": "HTML-содержимое",
|
||||
"admin.marketing.htmlPlaceholder": "<html><body>…</body></html>",
|
||||
"admin.marketing.incompleteDesc": "Заполните тему и HTML.",
|
||||
"admin.marketing.incompleteTitle": "Содержимое неполное",
|
||||
"admin.marketing.loadErrorTitle": "Ошибка загрузки",
|
||||
"admin.marketing.localCheckUnavailable": "Локальная проверка в этом браузере недоступна: сервер проверит предварительную тестовую отправку.",
|
||||
"admin.marketing.networkDesc": "Не удаётся связаться с сервером.",
|
||||
"admin.marketing.newCampaign": "Новая рассылка",
|
||||
"admin.marketing.noHistory": "Рассылок не зарегистрировано.",
|
||||
"admin.marketing.preview": "Предпросмотр",
|
||||
"admin.marketing.previewEmpty": "Предпросмотр: вставьте HTML рядом с этой панелью.",
|
||||
"admin.marketing.previewTitle": "Предпросмотр письма",
|
||||
"admin.marketing.recipientsAvailable": "{count} получателей доступно после исключения отписавшихся.",
|
||||
"admin.marketing.sendReal": "Отправить {count} получателю(ям)",
|
||||
"admin.marketing.sendRefusedTitle": "Рассылка отклонена",
|
||||
"admin.marketing.sendTest": "Тестовая отправка",
|
||||
"admin.marketing.sendingDesc": "{count} письмо(ем) в очереди отправки (интервал 0,2 с). Отписавшиеся исключаются.",
|
||||
"admin.marketing.sendingTitle": "Идёт рассылка",
|
||||
"admin.marketing.subject": "Тема",
|
||||
"admin.marketing.subjectPlaceholder": "Напр.: Ваш перевод готов — скидка 20% на 7 дней",
|
||||
"admin.marketing.subtitle": "Рассылка писем по аудитории с обязательной тестовой отправкой, автоматической ссылкой отписки и полной историей.",
|
||||
"admin.marketing.testDoneDesc": "Письмо получено на {email}",
|
||||
"admin.marketing.testDoneTitle": "Тестовая отправка выполнена",
|
||||
"admin.marketing.testEmail": "Тестовый email (необязательно)",
|
||||
"admin.marketing.testEmailPlaceholder": "Иначе: SMTP-адрес отправителя",
|
||||
"admin.marketing.testFailedTitle": "Тестовая отправка не удалась",
|
||||
"admin.marketing.testRequiredHint": "Реальной рассылке предшествует тестовая отправка точно такого же содержимого (проверяется сервером).",
|
||||
"admin.marketing.testValidated": "Тестовая отправка этого содержимого подтверждена.",
|
||||
"admin.marketing.title": "Маркетинг — email-рассылки",
|
||||
"admin.marketing.unsubscribedNote": "{count} отписавшихся будут исключены из любой рассылки.",
|
||||
"admin.models.addModel": "Добавить модель",
|
||||
"admin.models.businessBadge": "Тариф Business",
|
||||
"admin.models.cancel": "Отмена",
|
||||
"admin.models.catalog": "Каталог OpenRouter",
|
||||
"admin.models.catalogErrorDesc": "Не удалось загрузить каталог OpenRouter.",
|
||||
"admin.models.catalogErrorTitle": "Каталог недоступен",
|
||||
"admin.models.customBadge": "Индивидуальный уровень",
|
||||
"admin.models.customNote": "Специальные модели, согласовываются с клиентом индивидуально.",
|
||||
"admin.models.defaultBadge": "по умолчанию",
|
||||
"admin.models.emptyTier": "Нет моделей: будет использован официальный набор тарифа.",
|
||||
"admin.models.enterpriseBadge": "Тариф Enterprise",
|
||||
"admin.models.essentialBadge": "Уровень ИИ «Базовый»",
|
||||
"admin.models.essentialCost": "— с коэффициентом стоимости 1",
|
||||
"admin.models.fallbackFirst": "(запасная № 1)",
|
||||
"admin.models.loadErrorDesc": "HTTP {status} — проверьте токен администратора.",
|
||||
"admin.models.loadErrorTitle": "Ошибка загрузки",
|
||||
"admin.models.matrixDesc": "Активные модели по уровням — порядок в списке задаёт приоритет запасного варианта. Радиокнопка выбирает модель по умолчанию, применяемую сразу, без повторного развёртывания.",
|
||||
"admin.models.matrixTitle": "Матрица моделей по тарифам",
|
||||
"admin.models.missingConfig": "Конфигурация уровней ИИ не найдена. Перезагрузите страницу.",
|
||||
"admin.models.moveDown": "Вниз",
|
||||
"admin.models.moveUp": "Вверх",
|
||||
"admin.models.networkDesc": "Не удаётся связаться с сервером.",
|
||||
"admin.models.networkTitle": "Сетевая ошибка",
|
||||
"admin.models.premiumBadge": "Уровень ИИ «Премиум»",
|
||||
"admin.models.premiumCost": "— с коэффициентом стоимости 5",
|
||||
"admin.models.premiumReservedNote": "Только для тарифов Business и Enterprise (движок «openrouter_premium»).",
|
||||
"admin.models.proBadge": "Тариф Pro",
|
||||
"admin.models.remove": "Убрать",
|
||||
"admin.models.save": "Сохранить",
|
||||
"admin.models.saveErrorTitle": "Ошибка сохранения",
|
||||
"admin.models.saveNetworkDesc": "Не удалось сохранить конфигурацию.",
|
||||
"admin.models.savedDesc": "Новая модель по умолчанию применяется уже со следующего перевода, без повторного развёртывания.",
|
||||
"admin.models.savedTitle": "Модели сохранены",
|
||||
"admin.models.saving": "Сохранение...",
|
||||
"admin.models.setDefault": "Назначить {model} моделью по умолчанию",
|
||||
"admin.models.sharedTierNote": "Общий уровень: тариф Business также использует «Базовый» для движка «openrouter» — приведённый выше список относится к обоим тарифам.",
|
||||
"admin.models.subtitle": "Матрица тарифы → уровни ИИ. Модель, реально используемая при переводе, — это модель уровня тарифа: тариф Pro никогда не задействует модель Premium.",
|
||||
"admin.models.title": "Модели и подписки",
|
||||
"admin.stats.aiTiers": "Уровни ИИ (30 дн.)",
|
||||
"admin.stats.includingCredits": "в том числе {amount} кредитами",
|
||||
"admin.stats.mrr": "Оценочный MRR",
|
||||
"admin.stats.noTranslationsYet": "За последние 30 дней переводов в базе нет: распределение по уровням заполнится следующими переводами.",
|
||||
"admin.stats.payments30": "{count} платёж(ей) за 30 дней",
|
||||
"admin.stats.refreshing": "Обновление...",
|
||||
"admin.stats.revenue30": "Выручка (30 дней)",
|
||||
"admin.stats.revenueTotal": "Всего получено выручки",
|
||||
"admin.stats.tiersSub": "Базовый / Премиум · классика {classic} · прочее {other}",
|
||||
"admin.stats.unavailable": "Бизнес-статистика недоступна ({error}).",
|
||||
"admin.stats.waitlist": "Лист ожидания",
|
||||
"admin.stats.waitlistSub": "записей в листе ожидания"
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
"landing.pricing.pro.desc": "Для требовательных профессионалов",
|
||||
"landing.pricing.pro.f1": "200 документов / мес.",
|
||||
"landing.pricing.pro.f2": "До 200 страниц на документ",
|
||||
"landing.pricing.pro.f3": "Перевод на базе ИИ",
|
||||
"landing.pricing.pro.f3": "Базовый ИИ: DeepSeek V4 Flash, GLM-5.3 Flash, MiniMax M3",
|
||||
"landing.pricing.pro.f4": "Google включены",
|
||||
"landing.pricing.pro.f5": "Пользовательские глоссарии и промпты",
|
||||
"landing.pricing.pro.f6": "Приоритетная поддержка",
|
||||
@@ -109,7 +109,7 @@
|
||||
"landing.pricing.business.desc": "Для команд с большими объёмами",
|
||||
"landing.pricing.business.f1": "1 000 документов / мес.",
|
||||
"landing.pricing.business.f2": "До 500 страниц на документ",
|
||||
"landing.pricing.business.f3": "Премиум ИИ (Claude)",
|
||||
"landing.pricing.business.f3": "Премиум ИИ (Claude 5)",
|
||||
"landing.pricing.business.f4": "Все провайдеры + API",
|
||||
"landing.pricing.business.f5": "Вебхуки и автоматизация",
|
||||
"landing.pricing.business.f6": "5 рабочих мест",
|
||||
@@ -142,5 +142,31 @@
|
||||
"landing.translate.supportedFormats": "Поддерживаются файлы DOCX, XLSX, PPTX или PDF",
|
||||
"landing.translate.aiAnalysis": "Активный ИИ-анализ",
|
||||
"landing.translate.processing": "Обработка",
|
||||
"landing.translate.preservingLayout": "Ваше форматирование сохраняется"
|
||||
"landing.translate.preservingLayout": "Ваше форматирование сохраняется",
|
||||
"landing.beforeAfter.seal": "Тот же макет, слово в слово",
|
||||
"landing.beforeAfter.proof1": "Диаграммы SmartArt воссоздаются, а не сплющиваются",
|
||||
"landing.beforeAfter.proof2": "Ряды и оси диаграмм переведены",
|
||||
"landing.beforeAfter.proof3": "Оглавления заново формируются на целевом языке",
|
||||
"landing.beforeAfter.targetTitle": "Техническая спецификация — Обработка воздуха",
|
||||
"landing.beforeAfter.targetPara1": "Система вентиляции должна быть введена в эксплуатацию до конца второго квартала.",
|
||||
"landing.beforeAfter.targetPara2Before": "",
|
||||
"landing.beforeAfter.targetTerm": "Приточно-вытяжная установка",
|
||||
"landing.beforeAfter.targetPara2After": "должна соответствовать классу фильтрации F7 согласно графику.",
|
||||
"landing.beforeAfter.targetItem1": "Тепловая нагрузка: 42 кВт при номинальном расходе",
|
||||
"landing.beforeAfter.targetItem2": "Уровень шума ниже 45 dB(A) на расстоянии 3 м",
|
||||
"landing.formats.pill": "СОВМЕСТИМОСТЬ",
|
||||
"landing.hero.visualCaption": "Тот же макет, новый язык — больше ничего не меняется",
|
||||
"landing.pricing.free.name": "Бесплатно",
|
||||
"landing.pricing.free.desc": "Идеально, чтобы попробовать приложение",
|
||||
"landing.pricing.free.cta": "Выбрать этот план",
|
||||
"landing.pricing.enterprise.name": "Enterprise",
|
||||
"landing.pricing.enterprise.desc": "Индивидуальные решения для крупных организаций",
|
||||
"landing.pricing.enterprise.cta": "Связаться с нами",
|
||||
"landing.beforeAfter.sourceTitle": "Cahier des charges — Traitement d'air",
|
||||
"landing.beforeAfter.sourcePara1": "L'installation de ventilation doit être mise en service avant la fin du deuxième trimestre.",
|
||||
"landing.beforeAfter.sourcePara2Before": "Le",
|
||||
"landing.beforeAfter.sourceTerm": "groupe de traitement d'air",
|
||||
"landing.beforeAfter.sourcePara2After": "doit respecter la classe de filtration F7 conformément au planning.",
|
||||
"landing.beforeAfter.sourceItem1": "Charge thermique : 42 kW au débit nominal",
|
||||
"landing.beforeAfter.sourceItem2": "Niveau sonore inférieur à 45 dB(A) à 3 m"
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"pricing.plans.pro.feat8": "История за 90 дней",
|
||||
"pricing.plans.business.feat1": "1 000 документов / месяц",
|
||||
"pricing.plans.business.feat2": "До 500 страниц на документ",
|
||||
"pricing.plans.business.feat3": "Базовый + Премиум ИИ (Claude Haiku)",
|
||||
"pricing.plans.business.feat3": "Базовый + Премиум ИИ (Claude 5)",
|
||||
"pricing.plans.business.feat4": "Все провайдеры перевода",
|
||||
"pricing.plans.business.feat5": "Файлы до 50 МБ",
|
||||
"pricing.plans.business.feat6": "Доступ к API (10 000 вызовов/мес.)",
|
||||
@@ -50,7 +50,7 @@
|
||||
"pricing.plans.business.feat9": "История за 1 год",
|
||||
"pricing.plans.business.feat10": "Расширенная аналитика",
|
||||
"pricing.plans.enterprise.feat1": "Безлимитные документы",
|
||||
"pricing.plans.enterprise.feat2": "Все ИИ-модели (GPT-5, Claude Opus 4.6…)",
|
||||
"pricing.plans.enterprise.feat2": "Все ИИ-модели (Claude 5, DeepSeek V4 Pro, GLM-5.3…)",
|
||||
"pricing.plans.enterprise.feat3": "Локальное развёртывание или выделенное облако",
|
||||
"pricing.plans.enterprise.feat4": "SLA 99,9 % гарантировано",
|
||||
"pricing.plans.enterprise.feat5": "Выделенная поддержка 24/7",
|
||||
@@ -108,19 +108,19 @@
|
||||
"pricing.trust.parallel.sub": "Сверхбыстрая многопоточная ИИ",
|
||||
"pricing.trust.availability.title": "Доступно 24/7",
|
||||
"pricing.trust.availability.sub": "99,9 % гарантированный аптайм",
|
||||
"pricing.aiModels.title": "Наши ИИ-модели — март 2026",
|
||||
"pricing.aiModels.title": "Наши ИИ-модели — сентябрь 2026",
|
||||
"pricing.aiModels.essential.title": "Базовый ИИ-перевод",
|
||||
"pricing.aiModels.essential.plan": "Тариф Pro",
|
||||
"pricing.aiModels.essential.descPrefix": "На базе",
|
||||
"pricing.aiModels.essential.descSuffix": "— самой экономичной ИИ-модели 2026 года. Качество на уровне frontier-моделей при стоимости в десятки раз ниже.",
|
||||
"pricing.aiModels.essential.modelName": "наша базовая ИИ-модель",
|
||||
"pricing.aiModels.essential.context": "163K токенов контекста",
|
||||
"pricing.aiModels.essential.value": "Отличное соотношение цены и качества",
|
||||
"pricing.aiModels.essential.descSuffix": "— самые экономичные ИИ-модели 2026 года.",
|
||||
"pricing.aiModels.essential.modelName": "DeepSeek V4 Flash, GLM-5.3 Flash и MiniMax M3",
|
||||
"pricing.aiModels.essential.context": "Контекст до 1,3 млн токенов (GLM-5.3 Flash)",
|
||||
"pricing.aiModels.essential.value": "Лучшее соотношение цены и качества",
|
||||
"pricing.aiModels.premium.title": "Премиум ИИ-перевод",
|
||||
"pricing.aiModels.premium.plan": "Тариф Business",
|
||||
"pricing.aiModels.premium.descPrefix": "На базе",
|
||||
"pricing.aiModels.premium.descSuffix": "от Anthropic — высокая точность на юридических, медицинских и сложных технических документах.",
|
||||
"pricing.aiModels.premium.context": "200K токенов контекста",
|
||||
"pricing.aiModels.premium.context": "1 млн токенов контекста",
|
||||
"pricing.aiModels.premium.precision": "Наивысшая точность",
|
||||
"pricing.faq.title": "Часто задаваемые вопросы",
|
||||
"pricing.faq.q1": "Могу ли я сменить тариф в любое время?",
|
||||
@@ -128,7 +128,7 @@
|
||||
"pricing.faq.q2": "Что такое «Базовый ИИ-перевод»?",
|
||||
"pricing.faq.a2": "Это наш ИИ-движок. Он понимает контекст ваших документов, сохраняет вёрстку и обрабатывает технические термины намного лучше классического перевода.",
|
||||
"pricing.faq.q3": "В чём разница между базовым и премиум ИИ-переводом?",
|
||||
"pricing.faq.a3": "Базовый ИИ использует оптимизированную модель (отличное соотношение цены и качества). Премиум ИИ использует Claude 3.5 Haiku от Anthropic, более точный на юридических, медицинских и сложных технических документах.",
|
||||
"pricing.faq.a3": "Базовый ИИ работает на моделях DeepSeek V4 Flash, GLM-5.3 Flash и MiniMax M3 (отличное соотношение цены и качества). Премиум ИИ использует Claude 5 от Anthropic, более точный на юридических, медицинских и сложных технических документах.",
|
||||
"pricing.faq.q4": "Сохраняются ли мои документы после перевода?",
|
||||
"pricing.faq.a4": "Переведённые файлы доступны в зависимости от тарифа (30 дней Starter, 90 дней Pro, 1 год Business). Они зашифрованы при хранении и передаче.",
|
||||
"pricing.faq.q5": "Что произойдёт при превышении месячной квоты?",
|
||||
@@ -146,5 +146,26 @@
|
||||
"pricing.toast.paymentError": "Ошибка при создании платежа.",
|
||||
"pricing.dashboard": "Панель",
|
||||
"pricing.okSymbol": "✓",
|
||||
"pricing.errSymbol": "✕"
|
||||
"pricing.errSymbol": "✕",
|
||||
"pricing.aiModels.essential.price": "От $0,09 за 1 млн токенов",
|
||||
"pricing.aiModels.premium.modelName": "Claude 5",
|
||||
"pricing.aiModels.premium.alternatives": "Альтернативы: DeepSeek V4 Pro и GLM-5.3",
|
||||
"pricing.confirm.title": "Подтвердите подписку",
|
||||
"pricing.confirm.subtitle": "Вы будете перенаправлены к нашему безопасному платёжному провайдеру.",
|
||||
"pricing.confirm.cancel": "Отмена",
|
||||
"pricing.confirm.cta": "Перейти к оплате",
|
||||
"pricing.confirm.year": "год",
|
||||
"pricing.confirm.month": "месяц",
|
||||
"pricing.confirm.monthlyEquivalent": "Годовая оплата — то есть {price} € / месяц.",
|
||||
"pricing.confirm.secureNote": "Отмена в любой момент из профиля. Платёж обрабатывает Stripe; данные вашей карты никогда не попадают на наши серверы.",
|
||||
"pricing.enterprise.subject": "Запрос по тарифу Enterprise",
|
||||
"pricing.enterpriseBand.cta": "Связаться с нами",
|
||||
"pricing.enterpriseBand.text": "Объёмы, выделенные движки, локальное размещение — давайте обсудим.",
|
||||
"pricing.error.server": "Ошибка сервера {status}",
|
||||
"pricing.freeBand.cta": "Начать бесплатно",
|
||||
"pricing.freeBand.text": "Хотите просто попробовать? Начните бесплатно — 5 документов в месяц, без карты.",
|
||||
"pricing.header.titleBase": "Тариф для",
|
||||
"pricing.header.titleAccent": "каждой задачи",
|
||||
"pricing.toast.close": "Закрыть",
|
||||
"pricing.aiModels.premium.price": "$2 / $10 за 1 млн токенов"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"providerTheme.openrouter.desc": "Единый доступ к лучшим open-source моделям, оптимизированным для перевода.",
|
||||
"providerTheme.openrouter_premium.badge": "Ультра",
|
||||
"providerTheme.openrouter_premium.subBadge": "Максимальный контекст",
|
||||
"providerTheme.openrouter_premium.desc": "С помощью современных моделей (GPT-4o, Claude Sonnet 4.6) для длинных документов.",
|
||||
"providerTheme.openrouter_premium.desc": "С помощью современных моделей (GPT-4o, Claude 5) для длинных документов.",
|
||||
"providerTheme.zai.badge": "Специализированный",
|
||||
"providerTheme.zai.subBadge": "Финансы и право",
|
||||
"providerTheme.zai.desc": "Модель точно настроена для требовательных бизнес-терминологий (юриспруденция, финансы).",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"admin.nav.providers": "服务商",
|
||||
"admin.nav.system": "系统",
|
||||
"admin.nav.logs": "日志",
|
||||
"admin.nav.stats": "统计",
|
||||
"admin.users.title": "用户管理",
|
||||
"admin.users.subtitle": "查看和管理用户账户",
|
||||
"admin.users.planUpdated": "套餐已更新",
|
||||
@@ -44,5 +45,105 @@
|
||||
"admin.system.waitingData": "等待数据...",
|
||||
"admin.system.purging": "清理中...",
|
||||
"admin.system.clean": "清理",
|
||||
"admin.system.purge": "清除"
|
||||
"admin.system.purge": "清除",
|
||||
"admin.nav.models": "模型与订阅",
|
||||
"admin.nav.marketing": "市场营销",
|
||||
"admin.marketing.audience": "目标人群",
|
||||
"admin.marketing.audienceAllUsers": "全部账户",
|
||||
"admin.marketing.audienceInactive": "30 天未活动的账户",
|
||||
"admin.marketing.audiencePlaceholder": "选择目标人群",
|
||||
"admin.marketing.audiencePlanBusiness": "Business 套餐",
|
||||
"admin.marketing.audiencePlanEnterprise": "Enterprise 套餐",
|
||||
"admin.marketing.audiencePlanFree": "免费套餐",
|
||||
"admin.marketing.audiencePlanPro": "Pro 套餐",
|
||||
"admin.marketing.audiencePlanStarter": "Starter 套餐",
|
||||
"admin.marketing.audienceWaitlist": "等候名单",
|
||||
"admin.marketing.badgeReal": "正式",
|
||||
"admin.marketing.badgeTest": "测试",
|
||||
"admin.marketing.confirmSend": "将此邮件发送给 {count} 位收件人(「{audience}」)?",
|
||||
"admin.marketing.footerNote": "发送时会自动添加包含退订链接的页脚。",
|
||||
"admin.marketing.hidePreview": "隐藏预览",
|
||||
"admin.marketing.history": "发送历史",
|
||||
"admin.marketing.historyFailedSuffix": ",{count} 封失败",
|
||||
"admin.marketing.historySent": "已发送 {sent}/{total}",
|
||||
"admin.marketing.html": "HTML 内容",
|
||||
"admin.marketing.htmlPlaceholder": "<html><body>…</body></html>",
|
||||
"admin.marketing.incompleteDesc": "请填写主题和 HTML。",
|
||||
"admin.marketing.incompleteTitle": "内容不完整",
|
||||
"admin.marketing.loadErrorTitle": "加载错误",
|
||||
"admin.marketing.localCheckUnavailable": "此浏览器不支持本地校验:服务器将检查事先的测试发送。",
|
||||
"admin.marketing.networkDesc": "无法连接后端。",
|
||||
"admin.marketing.newCampaign": "新营销活动",
|
||||
"admin.marketing.noHistory": "暂无发送记录。",
|
||||
"admin.marketing.preview": "预览",
|
||||
"admin.marketing.previewEmpty": "预览:请在旁边粘贴您的 HTML。",
|
||||
"admin.marketing.previewTitle": "邮件预览",
|
||||
"admin.marketing.recipientsAvailable": "排除退订用户后,共有 {count} 位收件人。",
|
||||
"admin.marketing.sendReal": "发送给 {count} 位收件人",
|
||||
"admin.marketing.sendRefusedTitle": "发送被拒绝",
|
||||
"admin.marketing.sendTest": "测试发送",
|
||||
"admin.marketing.sendingDesc": "{count} 封邮件进入发送队列(间隔 0.2 秒)。退订用户已被排除。",
|
||||
"admin.marketing.sendingTitle": "发送进行中",
|
||||
"admin.marketing.subject": "主题",
|
||||
"admin.marketing.subjectPlaceholder": "例如:您的翻译已完成 — 7 天内 8 折",
|
||||
"admin.marketing.subtitle": "向目标人群发送邮件,必须先测试发送,自动附加退订链接,并保留完整历史。",
|
||||
"admin.marketing.testDoneDesc": "邮件已送达 {email}",
|
||||
"admin.marketing.testDoneTitle": "测试发送完成",
|
||||
"admin.marketing.testEmail": "测试邮箱(可选)",
|
||||
"admin.marketing.testEmailPlaceholder": "否则使用 SMTP 发件地址",
|
||||
"admin.marketing.testFailedTitle": "测试发送失败",
|
||||
"admin.marketing.testRequiredHint": "正式发送要求先对完全相同的内容进行测试发送(由服务器校验)。",
|
||||
"admin.marketing.testValidated": "此内容的测试发送已通过。",
|
||||
"admin.marketing.title": "营销 — 邮件营销",
|
||||
"admin.marketing.unsubscribedNote": "{count} 位退订用户将被排除在所有发送之外。",
|
||||
"admin.models.addModel": "添加模型",
|
||||
"admin.models.businessBadge": "Business 套餐",
|
||||
"admin.models.cancel": "取消",
|
||||
"admin.models.catalog": "OpenRouter 目录",
|
||||
"admin.models.catalogErrorDesc": "无法加载 OpenRouter 目录。",
|
||||
"admin.models.catalogErrorTitle": "目录不可用",
|
||||
"admin.models.customBadge": "定制层级",
|
||||
"admin.models.customNote": "定制模型,与客户逐案商定。",
|
||||
"admin.models.defaultBadge": "默认",
|
||||
"admin.models.emptyTier": "暂无模型:将使用套餐的官方模型阵容。",
|
||||
"admin.models.enterpriseBadge": "Enterprise 套餐",
|
||||
"admin.models.essentialBadge": "基础 AI 层级",
|
||||
"admin.models.essentialCost": "— 按成本系数 1 计费",
|
||||
"admin.models.fallbackFirst": "(首选备用)",
|
||||
"admin.models.loadErrorDesc": "HTTP {status} — 请检查管理员令牌。",
|
||||
"admin.models.loadErrorTitle": "加载错误",
|
||||
"admin.models.matrixDesc": "每个层级的有效模型 — 列表顺序即备用优先级。单选按钮选择默认模型,立即生效,无需重新部署。",
|
||||
"admin.models.matrixTitle": "按套餐划分的模型矩阵",
|
||||
"admin.models.missingConfig": "未找到 AI 层级配置。请刷新页面。",
|
||||
"admin.models.moveDown": "下移",
|
||||
"admin.models.moveUp": "上移",
|
||||
"admin.models.networkDesc": "无法连接后端。",
|
||||
"admin.models.networkTitle": "网络错误",
|
||||
"admin.models.premiumBadge": "高级 AI 层级",
|
||||
"admin.models.premiumCost": "— 按成本系数 5 计费",
|
||||
"admin.models.premiumReservedNote": "仅限 Business 和 Enterprise 套餐(「openrouter_premium」引擎)。",
|
||||
"admin.models.proBadge": "Pro 套餐",
|
||||
"admin.models.remove": "移除",
|
||||
"admin.models.save": "保存",
|
||||
"admin.models.saveErrorTitle": "保存错误",
|
||||
"admin.models.saveNetworkDesc": "无法保存配置。",
|
||||
"admin.models.savedDesc": "新的默认模型从下一次翻译起生效,无需重新部署。",
|
||||
"admin.models.savedTitle": "模型已保存",
|
||||
"admin.models.saving": "保存中...",
|
||||
"admin.models.setDefault": "将 {model} 设为默认模型",
|
||||
"admin.models.sharedTierNote": "共享层级:Business 套餐的「openrouter」引擎同样使用基础层级 — 上面的列表对两个套餐都适用。",
|
||||
"admin.models.subtitle": "套餐 → AI 层级矩阵。每次翻译实际使用的模型是套餐所属层级的模型:Pro 套餐绝不会触发 Premium 模型。",
|
||||
"admin.models.title": "模型与订阅",
|
||||
"admin.stats.aiTiers": "AI 层级(30 天)",
|
||||
"admin.stats.includingCredits": "其中 {amount} 为积分",
|
||||
"admin.stats.mrr": "预估 MRR",
|
||||
"admin.stats.noTranslationsYet": "最近 30 天数据库中没有翻译记录:层级分布将随后续翻译逐步填充。",
|
||||
"admin.stats.payments30": "30 天内 {count} 笔付款",
|
||||
"admin.stats.refreshing": "刷新中...",
|
||||
"admin.stats.revenue30": "收入(30 天)",
|
||||
"admin.stats.revenueTotal": "累计已收收入",
|
||||
"admin.stats.tiersSub": "基础 / 高级 · 经典 {classic} · 其他 {other}",
|
||||
"admin.stats.unavailable": "业务统计不可用({error})。",
|
||||
"admin.stats.waitlist": "等候名单",
|
||||
"admin.stats.waitlistSub": "位用户等候中"
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
"landing.pricing.pro.desc": "适合专业用户",
|
||||
"landing.pricing.pro.f1": "每月200份文档",
|
||||
"landing.pricing.pro.f2": "每份文档最多200页",
|
||||
"landing.pricing.pro.f3": "AI翻译",
|
||||
"landing.pricing.pro.f3": "基础 AI:DeepSeek V4 Flash、GLM-5.3 Flash、MiniMax M3",
|
||||
"landing.pricing.pro.f4": "包含Google",
|
||||
"landing.pricing.pro.f5": "自定义术语表和提示词",
|
||||
"landing.pricing.pro.f6": "优先支持",
|
||||
@@ -109,7 +109,7 @@
|
||||
"landing.pricing.business.desc": "适合大批量需求的团队",
|
||||
"landing.pricing.business.f1": "每月1,000份文档",
|
||||
"landing.pricing.business.f2": "每份文档最多500页",
|
||||
"landing.pricing.business.f3": "高级AI(Claude)",
|
||||
"landing.pricing.business.f3": "高级AI(Claude 5)",
|
||||
"landing.pricing.business.f4": "所有供应商 + API",
|
||||
"landing.pricing.business.f5": "Webhook和自动化",
|
||||
"landing.pricing.business.f6": "5个团队席位",
|
||||
@@ -142,5 +142,31 @@
|
||||
"landing.translate.supportedFormats": "支持 DOCX, XLSX, PPTX 或 PDF 文件",
|
||||
"landing.translate.aiAnalysis": "AI 分析中",
|
||||
"landing.translate.processing": "正在处理",
|
||||
"landing.translate.preservingLayout": "正在保留您的排版"
|
||||
"landing.translate.preservingLayout": "正在保留您的排版",
|
||||
"landing.beforeAfter.seal": "相同的排版,逐字对应",
|
||||
"landing.beforeAfter.proof1": "SmartArt 图形会重新构建,而不是被压平",
|
||||
"landing.beforeAfter.proof2": "图表系列与坐标轴一并翻译",
|
||||
"landing.beforeAfter.proof3": "目录按目标语言重新生成",
|
||||
"landing.beforeAfter.targetTitle": "技术规格 — 空气处理",
|
||||
"landing.beforeAfter.targetPara1": "通风系统必须在第二季度结束前调试运行。",
|
||||
"landing.beforeAfter.targetPara2Before": "",
|
||||
"landing.beforeAfter.targetTerm": "空气处理机组",
|
||||
"landing.beforeAfter.targetPara2After": "必须按照计划达到 F7 过滤等级。",
|
||||
"landing.beforeAfter.targetItem1": "热负荷:额定流量下 42 kW",
|
||||
"landing.beforeAfter.targetItem2": "3 米处噪音低于 45 dB(A)",
|
||||
"landing.formats.pill": "兼容性",
|
||||
"landing.hero.visualCaption": "相同的排版,新的语言 — 其余一切保持不变",
|
||||
"landing.pricing.free.name": "免费",
|
||||
"landing.pricing.free.desc": "适合初次体验应用",
|
||||
"landing.pricing.free.cta": "选择此套餐",
|
||||
"landing.pricing.enterprise.name": "企业版",
|
||||
"landing.pricing.enterprise.desc": "面向大型组织的定制解决方案",
|
||||
"landing.pricing.enterprise.cta": "联系我们",
|
||||
"landing.beforeAfter.sourceTitle": "Cahier des charges — Traitement d'air",
|
||||
"landing.beforeAfter.sourcePara1": "L'installation de ventilation doit être mise en service avant la fin du deuxième trimestre.",
|
||||
"landing.beforeAfter.sourcePara2Before": "Le",
|
||||
"landing.beforeAfter.sourceTerm": "groupe de traitement d'air",
|
||||
"landing.beforeAfter.sourcePara2After": "doit respecter la classe de filtration F7 conformément au planning.",
|
||||
"landing.beforeAfter.sourceItem1": "Charge thermique : 42 kW au débit nominal",
|
||||
"landing.beforeAfter.sourceItem2": "Niveau sonore inférieur à 45 dB(A) à 3 m"
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"pricing.plans.pro.feat8": "90 天历史记录",
|
||||
"pricing.plans.business.feat1": "每月 1,000 份文档",
|
||||
"pricing.plans.business.feat2": "每份文档最多 500 页",
|
||||
"pricing.plans.business.feat3": "基础 + 高级 AI(Claude Haiku)",
|
||||
"pricing.plans.business.feat3": "基础 + 高级 AI(Claude 5)",
|
||||
"pricing.plans.business.feat4": "所有翻译服务商",
|
||||
"pricing.plans.business.feat5": "文件最大 50 MB",
|
||||
"pricing.plans.business.feat6": "API 访问(每月 10,000 次调用)",
|
||||
@@ -50,7 +50,7 @@
|
||||
"pricing.plans.business.feat9": "1 年历史记录",
|
||||
"pricing.plans.business.feat10": "高级分析",
|
||||
"pricing.plans.enterprise.feat1": "无限文档",
|
||||
"pricing.plans.enterprise.feat2": "所有 AI 模型(GPT-5、Claude Opus 4.6…)",
|
||||
"pricing.plans.enterprise.feat2": "所有 AI 模型(Claude 5、DeepSeek V4 Pro、GLM-5.3…)",
|
||||
"pricing.plans.enterprise.feat3": "本地部署或专属云",
|
||||
"pricing.plans.enterprise.feat4": "99.9% SLA 保障",
|
||||
"pricing.plans.enterprise.feat5": "24/7 专属支持",
|
||||
@@ -108,19 +108,19 @@
|
||||
"pricing.trust.parallel.sub": "超快多线程 AI",
|
||||
"pricing.trust.availability.title": "7×24 小时可用",
|
||||
"pricing.trust.availability.sub": "99.9% 可用性保障",
|
||||
"pricing.aiModels.title": "我们的 AI 模型 — 2026年3月",
|
||||
"pricing.aiModels.title": "我们的 AI 模型 — 2026年9月",
|
||||
"pricing.aiModels.essential.title": "基础 AI 翻译",
|
||||
"pricing.aiModels.essential.plan": "专业版方案",
|
||||
"pricing.aiModels.essential.descPrefix": "基于",
|
||||
"pricing.aiModels.essential.descSuffix": "— 2026 年最具性价比的 AI 模型。质量媲美前沿模型,成本仅为其一小部分。",
|
||||
"pricing.aiModels.essential.modelName": "Essential AI模型",
|
||||
"pricing.aiModels.essential.context": "163K 上下文 Token",
|
||||
"pricing.aiModels.essential.value": "性价比极佳",
|
||||
"pricing.aiModels.essential.descSuffix": "— 2026 年最具性价比的 AI 模型。",
|
||||
"pricing.aiModels.essential.modelName": "DeepSeek V4 Flash、GLM-5.3 Flash 和 MiniMax M3",
|
||||
"pricing.aiModels.essential.context": "上下文最多 130 万 Token(GLM-5.3 Flash)",
|
||||
"pricing.aiModels.essential.value": "极致性价比",
|
||||
"pricing.aiModels.premium.title": "高级 AI 翻译",
|
||||
"pricing.aiModels.premium.plan": "企业版方案",
|
||||
"pricing.aiModels.premium.descPrefix": "基于",
|
||||
"pricing.aiModels.premium.descSuffix": "Anthropic 出品 — 在法律、医疗和复杂技术文档方面表现精准。",
|
||||
"pricing.aiModels.premium.context": "200K 上下文 Token",
|
||||
"pricing.aiModels.premium.context": "100 万上下文 Token",
|
||||
"pricing.aiModels.premium.precision": "最高精度",
|
||||
"pricing.faq.title": "常见问题",
|
||||
"pricing.faq.q1": "我可以随时更换方案吗?",
|
||||
@@ -128,7 +128,7 @@
|
||||
"pricing.faq.q2": "什么是「基础 AI 翻译」?",
|
||||
"pricing.faq.a2": "这是我们的AI引擎。它能理解文档上下文,保留排版,并比传统翻译更好地处理技术术语。",
|
||||
"pricing.faq.q3": "基础 AI 和高级 AI 有什么区别?",
|
||||
"pricing.faq.a3": "Essential AI使用优化模型(性价比极佳)。Premium AI使用Anthropic的Claude 3.5 Haiku,在法律、医学和复杂技术文档上更准确。",
|
||||
"pricing.faq.a3": "Essential AI使用DeepSeek V4 Flash、GLM-5.3 Flash和MiniMax M3(性价比极佳)。Premium AI使用Anthropic的Claude 5,在法律、医学和复杂技术文档上更准确。",
|
||||
"pricing.faq.q4": "翻译后文档会被保留吗?",
|
||||
"pricing.faq.a4": "翻译文件根据您的方案保留(入门版 30 天、专业版 90 天、企业版 1 年)。存储和传输过程中均已加密。",
|
||||
"pricing.faq.q5": "超过月度配额会怎样?",
|
||||
@@ -146,5 +146,26 @@
|
||||
"pricing.toast.paymentError": "创建支付时出错。",
|
||||
"pricing.dashboard": "仪表板",
|
||||
"pricing.okSymbol": "✓",
|
||||
"pricing.errSymbol": "✕"
|
||||
"pricing.errSymbol": "✕",
|
||||
"pricing.aiModels.essential.price": "每百万 Token 低至 $0.09",
|
||||
"pricing.aiModels.premium.modelName": "Claude 5",
|
||||
"pricing.aiModels.premium.alternatives": "备选模型:DeepSeek V4 Pro 和 GLM-5.3",
|
||||
"pricing.confirm.title": "确认您的订阅",
|
||||
"pricing.confirm.subtitle": "您将被重定向到我们的安全支付服务商。",
|
||||
"pricing.confirm.cancel": "取消",
|
||||
"pricing.confirm.cta": "继续付款",
|
||||
"pricing.confirm.year": "年",
|
||||
"pricing.confirm.month": "个月",
|
||||
"pricing.confirm.monthlyEquivalent": "按年计费 — 相当于每月 {price} 欧元。",
|
||||
"pricing.confirm.secureNote": "可随时在个人资料中取消。付款由 Stripe 处理;您的银行卡信息绝不会经过我们的服务器。",
|
||||
"pricing.enterprise.subject": "企业版方案咨询",
|
||||
"pricing.enterpriseBand.cta": "联系我们",
|
||||
"pricing.enterpriseBand.text": "大批量、专属引擎、本地部署选项 — 欢迎洽谈。",
|
||||
"pricing.error.server": "服务器错误 {status}",
|
||||
"pricing.freeBand.cta": "免费开始",
|
||||
"pricing.freeBand.text": "只想先试试?免费开始 — 每月 5 份文档,无需银行卡。",
|
||||
"pricing.header.titleBase": "总有一款套餐适合",
|
||||
"pricing.header.titleAccent": "您的需求",
|
||||
"pricing.toast.close": "关闭",
|
||||
"pricing.aiModels.premium.price": "每百万 Token $2 / $10"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"providerTheme.openrouter.desc": "统一访问为翻译优化的最佳开源模型。",
|
||||
"providerTheme.openrouter_premium.badge": "超级",
|
||||
"providerTheme.openrouter_premium.subBadge": "最大上下文",
|
||||
"providerTheme.openrouter_premium.desc": "由尖端模型(GPT-4o、Claude Sonnet 4.6)辅助,适用于长文档。",
|
||||
"providerTheme.openrouter_premium.desc": "由尖端模型(GPT-4o、Claude 5)辅助,适用于长文档。",
|
||||
"providerTheme.zai.badge": "专业",
|
||||
"providerTheme.zai.subBadge": "金融与法律",
|
||||
"providerTheme.zai.desc": "针对苛刻的业务术语(法律、金融)进行了微调的模型。",
|
||||
|
||||
@@ -8,9 +8,12 @@ export const openaiModels = [
|
||||
];
|
||||
|
||||
export const openrouterModels = [
|
||||
{ id: "google/gemini-3.5-flash", name: "Gemini 3.5 Flash" },
|
||||
{ id: "deepseek/deepseek-chat", name: "DeepSeek Chat" },
|
||||
{ id: "anthropic/claude-sonnet-4.6", name: "Claude Sonnet 4.6" },
|
||||
{ id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash" },
|
||||
{ id: "z-ai/glm-5.3-flash", name: "GLM-5.3 Flash" },
|
||||
{ id: "minimax/minimax-m3", name: "MiniMax M3" },
|
||||
{ id: "anthropic/claude-sonnet-5", name: "Claude 5" },
|
||||
{ id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro" },
|
||||
{ id: "z-ai/glm-5.3", name: "GLM-5.3" },
|
||||
];
|
||||
|
||||
interface TranslationSettings {
|
||||
@@ -101,7 +104,7 @@ const defaultSettings: TranslationSettings = {
|
||||
openaiApiKey: "",
|
||||
openaiModel: "gpt-4o-mini",
|
||||
openrouterApiKey: "",
|
||||
openrouterModel: "deepseek/deepseek-chat-v3-0324",
|
||||
openrouterModel: "deepseek/deepseek-v4-flash",
|
||||
libreTranslateUrl: "",
|
||||
systemPrompt: "",
|
||||
glossary: "",
|
||||
|
||||
@@ -31,10 +31,24 @@ import os
|
||||
# NOTE: Stripe price IDs must be set via env vars in production.
|
||||
# Create products/prices in Stripe Dashboard: https://dashboard.stripe.com/products
|
||||
#
|
||||
# LLM models used (via OpenRouter — June 2026):
|
||||
# Essentielle : deepseek/deepseek-chat (DeepSeek V4/V3)
|
||||
# Premium : anthropic/claude-sonnet-4.6 (Claude Sonnet 4.6)
|
||||
# or google/gemini-3.5-flash (Gemini 3.5 Flash)
|
||||
# Official LLM ranges (via OpenRouter — September 2026). The model used per
|
||||
# translation is resolved from the plan's AI tier (admin settings > plan default):
|
||||
# Essentielle (Pro plan) : deepseek/deepseek-v4-flash, z-ai/glm-5.3-flash,
|
||||
# minimax/minimax-m3
|
||||
# Premium (Business) : anthropic/claude-sonnet-5 (default),
|
||||
# alternatives deepseek/deepseek-v4-pro, z-ai/glm-5.3
|
||||
# A Pro user must never trigger a Premium model (see translate_routes).
|
||||
DEFAULT_AI_MODELS_ESSENTIAL = [
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"z-ai/glm-5.3-flash",
|
||||
"minimax/minimax-m3",
|
||||
]
|
||||
DEFAULT_AI_MODELS_PREMIUM = [
|
||||
"anthropic/claude-sonnet-5",
|
||||
"deepseek/deepseek-v4-pro",
|
||||
"z-ai/glm-5.3",
|
||||
]
|
||||
|
||||
PLANS = {
|
||||
PlanType.FREE: {
|
||||
"name": "Free",
|
||||
@@ -97,7 +111,7 @@ PLANS = {
|
||||
"max_file_size_mb": 25,
|
||||
"max_chars_per_month": 2_000_000,
|
||||
"providers": ["google", "google_cloud", "openrouter"],
|
||||
"ai_model_essential": "deepseek/deepseek-chat",
|
||||
"ai_models_essential": list(DEFAULT_AI_MODELS_ESSENTIAL),
|
||||
"features": [
|
||||
"pricing.plans.pro.feat1",
|
||||
"pricing.plans.pro.feat2",
|
||||
@@ -127,8 +141,8 @@ PLANS = {
|
||||
"max_file_size_mb": 50,
|
||||
"max_chars_per_month": 10_000_000,
|
||||
"providers": ["google", "google_cloud", "openrouter", "openrouter_premium", "openai", "zai"],
|
||||
"ai_model_essential": "deepseek/deepseek-chat",
|
||||
"ai_model_premium": "anthropic/claude-sonnet-4.6",
|
||||
"ai_models_essential": list(DEFAULT_AI_MODELS_ESSENTIAL),
|
||||
"ai_models_premium": list(DEFAULT_AI_MODELS_PREMIUM),
|
||||
"features": [
|
||||
"pricing.plans.business.feat1",
|
||||
"pricing.plans.business.feat2",
|
||||
|
||||
@@ -5,19 +5,31 @@ Story 3.5: API Versioning - Migrated from main.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import asyncio
|
||||
import html as html_module
|
||||
import secrets
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Literal
|
||||
import hashlib
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, Literal, List
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Form, Request, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import JSONResponse, HTMLResponse
|
||||
from passlib.context import CryptContext
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from config import config
|
||||
from models.subscription import PlanType, PLANS
|
||||
from models.subscription import (
|
||||
PlanType,
|
||||
PLANS,
|
||||
DEFAULT_AI_MODELS_ESSENTIAL,
|
||||
DEFAULT_AI_MODELS_PREMIUM,
|
||||
)
|
||||
from services import pricing_config as pricing_cfg
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
@@ -619,7 +631,122 @@ async def get_admin_stats(admin_id: str = Depends(require_admin)):
|
||||
if docs > 0:
|
||||
active_users += 1
|
||||
|
||||
cache_stats = _translation_cache.get_stats()
|
||||
# Correction : TranslationCache expose stats() (get_stats n'a jamais existé —
|
||||
# l'appel cassé renvoyait une erreur 500 sur GET /stats avant cette correction).
|
||||
try:
|
||||
cache_stats = _translation_cache.stats()
|
||||
except Exception as e:
|
||||
logger.warning("Cache stats unavailable: %s", e)
|
||||
cache_stats = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Statistiques enrichies (spec « Modèles & abonnements ») : revenus,
|
||||
# MRR estimé, crédits achetés, liste d'attente, paliers IA utilisés.
|
||||
# Toutes ces métriques sont best-effort : un échec ne casse pas /stats.
|
||||
# ------------------------------------------------------------------
|
||||
revenue = {
|
||||
"collected_total": 0.0,
|
||||
"collected_30d": 0.0,
|
||||
"credits_purchased": 0.0,
|
||||
"payments_30d": 0,
|
||||
"currency": "EUR",
|
||||
}
|
||||
mrr_estimated = {"total": 0.0, "by_plan": {}}
|
||||
ai_tier_usage = {"essential": 0, "premium": 0, "classic": 0, "other": 0}
|
||||
waitlist_count = 0
|
||||
|
||||
# MRR estimé : pour chaque plan payé, abonnés × prix mensuel effectif
|
||||
# (overrides admin inclus via get_effective_monthly_yearly).
|
||||
for plan_value, count in plan_distribution.items():
|
||||
try:
|
||||
monthly, _yearly = pricing_cfg.get_effective_monthly_yearly(plan_value)
|
||||
except Exception:
|
||||
continue
|
||||
if monthly and monthly > 0:
|
||||
mrr_plan = round(monthly * count, 2)
|
||||
mrr_estimated["by_plan"][plan_value] = mrr_plan
|
||||
mrr_estimated["total"] = round(mrr_estimated["total"] + mrr_plan, 2)
|
||||
|
||||
try:
|
||||
from routes.waitlist_routes import _load_waitlist
|
||||
|
||||
waitlist_count = len(_load_waitlist())
|
||||
except Exception as e:
|
||||
logger.debug("Waitlist count unavailable: %s", e)
|
||||
|
||||
if USE_DATABASE and DATABASE_AVAILABLE:
|
||||
try:
|
||||
from database.connection import get_sync_session
|
||||
from database.models import PaymentHistory as DBPayment
|
||||
from database.models import Translation as DBTranslation
|
||||
from sqlalchemy import func as _sqlfunc
|
||||
|
||||
cutoff_30d = datetime.now(timezone.utc) - timedelta(days=30)
|
||||
with get_sync_session() as session:
|
||||
# Devise dominante (le site facture en EUR ; Stripe peut renvoyer
|
||||
# une autre devise pour d'anciens paiements) — agrégation en SQL.
|
||||
currency_totals = (
|
||||
session.query(
|
||||
DBPayment.currency,
|
||||
_sqlfunc.sum(DBPayment.amount_cents),
|
||||
)
|
||||
.filter(DBPayment.status == "succeeded")
|
||||
.group_by(DBPayment.currency)
|
||||
.all()
|
||||
)
|
||||
if currency_totals:
|
||||
main_currency, _main_total = max(
|
||||
currency_totals, key=lambda row: (row[1] or 0)
|
||||
)
|
||||
|
||||
def _sum_cents(extra_filters=()):
|
||||
q = session.query(_sqlfunc.sum(DBPayment.amount_cents)).filter(
|
||||
DBPayment.status == "succeeded",
|
||||
DBPayment.currency == main_currency,
|
||||
)
|
||||
for f in extra_filters:
|
||||
q = q.filter(f)
|
||||
return (q.scalar() or 0) / 100.0
|
||||
|
||||
def _count_payments(extra_filters=()):
|
||||
q = session.query(_sqlfunc.count(DBPayment.id)).filter(
|
||||
DBPayment.status == "succeeded",
|
||||
DBPayment.currency == main_currency,
|
||||
)
|
||||
for f in extra_filters:
|
||||
q = q.filter(f)
|
||||
return q.scalar() or 0
|
||||
|
||||
total = _sum_cents()
|
||||
last30 = _sum_cents((DBPayment.created_at >= cutoff_30d,))
|
||||
credits = _sum_cents((DBPayment.payment_type == "credits",))
|
||||
n30 = _count_payments((DBPayment.created_at >= cutoff_30d,))
|
||||
revenue.update(
|
||||
collected_total=round(total, 2),
|
||||
collected_30d=round(last30, 2),
|
||||
credits_purchased=round(credits, 2),
|
||||
payments_30d=n30,
|
||||
currency=(main_currency or "eur").upper(),
|
||||
)
|
||||
|
||||
# Répartition des paliers IA utilisés (traductions des 30 derniers jours).
|
||||
tier_rows = (
|
||||
session.query(DBTranslation.provider)
|
||||
.filter(DBTranslation.created_at >= cutoff_30d)
|
||||
.all()
|
||||
)
|
||||
for (prov,) in tier_rows:
|
||||
p = (prov or "").strip().lower()
|
||||
if p == "openrouter_premium":
|
||||
ai_tier_usage["premium"] += 1
|
||||
elif p in ("openrouter", "deepseek", "minimax", "zai"):
|
||||
ai_tier_usage["essential"] += 1
|
||||
elif p in ("google", "google_cloud"):
|
||||
ai_tier_usage["classic"] += 1
|
||||
else:
|
||||
ai_tier_usage["other"] += 1
|
||||
except Exception as e:
|
||||
logger.warning("Revenue/tier stats unavailable: %s", e)
|
||||
|
||||
return {
|
||||
"users": {
|
||||
@@ -631,6 +758,10 @@ async def get_admin_stats(admin_id: str = Depends(require_admin)):
|
||||
"docs_this_month": total_docs_translated,
|
||||
"pages_this_month": total_pages_translated,
|
||||
},
|
||||
"revenue": revenue,
|
||||
"mrr_estimated": mrr_estimated,
|
||||
"ai_tier_usage": ai_tier_usage,
|
||||
"waitlist_count": waitlist_count,
|
||||
"cache": cache_stats,
|
||||
"config": {
|
||||
"translation_service": config.TRANSLATION_SERVICE,
|
||||
@@ -911,6 +1042,32 @@ 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.
|
||||
|
||||
``default_model`` vide → premier de ``models`` → défaut du plan (PLANS).
|
||||
Source des valeurs par défaut : la gamme officielle de ``models/subscription.py``.
|
||||
"""
|
||||
models: List[str] = Field(default_factory=list)
|
||||
default_model: str = ""
|
||||
|
||||
|
||||
class AiTiersSettings(BaseModel):
|
||||
essential: AiTierSettings = Field(
|
||||
default_factory=lambda: AiTierSettings(models=list(DEFAULT_AI_MODELS_ESSENTIAL))
|
||||
)
|
||||
premium: AiTierSettings = Field(
|
||||
default_factory=lambda: AiTierSettings(models=list(DEFAULT_AI_MODELS_PREMIUM))
|
||||
)
|
||||
|
||||
|
||||
def _default_ai_tiers() -> AiTiersSettings:
|
||||
return AiTiersSettings(
|
||||
essential=AiTierSettings(models=list(DEFAULT_AI_MODELS_ESSENTIAL)),
|
||||
premium=AiTierSettings(models=list(DEFAULT_AI_MODELS_PREMIUM)),
|
||||
)
|
||||
|
||||
|
||||
class SettingsConfig(BaseModel):
|
||||
google: ProviderSettings = ProviderSettings(enabled=True)
|
||||
google_cloud: ProviderSettings = ProviderSettings() # Cloud Translation API v2 (clé API)
|
||||
@@ -923,11 +1080,47 @@ class SettingsConfig(BaseModel):
|
||||
zai: ProviderSettings = ProviderSettings()
|
||||
mistral: ProviderSettings = ProviderSettings() # OCR Mistral (PDF scannés)
|
||||
smtp: SmtpSettings = SmtpSettings()
|
||||
ai_tiers: AiTiersSettings = Field(default_factory=_default_ai_tiers)
|
||||
fallback_chain: str = "google,google_cloud,openrouter,openrouter_premium,openai,deepseek,zai"
|
||||
fallback_chain_classic: str = "google,google_cloud"
|
||||
fallback_chain_llm: str = "openrouter,openrouter_premium,openai,deepseek,zai"
|
||||
|
||||
|
||||
def normalize_ai_tiers(ai_tiers: AiTiersSettings) -> AiTiersSettings:
|
||||
"""Déduplique, nettoie et complète un bloc ai_tiers avec la gamme officielle.
|
||||
|
||||
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.
|
||||
"""
|
||||
essential_official = list(DEFAULT_AI_MODELS_ESSENTIAL)
|
||||
premium_official = list(DEFAULT_AI_MODELS_PREMIUM)
|
||||
|
||||
def _fill(tier: AiTierSettings, official: List[str], other_official: List[str],
|
||||
tier_name: str, other_name: str) -> AiTierSettings:
|
||||
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:
|
||||
models = list(official)
|
||||
default = (tier.default_model or "").strip()
|
||||
if default and default not in models:
|
||||
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 AiTiersSettings(
|
||||
essential=_fill(ai_tiers.essential, essential_official, premium_official,
|
||||
"essential", "Premium"),
|
||||
premium=_fill(ai_tiers.premium, premium_official, essential_official,
|
||||
"premium", "Essentielle"),
|
||||
)
|
||||
|
||||
|
||||
def load_settings() -> SettingsConfig:
|
||||
try:
|
||||
import json
|
||||
@@ -983,10 +1176,10 @@ async def get_settings(admin_id: str = Depends(require_admin)):
|
||||
return d
|
||||
|
||||
payload = settings.model_dump()
|
||||
# Essentielle : Gemini 3.5 Flash / DeepSeek Chat — meilleur rapport qualité/prix (juin 2026)
|
||||
payload["openrouter"] = _merge_env(settings.openrouter, key_env="OPENROUTER_API_KEY", model_env="OPENROUTER_MODEL", default_model="google/gemini-3.5-flash")
|
||||
# Premium : Claude Sonnet 4.6 — précision maximale sur documents complexes
|
||||
payload["openrouter_premium"] = _merge_env(settings.openrouter_premium, key_env="OPENROUTER_API_KEY", model_env="OPENROUTER_PREMIUM_MODEL", default_model="anthropic/claude-sonnet-4.6")
|
||||
# Essentielle : DeepSeek V4 Flash / GLM-5.3 Flash / MiniMax M3 — meilleur rapport qualité/prix (sept. 2026)
|
||||
payload["openrouter"] = _merge_env(settings.openrouter, key_env="OPENROUTER_API_KEY", model_env="OPENROUTER_MODEL", default_model="deepseek/deepseek-v4-flash")
|
||||
# Premium : Claude 5 (Sonnet) — précision maximale sur documents complexes
|
||||
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")
|
||||
@@ -1036,6 +1229,20 @@ 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é)."""
|
||||
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()
|
||||
|
||||
return _is_default(ai_tiers.essential, list(DEFAULT_AI_MODELS_ESSENTIAL)) and _is_default(
|
||||
ai_tiers.premium, list(DEFAULT_AI_MODELS_PREMIUM)
|
||||
)
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
async def update_settings(
|
||||
settings: SettingsConfig, admin_id: str = Depends(require_admin)
|
||||
@@ -1048,6 +1255,23 @@ async def update_settings(
|
||||
if settings.smtp.password is not None:
|
||||
settings.smtp.password = settings.smtp.password.strip() or None
|
||||
|
||||
# Paliers IA : un client qui renvoie le bloc par défaut vierge (liste vide ou
|
||||
# gamme officielle, aucun défaut) n'écrase pas les paliers personnalisés
|
||||
# existants — sinon chaque sauvegarde d'une autre page réinitialiserait la
|
||||
# matrice « Modèles & abonnements ».
|
||||
if _ai_tiers_is_pristine(settings.ai_tiers) and existing.ai_tiers != _default_ai_tiers():
|
||||
settings.ai_tiers = existing.ai_tiers
|
||||
|
||||
# Paliers IA : dédoublonne et complète avec la gamme officielle (jamais vide).
|
||||
# ValueError → 400 si un défaut appartient à la gamme du palier opposé.
|
||||
try:
|
||||
settings.ai_tiers = normalize_ai_tiers(settings.ai_tiers)
|
||||
except ValueError as e:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "INVALID_AI_TIER", "message": str(e)},
|
||||
)
|
||||
|
||||
save_settings(settings)
|
||||
logger.info(f"admin_settings_updated by {admin_id}")
|
||||
return JSONResponse(
|
||||
@@ -1971,3 +2195,579 @@ async def setup_stripe_webhook(request: Request, admin_id: str = Depends(require
|
||||
"error": "STRIPE_API_ERROR",
|
||||
"message": str(e),
|
||||
})
|
||||
|
||||
|
||||
# ============================================================
|
||||
# MARKETING (relances par email — admin + désabonnement public)
|
||||
# Audiences : liste d'attente (data/waitlist.json), inactifs 30 j,
|
||||
# par plan (plan:<id>) et tous les comptes. Historique et
|
||||
# désabonnements dans des fichiers JSON (même motif que waitlist).
|
||||
# ============================================================
|
||||
|
||||
MARKETING_HISTORY_FILE = config.BASE_DIR / "data" / "marketing_emails.json"
|
||||
UNSUBSCRIBES_FILE = config.BASE_DIR / "data" / "unsubscribes.json"
|
||||
_marketing_lock = threading.Lock()
|
||||
# Empreintes des campagnes réelles en cours d'envoi (anti-simultanéité).
|
||||
_active_campaign_hashes: set = set()
|
||||
|
||||
# Audiences officielles exposées à l'admin (« par plan » couvre tous les plans).
|
||||
MARKETING_BASE_AUDIENCES = ["waitlist", "inactive_30", "plan:pro", "all_users"]
|
||||
|
||||
|
||||
def _load_unsubscribes() -> List[dict]:
|
||||
if not UNSUBSCRIBES_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
with open(UNSUBSCRIBES_FILE, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, list) else []
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load unsubscribes: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _save_unsubscribes(entries: List[dict]) -> None:
|
||||
UNSUBSCRIBES_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(UNSUBSCRIBES_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(entries, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def _unsubscribed_emails() -> set:
|
||||
return {
|
||||
(e.get("email") or "").strip().lower()
|
||||
for e in _load_unsubscribes()
|
||||
if e.get("email")
|
||||
}
|
||||
|
||||
|
||||
def _load_marketing_history() -> List[dict]:
|
||||
if not MARKETING_HISTORY_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
with open(MARKETING_HISTORY_FILE, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, list) else []
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load marketing history: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _save_marketing_history(entries: List[dict]) -> None:
|
||||
MARKETING_HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(MARKETING_HISTORY_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(entries, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def _all_accounts() -> List[dict]:
|
||||
"""Comptes utilisateurs (email, plan, activité, création) — base de données ou users.json."""
|
||||
from services.auth_service import USE_DATABASE, DATABASE_AVAILABLE, load_users
|
||||
|
||||
accounts: List[dict] = []
|
||||
seen: set = set()
|
||||
|
||||
def _add(email: Optional[str], plan: str = "free", docs: int = 0, created_at=None):
|
||||
em = (email or "").strip().lower()
|
||||
if not em or em in seen:
|
||||
return
|
||||
seen.add(em)
|
||||
accounts.append(
|
||||
{
|
||||
"email": em,
|
||||
"plan": plan,
|
||||
"docs_this_month": docs,
|
||||
"created_at": created_at,
|
||||
}
|
||||
)
|
||||
|
||||
if USE_DATABASE and DATABASE_AVAILABLE:
|
||||
try:
|
||||
from database.connection import get_sync_session
|
||||
from database.models import User as DBUser
|
||||
|
||||
with get_sync_session() as session:
|
||||
for u in session.query(DBUser).all():
|
||||
pv = u.plan.value if hasattr(u.plan, "value") else str(u.plan)
|
||||
_add(
|
||||
u.email,
|
||||
pv,
|
||||
int(u.docs_translated_this_month or 0),
|
||||
u.created_at,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Marketing audiences: DB users unavailable: %s", e)
|
||||
|
||||
for ud in load_users().values():
|
||||
pr = ud.get("plan", "free")
|
||||
pv = pr.value if hasattr(pr, "value") else str(pr)
|
||||
try:
|
||||
docs = int(ud.get("docs_translated_this_month", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
docs = 0
|
||||
_add(ud.get("email"), pv, docs, ud.get("created_at"))
|
||||
|
||||
return accounts
|
||||
|
||||
|
||||
def _parse_account_created_at(value) -> Optional[datetime]:
|
||||
"""created_at d'un compte (ISO string ou datetime) en datetime UTC."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(value))
|
||||
return parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _is_inactive_30(account: dict, cutoff_30d: datetime) -> bool:
|
||||
"""Inactif 30 j : aucune traduction ce mois ET compte créé il y a ≥ 30 jours
|
||||
(un compte créé aujourd'hui n'est pas « inactif depuis 30 jours »).
|
||||
Un compte sans date de création connue reste éligible (comportement prudent)."""
|
||||
if account["docs_this_month"] != 0:
|
||||
return False
|
||||
created = _parse_account_created_at(account.get("created_at"))
|
||||
return created is None or created <= cutoff_30d
|
||||
|
||||
|
||||
def _resolve_audience(audience: str) -> List[dict]:
|
||||
"""Destinataires d'une audience, désabonnés exclus et dédoublonnés."""
|
||||
a = (audience or "").strip().lower()
|
||||
unsub = _unsubscribed_emails()
|
||||
recipients: List[dict] = []
|
||||
|
||||
if a == "waitlist":
|
||||
from routes.waitlist_routes import _load_waitlist
|
||||
|
||||
for entry in _load_waitlist():
|
||||
recipients.append({"email": (entry.get("email") or "").strip().lower()})
|
||||
else:
|
||||
accounts = _all_accounts()
|
||||
if a == "all_users":
|
||||
recipients = accounts
|
||||
elif a == "inactive_30":
|
||||
# Approximation « inactif » : aucune traduction ce mois-ci ET compte
|
||||
# créé il y a au moins 30 jours (un nouveau compte n'est pas inactif).
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
|
||||
recipients = [acc for acc in accounts if _is_inactive_30(acc, cutoff)]
|
||||
elif a.startswith("plan:"):
|
||||
plan_id = a.split(":", 1)[1].strip()
|
||||
recipients = [acc for acc in accounts if acc["plan"] == plan_id]
|
||||
else:
|
||||
return []
|
||||
|
||||
# Dédoublonnage + exclusion des désabonnés (jamais d'envoi à un désabonné).
|
||||
final: List[dict] = []
|
||||
seen: set = set()
|
||||
for r in recipients:
|
||||
em = (r.get("email") or "").strip().lower()
|
||||
if not em or em in seen or em in unsub:
|
||||
continue
|
||||
seen.add(em)
|
||||
final.append(r)
|
||||
return final
|
||||
|
||||
|
||||
def _marketing_content_hash(subject: str, html: str) -> str:
|
||||
return hashlib.sha256(f"{subject}\n{html}".encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _unsubscribe_link(email: str) -> str:
|
||||
base = (
|
||||
os.getenv("FRONTEND_URL", "http://localhost:3000").rstrip("/")
|
||||
)
|
||||
from urllib.parse import quote
|
||||
|
||||
return f"{base}/api/v1/marketing/unsubscribe?email={quote(email)}"
|
||||
|
||||
|
||||
def _marketing_extra_headers(email: str) -> dict:
|
||||
"""En-têtes de délivrabilité (RFC 2369 / RFC 8058) : désabonnement en un clic."""
|
||||
return {
|
||||
"List-Unsubscribe": f"<{_unsubscribe_link(email)}>",
|
||||
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||
}
|
||||
|
||||
|
||||
def _append_unsubscribe_footer(html: str, email: str) -> str:
|
||||
link = _unsubscribe_link(email)
|
||||
footer = (
|
||||
'<div style="margin-top:32px;padding-top:16px;border-top:1px solid #e2e8f0;'
|
||||
'font-size:11px;color:#64748b;text-align:center;">'
|
||||
"Vous recevez cet email car vous avez un compte Wordly.art. "
|
||||
f'<a href="{link}" style="color:#64748b;">Me désabonner</a></div>'
|
||||
)
|
||||
match = re.search(r"</body>", html, re.IGNORECASE)
|
||||
if match:
|
||||
idx = match.start()
|
||||
return html[:idx] + footer + html[idx:]
|
||||
return html + footer
|
||||
|
||||
|
||||
class MarketingEmailRequest(BaseModel):
|
||||
audience: str
|
||||
subject: str = Field(..., min_length=1, max_length=200)
|
||||
html: str = Field(..., min_length=1)
|
||||
test_mode: bool = True
|
||||
test_email: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/marketing/audiences")
|
||||
async def marketing_audiences(admin_id: str = Depends(require_admin)):
|
||||
"""Comptes disponibles par audience (désabonnés exclus des comptes)."""
|
||||
unsub = _unsubscribed_emails()
|
||||
accounts = _all_accounts()
|
||||
try:
|
||||
from routes.waitlist_routes import _load_waitlist
|
||||
|
||||
waitlist_emails = [
|
||||
(e.get("email") or "").strip().lower() for e in _load_waitlist()
|
||||
]
|
||||
except Exception:
|
||||
waitlist_emails = []
|
||||
|
||||
def _count(items: List[dict]) -> int:
|
||||
return len(
|
||||
[
|
||||
r
|
||||
for r in items
|
||||
if r.get("email") and r["email"].lower() not in unsub
|
||||
]
|
||||
)
|
||||
|
||||
cutoff_inactive = datetime.now(timezone.utc) - timedelta(days=30)
|
||||
audiences = {
|
||||
"waitlist": len([e for e in waitlist_emails if e and e not in unsub]),
|
||||
"inactive_30": _count(
|
||||
[acc for acc in accounts if _is_inactive_30(acc, cutoff_inactive)]
|
||||
),
|
||||
"all_users": _count(accounts),
|
||||
}
|
||||
for pt in PlanType:
|
||||
audiences[f"plan:{pt.value}"] = _count(
|
||||
[a for a in accounts if a["plan"] == pt.value]
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"data": {
|
||||
"audiences": audiences,
|
||||
"unsubscribed_count": len(unsub),
|
||||
},
|
||||
"meta": {},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class MarketingAudienceRequest(BaseModel):
|
||||
audience: str
|
||||
|
||||
|
||||
@router.post("/marketing/audiences")
|
||||
async def marketing_audience_preview(
|
||||
body: MarketingAudienceRequest, admin_id: str = Depends(require_admin)
|
||||
):
|
||||
"""Aperçu d'une audience arbitraire (ex. plan:starter) : nombre de
|
||||
destinataires après exclusion des désabonnés, jamais leurs adresses."""
|
||||
a = (body.audience or "").strip().lower()
|
||||
known = a in MARKETING_BASE_AUDIENCES or a.startswith("plan:")
|
||||
if not known:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "UNKNOWN_AUDIENCE",
|
||||
"message": "Audience inconnue (waitlist, inactive_30, plan:<id>, all_users).",
|
||||
},
|
||||
)
|
||||
recipients = _resolve_audience(a)
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"data": {"audience": a, "count": len(recipients)}, "meta": {}},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/marketing/email/history")
|
||||
async def marketing_email_history(admin_id: str = Depends(require_admin)):
|
||||
"""Historique des envois marketing (les plus récents d'abord)."""
|
||||
history = _load_marketing_history()
|
||||
history = sorted(history, key=lambda e: e.get("sent_at", ""), reverse=True)[:100]
|
||||
return JSONResponse(status_code=200, content={"data": history, "meta": {}})
|
||||
|
||||
|
||||
@router.post("/marketing/email/send")
|
||||
async def marketing_email_send(
|
||||
body: MarketingEmailRequest, admin_id: str = Depends(require_admin)
|
||||
):
|
||||
"""Envoi d'un email de relance marketing.
|
||||
|
||||
- ``test_mode=True`` : envoi unique à l'adresse de test (admin ou expéditeur SMTP).
|
||||
- ``test_mode=False`` : envoi réel à l'audience ; exige un envoi test
|
||||
préalable au contenu identique (jamais d'envoi en masse « à l'aveugle »).
|
||||
Les désabonnés sont exclus de toute audience. Les échecs SMTP individuels
|
||||
n'interrompent pas l'envoi et jamais en erreur HTTP 500.
|
||||
"""
|
||||
from services.email_service import is_smtp_configured, send_email_async
|
||||
|
||||
if not is_smtp_configured():
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "SMTP_NOT_CONFIGURED",
|
||||
"message": "Aucun SMTP configuré (réglages Fournisseurs ou .env).",
|
||||
},
|
||||
)
|
||||
|
||||
subject = body.subject.strip()
|
||||
html = body.html.strip()
|
||||
if not subject or not html:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "INVALID_CONTENT", "message": "Sujet et HTML requis."},
|
||||
)
|
||||
|
||||
content_hash = _marketing_content_hash(subject, html)
|
||||
|
||||
# --- Envoi test : un seul destinataire (admin ou adresse fournie) ---
|
||||
if body.test_mode:
|
||||
settings = load_settings()
|
||||
recipient = (
|
||||
(body.test_email or "").strip()
|
||||
or (settings.smtp.from_email or "").strip()
|
||||
or os.getenv("SMTP_FROM_EMAIL", "").strip()
|
||||
)
|
||||
if not recipient:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "NO_TEST_RECIPIENT",
|
||||
"message": "Indiquez une adresse de test ou configurez from_email.",
|
||||
},
|
||||
)
|
||||
sent = await send_email_async(
|
||||
recipient,
|
||||
subject,
|
||||
_append_unsubscribe_footer(html, recipient),
|
||||
_marketing_extra_headers(recipient),
|
||||
)
|
||||
entry = {
|
||||
"id": uuid.uuid4().hex,
|
||||
"sent_at": datetime.now(timezone.utc).isoformat(),
|
||||
"audience": body.audience,
|
||||
"subject": subject,
|
||||
"test_mode": True,
|
||||
"test_recipient": recipient,
|
||||
"recipients_total": 1,
|
||||
"sent_count": 1 if sent else 0,
|
||||
"failed_count": 0 if sent else 1,
|
||||
"failed": [] if sent else [{"email": recipient, "error": "send_email_async a renvoyé False"}],
|
||||
"content_hash": content_hash,
|
||||
}
|
||||
with _marketing_lock:
|
||||
history = _load_marketing_history()
|
||||
history.append(entry)
|
||||
_save_marketing_history(history)
|
||||
if not sent:
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={
|
||||
"error": "TEST_SEND_FAILED",
|
||||
"message": "L'envoi test a échoué (voir les logs SMTP).",
|
||||
"data": entry,
|
||||
},
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"data": {
|
||||
"test_mode": True,
|
||||
"sent": 1,
|
||||
"failed": 0,
|
||||
"recipient": recipient,
|
||||
},
|
||||
"meta": {},
|
||||
},
|
||||
)
|
||||
|
||||
# --- Envoi réel : un test au contenu identique est obligatoire ---
|
||||
history = _load_marketing_history()
|
||||
has_matching_test = any(
|
||||
e.get("content_hash") == content_hash and e.get("test_mode") and e.get("sent_count", 0) > 0
|
||||
for e in history
|
||||
)
|
||||
if not has_matching_test:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "TEST_SEND_REQUIRED",
|
||||
"message": "Effectuez d'abord un envoi test de ce contenu exact (sujet + HTML).",
|
||||
},
|
||||
)
|
||||
|
||||
recipients = _resolve_audience(body.audience)
|
||||
if not recipients:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "AUDIENCE_EMPTY",
|
||||
"message": "Aucun destinataire dans cette audience (ou tous désabonnés).",
|
||||
},
|
||||
)
|
||||
|
||||
# Une seule campagne à la fois, même contenu ou non.
|
||||
with _marketing_lock:
|
||||
if content_hash in _active_campaign_hashes:
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={
|
||||
"error": "CAMPAIGN_ALREADY_RUNNING",
|
||||
"message": "Un envoi de cette campagne est déjà en cours.",
|
||||
},
|
||||
)
|
||||
_active_campaign_hashes.add(content_hash)
|
||||
|
||||
entry = {
|
||||
"id": uuid.uuid4().hex,
|
||||
"sent_at": datetime.now(timezone.utc).isoformat(),
|
||||
"audience": (body.audience or "").strip().lower(),
|
||||
"subject": subject,
|
||||
"test_mode": False,
|
||||
"status": "running",
|
||||
"recipients_total": len(recipients),
|
||||
"sent_count": 0,
|
||||
"failed_count": 0,
|
||||
"failed": [],
|
||||
"content_hash": content_hash,
|
||||
}
|
||||
# Journalise l'entrée « running » AVANT la boucle d'envoi : si le serveur
|
||||
# s'arrête en pleine campagne, l'historique trace l'envoi lancé.
|
||||
with _marketing_lock:
|
||||
history_entries = _load_marketing_history()
|
||||
history_entries.append(entry)
|
||||
_save_marketing_history(history_entries)
|
||||
|
||||
asyncio.create_task(
|
||||
_execute_bulk_send(subject, html, recipients, entry, admin_id)
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=202,
|
||||
content={
|
||||
"data": {
|
||||
"queued": len(recipients),
|
||||
"audience": entry["audience"],
|
||||
"test_mode": False,
|
||||
},
|
||||
"meta": {},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _execute_bulk_send(
|
||||
subject: str,
|
||||
html: str,
|
||||
recipients: List[dict],
|
||||
entry: dict,
|
||||
admin_id: str = "",
|
||||
) -> None:
|
||||
"""Envoi séquentiel (0,2 s d'intervalle) — n'échoue jamais globalement.
|
||||
|
||||
Met à jour l'entrée d'historique (statut « running » → « completed ») et
|
||||
revérifie les désabonnés à CHAQUE itération : un désabonnement pendant la
|
||||
campagne est honoré immédiatement.
|
||||
"""
|
||||
from services.email_service import send_email_async
|
||||
|
||||
failures: List[dict] = []
|
||||
sent = 0
|
||||
skipped = 0
|
||||
try:
|
||||
for r in recipients:
|
||||
email_addr = r["email"]
|
||||
# Re-vérification à chaque itération (désabonnement en cours de campagne).
|
||||
if email_addr.lower() in _unsubscribed_emails():
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
ok = await send_email_async(
|
||||
email_addr,
|
||||
subject,
|
||||
_append_unsubscribe_footer(html, email_addr),
|
||||
_marketing_extra_headers(email_addr),
|
||||
)
|
||||
except Exception as send_err: # un échec individuel n'arrête rien
|
||||
logger.warning("marketing send to %s failed: %s", email_addr, send_err)
|
||||
ok = False
|
||||
if ok:
|
||||
sent += 1
|
||||
else:
|
||||
failures.append({"email": email_addr, "error": "échec SMTP"})
|
||||
await asyncio.sleep(0.2)
|
||||
entry["sent_count"] = sent
|
||||
entry["failed_count"] = len(failures)
|
||||
entry["failed"] = failures[:50]
|
||||
entry["skipped_unsubscribed"] = skipped
|
||||
entry["status"] = "completed"
|
||||
entry["completed_at"] = datetime.now(timezone.utc).isoformat()
|
||||
except Exception as bulk_err: # la campagne ne doit jamais laisser un statut « running »
|
||||
logger.exception("marketing_bulk_send crashed: %s", bulk_err)
|
||||
entry["status"] = "failed"
|
||||
entry["completed_at"] = datetime.now(timezone.utc).isoformat()
|
||||
finally:
|
||||
with _marketing_lock:
|
||||
_active_campaign_hashes.discard(entry.get("content_hash"))
|
||||
all_entries = _load_marketing_history()
|
||||
for i, existing in enumerate(all_entries):
|
||||
if existing.get("id") == entry.get("id"):
|
||||
all_entries[i] = entry
|
||||
break
|
||||
else:
|
||||
all_entries.append(entry)
|
||||
_save_marketing_history(all_entries)
|
||||
logger.info(
|
||||
"marketing_bulk_send audience=%s sent=%s failed=%s skipped=%s by=%s",
|
||||
entry["audience"], sent, len(failures), skipped, admin_id,
|
||||
)
|
||||
|
||||
|
||||
marketing_public_router = APIRouter(prefix="/api/v1/marketing", tags=["Marketing"])
|
||||
|
||||
|
||||
@marketing_public_router.get("/unsubscribe", response_class=HTMLResponse)
|
||||
async def marketing_unsubscribe(email: str, request: Request):
|
||||
"""Désabonnement public : ajoute l'email à data/unsubscribes.json.
|
||||
|
||||
Idempotent ; aucune information sensible n'est renvoyée.
|
||||
"""
|
||||
from urllib.parse import unquote
|
||||
|
||||
em = unquote(email or "").strip().lower()
|
||||
em_display = html_module.escape(em)
|
||||
site_url = os.getenv("FRONTEND_URL", "http://localhost:3000").rstrip("/")
|
||||
if not em or "@" not in em or len(em) > 320:
|
||||
return HTMLResponse(
|
||||
"<html><body style='font-family:sans-serif;text-align:center;padding:48px;'>"
|
||||
"<p>Lien de désabonnement invalide.</p></body></html>",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
with _marketing_lock:
|
||||
entries = _load_unsubscribes()
|
||||
if not any((e.get("email") or "").lower() == em for e in entries):
|
||||
entries.append(
|
||||
{
|
||||
"email": em,
|
||||
"unsubscribed_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
_save_unsubscribes(entries)
|
||||
|
||||
logger.info("marketing_unsubscribe email=%s", em)
|
||||
return HTMLResponse(
|
||||
"<html><body style='font-family:sans-serif;text-align:center;padding:48px;'>"
|
||||
"<h2>C'est bien noté.</h2>"
|
||||
f"<p>L'adresse <strong>{em_display}</strong> ne recevra plus d'email marketing.</p>"
|
||||
f"<p><a href='{site_url}'>Retour sur Wordly.art</a></p></body></html>",
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ from routes.translate_routes import router_v1 as translate_router
|
||||
from routes.auth_routes import router_v1 as auth_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
|
||||
from routes.legacy_routes import router as legacy_router
|
||||
from routes.glossary_routes import router as glossary_router
|
||||
from routes.prompt_routes import router as prompt_router
|
||||
@@ -23,6 +24,7 @@ router.include_router(translate_router, tags=["Translation"])
|
||||
router.include_router(auth_router, tags=["Authentication"])
|
||||
router.include_router(api_key_router, tags=["API Keys"])
|
||||
router.include_router(admin_router, tags=["Admin"])
|
||||
router.include_router(marketing_public_router, tags=["Marketing"])
|
||||
router.include_router(legacy_router, tags=["Legacy"])
|
||||
router.include_router(glossary_router, tags=["Glossaries"])
|
||||
router.include_router(prompt_router, tags=["Prompts"])
|
||||
|
||||
@@ -783,6 +783,8 @@ async def get_plans_v1():
|
||||
"features": plan["features"],
|
||||
"ai_translation": plan.get("ai_translation", False),
|
||||
"ai_tier": plan.get("ai_tier"),
|
||||
"ai_models_essential": plan.get("ai_models_essential", []),
|
||||
"ai_models_premium": plan.get("ai_models_premium", []),
|
||||
"api_access": plan.get("api_access", False),
|
||||
"priority_processing": plan.get("priority_processing", False),
|
||||
"team_seats": plan.get("team_seats"),
|
||||
|
||||
@@ -51,9 +51,13 @@ async def get_available_providers(
|
||||
"""
|
||||
from routes.admin_routes import load_settings
|
||||
from models.subscription import PlanType, PLANS
|
||||
from routes.translate_routes import _resolve_tier_model
|
||||
|
||||
settings = load_settings()
|
||||
is_dev = os.getenv("APP_ENV", "production").lower() == "development"
|
||||
# Le modèle affiché est celui réellement routé pour le plan de l'appelant
|
||||
# (réglages admin « ai_tiers » > défaut du plan — gamme officielle).
|
||||
user_plan = str(current_user.plan) if current_user is not None else None
|
||||
|
||||
def _key_ready(key_var: str) -> bool:
|
||||
return bool(os.getenv(key_var, "").strip())
|
||||
@@ -84,12 +88,7 @@ async def get_available_providers(
|
||||
|
||||
# AI Essentielle (OpenRouter — cheap model / Eco)
|
||||
if _is_enabled("openrouter", key_var="OPENROUTER_API_KEY"):
|
||||
or_cfg = getattr(settings, "openrouter", None)
|
||||
model = _resolve_model(
|
||||
or_cfg.model if or_cfg else None,
|
||||
"OPENROUTER_MODEL",
|
||||
"google/gemini-3.5-flash",
|
||||
)
|
||||
model = _resolve_tier_model(settings, user_plan, premium=False)
|
||||
available.append({
|
||||
"id": "openrouter",
|
||||
"label": "Traduction IA Éco",
|
||||
@@ -118,12 +117,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"):
|
||||
orp_cfg = getattr(settings, "openrouter_premium", None)
|
||||
model = _resolve_model(
|
||||
orp_cfg.model if orp_cfg else None,
|
||||
"OPENROUTER_PREMIUM_MODEL",
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
)
|
||||
model = _resolve_tier_model(settings, user_plan, premium=True)
|
||||
available.append({
|
||||
"id": "openrouter_premium",
|
||||
"label": "Traduction IA Premium",
|
||||
|
||||
@@ -182,6 +182,89 @@ def _allowed_providers_for_plan(plan: PlanType) -> set:
|
||||
return set(plan_cfg.get("providers", []))
|
||||
|
||||
|
||||
def _plan_from_str(plan_str: Optional[str]) -> PlanType:
|
||||
"""Resolve a ``user_plan`` string ("PlanType.PRO" or "pro") to a PlanType."""
|
||||
name = (plan_str or "").strip().lower()
|
||||
if name.startswith("plantype."):
|
||||
name = name.split(".", 1)[1]
|
||||
try:
|
||||
return PlanType(name)
|
||||
except ValueError:
|
||||
if name:
|
||||
logger.warning(
|
||||
"Unrecognized user_plan %r — falling back to the Free plan "
|
||||
"(Essentielle tier).",
|
||||
plan_str,
|
||||
)
|
||||
return PlanType.FREE
|
||||
|
||||
|
||||
def _plan_ai_models(plan: PlanType, premium: bool) -> list:
|
||||
"""Official model range for the plan's AI tier (Essentielle or Premium).
|
||||
|
||||
Plans without their own list (Free, Starter, Enterprise custom) fall back
|
||||
to the global defaults: Pro's Essentielle range and Business's Premium range.
|
||||
"""
|
||||
from models.subscription import (
|
||||
DEFAULT_AI_MODELS_ESSENTIAL,
|
||||
DEFAULT_AI_MODELS_PREMIUM,
|
||||
)
|
||||
|
||||
key = "ai_models_premium" if premium else "ai_models_essential"
|
||||
models = [m for m in (PLANS.get(plan, {}).get(key) or []) if m]
|
||||
if not models:
|
||||
models = list(DEFAULT_AI_MODELS_PREMIUM if premium else DEFAULT_AI_MODELS_ESSENTIAL)
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
resolved = ""
|
||||
if tier_cfg is not None:
|
||||
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]
|
||||
|
||||
# Garde : le modèle résolu doit appartenir au palier demandé.
|
||||
if premium and resolved in DEFAULT_AI_MODELS_ESSENTIAL:
|
||||
logger.warning(
|
||||
"ai_tiers.premium resolved to an Essentielle model (%s) — using the "
|
||||
"plan Premium default instead.",
|
||||
resolved,
|
||||
)
|
||||
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,
|
||||
)
|
||||
resolved = _plan_ai_models(_plan_from_str(user_plan), premium=False)[0]
|
||||
return resolved
|
||||
|
||||
|
||||
def _image_translation_allowed_for_plan(plan: PlanType) -> bool:
|
||||
"""Vision (text-in-images) is sold on Pro and above."""
|
||||
return plan in (PlanType.PRO, PlanType.BUSINESS, PlanType.ENTERPRISE)
|
||||
@@ -1275,9 +1358,9 @@ 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")
|
||||
model = _cfg(_admin_cfg.openrouter.model, "OPENROUTER_MODEL", "google/gemini-3.5-flash")
|
||||
if model in ("deepseek/deepseek-v3.2", "google/gemini-2.0-flash-001"):
|
||||
model = "google/gemini-3.5-flash"
|
||||
# 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)
|
||||
|
||||
# Story 3.10: Retrieve and format glossary terms for LLM prompt
|
||||
glossary_terms = None
|
||||
@@ -1363,7 +1446,8 @@ async def _run_translation_job(
|
||||
)
|
||||
elif _p == "openrouter_premium":
|
||||
premium_key = _cfg(_admin_cfg.openrouter_premium.api_key, "OPENROUTER_API_KEY")
|
||||
premium_model = _cfg(_admin_cfg.openrouter_premium.model, "OPENROUTER_PREMIUM_MODEL", "anthropic/claude-sonnet-4.6")
|
||||
# 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:
|
||||
|
||||
@@ -56,8 +56,18 @@ def is_smtp_configured() -> bool:
|
||||
return bool(cfg.get("host") and cfg.get("user"))
|
||||
|
||||
|
||||
def send_email(to: str, subject: str, body: str) -> bool:
|
||||
"""Send an email via SMTP (sync). Returns True if sent successfully."""
|
||||
def send_email(
|
||||
to: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
extra_headers: Optional[dict] = None,
|
||||
) -> bool:
|
||||
"""Send an email via SMTP (sync). Returns True if sent successfully.
|
||||
|
||||
``extra_headers`` is merged into the message headers (e.g. the
|
||||
``List-Unsubscribe`` / ``List-Unsubscribe-Post`` marketing headers);
|
||||
existing call sites are unaffected.
|
||||
"""
|
||||
cfg = _get_smtp_config()
|
||||
if not cfg.get("host"):
|
||||
logger.warning("SMTP not configured, email not sent")
|
||||
@@ -69,6 +79,9 @@ def send_email(to: str, subject: str, body: str) -> bool:
|
||||
msg["From"] = cfg["from_email"]
|
||||
msg["To"] = to
|
||||
msg.attach(MIMEText(body, "html"))
|
||||
for header_name, header_value in (extra_headers or {}).items():
|
||||
if header_name and header_value:
|
||||
msg[header_name] = header_value
|
||||
|
||||
port = int(cfg.get("port", 587))
|
||||
use_tls = cfg.get("use_tls", True)
|
||||
@@ -91,7 +104,12 @@ def send_email(to: str, subject: str, body: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def send_email_async(to: str, subject: str, body: str) -> bool:
|
||||
async def send_email_async(
|
||||
to: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
extra_headers: Optional[dict] = None,
|
||||
) -> bool:
|
||||
"""Send an email via SMTP (async). Returns True if sent successfully."""
|
||||
cfg = _get_smtp_config()
|
||||
if not cfg.get("host"):
|
||||
@@ -112,8 +130,11 @@ async def send_email_async(to: str, subject: str, body: str) -> bool:
|
||||
|
||||
# Use sync smtplib inside a thread for reliability (aiosmtplib has quirks with raw messages)
|
||||
import asyncio
|
||||
import functools
|
||||
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
None, send_email, to, subject, body
|
||||
None,
|
||||
functools.partial(send_email, to, subject, body, extra_headers),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email (async) to {to}: {e}")
|
||||
|
||||
483
tests/test_admin_marketing.py
Normal file
483
tests/test_admin_marketing.py
Normal file
@@ -0,0 +1,483 @@
|
||||
"""
|
||||
Endpoints marketing (spec « Stratégie LLM par abonnement ») :
|
||||
- audiences avec compteurs (liste d'attente, inactifs, par plan, tous),
|
||||
- envoi test obligatoire avant tout envoi en masse,
|
||||
- désabonnés exclus de tout envoi,
|
||||
- historique journalisé (envoyés/échecs),
|
||||
- désabonnement public.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
ADMIN_LOGIN_URL = "/api/v1/admin/login"
|
||||
AUDIENCES_URL = "/api/v1/admin/marketing/audiences"
|
||||
SEND_URL = "/api/v1/admin/marketing/email/send"
|
||||
HISTORY_URL = "/api/v1/admin/marketing/email/history"
|
||||
UNSUBSCRIBE_URL = "/api/v1/marketing/unsubscribe"
|
||||
|
||||
SUBJECT = "Relance test"
|
||||
HTML = "<html><body><p>Bonjour !</p></body></html>"
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def marketing_env(tmp_path: Path, monkeypatch):
|
||||
"""Fichiers data isolés + service email simulé."""
|
||||
import routes.admin_routes as admin_routes_mod
|
||||
import routes.waitlist_routes as waitlist_mod
|
||||
import services.email_service as email_svc
|
||||
|
||||
history_file = tmp_path / "marketing_emails.json"
|
||||
unsub_file = tmp_path / "unsubscribes.json"
|
||||
waitlist_file = tmp_path / "waitlist.json"
|
||||
|
||||
monkeypatch.setattr(admin_routes_mod, "MARKETING_HISTORY_FILE", history_file)
|
||||
monkeypatch.setattr(admin_routes_mod, "UNSUBSCRIBES_FILE", unsub_file)
|
||||
monkeypatch.setattr(waitlist_mod, "WAITLIST_FILE", waitlist_file)
|
||||
monkeypatch.setattr(admin_routes_mod, "_active_campaign_hashes", set())
|
||||
|
||||
sent_to: list[str] = []
|
||||
captured_headers: dict[str, dict] = {}
|
||||
|
||||
async def _fake_send(to: str, subject: str, body: str, extra_headers=None) -> bool:
|
||||
sent_to.append(to)
|
||||
captured_headers[to] = extra_headers or {}
|
||||
return True
|
||||
|
||||
async def _failing_send(to: str, subject: str, body: str, extra_headers=None) -> bool:
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(email_svc, "send_email_async", _fake_send)
|
||||
monkeypatch.setattr(email_svc, "is_smtp_configured", lambda: True)
|
||||
|
||||
def set_sender(fn):
|
||||
monkeypatch.setattr(email_svc, "send_email_async", fn)
|
||||
|
||||
return {
|
||||
"history_file": history_file,
|
||||
"unsub_file": unsub_file,
|
||||
"waitlist_file": waitlist_file,
|
||||
"sent_to": sent_to,
|
||||
"captured_headers": captured_headers,
|
||||
"fail_mode": lambda: set_sender(_failing_send),
|
||||
"set_sender": set_sender,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(marketing_env, 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 main import app
|
||||
|
||||
return TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_token(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
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(admin_token):
|
||||
return {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
|
||||
def _seed_users(tmp_path: Path) -> Path:
|
||||
users = {
|
||||
"u-pro": {
|
||||
"email": "pro@example.com",
|
||||
"name": "Pro User",
|
||||
"plan": "pro",
|
||||
"docs_translated_this_month": 5,
|
||||
},
|
||||
"u-idle": {
|
||||
"email": "idle@example.com",
|
||||
"name": "Idle User",
|
||||
"plan": "starter",
|
||||
"docs_translated_this_month": 0,
|
||||
},
|
||||
}
|
||||
path = tmp_path / "users.json"
|
||||
_write_json(path, users)
|
||||
return path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audiences
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_audiences_counts_and_unsubscribed_excluded(
|
||||
client, auth_headers, marketing_env, tmp_path
|
||||
):
|
||||
_seed_users(tmp_path)
|
||||
_write_json(
|
||||
marketing_env["waitlist_file"],
|
||||
[
|
||||
{"email": "lead1@example.com", "interest": "pdf", "joined_at": "2026-09-01"},
|
||||
{"email": "lead2@example.com", "interest": None, "joined_at": "2026-09-02"},
|
||||
],
|
||||
)
|
||||
_write_json(marketing_env["unsub_file"], [{"email": "lead2@example.com"}])
|
||||
|
||||
r = client.get(AUDIENCES_URL, headers=auth_headers)
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()["data"]
|
||||
assert data["audiences"]["waitlist"] == 1 # lead2 désabonné → exclu
|
||||
assert data["audiences"]["plan:pro"] == 1
|
||||
assert data["audiences"]["inactive_30"] == 1 # idle@example.com
|
||||
assert data["audiences"]["all_users"] == 2
|
||||
assert data["unsubscribed_count"] == 1
|
||||
|
||||
|
||||
def test_audiences_requires_admin(client):
|
||||
assert client.get(AUDIENCES_URL).status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Envoi test + garde-fou « test avant envoi réel »
|
||||
# ---------------------------------------------------------------------------
|
||||
def _post_send(client, auth_headers, **overrides):
|
||||
payload = {
|
||||
"audience": "waitlist",
|
||||
"subject": SUBJECT,
|
||||
"html": HTML,
|
||||
"test_mode": True,
|
||||
}
|
||||
payload.update(overrides)
|
||||
return client.post(SEND_URL, json=payload, headers=auth_headers)
|
||||
|
||||
|
||||
def test_test_send_writes_history(client, auth_headers, marketing_env):
|
||||
_write_json(
|
||||
marketing_env["waitlist_file"],
|
||||
[{"email": "lead1@example.com"}],
|
||||
)
|
||||
r = _post_send(client, auth_headers, test_email="admin@example.com")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["data"]["sent"] == 1
|
||||
assert marketing_env["sent_to"] == ["admin@example.com"]
|
||||
|
||||
entries = json.loads(marketing_env["history_file"].read_text(encoding="utf-8"))
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["test_mode"] is True
|
||||
assert entries[0]["sent_count"] == 1
|
||||
assert entries[0]["content_hash"]
|
||||
|
||||
|
||||
def test_mass_send_requires_matching_test_first(client, auth_headers, marketing_env):
|
||||
_write_json(marketing_env["waitlist_file"], [{"email": "lead1@example.com"}])
|
||||
r = _post_send(client, auth_headers, test_mode=False)
|
||||
assert r.status_code == 400
|
||||
assert r.json()["error"] == "TEST_SEND_REQUIRED"
|
||||
assert marketing_env["sent_to"] == []
|
||||
|
||||
|
||||
def test_mass_send_after_test_excludes_unsubscribed(client, auth_headers, marketing_env):
|
||||
_write_json(
|
||||
marketing_env["waitlist_file"],
|
||||
[
|
||||
{"email": "lead1@example.com"},
|
||||
{"email": "gone@example.com"},
|
||||
],
|
||||
)
|
||||
_write_json(marketing_env["unsub_file"], [{"email": "gone@example.com"}])
|
||||
|
||||
# Envoi test au contenu identique (préalable obligatoire)
|
||||
assert _post_send(client, auth_headers, test_email="admin@example.com").status_code == 200
|
||||
marketing_env["sent_to"].clear()
|
||||
|
||||
r = _post_send(client, auth_headers, test_mode=False)
|
||||
assert r.status_code == 202, r.text
|
||||
assert r.json()["data"]["queued"] == 1 # gone@example.com exclu de la file
|
||||
|
||||
|
||||
def test_bulk_executor_logs_history_and_excludes_unsubscribed(marketing_env):
|
||||
"""L'exécuteur d'envoi de fond journalise envoyés/échecs et n'écrit
|
||||
jamais à un désabonné (résolution d'audience + envoi réel)."""
|
||||
import routes.admin_routes as admin_routes_mod
|
||||
|
||||
_write_json(
|
||||
marketing_env["waitlist_file"],
|
||||
[
|
||||
{"email": "lead1@example.com"},
|
||||
{"email": "gone@example.com"},
|
||||
],
|
||||
)
|
||||
_write_json(marketing_env["unsub_file"], [{"email": "gone@example.com"}])
|
||||
|
||||
recipients = admin_routes_mod._resolve_audience("waitlist")
|
||||
assert [r["email"] for r in recipients] == ["lead1@example.com"]
|
||||
|
||||
entry = {"id": "x", "audience": "waitlist", "test_mode": False,
|
||||
"recipients_total": len(recipients), "sent_count": 0,
|
||||
"failed_count": 0, "failed": [], "sent_at": "2026-09-05T00:00:00+00:00"}
|
||||
asyncio.run(
|
||||
admin_routes_mod._execute_bulk_send(SUBJECT, HTML, recipients, entry, "admin")
|
||||
)
|
||||
|
||||
assert marketing_env["sent_to"] == ["lead1@example.com"]
|
||||
entries = json.loads(marketing_env["history_file"].read_text(encoding="utf-8"))
|
||||
assert entries[-1]["sent_count"] == 1
|
||||
assert entries[-1]["failed_count"] == 0
|
||||
assert "completed_at" in entries[-1]
|
||||
|
||||
|
||||
def test_bulk_executor_continues_after_individual_smtp_failure(marketing_env):
|
||||
"""Un échec SMTP individuel n'interrompt pas l'envoi (jamais d'erreur 500)."""
|
||||
import routes.admin_routes as admin_routes_mod
|
||||
async def _flaky_send(to: str, subject: str, body: str, extra_headers=None) -> bool:
|
||||
if to == "bad@example.com":
|
||||
raise RuntimeError("SMTP down")
|
||||
marketing_env["sent_to"].append(to)
|
||||
return True
|
||||
|
||||
marketing_env["set_sender"](_flaky_send)
|
||||
_write_json(
|
||||
marketing_env["waitlist_file"],
|
||||
[{"email": "bad@example.com"}, {"email": "good@example.com"}],
|
||||
)
|
||||
|
||||
recipients = admin_routes_mod._resolve_audience("waitlist")
|
||||
entry = {"id": "y", "audience": "waitlist", "test_mode": False,
|
||||
"recipients_total": len(recipients), "sent_count": 0,
|
||||
"failed_count": 0, "failed": [], "sent_at": "2026-09-05T00:00:00+00:00"}
|
||||
asyncio.run(
|
||||
admin_routes_mod._execute_bulk_send(SUBJECT, HTML, recipients, entry, "admin")
|
||||
)
|
||||
|
||||
assert entry["sent_count"] == 1
|
||||
assert entry["failed_count"] == 1
|
||||
assert entry["failed"][0]["email"] == "bad@example.com"
|
||||
assert marketing_env["sent_to"] == ["good@example.com"]
|
||||
|
||||
|
||||
def test_unsubscribed_via_public_link_is_excluded(client, auth_headers, marketing_env):
|
||||
_write_json(marketing_env["waitlist_file"], [{"email": "bye@example.com"}])
|
||||
r = client.get(UNSUBSCRIBE_URL, params={"email": "bye@example.com"})
|
||||
assert r.status_code == 200
|
||||
unsub = json.loads(marketing_env["unsub_file"].read_text(encoding="utf-8"))
|
||||
assert [u["email"] for u in unsub] == ["bye@example.com"]
|
||||
|
||||
# Audiences vides → l'envoi est refusé proprement (pas d'erreur 500).
|
||||
r2 = _post_send(client, auth_headers, test_mode=False)
|
||||
assert r2.status_code == 400
|
||||
assert r2.json()["error"] in ("AUDIENCE_EMPTY", "TEST_SEND_REQUIRED")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Historique + résilience SMTP
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_history_endpoint_returns_entries(client, auth_headers, marketing_env):
|
||||
_write_json(
|
||||
marketing_env["history_file"],
|
||||
[
|
||||
{
|
||||
"id": "abc",
|
||||
"sent_at": "2026-09-05T10:00:00+00:00",
|
||||
"audience": "waitlist",
|
||||
"subject": SUBJECT,
|
||||
"test_mode": True,
|
||||
"recipients_total": 1,
|
||||
"sent_count": 1,
|
||||
"failed_count": 0,
|
||||
"failed": [],
|
||||
}
|
||||
],
|
||||
)
|
||||
r = client.get(HISTORY_URL, headers=auth_headers)
|
||||
assert r.status_code == 200
|
||||
entries = r.json()["data"]
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["id"] == "abc"
|
||||
|
||||
|
||||
def test_bulk_failure_does_not_raise_and_is_logged(client, auth_headers, marketing_env):
|
||||
marketing_env["fail_mode"]()
|
||||
_write_json(marketing_env["waitlist_file"], [{"email": "lead1@example.com"}])
|
||||
assert _post_send(client, auth_headers, test_email="admin@example.com").status_code == 502
|
||||
|
||||
entry = json.loads(marketing_env["history_file"].read_text(encoding="utf-8"))[0]
|
||||
assert entry["sent_count"] == 0
|
||||
assert entry["failed_count"] == 1
|
||||
|
||||
marketing_env["sent_to"].clear()
|
||||
# L'envoi test ayant échoué, l'envoi réel reste bloqué.
|
||||
r = _post_send(client, auth_headers, test_mode=False)
|
||||
assert r.status_code == 400
|
||||
assert r.json()["error"] == "TEST_SEND_REQUIRED"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# En-têtes de délivrabilité, verrou anti-simultané, re-vérification des désabonnés
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_bulk_send_carries_list_unsubscribe_headers(marketing_env):
|
||||
"""Les emails marketing embarquent List-Unsubscribe / List-Unsubscribe-Post."""
|
||||
import routes.admin_routes as admin_routes_mod
|
||||
|
||||
_write_json(marketing_env["waitlist_file"], [{"email": "lead1@example.com"}])
|
||||
recipients = admin_routes_mod._resolve_audience("waitlist")
|
||||
entry = {"id": "h1", "audience": "waitlist", "test_mode": False,
|
||||
"recipients_total": 1, "sent_count": 0, "failed_count": 0,
|
||||
"failed": [], "content_hash": "abc", "sent_at": "t"}
|
||||
asyncio.run(
|
||||
admin_routes_mod._execute_bulk_send(SUBJECT, HTML, recipients, entry, "admin")
|
||||
)
|
||||
|
||||
headers = marketing_env["captured_headers"]["lead1@example.com"]
|
||||
assert headers["List-Unsubscribe"].startswith("<http")
|
||||
assert "unsubscribe?email=" in headers["List-Unsubscribe"]
|
||||
from urllib.parse import unquote
|
||||
|
||||
assert "lead1@example.com" in unquote(headers["List-Unsubscribe"])
|
||||
assert headers["List-Unsubscribe-Post"] == "List-Unsubscribe=One-Click"
|
||||
# L'entrée « running » mise à jour (pas dupliquée) et clôturée.
|
||||
entries = json.loads(marketing_env["history_file"].read_text(encoding="utf-8"))
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["status"] == "completed"
|
||||
|
||||
|
||||
def test_mass_send_writes_running_entry_then_completes(client, auth_headers, marketing_env):
|
||||
_write_json(marketing_env["waitlist_file"], [{"email": "lead1@example.com"}])
|
||||
assert _post_send(client, auth_headers, test_email="a@ex.com").status_code == 200
|
||||
marketing_env["sent_to"].clear()
|
||||
|
||||
assert _post_send(client, auth_headers, test_mode=False).status_code == 202
|
||||
entries = json.loads(marketing_env["history_file"].read_text(encoding="utf-8"))
|
||||
bulk = [e for e in entries if not e["test_mode"]]
|
||||
assert len(bulk) == 1 # une seule entrée : « running » puis mise à jour
|
||||
assert bulk[0]["status"] in ("running", "completed")
|
||||
|
||||
|
||||
def test_second_real_send_same_content_refused_while_running(client, auth_headers, marketing_env):
|
||||
"""Deux campagnes simultanées de même contenu : la seconde est refusée."""
|
||||
import routes.admin_routes as admin_routes_mod
|
||||
|
||||
_write_json(marketing_env["waitlist_file"], [{"email": "lead1@example.com"}])
|
||||
assert _post_send(client, auth_headers, test_email="a@ex.com").status_code == 200
|
||||
|
||||
# On simule une campagne déjà en cours pour ce contenu exact.
|
||||
content_hash = admin_routes_mod._marketing_content_hash(SUBJECT, HTML)
|
||||
admin_routes_mod._active_campaign_hashes.add(content_hash)
|
||||
try:
|
||||
r = _post_send(client, auth_headers, test_mode=False)
|
||||
assert r.status_code == 409
|
||||
assert r.json()["error"] == "CAMPAIGN_ALREADY_RUNNING"
|
||||
finally:
|
||||
admin_routes_mod._active_campaign_hashes.discard(content_hash)
|
||||
|
||||
|
||||
def test_unsubscribe_during_bulk_send_is_honored(marketing_env):
|
||||
"""Un désabonnement déposé pendant la campagne stoppe l'envoi restant."""
|
||||
import routes.admin_routes as admin_routes_mod
|
||||
import services.email_service as email_svc
|
||||
|
||||
_write_json(
|
||||
marketing_env["waitlist_file"],
|
||||
[{"email": "a@example.com"}, {"email": "b@example.com"}],
|
||||
)
|
||||
|
||||
async def _slow_send(to: str, subject: str, body: str, extra_headers=None) -> bool:
|
||||
if to == "a@example.com":
|
||||
# Se désabonne pendant que la campagne est en cours.
|
||||
with admin_routes_mod._marketing_lock:
|
||||
entries = admin_routes_mod._load_unsubscribes()
|
||||
entries.append({"email": "b@example.com", "unsubscribed_at": "t"})
|
||||
admin_routes_mod._save_unsubscribes(entries)
|
||||
marketing_env["sent_to"].append(to)
|
||||
return True
|
||||
|
||||
marketing_env["set_sender"](_slow_send)
|
||||
|
||||
recipients = admin_routes_mod._resolve_audience("waitlist")
|
||||
entry = {"id": "h2", "audience": "waitlist", "test_mode": False,
|
||||
"recipients_total": len(recipients), "sent_count": 0,
|
||||
"failed_count": 0, "failed": [], "content_hash": "xyz", "sent_at": "t"}
|
||||
asyncio.run(
|
||||
admin_routes_mod._execute_bulk_send(SUBJECT, HTML, recipients, entry, "admin")
|
||||
)
|
||||
|
||||
assert marketing_env["sent_to"] == ["a@example.com"] # b est passé en désabonné
|
||||
assert entry["sent_count"] == 1
|
||||
assert entry["skipped_unsubscribed"] == 1
|
||||
assert entry["status"] == "completed"
|
||||
|
||||
|
||||
def test_unsubscribe_page_escapes_email(client, marketing_env):
|
||||
r = client.get(UNSUBSCRIBE_URL, params={"email": "<script>alert(1)</script>@x.com"})
|
||||
assert r.status_code == 200
|
||||
body = r.text
|
||||
assert "<script>" not in body
|
||||
assert "<script>" in body
|
||||
|
||||
|
||||
def test_put_settings_pristine_ai_tiers_keeps_existing(client, auth_headers, monkeypatch, tmp_path):
|
||||
"""Un PUT avec ai_tiers vierge ne réinitialise pas les paliers personnalisés."""
|
||||
import routes.admin_routes as admin_routes_mod
|
||||
|
||||
settings_file = tmp_path / "provider_settings.json"
|
||||
monkeypatch.setattr(admin_routes_mod, "SETTINGS_FILE", str(settings_file))
|
||||
custom = {
|
||||
"ai_tiers": {
|
||||
"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"],
|
||||
"default_model": "anthropic/claude-sonnet-5",
|
||||
},
|
||||
}
|
||||
}
|
||||
admin_routes_mod.save_settings(admin_routes_mod.SettingsConfig.model_validate(custom))
|
||||
|
||||
# PUT avec un bloc ai_tiers vierge (client sans gestion des paliers).
|
||||
r = client.put(
|
||||
"/api/v1/admin/settings",
|
||||
json={"smtp": {}, "ai_tiers": {"essential": {"models": [], "default_model": ""},
|
||||
"premium": {"models": [], "default_model": ""}}},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
saved = admin_routes_mod.load_settings()
|
||||
assert saved.ai_tiers.essential.default_model == "z-ai/glm-5.3-flash"
|
||||
assert saved.ai_tiers.essential.models[0] == "z-ai/glm-5.3-flash"
|
||||
|
||||
# Un PUT avec un défaut Essentielle appartenant à la gamme Premium → 400.
|
||||
r2 = client.put(
|
||||
"/api/v1/admin/settings",
|
||||
json={"ai_tiers": {"essential": {"models": [], "default_model": "anthropic/claude-sonnet-5"},
|
||||
"premium": {"models": [], "default_model": ""}}},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r2.status_code == 400
|
||||
assert r2.json()["error"] == "INVALID_AI_TIER"
|
||||
243
tests/test_admin_stats_revenue.py
Normal file
243
tests/test_admin_stats_revenue.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""
|
||||
Statistiques admin enrichies (spec « Stratégie LLM par abonnement ») :
|
||||
revenus encaissés, MRR estimé, paliers IA utilisés, compteur liste d'attente.
|
||||
En mode JSON (sans base de données), les revenus restent à zéro sans erreur.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
ADMIN_LOGIN_URL = "/api/v1/admin/login"
|
||||
STATS_URL = "/api/v1/admin/stats"
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stats_env(tmp_path: Path, monkeypatch):
|
||||
import routes.waitlist_routes as waitlist_mod
|
||||
|
||||
waitlist_file = tmp_path / "waitlist.json"
|
||||
monkeypatch.setattr(waitlist_mod, "WAITLIST_FILE", waitlist_file)
|
||||
return {"waitlist_file": waitlist_file}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(stats_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 main import app
|
||||
|
||||
return TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_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
|
||||
return {"Authorization": f"Bearer {r.json()['access_token']}"}
|
||||
|
||||
|
||||
def test_stats_include_revenue_mrr_waitlist_and_tiers(client, auth_headers, stats_env):
|
||||
_write_json(stats_env["waitlist_file"], [{"email": "a@example.com"}])
|
||||
|
||||
r = client.get(STATS_URL, headers=auth_headers)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
|
||||
# Nouveaux blocs présents
|
||||
for key in ("revenue", "mrr_estimated", "ai_tier_usage", "waitlist_count"):
|
||||
assert key in body, f"clé manquante dans /admin/stats : {key}"
|
||||
|
||||
# Sans base de données : revenus à zéro, sans erreur
|
||||
assert body["revenue"]["collected_total"] == 0.0
|
||||
assert body["revenue"]["collected_30d"] == 0.0
|
||||
assert body["revenue"]["credits_purchased"] == 0.0
|
||||
|
||||
# Compteur liste d'attente branché sur data/waitlist.json
|
||||
assert body["waitlist_count"] == 1
|
||||
|
||||
# Paliers IA : structure complète
|
||||
assert set(body["ai_tier_usage"].keys()) == {
|
||||
"essential",
|
||||
"premium",
|
||||
"classic",
|
||||
"other",
|
||||
}
|
||||
|
||||
# Anciennes clés toujours présentes (non-régression)
|
||||
assert "users" in body and "translations" in body and "cache" in body
|
||||
|
||||
|
||||
def test_stats_mrr_estimated_from_plan_distribution(client, auth_headers, monkeypatch):
|
||||
"""Le MRR estimé s'appuie sur la répartition des plans (prix mensuel effectif)."""
|
||||
import services.auth_service as auth_svc
|
||||
|
||||
users_file = auth_svc.USERS_FILE
|
||||
users_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
_write_json(
|
||||
users_file,
|
||||
{
|
||||
"u1": {
|
||||
"email": "p1@example.com",
|
||||
"name": "P1",
|
||||
"plan": "pro",
|
||||
"docs_translated_this_month": 0,
|
||||
},
|
||||
"u2": {
|
||||
"email": "p2@example.com",
|
||||
"name": "P2",
|
||||
"plan": "pro",
|
||||
"docs_translated_this_month": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
r = client.get(STATS_URL, headers=auth_headers)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
|
||||
from services import pricing_config as pricing_cfg
|
||||
from models.subscription import PlanType
|
||||
|
||||
monthly_pro, _ = pricing_cfg.get_effective_monthly_yearly(PlanType.PRO.value)
|
||||
assert body["users"]["by_plan"].get("pro") == 2
|
||||
assert body["mrr_estimated"]["by_plan"].get("pro") == pytest.approx(
|
||||
round(monthly_pro * 2, 2)
|
||||
)
|
||||
assert body["mrr_estimated"]["total"] == pytest.approx(
|
||||
round(monthly_pro * 2, 2)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Revenus et paliers IA en mode base de données (SQLite de test, cf. conftest)
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.fixture
|
||||
def db_client(monkeypatch, tmp_path: Path):
|
||||
"""TestClient avec authentification admin simple et base de test active."""
|
||||
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", True)
|
||||
monkeypatch.setattr(auth_svc, "DATABASE_AVAILABLE", True)
|
||||
|
||||
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 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)
|
||||
|
||||
from main import app
|
||||
|
||||
return TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
|
||||
def test_stats_revenue_and_tiers_from_database(db_client):
|
||||
"""Paiements réussis + traductions en base → revenus et paliers non nuls."""
|
||||
from database.connection import get_sync_session
|
||||
from database.models import User as DBUser, PaymentHistory, Translation
|
||||
|
||||
with get_sync_session() as session:
|
||||
user = DBUser(
|
||||
email="payer@example.com",
|
||||
name="Payer",
|
||||
password_hash="not-a-real-hash",
|
||||
)
|
||||
session.add(user)
|
||||
session.flush()
|
||||
|
||||
session.add_all(
|
||||
[
|
||||
PaymentHistory(
|
||||
user_id=user.id,
|
||||
amount_cents=1900,
|
||||
currency="eur",
|
||||
payment_type="subscription",
|
||||
status="succeeded",
|
||||
),
|
||||
PaymentHistory(
|
||||
user_id=user.id,
|
||||
amount_cents=900,
|
||||
currency="eur",
|
||||
payment_type="credits",
|
||||
status="succeeded",
|
||||
),
|
||||
PaymentHistory(
|
||||
user_id=user.id,
|
||||
amount_cents=5000,
|
||||
currency="eur",
|
||||
payment_type="subscription",
|
||||
status="failed", # jamais comptabilisé
|
||||
),
|
||||
Translation(
|
||||
user_id=user.id,
|
||||
original_filename="a.xlsx",
|
||||
file_type="xlsx",
|
||||
target_language="fr",
|
||||
provider="openrouter",
|
||||
status="completed",
|
||||
),
|
||||
Translation(
|
||||
user_id=user.id,
|
||||
original_filename="b.xlsx",
|
||||
file_type="xlsx",
|
||||
target_language="fr",
|
||||
provider="openrouter_premium",
|
||||
status="completed",
|
||||
),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
r = db_client.get(STATS_URL, headers=_admin_headers(db_client))
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
|
||||
assert body["revenue"]["currency"] == "EUR"
|
||||
assert body["revenue"]["collected_total"] >= 28.0 # 19 € + 9 € (l'échec ignoré)
|
||||
assert body["revenue"]["collected_30d"] >= 28.0
|
||||
assert body["revenue"]["credits_purchased"] >= 9.0
|
||||
assert body["revenue"]["payments_30d"] >= 2
|
||||
assert body["ai_tier_usage"]["essential"] >= 1
|
||||
assert body["ai_tier_usage"]["premium"] >= 1
|
||||
|
||||
|
||||
def _admin_headers(client) -> dict:
|
||||
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']}"}
|
||||
157
tests/test_ai_tier_routing.py
Normal file
157
tests/test_ai_tier_routing.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
Routage réel des modèles par palier IA (spec « Stratégie LLM par abonnement »).
|
||||
|
||||
- PLANS expose ai_models_essential / ai_models_premium (gamme officielle).
|
||||
- Un plan Pro (moteur openrouter) résout toujours un modèle Essentielle,
|
||||
jamais un modèle Premium.
|
||||
- Le défaut admin (ai_tiers.<palier>.default_model) prime sur le défaut du
|
||||
plan, sans redéploiement.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from models.subscription import (
|
||||
DEFAULT_AI_MODELS_ESSENTIAL,
|
||||
DEFAULT_AI_MODELS_PREMIUM,
|
||||
PLANS,
|
||||
PlanType,
|
||||
)
|
||||
from routes.translate_routes import (
|
||||
_allowed_providers_for_plan,
|
||||
_plan_ai_models,
|
||||
_resolve_tier_model,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch, tmp_path: Path):
|
||||
"""TestClient with JSON auth and rate limiting disabled."""
|
||||
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 main import app
|
||||
|
||||
return TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
|
||||
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})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gamme officielle dans PLANS
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_plans_expose_official_model_ranges():
|
||||
essential = PLANS[PlanType.PRO]["ai_models_essential"]
|
||||
assert essential == [
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"z-ai/glm-5.3-flash",
|
||||
"minimax/minimax-m3",
|
||||
]
|
||||
business = PLANS[PlanType.BUSINESS]
|
||||
assert business["ai_models_essential"] == essential
|
||||
assert business["ai_models_premium"] == [
|
||||
"anthropic/claude-sonnet-5",
|
||||
"deepseek/deepseek-v4-pro",
|
||||
"z-ai/glm-5.3",
|
||||
]
|
||||
|
||||
|
||||
def test_old_single_model_keys_removed():
|
||||
assert "ai_model_essential" not in PLANS[PlanType.PRO]
|
||||
assert "ai_model_essential" not in PLANS[PlanType.BUSINESS]
|
||||
assert "ai_model_premium" not in PLANS[PlanType.BUSINESS]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Résolution par palier
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_pro_resolves_essential_plan_default():
|
||||
assert _resolve_tier_model(_settings(), "pro", premium=False) == (
|
||||
"deepseek/deepseek-v4-flash"
|
||||
)
|
||||
|
||||
|
||||
def test_business_resolves_premium_plan_default():
|
||||
assert _resolve_tier_model(_settings(), "business", premium=True) == (
|
||||
"anthropic/claude-sonnet-5"
|
||||
)
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def test_admin_tier_models_first_used_when_default_empty():
|
||||
cfg = _settings(
|
||||
{
|
||||
"essential": {
|
||||
"models": ["minimax/minimax-m3", "deepseek/deepseek-v4-flash"],
|
||||
"default_model": "",
|
||||
}
|
||||
}
|
||||
)
|
||||
assert _resolve_tier_model(cfg, "pro", premium=False) == "minimax/minimax-m3"
|
||||
|
||||
|
||||
def test_pro_never_resolves_premium_model():
|
||||
cfg = _settings(
|
||||
{
|
||||
"essential": {"models": [], "default_model": ""},
|
||||
"premium": {"models": [], "default_model": ""},
|
||||
}
|
||||
)
|
||||
for plan_str in ("pro", "PlanType.PRO"):
|
||||
model = _resolve_tier_model(cfg, plan_str, premium=False)
|
||||
assert model in DEFAULT_AI_MODELS_ESSENTIAL
|
||||
assert model not in DEFAULT_AI_MODELS_PREMIUM
|
||||
|
||||
|
||||
def test_pro_plan_does_not_allow_premium_provider():
|
||||
allowed = _allowed_providers_for_plan(PlanType.PRO)
|
||||
assert "openrouter" in allowed
|
||||
assert "openrouter_premium" not in allowed
|
||||
|
||||
|
||||
def test_plan_fallback_for_plans_without_range():
|
||||
# Free/Enterprise n'ont pas de liste : retombée sur la gamme officielle.
|
||||
assert _plan_ai_models(PlanType.FREE, premium=False) == DEFAULT_AI_MODELS_ESSENTIAL
|
||||
assert _plan_ai_models(PlanType.ENTERPRISE, premium=True) == DEFAULT_AI_MODELS_PREMIUM
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /plans expose les gammes (affichage public)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_get_plans_exposes_model_lists(client):
|
||||
r = client.get("/api/v1/auth/plans")
|
||||
assert r.status_code == 200
|
||||
plans = {p["id"]: p for p in r.json()["data"]["plans"]}
|
||||
assert plans["pro"]["ai_models_essential"] == [
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"z-ai/glm-5.3-flash",
|
||||
"minimax/minimax-m3",
|
||||
]
|
||||
assert plans["business"]["ai_models_premium"][0] == "anthropic/claude-sonnet-5"
|
||||
assert "ai_model_essential" not in plans["pro"]
|
||||
152
tests/test_worker_tier_routing.py
Normal file
152
tests/test_worker_tier_routing.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Intégration : ``_run_translation_job`` route bien le modèle par palier.
|
||||
|
||||
Un faux fournisseur OpenAI-compatible capture le paramètre ``model`` reçu du
|
||||
worker ; ``load_settings`` (réglages admin) est substitué. On vérifie que :
|
||||
- provider="openrouter" → modèle Essentielle (réglages > plan) ;
|
||||
- provider="openrouter_premium" → modèle Premium ;
|
||||
- un ``ai_tiers.essential`` personnalisé est honoré sans redéploiement ;
|
||||
- un ``ai_tiers.essential`` contenant un modèle Premium est corrigé à l'exécution.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
import routes.translate_routes as tr
|
||||
|
||||
|
||||
@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 _settings(ai_tiers: dict, api_key: str = "sk-or-test"):
|
||||
from routes.admin_routes import SettingsConfig
|
||||
|
||||
return SettingsConfig.model_validate(
|
||||
{
|
||||
"openrouter": {"api_key": api_key},
|
||||
"openrouter_premium": {"api_key": api_key},
|
||||
"ai_tiers": ai_tiers,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
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) -> list:
|
||||
"""Lance _run_translation_job avec des faux traducteurs/fournisseurs et
|
||||
renvoie la liste des modèles capturés par le faux fournisseur."""
|
||||
import routes.admin_routes as admin_routes_mod
|
||||
import services.providers.openai_provider as openai_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
|
||||
captured.append(model)
|
||||
|
||||
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}
|
||||
|
||||
monkeypatch.setattr(openai_provider_mod, "OpenAITranslationProvider", FakeProvider)
|
||||
monkeypatch.setattr(tr, "ExcelTranslator", FakeExcelTranslator)
|
||||
monkeypatch.setattr(
|
||||
admin_routes_mod, "load_settings", lambda: admin_settings, raising=True
|
||||
)
|
||||
|
||||
_register_job("job-test")
|
||||
try:
|
||||
asyncio.run(
|
||||
tr._run_translation_job(
|
||||
job_id="job-test",
|
||||
input_path=Path(minimal_xlsx),
|
||||
file_extension=".xlsx",
|
||||
target_lang="fr",
|
||||
source_lang="en",
|
||||
provider=provider,
|
||||
user_id=None,
|
||||
custom_prompt=None,
|
||||
glossary_id=None,
|
||||
prompt_id=None,
|
||||
webhook_url=None,
|
||||
user_plan=user_plan,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
tr._translation_jobs.pop("job-test", None)
|
||||
return captured
|
||||
|
||||
|
||||
def test_openrouter_pro_uses_essential_tier_model(monkeypatch, tmp_path, minimal_xlsx):
|
||||
settings = _settings({"essential": {"models": [], "default_model": ""},
|
||||
"premium": {"models": [], "default_model": ""}})
|
||||
captured = _run_job(monkeypatch, tmp_path, minimal_xlsx, settings,
|
||||
provider="openrouter", user_plan="PlanType.PRO")
|
||||
assert captured, "le faux fournisseur n'a pas été instancié"
|
||||
assert captured[0] == "deepseek/deepseek-v4-flash"
|
||||
assert captured[0] not in (
|
||||
"anthropic/claude-sonnet-5",
|
||||
"deepseek/deepseek-v4-pro",
|
||||
"z-ai/glm-5.3",
|
||||
)
|
||||
|
||||
|
||||
def test_openrouter_premium_uses_premium_tier_model(monkeypatch, tmp_path, minimal_xlsx):
|
||||
settings = _settings({"essential": {"models": [], "default_model": ""},
|
||||
"premium": {"models": [], "default_model": ""}})
|
||||
captured = _run_job(monkeypatch, tmp_path, minimal_xlsx, settings,
|
||||
provider="openrouter_premium", user_plan="PlanType.BUSINESS")
|
||||
assert captured, "le faux fournisseur n'a pas été instancié"
|
||||
assert captured[0] == "anthropic/claude-sonnet-5"
|
||||
|
||||
|
||||
def test_admin_tier_default_applied_without_redeployment(monkeypatch, tmp_path, minimal_xlsx):
|
||||
settings = _settings({"essential": {"models": [], "default_model": "z-ai/glm-5.3-flash"},
|
||||
"premium": {"models": [], "default_model": ""}})
|
||||
captured = _run_job(monkeypatch, tmp_path, minimal_xlsx, settings,
|
||||
provider="openrouter", user_plan="PlanType.PRO")
|
||||
assert captured[0] == "z-ai/glm-5.3-flash"
|
||||
|
||||
|
||||
def test_essential_tier_resolving_premium_model_is_corrected(monkeypatch, tmp_path, minimal_xlsx):
|
||||
"""Garde d'exécution : un ai_tiers essential contaminé par un modèle
|
||||
Premium ne doit pas partir en production — repli sur le défaut du plan."""
|
||||
settings = _settings({"essential": {"models": [], "default_model": "anthropic/claude-sonnet-5"},
|
||||
"premium": {"models": [], "default_model": ""}})
|
||||
captured = _run_job(monkeypatch, tmp_path, minimal_xlsx, settings,
|
||||
provider="openrouter", user_plan="PlanType.PRO")
|
||||
assert captured[0] == "deepseek/deepseek-v4-flash"
|
||||
Reference in New Issue
Block a user