-
-
-
-
Email SMTP
+
{t("admin.settings.smtp.title")}
- {config.smtp.enabled ? "Activé" : "Désactivé"}
+ {config.smtp.enabled ? t("admin.settings.enabled") : t("admin.settings.disabled")}
{envInfo.smtp && (
- Config dans .env
+ {t("admin.settings.envBadge")}
)}
- testProvider("smtp")}
- disabled={testResults.smtp === "testing"}
- className="h-8"
- >
- {testResults.smtp === "testing" ? (
- <> Test...>
- ) : testResults.smtp === "ok" ? (
- <> OK>
- ) : testResults.smtp === "error" ? (
- <> Erreur>
- ) : (
- <> Tester>
- )}
-
+ testProvider("smtp")}
+ labels={{
+ test: t("admin.settings.test"),
+ testing: t("admin.settings.testing"),
+ ok: t("admin.settings.ok"),
+ error: t("admin.settings.error"),
+ }}
+ />
updateSmtp({ enabled })} />
-
- Configuration du serveur SMTP pour l'envoi d'emails (mot de passe oublié, notifications, etc.)
-
+ {t("admin.settings.smtp.desc")}
{testMessages.smtp && (
{testMessages.smtp}
@@ -700,16 +726,16 @@ export default function AdminSettingsPage() {
- Hôte SMTP
+ {t("admin.settings.smtp.host")}
updateSmtp({ host: e.target.value })}
/>
-
Port
+
{t("admin.settings.smtp.port")}
- Nom d'utilisateur
+ {t("admin.settings.smtp.username")}
updateSmtp({ username: e.target.value })}
/>
-
Mot de passe
+
{t("admin.settings.smtp.password")}
@@ -768,13 +794,13 @@ export default function AdminSettingsPage() {
className="h-8"
>
{isSendingTestEmail ? (
- <> Envoi...>
+ <> {t("admin.settings.sending")}>
) : testEmailResult === "ok" ? (
- <> Envoyé>
+ <> {t("admin.settings.smtp.sent")}>
) : testEmailResult === "error" ? (
- <> Échec>
+ <> {t("admin.settings.failure")}>
) : (
- <> Envoyer un email de test>
+ <> {t("admin.settings.smtp.sendTest")}>
)}
{testEmailMessage && (
@@ -785,19 +811,48 @@ export default function AdminSettingsPage() {
-
+
+
+ {/* ── Chaîne de secours ── */}
+
}
+ title={t("admin.settings.fallback.title")}
+ description={t("admin.settings.fallback.desc")}
+ >
+
+
+
+ {t("admin.settings.fallback.classic")}
+ setConfig((prev) => ({ ...prev, fallback_chain_classic: e.target.value }))}
+ placeholder="google,google_cloud"
+ />
+
+
+ {t("admin.settings.fallback.llm")}
+ setConfig((prev) => ({ ...prev, fallback_chain_llm: e.target.value }))}
+ placeholder="openrouter,openai,deepseek,zai"
+ />
+
+ {t("admin.settings.fallback.note")}
+
+
+
{isSaving ? (
<>
- Sauvegarde...
+ {t("admin.settings.saving")}
>
) : (
<>
- Sauvegarder la configuration
+ {t("admin.settings.save")}
>
)}
@@ -806,6 +861,32 @@ export default function AdminSettingsPage() {
);
}
+type TestState = "ok" | "error" | "testing" | "idle";
+
+function TestButton({
+ result,
+ onTest,
+ labels,
+}: {
+ result: TestState;
+ onTest: () => void;
+ labels: { test: string; testing: string; ok: string; error: string };
+}) {
+ return (
+
+ {result === "testing" ? (
+ <> {labels.testing}>
+ ) : result === "ok" ? (
+ <> {labels.ok}>
+ ) : result === "error" ? (
+ <> {labels.error}>
+ ) : (
+ <> {labels.test}>
+ )}
+
+ );
+}
+
function ProviderCard({
title,
description,
@@ -816,6 +897,7 @@ function ProviderCard({
testMessage,
noApiKey = false,
envKeySet = false,
+ t,
children,
}: {
title: string;
@@ -823,10 +905,11 @@ function ProviderCard({
enabled: boolean;
onToggle: (enabled: boolean) => void;
onTest: () => void;
- testResult: "ok" | "error" | "testing" | "idle";
+ testResult: TestState;
testMessage?: string;
noApiKey?: boolean;
envKeySet?: boolean;
+ t: (key: string, params?: Record
) => string;
children?: React.ReactNode;
}) {
return (
@@ -836,33 +919,26 @@ function ProviderCard({
{title}
- {enabled ? "Activé" : "Désactivé"}
+ {enabled ? t("admin.settings.enabled") : t("admin.settings.disabled")}
{envKeySet && !noApiKey && (
- Clé dans .env
+ {t("admin.settings.envBadge")}
)}
-
- {testResult === "testing" ? (
- <> Test...>
- ) : testResult === "ok" ? (
- <> OK>
- ) : testResult === "error" ? (
- <> Erreur>
- ) : (
- <> Tester>
- )}
-
+
diff --git a/frontend/src/app/admin/types.ts b/frontend/src/app/admin/types.ts
index e33b77f..77f07ec 100644
--- a/frontend/src/app/admin/types.ts
+++ b/frontend/src/app/admin/types.ts
@@ -81,11 +81,20 @@ export interface TranslationStatsResponse {
/* ── Modèles & abonnements (paliers IA) ─────────────────────── */
+/** Route d'un palier : un modèle via un canal donné (multi-canaux). */
+export interface AiTierRoute {
+ provider: string; // openrouter | openrouter_premium | deepseek | zhipu | minimax | openai | zai
+ model: string;
+ label?: string;
+}
+
export interface AiTierSettings {
- /** Modèles actifs — l'ordre est la priorité de secours. */
+ /** Ancien format : modèles OpenRouter (l'ordre est la priorité de secours). */
models: string[];
/** Modèle par défaut ; vide = premier de la liste = défaut du plan. */
default_model: string;
+ /** Routes multi-canaux {provider, model} — priment sur models/default_model. */
+ routes: AiTierRoute[];
}
export interface AiTiersSettings {
@@ -165,3 +174,48 @@ export interface MarketingEmailSendResponse {
};
meta: Record
;
}
+
+/* ── Marketing : génération IA + réductions ─────────────────── */
+
+export interface MarketingEmailGenerateRequest {
+ provider: string;
+ model?: string;
+ brief: string;
+ language?: string;
+ tone?: string;
+ promo_code?: string;
+}
+
+export interface MarketingEmailGenerateResponse {
+ data: {
+ subject: string;
+ html: string;
+ provider: string;
+ model: string;
+ language: string;
+ };
+ meta: Record;
+}
+
+/** Code promo (réduction) — miroir de data/promo_codes.json côté serveur. */
+export interface PromoCode {
+ code: string;
+ active: boolean;
+ type: "percent" | "amount";
+ value: number;
+ plans: string[];
+ max_redemptions: number | null;
+ times_used: number;
+ expires_at: string | null;
+ stripe_coupon_id: string | null;
+ stripe_promotion_code_id: string | null;
+ stripe_mode: "test" | "live" | "local_only";
+ created_at: string;
+ deactivated_at: string | null;
+ last_used_at?: string;
+}
+
+export interface PromoListResponse {
+ data: { promos: PromoCode[] };
+ meta: Record;
+}
diff --git a/frontend/src/app/pricing/page.tsx b/frontend/src/app/pricing/page.tsx
index d57da4a..86a4181 100644
--- a/frontend/src/app/pricing/page.tsx
+++ b/frontend/src/app/pricing/page.tsx
@@ -11,6 +11,8 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
@@ -275,7 +277,15 @@ export default function PricingPage() {
const searchParams = useSearchParams();
const planFromUrl = searchParams.get("plan");
const billingFromUrl = searchParams.get("billing");
+ const promoFromUrl = (searchParams.get("promo") || "").toUpperCase();
const [isYearly, setIsYearly] = useState(billingFromUrl === "yearly");
+ /** Code promo (lu depuis ?promo=, modifiable dans la confirmation). */
+ const [promoCode, setPromoCode] = useState(promoFromUrl);
+ /** Résultat de la vérification du code (GET /promos/validate). */
+ const [promoStatus, setPromoStatus] = useState<{
+ state: "idle" | "checking" | "valid" | "invalid";
+ text?: string;
+ }>({ state: "idle" });
const [plans, setPlans] = useState(STATIC_PLANS);
const [credits, setCredits] = useState(STATIC_CREDITS);
const [currentPlan, setCurrentPlan] = useState(null);
@@ -375,6 +385,7 @@ export default function PricingPage() {
body: JSON.stringify({
plan: planId,
billing_period: isYearly ? "yearly" : "monthly",
+ promo_code: promoCode.trim() || undefined,
}),
});
@@ -383,10 +394,15 @@ export default function PricingPage() {
try { data = await res.json(); } catch { /* ignore */ }
const url = data.data?.url ?? data.url;
+ const promoReason = data.promo_reason ?? data.data?.promo_reason;
const backendMessage = data.message ?? data.error ?? data.data?.error;
if (!res.ok) {
- throw new Error(backendMessage || t('pricing.error.server', { status: res.status }));
+ // Refus d'un code promo : cause traduite si elle est connue.
+ const message = promoReason
+ ? (t(`pricing.promoError.${promoReason}`) || backendMessage)
+ : (backendMessage || t('pricing.error.server', { status: res.status }));
+ throw new Error(message);
}
if (url) {
@@ -406,8 +422,39 @@ export default function PricingPage() {
}
};
- const handleBuyCredits = async (packageIndex: number) => {
- const token = localStorage.getItem("token");
+ /** Vérifie le code promo AVANT l'ouverture de Stripe (endpoint public). */
+ const applyPromoCode = async () => {
+ const code = promoCode.trim();
+ if (!code || !confirmPlan) return;
+ setPromoStatus({ state: "checking" });
+ try {
+ const res = await fetch(
+ `${API_BASE}/api/v1/promos/validate?code=${encodeURIComponent(code)}&plan=${encodeURIComponent(confirmPlan.id)}`,
+ { cache: "no-store" }
+ );
+ const json = await res.json().catch(() => null);
+ const d = json?.data;
+ if (d?.valid) {
+ const text =
+ typeof d.percent_off === "number"
+ ? `−${d.percent_off} %`
+ : typeof d.amount_off === "number"
+ ? `−${d.amount_off} €`
+ : t('pricing.promo.valid');
+ setPromoStatus({ state: "valid", text });
+ } else {
+ const reason = d?.reason_code;
+ setPromoStatus({
+ state: "invalid",
+ text: (reason ? t(`pricing.promoError.${reason}`) : "") || d?.message || t('pricing.promoError.unknown'),
+ });
+ }
+ } catch {
+ setPromoStatus({ state: "invalid", text: t('pricing.toast.networkError') });
+ }
+ };
+
+ const handleBuyCredits = async (packageIndex: number) => { const token = localStorage.getItem("token");
if (!token) {
router.push(`/auth/login?redirect=/pricing`);
return;
@@ -499,6 +546,41 @@ export default function PricingPage() {
{t('pricing.confirm.monthlyEquivalent', { price: (confirmPlan.price_yearly / 12).toFixed(2) })}
)}
+ {/* Code promo : validé côté serveur avant l'ouverture de Stripe. */}
+
+
{t('pricing.promo.label')}
+
+ {
+ setPromoCode(e.target.value.toUpperCase());
+ if (promoStatus.state !== "idle") setPromoStatus({ state: "idle" });
+ }}
+ maxLength={24}
+ />
+
+ {promoStatus.state === "checking"
+ ? t('pricing.promo.checking')
+ : t('pricing.promo.apply')}
+
+
+ {promoStatus.state === "valid" && promoStatus.text && (
+
{promoStatus.text}
+ )}
+ {promoStatus.state === "invalid" && promoStatus.text && (
+
{promoStatus.text}
+ )}
+
+ {t('pricing.promo.subscriptionOnly')}
+
+
{t('pricing.confirm.secureNote')}
diff --git a/frontend/src/lib/i18n/messages/ar/admin.json b/frontend/src/lib/i18n/messages/ar/admin.json
index c9b830a..2c74ad5 100644
--- a/frontend/src/lib/i18n/messages/ar/admin.json
+++ b/frontend/src/lib/i18n/messages/ar/admin.json
@@ -145,5 +145,180 @@
"admin.stats.tiersSub": "أساسي / متميز · كلاسيكي {classic} · آخر {other}",
"admin.stats.unavailable": "إحصائيات الأعمال غير متاحة ({error}).",
"admin.stats.waitlist": "قائمة الانتظار",
- "admin.stats.waitlistSub": "مُدرجون في قائمة الانتظار"
+ "admin.stats.waitlistSub": "مُدرجون في قائمة الانتظار",
+ "admin.settings.title": "المزودون",
+ "admin.settings.subtitle": "اضبط صلاحيات المزودين. يمكن أيضًا تعيين مفاتيح API في ملف .env.",
+ "admin.settings.sectionClassic": "الترجمة الكلاسيكية",
+ "admin.settings.sectionClassicDesc": "محركات Google للترجمة العادية.",
+ "admin.settings.sectionDirect": "مزودو الذكاء الاصطناعي المباشرون",
+ "admin.settings.sectionDirectDesc": "وصول مباشر متوافق مع OpenAI — يُستخدم كقناة لمستوى الذكاء الاصطناعي (صفحة «النماذج والاشتراكات») أو لتوليد الرسائل.",
+ "admin.settings.sectionGateway": "بوابات متعددة النماذج",
+ "admin.settings.sectionGatewayDesc": "مفتاح واحد ومئات النماذج. تُدار النماذج لكل مستوى من صفحة «النماذج والاشتراكات».",
+ "admin.settings.sectionOcr": "التعرف الضوئي (PDF ممسوحة)",
+ "admin.settings.sectionOcrDesc": "استخراج النص من صفحات الصور قبل الترجمة.",
+ "admin.settings.sectionEmails": "الرسائل",
+ "admin.settings.sectionEmailsDesc": "خادم الإرسال للإشعارات ورسائل المتابعة.",
+ "admin.settings.enabled": "مفعّل",
+ "admin.settings.disabled": "معطّل",
+ "admin.settings.envBadge": "المفتاح في .env",
+ "admin.settings.envConfigured": "مُعيَّن في .env",
+ "admin.settings.envKeyPlaceholder": "مفتاح معيَّن في .env (اتركه فارغًا لاستخدامه)",
+ "admin.settings.apiKey": "مفتاح API",
+ "admin.settings.model": "النموذج",
+ "admin.settings.baseUrl": "العنوان الأساسي",
+ "admin.settings.test": "اختبار",
+ "admin.settings.testing": "جارٍ الاختبار...",
+ "admin.settings.ok": "تم",
+ "admin.settings.error": "خطأ",
+ "admin.settings.failure": "فشل",
+ "admin.settings.sending": "جارٍ الإرسال...",
+ "admin.settings.save": "حفظ الإعدادات",
+ "admin.settings.saving": "جارٍ الحفظ...",
+ "admin.settings.savedTitle": "✅ تم حفظ الإعدادات",
+ "admin.settings.savedDesc": "تم حفظ الإعدادات بنجاح.",
+ "admin.settings.saveErrorTitle": "خطأ في الحفظ",
+ "admin.settings.loadErrorTitle": "خطأ في التحميل",
+ "admin.settings.loadErrorDesc": "HTTP {status} — تحقق من رمز المدير.",
+ "admin.settings.networkErrorTitle": "خطأ في الشبكة",
+ "admin.settings.networkErrorDesc": "لا يمكن الوصول إلى الخادم الخلفي.",
+ "admin.settings.networkSaveErrorDesc": "لا يمكن الوصول إلى الخادم الخلفي للحفظ.",
+ "admin.settings.connectionOk": "الاتصال ناجح",
+ "admin.settings.modelsFound": "تم العثور على {count} نماذج لـ {provider}",
+ "admin.settings.providerError": "خطأ {provider}",
+ "admin.settings.google.title": "Google Translate",
+ "admin.settings.google.desc": "وصول ويب غير رسمي عبر deep_translator. لا يحتاج مفتاحًا؛ يُنصح بالاستخدام المعقول.",
+ "admin.settings.googleCloud.title": "Google Cloud Translation (رسمي)",
+ "admin.settings.googleCloud.desc": "واجهة Google Cloud الرسمية. 500 ألف حرف/شهر مجانًا ثم نحو 20 $ لكل مليون حرف. مفتاح API من console.cloud.google.com.",
+ "admin.settings.googleCloud.hint": "فعّل Cloud Translation API في Google Cloud Console ثم أنشئ مفتاح API مقيَّدًا بهذه الواجهة.",
+ "admin.settings.deepseek.title": "DeepSeek (مباشر)",
+ "admin.settings.deepseek.desc": "واجهة DeepSeek المباشرة المتوافقة مع OpenAI. المفتاح من platform.deepseek.com — صالح كقناة لمستوى الذكاء الاصطناعي.",
+ "admin.settings.zhipu.title": "z.ai / Zhipu (GLM)",
+ "admin.settings.zhipu.desc": "وصول مباشر إلى نماذج GLM من z.ai، متوافق مع OpenAI. المفتاح من z.ai — صالح كقناة لمستوى الذكاء الاصطناعي.",
+ "admin.settings.zhipu.urlHint": "الافتراضي: https://api.z.ai/api/paas/v4 — غيّره فقط إذا كنت تستخدم وسيطًا.",
+ "admin.settings.minimax.title": "MiniMax (مباشر)",
+ "admin.settings.minimax.desc": "واجهة MiniMax العامة المتوافقة مع OpenAI. المفتاح من minimax.io — صالح كقناة لمستوى الذكاء الاصطناعي.",
+ "admin.settings.openai.title": "OpenAI",
+ "admin.settings.openai.desc": "ترجمات GPT. احصل على مفتاح من platform.openai.com.",
+ "admin.settings.zai.title": "xAI (Grok)",
+ "admin.settings.zai.desc": "نماذج Grok من xAI. واجهة متوافقة مع OpenAI. احصل على مفتاحك من x.ai.",
+ "admin.settings.zai.urlHint": "الافتراضي: https://api.x.ai/v1 — غيّره فقط إذا كنت تستخدم وسيطًا.",
+ "admin.settings.openrouter.title": "OpenRouter — مفتاح البوابة",
+ "admin.settings.openrouter.desc": "المستوى الأساسي (خطة Pro). المجموعة الحالية: deepseek/deepseek-v4-flash، z-ai/glm-5.3-flash، minimax/minimax-m3.",
+ "admin.settings.openrouter.keyLabel": "مفتاح OpenRouter API (مشترك مع Premium)",
+ "admin.settings.openrouter.envSharedPlaceholder": "مفتاح معيَّن في .env (مشترك مع Premium)",
+ "admin.settings.openrouter.reminder": "النماذج تُدار من صفحة «النماذج والاشتراكات».",
+ "admin.settings.openrouterPremium.title": "OpenRouter Premium — مفتاح البوابة",
+ "admin.settings.openrouterPremium.desc": "المستوى المتقدم (خطة Business). المجموعة الحالية: anthropic/claude-sonnet-5، deepseek/deepseek-v4-pro، z-ai/glm-5.3.",
+ "admin.settings.openrouterPremium.reminder": "النماذج تُدار من صفحة «النماذج والاشتراكات». يشارك نفس مفتاح OpenRouter.",
+ "admin.settings.mistral.title": "OCR Mistral (PDF ممسوحة)",
+ "admin.settings.mistral.desc": "يستخرج النص من ملفات PDF الممسوحة (صفحات الصور) قبل الترجمة. بدون مفتاح تُرفض هذه الملفات برسالة واضحة. نحو 1 € / 1000 صفحة. المفتاح: console.mistral.ai.",
+ "admin.settings.mistral.keyLabel": "مفتاح API الخاص بـ Mistral",
+ "admin.settings.mistral.modelLabel": "نموذج OCR",
+ "admin.settings.mistral.modelHint": "الموصى به: mistral-ocr-latest",
+ "admin.settings.smtp.title": "البريد عبر SMTP",
+ "admin.settings.smtp.desc": "إعدادات خادم SMTP لإرسال الرسائل (استعادة كلمة المرور، الإشعارات، رسائل المتابعة).",
+ "admin.settings.smtp.host": "خادم SMTP",
+ "admin.settings.smtp.port": "المنفذ",
+ "admin.settings.smtp.username": "اسم المستخدم",
+ "admin.settings.smtp.password": "كلمة المرور",
+ "admin.settings.smtp.fromEmail": "عنوان المرسِل",
+ "admin.settings.smtp.useTls": "استخدام TLS",
+ "admin.settings.smtp.sendTest": "إرسال رسالة تجريبية",
+ "admin.settings.smtp.sent": "أُرسلت",
+ "admin.settings.smtp.sendFailed": "فشل الإرسال",
+ "admin.models.channelOpenrouter": "OpenRouter",
+ "admin.models.channelOpenrouterPremium": "OpenRouter (Premium)",
+ "admin.models.channelDeepseek": "DeepSeek (مباشر)",
+ "admin.models.channelZhipu": "z.ai (GLM)",
+ "admin.models.channelMinimax": "MiniMax (مباشر)",
+ "admin.models.channelOpenai": "OpenAI",
+ "admin.models.channelZai": "xAI (Grok)",
+ "admin.models.customModelPlaceholder": "معرّف-النموذج",
+ "admin.models.routeHint": "القناة تختار الوصول المباشر أو البوابة؛ معرّفات النماذج تختلف من قناة إلى أخرى.",
+ "admin.models.addRouteConfirm": "إضافة",
+ "admin.marketing.generateTitle": "التوليد بالذكاء الاصطناعي",
+ "admin.marketing.generateDesc": "يُكتب النص استنادًا إلى الخطة التسويقية (التموضع والأرقام المسموح بها). لا يُرسل شيء تلقائيًا: راجع ثم أرسل.",
+ "admin.marketing.channel": "القناة",
+ "admin.marketing.model": "النموذج",
+ "admin.marketing.modelPlaceholder": "افتراضي القناة",
+ "admin.marketing.promoOptional": "خصم (اختياري)",
+ "admin.marketing.promoNone": "بدون",
+ "admin.marketing.brief": "الملخص",
+ "admin.marketing.briefPlaceholder": "مثال: متابعة المسجلين في قائمة الانتظار والتنبيه لمزايا خطة Pro.",
+ "admin.marketing.language": "لغة الرسالة",
+ "admin.marketing.tone": "النبرة",
+ "admin.marketing.toneNone": "محايدة",
+ "admin.marketing.generateBtn": "توليد",
+ "admin.marketing.generating": "جارٍ التوليد...",
+ "admin.marketing.generatedTitle": "تم توليد الرسالة",
+ "admin.marketing.generatedDesc": "أُدرج الموضوع وHTML في النموذج — راجعه قبل الإرسال ({provider}).",
+ "admin.marketing.generateFailedTitle": "تعذّر التوليد",
+ "admin.marketing.briefRequired": "صِف أولًا المحتوى المطلوب في الملخص.",
+ "admin.marketing.langFr": "الفرنسية",
+ "admin.marketing.langEn": "الإنجليزية",
+ "admin.marketing.langDe": "الألمانية",
+ "admin.marketing.langEs": "الإسبانية",
+ "admin.marketing.langIt": "الإيطالية",
+ "admin.marketing.langPt": "البرتغالية",
+ "admin.marketing.langNl": "الهولندية",
+ "admin.marketing.langAr": "العربية",
+ "admin.marketing.langFa": "الفارسية",
+ "admin.marketing.langJa": "اليابانية",
+ "admin.marketing.langKo": "الكورية",
+ "admin.marketing.langRu": "الروسية",
+ "admin.marketing.langZh": "الصينية",
+ "admin.marketing.toneFriendly": "ودّية",
+ "admin.marketing.toneProfessional": "مهنية",
+ "admin.marketing.toneUrgent": "عاجلة",
+ "admin.marketing.toneFestive": "احتفالية",
+ "admin.marketing.promoTitle": "الخصومات",
+ "admin.marketing.promoDesc": "أكواد الخصم تُطبَّق على الدفع عبر Stripe؛ يرتفع العداد بعد اكتمال الاشتراك فعليًا.",
+ "admin.marketing.promoEmpty": "لا توجد أكواد خصم بعد.",
+ "admin.marketing.promoActive": "نشط",
+ "admin.marketing.promoInactive": "معطّل",
+ "admin.marketing.promoLocalOnly": "خارج Stripe",
+ "admin.marketing.promoDeactivate": "تعطيل",
+ "admin.marketing.promoUsage": "{used}/{max} استخدامًا",
+ "admin.marketing.promoPlansLabel": "الخطط:",
+ "admin.marketing.promoAllPlans": "الكل",
+ "admin.marketing.promoExpiresOn": "تنتهي في {date}",
+ "admin.marketing.promoNeverExpires": "بدون انتهاء",
+ "admin.marketing.promoCreateTitle": "إنشاء خصم",
+ "admin.marketing.promoCreateDesc": "يُنشأ الكود أيضًا في Stripe (كوبون + كود ترويجي) عندما يكون Stripe مضبوطًا.",
+ "admin.marketing.promoCode": "الكود",
+ "admin.marketing.promoType": "النوع",
+ "admin.marketing.promoTypePercent": "نسبة مئوية",
+ "admin.marketing.promoTypeAmount": "مبلغ (EUR)",
+ "admin.marketing.promoValue": "القيمة",
+ "admin.marketing.promoPlans": "الخطط المشمولة",
+ "admin.marketing.promoMaxRedemptions": "حد الاستخدام",
+ "admin.marketing.promoMaxPlaceholder": "غير محدود",
+ "admin.marketing.promoExpires": "تاريخ الانتهاء",
+ "admin.marketing.promoCreate": "إنشاء الكود",
+ "admin.marketing.promoCreating": "جارٍ الإنشاء...",
+ "admin.marketing.promoCreatedTitle": "تم إنشاء كود الخصم",
+ "admin.marketing.promoCreatedDesc": "الكود {code} جاهز.",
+ "admin.marketing.promoCreateFailedTitle": "رُفض الإنشاء",
+ "admin.marketing.promoInvalidInput": "تحقق من الكود والقيمة.",
+ "admin.marketing.promoDeactivatedTitle": "تم تعطيل الكود",
+ "admin.marketing.promoDeactivatedDesc": "الكود {code} لم يعد صالحًا.",
+ "admin.settings.fallback.title": "سلسلة الاحتياط",
+ "admin.settings.fallback.desc": "ترتيب الأولوية للاختيار التلقائي للمزودين.",
+ "admin.settings.fallback.classic": "الوضع الكلاسيكي (Google)",
+ "admin.settings.fallback.llm": "وضع LLM (البوابات والوصول المباشر)",
+ "admin.settings.fallback.note": "القنوات المسجلة فقط (google وopenai وdeepseek وminimax) تشارك في هذه السلسلة؛ مستويات الذكاء الاصطناعي والقنوات الأخرى لا تشارك.",
+ "admin.models.duplicateRouteTitle": "رُفض التكرار",
+ "admin.models.duplicateRouteDesc": "هذا الثنائي قناة + نموذج موجود في القائمة بالفعل.",
+ "admin.marketing.promoDeactivateFailedTitle": "رُفض التعطيل",
+ "admin.marketing.promoDelete": "حذف",
+ "admin.marketing.promoDeleteConfirm": "حذف الكود {code} نهائيًا؟",
+ "admin.marketing.promoDeletedTitle": "تم حذف الكود",
+ "admin.marketing.promoDeletedDesc": "تم حذف الكود {code}.",
+ "admin.marketing.promoDeleteFailedTitle": "رُفض الحذف",
+ "admin.marketing.promoLinkStripe": "ربط بـ Stripe",
+ "admin.marketing.promoLinkedTitle": "تم ربط الكود بـ Stripe",
+ "admin.marketing.promoLinkedDesc": "الكود {code} صار قابلًا للتطبيق على الدفع.",
+ "admin.marketing.promoLinkFailedTitle": "تعذّر الربط",
+ "admin.marketing.promoExpiryHint": "الكود «حتى العاشر» يبقى صالحًا طوال اليوم العاشر (حتى 23:59:59 بالتوقيت العالمي).",
+ "admin.marketing.generateEmpty": "كان رد الخادم فارغًا أو غير قابل للاستخدام."
}
diff --git a/frontend/src/lib/i18n/messages/ar/pricing.json b/frontend/src/lib/i18n/messages/ar/pricing.json
index fbb0739..c0ddf57 100644
--- a/frontend/src/lib/i18n/messages/ar/pricing.json
+++ b/frontend/src/lib/i18n/messages/ar/pricing.json
@@ -167,5 +167,17 @@
"pricing.header.titleBase": "خطة لكل",
"pricing.header.titleAccent": "احتياج",
"pricing.toast.close": "إغلاق",
- "pricing.aiModels.premium.price": "2 $ / 10 $ لكل مليون رمز"
+ "pricing.aiModels.premium.price": "2 $ / 10 $ لكل مليون رمز",
+ "pricing.promo.label": "كود الخصم",
+ "pricing.promo.placeholder": "كودك",
+ "pricing.promo.apply": "تطبيق",
+ "pricing.promo.checking": "جارٍ التحقق...",
+ "pricing.promo.valid": "كود صالح",
+ "pricing.promo.subscriptionOnly": "صالح على الاشتراكات، لا على الرصيد.",
+ "pricing.promoError.unknown": "كود الخصم هذا غير موجود.",
+ "pricing.promoError.inactive": "كود الخصم هذا لم يعد نشطًا.",
+ "pricing.promoError.expired": "انتهت صلاحية كود الخصم هذا.",
+ "pricing.promoError.exhausted": "بلغ كود الخصم هذا حده الأقصى للاستخدام.",
+ "pricing.promoError.plan": "كود الخصم هذا لا ينطبق على هذه الخطة.",
+ "pricing.promoError.local_only": "كود الخصم هذا غير مرتبط بالدفع عبر الإنترنت."
}
diff --git a/frontend/src/lib/i18n/messages/de/admin.json b/frontend/src/lib/i18n/messages/de/admin.json
index c499253..5e1d970 100644
--- a/frontend/src/lib/i18n/messages/de/admin.json
+++ b/frontend/src/lib/i18n/messages/de/admin.json
@@ -145,5 +145,180 @@
"admin.stats.tiersSub": "Basis / Premium · klassisch {classic} · sonstige {other}",
"admin.stats.unavailable": "Geschäftszahlen nicht verfügbar ({error}).",
"admin.stats.waitlist": "Warteliste",
- "admin.stats.waitlistSub": "Einträge auf der Warteliste"
+ "admin.stats.waitlistSub": "Einträge auf der Warteliste",
+ "admin.settings.title": "Anbieter",
+ "admin.settings.subtitle": "Anbieterzugänge konfigurieren. API-Schlüssel können auch in der .env-Datei hinterlegt werden.",
+ "admin.settings.sectionClassic": "Klassische Übersetzung",
+ "admin.settings.sectionClassicDesc": "Google-Maschinen für die Standardübersetzung.",
+ "admin.settings.sectionDirect": "Direkte KI-Anbieter",
+ "admin.settings.sectionDirectDesc": "Direkte OpenAI-kompatible Zugänge — als Kanal einer KI-Stufe (Seite „Modelle & Abos“) oder zum Erstellen von E-Mails nutzbar.",
+ "admin.settings.sectionGateway": "Multi-Modell-Gateways",
+ "admin.settings.sectionGatewayDesc": "Ein Schlüssel, hunderte Modelle. Die Modelle pro Stufe werden auf der Seite „Modelle & Abos“ verwaltet.",
+ "admin.settings.sectionOcr": "OCR (gescannte PDFs)",
+ "admin.settings.sectionOcrDesc": "Text aus Bildseiten vor der Übersetzung erfassen.",
+ "admin.settings.sectionEmails": "E-Mails",
+ "admin.settings.sectionEmailsDesc": "Versandserver für Benachrichtigungen und Erinnerungen.",
+ "admin.settings.enabled": "Aktiviert",
+ "admin.settings.disabled": "Deaktiviert",
+ "admin.settings.envBadge": "Schlüssel in .env",
+ "admin.settings.envConfigured": "In .env konfiguriert",
+ "admin.settings.envKeyPlaceholder": "Schlüssel in .env konfiguriert (leer lassen, um ihn zu nutzen)",
+ "admin.settings.apiKey": "API-Schlüssel",
+ "admin.settings.model": "Modell",
+ "admin.settings.baseUrl": "Basis-URL",
+ "admin.settings.test": "Testen",
+ "admin.settings.testing": "Test...",
+ "admin.settings.ok": "OK",
+ "admin.settings.error": "Fehler",
+ "admin.settings.failure": "Fehlgeschlagen",
+ "admin.settings.sending": "Wird gesendet...",
+ "admin.settings.save": "Konfiguration speichern",
+ "admin.settings.saving": "Speichern...",
+ "admin.settings.savedTitle": "✅ Konfiguration gespeichert",
+ "admin.settings.savedDesc": "Die Einstellungen wurden erfolgreich gespeichert.",
+ "admin.settings.saveErrorTitle": "Fehler beim Speichern",
+ "admin.settings.loadErrorTitle": "Fehler beim Laden",
+ "admin.settings.loadErrorDesc": "HTTP {status} — prüfen Sie Ihren Admin-Token.",
+ "admin.settings.networkErrorTitle": "Netzwerkfehler",
+ "admin.settings.networkErrorDesc": "Backend nicht erreichbar.",
+ "admin.settings.networkSaveErrorDesc": "Backend zum Speichern nicht erreichbar.",
+ "admin.settings.connectionOk": "Verbindung OK",
+ "admin.settings.modelsFound": "{count} {provider}-Modelle gefunden",
+ "admin.settings.providerError": "Fehler {provider}",
+ "admin.settings.google.title": "Google Translate",
+ "admin.settings.google.desc": "Inoffizieller Webzugriff über deep_translator. Kein Schlüssel nötig, maßvolle Nutzung empfohlen.",
+ "admin.settings.googleCloud.title": "Google Cloud Translation (offiziell)",
+ "admin.settings.googleCloud.desc": "Offizielle Google-Cloud-API. 500.000 Zeichen/Monat kostenlos, danach ca. 20 $ pro Million Zeichen. API-Schlüssel auf console.cloud.google.com.",
+ "admin.settings.googleCloud.hint": "Aktivieren Sie die Cloud Translation API in der Google Cloud Console und erstellen Sie einen darauf beschränkten API-Schlüssel.",
+ "admin.settings.deepseek.title": "DeepSeek (direkt)",
+ "admin.settings.deepseek.desc": "Direkte DeepSeek-API, OpenAI-kompatibel. Schlüssel auf platform.deepseek.com — als Kanal einer KI-Stufe nutzbar.",
+ "admin.settings.zhipu.title": "z.ai / Zhipu (GLM)",
+ "admin.settings.zhipu.desc": "Direkter Zugriff auf die GLM-Modelle von z.ai, OpenAI-kompatibel. Schlüssel auf z.ai — als Kanal einer KI-Stufe nutzbar.",
+ "admin.settings.zhipu.urlHint": "Standard: https://api.z.ai/api/paas/v4 — nur bei Nutzung eines Proxys ändern.",
+ "admin.settings.minimax.title": "MiniMax (direkt)",
+ "admin.settings.minimax.desc": "Öffentliche MiniMax-API, OpenAI-kompatibel. Schlüssel auf minimax.io — als Kanal einer KI-Stufe nutzbar.",
+ "admin.settings.openai.title": "OpenAI",
+ "admin.settings.openai.desc": "GPT-Übersetzungen. Schlüssel auf platform.openai.com erhalten.",
+ "admin.settings.zai.title": "xAI (Grok)",
+ "admin.settings.zai.desc": "Grok-Modelle von xAI. OpenAI-kompatible API. Schlüssel auf x.ai erhalten.",
+ "admin.settings.zai.urlHint": "Standard: https://api.x.ai/v1 — nur bei Nutzung eines Proxys ändern.",
+ "admin.settings.openrouter.title": "OpenRouter — Gateway-Schlüssel",
+ "admin.settings.openrouter.desc": "Essential-Stufe (Pro-Abo). Aktuelle Auswahl: deepseek/deepseek-v4-flash, z-ai/glm-5.3-flash, minimax/minimax-m3.",
+ "admin.settings.openrouter.keyLabel": "OpenRouter-API-Schlüssel (mit Premium geteilt)",
+ "admin.settings.openrouter.envSharedPlaceholder": "Schlüssel in .env konfiguriert (mit Premium geteilt)",
+ "admin.settings.openrouter.reminder": "Modelle werden auf der Seite „Modelle & Abos“ verwaltet.",
+ "admin.settings.openrouterPremium.title": "OpenRouter Premium — Gateway-Schlüssel",
+ "admin.settings.openrouterPremium.desc": "Premium-Stufe (Business-Abo). Aktuelle Auswahl: anthropic/claude-sonnet-5, deepseek/deepseek-v4-pro, z-ai/glm-5.3.",
+ "admin.settings.openrouterPremium.reminder": "Modelle werden auf der Seite „Modelle & Abos“ verwaltet. Nutzt denselben OpenRouter-Schlüssel.",
+ "admin.settings.mistral.title": "Mistral OCR (gescannte PDFs)",
+ "admin.settings.mistral.desc": "Extrahiert Text aus gescannten PDFs (Bildseiten) vor der Übersetzung. Ohne Schlüssel werden diese Dateien mit klarer Meldung abgelehnt. Ca. 1 € / 1.000 Seiten. Schlüssel: console.mistral.ai.",
+ "admin.settings.mistral.keyLabel": "Mistral-API-Schlüssel",
+ "admin.settings.mistral.modelLabel": "OCR-Modell",
+ "admin.settings.mistral.modelHint": "Empfohlen: mistral-ocr-latest",
+ "admin.settings.smtp.title": "SMTP-E-Mail",
+ "admin.settings.smtp.desc": "SMTP-Servereinstellungen für den E-Mail-Versand (Passwort vergessen, Benachrichtigungen, Erinnerungen).",
+ "admin.settings.smtp.host": "SMTP-Host",
+ "admin.settings.smtp.port": "Port",
+ "admin.settings.smtp.username": "Benutzername",
+ "admin.settings.smtp.password": "Passwort",
+ "admin.settings.smtp.fromEmail": "Absenderadresse",
+ "admin.settings.smtp.useTls": "TLS verwenden",
+ "admin.settings.smtp.sendTest": "Test-E-Mail senden",
+ "admin.settings.smtp.sent": "Gesendet",
+ "admin.settings.smtp.sendFailed": "Senden fehlgeschlagen",
+ "admin.models.channelOpenrouter": "OpenRouter",
+ "admin.models.channelOpenrouterPremium": "OpenRouter (Premium)",
+ "admin.models.channelDeepseek": "DeepSeek (direkt)",
+ "admin.models.channelZhipu": "z.ai (GLM)",
+ "admin.models.channelMinimax": "MiniMax (direkt)",
+ "admin.models.channelOpenai": "OpenAI",
+ "admin.models.channelZai": "xAI (Grok)",
+ "admin.models.customModelPlaceholder": "modell-id",
+ "admin.models.routeHint": "Der Kanal wählt direkten Zugang oder Gateway; Modell-IDs unterscheiden sich je Kanal.",
+ "admin.models.addRouteConfirm": "Hinzufügen",
+ "admin.marketing.generateTitle": "Mit KI erstellen",
+ "admin.marketing.generateDesc": "Der Text wird anhand des Marketingplans verfasst (Positionierung und zugelassene Zahlen). Nichts wird automatisch gesendet: erst prüfen, dann senden.",
+ "admin.marketing.channel": "Kanal",
+ "admin.marketing.model": "Modell",
+ "admin.marketing.modelPlaceholder": "Kanal-Standard",
+ "admin.marketing.promoOptional": "Rabatt (optional)",
+ "admin.marketing.promoNone": "Keine",
+ "admin.marketing.brief": "Briefing",
+ "admin.marketing.briefPlaceholder": "Z. B.: Wartelisten-Anmeldungen an die Vorteile des Pro-Abos erinnern.",
+ "admin.marketing.language": "E-Mail-Sprache",
+ "admin.marketing.tone": "Tonalität",
+ "admin.marketing.toneNone": "Neutral",
+ "admin.marketing.generateBtn": "Erstellen",
+ "admin.marketing.generating": "Wird erstellt...",
+ "admin.marketing.generatedTitle": "E-Mail erstellt",
+ "admin.marketing.generatedDesc": "Betreff und HTML ins Formular eingefügt — vor dem Versand prüfen ({provider}).",
+ "admin.marketing.generateFailedTitle": "Erstellung fehlgeschlagen",
+ "admin.marketing.briefRequired": "Beschreiben Sie zuerst den gewünschten Inhalt im Briefing.",
+ "admin.marketing.langFr": "Französisch",
+ "admin.marketing.langEn": "Englisch",
+ "admin.marketing.langDe": "Deutsch",
+ "admin.marketing.langEs": "Spanisch",
+ "admin.marketing.langIt": "Italienisch",
+ "admin.marketing.langPt": "Portugiesisch",
+ "admin.marketing.langNl": "Niederländisch",
+ "admin.marketing.langAr": "Arabisch",
+ "admin.marketing.langFa": "Persisch",
+ "admin.marketing.langJa": "Japanisch",
+ "admin.marketing.langKo": "Koreanisch",
+ "admin.marketing.langRu": "Russisch",
+ "admin.marketing.langZh": "Chinesisch",
+ "admin.marketing.toneFriendly": "Freundlich",
+ "admin.marketing.toneProfessional": "Professionell",
+ "admin.marketing.toneUrgent": "Dringend",
+ "admin.marketing.toneFestive": "Festlich",
+ "admin.marketing.promoTitle": "Rabatte",
+ "admin.marketing.promoDesc": "Promo-Codes werden auf Stripe-Zahlungen angewendet; der Zähler steigt erst, wenn das Abo wirklich abgeschlossen ist.",
+ "admin.marketing.promoEmpty": "Noch keine Promo-Codes.",
+ "admin.marketing.promoActive": "Aktiv",
+ "admin.marketing.promoInactive": "Deaktiviert",
+ "admin.marketing.promoLocalOnly": "Ohne Stripe",
+ "admin.marketing.promoDeactivate": "Deaktivieren",
+ "admin.marketing.promoUsage": "{used}/{max} Einlösungen",
+ "admin.marketing.promoPlansLabel": "Abos:",
+ "admin.marketing.promoAllPlans": "Alle",
+ "admin.marketing.promoExpiresOn": "läuft am {date} ab",
+ "admin.marketing.promoNeverExpires": "ohne Ablauf",
+ "admin.marketing.promoCreateTitle": "Rabatt erstellen",
+ "admin.marketing.promoCreateDesc": "Der Code wird auch in Stripe angelegt (Coupon + Promo-Code), wenn Stripe konfiguriert ist.",
+ "admin.marketing.promoCode": "Code",
+ "admin.marketing.promoType": "Typ",
+ "admin.marketing.promoTypePercent": "Prozent",
+ "admin.marketing.promoTypeAmount": "Betrag (EUR)",
+ "admin.marketing.promoValue": "Wert",
+ "admin.marketing.promoPlans": "Berechtigte Abos",
+ "admin.marketing.promoMaxRedemptions": "Einlöse-Limit",
+ "admin.marketing.promoMaxPlaceholder": "Unbegrenzt",
+ "admin.marketing.promoExpires": "Ablauf",
+ "admin.marketing.promoCreate": "Code erstellen",
+ "admin.marketing.promoCreating": "Wird erstellt...",
+ "admin.marketing.promoCreatedTitle": "Promo-Code erstellt",
+ "admin.marketing.promoCreatedDesc": "Der Code {code} ist bereit.",
+ "admin.marketing.promoCreateFailedTitle": "Erstellung abgelehnt",
+ "admin.marketing.promoInvalidInput": "Prüfen Sie Code und Wert.",
+ "admin.marketing.promoDeactivatedTitle": "Code deaktiviert",
+ "admin.marketing.promoDeactivatedDesc": "Der Code {code} gilt nicht mehr.",
+ "admin.settings.fallback.title": "Fallback-Kette",
+ "admin.settings.fallback.desc": "Prioritätsreihenfolge für die automatische Anbieterauswahl.",
+ "admin.settings.fallback.classic": "Klassischer Modus (Google)",
+ "admin.settings.fallback.llm": "LLM-Modus (Gateways und direkte Zugänge)",
+ "admin.settings.fallback.note": "Nur registrierte Kanäle (google, openai, deepseek, minimax) nehmen an dieser Kette teil; KI-Stufen und andere Kanäle nicht.",
+ "admin.models.duplicateRouteTitle": "Duplikat abgelehnt",
+ "admin.models.duplicateRouteDesc": "Dieses Kanal-+-Modell-Paar steht bereits in der Liste.",
+ "admin.marketing.promoDeactivateFailedTitle": "Deaktivierung abgelehnt",
+ "admin.marketing.promoDelete": "Löschen",
+ "admin.marketing.promoDeleteConfirm": "Code {code} endgültig löschen?",
+ "admin.marketing.promoDeletedTitle": "Code gelöscht",
+ "admin.marketing.promoDeletedDesc": "Der Code {code} wurde gelöscht.",
+ "admin.marketing.promoDeleteFailedTitle": "Löschen abgelehnt",
+ "admin.marketing.promoLinkStripe": "Mit Stripe verknüpfen",
+ "admin.marketing.promoLinkedTitle": "Code mit Stripe verknüpft",
+ "admin.marketing.promoLinkedDesc": "Der Code {code} ist jetzt beim Bezahlvorgang anwendbar.",
+ "admin.marketing.promoLinkFailedTitle": "Verknüpfung fehlgeschlagen",
+ "admin.marketing.promoExpiryHint": "Ein Code „bis zum 10.“ gilt den ganzen 10. (Ende: 23:59:59 UTC).",
+ "admin.marketing.generateEmpty": "Die Antwort des Servers war leer oder unbrauchbar."
}
diff --git a/frontend/src/lib/i18n/messages/de/pricing.json b/frontend/src/lib/i18n/messages/de/pricing.json
index 8ff2e3e..5a9dbf1 100644
--- a/frontend/src/lib/i18n/messages/de/pricing.json
+++ b/frontend/src/lib/i18n/messages/de/pricing.json
@@ -167,5 +167,17 @@
"pricing.header.titleBase": "Ein Plan für",
"pricing.header.titleAccent": "jeden Bedarf",
"pricing.toast.close": "Schließen",
- "pricing.aiModels.premium.price": "2 $ / 10 $ pro 1M Token"
+ "pricing.aiModels.premium.price": "2 $ / 10 $ pro 1M Token",
+ "pricing.promo.label": "Promo-Code",
+ "pricing.promo.placeholder": "DEINCODE",
+ "pricing.promo.apply": "Anwenden",
+ "pricing.promo.checking": "Prüfung...",
+ "pricing.promo.valid": "Code gültig",
+ "pricing.promo.subscriptionOnly": "Nur für Abos gültig, nicht für Credits.",
+ "pricing.promoError.unknown": "Dieser Promo-Code existiert nicht.",
+ "pricing.promoError.inactive": "Dieser Promo-Code ist nicht mehr aktiv.",
+ "pricing.promoError.expired": "Dieser Promo-Code ist abgelaufen.",
+ "pricing.promoError.exhausted": "Dieser Promo-Code hat sein Einlöse-Limit erreicht.",
+ "pricing.promoError.plan": "Dieser Promo-Code gilt nicht für dieses Abo.",
+ "pricing.promoError.local_only": "Dieser Promo-Code ist nicht für die Online-Zahlung freigeschaltet."
}
diff --git a/frontend/src/lib/i18n/messages/en/admin.json b/frontend/src/lib/i18n/messages/en/admin.json
index abacd23..c1ab8db 100644
--- a/frontend/src/lib/i18n/messages/en/admin.json
+++ b/frontend/src/lib/i18n/messages/en/admin.json
@@ -145,5 +145,180 @@
"admin.stats.tiersSub": "Essential / Premium · classic {classic} · other {other}",
"admin.stats.unavailable": "Business statistics unavailable ({error}).",
"admin.stats.waitlist": "Waitlist",
- "admin.stats.waitlistSub": "people waiting"
+ "admin.stats.waitlistSub": "people waiting",
+ "admin.settings.title": "Providers",
+ "admin.settings.subtitle": "Configure provider access. API keys can also be set in the .env file.",
+ "admin.settings.sectionClassic": "Classic translation",
+ "admin.settings.sectionClassicDesc": "Google engines for standard translation.",
+ "admin.settings.sectionDirect": "Direct AI providers",
+ "admin.settings.sectionDirectDesc": "Direct OpenAI-compatible endpoints — usable as an AI tier channel (see the “Models & subscriptions” page) or for email generation.",
+ "admin.settings.sectionGateway": "Multi-model gateways",
+ "admin.settings.sectionGatewayDesc": "One key, hundreds of models. Per-tier models are managed on the “Models & subscriptions” page.",
+ "admin.settings.sectionOcr": "OCR (scanned PDFs)",
+ "admin.settings.sectionOcrDesc": "Extract text from image-only pages before translation.",
+ "admin.settings.sectionEmails": "Emails",
+ "admin.settings.sectionEmailsDesc": "Sending server for notifications and follow-up emails.",
+ "admin.settings.enabled": "Enabled",
+ "admin.settings.disabled": "Disabled",
+ "admin.settings.envBadge": "Key in .env",
+ "admin.settings.envConfigured": "Configured in .env",
+ "admin.settings.envKeyPlaceholder": "Key configured in .env (leave empty to use it)",
+ "admin.settings.apiKey": "API key",
+ "admin.settings.model": "Model",
+ "admin.settings.baseUrl": "Base URL",
+ "admin.settings.test": "Test",
+ "admin.settings.testing": "Testing...",
+ "admin.settings.ok": "OK",
+ "admin.settings.error": "Error",
+ "admin.settings.failure": "Failed",
+ "admin.settings.sending": "Sending...",
+ "admin.settings.save": "Save configuration",
+ "admin.settings.saving": "Saving...",
+ "admin.settings.savedTitle": "✅ Configuration saved",
+ "admin.settings.savedDesc": "Your settings have been saved successfully.",
+ "admin.settings.saveErrorTitle": "Save failed",
+ "admin.settings.loadErrorTitle": "Loading error",
+ "admin.settings.loadErrorDesc": "HTTP {status} — check your admin token.",
+ "admin.settings.networkErrorTitle": "Network error",
+ "admin.settings.networkErrorDesc": "Cannot reach the backend.",
+ "admin.settings.networkSaveErrorDesc": "Cannot reach the backend to save.",
+ "admin.settings.connectionOk": "Connection OK",
+ "admin.settings.modelsFound": "{count} {provider} models found",
+ "admin.settings.providerError": "{provider} error",
+ "admin.settings.google.title": "Google Translate",
+ "admin.settings.google.desc": "Unofficial web access via deep_translator. No key required, reasonable use recommended.",
+ "admin.settings.googleCloud.title": "Google Cloud Translation (official)",
+ "admin.settings.googleCloud.desc": "Official Google Cloud API. 500,000 characters/month free, then about $20 per million characters. API key from console.cloud.google.com.",
+ "admin.settings.googleCloud.hint": "Enable the Cloud Translation API in Google Cloud Console, then create an API key restricted to that API.",
+ "admin.settings.deepseek.title": "DeepSeek (direct)",
+ "admin.settings.deepseek.desc": "Direct DeepSeek API, OpenAI-compatible. Key from platform.deepseek.com — usable as an AI tier channel.",
+ "admin.settings.zhipu.title": "z.ai / Zhipu (GLM)",
+ "admin.settings.zhipu.desc": "Direct access to z.ai GLM models, OpenAI-compatible. Key from z.ai — usable as an AI tier channel.",
+ "admin.settings.zhipu.urlHint": "Default: https://api.z.ai/api/paas/v4 — change only if you use a proxy.",
+ "admin.settings.minimax.title": "MiniMax (direct)",
+ "admin.settings.minimax.desc": "Public MiniMax API, OpenAI-compatible. Key from minimax.io — usable as an AI tier channel.",
+ "admin.settings.openai.title": "OpenAI",
+ "admin.settings.openai.desc": "GPT translations. Get a key at platform.openai.com.",
+ "admin.settings.zai.title": "xAI (Grok)",
+ "admin.settings.zai.desc": "Grok models by xAI. OpenAI-compatible API. Get your key at x.ai.",
+ "admin.settings.zai.urlHint": "Default: https://api.x.ai/v1 — change only if you use a proxy.",
+ "admin.settings.openrouter.title": "OpenRouter — gateway key",
+ "admin.settings.openrouter.desc": "Essential tier (Pro plan). Current range: deepseek/deepseek-v4-flash, z-ai/glm-5.3-flash, minimax/minimax-m3.",
+ "admin.settings.openrouter.keyLabel": "OpenRouter API key (shared with Premium)",
+ "admin.settings.openrouter.envSharedPlaceholder": "Key configured in .env (shared with Premium)",
+ "admin.settings.openrouter.reminder": "Models are managed on the “Models & subscriptions” page.",
+ "admin.settings.openrouterPremium.title": "OpenRouter Premium — gateway key",
+ "admin.settings.openrouterPremium.desc": "Premium tier (Business plan). Current range: anthropic/claude-sonnet-5, deepseek/deepseek-v4-pro, z-ai/glm-5.3.",
+ "admin.settings.openrouterPremium.reminder": "Models are managed on the “Models & subscriptions” page. Shares the same OpenRouter key.",
+ "admin.settings.mistral.title": "Mistral OCR (scanned PDFs)",
+ "admin.settings.mistral.desc": "Extracts text from scanned PDFs (image pages) before translation. Without a key, such files are rejected with a clear message. About €1 / 1,000 pages. Key: console.mistral.ai.",
+ "admin.settings.mistral.keyLabel": "Mistral API key",
+ "admin.settings.mistral.modelLabel": "OCR model",
+ "admin.settings.mistral.modelHint": "Recommended: mistral-ocr-latest",
+ "admin.settings.smtp.title": "SMTP email",
+ "admin.settings.smtp.desc": "SMTP server settings for sending emails (password reset, notifications, follow-ups).",
+ "admin.settings.smtp.host": "SMTP host",
+ "admin.settings.smtp.port": "Port",
+ "admin.settings.smtp.username": "Username",
+ "admin.settings.smtp.password": "Password",
+ "admin.settings.smtp.fromEmail": "From address",
+ "admin.settings.smtp.useTls": "Use TLS",
+ "admin.settings.smtp.sendTest": "Send a test email",
+ "admin.settings.smtp.sent": "Sent",
+ "admin.settings.smtp.sendFailed": "Send failed",
+ "admin.models.channelOpenrouter": "OpenRouter",
+ "admin.models.channelOpenrouterPremium": "OpenRouter (Premium)",
+ "admin.models.channelDeepseek": "DeepSeek (direct)",
+ "admin.models.channelZhipu": "z.ai (GLM)",
+ "admin.models.channelMinimax": "MiniMax (direct)",
+ "admin.models.channelOpenai": "OpenAI",
+ "admin.models.channelZai": "xAI (Grok)",
+ "admin.models.customModelPlaceholder": "model-id",
+ "admin.models.routeHint": "The channel picks a direct access or the gateway; model IDs differ from one channel to another.",
+ "admin.models.addRouteConfirm": "Add",
+ "admin.marketing.generateTitle": "Generate with AI",
+ "admin.marketing.generateDesc": "The copy is written from the marketing plan (positioning and approved figures). Nothing is sent automatically: review, then send.",
+ "admin.marketing.channel": "Channel",
+ "admin.marketing.model": "Model",
+ "admin.marketing.modelPlaceholder": "Channel default",
+ "admin.marketing.promoOptional": "Discount (optional)",
+ "admin.marketing.promoNone": "None",
+ "admin.marketing.brief": "Brief",
+ "admin.marketing.briefPlaceholder": "E.g.: follow up with waitlist sign-ups, reminding them of the Pro plan benefits.",
+ "admin.marketing.language": "Email language",
+ "admin.marketing.tone": "Tone",
+ "admin.marketing.toneNone": "Neutral",
+ "admin.marketing.generateBtn": "Generate",
+ "admin.marketing.generating": "Generating...",
+ "admin.marketing.generatedTitle": "Email generated",
+ "admin.marketing.generatedDesc": "Subject and HTML inserted into the form — review before sending ({provider}).",
+ "admin.marketing.generateFailedTitle": "Generation failed",
+ "admin.marketing.briefRequired": "Describe the desired content in the brief first.",
+ "admin.marketing.langFr": "French",
+ "admin.marketing.langEn": "English",
+ "admin.marketing.langDe": "German",
+ "admin.marketing.langEs": "Spanish",
+ "admin.marketing.langIt": "Italian",
+ "admin.marketing.langPt": "Portuguese",
+ "admin.marketing.langNl": "Dutch",
+ "admin.marketing.langAr": "Arabic",
+ "admin.marketing.langFa": "Persian",
+ "admin.marketing.langJa": "Japanese",
+ "admin.marketing.langKo": "Korean",
+ "admin.marketing.langRu": "Russian",
+ "admin.marketing.langZh": "Chinese",
+ "admin.marketing.toneFriendly": "Friendly",
+ "admin.marketing.toneProfessional": "Professional",
+ "admin.marketing.toneUrgent": "Urgent",
+ "admin.marketing.toneFestive": "Festive",
+ "admin.marketing.promoTitle": "Discounts",
+ "admin.marketing.promoDesc": "Promo codes applied to Stripe payments; the counter increases once the subscription is actually completed.",
+ "admin.marketing.promoEmpty": "No promo codes yet.",
+ "admin.marketing.promoActive": "Active",
+ "admin.marketing.promoInactive": "Deactivated",
+ "admin.marketing.promoLocalOnly": "Outside Stripe",
+ "admin.marketing.promoDeactivate": "Deactivate",
+ "admin.marketing.promoUsage": "{used}/{max} redemptions",
+ "admin.marketing.promoPlansLabel": "Plans:",
+ "admin.marketing.promoAllPlans": "All",
+ "admin.marketing.promoExpiresOn": "expires {date}",
+ "admin.marketing.promoNeverExpires": "no expiry",
+ "admin.marketing.promoCreateTitle": "Create a discount",
+ "admin.marketing.promoCreateDesc": "The code is also created in Stripe (coupon + promotion code) when Stripe is configured.",
+ "admin.marketing.promoCode": "Code",
+ "admin.marketing.promoType": "Type",
+ "admin.marketing.promoTypePercent": "Percentage",
+ "admin.marketing.promoTypeAmount": "Amount (EUR)",
+ "admin.marketing.promoValue": "Value",
+ "admin.marketing.promoPlans": "Eligible plans",
+ "admin.marketing.promoMaxRedemptions": "Redemption limit",
+ "admin.marketing.promoMaxPlaceholder": "Unlimited",
+ "admin.marketing.promoExpires": "Expiry",
+ "admin.marketing.promoCreate": "Create code",
+ "admin.marketing.promoCreating": "Creating...",
+ "admin.marketing.promoCreatedTitle": "Promo code created",
+ "admin.marketing.promoCreatedDesc": "Code {code} is ready.",
+ "admin.marketing.promoCreateFailedTitle": "Creation refused",
+ "admin.marketing.promoInvalidInput": "Check the code and the value.",
+ "admin.marketing.promoDeactivatedTitle": "Code deactivated",
+ "admin.marketing.promoDeactivatedDesc": "Code {code} no longer applies.",
+ "admin.settings.fallback.title": "Fallback chain",
+ "admin.settings.fallback.desc": "Priority order for automatic provider selection.",
+ "admin.settings.fallback.classic": "Classic mode (Google)",
+ "admin.settings.fallback.llm": "LLM mode (gateways and direct accesses)",
+ "admin.settings.fallback.note": "Only registered engines (google, openai, deepseek, minimax) take part in this chain; AI tiers and other channels do not.",
+ "admin.models.duplicateRouteTitle": "Duplicate refused",
+ "admin.models.duplicateRouteDesc": "This channel + model pair is already in the list.",
+ "admin.marketing.promoDeactivateFailedTitle": "Deactivation refused",
+ "admin.marketing.promoDelete": "Delete",
+ "admin.marketing.promoDeleteConfirm": "Permanently delete code {code}?",
+ "admin.marketing.promoDeletedTitle": "Code deleted",
+ "admin.marketing.promoDeletedDesc": "Code {code} has been deleted.",
+ "admin.marketing.promoDeleteFailedTitle": "Deletion refused",
+ "admin.marketing.promoLinkStripe": "Link to Stripe",
+ "admin.marketing.promoLinkedTitle": "Code linked to Stripe",
+ "admin.marketing.promoLinkedDesc": "Code {code} can now be applied at checkout.",
+ "admin.marketing.promoLinkFailedTitle": "Linking failed",
+ "admin.marketing.promoExpiryHint": "A code “until the 10th” stays valid through the 10th (ends 23:59:59 UTC).",
+ "admin.marketing.generateEmpty": "The server response was empty or unusable."
}
diff --git a/frontend/src/lib/i18n/messages/en/pricing.json b/frontend/src/lib/i18n/messages/en/pricing.json
index 749f622..6cc3795 100644
--- a/frontend/src/lib/i18n/messages/en/pricing.json
+++ b/frontend/src/lib/i18n/messages/en/pricing.json
@@ -167,5 +167,17 @@
"pricing.aiModels.essential.price": "From $0.09 per million tokens",
"pricing.aiModels.premium.modelName": "Claude 5",
"pricing.aiModels.premium.alternatives": "Alternatives: DeepSeek V4 Pro and GLM-5.3",
- "pricing.aiModels.premium.price": "$2 / $10 per 1M tokens"
+ "pricing.aiModels.premium.price": "$2 / $10 per 1M tokens",
+ "pricing.promo.label": "Promo code",
+ "pricing.promo.placeholder": "YOURCODE",
+ "pricing.promo.apply": "Apply",
+ "pricing.promo.checking": "Checking...",
+ "pricing.promo.valid": "Valid code",
+ "pricing.promo.subscriptionOnly": "Valid on subscriptions only, not on credits.",
+ "pricing.promoError.unknown": "This promo code does not exist.",
+ "pricing.promoError.inactive": "This promo code is no longer active.",
+ "pricing.promoError.expired": "This promo code has expired.",
+ "pricing.promoError.exhausted": "This promo code has reached its redemption limit.",
+ "pricing.promoError.plan": "This promo code does not apply to this plan.",
+ "pricing.promoError.local_only": "This promo code is not valid for online payment."
}
diff --git a/frontend/src/lib/i18n/messages/es/admin.json b/frontend/src/lib/i18n/messages/es/admin.json
index 0582ad4..ee064d8 100644
--- a/frontend/src/lib/i18n/messages/es/admin.json
+++ b/frontend/src/lib/i18n/messages/es/admin.json
@@ -145,5 +145,180 @@
"admin.stats.tiersSub": "Esencial / Premium · clásico {classic} · otro {other}",
"admin.stats.unavailable": "Estadísticas de negocio no disponibles ({error}).",
"admin.stats.waitlist": "Lista de espera",
- "admin.stats.waitlistSub": "inscritos en espera"
+ "admin.stats.waitlistSub": "inscritos en espera",
+ "admin.settings.title": "Proveedores",
+ "admin.settings.subtitle": "Configura los accesos de proveedores. Las claves API también pueden definirse en el archivo .env.",
+ "admin.settings.sectionClassic": "Traducción clásica",
+ "admin.settings.sectionClassicDesc": "Motores de Google para la traducción estándar.",
+ "admin.settings.sectionDirect": "Proveedores de IA directos",
+ "admin.settings.sectionDirectDesc": "Accesos directos compatibles con OpenAI — utilizables como canal de un nivel de IA (página «Modelos y suscripciones») o para generar correos.",
+ "admin.settings.sectionGateway": "Pasarelas multiplano",
+ "admin.settings.sectionGatewayDesc": "Una sola clave, cientos de modelos. Los modelos por nivel se gestionan en la página «Modelos y suscripciones».",
+ "admin.settings.sectionOcr": "OCR (PDF escaneados)",
+ "admin.settings.sectionOcrDesc": "Extracción del texto de las páginas de imagen antes de traducir.",
+ "admin.settings.sectionEmails": "Correos",
+ "admin.settings.sectionEmailsDesc": "Servidor de envío para notificaciones y reenganches.",
+ "admin.settings.enabled": "Activado",
+ "admin.settings.disabled": "Desactivado",
+ "admin.settings.envBadge": "Clave en .env",
+ "admin.settings.envConfigured": "Configurado en .env",
+ "admin.settings.envKeyPlaceholder": "Clave configurada en .env (déjela vacía para usarla)",
+ "admin.settings.apiKey": "Clave API",
+ "admin.settings.model": "Modelo",
+ "admin.settings.baseUrl": "URL base",
+ "admin.settings.test": "Probar",
+ "admin.settings.testing": "Prueba...",
+ "admin.settings.ok": "OK",
+ "admin.settings.error": "Error",
+ "admin.settings.failure": "Fallo",
+ "admin.settings.sending": "Enviando...",
+ "admin.settings.save": "Guardar la configuración",
+ "admin.settings.saving": "Guardando...",
+ "admin.settings.savedTitle": "✅ Configuración guardada",
+ "admin.settings.savedDesc": "Los parámetros se han guardado correctamente.",
+ "admin.settings.saveErrorTitle": "Error al guardar",
+ "admin.settings.loadErrorTitle": "Error de carga",
+ "admin.settings.loadErrorDesc": "HTTP {status} — verifique su token de administrador.",
+ "admin.settings.networkErrorTitle": "Error de red",
+ "admin.settings.networkErrorDesc": "No se puede contactar con el backend.",
+ "admin.settings.networkSaveErrorDesc": "No se puede contactar con el backend para guardar.",
+ "admin.settings.connectionOk": "Conexión correcta",
+ "admin.settings.modelsFound": "{count} modelos de {provider} encontrados",
+ "admin.settings.providerError": "Error {provider}",
+ "admin.settings.google.title": "Google Translate",
+ "admin.settings.google.desc": "Acceso web no oficial vía deep_translator. No requiere clave; se recomienda un uso razonable.",
+ "admin.settings.googleCloud.title": "Google Cloud Translation (oficial)",
+ "admin.settings.googleCloud.desc": "API oficial de Google Cloud. 500 000 caracteres/mes gratis, luego unos 20 $ por millón de caracteres. Clave API en console.cloud.google.com.",
+ "admin.settings.googleCloud.hint": "Active Cloud Translation API en Google Cloud Console y cree una clave API restringida a esa API.",
+ "admin.settings.deepseek.title": "DeepSeek (directo)",
+ "admin.settings.deepseek.desc": "API directa de DeepSeek, compatible con OpenAI. Clave en platform.deepseek.com — utilizable como canal de un nivel de IA.",
+ "admin.settings.zhipu.title": "z.ai / Zhipu (GLM)",
+ "admin.settings.zhipu.desc": "Acceso directo a los modelos GLM de z.ai, compatible con OpenAI. Clave en z.ai — utilizable como canal de un nivel de IA.",
+ "admin.settings.zhipu.urlHint": "Por defecto: https://api.z.ai/api/paas/v4 — cámbielo solo si usa un proxy.",
+ "admin.settings.minimax.title": "MiniMax (directo)",
+ "admin.settings.minimax.desc": "API pública de MiniMax, compatible con OpenAI. Clave en minimax.io — utilizable como canal de un nivel de IA.",
+ "admin.settings.openai.title": "OpenAI",
+ "admin.settings.openai.desc": "Traducciones GPT. Obtenga una clave en platform.openai.com.",
+ "admin.settings.zai.title": "xAI (Grok)",
+ "admin.settings.zai.desc": "Modelos Grok de xAI. API compatible con OpenAI. Obtenga su clave en x.ai.",
+ "admin.settings.zai.urlHint": "Por defecto: https://api.x.ai/v1 — cámbielo solo si usa un proxy.",
+ "admin.settings.openrouter.title": "OpenRouter — clave de pasarela",
+ "admin.settings.openrouter.desc": "Nivel Esencial (plan Pro). Gama actual: deepseek/deepseek-v4-flash, z-ai/glm-5.3-flash, minimax/minimax-m3.",
+ "admin.settings.openrouter.keyLabel": "Clave API de OpenRouter (compartida con Premium)",
+ "admin.settings.openrouter.envSharedPlaceholder": "Clave configurada en .env (compartida con Premium)",
+ "admin.settings.openrouter.reminder": "Modelos gestionados en la página «Modelos y suscripciones».",
+ "admin.settings.openrouterPremium.title": "OpenRouter Premium — clave de pasarela",
+ "admin.settings.openrouterPremium.desc": "Nivel Premium (plan Business). Gama actual: anthropic/claude-sonnet-5, deepseek/deepseek-v4-pro, z-ai/glm-5.3.",
+ "admin.settings.openrouterPremium.reminder": "Modelos gestionados en la página «Modelos y suscripciones». Comparte la misma clave de OpenRouter.",
+ "admin.settings.mistral.title": "OCR Mistral (PDF escaneados)",
+ "admin.settings.mistral.desc": "Extrae el texto de los PDF escaneados (páginas de imagen) antes de traducir. Sin clave, estos archivos se rechazan con un mensaje claro. Unos 1 € / 1 000 páginas. Clave: console.mistral.ai.",
+ "admin.settings.mistral.keyLabel": "Clave API de Mistral",
+ "admin.settings.mistral.modelLabel": "Modelo OCR",
+ "admin.settings.mistral.modelHint": "Recomendado: mistral-ocr-latest",
+ "admin.settings.smtp.title": "Correo SMTP",
+ "admin.settings.smtp.desc": "Configuración del servidor SMTP para el envío de correos (contraseña olvidada, notificaciones, reenganches).",
+ "admin.settings.smtp.host": "Host SMTP",
+ "admin.settings.smtp.port": "Puerto",
+ "admin.settings.smtp.username": "Nombre de usuario",
+ "admin.settings.smtp.password": "Contraseña",
+ "admin.settings.smtp.fromEmail": "Dirección de envío",
+ "admin.settings.smtp.useTls": "Usar TLS",
+ "admin.settings.smtp.sendTest": "Enviar un correo de prueba",
+ "admin.settings.smtp.sent": "Enviado",
+ "admin.settings.smtp.sendFailed": "Fallo en el envío",
+ "admin.models.channelOpenrouter": "OpenRouter",
+ "admin.models.channelOpenrouterPremium": "OpenRouter (Premium)",
+ "admin.models.channelDeepseek": "DeepSeek (directo)",
+ "admin.models.channelZhipu": "z.ai (GLM)",
+ "admin.models.channelMinimax": "MiniMax (directo)",
+ "admin.models.channelOpenai": "OpenAI",
+ "admin.models.channelZai": "xAI (Grok)",
+ "admin.models.customModelPlaceholder": "id-del-modelo",
+ "admin.models.routeHint": "El canal elige el acceso directo o la pasarela; los identificadores de modelos difieren según el canal.",
+ "admin.models.addRouteConfirm": "Añadir",
+ "admin.marketing.generateTitle": "Generar con IA",
+ "admin.marketing.generateDesc": "El texto se redacta a partir del plan de marketing (posicionamiento y cifras autorizadas). No se envía nada automáticamente: revise y envíe.",
+ "admin.marketing.channel": "Canal",
+ "admin.marketing.model": "Modelo",
+ "admin.marketing.modelPlaceholder": "Predeterminado del canal",
+ "admin.marketing.promoOptional": "Descuento (opcional)",
+ "admin.marketing.promoNone": "Ninguno",
+ "admin.marketing.brief": "Brief",
+ "admin.marketing.briefPlaceholder": "Ej.: reenganchear a los inscritos en la lista de espera recordando las ventajas del plan Pro.",
+ "admin.marketing.language": "Idioma del correo",
+ "admin.marketing.tone": "Tono",
+ "admin.marketing.toneNone": "Neutro",
+ "admin.marketing.generateBtn": "Generar",
+ "admin.marketing.generating": "Generando...",
+ "admin.marketing.generatedTitle": "Correo generado",
+ "admin.marketing.generatedDesc": "Asunto y HTML insertados en el formulario — revise antes de enviar ({provider}).",
+ "admin.marketing.generateFailedTitle": "No se pudo generar",
+ "admin.marketing.briefRequired": "Describa primero el contenido deseado en el brief.",
+ "admin.marketing.langFr": "Francés",
+ "admin.marketing.langEn": "Inglés",
+ "admin.marketing.langDe": "Alemán",
+ "admin.marketing.langEs": "Español",
+ "admin.marketing.langIt": "Italiano",
+ "admin.marketing.langPt": "Portugués",
+ "admin.marketing.langNl": "Neerlandés",
+ "admin.marketing.langAr": "Árabe",
+ "admin.marketing.langFa": "Persa",
+ "admin.marketing.langJa": "Japonés",
+ "admin.marketing.langKo": "Coreano",
+ "admin.marketing.langRu": "Ruso",
+ "admin.marketing.langZh": "Chino",
+ "admin.marketing.toneFriendly": "Cercano",
+ "admin.marketing.toneProfessional": "Profesional",
+ "admin.marketing.toneUrgent": "Urgente",
+ "admin.marketing.toneFestive": "Festivo",
+ "admin.marketing.promoTitle": "Descuentos",
+ "admin.marketing.promoDesc": "Códigos promo aplicados al pago mediante Stripe; el contador aumenta cuando la suscripción se completa de verdad.",
+ "admin.marketing.promoEmpty": "Aún no hay códigos promo.",
+ "admin.marketing.promoActive": "Activo",
+ "admin.marketing.promoInactive": "Desactivado",
+ "admin.marketing.promoLocalOnly": "Fuera de Stripe",
+ "admin.marketing.promoDeactivate": "Desactivar",
+ "admin.marketing.promoUsage": "{used}/{max} usos",
+ "admin.marketing.promoPlansLabel": "Planes:",
+ "admin.marketing.promoAllPlans": "Todos",
+ "admin.marketing.promoExpiresOn": "caduca el {date}",
+ "admin.marketing.promoNeverExpires": "sin caducidad",
+ "admin.marketing.promoCreateTitle": "Crear un descuento",
+ "admin.marketing.promoCreateDesc": "El código también se crea en Stripe (cupón + código promocional) cuando Stripe está configurado.",
+ "admin.marketing.promoCode": "Código",
+ "admin.marketing.promoType": "Tipo",
+ "admin.marketing.promoTypePercent": "Porcentaje",
+ "admin.marketing.promoTypeAmount": "Importe (EUR)",
+ "admin.marketing.promoValue": "Valor",
+ "admin.marketing.promoPlans": "Planes elegibles",
+ "admin.marketing.promoMaxRedemptions": "Límite de usos",
+ "admin.marketing.promoMaxPlaceholder": "Ilimitado",
+ "admin.marketing.promoExpires": "Caducidad",
+ "admin.marketing.promoCreate": "Crear el código",
+ "admin.marketing.promoCreating": "Creando...",
+ "admin.marketing.promoCreatedTitle": "Código promo creado",
+ "admin.marketing.promoCreatedDesc": "El código {code} está listo.",
+ "admin.marketing.promoCreateFailedTitle": "Creación rechazada",
+ "admin.marketing.promoInvalidInput": "Verifique el código y el valor.",
+ "admin.marketing.promoDeactivatedTitle": "Código desactivado",
+ "admin.marketing.promoDeactivatedDesc": "El código {code} ya no se aplica.",
+ "admin.settings.fallback.title": "Cadena de respaldo",
+ "admin.settings.fallback.desc": "Orden de prioridad para la selección automática de proveedores.",
+ "admin.settings.fallback.classic": "Modo clásico (Google)",
+ "admin.settings.fallback.llm": "Modo LLM (pasarelas y accesos directos)",
+ "admin.settings.fallback.note": "Solo los canales registrados (google, openai, deepseek, minimax) participan en esta cadena; los niveles de IA y los demás canales no.",
+ "admin.models.duplicateRouteTitle": "Duplicado rechazado",
+ "admin.models.duplicateRouteDesc": "Ese par canal + modelo ya está en la lista.",
+ "admin.marketing.promoDeactivateFailedTitle": "Desactivación rechazada",
+ "admin.marketing.promoDelete": "Eliminar",
+ "admin.marketing.promoDeleteConfirm": "¿Eliminar definitivamente el código {code}?",
+ "admin.marketing.promoDeletedTitle": "Código eliminado",
+ "admin.marketing.promoDeletedDesc": "El código {code} ha sido eliminado.",
+ "admin.marketing.promoDeleteFailedTitle": "Eliminación rechazada",
+ "admin.marketing.promoLinkStripe": "Vincular a Stripe",
+ "admin.marketing.promoLinkedTitle": "Código vinculado a Stripe",
+ "admin.marketing.promoLinkedDesc": "El código {code} ya se puede aplicar al pago.",
+ "admin.marketing.promoLinkFailedTitle": "No se pudo vincular",
+ "admin.marketing.promoExpiryHint": "Un código «hasta el 10» sigue siendo válido todo el 10 (fin: 23:59:59 UTC).",
+ "admin.marketing.generateEmpty": "La respuesta del servidor estaba vacía o no era utilizable."
}
diff --git a/frontend/src/lib/i18n/messages/es/pricing.json b/frontend/src/lib/i18n/messages/es/pricing.json
index 978630c..7031258 100644
--- a/frontend/src/lib/i18n/messages/es/pricing.json
+++ b/frontend/src/lib/i18n/messages/es/pricing.json
@@ -167,5 +167,17 @@
"pricing.header.titleBase": "Un plan para",
"pricing.header.titleAccent": "cada necesidad",
"pricing.toast.close": "Cerrar",
- "pricing.aiModels.premium.price": "2 $ / 10 $ por 1M de tokens"
+ "pricing.aiModels.premium.price": "2 $ / 10 $ por 1M de tokens",
+ "pricing.promo.label": "Código promo",
+ "pricing.promo.placeholder": "TUCODIGO",
+ "pricing.promo.apply": "Aplicar",
+ "pricing.promo.checking": "Verificando...",
+ "pricing.promo.valid": "Código válido",
+ "pricing.promo.subscriptionOnly": "Válido en suscripciones, no en créditos.",
+ "pricing.promoError.unknown": "Este código promo no existe.",
+ "pricing.promoError.inactive": "Este código promo ya no está activo.",
+ "pricing.promoError.expired": "Este código promo ha caducado.",
+ "pricing.promoError.exhausted": "Este código promo ha alcanzado su límite de usos.",
+ "pricing.promoError.plan": "Este código promo no es válido para este plan.",
+ "pricing.promoError.local_only": "Este código promo no está habilitado para el pago en línea."
}
diff --git a/frontend/src/lib/i18n/messages/fa/admin.json b/frontend/src/lib/i18n/messages/fa/admin.json
index 9afe0dd..7f20ef3 100644
--- a/frontend/src/lib/i18n/messages/fa/admin.json
+++ b/frontend/src/lib/i18n/messages/fa/admin.json
@@ -145,5 +145,180 @@
"admin.stats.tiersSub": "پایه / پیشرفته · کلاسیک {classic} · سایر {other}",
"admin.stats.unavailable": "آمار کسبوکار در دسترس نیست ({error}).",
"admin.stats.waitlist": "فهرست انتظار",
- "admin.stats.waitlistSub": "نفر در انتظار"
+ "admin.stats.waitlistSub": "نفر در انتظار",
+ "admin.settings.title": "ارائهدهندگان",
+ "admin.settings.subtitle": "دسترسی ارائهدهندگان را تنظیم کنید. کلیدهای API را میتوان در فایل .env نیز قرار داد.",
+ "admin.settings.sectionClassic": "ترجمه کلاسیک",
+ "admin.settings.sectionClassicDesc": "موتورهای Google برای ترجمه استاندارد.",
+ "admin.settings.sectionDirect": "ارائهدهندگان مستقیم هوش مصنوعی",
+ "admin.settings.sectionDirectDesc": "دسترسیهای مستقیم سازگار با OpenAI — بهعنوان کانال یک سطح هوش مصنوعی (صفحه «مدلها و اشتراکها») یا برای تولید ایمیل قابل استفادهاند.",
+ "admin.settings.sectionGateway": "درگاههای چندمدلی",
+ "admin.settings.sectionGatewayDesc": "یک کلید، صدها مدل. مدلهای هر سطح در صفحه «مدلها و اشتراکها» مدیریت میشوند.",
+ "admin.settings.sectionOcr": "OCR (PDFهای اسکنشده)",
+ "admin.settings.sectionOcrDesc": "استخراج متن صفحات تصویری پیش از ترجمه.",
+ "admin.settings.sectionEmails": "ایمیلها",
+ "admin.settings.sectionEmailsDesc": "سرور ارسال برای اعلانها و ایمیلهای پیگیری.",
+ "admin.settings.enabled": "فعال",
+ "admin.settings.disabled": "غیرفعال",
+ "admin.settings.envBadge": "کلید در .env",
+ "admin.settings.envConfigured": "تنظیمشده در .env",
+ "admin.settings.envKeyPlaceholder": "کلید در .env تنظیم شده (برای استفاده خالی بگذارید)",
+ "admin.settings.apiKey": "کلید API",
+ "admin.settings.model": "مدل",
+ "admin.settings.baseUrl": "آدرس پایه",
+ "admin.settings.test": "آزمایش",
+ "admin.settings.testing": "در حال آزمایش...",
+ "admin.settings.ok": "انجام شد",
+ "admin.settings.error": "خطا",
+ "admin.settings.failure": "ناموفق",
+ "admin.settings.sending": "در حال ارسال...",
+ "admin.settings.save": "ذخیره پیکربندی",
+ "admin.settings.saving": "در حال ذخیره...",
+ "admin.settings.savedTitle": "✅ پیکربندی ذخیره شد",
+ "admin.settings.savedDesc": "تنظیمات با موفقیت ذخیره شدند.",
+ "admin.settings.saveErrorTitle": "خطا در ذخیره",
+ "admin.settings.loadErrorTitle": "خطا در بارگیری",
+ "admin.settings.loadErrorDesc": "HTTP {status} — توکن مدیر خود را بررسی کنید.",
+ "admin.settings.networkErrorTitle": "خطای شبکه",
+ "admin.settings.networkErrorDesc": "دسترسی به سرور ممکن نیست.",
+ "admin.settings.networkSaveErrorDesc": "برای ذخیره، دسترسی به سرور ممکن نیست.",
+ "admin.settings.connectionOk": "اتصال برقرار است",
+ "admin.settings.modelsFound": "{count} مدل {provider} یافت شد",
+ "admin.settings.providerError": "خطای {provider}",
+ "admin.settings.google.title": "Google Translate",
+ "admin.settings.google.desc": "دسترسی وب غیررسمی از طریق deep_translator. کلید لازم ندارد؛ استفاده معقول توصیه میشود.",
+ "admin.settings.googleCloud.title": "Google Cloud Translation (رسمی)",
+ "admin.settings.googleCloud.desc": "رسمی Google Cloud. ۵۰۰٬۰۰۰ نویسه/ماه رایگان، سپس حدود ۲۰ دلار برای هر میلیون نویسه. کلید API از console.cloud.google.com.",
+ "admin.settings.googleCloud.hint": "در Google Cloud Console سرویس Cloud Translation API را فعال کنید و یک کلید API محدود به همان سرویس بسازید.",
+ "admin.settings.deepseek.title": "DeepSeek (مستقیم)",
+ "admin.settings.deepseek.desc": "API مستقیم DeepSeek، سازگار با OpenAI. کلید از platform.deepseek.com — بهعنوان کانال سطح هوش مصنوعی قابل استفاده است.",
+ "admin.settings.zhipu.title": "z.ai / Zhipu (GLM)",
+ "admin.settings.zhipu.desc": "دسترسی مستقیم به مدلهای GLM از z.ai، سازگار با OpenAI. کلید از z.ai — بهعنوان کانال سطح هوش مصنوعی قابل استفاده است.",
+ "admin.settings.zhipu.urlHint": "پیشفرض: https://api.z.ai/api/paas/v4 — فقط در صورت استفاده از پروکسی تغییر دهید.",
+ "admin.settings.minimax.title": "MiniMax (مستقیم)",
+ "admin.settings.minimax.desc": "API عمومی MiniMax، سازگار با OpenAI. کلید از minimax.io — بهعنوان کانال سطح هوش مصنوعی قابل استفاده است.",
+ "admin.settings.openai.title": "OpenAI",
+ "admin.settings.openai.desc": "ترجمههای GPT. کلید خود را از platform.openai.com بگیرید.",
+ "admin.settings.zai.title": "xAI (Grok)",
+ "admin.settings.zai.desc": "مدلهای Grok از xAI. API سازگار با OpenAI. کلید خود را از x.ai بگیرید.",
+ "admin.settings.zai.urlHint": "پیشفرض: https://api.x.ai/v1 — فقط در صورت استفاده از پروکسی تغییر دهید.",
+ "admin.settings.openrouter.title": "OpenRouter — کلید درگاه",
+ "admin.settings.openrouter.desc": "سطح Essential (طرح Pro). فهرست کنونی: deepseek/deepseek-v4-flash، z-ai/glm-5.3-flash، minimax/minimax-m3.",
+ "admin.settings.openrouter.keyLabel": "کلید API OpenRouter (مشترک با Premium)",
+ "admin.settings.openrouter.envSharedPlaceholder": "کلید در .env تنظیم شده (مشترک با Premium)",
+ "admin.settings.openrouter.reminder": "مدلها در صفحه «مدلها و اشتراکها» مدیریت میشوند.",
+ "admin.settings.openrouterPremium.title": "OpenRouter Premium — کلید درگاه",
+ "admin.settings.openrouterPremium.desc": "سطح Premium (طرح Business). فهرست کنونی: anthropic/claude-sonnet-5، deepseek/deepseek-v4-pro، z-ai/glm-5.3.",
+ "admin.settings.openrouterPremium.reminder": "مدلها در صفحه «مدلها و اشتراکها» مدیریت میشوند. همان کلید OpenRouter را به اشتراک میگذارد.",
+ "admin.settings.mistral.title": "OCR Mistral (PDFهای اسکنشده)",
+ "admin.settings.mistral.desc": "متن PDFهای اسکنشده (صفحات تصویری) را پیش از ترجمه استخراج میکند. بدون کلید، این فایلها با پیامی روشن رد میشوند. حدود ۱ یورو / ۱۰۰۰ صفحه. کلید: console.mistral.ai.",
+ "admin.settings.mistral.keyLabel": "کلید API Mistral",
+ "admin.settings.mistral.modelLabel": "مدل OCR",
+ "admin.settings.mistral.modelHint": "پیشنهادی: mistral-ocr-latest",
+ "admin.settings.smtp.title": "ایمیل SMTP",
+ "admin.settings.smtp.desc": "تنظیمات سرور SMTP برای ارسال ایمیل (فراموشی رمز، اعلانها، ایمیلهای پیگیری).",
+ "admin.settings.smtp.host": "میزبان SMTP",
+ "admin.settings.smtp.port": "درگاه",
+ "admin.settings.smtp.username": "نام کاربری",
+ "admin.settings.smtp.password": "رمز عبور",
+ "admin.settings.smtp.fromEmail": "نشانی فرستنده",
+ "admin.settings.smtp.useTls": "استفاده از TLS",
+ "admin.settings.smtp.sendTest": "ارسال ایمیل آزمایشی",
+ "admin.settings.smtp.sent": "ارسال شد",
+ "admin.settings.smtp.sendFailed": "ارسال ناموفق",
+ "admin.models.channelOpenrouter": "OpenRouter",
+ "admin.models.channelOpenrouterPremium": "OpenRouter (Premium)",
+ "admin.models.channelDeepseek": "DeepSeek (مستقیم)",
+ "admin.models.channelZhipu": "z.ai (GLM)",
+ "admin.models.channelMinimax": "MiniMax (مستقیم)",
+ "admin.models.channelOpenai": "OpenAI",
+ "admin.models.channelZai": "xAI (Grok)",
+ "admin.models.customModelPlaceholder": "شناسه-مدل",
+ "admin.models.routeHint": "کانال، دسترسی مستقیم یا درگاه را برمیگزیند؛ شناسه مدلها در هر کانال متفاوت است.",
+ "admin.models.addRouteConfirm": "افزودن",
+ "admin.marketing.generateTitle": "تولید با هوش مصنوعی",
+ "admin.marketing.generateDesc": "متن بر اساس برنامه بازاریابی نوشته میشود (جایگاه و ارقام مجاز). هیچچیز بهطور خودکار ارسال نمیشود: بازبینی کنید و سپس بفرستید.",
+ "admin.marketing.channel": "کانال",
+ "admin.marketing.model": "مدل",
+ "admin.marketing.modelPlaceholder": "پیشفرض کانال",
+ "admin.marketing.promoOptional": "تخفیف (اختیاری)",
+ "admin.marketing.promoNone": "بدون تخفیف",
+ "admin.marketing.brief": "شرح درخواست",
+ "admin.marketing.briefPlaceholder": "مثال: یادآوری به ثبتنامشدگان لیست انتظار درباره مزایای طرح Pro.",
+ "admin.marketing.language": "زبان ایمیل",
+ "admin.marketing.tone": "لحن",
+ "admin.marketing.toneNone": "بیطرف",
+ "admin.marketing.generateBtn": "تولید",
+ "admin.marketing.generating": "در حال تولید...",
+ "admin.marketing.generatedTitle": "ایمیل تولید شد",
+ "admin.marketing.generatedDesc": "موضوع و HTML در فرم درج شدند — پیش از ارسال بازبینی کنید ({provider}).",
+ "admin.marketing.generateFailedTitle": "تولید ناموفق بود",
+ "admin.marketing.briefRequired": "نخست محتوای موردنظر را در شرح درخواست بنویسید.",
+ "admin.marketing.langFr": "فرانسوی",
+ "admin.marketing.langEn": "انگلیسی",
+ "admin.marketing.langDe": "آلمانی",
+ "admin.marketing.langEs": "اسپانیایی",
+ "admin.marketing.langIt": "ایتالیایی",
+ "admin.marketing.langPt": "پرتغالی",
+ "admin.marketing.langNl": "هلندی",
+ "admin.marketing.langAr": "عربی",
+ "admin.marketing.langFa": "فارسی",
+ "admin.marketing.langJa": "ژاپنی",
+ "admin.marketing.langKo": "کرهای",
+ "admin.marketing.langRu": "روسی",
+ "admin.marketing.langZh": "چینی",
+ "admin.marketing.toneFriendly": "صمیمی",
+ "admin.marketing.toneProfessional": "حرفهای",
+ "admin.marketing.toneUrgent": "فوری",
+ "admin.marketing.toneFestive": "جشنوارهای",
+ "admin.marketing.promoTitle": "تخفیفها",
+ "admin.marketing.promoDesc": "کدهای تخفیف از طریق Stripe بر پرداخت اعمال میشوند؛ شمارنده پس از تکمیل واقعی اشتراک بالا میرود.",
+ "admin.marketing.promoEmpty": "هنوز کد تخفیفی وجود ندارد.",
+ "admin.marketing.promoActive": "فعال",
+ "admin.marketing.promoInactive": "غیرفعال",
+ "admin.marketing.promoLocalOnly": "خارج از Stripe",
+ "admin.marketing.promoDeactivate": "غیرفعالسازی",
+ "admin.marketing.promoUsage": "{used}/{max} بار استفاده",
+ "admin.marketing.promoPlansLabel": "طرحها:",
+ "admin.marketing.promoAllPlans": "همه",
+ "admin.marketing.promoExpiresOn": "تاریخ انقضا {date}",
+ "admin.marketing.promoNeverExpires": "بدون انقضا",
+ "admin.marketing.promoCreateTitle": "ایجاد تخفیف",
+ "admin.marketing.promoCreateDesc": "اگر Stripe تنظیم شده باشد، کد در Stripe نیز ساخته میشود (کوپن + کد تبلیغاتی).",
+ "admin.marketing.promoCode": "کد",
+ "admin.marketing.promoType": "نوع",
+ "admin.marketing.promoTypePercent": "درصد",
+ "admin.marketing.promoTypeAmount": "مبلغ (EUR)",
+ "admin.marketing.promoValue": "مقدار",
+ "admin.marketing.promoPlans": "طرحهای واجد شرایط",
+ "admin.marketing.promoMaxRedemptions": "سقف استفاده",
+ "admin.marketing.promoMaxPlaceholder": "نامحدود",
+ "admin.marketing.promoExpires": "انقضا",
+ "admin.marketing.promoCreate": "ایجاد کد",
+ "admin.marketing.promoCreating": "در حال ایجاد...",
+ "admin.marketing.promoCreatedTitle": "کد تخفیف ایجاد شد",
+ "admin.marketing.promoCreatedDesc": "کد {code} آماده است.",
+ "admin.marketing.promoCreateFailedTitle": "ایجاد رد شد",
+ "admin.marketing.promoInvalidInput": "کد و مقدار را بررسی کنید.",
+ "admin.marketing.promoDeactivatedTitle": "کد غیرفعال شد",
+ "admin.marketing.promoDeactivatedDesc": "کد {code} دیگر اعمال نمیشود.",
+ "admin.settings.fallback.title": "زنجیره پشتیبان",
+ "admin.settings.fallback.desc": "ترتیب اولویت برای انتخاب خودکار ارائهدهندگان.",
+ "admin.settings.fallback.classic": "حالت کلاسیک (Google)",
+ "admin.settings.fallback.llm": "حالت LLM (درگاهها و دسترسی مستقیم)",
+ "admin.settings.fallback.note": "فقط کانالهای ثبتشده (google، openai، deepseek، minimax) در این زنجیره شرکت میکنند؛ سطوح هوش مصنوعی و کانالهای دیگر نه.",
+ "admin.models.duplicateRouteTitle": "تکرار رد شد",
+ "admin.models.duplicateRouteDesc": "این جفت کانال + مدل از قبل در فهرست هست.",
+ "admin.marketing.promoDeactivateFailedTitle": "غیرفعالسازی رد شد",
+ "admin.marketing.promoDelete": "حذف",
+ "admin.marketing.promoDeleteConfirm": "کد {code} برای همیشه حذف شود؟",
+ "admin.marketing.promoDeletedTitle": "کد حذف شد",
+ "admin.marketing.promoDeletedDesc": "کد {code} حذف شد.",
+ "admin.marketing.promoDeleteFailedTitle": "حذف رد شد",
+ "admin.marketing.promoLinkStripe": "پیوند به Stripe",
+ "admin.marketing.promoLinkedTitle": "کد به Stripe پیوند خورد",
+ "admin.marketing.promoLinkedDesc": "کد {code} اکنون در پرداخت قابل اعمال است.",
+ "admin.marketing.promoLinkFailedTitle": "پیوند ناموفق بود",
+ "admin.marketing.promoExpiryHint": "کد «تا ۱۰» تمام روز دهم معتبر است (پایان: ۲۳:۵۹:۵۹ UTC).",
+ "admin.marketing.generateEmpty": "پاسخ سرور خالی یا غیرقابل استفاده بود."
}
diff --git a/frontend/src/lib/i18n/messages/fa/pricing.json b/frontend/src/lib/i18n/messages/fa/pricing.json
index f899c14..3d21785 100644
--- a/frontend/src/lib/i18n/messages/fa/pricing.json
+++ b/frontend/src/lib/i18n/messages/fa/pricing.json
@@ -167,5 +167,17 @@
"pricing.header.titleBase": "برنامهای برای",
"pricing.header.titleAccent": "هر نیاز",
"pricing.toast.close": "بستن",
- "pricing.aiModels.premium.price": "۲ دلار / ۱۰ دلار به ازای هر میلیون توکن"
+ "pricing.aiModels.premium.price": "۲ دلار / ۱۰ دلار به ازای هر میلیون توکن",
+ "pricing.promo.label": "کد تخفیف",
+ "pricing.promo.placeholder": "کدشما",
+ "pricing.promo.apply": "اعمال",
+ "pricing.promo.checking": "در حال بررسی...",
+ "pricing.promo.valid": "کد معتبر است",
+ "pricing.promo.subscriptionOnly": "فقط روی اشتراکها معتبر است، نه روی اعتبارها.",
+ "pricing.promoError.unknown": "این کد تخفیف وجود ندارد.",
+ "pricing.promoError.inactive": "این کد تخفیف دیگر فعال نیست.",
+ "pricing.promoError.expired": "این کد تخفیف منقضی شده است.",
+ "pricing.promoError.exhausted": "این کد تخفیف به سقف استفاده رسیده است.",
+ "pricing.promoError.plan": "این کد تخفیف برای این طرح معتبر نیست.",
+ "pricing.promoError.local_only": "این کد تخفیف برای پرداخت آنلاین فعال نشده است."
}
diff --git a/frontend/src/lib/i18n/messages/fr/admin.json b/frontend/src/lib/i18n/messages/fr/admin.json
index 066cac1..fd758f7 100644
--- a/frontend/src/lib/i18n/messages/fr/admin.json
+++ b/frontend/src/lib/i18n/messages/fr/admin.json
@@ -145,5 +145,180 @@
"admin.stats.tiersSub": "Essentielle / Premium · classique {classic} · autre {other}",
"admin.stats.unavailable": "Statistiques business indisponibles ({error}).",
"admin.stats.waitlist": "Liste d'attente",
- "admin.stats.waitlistSub": "inscrits en attente"
+ "admin.stats.waitlistSub": "inscrits en attente",
+ "admin.settings.title": "Fournisseurs",
+ "admin.settings.subtitle": "Configurez les accès fournisseurs. Les clés API peuvent aussi être définies dans le fichier .env.",
+ "admin.settings.sectionClassic": "Traduction classique",
+ "admin.settings.sectionClassicDesc": "Moteurs Google pour la traduction standard.",
+ "admin.settings.sectionDirect": "Fournisseurs IA directs",
+ "admin.settings.sectionDirectDesc": "Accès directs compatibles OpenAI — utilisables comme canal d'un palier IA (page « Modèles & abonnements ») ou pour la génération d'emails.",
+ "admin.settings.sectionGateway": "Passerelles multi-modèles",
+ "admin.settings.sectionGatewayDesc": "Une seule clé, des centaines de modèles. Les modèles par palier se règlent dans « Modèles & abonnements ».",
+ "admin.settings.sectionOcr": "OCR (PDF scannés)",
+ "admin.settings.sectionOcrDesc": "Extraction du texte des pages image avant traduction.",
+ "admin.settings.sectionEmails": "Emails",
+ "admin.settings.sectionEmailsDesc": "Serveur d'envoi pour les notifications et les relances.",
+ "admin.settings.enabled": "Activé",
+ "admin.settings.disabled": "Désactivé",
+ "admin.settings.envBadge": "Clé dans .env",
+ "admin.settings.envConfigured": "Configuré dans .env",
+ "admin.settings.envKeyPlaceholder": "Clé configurée dans .env (laisser vide pour l'utiliser)",
+ "admin.settings.apiKey": "Clé API",
+ "admin.settings.model": "Modèle",
+ "admin.settings.baseUrl": "URL de base",
+ "admin.settings.test": "Tester",
+ "admin.settings.testing": "Test...",
+ "admin.settings.ok": "OK",
+ "admin.settings.error": "Erreur",
+ "admin.settings.failure": "Échec",
+ "admin.settings.sending": "Envoi...",
+ "admin.settings.save": "Sauvegarder la configuration",
+ "admin.settings.saving": "Sauvegarde...",
+ "admin.settings.savedTitle": "✅ Configuration sauvegardée",
+ "admin.settings.savedDesc": "Les paramètres ont été enregistrés avec succès.",
+ "admin.settings.saveErrorTitle": "Erreur de sauvegarde",
+ "admin.settings.loadErrorTitle": "Erreur de chargement",
+ "admin.settings.loadErrorDesc": "HTTP {status} — vérifiez votre token admin.",
+ "admin.settings.networkErrorTitle": "Erreur réseau",
+ "admin.settings.networkErrorDesc": "Impossible de contacter le backend.",
+ "admin.settings.networkSaveErrorDesc": "Impossible de contacter le backend pour la sauvegarde.",
+ "admin.settings.connectionOk": "Connexion OK",
+ "admin.settings.modelsFound": "{count} modèles {provider} trouvés",
+ "admin.settings.providerError": "Erreur {provider}",
+ "admin.settings.google.title": "Google Translate",
+ "admin.settings.google.desc": "Accès web non officiel via deep_translator. Aucune clé requise, usage raisonnable recommandé.",
+ "admin.settings.googleCloud.title": "Google Cloud Translation (officiel)",
+ "admin.settings.googleCloud.desc": "API officielle Google Cloud. 500 000 caractères/mois offerts, puis environ 20 $ par million de caractères. Clé API sur console.cloud.google.com.",
+ "admin.settings.googleCloud.hint": "Activez Cloud Translation API dans Google Cloud Console, puis créez une clé API restreinte à cette API.",
+ "admin.settings.deepseek.title": "DeepSeek (accès direct)",
+ "admin.settings.deepseek.desc": "API DeepSeek directe, compatible OpenAI. Clé sur platform.deepseek.com — utilisable comme canal d'un palier IA.",
+ "admin.settings.zhipu.title": "z.ai / Zhipu (GLM)",
+ "admin.settings.zhipu.desc": "Accès direct aux modèles GLM de z.ai, compatible OpenAI. Clé sur z.ai — utilisable comme canal d'un palier IA.",
+ "admin.settings.zhipu.urlHint": "Par défaut : https://api.z.ai/api/paas/v4 — à changer uniquement si vous utilisez un proxy.",
+ "admin.settings.minimax.title": "MiniMax (accès direct)",
+ "admin.settings.minimax.desc": "API publique MiniMax, compatible OpenAI. Clé sur minimax.io — utilisable comme canal d'un palier IA.",
+ "admin.settings.openai.title": "OpenAI",
+ "admin.settings.openai.desc": "Traductions GPT. Obtenez une clé sur platform.openai.com.",
+ "admin.settings.zai.title": "xAI (Grok)",
+ "admin.settings.zai.desc": "Modèles Grok par xAI. API compatible OpenAI. Obtenez votre clé sur x.ai.",
+ "admin.settings.zai.urlHint": "Par défaut : https://api.x.ai/v1 — à changer uniquement si vous utilisez un proxy.",
+ "admin.settings.openrouter.title": "OpenRouter — clé de passerelle",
+ "admin.settings.openrouter.desc": "Palier Essentielle (forfait Pro). Gamme actuelle : deepseek/deepseek-v4-flash, z-ai/glm-5.3-flash, minimax/minimax-m3.",
+ "admin.settings.openrouter.keyLabel": "Clé API OpenRouter (partagée avec Premium)",
+ "admin.settings.openrouter.envSharedPlaceholder": "Clé configurée dans .env (partagée avec Premium)",
+ "admin.settings.openrouter.reminder": "Modèles gérés dans la page « Modèles & abonnements ».",
+ "admin.settings.openrouterPremium.title": "OpenRouter Premium — clé de passerelle",
+ "admin.settings.openrouterPremium.desc": "Palier Premium (forfait Business). Gamme actuelle : anthropic/claude-sonnet-5, deepseek/deepseek-v4-pro, z-ai/glm-5.3.",
+ "admin.settings.openrouterPremium.reminder": "Modèles gérés dans la page « Modèles & abonnements ». Partage la même clé OpenRouter.",
+ "admin.settings.mistral.title": "OCR Mistral (PDF scannés)",
+ "admin.settings.mistral.desc": "Extraction du texte des PDF scannés (pages image) avant traduction. Sans clé, ces fichiers sont refusés avec un message clair. Environ 1 € / 1 000 pages. Clé : console.mistral.ai.",
+ "admin.settings.mistral.keyLabel": "Clé API Mistral",
+ "admin.settings.mistral.modelLabel": "Modèle OCR",
+ "admin.settings.mistral.modelHint": "Recommandé : mistral-ocr-latest",
+ "admin.settings.smtp.title": "Email SMTP",
+ "admin.settings.smtp.desc": "Configuration du serveur SMTP pour l'envoi d'emails (mot de passe oublié, notifications, relances).",
+ "admin.settings.smtp.host": "Hôte SMTP",
+ "admin.settings.smtp.port": "Port",
+ "admin.settings.smtp.username": "Nom d'utilisateur",
+ "admin.settings.smtp.password": "Mot de passe",
+ "admin.settings.smtp.fromEmail": "Adresse d'expédition",
+ "admin.settings.smtp.useTls": "Utiliser TLS",
+ "admin.settings.smtp.sendTest": "Envoyer un email de test",
+ "admin.settings.smtp.sent": "Envoyé",
+ "admin.settings.smtp.sendFailed": "Échec de l'envoi",
+ "admin.models.channelOpenrouter": "OpenRouter",
+ "admin.models.channelOpenrouterPremium": "OpenRouter (Premium)",
+ "admin.models.channelDeepseek": "DeepSeek (accès direct)",
+ "admin.models.channelZhipu": "z.ai (GLM)",
+ "admin.models.channelMinimax": "MiniMax (accès direct)",
+ "admin.models.channelOpenai": "OpenAI",
+ "admin.models.channelZai": "xAI (Grok)",
+ "admin.models.customModelPlaceholder": "identifiant-du-modèle",
+ "admin.models.routeHint": "Le canal choisit l'accès direct ou la passerelle ; les identifiants de modèles diffèrent d'un canal à l'autre.",
+ "admin.models.addRouteConfirm": "Ajouter",
+ "admin.marketing.generateTitle": "Générer avec l'IA",
+ "admin.marketing.generateDesc": "Le texte est rédigé à partir du plan marketing (positionnement et chiffres autorisés). Rien n'est envoyé automatiquement : relisez puis envoyez.",
+ "admin.marketing.channel": "Canal",
+ "admin.marketing.model": "Modèle",
+ "admin.marketing.modelPlaceholder": "Par défaut du canal",
+ "admin.marketing.promoOptional": "Réduction (facultatif)",
+ "admin.marketing.promoNone": "Aucune",
+ "admin.marketing.brief": "Brief",
+ "admin.marketing.briefPlaceholder": "Ex. : relancer les inscrits de la liste d'attente avec un rappel des avantages du forfait Pro.",
+ "admin.marketing.language": "Langue de l'email",
+ "admin.marketing.tone": "Ton",
+ "admin.marketing.toneNone": "Neutre",
+ "admin.marketing.generateBtn": "Générer",
+ "admin.marketing.generating": "Génération...",
+ "admin.marketing.generatedTitle": "Email généré",
+ "admin.marketing.generatedDesc": "Sujet et HTML insérés dans le formulaire — relisez avant envoi ({provider}).",
+ "admin.marketing.generateFailedTitle": "Génération impossible",
+ "admin.marketing.briefRequired": "Décrivez d'abord le contenu voulu dans le brief.",
+ "admin.marketing.langFr": "Français",
+ "admin.marketing.langEn": "Anglais",
+ "admin.marketing.langDe": "Allemand",
+ "admin.marketing.langEs": "Espagnol",
+ "admin.marketing.langIt": "Italien",
+ "admin.marketing.langPt": "Portugais",
+ "admin.marketing.langNl": "Néerlandais",
+ "admin.marketing.langAr": "Arabe",
+ "admin.marketing.langFa": "Persan",
+ "admin.marketing.langJa": "Japonais",
+ "admin.marketing.langKo": "Coréen",
+ "admin.marketing.langRu": "Russe",
+ "admin.marketing.langZh": "Chinois",
+ "admin.marketing.toneFriendly": "Amical",
+ "admin.marketing.toneProfessional": "Professionnel",
+ "admin.marketing.toneUrgent": "Urgent",
+ "admin.marketing.toneFestive": "Festif",
+ "admin.marketing.promoTitle": "Réductions",
+ "admin.marketing.promoDesc": "Codes promo appliqués au paiement par Stripe ; le compteur augmente quand l'abonnement est réellement souscrit.",
+ "admin.marketing.promoEmpty": "Aucun code promo pour le moment.",
+ "admin.marketing.promoActive": "Actif",
+ "admin.marketing.promoInactive": "Désactivé",
+ "admin.marketing.promoLocalOnly": "Hors Stripe",
+ "admin.marketing.promoDeactivate": "Désactiver",
+ "admin.marketing.promoUsage": "{used}/{max} utilisations",
+ "admin.marketing.promoPlansLabel": "Forfaits :",
+ "admin.marketing.promoAllPlans": "Tous",
+ "admin.marketing.promoExpiresOn": "expire le {date}",
+ "admin.marketing.promoNeverExpires": "sans expiration",
+ "admin.marketing.promoCreateTitle": "Créer une réduction",
+ "admin.marketing.promoCreateDesc": "Le code est créé aussi chez Stripe (coupon + code promotionnel) quand Stripe est configuré.",
+ "admin.marketing.promoCode": "Code",
+ "admin.marketing.promoType": "Type",
+ "admin.marketing.promoTypePercent": "Pourcentage",
+ "admin.marketing.promoTypeAmount": "Montant (EUR)",
+ "admin.marketing.promoValue": "Valeur",
+ "admin.marketing.promoPlans": "Forfaits éligibles",
+ "admin.marketing.promoMaxRedemptions": "Plafond d'utilisations",
+ "admin.marketing.promoMaxPlaceholder": "Illimité",
+ "admin.marketing.promoExpires": "Expiration",
+ "admin.marketing.promoCreate": "Créer le code",
+ "admin.marketing.promoCreating": "Création...",
+ "admin.marketing.promoCreatedTitle": "Code promo créé",
+ "admin.marketing.promoCreatedDesc": "Le code {code} est prêt.",
+ "admin.marketing.promoCreateFailedTitle": "Création refusée",
+ "admin.marketing.promoInvalidInput": "Vérifiez le code et la valeur.",
+ "admin.marketing.promoDeactivatedTitle": "Code désactivé",
+ "admin.marketing.promoDeactivatedDesc": "Le code {code} n'est plus applicable.",
+ "admin.settings.fallback.title": "Chaîne de secours",
+ "admin.settings.fallback.desc": "Ordre de priorité pour la sélection automatique des fournisseurs.",
+ "admin.settings.fallback.classic": "Mode classique (Google)",
+ "admin.settings.fallback.llm": "Mode LLM (passerelles et accès directs)",
+ "admin.settings.fallback.note": "Seuls les canaux enregistrés (google, openai, deepseek, minimax) participent à cette chaîne ; les paliers IA et les autres canaux n'y participent pas.",
+ "admin.models.duplicateRouteTitle": "Doublon refusé",
+ "admin.models.duplicateRouteDesc": "Ce couple canal + modèle existe déjà dans la liste.",
+ "admin.marketing.promoDeactivateFailedTitle": "Désactivation refusée",
+ "admin.marketing.promoDelete": "Supprimer",
+ "admin.marketing.promoDeleteConfirm": "Supprimer définitivement le code {code} ?",
+ "admin.marketing.promoDeletedTitle": "Code supprimé",
+ "admin.marketing.promoDeletedDesc": "Le code {code} a été supprimé.",
+ "admin.marketing.promoDeleteFailedTitle": "Suppression refusée",
+ "admin.marketing.promoLinkStripe": "Relier à Stripe",
+ "admin.marketing.promoLinkedTitle": "Code relié à Stripe",
+ "admin.marketing.promoLinkedDesc": "Le code {code} est maintenant applicable au paiement.",
+ "admin.marketing.promoLinkFailedTitle": "Reliage impossible",
+ "admin.marketing.promoExpiryHint": "Un code « jusqu'au 10 » reste valable tout le 10 (fin : 23:59:59 UTC).",
+ "admin.marketing.generateEmpty": "La réponse du serveur était vide ou inexploitable."
}
diff --git a/frontend/src/lib/i18n/messages/fr/pricing.json b/frontend/src/lib/i18n/messages/fr/pricing.json
index f261a41..62adda6 100644
--- a/frontend/src/lib/i18n/messages/fr/pricing.json
+++ b/frontend/src/lib/i18n/messages/fr/pricing.json
@@ -167,5 +167,17 @@
"pricing.aiModels.essential.price": "À partir de 0,09 $ par million de jetons",
"pricing.aiModels.premium.modelName": "Claude 5",
"pricing.aiModels.premium.alternatives": "Alternatives : DeepSeek V4 Pro et GLM-5.3",
- "pricing.aiModels.premium.price": "2 $ / 10 $ par 1M de jetons"
+ "pricing.aiModels.premium.price": "2 $ / 10 $ par 1M de jetons",
+ "pricing.promo.label": "Code promo",
+ "pricing.promo.placeholder": "VOTRCODE",
+ "pricing.promo.apply": "Appliquer",
+ "pricing.promo.checking": "Vérification...",
+ "pricing.promo.valid": "Code valable",
+ "pricing.promo.subscriptionOnly": "Valable sur les abonnements, pas sur les crédits.",
+ "pricing.promoError.unknown": "Ce code promo n'existe pas.",
+ "pricing.promoError.inactive": "Ce code promo n'est plus actif.",
+ "pricing.promoError.expired": "Ce code promo a expiré.",
+ "pricing.promoError.exhausted": "Ce code promo a atteint sa limite d'utilisations.",
+ "pricing.promoError.plan": "Ce code promo n'est pas valable pour ce forfait.",
+ "pricing.promoError.local_only": "Ce code promo n'est pas relié au paiement en ligne."
}
diff --git a/frontend/src/lib/i18n/messages/it/admin.json b/frontend/src/lib/i18n/messages/it/admin.json
index 4605c2f..991338d 100644
--- a/frontend/src/lib/i18n/messages/it/admin.json
+++ b/frontend/src/lib/i18n/messages/it/admin.json
@@ -145,5 +145,180 @@
"admin.stats.tiersSub": "Essenziale / Premium · classico {classic} · altro {other}",
"admin.stats.unavailable": "Statistiche business non disponibili ({error}).",
"admin.stats.waitlist": "Lista d'attesa",
- "admin.stats.waitlistSub": "iscritti in attesa"
+ "admin.stats.waitlistSub": "iscritti in attesa",
+ "admin.settings.title": "Fornitori",
+ "admin.settings.subtitle": "Configura gli accessi ai fornitori. Le chiavi API possono anche essere definite nel file .env.",
+ "admin.settings.sectionClassic": "Traduzione classica",
+ "admin.settings.sectionClassicDesc": "Motori Google per la traduzione standard.",
+ "admin.settings.sectionDirect": "Fornitori IA diretti",
+ "admin.settings.sectionDirectDesc": "Accessi diretti compatibili OpenAI — utilizzabili come canale di un livello IA (pagina «Modelli e abbonamenti») o per la generazione delle email.",
+ "admin.settings.sectionGateway": "Gateway multi-modello",
+ "admin.settings.sectionGatewayDesc": "Una sola chiave, centinaia di modelli. I modelli per livello si gestiscono nella pagina «Modelli e abbonamenti».",
+ "admin.settings.sectionOcr": "OCR (PDF scansionati)",
+ "admin.settings.sectionOcrDesc": "Estrazione del testo dalle pagine immagine prima della traduzione.",
+ "admin.settings.sectionEmails": "Email",
+ "admin.settings.sectionEmailsDesc": "Server di invio per notifiche e promemoria.",
+ "admin.settings.enabled": "Attivato",
+ "admin.settings.disabled": "Disattivato",
+ "admin.settings.envBadge": "Chiave in .env",
+ "admin.settings.envConfigured": "Configurato in .env",
+ "admin.settings.envKeyPlaceholder": "Chiave configurata in .env (lasciare vuoto per usarla)",
+ "admin.settings.apiKey": "Chiave API",
+ "admin.settings.model": "Modello",
+ "admin.settings.baseUrl": "URL di base",
+ "admin.settings.test": "Prova",
+ "admin.settings.testing": "Test...",
+ "admin.settings.ok": "OK",
+ "admin.settings.error": "Errore",
+ "admin.settings.failure": "Fallito",
+ "admin.settings.sending": "Invio...",
+ "admin.settings.save": "Salva la configurazione",
+ "admin.settings.saving": "Salvataggio...",
+ "admin.settings.savedTitle": "✅ Configurazione salvata",
+ "admin.settings.savedDesc": "Le impostazioni sono state salvate correttamente.",
+ "admin.settings.saveErrorTitle": "Errore di salvataggio",
+ "admin.settings.loadErrorTitle": "Errore di caricamento",
+ "admin.settings.loadErrorDesc": "HTTP {status} — verifica il tuo token amministratore.",
+ "admin.settings.networkErrorTitle": "Errore di rete",
+ "admin.settings.networkErrorDesc": "Impossibile contattare il backend.",
+ "admin.settings.networkSaveErrorDesc": "Impossibile contattare il backend per salvare.",
+ "admin.settings.connectionOk": "Connessione OK",
+ "admin.settings.modelsFound": "{count} modelli {provider} trovati",
+ "admin.settings.providerError": "Errore {provider}",
+ "admin.settings.google.title": "Google Translate",
+ "admin.settings.google.desc": "Accesso web non ufficiale tramite deep_translator. Nessuna chiave richiesta, uso ragionevole consigliato.",
+ "admin.settings.googleCloud.title": "Google Cloud Translation (ufficiale)",
+ "admin.settings.googleCloud.desc": "API ufficiale di Google Cloud. 500 000 caratteri/mese gratuiti, poi circa 20 $ per milione di caratteri. Chiave API su console.cloud.google.com.",
+ "admin.settings.googleCloud.hint": "Attiva la Cloud Translation API nella Google Cloud Console, poi crea una chiave API limitata a questa API.",
+ "admin.settings.deepseek.title": "DeepSeek (diretto)",
+ "admin.settings.deepseek.desc": "API diretta DeepSeek, compatibile OpenAI. Chiave su platform.deepseek.com — utilizzabile come canale di un livello IA.",
+ "admin.settings.zhipu.title": "z.ai / Zhipu (GLM)",
+ "admin.settings.zhipu.desc": "Accesso diretto ai modelli GLM di z.ai, compatibile OpenAI. Chiave su z.ai — utilizzabile come canale di un livello IA.",
+ "admin.settings.zhipu.urlHint": "Predefinito: https://api.z.ai/api/paas/v4 — da cambiare solo se usi un proxy.",
+ "admin.settings.minimax.title": "MiniMax (diretto)",
+ "admin.settings.minimax.desc": "API pubblica MiniMax, compatibile OpenAI. Chiave su minimax.io — utilizzabile come canale di un livello IA.",
+ "admin.settings.openai.title": "OpenAI",
+ "admin.settings.openai.desc": "Traduzioni GPT. Ottieni una chiave su platform.openai.com.",
+ "admin.settings.zai.title": "xAI (Grok)",
+ "admin.settings.zai.desc": "Modelli Grok di xAI. API compatibile OpenAI. Ottieni la chiave su x.ai.",
+ "admin.settings.zai.urlHint": "Predefinito: https://api.x.ai/v1 — da cambiare solo se usi un proxy.",
+ "admin.settings.openrouter.title": "OpenRouter — chiave gateway",
+ "admin.settings.openrouter.desc": "Livello Essenziale (piano Pro). Gamma attuale: deepseek/deepseek-v4-flash, z-ai/glm-5.3-flash, minimax/minimax-m3.",
+ "admin.settings.openrouter.keyLabel": "Chiave API OpenRouter (condivisa con Premium)",
+ "admin.settings.openrouter.envSharedPlaceholder": "Chiave configurata in .env (condivisa con Premium)",
+ "admin.settings.openrouter.reminder": "Modelli gestiti nella pagina «Modelli e abbonamenti».",
+ "admin.settings.openrouterPremium.title": "OpenRouter Premium — chiave gateway",
+ "admin.settings.openrouterPremium.desc": "Livello Premium (piano Business). Gamma attuale: anthropic/claude-sonnet-5, deepseek/deepseek-v4-pro, z-ai/glm-5.3.",
+ "admin.settings.openrouterPremium.reminder": "Modelli gestiti nella pagina «Modelli e abbonamenti». Condivide la stessa chiave OpenRouter.",
+ "admin.settings.mistral.title": "OCR Mistral (PDF scansionati)",
+ "admin.settings.mistral.desc": "Estrae il testo dai PDF scansionati (pagine immagine) prima della traduzione. Senza chiave questi file vengono rifiutati con un messaggio chiaro. Circa 1 € / 1 000 pagine. Chiave: console.mistral.ai.",
+ "admin.settings.mistral.keyLabel": "Chiave API Mistral",
+ "admin.settings.mistral.modelLabel": "Modello OCR",
+ "admin.settings.mistral.modelHint": "Consigliato: mistral-ocr-latest",
+ "admin.settings.smtp.title": "Email SMTP",
+ "admin.settings.smtp.desc": "Configurazione del server SMTP per l'invio delle email (password dimenticata, notifiche, promemoria).",
+ "admin.settings.smtp.host": "Host SMTP",
+ "admin.settings.smtp.port": "Porta",
+ "admin.settings.smtp.username": "Nome utente",
+ "admin.settings.smtp.password": "Password",
+ "admin.settings.smtp.fromEmail": "Indirizzo mittente",
+ "admin.settings.smtp.useTls": "Usa TLS",
+ "admin.settings.smtp.sendTest": "Invia un'email di prova",
+ "admin.settings.smtp.sent": "Inviata",
+ "admin.settings.smtp.sendFailed": "Invio non riuscito",
+ "admin.models.channelOpenrouter": "OpenRouter",
+ "admin.models.channelOpenrouterPremium": "OpenRouter (Premium)",
+ "admin.models.channelDeepseek": "DeepSeek (diretto)",
+ "admin.models.channelZhipu": "z.ai (GLM)",
+ "admin.models.channelMinimax": "MiniMax (diretto)",
+ "admin.models.channelOpenai": "OpenAI",
+ "admin.models.channelZai": "xAI (Grok)",
+ "admin.models.customModelPlaceholder": "id-del-modello",
+ "admin.models.routeHint": "Il canale sceglie l'accesso diretto o il gateway; gli identificativi dei modelli cambiano da un canale all'altro.",
+ "admin.models.addRouteConfirm": "Aggiungi",
+ "admin.marketing.generateTitle": "Genera con l'IA",
+ "admin.marketing.generateDesc": "Il testo è scritto a partire dal piano marketing (posizionamento e cifre autorizzate). Nulla viene inviato automaticamente: rileggi e poi invia.",
+ "admin.marketing.channel": "Canale",
+ "admin.marketing.model": "Modello",
+ "admin.marketing.modelPlaceholder": "Predefinito del canale",
+ "admin.marketing.promoOptional": "Sconto (facoltativo)",
+ "admin.marketing.promoNone": "Nessuno",
+ "admin.marketing.brief": "Brief",
+ "admin.marketing.briefPlaceholder": "Es.: ricontatta gli iscritti alla lista d'attente ricordando i vantaggi del piano Pro.",
+ "admin.marketing.language": "Lingua dell'email",
+ "admin.marketing.tone": "Tono",
+ "admin.marketing.toneNone": "Neutro",
+ "admin.marketing.generateBtn": "Genera",
+ "admin.marketing.generating": "Generazione...",
+ "admin.marketing.generatedTitle": "Email generata",
+ "admin.marketing.generatedDesc": "Oggetto e HTML inseriti nel modulo — rileggi prima di inviare ({provider}).",
+ "admin.marketing.generateFailedTitle": "Generazione non riuscita",
+ "admin.marketing.briefRequired": "Descrivi prima il contenuto desiderato nel brief.",
+ "admin.marketing.langFr": "Francese",
+ "admin.marketing.langEn": "Inglese",
+ "admin.marketing.langDe": "Tedesco",
+ "admin.marketing.langEs": "Spagnolo",
+ "admin.marketing.langIt": "Italiano",
+ "admin.marketing.langPt": "Portoghese",
+ "admin.marketing.langNl": "Olandese",
+ "admin.marketing.langAr": "Arabo",
+ "admin.marketing.langFa": "Persiano",
+ "admin.marketing.langJa": "Giapponese",
+ "admin.marketing.langKo": "Coreano",
+ "admin.marketing.langRu": "Russo",
+ "admin.marketing.langZh": "Cinese",
+ "admin.marketing.toneFriendly": "Amichevole",
+ "admin.marketing.toneProfessional": "Professionale",
+ "admin.marketing.toneUrgent": "Urgente",
+ "admin.marketing.toneFestive": "Festivo",
+ "admin.marketing.promoTitle": "Sconti",
+ "admin.marketing.promoDesc": "Codici promo applicati al pagamento tramite Stripe; il contatore aumenta quando l'abbonamento è davvero completato.",
+ "admin.marketing.promoEmpty": "Ancora nessun codice promo.",
+ "admin.marketing.promoActive": "Attivo",
+ "admin.marketing.promoInactive": "Disattivato",
+ "admin.marketing.promoLocalOnly": "Fuori Stripe",
+ "admin.marketing.promoDeactivate": "Disattiva",
+ "admin.marketing.promoUsage": "{used}/{max} utilizzi",
+ "admin.marketing.promoPlansLabel": "Piani:",
+ "admin.marketing.promoAllPlans": "Tutti",
+ "admin.marketing.promoExpiresOn": "scade il {date}",
+ "admin.marketing.promoNeverExpires": "senza scadenza",
+ "admin.marketing.promoCreateTitle": "Crea uno sconto",
+ "admin.marketing.promoCreateDesc": "Il codice viene creato anche su Stripe (coupon + codice promozionale) quando Stripe è configurato.",
+ "admin.marketing.promoCode": "Codice",
+ "admin.marketing.promoType": "Tipo",
+ "admin.marketing.promoTypePercent": "Percentuale",
+ "admin.marketing.promoTypeAmount": "Importo (EUR)",
+ "admin.marketing.promoValue": "Valore",
+ "admin.marketing.promoPlans": "Piani idonei",
+ "admin.marketing.promoMaxRedemptions": "Limite di utilizzi",
+ "admin.marketing.promoMaxPlaceholder": "Illimitato",
+ "admin.marketing.promoExpires": "Scadenza",
+ "admin.marketing.promoCreate": "Crea il codice",
+ "admin.marketing.promoCreating": "Creazione...",
+ "admin.marketing.promoCreatedTitle": "Codice promo creato",
+ "admin.marketing.promoCreatedDesc": "Il codice {code} è pronto.",
+ "admin.marketing.promoCreateFailedTitle": "Creazione rifiutata",
+ "admin.marketing.promoInvalidInput": "Verifica il codice e il valore.",
+ "admin.marketing.promoDeactivatedTitle": "Codice disattivato",
+ "admin.marketing.promoDeactivatedDesc": "Il codice {code} non è più applicabile.",
+ "admin.settings.fallback.title": "Catena di soccorso",
+ "admin.settings.fallback.desc": "Ordine di priorità per la selezione automatica dei fornitori.",
+ "admin.settings.fallback.classic": "Modalità classica (Google)",
+ "admin.settings.fallback.llm": "Modalità LLM (gateway e accessi diretti)",
+ "admin.settings.fallback.note": "Solo i canali registrati (google, openai, deepseek, minimax) partecipano a questa catena; i livelli IA e gli altri canali no.",
+ "admin.models.duplicateRouteTitle": "Duplicato rifiutato",
+ "admin.models.duplicateRouteDesc": "Questa coppia canale + modello è già nell'elenco.",
+ "admin.marketing.promoDeactivateFailedTitle": "Disattivazione rifiutata",
+ "admin.marketing.promoDelete": "Elimina",
+ "admin.marketing.promoDeleteConfirm": "Eliminare definitivamente il codice {code}?",
+ "admin.marketing.promoDeletedTitle": "Codice eliminato",
+ "admin.marketing.promoDeletedDesc": "Il codice {code} è stato eliminato.",
+ "admin.marketing.promoDeleteFailedTitle": "Eliminazione rifiutata",
+ "admin.marketing.promoLinkStripe": "Collega a Stripe",
+ "admin.marketing.promoLinkedTitle": "Codice collegato a Stripe",
+ "admin.marketing.promoLinkedDesc": "Il codice {code} ora è applicabile al pagamento.",
+ "admin.marketing.promoLinkFailedTitle": "Collegamento non riuscito",
+ "admin.marketing.promoExpiryHint": "Un codice «fino al 10» resta valido tutto il 10 (fine: 23:59:59 UTC).",
+ "admin.marketing.generateEmpty": "La risposta del server era vuota o inutilizzabile."
}
diff --git a/frontend/src/lib/i18n/messages/it/pricing.json b/frontend/src/lib/i18n/messages/it/pricing.json
index 2ff32bf..10fa367 100644
--- a/frontend/src/lib/i18n/messages/it/pricing.json
+++ b/frontend/src/lib/i18n/messages/it/pricing.json
@@ -167,5 +167,17 @@
"pricing.header.titleBase": "Un piano per",
"pricing.header.titleAccent": "ogni esigenza",
"pricing.toast.close": "Chiudi",
- "pricing.aiModels.premium.price": "2 $ / 10 $ per 1M di token"
+ "pricing.aiModels.premium.price": "2 $ / 10 $ per 1M di token",
+ "pricing.promo.label": "Codice promo",
+ "pricing.promo.placeholder": "TUOCODICE",
+ "pricing.promo.apply": "Applica",
+ "pricing.promo.checking": "Verifica...",
+ "pricing.promo.valid": "Codice valido",
+ "pricing.promo.subscriptionOnly": "Valido sugli abbonamenti, non sui crediti.",
+ "pricing.promoError.unknown": "Questo codice promo non esiste.",
+ "pricing.promoError.inactive": "Questo codice promo non è più attivo.",
+ "pricing.promoError.expired": "Questo codice promo è scaduto.",
+ "pricing.promoError.exhausted": "Questo codice promo ha raggiunto il limite di utilizzi.",
+ "pricing.promoError.plan": "Questo codice promo non è valido per questo piano.",
+ "pricing.promoError.local_only": "Questo codice promo non è abilitato al pagamento online."
}
diff --git a/frontend/src/lib/i18n/messages/ja/admin.json b/frontend/src/lib/i18n/messages/ja/admin.json
index ea84fa8..73edbc8 100644
--- a/frontend/src/lib/i18n/messages/ja/admin.json
+++ b/frontend/src/lib/i18n/messages/ja/admin.json
@@ -145,5 +145,180 @@
"admin.stats.tiersSub": "エッセンシャル / プレミアム · クラシック {classic} · その他 {other}",
"admin.stats.unavailable": "ビジネス統計を利用できません({error})。",
"admin.stats.waitlist": "ウェイトリスト",
- "admin.stats.waitlistSub": "待機中の登録者"
+ "admin.stats.waitlistSub": "待機中の登録者",
+ "admin.settings.title": "プロバイダー",
+ "admin.settings.subtitle": "プロバイダーへのアクセスを設定します。APIキーは.envファイルにも設定できます。",
+ "admin.settings.sectionClassic": "クラシック翻訳",
+ "admin.settings.sectionClassicDesc": "標準翻訳用のGoogleエンジン。",
+ "admin.settings.sectionDirect": "直接AIプロバイダー",
+ "admin.settings.sectionDirectDesc": "OpenAI互換の直接アクセス — AIティアのチャネル(「モデルとサブスクリプション」ページ)やメール生成に使用できます。",
+ "admin.settings.sectionGateway": "マルチモデルゲートウェイ",
+ "admin.settings.sectionGatewayDesc": "鍵は1つで、モデルは数百。ティアごとのモデルは「モデルとサブスクリプション」ページで管理します。",
+ "admin.settings.sectionOcr": "OCR(スキャンPDF)",
+ "admin.settings.sectionOcrDesc": "翻訳前に画像ページからテキストを抽出します。",
+ "admin.settings.sectionEmails": "メール",
+ "admin.settings.sectionEmailsDesc": "通知やフォローアップメールを送るサーバー。",
+ "admin.settings.enabled": "有効",
+ "admin.settings.disabled": "無効",
+ "admin.settings.envBadge": ".envのキー",
+ "admin.settings.envConfigured": ".envに設定済み",
+ "admin.settings.envKeyPlaceholder": ".envに設定済みのキー(使う場合は空のまま)",
+ "admin.settings.apiKey": "APIキー",
+ "admin.settings.model": "モデル",
+ "admin.settings.baseUrl": "ベースURL",
+ "admin.settings.test": "テスト",
+ "admin.settings.testing": "テスト中...",
+ "admin.settings.ok": "OK",
+ "admin.settings.error": "エラー",
+ "admin.settings.failure": "失敗",
+ "admin.settings.sending": "送信中...",
+ "admin.settings.save": "設定を保存",
+ "admin.settings.saving": "保存中...",
+ "admin.settings.savedTitle": "✅ 設定を保存しました",
+ "admin.settings.savedDesc": "設定は正常に保存されました。",
+ "admin.settings.saveErrorTitle": "保存エラー",
+ "admin.settings.loadErrorTitle": "読み込みエラー",
+ "admin.settings.loadErrorDesc": "HTTP {status} — 管理者トークンを確認してください。",
+ "admin.settings.networkErrorTitle": "ネットワークエラー",
+ "admin.settings.networkErrorDesc": "バックエンドに接続できません。",
+ "admin.settings.networkSaveErrorDesc": "保存のためバックエンドに接続できません。",
+ "admin.settings.connectionOk": "接続OK",
+ "admin.settings.modelsFound": "{provider}のモデルが{count}件見つかりました",
+ "admin.settings.providerError": "{provider}エラー",
+ "admin.settings.google.title": "Google Translate",
+ "admin.settings.google.desc": "deep_translator経由の非公式ウェブアクセス。キー不要、適度な利用を推奨。",
+ "admin.settings.googleCloud.title": "Google Cloud Translation(公式)",
+ "admin.settings.googleCloud.desc": "Google Cloudの公式API。毎月50万文字まで無料、その後は約100万文字あたり20ドル。キーはconsole.cloud.google.comで取得。",
+ "admin.settings.googleCloud.hint": "Google Cloud ConsoleでCloud Translation APIを有効にし、そのAPIに限定したAPIキーを作成してください。",
+ "admin.settings.deepseek.title": "DeepSeek(直接)",
+ "admin.settings.deepseek.desc": "OpenAI互換のDeepSeek直接API。キーはplatform.deepseek.comで取得 — AIティアのチャネルとして使用可能。",
+ "admin.settings.zhipu.title": "z.ai / Zhipu(GLM)",
+ "admin.settings.zhipu.desc": "z.aiのGLMモデルへの直接アクセス、OpenAI互換。キーはz.aiで取得 — AIティアのチャネルとして使用可能。",
+ "admin.settings.zhipu.urlHint": "既定:https://api.z.ai/api/paas/v4 — プロキシを使う場合のみ変更。",
+ "admin.settings.minimax.title": "MiniMax(直接)",
+ "admin.settings.minimax.desc": "OpenAI互換のMiniMax公開API。キーはminimax.ioで取得 — AIティアのチャネルとして使用可能。",
+ "admin.settings.openai.title": "OpenAI",
+ "admin.settings.openai.desc": "GPT翻訳。platform.openai.comでキーを取得してください。",
+ "admin.settings.zai.title": "xAI(Grok)",
+ "admin.settings.zai.desc": "xAIのGrokモデル。OpenAI互換API。キーはx.aiで取得してください。",
+ "admin.settings.zai.urlHint": "既定:https://api.x.ai/v1 — プロキシを使う場合のみ変更。",
+ "admin.settings.openrouter.title": "OpenRouter — ゲートウェイキー",
+ "admin.settings.openrouter.desc": "Essentialティア(Proプラン)。現在のラインナップ:deepseek/deepseek-v4-flash、z-ai/glm-5.3-flash、minimax/minimax-m3。",
+ "admin.settings.openrouter.keyLabel": "OpenRouter APIキー(Premiumと共有)",
+ "admin.settings.openrouter.envSharedPlaceholder": ".envに設定済みのキー(Premiumと共有)",
+ "admin.settings.openrouter.reminder": "モデルは「モデルとサブスクリプション」ページで管理します。",
+ "admin.settings.openrouterPremium.title": "OpenRouter Premium — ゲートウェイキー",
+ "admin.settings.openrouterPremium.desc": "Premiumティア(Businessプラン)。現在のラインナップ:anthropic/claude-sonnet-5、deepseek/deepseek-v4-pro、z-ai/glm-5.3。",
+ "admin.settings.openrouterPremium.reminder": "モデルは「モデルとサブスクリプション」ページで管理します。同じOpenRouterキーを共有します。",
+ "admin.settings.mistral.title": "Mistral OCR(スキャンPDF)",
+ "admin.settings.mistral.desc": "スキャンPDF(画像ページ)から翻訳前にテキストを抽出します。キーがない場合、これらのファイルは明確なメッセージ付きで拒否されます。約1ユーロ/1,000ページ。キー:console.mistral.ai。",
+ "admin.settings.mistral.keyLabel": "Mistral APIキー",
+ "admin.settings.mistral.modelLabel": "OCRモデル",
+ "admin.settings.mistral.modelHint": "推奨:mistral-ocr-latest",
+ "admin.settings.smtp.title": "SMTPメール",
+ "admin.settings.smtp.desc": "メール送信のためのSMTPサーバー設定(パスワード再設定、通知、フォローアップ)。",
+ "admin.settings.smtp.host": "SMTPホスト",
+ "admin.settings.smtp.port": "ポート",
+ "admin.settings.smtp.username": "ユーザー名",
+ "admin.settings.smtp.password": "パスワード",
+ "admin.settings.smtp.fromEmail": "送信元アドレス",
+ "admin.settings.smtp.useTls": "TLSを使用",
+ "admin.settings.smtp.sendTest": "テストメールを送信",
+ "admin.settings.smtp.sent": "送信済み",
+ "admin.settings.smtp.sendFailed": "送信に失敗しました",
+ "admin.models.channelOpenrouter": "OpenRouter",
+ "admin.models.channelOpenrouterPremium": "OpenRouter(Premium)",
+ "admin.models.channelDeepseek": "DeepSeek(直接)",
+ "admin.models.channelZhipu": "z.ai(GLM)",
+ "admin.models.channelMinimax": "MiniMax(直接)",
+ "admin.models.channelOpenai": "OpenAI",
+ "admin.models.channelZai": "xAI(Grok)",
+ "admin.models.customModelPlaceholder": "モデルID",
+ "admin.models.routeHint": "チャネルで直接アクセスかゲートウェイかを選びます。モデルIDはチャネルごとに異なります。",
+ "admin.models.addRouteConfirm": "追加",
+ "admin.marketing.generateTitle": "AIで生成",
+ "admin.marketing.generateDesc": "文章はマーケティングプラン(ポジショニングと許可された数値)に基づいて書かれます。自動送信は一切ありません。確認してから送信してください。",
+ "admin.marketing.channel": "チャネル",
+ "admin.marketing.model": "モデル",
+ "admin.marketing.modelPlaceholder": "チャネルの既定",
+ "admin.marketing.promoOptional": "割引(任意)",
+ "admin.marketing.promoNone": "なし",
+ "admin.marketing.brief": "概要",
+ "admin.marketing.briefPlaceholder": "例:ウェイティングリスト登録者にProプランの特典を思い出してもらう。",
+ "admin.marketing.language": "メールの言語",
+ "admin.marketing.tone": "トーン",
+ "admin.marketing.toneNone": "標準",
+ "admin.marketing.generateBtn": "生成",
+ "admin.marketing.generating": "生成中...",
+ "admin.marketing.generatedTitle": "メールを生成しました",
+ "admin.marketing.generatedDesc": "件名とHTMLをフォームに挿入しました — 送信前に確認してください({provider})。",
+ "admin.marketing.generateFailedTitle": "生成できませんでした",
+ "admin.marketing.briefRequired": "まず概要に希望する内容を記入してください。",
+ "admin.marketing.langFr": "フランス語",
+ "admin.marketing.langEn": "英語",
+ "admin.marketing.langDe": "ドイツ語",
+ "admin.marketing.langEs": "スペイン語",
+ "admin.marketing.langIt": "イタリア語",
+ "admin.marketing.langPt": "ポルトガル語",
+ "admin.marketing.langNl": "オランダ語",
+ "admin.marketing.langAr": "アラビア語",
+ "admin.marketing.langFa": "ペルシャ語",
+ "admin.marketing.langJa": "日本語",
+ "admin.marketing.langKo": "韓国語",
+ "admin.marketing.langRu": "ロシア語",
+ "admin.marketing.langZh": "中国語",
+ "admin.marketing.toneFriendly": "親しみやすく",
+ "admin.marketing.toneProfessional": "プロフェッショナル",
+ "admin.marketing.toneUrgent": "緊急",
+ "admin.marketing.toneFestive": "お祝い",
+ "admin.marketing.promoTitle": "割引",
+ "admin.marketing.promoDesc": "プロモコードはStripeの支払いに適用されます。カウンターはサブスクリプションが実際に完了したときだけ増えます。",
+ "admin.marketing.promoEmpty": "プロモコードはまだありません。",
+ "admin.marketing.promoActive": "有効",
+ "admin.marketing.promoInactive": "無効",
+ "admin.marketing.promoLocalOnly": "Stripe外",
+ "admin.marketing.promoDeactivate": "無効化",
+ "admin.marketing.promoUsage": "{used}/{max}回使用",
+ "admin.marketing.promoPlansLabel": "プラン:",
+ "admin.marketing.promoAllPlans": "すべて",
+ "admin.marketing.promoExpiresOn": "{date}に期限切れ",
+ "admin.marketing.promoNeverExpires": "期限なし",
+ "admin.marketing.promoCreateTitle": "割引を作成",
+ "admin.marketing.promoCreateDesc": "Stripeが設定されている場合、コードはStripeにも作成されます(クーポン+プロモコード)。",
+ "admin.marketing.promoCode": "コード",
+ "admin.marketing.promoType": "種類",
+ "admin.marketing.promoTypePercent": "パーセント",
+ "admin.marketing.promoTypeAmount": "金額(EUR)",
+ "admin.marketing.promoValue": "値",
+ "admin.marketing.promoPlans": "対象プラン",
+ "admin.marketing.promoMaxRedemptions": "使用上限",
+ "admin.marketing.promoMaxPlaceholder": "無制限",
+ "admin.marketing.promoExpires": "有効期限",
+ "admin.marketing.promoCreate": "コードを作成",
+ "admin.marketing.promoCreating": "作成中...",
+ "admin.marketing.promoCreatedTitle": "プロモコードを作成しました",
+ "admin.marketing.promoCreatedDesc": "コード{code}は準備できました。",
+ "admin.marketing.promoCreateFailedTitle": "作成できませんでした",
+ "admin.marketing.promoInvalidInput": "コードと値を確認してください。",
+ "admin.marketing.promoDeactivatedTitle": "コードを無効化しました",
+ "admin.marketing.promoDeactivatedDesc": "コード{code}はもう適用されません。",
+ "admin.settings.fallback.title": "フォールバックチェーン",
+ "admin.settings.fallback.desc": "プロバイダー自動選択の優先順位。",
+ "admin.settings.fallback.classic": "クラシックモード(Google)",
+ "admin.settings.fallback.llm": "LLMモード(ゲートウェイと直接アクセス)",
+ "admin.settings.fallback.note": "このチェーンに参加するのは登録済みチャネル(google、openai、deepseek、minimax)のみです。AIティアや他のチャネルは参加しません。",
+ "admin.models.duplicateRouteTitle": "重複は拒否されました",
+ "admin.models.duplicateRouteDesc": "そのチャネル+モデルの組み合わせは既にリストにあります。",
+ "admin.marketing.promoDeactivateFailedTitle": "無効化できませんでした",
+ "admin.marketing.promoDelete": "削除",
+ "admin.marketing.promoDeleteConfirm": "コード{code}を完全に削除しますか?",
+ "admin.marketing.promoDeletedTitle": "コードを削除しました",
+ "admin.marketing.promoDeletedDesc": "コード{code}を削除しました。",
+ "admin.marketing.promoDeleteFailedTitle": "削除できませんでした",
+ "admin.marketing.promoLinkStripe": "Stripeにリンク",
+ "admin.marketing.promoLinkedTitle": "コードをStripeにリンクしました",
+ "admin.marketing.promoLinkedDesc": "コード{code}は決済で使えるようになりました。",
+ "admin.marketing.promoLinkFailedTitle": "リンクできませんでした",
+ "admin.marketing.promoExpiryHint": "「10日まで」のコードは10日いっぱい(23:59:59 UTCまで)有効です。",
+ "admin.marketing.generateEmpty": "サーバーの応答が空か、利用できませんでした。"
}
diff --git a/frontend/src/lib/i18n/messages/ja/pricing.json b/frontend/src/lib/i18n/messages/ja/pricing.json
index aace9c5..f0cf8e5 100644
--- a/frontend/src/lib/i18n/messages/ja/pricing.json
+++ b/frontend/src/lib/i18n/messages/ja/pricing.json
@@ -167,5 +167,17 @@
"pricing.header.titleBase": "あらゆるニーズに",
"pricing.header.titleAccent": "ぴったりのプラン",
"pricing.toast.close": "閉じる",
- "pricing.aiModels.premium.price": "100万トークンあたり $2 / $10"
+ "pricing.aiModels.premium.price": "100万トークンあたり $2 / $10",
+ "pricing.promo.label": "プロモコード",
+ "pricing.promo.placeholder": "あなたのコード",
+ "pricing.promo.apply": "適用",
+ "pricing.promo.checking": "確認中...",
+ "pricing.promo.valid": "コードは有効です",
+ "pricing.promo.subscriptionOnly": "サブスクリプションのみ有効。クレジットには使えません。",
+ "pricing.promoError.unknown": "このプロモコードは存在しません。",
+ "pricing.promoError.inactive": "このプロモコードは無効になっています。",
+ "pricing.promoError.expired": "このプロモコードは期限切れです。",
+ "pricing.promoError.exhausted": "このプロモコードは使用上限に達しました。",
+ "pricing.promoError.plan": "このプロモコードはこのプランには使えません。",
+ "pricing.promoError.local_only": "このプロモコードはオンライン決済に対応していません。"
}
diff --git a/frontend/src/lib/i18n/messages/ko/admin.json b/frontend/src/lib/i18n/messages/ko/admin.json
index 9c23a00..db16e7a 100644
--- a/frontend/src/lib/i18n/messages/ko/admin.json
+++ b/frontend/src/lib/i18n/messages/ko/admin.json
@@ -145,5 +145,180 @@
"admin.stats.tiersSub": "에센셜 / 프리미엄 · 클래식 {classic} · 기타 {other}",
"admin.stats.unavailable": "비즈니스 통계를 사용할 수 없습니다 ({error}).",
"admin.stats.waitlist": "대기 명단",
- "admin.stats.waitlistSub": "대기 중인 등록자"
+ "admin.stats.waitlistSub": "대기 중인 등록자",
+ "admin.settings.title": "공급자",
+ "admin.settings.subtitle": "공급자 접근을 설정합니다. API 키는 .env 파일에서도 설정할 수 있습니다.",
+ "admin.settings.sectionClassic": "클래식 번역",
+ "admin.settings.sectionClassicDesc": "표준 번역을 위한 Google 엔진.",
+ "admin.settings.sectionDirect": "직접 AI 공급자",
+ "admin.settings.sectionDirectDesc": "OpenAI 호환 직접 접근 — AI 등급의 채널(「모델 및 구독」 페이지)이나 이메일 생성에 사용할 수 있습니다.",
+ "admin.settings.sectionGateway": "멀티 모델 게이트웨이",
+ "admin.settings.sectionGatewayDesc": "키 하나로 수백 가지 모델. 등급별 모델은 「모델 및 구독」 페이지에서 관리합니다.",
+ "admin.settings.sectionOcr": "OCR (스캔 PDF)",
+ "admin.settings.sectionOcrDesc": "번역 전에 이미지 페이지에서 텍스트를 추출합니다.",
+ "admin.settings.sectionEmails": "이메일",
+ "admin.settings.sectionEmailsDesc": "알림과 후속 이메일을 보내는 서버.",
+ "admin.settings.enabled": "사용 함",
+ "admin.settings.disabled": "사용 안 함",
+ "admin.settings.envBadge": ".env의 키",
+ "admin.settings.envConfigured": ".env에 설정됨",
+ "admin.settings.envKeyPlaceholder": ".env에 설정된 키(사용하려면 비워 두세요)",
+ "admin.settings.apiKey": "API 키",
+ "admin.settings.model": "모델",
+ "admin.settings.baseUrl": "기본 URL",
+ "admin.settings.test": "테스트",
+ "admin.settings.testing": "테스트 중...",
+ "admin.settings.ok": "확인",
+ "admin.settings.error": "오류",
+ "admin.settings.failure": "실패",
+ "admin.settings.sending": "보내는 중...",
+ "admin.settings.save": "설정 저장",
+ "admin.settings.saving": "저장 중...",
+ "admin.settings.savedTitle": "✅ 설정이 저장되었습니다",
+ "admin.settings.savedDesc": "설정이 성공적으로 저장되었습니다.",
+ "admin.settings.saveErrorTitle": "저장 오류",
+ "admin.settings.loadErrorTitle": "불러오기 오류",
+ "admin.settings.loadErrorDesc": "HTTP {status} — 관리자 토큰을 확인하세요.",
+ "admin.settings.networkErrorTitle": "네트워크 오류",
+ "admin.settings.networkErrorDesc": "백엔드에 연결할 수 없습니다.",
+ "admin.settings.networkSaveErrorDesc": "저장을 위해 백엔드에 연결할 수 없습니다.",
+ "admin.settings.connectionOk": "연결 정상",
+ "admin.settings.modelsFound": "{provider} 모델 {count}개를 찾았습니다",
+ "admin.settings.providerError": "{provider} 오류",
+ "admin.settings.google.title": "Google Translate",
+ "admin.settings.google.desc": "deep_translator를 통한 비공식 웹 접근. 키가 필요 없으며 적당한 사용을 권장합니다.",
+ "admin.settings.googleCloud.title": "Google Cloud Translation (공식)",
+ "admin.settings.googleCloud.desc": "Google Cloud 공식 API. 매월 50만 자 무료, 이후 백만 자당 약 20달러. 키는 console.cloud.google.com에서 발급.",
+ "admin.settings.googleCloud.hint": "Google Cloud Console에서 Cloud Translation API를 활성화하고 해당 API로 제한된 API 키를 만드세요.",
+ "admin.settings.deepseek.title": "DeepSeek (직접)",
+ "admin.settings.deepseek.desc": "OpenAI 호환 DeepSeek 직접 API. 키는 platform.deepseek.com에서 — AI 등급 채널로 사용 가능.",
+ "admin.settings.zhipu.title": "z.ai / Zhipu (GLM)",
+ "admin.settings.zhipu.desc": "z.ai의 GLM 모델 직접 접근, OpenAI 호환. 키는 z.ai에서 — AI 등급 채널로 사용 가능.",
+ "admin.settings.zhipu.urlHint": "기본값: https://api.z.ai/api/paas/v4 — 프록시를 사용할 때만 변경하세요.",
+ "admin.settings.minimax.title": "MiniMax (직접)",
+ "admin.settings.minimax.desc": "OpenAI 호환 MiniMax 공개 API. 키는 minimax.io에서 — AI 등급 채널로 사용 가능.",
+ "admin.settings.openai.title": "OpenAI",
+ "admin.settings.openai.desc": "GPT 번역. platform.openai.com에서 키를 받으세요.",
+ "admin.settings.zai.title": "xAI (Grok)",
+ "admin.settings.zai.desc": "xAI의 Grok 모델. OpenAI 호환 API. x.ai에서 키를 받으세요.",
+ "admin.settings.zai.urlHint": "기본값: https://api.x.ai/v1 — 프록시를 사용할 때만 변경하세요.",
+ "admin.settings.openrouter.title": "OpenRouter — 게이트웨이 키",
+ "admin.settings.openrouter.desc": "Essential 등급 (Pro 플랜). 현재 라인업: deepseek/deepseek-v4-flash, z-ai/glm-5.3-flash, minimax/minimax-m3.",
+ "admin.settings.openrouter.keyLabel": "OpenRouter API 키 (Premium과 공유)",
+ "admin.settings.openrouter.envSharedPlaceholder": ".env에 설정된 키(Premium과 공유)",
+ "admin.settings.openrouter.reminder": "모델은 「모델 및 구독」 페이지에서 관리합니다.",
+ "admin.settings.openrouterPremium.title": "OpenRouter Premium — 게이트웨이 키",
+ "admin.settings.openrouterPremium.desc": "Premium 등급 (Business 플랜). 현재 라인업: anthropic/claude-sonnet-5, deepseek/deepseek-v4-pro, z-ai/glm-5.3.",
+ "admin.settings.openrouterPremium.reminder": "모델은 「모델 및 구독」 페이지에서 관리합니다. 같은 OpenRouter 키를 공유합니다.",
+ "admin.settings.mistral.title": "Mistral OCR (스캔 PDF)",
+ "admin.settings.mistral.desc": "번역 전에 스캔된 PDF(이미지 페이지)에서 텍스트를 추출합니다. 키가 없으면 이런 파일은 명확한 안내와 함께 거부됩니다. 약 1유로/1,000페이지. 키: console.mistral.ai.",
+ "admin.settings.mistral.keyLabel": "Mistral API 키",
+ "admin.settings.mistral.modelLabel": "OCR 모델",
+ "admin.settings.mistral.modelHint": "권장: mistral-ocr-latest",
+ "admin.settings.smtp.title": "SMTP 이메일",
+ "admin.settings.smtp.desc": "이메일 발송을 위한 SMTP 서버 설정(비밀번호 찾기, 알림, 후속 이메일).",
+ "admin.settings.smtp.host": "SMTP 호스트",
+ "admin.settings.smtp.port": "포트",
+ "admin.settings.smtp.username": "사용자 이름",
+ "admin.settings.smtp.password": "비밀번호",
+ "admin.settings.smtp.fromEmail": "발신 주소",
+ "admin.settings.smtp.useTls": "TLS 사용",
+ "admin.settings.smtp.sendTest": "테스트 이메일 보내기",
+ "admin.settings.smtp.sent": "보냄",
+ "admin.settings.smtp.sendFailed": "보내기 실패",
+ "admin.models.channelOpenrouter": "OpenRouter",
+ "admin.models.channelOpenrouterPremium": "OpenRouter (Premium)",
+ "admin.models.channelDeepseek": "DeepSeek (직접)",
+ "admin.models.channelZhipu": "z.ai (GLM)",
+ "admin.models.channelMinimax": "MiniMax (직접)",
+ "admin.models.channelOpenai": "OpenAI",
+ "admin.models.channelZai": "xAI (Grok)",
+ "admin.models.customModelPlaceholder": "모델 ID",
+ "admin.models.routeHint": "채널은 직접 접근과 게이트웨이 중 하나를 고릅니다. 모델 ID는 채널마다 다릅니다.",
+ "admin.models.addRouteConfirm": "추가",
+ "admin.marketing.generateTitle": "AI로 생성",
+ "admin.marketing.generateDesc": "문안은 마케팅 계획(포지셔닝과 허용된 수치)에 따라 작성됩니다. 아무것도 자동 발송되지 않습니다. 확인 후 보내세요.",
+ "admin.marketing.channel": "채널",
+ "admin.marketing.model": "모델",
+ "admin.marketing.modelPlaceholder": "채널 기본값",
+ "admin.marketing.promoOptional": "할인 (선택)",
+ "admin.marketing.promoNone": "없음",
+ "admin.marketing.brief": "브리프",
+ "admin.marketing.briefPlaceholder": "예: 대기자 명단 등록자에게 Pro 플랜의 장점을 상기시키기.",
+ "admin.marketing.language": "이메일 언어",
+ "admin.marketing.tone": "어조",
+ "admin.marketing.toneNone": "중립",
+ "admin.marketing.generateBtn": "생성",
+ "admin.marketing.generating": "생성 중...",
+ "admin.marketing.generatedTitle": "이메일이 생성되었습니다",
+ "admin.marketing.generatedDesc": "제목과 HTML이 폼에 삽입되었습니다 — 보내기 전에 확인하세요({provider}).",
+ "admin.marketing.generateFailedTitle": "생성 실패",
+ "admin.marketing.briefRequired": "먼저 브리프에 원하는 내용을 적어주세요.",
+ "admin.marketing.langFr": "프랑스어",
+ "admin.marketing.langEn": "영어",
+ "admin.marketing.langDe": "독일어",
+ "admin.marketing.langEs": "스페인어",
+ "admin.marketing.langIt": "이탈리아어",
+ "admin.marketing.langPt": "포르투갈어",
+ "admin.marketing.langNl": "네덜란드어",
+ "admin.marketing.langAr": "아랍어",
+ "admin.marketing.langFa": "페르시아어",
+ "admin.marketing.langJa": "일본어",
+ "admin.marketing.langKo": "한국어",
+ "admin.marketing.langRu": "러시아어",
+ "admin.marketing.langZh": "중국어",
+ "admin.marketing.toneFriendly": "친근함",
+ "admin.marketing.toneProfessional": "전문적",
+ "admin.marketing.toneUrgent": "긴급",
+ "admin.marketing.toneFestive": "축제",
+ "admin.marketing.promoTitle": "할인",
+ "admin.marketing.promoDesc": "프로모 코드는 Stripe 결제에 적용됩니다. 카운터는 구독이 실제로 완료되었을 때만 올라갑니다.",
+ "admin.marketing.promoEmpty": "아직 프로모 코드가 없습니다.",
+ "admin.marketing.promoActive": "활성",
+ "admin.marketing.promoInactive": "비활성",
+ "admin.marketing.promoLocalOnly": "Stripe 외부",
+ "admin.marketing.promoDeactivate": "비활성화",
+ "admin.marketing.promoUsage": "{used}/{max}회 사용",
+ "admin.marketing.promoPlansLabel": "플랜:",
+ "admin.marketing.promoAllPlans": "전체",
+ "admin.marketing.promoExpiresOn": "{date}에 만료",
+ "admin.marketing.promoNeverExpires": "만료 없음",
+ "admin.marketing.promoCreateTitle": "할인 만들기",
+ "admin.marketing.promoCreateDesc": "Stripe가 설정되어 있으면 코드는 Stripe에도 생성됩니다(쿠폰 + 프로모 코드).",
+ "admin.marketing.promoCode": "코드",
+ "admin.marketing.promoType": "유형",
+ "admin.marketing.promoTypePercent": "비율",
+ "admin.marketing.promoTypeAmount": "금액 (EUR)",
+ "admin.marketing.promoValue": "값",
+ "admin.marketing.promoPlans": "대상 플랜",
+ "admin.marketing.promoMaxRedemptions": "사용 한도",
+ "admin.marketing.promoMaxPlaceholder": "무제한",
+ "admin.marketing.promoExpires": "만료",
+ "admin.marketing.promoCreate": "코드 만들기",
+ "admin.marketing.promoCreating": "만드는 중...",
+ "admin.marketing.promoCreatedTitle": "프로모 코드 생성됨",
+ "admin.marketing.promoCreatedDesc": "코드 {code}이(가) 준비되었습니다.",
+ "admin.marketing.promoCreateFailedTitle": "생성이 거부되었습니다",
+ "admin.marketing.promoInvalidInput": "코드와 값을 확인하세요.",
+ "admin.marketing.promoDeactivatedTitle": "코드가 비활성화되었습니다",
+ "admin.marketing.promoDeactivatedDesc": "코드 {code}은(는) 더 이상 적용되지 않습니다.",
+ "admin.settings.fallback.title": "폴백 체인",
+ "admin.settings.fallback.desc": "공급자 자동 선택의 우선순위.",
+ "admin.settings.fallback.classic": "클래식 모드 (Google)",
+ "admin.settings.fallback.llm": "LLM 모드 (게이트웨이 및 직접 접근)",
+ "admin.settings.fallback.note": "등록된 채널(google, openai, deepseek, minimax)만 이 체인에 참여합니다. AI 등급과 다른 채널은 참여하지 않습니다.",
+ "admin.models.duplicateRouteTitle": "중복 거부됨",
+ "admin.models.duplicateRouteDesc": "그 채널 + 모델 조합은 이미 목록에 있습니다.",
+ "admin.marketing.promoDeactivateFailedTitle": "비활성화 거부됨",
+ "admin.marketing.promoDelete": "삭제",
+ "admin.marketing.promoDeleteConfirm": "코드 {code}을(를) 완전히 삭제할까요?",
+ "admin.marketing.promoDeletedTitle": "코드 삭제됨",
+ "admin.marketing.promoDeletedDesc": "코드 {code}이(가) 삭제되었습니다.",
+ "admin.marketing.promoDeleteFailedTitle": "삭제 거부됨",
+ "admin.marketing.promoLinkStripe": "Stripe에 연결",
+ "admin.marketing.promoLinkedTitle": "코드가 Stripe에 연결됨",
+ "admin.marketing.promoLinkedDesc": "코드 {code}은(는) 이제 결제에 적용할 수 있습니다.",
+ "admin.marketing.promoLinkFailedTitle": "연결 실패",
+ "admin.marketing.promoExpiryHint": "«10일까지» 코드는 10일 하루 종일 유효합니다(23:59:59 UTC 종료).",
+ "admin.marketing.generateEmpty": "서버 응답이 비어 있거나 사용할 수 없습니다."
}
diff --git a/frontend/src/lib/i18n/messages/ko/pricing.json b/frontend/src/lib/i18n/messages/ko/pricing.json
index a749137..0c4895d 100644
--- a/frontend/src/lib/i18n/messages/ko/pricing.json
+++ b/frontend/src/lib/i18n/messages/ko/pricing.json
@@ -167,5 +167,17 @@
"pricing.header.titleBase": "모든 필요를 위한",
"pricing.header.titleAccent": "하나의 플랜",
"pricing.toast.close": "닫기",
- "pricing.aiModels.premium.price": "백만 토큰당 $2 / $10"
+ "pricing.aiModels.premium.price": "백만 토큰당 $2 / $10",
+ "pricing.promo.label": "프로모 코드",
+ "pricing.promo.placeholder": "내코드",
+ "pricing.promo.apply": "적용",
+ "pricing.promo.checking": "확인 중...",
+ "pricing.promo.valid": "유효한 코드",
+ "pricing.promo.subscriptionOnly": "구독에만 유효하며 크레딧에는 사용할 수 없습니다.",
+ "pricing.promoError.unknown": "이 프로모 코드는 존재하지 않습니다.",
+ "pricing.promoError.inactive": "이 프로모 코드는 더 이상 활성 상태가 아닙니다.",
+ "pricing.promoError.expired": "이 프로모 코드는 만료되었습니다.",
+ "pricing.promoError.exhausted": "이 프로모 코드는 사용 한도에 도달했습니다.",
+ "pricing.promoError.plan": "이 프로모 코드는 이 플랜에 적용되지 않습니다.",
+ "pricing.promoError.local_only": "이 프로모 코드는 온라인 결제에 연결되어 있지 않습니다."
}
diff --git a/frontend/src/lib/i18n/messages/nl/admin.json b/frontend/src/lib/i18n/messages/nl/admin.json
index fe58eee..bd75aa8 100644
--- a/frontend/src/lib/i18n/messages/nl/admin.json
+++ b/frontend/src/lib/i18n/messages/nl/admin.json
@@ -145,5 +145,180 @@
"admin.stats.tiersSub": "Basis / Premium · klassiek {classic} · overig {other}",
"admin.stats.unavailable": "Bedrijfsstatistieken niet beschikbaar ({error}).",
"admin.stats.waitlist": "Wachtlijst",
- "admin.stats.waitlistSub": "inschrijvingen op de wachtlijst"
+ "admin.stats.waitlistSub": "inschrijvingen op de wachtlijst",
+ "admin.settings.title": "Aanbieders",
+ "admin.settings.subtitle": "Configureer de aanbieders Toegangen. API-sleutels kunnen ook in het .env-bestand worden gezet.",
+ "admin.settings.sectionClassic": "Klassieke vertaling",
+ "admin.settings.sectionClassicDesc": "Google-motoren voor de standaardvertaling.",
+ "admin.settings.sectionDirect": "Directe AI-aanbieders",
+ "admin.settings.sectionDirectDesc": "Directe OpenAI-compatibele toegangen — bruikbaar als kanaal van een AI-niveau (pagina «Modellen & abonnementen») of voor het genereren van e-mails.",
+ "admin.settings.sectionGateway": "Multi-model gateways",
+ "admin.settings.sectionGatewayDesc": "Eén sleutel, honderden modellen. De modellen per niveau worden beheerd op de pagina «Modellen & abonnementen».",
+ "admin.settings.sectionOcr": "OCR (gescande PDF's)",
+ "admin.settings.sectionOcrDesc": "Tekst uit afbeeldingspagina's halen vóór de vertaling.",
+ "admin.settings.sectionEmails": "E-mails",
+ "admin.settings.sectionEmailsDesc": "Verzendserver voor meldingen en opvolgmails.",
+ "admin.settings.enabled": "Ingeschakeld",
+ "admin.settings.disabled": "Uitgeschakeld",
+ "admin.settings.envBadge": "Sleutel in .env",
+ "admin.settings.envConfigured": "Ingesteld in .env",
+ "admin.settings.envKeyPlaceholder": "Sleutel ingesteld in .env (leeg laten om te gebruiken)",
+ "admin.settings.apiKey": "API-sleutel",
+ "admin.settings.model": "Model",
+ "admin.settings.baseUrl": "Basis-URL",
+ "admin.settings.test": "Testen",
+ "admin.settings.testing": "Testen...",
+ "admin.settings.ok": "OK",
+ "admin.settings.error": "Fout",
+ "admin.settings.failure": "Mislukt",
+ "admin.settings.sending": "Verzenden...",
+ "admin.settings.save": "Configuratie opslaan",
+ "admin.settings.saving": "Opslaan...",
+ "admin.settings.savedTitle": "✅ Configuratie opgeslagen",
+ "admin.settings.savedDesc": "De instellingen zijn succesvol opgeslagen.",
+ "admin.settings.saveErrorTitle": "Fout bij opslaan",
+ "admin.settings.loadErrorTitle": "Laadfout",
+ "admin.settings.loadErrorDesc": "HTTP {status} — controleer uw admin-token.",
+ "admin.settings.networkErrorTitle": "Netwerkfout",
+ "admin.settings.networkErrorDesc": "Kan de backend niet bereiken.",
+ "admin.settings.networkSaveErrorDesc": "Kan de backend niet bereiken om op te slaan.",
+ "admin.settings.connectionOk": "Verbinding OK",
+ "admin.settings.modelsFound": "{count} {provider}-modellen gevonden",
+ "admin.settings.providerError": "Fout {provider}",
+ "admin.settings.google.title": "Google Translate",
+ "admin.settings.google.desc": "Officiële webtoegang via deep_translator. Geen sleutel nodig, gematigd gebruik aanbevolen.",
+ "admin.settings.googleCloud.title": "Google Cloud Translation (officieel)",
+ "admin.settings.googleCloud.desc": "Officiële Google Cloud API. 500.000 tekens/maand gratis, daarna ca. $20 per miljoen tekens. API-sleutel op console.cloud.google.com.",
+ "admin.settings.googleCloud.hint": "Schakel de Cloud Translation API in Google Cloud Console in en maak een API-sleutel die tot die API beperkt is.",
+ "admin.settings.deepseek.title": "DeepSeek (direct)",
+ "admin.settings.deepseek.desc": "Directe DeepSeek-API, OpenAI-compatibel. Sleutel op platform.deepseek.com — bruikbaar als kanaal van een AI-niveau.",
+ "admin.settings.zhipu.title": "z.ai / Zhipu (GLM)",
+ "admin.settings.zhipu.desc": "Directe toegang tot de GLM-modellen van z.ai, OpenAI-compatibel. Sleutel op z.ai — bruikbaar als kanaal van een AI-niveau.",
+ "admin.settings.zhipu.urlHint": "Standaard: https://api.z.ai/api/paas/v4 — wijzig dit alleen als u een proxy gebruikt.",
+ "admin.settings.minimax.title": "MiniMax (direct)",
+ "admin.settings.minimax.desc": "Publieke MiniMax-API, OpenAI-compatibel. Sleutel op minimax.io — bruikbaar als kanaal van een AI-niveau.",
+ "admin.settings.openai.title": "OpenAI",
+ "admin.settings.openai.desc": "GPT-vertalingen. Vraag een sleutel aan op platform.openai.com.",
+ "admin.settings.zai.title": "xAI (Grok)",
+ "admin.settings.zai.desc": "Grok-modellen van xAI. OpenAI-compatibele API. Vraag uw sleutel aan op x.ai.",
+ "admin.settings.zai.urlHint": "Standaard: https://api.x.ai/v1 — wijzig dit alleen als u een proxy gebruikt.",
+ "admin.settings.openrouter.title": "OpenRouter — gateway-sleutel",
+ "admin.settings.openrouter.desc": "Essential-niveau (Pro-abonnement). Huidige reeks: deepseek/deepseek-v4-flash, z-ai/glm-5.3-flash, minimax/minimax-m3.",
+ "admin.settings.openrouter.keyLabel": "OpenRouter API-sleutel (gedeeld met Premium)",
+ "admin.settings.openrouter.envSharedPlaceholder": "Sleutel ingesteld in .env (gedeeld met Premium)",
+ "admin.settings.openrouter.reminder": "Modellen worden beheerd op de pagina «Modellen & abonnementen».",
+ "admin.settings.openrouterPremium.title": "OpenRouter Premium — gateway-sleutel",
+ "admin.settings.openrouterPremium.desc": "Premium-niveau (Business-abonnement). Huidige reeks: anthropic/claude-sonnet-5, deepseek/deepseek-v4-pro, z-ai/glm-5.3.",
+ "admin.settings.openrouterPremium.reminder": "Modellen worden beheerd op de pagina «Modellen & abonnementen». Deelt dezelfde OpenRouter-sleutel.",
+ "admin.settings.mistral.title": "Mistral OCR (gescande PDF's)",
+ "admin.settings.mistral.desc": "Haalt tekst uit gescande PDF's (afbeeldingspagina's) vóór de vertaling. Zonder sleutel worden deze bestanden geweigerd met een duidelijke melding. Ca. €1 / 1.000 pagina's. Sleutel: console.mistral.ai.",
+ "admin.settings.mistral.keyLabel": "Mistral API-sleutel",
+ "admin.settings.mistral.modelLabel": "OCR-model",
+ "admin.settings.mistral.modelHint": "Aanbevolen: mistral-ocr-latest",
+ "admin.settings.smtp.title": "SMTP-e-mail",
+ "admin.settings.smtp.desc": "SMTP-serverinstellingen voor het versturen van e-mails (wachtwoord vergeten, meldingen, opvolgmails).",
+ "admin.settings.smtp.host": "SMTP-host",
+ "admin.settings.smtp.port": "Poort",
+ "admin.settings.smtp.username": "Gebruikersnaam",
+ "admin.settings.smtp.password": "Wachtwoord",
+ "admin.settings.smtp.fromEmail": "Afzenderadres",
+ "admin.settings.smtp.useTls": "TLS gebruiken",
+ "admin.settings.smtp.sendTest": "Stuur een testmail",
+ "admin.settings.smtp.sent": "Verzonden",
+ "admin.settings.smtp.sendFailed": "Verzenden mislukt",
+ "admin.models.channelOpenrouter": "OpenRouter",
+ "admin.models.channelOpenrouterPremium": "OpenRouter (Premium)",
+ "admin.models.channelDeepseek": "DeepSeek (direct)",
+ "admin.models.channelZhipu": "z.ai (GLM)",
+ "admin.models.channelMinimax": "MiniMax (direct)",
+ "admin.models.channelOpenai": "OpenAI",
+ "admin.models.channelZai": "xAI (Grok)",
+ "admin.models.customModelPlaceholder": "model-id",
+ "admin.models.routeHint": "Het kanaal kiest directe toegang of gateway; model-id's verschillen per kanaal.",
+ "admin.models.addRouteConfirm": "Toevoegen",
+ "admin.marketing.generateTitle": "Genereren met AI",
+ "admin.marketing.generateDesc": "De tekst wordt geschreven op basis van het marketingplan (positionering en toegestane cijfers). Niets wordt automatisch verstuurd: eerst lezen, dan versturen.",
+ "admin.marketing.channel": "Kanaal",
+ "admin.marketing.model": "Model",
+ "admin.marketing.modelPlaceholder": "Standaard van het kanaal",
+ "admin.marketing.promoOptional": "Korting (optioneel)",
+ "admin.marketing.promoNone": "Geen",
+ "admin.marketing.brief": "Briefing",
+ "admin.marketing.briefPlaceholder": "Bijv.: wachtlijstaanmeldingen herinneren aan de voordelen van het Pro-abonnement.",
+ "admin.marketing.language": "Taal van de e-mail",
+ "admin.marketing.tone": "Toon",
+ "admin.marketing.toneNone": "Neutraal",
+ "admin.marketing.generateBtn": "Genereren",
+ "admin.marketing.generating": "Genereren...",
+ "admin.marketing.generatedTitle": "E-mail gegenereerd",
+ "admin.marketing.generatedDesc": "Onderwerp en HTML in het formulier geplaatst — eerst controleren voordat u verstuurt ({provider}).",
+ "admin.marketing.generateFailedTitle": "Genereren mislukt",
+ "admin.marketing.briefRequired": "Beschrijf eerst de gewenste inhoud in de briefing.",
+ "admin.marketing.langFr": "Frans",
+ "admin.marketing.langEn": "Engels",
+ "admin.marketing.langDe": "Duits",
+ "admin.marketing.langEs": "Spaans",
+ "admin.marketing.langIt": "Italiaans",
+ "admin.marketing.langPt": "Portugees",
+ "admin.marketing.langNl": "Nederlands",
+ "admin.marketing.langAr": "Arabisch",
+ "admin.marketing.langFa": "Perzisch",
+ "admin.marketing.langJa": "Japans",
+ "admin.marketing.langKo": "Koreaans",
+ "admin.marketing.langRu": "Russisch",
+ "admin.marketing.langZh": "Chinees",
+ "admin.marketing.toneFriendly": "Vriendelijk",
+ "admin.marketing.toneProfessional": "Professioneel",
+ "admin.marketing.toneUrgent": "Dringend",
+ "admin.marketing.toneFestive": "Festief",
+ "admin.marketing.promoTitle": "Kortingen",
+ "admin.marketing.promoDesc": "Promocodes worden via Stripe op de betaling toegepast; de teller stijgt pas als het abonnement echt is afgerond.",
+ "admin.marketing.promoEmpty": "Nog geen promocodes.",
+ "admin.marketing.promoActive": "Actief",
+ "admin.marketing.promoInactive": "Uitgeschakeld",
+ "admin.marketing.promoLocalOnly": "Buiten Stripe",
+ "admin.marketing.promoDeactivate": "Uitschakelen",
+ "admin.marketing.promoUsage": "{used}/{max} keer gebruikt",
+ "admin.marketing.promoPlansLabel": "Abonnementen:",
+ "admin.marketing.promoAllPlans": "Alle",
+ "admin.marketing.promoExpiresOn": "vervalt op {date}",
+ "admin.marketing.promoNeverExpires": "geen vervaldatum",
+ "admin.marketing.promoCreateTitle": "Korting aanmaken",
+ "admin.marketing.promoCreateDesc": "De code wordt ook in Stripe aangemaakt (coupon + promotiecode) wanneer Stripe is ingesteld.",
+ "admin.marketing.promoCode": "Code",
+ "admin.marketing.promoType": "Type",
+ "admin.marketing.promoTypePercent": "Percentage",
+ "admin.marketing.promoTypeAmount": "Bedrag (EUR)",
+ "admin.marketing.promoValue": "Waarde",
+ "admin.marketing.promoPlans": "Geschikte abonnementen",
+ "admin.marketing.promoMaxRedemptions": "Inwissellimiet",
+ "admin.marketing.promoMaxPlaceholder": "Onbeperkt",
+ "admin.marketing.promoExpires": "Vervaldatum",
+ "admin.marketing.promoCreate": "Code aanmaken",
+ "admin.marketing.promoCreating": "Aanmaken...",
+ "admin.marketing.promoCreatedTitle": "Promocode aangemaakt",
+ "admin.marketing.promoCreatedDesc": "De code {code} is klaar.",
+ "admin.marketing.promoCreateFailedTitle": "Aanmaak geweigerd",
+ "admin.marketing.promoInvalidInput": "Controleer de code en de waarde.",
+ "admin.marketing.promoDeactivatedTitle": "Code uitgeschakeld",
+ "admin.marketing.promoDeactivatedDesc": "De code {code} geldt niet meer.",
+ "admin.settings.fallback.title": "Fallback-keten",
+ "admin.settings.fallback.desc": "Prioriteitsvolgorde voor de automatische selectie van aanbieders.",
+ "admin.settings.fallback.classic": "Klassieke modus (Google)",
+ "admin.settings.fallback.llm": "LLM-modus (gateways en directe toegangen)",
+ "admin.settings.fallback.note": "Alleen geregistreerde kanalen (google, openai, deepseek, minimax) doen mee aan deze keten; AI-niveaus en andere kanalen niet.",
+ "admin.models.duplicateRouteTitle": "Duplicaat geweigerd",
+ "admin.models.duplicateRouteDesc": "Dit kanaal-+-model paar staat al in de lijst.",
+ "admin.marketing.promoDeactivateFailedTitle": "Uitschakelen geweigerd",
+ "admin.marketing.promoDelete": "Verwijderen",
+ "admin.marketing.promoDeleteConfirm": "Code {code} definitief verwijderen?",
+ "admin.marketing.promoDeletedTitle": "Code verwijderd",
+ "admin.marketing.promoDeletedDesc": "De code {code} is verwijderd.",
+ "admin.marketing.promoDeleteFailedTitle": "Verwijderen geweigerd",
+ "admin.marketing.promoLinkStripe": "Koppelen aan Stripe",
+ "admin.marketing.promoLinkedTitle": "Code gekoppeld aan Stripe",
+ "admin.marketing.promoLinkedDesc": "De code {code} is nu toepasbaar bij de betaling.",
+ "admin.marketing.promoLinkFailedTitle": "Koppelen mislukt",
+ "admin.marketing.promoExpiryHint": "Een code «tot en met de 10e» blijft de hele 10e geldig (einde: 23:59:59 UTC).",
+ "admin.marketing.generateEmpty": "Het antwoord van de server was leeg of onbruikbaar."
}
diff --git a/frontend/src/lib/i18n/messages/nl/pricing.json b/frontend/src/lib/i18n/messages/nl/pricing.json
index 5e41bcf..29347a8 100644
--- a/frontend/src/lib/i18n/messages/nl/pricing.json
+++ b/frontend/src/lib/i18n/messages/nl/pricing.json
@@ -167,5 +167,17 @@
"pricing.header.titleBase": "Een plan voor",
"pricing.header.titleAccent": "elke behoefte",
"pricing.toast.close": "Sluiten",
- "pricing.aiModels.premium.price": "$2 / $10 per 1M tokens"
+ "pricing.aiModels.premium.price": "$2 / $10 per 1M tokens",
+ "pricing.promo.label": "Promocode",
+ "pricing.promo.placeholder": "JOUWCODE",
+ "pricing.promo.apply": "Toepassen",
+ "pricing.promo.checking": "Controleren...",
+ "pricing.promo.valid": "Code geldig",
+ "pricing.promo.subscriptionOnly": "Geldig op abonnementen, niet op credits.",
+ "pricing.promoError.unknown": "Deze promocode bestaat niet.",
+ "pricing.promoError.inactive": "Deze promocode is niet meer actief.",
+ "pricing.promoError.expired": "Deze promocode is verlopen.",
+ "pricing.promoError.exhausted": "Deze promocode heeft zijn inwissellimiet bereikt.",
+ "pricing.promoError.plan": "Deze promocode geldt niet voor dit abonnement.",
+ "pricing.promoError.local_only": "Deze promocode is niet geschakeld voor online betaling."
}
diff --git a/frontend/src/lib/i18n/messages/pt/admin.json b/frontend/src/lib/i18n/messages/pt/admin.json
index 492b0de..58a3a37 100644
--- a/frontend/src/lib/i18n/messages/pt/admin.json
+++ b/frontend/src/lib/i18n/messages/pt/admin.json
@@ -145,5 +145,180 @@
"admin.stats.tiersSub": "Essencial / Premium · clássico {classic} · outro {other}",
"admin.stats.unavailable": "Estatísticas de negócio indisponíveis ({error}).",
"admin.stats.waitlist": "Lista de espera",
- "admin.stats.waitlistSub": "inscritos na espera"
+ "admin.stats.waitlistSub": "inscritos na espera",
+ "admin.settings.title": "Fornecedores",
+ "admin.settings.subtitle": "Configure os acessos dos fornecedores. As chaves API também podem ser definidas no ficheiro .env.",
+ "admin.settings.sectionClassic": "Tradução clássica",
+ "admin.settings.sectionClassicDesc": "Motores Google para a tradução padrão.",
+ "admin.settings.sectionDirect": "Fornecedores de IA diretos",
+ "admin.settings.sectionDirectDesc": "Acessos diretos compatíveis com OpenAI — utilizáveis como canal de um nível de IA (página «Modelos e assinaturas») ou para gerar emails.",
+ "admin.settings.sectionGateway": "Gateways multi-modelos",
+ "admin.settings.sectionGatewayDesc": "Uma chave, centenas de modelos. Os modelos por nível gerem-se na página «Modelos e assinaturas».",
+ "admin.settings.sectionOcr": "OCR (PDF digitalizados)",
+ "admin.settings.sectionOcrDesc": "Extração do texto das páginas de imagem antes da tradução.",
+ "admin.settings.sectionEmails": "Emails",
+ "admin.settings.sectionEmailsDesc": "Servidor de envio para notificações e retomas de contato.",
+ "admin.settings.enabled": "Ativado",
+ "admin.settings.disabled": "Desativado",
+ "admin.settings.envBadge": "Chave no .env",
+ "admin.settings.envConfigured": "Configurado no .env",
+ "admin.settings.envKeyPlaceholder": "Chave configurada no .env (deixe vazio para a utilizar)",
+ "admin.settings.apiKey": "Chave API",
+ "admin.settings.model": "Modelo",
+ "admin.settings.baseUrl": "URL de base",
+ "admin.settings.test": "Testar",
+ "admin.settings.testing": "Teste...",
+ "admin.settings.ok": "OK",
+ "admin.settings.error": "Erro",
+ "admin.settings.failure": "Falha",
+ "admin.settings.sending": "A enviar...",
+ "admin.settings.save": "Guardar a configuração",
+ "admin.settings.saving": "A guardar...",
+ "admin.settings.savedTitle": "✅ Configuração guardada",
+ "admin.settings.savedDesc": "Os parâmetros foram guardados com sucesso.",
+ "admin.settings.saveErrorTitle": "Erro ao guardar",
+ "admin.settings.loadErrorTitle": "Erro de carregamento",
+ "admin.settings.loadErrorDesc": "HTTP {status} — verifique o seu token de administrador.",
+ "admin.settings.networkErrorTitle": "Erro de rede",
+ "admin.settings.networkErrorDesc": "Não é possível contactar o backend.",
+ "admin.settings.networkSaveErrorDesc": "Não é possível contactar o backend para guardar.",
+ "admin.settings.connectionOk": "Ligação OK",
+ "admin.settings.modelsFound": "{count} modelos {provider} encontrados",
+ "admin.settings.providerError": "Erro {provider}",
+ "admin.settings.google.title": "Google Translate",
+ "admin.settings.google.desc": "Acesso web não oficial via deep_translator. Não requer chave, uso razoável recomendado.",
+ "admin.settings.googleCloud.title": "Google Cloud Translation (oficial)",
+ "admin.settings.googleCloud.desc": "API oficial da Google Cloud. 500 000 carateres/mês grátis, depois cerca de 20 $ por milhão de carateres. Chave API em console.cloud.google.com.",
+ "admin.settings.googleCloud.hint": "Ative a Cloud Translation API na Google Cloud Console e crie uma chave API restrita a essa API.",
+ "admin.settings.deepseek.title": "DeepSeek (direto)",
+ "admin.settings.deepseek.desc": "API direta da DeepSeek, compatível com OpenAI. Chave em platform.deepseek.com — utilizável como canal de um nível de IA.",
+ "admin.settings.zhipu.title": "z.ai / Zhipu (GLM)",
+ "admin.settings.zhipu.desc": "Acesso direto aos modelos GLM da z.ai, compatível com OpenAI. Chave em z.ai — utilizável como canal de um nível de IA.",
+ "admin.settings.zhipu.urlHint": "Por omissão: https://api.z.ai/api/paas/v4 — altere apenas se usar um proxy.",
+ "admin.settings.minimax.title": "MiniMax (direto)",
+ "admin.settings.minimax.desc": "API pública da MiniMax, compatível com OpenAI. Chave em minimax.io — utilizável como canal de um nível de IA.",
+ "admin.settings.openai.title": "OpenAI",
+ "admin.settings.openai.desc": "Traduções GPT. Obtenha uma chave em platform.openai.com.",
+ "admin.settings.zai.title": "xAI (Grok)",
+ "admin.settings.zai.desc": "Modelos Grok da xAI. API compatível com OpenAI. Obtenha a chave em x.ai.",
+ "admin.settings.zai.urlHint": "Por omissão: https://api.x.ai/v1 — altere apenas se usar um proxy.",
+ "admin.settings.openrouter.title": "OpenRouter — chave de gateway",
+ "admin.settings.openrouter.desc": "Nível Essencial (plano Pro). Gama atual: deepseek/deepseek-v4-flash, z-ai/glm-5.3-flash, minimax/minimax-m3.",
+ "admin.settings.openrouter.keyLabel": "Chave API OpenRouter (partilhada com Premium)",
+ "admin.settings.openrouter.envSharedPlaceholder": "Chave configurada no .env (partilhada com Premium)",
+ "admin.settings.openrouter.reminder": "Modelos geridos na página «Modelos e assinaturas».",
+ "admin.settings.openrouterPremium.title": "OpenRouter Premium — chave de gateway",
+ "admin.settings.openrouterPremium.desc": "Nível Premium (plano Business). Gama atual: anthropic/claude-sonnet-5, deepseek/deepseek-v4-pro, z-ai/glm-5.3.",
+ "admin.settings.openrouterPremium.reminder": "Modelos geridos na página «Modelos e assinaturas». Partilha a mesma chave OpenRouter.",
+ "admin.settings.mistral.title": "OCR Mistral (PDF digitalizados)",
+ "admin.settings.mistral.desc": "Extrai o texto dos PDF digitalizados (páginas de imagem) antes da tradução. Sem chave, esses ficheiros são recusados com uma mensagem clara. Cerca de 1 € / 1 000 páginas. Chave: console.mistral.ai.",
+ "admin.settings.mistral.keyLabel": "Chave API Mistral",
+ "admin.settings.mistral.modelLabel": "Modelo OCR",
+ "admin.settings.mistral.modelHint": "Recomendado: mistral-ocr-latest",
+ "admin.settings.smtp.title": "Email SMTP",
+ "admin.settings.smtp.desc": "Configuração do servidor SMTP para o envio de emails (palavra-passe esquecida, notificações, retomas de contato).",
+ "admin.settings.smtp.host": "Anfitrião SMTP",
+ "admin.settings.smtp.port": "Porta",
+ "admin.settings.smtp.username": "Nome de utilizador",
+ "admin.settings.smtp.password": "Palavra-passe",
+ "admin.settings.smtp.fromEmail": "Endereço de envio",
+ "admin.settings.smtp.useTls": "Utilizar TLS",
+ "admin.settings.smtp.sendTest": "Enviar um email de teste",
+ "admin.settings.smtp.sent": "Enviado",
+ "admin.settings.smtp.sendFailed": "Falha no envio",
+ "admin.models.channelOpenrouter": "OpenRouter",
+ "admin.models.channelOpenrouterPremium": "OpenRouter (Premium)",
+ "admin.models.channelDeepseek": "DeepSeek (direto)",
+ "admin.models.channelZhipu": "z.ai (GLM)",
+ "admin.models.channelMinimax": "MiniMax (direto)",
+ "admin.models.channelOpenai": "OpenAI",
+ "admin.models.channelZai": "xAI (Grok)",
+ "admin.models.customModelPlaceholder": "id-do-modelo",
+ "admin.models.routeHint": "O canal escolhe o acesso direto ou o gateway; os identificadores de modelos diferem de canal para canal.",
+ "admin.models.addRouteConfirm": "Adicionar",
+ "admin.marketing.generateTitle": "Gerar com IA",
+ "admin.marketing.generateDesc": "O texto é redigido a partir do plano de marketing (posicionamento e números autorizados). Nada é enviado automaticamente: reveja e depois envie.",
+ "admin.marketing.channel": "Canal",
+ "admin.marketing.model": "Modelo",
+ "admin.marketing.modelPlaceholder": "Predefinição do canal",
+ "admin.marketing.promoOptional": "Desconto (facultativo)",
+ "admin.marketing.promoNone": "Nenhum",
+ "admin.marketing.brief": "Briefing",
+ "admin.marketing.briefPlaceholder": "Ex.: retomar os inscritos na lista de espera lembrando as vantagens do plano Pro.",
+ "admin.marketing.language": "Língua do email",
+ "admin.marketing.tone": "Tom",
+ "admin.marketing.toneNone": "Neutro",
+ "admin.marketing.generateBtn": "Gerar",
+ "admin.marketing.generating": "A gerar...",
+ "admin.marketing.generatedTitle": "Email gerado",
+ "admin.marketing.generatedDesc": "Assunto e HTML inseridos no formulário — reveja antes de enviar ({provider}).",
+ "admin.marketing.generateFailedTitle": "Geração impossível",
+ "admin.marketing.briefRequired": "Descreva primeiro o conteúdo pretendido no briefing.",
+ "admin.marketing.langFr": "Francês",
+ "admin.marketing.langEn": "Inglês",
+ "admin.marketing.langDe": "Alemão",
+ "admin.marketing.langEs": "Espanhol",
+ "admin.marketing.langIt": "Italiano",
+ "admin.marketing.langPt": "Português",
+ "admin.marketing.langNl": "Neerlandês",
+ "admin.marketing.langAr": "Árabe",
+ "admin.marketing.langFa": "Persa",
+ "admin.marketing.langJa": "Japonês",
+ "admin.marketing.langKo": "Coreano",
+ "admin.marketing.langRu": "Russo",
+ "admin.marketing.langZh": "Chinês",
+ "admin.marketing.toneFriendly": "Amigável",
+ "admin.marketing.toneProfessional": "Profissional",
+ "admin.marketing.toneUrgent": "Urgente",
+ "admin.marketing.toneFestive": "Festivo",
+ "admin.marketing.promoTitle": "Descontos",
+ "admin.marketing.promoDesc": "Códigos promo aplicados ao pagamento via Stripe; o contador aumenta quando a assinatura é realmente concluída.",
+ "admin.marketing.promoEmpty": "Ainda não há códigos promo.",
+ "admin.marketing.promoActive": "Ativo",
+ "admin.marketing.promoInactive": "Desativado",
+ "admin.marketing.promoLocalOnly": "Fora do Stripe",
+ "admin.marketing.promoDeactivate": "Desativar",
+ "admin.marketing.promoUsage": "{used}/{max} utilizações",
+ "admin.marketing.promoPlansLabel": "Planos:",
+ "admin.marketing.promoAllPlans": "Todos",
+ "admin.marketing.promoExpiresOn": "expira em {date}",
+ "admin.marketing.promoNeverExpires": "sem expiração",
+ "admin.marketing.promoCreateTitle": "Criar um desconto",
+ "admin.marketing.promoCreateDesc": "O código também é criado no Stripe (cupão + código promocional) quando o Stripe está configurado.",
+ "admin.marketing.promoCode": "Código",
+ "admin.marketing.promoType": "Tipo",
+ "admin.marketing.promoTypePercent": "Percentagem",
+ "admin.marketing.promoTypeAmount": "Montante (EUR)",
+ "admin.marketing.promoValue": "Valor",
+ "admin.marketing.promoPlans": "Planos elegíveis",
+ "admin.marketing.promoMaxRedemptions": "Limite de utilizações",
+ "admin.marketing.promoMaxPlaceholder": "Ilimitado",
+ "admin.marketing.promoExpires": "Expiração",
+ "admin.marketing.promoCreate": "Criar o código",
+ "admin.marketing.promoCreating": "A criar...",
+ "admin.marketing.promoCreatedTitle": "Código promo criado",
+ "admin.marketing.promoCreatedDesc": "O código {code} está pronto.",
+ "admin.marketing.promoCreateFailedTitle": "Criação recusada",
+ "admin.marketing.promoInvalidInput": "Verifique o código e o valor.",
+ "admin.marketing.promoDeactivatedTitle": "Código desativado",
+ "admin.marketing.promoDeactivatedDesc": "O código {code} já não é aplicável.",
+ "admin.settings.fallback.title": "Cadeia de recurso",
+ "admin.settings.fallback.desc": "Ordem de prioridade para a seleção automática de fornecedores.",
+ "admin.settings.fallback.classic": "Modo clássico (Google)",
+ "admin.settings.fallback.llm": "Modo LLM (gateways e acessos diretos)",
+ "admin.settings.fallback.note": "Apenas os canais registados (google, openai, deepseek, minimax) participam nesta cadeia; os níveis de IA e os outros canais não.",
+ "admin.models.duplicateRouteTitle": "Duplicado recusado",
+ "admin.models.duplicateRouteDesc": "Esse par canal + modelo já está na lista.",
+ "admin.marketing.promoDeactivateFailedTitle": "Desativação recusada",
+ "admin.marketing.promoDelete": "Eliminar",
+ "admin.marketing.promoDeleteConfirm": "Eliminar definitivamente o código {code}?",
+ "admin.marketing.promoDeletedTitle": "Código eliminado",
+ "admin.marketing.promoDeletedDesc": "O código {code} foi eliminado.",
+ "admin.marketing.promoDeleteFailedTitle": "Eliminação recusada",
+ "admin.marketing.promoLinkStripe": "Ligar ao Stripe",
+ "admin.marketing.promoLinkedTitle": "Código ligado ao Stripe",
+ "admin.marketing.promoLinkedDesc": "O código {code} já é aplicável ao pagamento.",
+ "admin.marketing.promoLinkFailedTitle": "Ligação impossível",
+ "admin.marketing.promoExpiryHint": "Um código «até dia 10» continua válido todo o dia 10 (fim: 23:59:59 UTC).",
+ "admin.marketing.generateEmpty": "A resposta do servidor estava vazia ou era inutilizável."
}
diff --git a/frontend/src/lib/i18n/messages/pt/pricing.json b/frontend/src/lib/i18n/messages/pt/pricing.json
index a85e15f..d68fdf8 100644
--- a/frontend/src/lib/i18n/messages/pt/pricing.json
+++ b/frontend/src/lib/i18n/messages/pt/pricing.json
@@ -167,5 +167,17 @@
"pricing.header.titleBase": "Um plano para",
"pricing.header.titleAccent": "cada necessidade",
"pricing.toast.close": "Fechar",
- "pricing.aiModels.premium.price": "2 US$ / 10 US$ por 1M de tokens"
+ "pricing.aiModels.premium.price": "2 US$ / 10 US$ por 1M de tokens",
+ "pricing.promo.label": "Código promo",
+ "pricing.promo.placeholder": "SEUCODIGO",
+ "pricing.promo.apply": "Aplicar",
+ "pricing.promo.checking": "A verificar...",
+ "pricing.promo.valid": "Código válido",
+ "pricing.promo.subscriptionOnly": "Válido em assinaturas, não em créditos.",
+ "pricing.promoError.unknown": "Este código promo não existe.",
+ "pricing.promoError.inactive": "Este código promo já não está ativo.",
+ "pricing.promoError.expired": "Este código promo expirou.",
+ "pricing.promoError.exhausted": "Este código promo atingiu o limite de utilizações.",
+ "pricing.promoError.plan": "Este código promo não é válido para este plano.",
+ "pricing.promoError.local_only": "Este código promo não está habilitado para o pagamento online."
}
diff --git a/frontend/src/lib/i18n/messages/ru/admin.json b/frontend/src/lib/i18n/messages/ru/admin.json
index 39a1c51..6bf04bd 100644
--- a/frontend/src/lib/i18n/messages/ru/admin.json
+++ b/frontend/src/lib/i18n/messages/ru/admin.json
@@ -145,5 +145,180 @@
"admin.stats.tiersSub": "Базовый / Премиум · классика {classic} · прочее {other}",
"admin.stats.unavailable": "Бизнес-статистика недоступна ({error}).",
"admin.stats.waitlist": "Лист ожидания",
- "admin.stats.waitlistSub": "записей в листе ожидания"
+ "admin.stats.waitlistSub": "записей в листе ожидания",
+ "admin.settings.title": "Поставщики",
+ "admin.settings.subtitle": "Настройте доступы поставщиков. Ключи API также можно задать в файле .env.",
+ "admin.settings.sectionClassic": "Классический перевод",
+ "admin.settings.sectionClassicDesc": "Механизмы Google для стандартного перевода.",
+ "admin.settings.sectionDirect": "Прямые ИИ-поставщики",
+ "admin.settings.sectionDirectDesc": "Прямые совместимые с OpenAI доступы — используются как канал ИИ-уровня (страница «Модели и подписки») или для генерации писем.",
+ "admin.settings.sectionGateway": "Мульти модельные шлюзы",
+ "admin.settings.sectionGatewayDesc": "Один ключ, сотни моделей. Модели по уровням настраиваются на странице «Модели и подписки».",
+ "admin.settings.sectionOcr": "OCR (сканированные PDF)",
+ "admin.settings.sectionOcrDesc": "Извлечение текста со страниц-изображений перед переводом.",
+ "admin.settings.sectionEmails": "Письма",
+ "admin.settings.sectionEmailsDesc": "Сервер отправки для уведомлений и повторных писем.",
+ "admin.settings.enabled": "Включено",
+ "admin.settings.disabled": "Отключено",
+ "admin.settings.envBadge": "Ключ в .env",
+ "admin.settings.envConfigured": "Задано в .env",
+ "admin.settings.envKeyPlaceholder": "Ключ задан в .env (оставьте пустым, чтобы использовать)",
+ "admin.settings.apiKey": "Ключ API",
+ "admin.settings.model": "Модель",
+ "admin.settings.baseUrl": "Базовый URL",
+ "admin.settings.test": "Проверить",
+ "admin.settings.testing": "Проверка...",
+ "admin.settings.ok": "OK",
+ "admin.settings.error": "Ошибка",
+ "admin.settings.failure": "Сбой",
+ "admin.settings.sending": "Отправка...",
+ "admin.settings.save": "Сохранить конфигурацию",
+ "admin.settings.saving": "Сохранение...",
+ "admin.settings.savedTitle": "✅ Конфигурация сохранена",
+ "admin.settings.savedDesc": "Параметры успешно сохранены.",
+ "admin.settings.saveErrorTitle": "Ошибка сохранения",
+ "admin.settings.loadErrorTitle": "Ошибка загрузки",
+ "admin.settings.loadErrorDesc": "HTTP {status} — проверьте административный токен.",
+ "admin.settings.networkErrorTitle": "Сетевая ошибка",
+ "admin.settings.networkErrorDesc": "Не удалось связаться с сервером.",
+ "admin.settings.networkSaveErrorDesc": "Не удалось связаться с сервером для сохранения.",
+ "admin.settings.connectionOk": "Соединение в порядке",
+ "admin.settings.modelsFound": "Найдено моделей {provider}: {count}",
+ "admin.settings.providerError": "Ошибка {provider}",
+ "admin.settings.google.title": "Google Translate",
+ "admin.settings.google.desc": "Неофициальный веб-доступ через deep_translator. Ключ не нужен, разумное использование приветствуется.",
+ "admin.settings.googleCloud.title": "Google Cloud Translation (официальный)",
+ "admin.settings.googleCloud.desc": "Официальный API Google Cloud. 500 000 знаков/месяц бесплатно, далее около 20 $ за миллион знаков. Ключ API на console.cloud.google.com.",
+ "admin.settings.googleCloud.hint": "Включите Cloud Translation API в Google Cloud Console и создайте ключ API с ограничением этим API.",
+ "admin.settings.deepseek.title": "DeepSeek (напрямую)",
+ "admin.settings.deepseek.desc": "Прямой API DeepSeek, совместим с OpenAI. Ключ на platform.deepseek.com — подходит как канал ИИ-уровня.",
+ "admin.settings.zhipu.title": "z.ai / Zhipu (GLM)",
+ "admin.settings.zhipu.desc": "Прямой доступ к моделям GLM от z.ai, совместим с OpenAI. Ключ на z.ai — подходит как канал ИИ-уровня.",
+ "admin.settings.zhipu.urlHint": "По умолчанию: https://api.z.ai/api/paas/v4 — меняйте только при использовании прокси.",
+ "admin.settings.minimax.title": "MiniMax (напрямую)",
+ "admin.settings.minimax.desc": "Публичный API MiniMax, совместим с OpenAI. Ключ на minimax.io — подходит как канал ИИ-уровня.",
+ "admin.settings.openai.title": "OpenAI",
+ "admin.settings.openai.desc": "Переводы GPT. Получите ключ на platform.openai.com.",
+ "admin.settings.zai.title": "xAI (Grok)",
+ "admin.settings.zai.desc": "Модели Grok от xAI. API совместим с OpenAI. Получите ключ на x.ai.",
+ "admin.settings.zai.urlHint": "По умолчанию: https://api.x.ai/v1 — меняйте только при использовании прокси.",
+ "admin.settings.openrouter.title": "OpenRouter — ключ шлюза",
+ "admin.settings.openrouter.desc": "Уровень Essential (тариф Pro). Текущая линейка: deepseek/deepseek-v4-flash, z-ai/glm-5.3-flash, minimax/minimax-m3.",
+ "admin.settings.openrouter.keyLabel": "Ключ API OpenRouter (общий с Premium)",
+ "admin.settings.openrouter.envSharedPlaceholder": "Ключ задан в .env (общий с Premium)",
+ "admin.settings.openrouter.reminder": "Модели управляются на странице «Модели и подписки».",
+ "admin.settings.openrouterPremium.title": "OpenRouter Premium — ключ шлюза",
+ "admin.settings.openrouterPremium.desc": "Уровень Premium (тариф Business). Текущая линейка: anthropic/claude-sonnet-5, deepseek/deepseek-v4-pro, z-ai/glm-5.3.",
+ "admin.settings.openrouterPremium.reminder": "Модели управляются на странице «Модели и подписки». Используется тот же ключ OpenRouter.",
+ "admin.settings.mistral.title": "OCR Mistral (сканированные PDF)",
+ "admin.settings.mistral.desc": "Извлекает текст из сканированных PDF (страницы-изображения) перед переводом. Без ключа такие файлы отклоняются с понятным сообщением. Около 1 € / 1000 страниц. Ключ: console.mistral.ai.",
+ "admin.settings.mistral.keyLabel": "Ключ API Mistral",
+ "admin.settings.mistral.modelLabel": "OCR-модель",
+ "admin.settings.mistral.modelHint": "Рекомендуется: mistral-ocr-latest",
+ "admin.settings.smtp.title": "Почта SMTP",
+ "admin.settings.smtp.desc": "Настройки SMTP-сервера для отправки писем (восстановление пароля, уведомления, повторные письма).",
+ "admin.settings.smtp.host": "SMTP-сервер",
+ "admin.settings.smtp.port": "Порт",
+ "admin.settings.smtp.username": "Имя пользователя",
+ "admin.settings.smtp.password": "Пароль",
+ "admin.settings.smtp.fromEmail": "Адрес отправителя",
+ "admin.settings.smtp.useTls": "Использовать TLS",
+ "admin.settings.smtp.sendTest": "Отправить тестовое письмо",
+ "admin.settings.smtp.sent": "Отправлено",
+ "admin.settings.smtp.sendFailed": "Не удалось отправить",
+ "admin.models.channelOpenrouter": "OpenRouter",
+ "admin.models.channelOpenrouterPremium": "OpenRouter (Premium)",
+ "admin.models.channelDeepseek": "DeepSeek (напрямую)",
+ "admin.models.channelZhipu": "z.ai (GLM)",
+ "admin.models.channelMinimax": "MiniMax (напрямую)",
+ "admin.models.channelOpenai": "OpenAI",
+ "admin.models.channelZai": "xAI (Grok)",
+ "admin.models.customModelPlaceholder": "идентификатор-модели",
+ "admin.models.routeHint": "Канал выбирает прямой доступ или шлюз; идентификаторы моделей различаются между каналами.",
+ "admin.models.addRouteConfirm": "Добавить",
+ "admin.marketing.generateTitle": "Создать с помощью ИИ",
+ "admin.marketing.generateDesc": "Текст составляется по маркетинговому плану (позиционирование и разрешённые цифры). Ничего не отправляется автоматически: проверьте и отправьте.",
+ "admin.marketing.channel": "Канал",
+ "admin.marketing.model": "Модель",
+ "admin.marketing.modelPlaceholder": "По умолчанию канала",
+ "admin.marketing.promoOptional": "Скидка (необязательно)",
+ "admin.marketing.promoNone": "Без скидки",
+ "admin.marketing.brief": "Задание",
+ "admin.marketing.briefPlaceholder": "Напр.: напомнить записавшимся в лист ожидания о преимуществах тарифа Pro.",
+ "admin.marketing.language": "Язык письма",
+ "admin.marketing.tone": "Тон",
+ "admin.marketing.toneNone": "Нейтральный",
+ "admin.marketing.generateBtn": "Создать",
+ "admin.marketing.generating": "Создание...",
+ "admin.marketing.generatedTitle": "Письмо создано",
+ "admin.marketing.generatedDesc": "Тема и HTML вставлены в форму — проверьте перед отправкой ({provider}).",
+ "admin.marketing.generateFailedTitle": "Не удалось создать",
+ "admin.marketing.briefRequired": "Сначала опишите желаемое содержание в задании.",
+ "admin.marketing.langFr": "Французский",
+ "admin.marketing.langEn": "Английский",
+ "admin.marketing.langDe": "Немецкий",
+ "admin.marketing.langEs": "Испанский",
+ "admin.marketing.langIt": "Итальянский",
+ "admin.marketing.langPt": "Португальский",
+ "admin.marketing.langNl": "Нидерландский",
+ "admin.marketing.langAr": "Арабский",
+ "admin.marketing.langFa": "Персидский",
+ "admin.marketing.langJa": "Японский",
+ "admin.marketing.langKo": "Корейский",
+ "admin.marketing.langRu": "Русский",
+ "admin.marketing.langZh": "Китайский",
+ "admin.marketing.toneFriendly": "Дружелюбный",
+ "admin.marketing.toneProfessional": "Профессиональный",
+ "admin.marketing.toneUrgent": "Срочный",
+ "admin.marketing.toneFestive": "Праздничный",
+ "admin.marketing.promoTitle": "Скидки",
+ "admin.marketing.promoDesc": "Промокоды применяются к оплате через Stripe; счётчик растёт только после реального оформления подписки.",
+ "admin.marketing.promoEmpty": "Промокодов пока нет.",
+ "admin.marketing.promoActive": "Активен",
+ "admin.marketing.promoInactive": "Отключён",
+ "admin.marketing.promoLocalOnly": "Вне Stripe",
+ "admin.marketing.promoDeactivate": "Отключить",
+ "admin.marketing.promoUsage": "использований: {used}/{max}",
+ "admin.marketing.promoPlansLabel": "Тарифы:",
+ "admin.marketing.promoAllPlans": "Все",
+ "admin.marketing.promoExpiresOn": "истекает {date}",
+ "admin.marketing.promoNeverExpires": "без срока",
+ "admin.marketing.promoCreateTitle": "Создать скидку",
+ "admin.marketing.promoCreateDesc": "Код также создаётся в Stripe (купон + промокод), если Stripe настроен.",
+ "admin.marketing.promoCode": "Код",
+ "admin.marketing.promoType": "Тип",
+ "admin.marketing.promoTypePercent": "Процент",
+ "admin.marketing.promoTypeAmount": "Сумма (EUR)",
+ "admin.marketing.promoValue": "Значение",
+ "admin.marketing.promoPlans": "Подходящие тарифы",
+ "admin.marketing.promoMaxRedemptions": "Лимит применений",
+ "admin.marketing.promoMaxPlaceholder": "Без лимита",
+ "admin.marketing.promoExpires": "Срок действия",
+ "admin.marketing.promoCreate": "Создать код",
+ "admin.marketing.promoCreating": "Создание...",
+ "admin.marketing.promoCreatedTitle": "Промокод создан",
+ "admin.marketing.promoCreatedDesc": "Код {code} готов.",
+ "admin.marketing.promoCreateFailedTitle": "Создание отклонено",
+ "admin.marketing.promoInvalidInput": "Проверьте код и значение.",
+ "admin.marketing.promoDeactivatedTitle": "Код отключён",
+ "admin.marketing.promoDeactivatedDesc": "Код {code} больше не применяется.",
+ "admin.settings.fallback.title": "Цепочка переключения",
+ "admin.settings.fallback.desc": "Порядок приоритета для автоматического выбора поставщиков.",
+ "admin.settings.fallback.classic": "Классический режим (Google)",
+ "admin.settings.fallback.llm": "Режим LLM (шлюзы и прямые доступы)",
+ "admin.settings.fallback.note": "В этой цепочке участвуют только зарегистрированные каналы (google, openai, deepseek, minimax); ИИ-уровни и прочие каналы — нет.",
+ "admin.models.duplicateRouteTitle": "Дубль отклонён",
+ "admin.models.duplicateRouteDesc": "Такая пара канал + модель уже есть в списке.",
+ "admin.marketing.promoDeactivateFailedTitle": "Отключение отклонено",
+ "admin.marketing.promoDelete": "Удалить",
+ "admin.marketing.promoDeleteConfirm": "Окончательно удалить код {code}?",
+ "admin.marketing.promoDeletedTitle": "Код удалён",
+ "admin.marketing.promoDeletedDesc": "Код {code} удалён.",
+ "admin.marketing.promoDeleteFailedTitle": "Удаление отклонено",
+ "admin.marketing.promoLinkStripe": "Привязать к Stripe",
+ "admin.marketing.promoLinkedTitle": "Код привязан к Stripe",
+ "admin.marketing.promoLinkedDesc": "Код {code} теперь применим к оплате.",
+ "admin.marketing.promoLinkFailedTitle": "Не удалось привязать",
+ "admin.marketing.promoExpiryHint": "Код «до 10-го» действует весь 10-й день (до 23:59:59 UTC).",
+ "admin.marketing.generateEmpty": "Ответ сервера был пустым или непригодным."
}
diff --git a/frontend/src/lib/i18n/messages/ru/pricing.json b/frontend/src/lib/i18n/messages/ru/pricing.json
index f76236e..b12f95e 100644
--- a/frontend/src/lib/i18n/messages/ru/pricing.json
+++ b/frontend/src/lib/i18n/messages/ru/pricing.json
@@ -167,5 +167,17 @@
"pricing.header.titleBase": "Тариф для",
"pricing.header.titleAccent": "каждой задачи",
"pricing.toast.close": "Закрыть",
- "pricing.aiModels.premium.price": "$2 / $10 за 1 млн токенов"
+ "pricing.aiModels.premium.price": "$2 / $10 за 1 млн токенов",
+ "pricing.promo.label": "Промокод",
+ "pricing.promo.placeholder": "ВАШКОД",
+ "pricing.promo.apply": "Применить",
+ "pricing.promo.checking": "Проверка...",
+ "pricing.promo.valid": "Код действителен",
+ "pricing.promo.subscriptionOnly": "Действует на подписки, но не на кредиты.",
+ "pricing.promoError.unknown": "Такого промокода не существует.",
+ "pricing.promoError.inactive": "Этот промокод больше не активен.",
+ "pricing.promoError.expired": "Срок действия этого промокода истёк.",
+ "pricing.promoError.exhausted": "Этот промокод достиг лимита применений.",
+ "pricing.promoError.plan": "Этот промокод не действует для этого тарифа.",
+ "pricing.promoError.local_only": "Этот промокод не привязан к онлайн-оплате."
}
diff --git a/frontend/src/lib/i18n/messages/zh/admin.json b/frontend/src/lib/i18n/messages/zh/admin.json
index 289cbba..f9b2c22 100644
--- a/frontend/src/lib/i18n/messages/zh/admin.json
+++ b/frontend/src/lib/i18n/messages/zh/admin.json
@@ -145,5 +145,180 @@
"admin.stats.tiersSub": "基础 / 高级 · 经典 {classic} · 其他 {other}",
"admin.stats.unavailable": "业务统计不可用({error})。",
"admin.stats.waitlist": "等候名单",
- "admin.stats.waitlistSub": "位用户等候中"
+ "admin.stats.waitlistSub": "位用户等候中",
+ "admin.settings.title": "服务商",
+ "admin.settings.subtitle": "配置服务商的访问。API 密钥也可以在 .env 文件中设置。",
+ "admin.settings.sectionClassic": "经典翻译",
+ "admin.settings.sectionClassicDesc": "用于标准翻译的 Google 引擎。",
+ "admin.settings.sectionDirect": "直连 AI 服务商",
+ "admin.settings.sectionDirectDesc": "兼容 OpenAI 的直连接入 —— 可作为 AI 层级的通道(见「模型与订阅」页面)或用于生成邮件。",
+ "admin.settings.sectionGateway": "多模型网关",
+ "admin.settings.sectionGatewayDesc": "一个密钥,数百个模型。每个层级的模型在「模型与订阅」页面管理。",
+ "admin.settings.sectionOcr": "OCR(扫描版 PDF)",
+ "admin.settings.sectionOcrDesc": "翻译前从图片页面提取文字。",
+ "admin.settings.sectionEmails": "邮件",
+ "admin.settings.sectionEmailsDesc": "用于通知和跟进邮件的发送服务器。",
+ "admin.settings.enabled": "已启用",
+ "admin.settings.disabled": "已停用",
+ "admin.settings.envBadge": "密钥在 .env",
+ "admin.settings.envConfigured": "已在 .env 中配置",
+ "admin.settings.envKeyPlaceholder": "密钥已在 .env 中配置(留空即可使用)",
+ "admin.settings.apiKey": "API 密钥",
+ "admin.settings.model": "模型",
+ "admin.settings.baseUrl": "基础 URL",
+ "admin.settings.test": "测试",
+ "admin.settings.testing": "测试中...",
+ "admin.settings.ok": "正常",
+ "admin.settings.error": "错误",
+ "admin.settings.failure": "失败",
+ "admin.settings.sending": "发送中...",
+ "admin.settings.save": "保存配置",
+ "admin.settings.saving": "保存中...",
+ "admin.settings.savedTitle": "✅ 配置已保存",
+ "admin.settings.savedDesc": "设置已成功保存。",
+ "admin.settings.saveErrorTitle": "保存出错",
+ "admin.settings.loadErrorTitle": "加载出错",
+ "admin.settings.loadErrorDesc": "HTTP {status} — 请检查您的管理员令牌。",
+ "admin.settings.networkErrorTitle": "网络错误",
+ "admin.settings.networkErrorDesc": "无法连接后端。",
+ "admin.settings.networkSaveErrorDesc": "无法连接后端进行保存。",
+ "admin.settings.connectionOk": "连接正常",
+ "admin.settings.modelsFound": "找到 {count} 个 {provider} 模型",
+ "admin.settings.providerError": "{provider} 错误",
+ "admin.settings.google.title": "Google 翻译",
+ "admin.settings.google.desc": "通过 deep_translator 的非官方网页访问。无需密钥,建议适度使用。",
+ "admin.settings.googleCloud.title": "Google Cloud 翻译(官方)",
+ "admin.settings.googleCloud.desc": "Google Cloud 官方 API。每月 50 万字符免费,之后约每百万字符 20 美元。密钥来自 console.cloud.google.com。",
+ "admin.settings.googleCloud.hint": "在 Google Cloud Console 中启用 Cloud Translation API,然后创建仅限该 API 的密钥。",
+ "admin.settings.deepseek.title": "DeepSeek(直连)",
+ "admin.settings.deepseek.desc": "DeepSeek 直连 API,兼容 OpenAI。密钥来自 platform.deepseek.com —— 可作为 AI 层级通道。",
+ "admin.settings.zhipu.title": "z.ai / Zhipu(GLM)",
+ "admin.settings.zhipu.desc": "直连 z.ai 的 GLM 模型,兼容 OpenAI。密钥来自 z.ai —— 可作为 AI 层级通道。",
+ "admin.settings.zhipu.urlHint": "默认:https://api.z.ai/api/paas/v4 — 仅在使用代理时修改。",
+ "admin.settings.minimax.title": "MiniMax(直连)",
+ "admin.settings.minimax.desc": "MiniMax 公开 API,兼容 OpenAI。密钥来自 minimax.io —— 可作为 AI 层级通道。",
+ "admin.settings.openai.title": "OpenAI",
+ "admin.settings.openai.desc": "GPT 翻译。请在 platform.openai.com 获取密钥。",
+ "admin.settings.zai.title": "xAI(Grok)",
+ "admin.settings.zai.desc": "xAI 的 Grok 模型。兼容 OpenAI 的 API。请在 x.ai 获取密钥。",
+ "admin.settings.zai.urlHint": "默认:https://api.x.ai/v1 — 仅在使用代理时修改。",
+ "admin.settings.openrouter.title": "OpenRouter — 网关密钥",
+ "admin.settings.openrouter.desc": "Essential 层(Pro 套餐)。当前型号:deepseek/deepseek-v4-flash、z-ai/glm-5.3-flash、minimax/minimax-m3。",
+ "admin.settings.openrouter.keyLabel": "OpenRouter API 密钥(与 Premium 共用)",
+ "admin.settings.openrouter.envSharedPlaceholder": "密钥已在 .env 中配置(与 Premium 共用)",
+ "admin.settings.openrouter.reminder": "模型在「模型与订阅」页面管理。",
+ "admin.settings.openrouterPremium.title": "OpenRouter Premium — 网关密钥",
+ "admin.settings.openrouterPremium.desc": "Premium 层(Business 套餐)。当前型号:anthropic/claude-sonnet-5、deepseek/deepseek-v4-pro、z-ai/glm-5.3。",
+ "admin.settings.openrouterPremium.reminder": "模型在「模型与订阅」页面管理。共用同一个 OpenRouter 密钥。",
+ "admin.settings.mistral.title": "Mistral OCR(扫描版 PDF)",
+ "admin.settings.mistral.desc": "翻译前从扫描版 PDF(图片页面)提取文字。没有密钥时,这类文件会被明确提示后拒绝。约 1 欧元 / 1000 页。密钥:console.mistral.ai。",
+ "admin.settings.mistral.keyLabel": "Mistral API 密钥",
+ "admin.settings.mistral.modelLabel": "OCR 模型",
+ "admin.settings.mistral.modelHint": "推荐:mistral-ocr-latest",
+ "admin.settings.smtp.title": "SMTP 邮件",
+ "admin.settings.smtp.desc": "用于发送邮件的 SMTP 服务器设置(忘记密码、通知、跟进邮件)。",
+ "admin.settings.smtp.host": "SMTP 主机",
+ "admin.settings.smtp.port": "端口",
+ "admin.settings.smtp.username": "用户名",
+ "admin.settings.smtp.password": "密码",
+ "admin.settings.smtp.fromEmail": "发件地址",
+ "admin.settings.smtp.useTls": "使用 TLS",
+ "admin.settings.smtp.sendTest": "发送测试邮件",
+ "admin.settings.smtp.sent": "已发送",
+ "admin.settings.smtp.sendFailed": "发送失败",
+ "admin.models.channelOpenrouter": "OpenRouter",
+ "admin.models.channelOpenrouterPremium": "OpenRouter(Premium)",
+ "admin.models.channelDeepseek": "DeepSeek(直连)",
+ "admin.models.channelZhipu": "z.ai(GLM)",
+ "admin.models.channelMinimax": "MiniMax(直连)",
+ "admin.models.channelOpenai": "OpenAI",
+ "admin.models.channelZai": "xAI(Grok)",
+ "admin.models.customModelPlaceholder": "模型 ID",
+ "admin.models.routeHint": "通道决定使用直连接入还是网关;模型标识因通道而异。",
+ "admin.models.addRouteConfirm": "添加",
+ "admin.marketing.generateTitle": "用 AI 生成",
+ "admin.marketing.generateDesc": "文案依据营销方案撰写(定位与允许使用的数字)。不会自动发送任何内容:请先审阅再发送。",
+ "admin.marketing.channel": "通道",
+ "admin.marketing.model": "模型",
+ "admin.marketing.modelPlaceholder": "通道默认",
+ "admin.marketing.promoOptional": "折扣(可选)",
+ "admin.marketing.promoNone": "无",
+ "admin.marketing.brief": "内容简报",
+ "admin.marketing.briefPlaceholder": "例如:提醒等候名单中的注册用户 Pro 套餐的优势。",
+ "admin.marketing.language": "邮件语言",
+ "admin.marketing.tone": "语气",
+ "admin.marketing.toneNone": "中性",
+ "admin.marketing.generateBtn": "生成",
+ "admin.marketing.generating": "生成中...",
+ "admin.marketing.generatedTitle": "邮件已生成",
+ "admin.marketing.generatedDesc": "主题和 HTML 已填入表单 —— 发送前请审阅({provider})。",
+ "admin.marketing.generateFailedTitle": "无法生成",
+ "admin.marketing.briefRequired": "请先在内容简报中描述所需内容。",
+ "admin.marketing.langFr": "法语",
+ "admin.marketing.langEn": "英语",
+ "admin.marketing.langDe": "德语",
+ "admin.marketing.langEs": "西班牙语",
+ "admin.marketing.langIt": "意大利语",
+ "admin.marketing.langPt": "葡萄牙语",
+ "admin.marketing.langNl": "荷兰语",
+ "admin.marketing.langAr": "阿拉伯语",
+ "admin.marketing.langFa": "波斯语",
+ "admin.marketing.langJa": "日语",
+ "admin.marketing.langKo": "韩语",
+ "admin.marketing.langRu": "俄语",
+ "admin.marketing.langZh": "中文",
+ "admin.marketing.toneFriendly": "亲切",
+ "admin.marketing.toneProfessional": "专业",
+ "admin.marketing.toneUrgent": "紧迫",
+ "admin.marketing.toneFestive": "喜庆",
+ "admin.marketing.promoTitle": "折扣",
+ "admin.marketing.promoDesc": "优惠码通过 Stripe 应用于付款;计数器只在订阅真正完成后增加。",
+ "admin.marketing.promoEmpty": "暂无优惠码。",
+ "admin.marketing.promoActive": "启用",
+ "admin.marketing.promoInactive": "已停用",
+ "admin.marketing.promoLocalOnly": "不在 Stripe",
+ "admin.marketing.promoDeactivate": "停用",
+ "admin.marketing.promoUsage": "已使用 {used}/{max} 次",
+ "admin.marketing.promoPlansLabel": "套餐:",
+ "admin.marketing.promoAllPlans": "全部",
+ "admin.marketing.promoExpiresOn": "{date} 到期",
+ "admin.marketing.promoNeverExpires": "不过期",
+ "admin.marketing.promoCreateTitle": "创建折扣",
+ "admin.marketing.promoCreateDesc": "如果配置了 Stripe,优惠码也会在 Stripe 中创建(优惠券 + 促销码)。",
+ "admin.marketing.promoCode": "代码",
+ "admin.marketing.promoType": "类型",
+ "admin.marketing.promoTypePercent": "百分比",
+ "admin.marketing.promoTypeAmount": "金额(EUR)",
+ "admin.marketing.promoValue": "数值",
+ "admin.marketing.promoPlans": "适用套餐",
+ "admin.marketing.promoMaxRedemptions": "使用上限",
+ "admin.marketing.promoMaxPlaceholder": "不限",
+ "admin.marketing.promoExpires": "到期日",
+ "admin.marketing.promoCreate": "创建代码",
+ "admin.marketing.promoCreating": "创建中...",
+ "admin.marketing.promoCreatedTitle": "优惠码已创建",
+ "admin.marketing.promoCreatedDesc": "代码 {code} 已就绪。",
+ "admin.marketing.promoCreateFailedTitle": "创建被拒绝",
+ "admin.marketing.promoInvalidInput": "请检查代码和数值。",
+ "admin.marketing.promoDeactivatedTitle": "代码已停用",
+ "admin.marketing.promoDeactivatedDesc": "代码 {code} 不再适用。",
+ "admin.settings.fallback.title": "备用链",
+ "admin.settings.fallback.desc": "自动选择服务商的优先顺序。",
+ "admin.settings.fallback.classic": "经典模式(Google)",
+ "admin.settings.fallback.llm": "LLM 模式(网关与直连接入)",
+ "admin.settings.fallback.note": "只有已注册的通道(google、openai、deepseek、minimax)参与这条链;AI 层级和其他通道不参与。",
+ "admin.models.duplicateRouteTitle": "已拒绝重复项",
+ "admin.models.duplicateRouteDesc": "该通道 + 模型组合已在列表中。",
+ "admin.marketing.promoDeactivateFailedTitle": "停用被拒绝",
+ "admin.marketing.promoDelete": "删除",
+ "admin.marketing.promoDeleteConfirm": "确定永久删除代码 {code} 吗?",
+ "admin.marketing.promoDeletedTitle": "代码已删除",
+ "admin.marketing.promoDeletedDesc": "代码 {code} 已删除。",
+ "admin.marketing.promoDeleteFailedTitle": "删除被拒绝",
+ "admin.marketing.promoLinkStripe": "关联到 Stripe",
+ "admin.marketing.promoLinkedTitle": "代码已关联到 Stripe",
+ "admin.marketing.promoLinkedDesc": "代码 {code} 现在可用于付款。",
+ "admin.marketing.promoLinkFailedTitle": "关联失败",
+ "admin.marketing.promoExpiryHint": "「至 10 日」的代码在整个 10 日当天有效(至 23:59:59 UTC)。",
+ "admin.marketing.generateEmpty": "服务器返回的内容为空或无法使用。"
}
diff --git a/frontend/src/lib/i18n/messages/zh/pricing.json b/frontend/src/lib/i18n/messages/zh/pricing.json
index ec2c777..143c20d 100644
--- a/frontend/src/lib/i18n/messages/zh/pricing.json
+++ b/frontend/src/lib/i18n/messages/zh/pricing.json
@@ -167,5 +167,17 @@
"pricing.header.titleBase": "总有一款套餐适合",
"pricing.header.titleAccent": "您的需求",
"pricing.toast.close": "关闭",
- "pricing.aiModels.premium.price": "每百万 Token $2 / $10"
+ "pricing.aiModels.premium.price": "每百万 Token $2 / $10",
+ "pricing.promo.label": "优惠码",
+ "pricing.promo.placeholder": "您的代码",
+ "pricing.promo.apply": "应用",
+ "pricing.promo.checking": "检查中...",
+ "pricing.promo.valid": "代码有效",
+ "pricing.promo.subscriptionOnly": "仅适用于订阅,不适用于积分。",
+ "pricing.promoError.unknown": "该优惠码不存在。",
+ "pricing.promoError.inactive": "该优惠码已停用。",
+ "pricing.promoError.expired": "该优惠码已过期。",
+ "pricing.promoError.exhausted": "该优惠码已达到使用上限。",
+ "pricing.promoError.plan": "该优惠码不适用于此套餐。",
+ "pricing.promoError.local_only": "该优惠码未关联在线支付。"
}
diff --git a/routes/admin_routes.py b/routes/admin_routes.py
index 4fbab74..fce43cb 100644
--- a/routes/admin_routes.py
+++ b/routes/admin_routes.py
@@ -31,6 +31,18 @@ from models.subscription import (
DEFAULT_AI_MODELS_PREMIUM,
)
from services import pricing_config as pricing_cfg
+from services.payment_service import (
+ DuplicatePromoError,
+ PromoValidationError,
+ StripePromoCreationError,
+ create_promo_code,
+ deactivate_promo_code,
+ delete_promo_code,
+ link_promo_to_stripe,
+ load_promo_codes,
+ save_promo_codes,
+)
+from services.providers.config import LLM_CHANNELS
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@@ -1042,14 +1054,37 @@ class SmtpSettings(BaseModel):
use_tls: bool = True
-class AiTierSettings(BaseModel):
- """Palier IA : modèles actifs (l'ordre = priorité de secours) + modèle par défaut.
+# Fournisseurs (canaux) utilisables dans une route de palier : source unique =
+# LLM_CHANNELS (services.providers.config). Les canaux « openrouter »/
+# « openrouter_premium » sont des passerelles multi-modèles ; les autres sont
+# des accès directs compatibles OpenAI.
+AI_TIER_PROVIDERS = tuple(LLM_CHANNELS.keys())
- ``default_model`` vide → premier de ``models`` → défaut du plan (PLANS).
- Source des valeurs par défaut : la gamme officielle de ``models/subscription.py``.
+
+class AiTierRoute(BaseModel):
+ """Route d'un palier : un modèle via un canal donné.
+
+ ``provider`` = canal (openrouter, deepseek, zhipu, minimax, openai, zai…),
+ ``model`` = identifiant du modèle chez ce canal, ``label`` = libellé libre.
+ """
+ provider: str = "openrouter"
+ model: str = ""
+ label: str = ""
+
+
+class AiTierSettings(BaseModel):
+ """Palier IA : routes actives (l'ordre = priorité de secours) + modèle par défaut.
+
+ Deux écritures acceptées :
+ - ``routes`` : liste de routes ``{provider, model, label}`` (multi-canaux) ;
+ - ``models``/``default_model`` : ancien format (chaînes OpenRouter),
+ encore accepté et routé via openrouter comme auparavant.
+ ``routes`` vide → repli sur ``default_model`` → premier de ``models`` →
+ défaut du plan (PLANS).
"""
models: List[str] = Field(default_factory=list)
default_model: str = ""
+ routes: List[AiTierRoute] = Field(default_factory=list)
class AiTiersSettings(BaseModel):
@@ -1076,6 +1111,7 @@ class SettingsConfig(BaseModel):
openrouter: ProviderSettings = ProviderSettings() # "Traduction IA Essentielle"
openrouter_premium: ProviderSettings = ProviderSettings() # "Traduction IA Premium"
deepseek: ProviderSettings = ProviderSettings()
+ zhipu: ProviderSettings = ProviderSettings() # z.ai / Zhipu (GLM, direct)
minimax: ProviderSettings = ProviderSettings()
zai: ProviderSettings = ProviderSettings()
mistral: ProviderSettings = ProviderSettings() # OCR Mistral (PDF scannés)
@@ -1089,29 +1125,66 @@ class SettingsConfig(BaseModel):
def normalize_ai_tiers(ai_tiers: AiTiersSettings) -> AiTiersSettings:
"""Déduplique, nettoie et complète un bloc ai_tiers avec la gamme officielle.
+ Routes : fournisseur connu obligatoire, modèle non vide, dédoublonnage
+ (canal + modèle) — l'ordre est conservé (ordre = priorité de secours).
Lève ValueError si le défaut d'un palier appartient à la gamme de l'autre
palier (un défaut Essentielle doit rester un modèle Essentielle, et
inversement) : un utilisateur Pro ne doit jamais déclencher un modèle
- Premium, et l'inverse serait une erreur de configuration.
+ Premium, et l'inverse serait une erreur de configuration. La même garde
+ s'applique aux routes openrouter (les modèles des autres canaux ont des
+ identifiants propres, le croisement n'a pas de sens pour eux).
"""
essential_official = list(DEFAULT_AI_MODELS_ESSENTIAL)
premium_official = list(DEFAULT_AI_MODELS_PREMIUM)
+ def _clean_routes(tier: AiTierSettings, tier_name: str, other_official: List[str],
+ other_name: str) -> List[AiTierRoute]:
+ routes: List[AiTierRoute] = []
+ seen: set = set()
+ for route in (tier.routes or []):
+ provider = (route.provider or "").strip().lower()
+ model = (route.model or "").strip()
+ if not provider and not model:
+ continue
+ if provider not in AI_TIER_PROVIDERS:
+ raise ValueError(
+ f"ai_tiers.{tier_name}.routes : fournisseur « {provider} » inconnu "
+ f"(canaux acceptés : {', '.join(AI_TIER_PROVIDERS)})."
+ )
+ if not model:
+ raise ValueError(
+ f"ai_tiers.{tier_name}.routes : une route du canal « {provider} » "
+ "n'a pas de modèle."
+ )
+ key = (provider, model)
+ if key in seen:
+ continue
+ if provider == "openrouter" and model in other_official:
+ raise ValueError(
+ f"ai_tiers.{tier_name}.routes : « {model} » appartient à la gamme "
+ f"{other_name} : choisissez un modèle de la gamme {tier_name}."
+ )
+ seen.add(key)
+ routes.append(AiTierRoute(provider=provider, model=model,
+ label=(route.label or "").strip()))
+ return routes
+
def _fill(tier: AiTierSettings, official: List[str], other_official: List[str],
tier_name: str, other_name: str) -> AiTierSettings:
+ routes = _clean_routes(tier, tier_name, other_official, other_name)
models = [m.strip() for m in (tier.models or []) if m and m.strip()]
models = list(dict.fromkeys(models)) # dédoublonnage, ordre conservé
- if not models:
+ if not models and not routes:
models = list(official)
default = (tier.default_model or "").strip()
- if default and default not in models:
+ if default and default not in models and not routes:
models.insert(0, default)
if default in other_official:
raise ValueError(
f"ai_tiers.{tier_name}.default_model « {default} » appartient à la "
f"gamme {other_name} : choisissez un modèle de la gamme {tier_name}."
)
- return AiTierSettings(models=models, default_model=default)
+ return AiTierSettings(models=models, default_model=default, routes=routes)
return AiTiersSettings(
essential=_fill(ai_tiers.essential, essential_official, premium_official,
@@ -1182,7 +1255,8 @@ async def get_settings(admin_id: str = Depends(require_admin)):
payload["openrouter_premium"] = _merge_env(settings.openrouter_premium, key_env="OPENROUTER_API_KEY", model_env="OPENROUTER_PREMIUM_MODEL", default_model="anthropic/claude-sonnet-5")
payload["openai"] = _merge_env(settings.openai, key_env="OPENAI_API_KEY", model_env="OPENAI_MODEL", default_model="gpt-4o-mini")
payload["deepseek"] = _merge_env(settings.deepseek, key_env="DEEPSEEK_API_KEY", model_env="DEEPSEEK_MODEL", default_model="deepseek-chat")
- payload["minimax"] = _merge_env(settings.minimax, key_env="MINIMAX_API_KEY", model_env="MINIMAX_MODEL", default_model="abab6.5s-chat")
+ payload["zhipu"] = _merge_env(settings.zhipu, key_env="ZHIPU_API_KEY", model_env="ZHIPU_MODEL", url_env="ZHIPU_BASE_URL", default_model="glm-5.3", default_url="https://api.z.ai/api/paas/v4")
+ payload["minimax"] = _merge_env(settings.minimax, key_env="MINIMAX_API_KEY", model_env="MINIMAX_MODEL", default_model="MiniMax-M3")
payload["zai"] = _merge_env(settings.zai, key_env="ZAI_API_KEY", model_env="ZAI_MODEL", url_env="ZAI_BASE_URL", default_model="grok-2-1212", default_url="https://api.x.ai/v1")
payload["google_cloud"] = _merge_env(settings.google_cloud, key_env="GOOGLE_CLOUD_API_KEY")
@@ -1216,6 +1290,7 @@ async def get_settings(admin_id: str = Depends(require_admin)):
"openrouter": has_openrouter,
"openrouter_premium": has_openrouter, # same key, different model
"deepseek": bool(os.getenv("DEEPSEEK_API_KEY", "").strip()),
+ "zhipu": bool(os.getenv("ZHIPU_API_KEY", "").strip()),
"minimax": bool(os.getenv("MINIMAX_API_KEY", "").strip()),
"zai": bool(os.getenv("ZAI_API_KEY", "").strip()),
@@ -1231,12 +1306,15 @@ async def get_settings(admin_id: str = Depends(require_admin)):
def _ai_tiers_is_pristine(ai_tiers: AiTiersSettings) -> bool:
"""True si le bloc ai_tiers reçu est le défaut vierge (client qui ne gère
- pas les paliers : liste vide/par défaut et aucun modèle imposé)."""
+ pas les paliers : liste vide/par défaut, aucune route et aucun modèle imposé)."""
def _is_default(tier: AiTierSettings, official: List[str]) -> bool:
models = [m.strip() for m in (tier.models or []) if m and m.strip()]
- return (not models or models == list(official)) and not (
- tier.default_model or ""
- ).strip()
+ routes = [r for r in (tier.routes or []) if (r.model or "").strip()]
+ return (
+ (not models or models == list(official))
+ and not routes
+ and not (tier.default_model or "").strip()
+ )
return _is_default(ai_tiers.essential, list(DEFAULT_AI_MODELS_ESSENTIAL)) and _is_default(
ai_tiers.premium, list(DEFAULT_AI_MODELS_PREMIUM)
@@ -1413,14 +1491,6 @@ async def test_provider(
)
- elif provider == "openrouter_premium":
- current.openrouter_premium = _update_provider(current.openrouter_premium, update_data)
- elif provider == "deepseek":
- current.deepseek = _update_provider(current.deepseek, update_data)
- elif provider == "minimax":
- current.minimax = _update_provider(current.minimax, update_data)
- elif provider == "zai":
- current.zai = _update_provider(current.zai, update_data)
elif provider == "openai":
api_key = _key(provider_config.api_key, "OPENAI_API_KEY")
if not api_key:
@@ -1430,13 +1500,57 @@ async def test_provider(
)
import openai as _openai
- client = _openai.OpenAI(api_key=api_key)
+ client = _openai.OpenAI(api_key=api_key, timeout=15)
models = list(client.models.list())
return JSONResponse(
status_code=200,
content={"available": True, "models_count": len(models)},
)
+ elif provider in ("deepseek", "minimax", "zhipu"):
+ # Fournisseurs directs compatibles OpenAI : la liste des modèles
+ # sert de test de connexion (clé + URL de base).
+ key_env = {
+ "deepseek": "DEEPSEEK_API_KEY",
+ "minimax": "MINIMAX_API_KEY",
+ "zhipu": "ZHIPU_API_KEY",
+ }[provider]
+ api_key = _key(provider_config.api_key, key_env)
+ if not api_key:
+ return JSONResponse(
+ status_code=400,
+ content={
+ "available": False,
+ "error": f"Aucune clé API trouvée pour {provider} (JSON ou .env)",
+ },
+ )
+ import openai as _openai
+
+ base_url = (provider_config.base_url or "").strip()
+ if not base_url:
+ from services.providers.config import LLM_CHANNELS
+
+ url_env = LLM_CHANNELS.get(provider, {}).get("url_env", "")
+ base_url = (os.getenv(url_env, "").strip() if url_env else "") or LLM_CHANNELS[provider]["base_url"]
+ client = _openai.OpenAI(api_key=api_key, base_url=base_url, timeout=15)
+ try:
+ models = list(client.models.list())
+ return JSONResponse(
+ status_code=200,
+ content={
+ "available": True,
+ "models_count": len(models),
+ "sample_models": [m.id for m in models[:5]],
+ },
+ )
+ except Exception as api_err:
+ return JSONResponse(
+ status_code=502,
+ content={
+ "available": False,
+ "error": f"{provider} injoignable ({str(api_err)[:200]})",
+ },
+ )
elif provider in ("openrouter", "openrouter_premium"):
api_key = _key(provider_config.api_key, "OPENROUTER_API_KEY")
@@ -1475,7 +1589,7 @@ async def test_provider(
import openai as _openai
base_url = (provider_config.base_url or "").strip() or os.getenv("ZAI_BASE_URL", "https://api.x.ai/v1")
- client = _openai.OpenAI(api_key=api_key, base_url=base_url)
+ client = _openai.OpenAI(api_key=api_key, base_url=base_url, timeout=15)
try:
models = list(client.models.list())
return JSONResponse(
@@ -2731,6 +2845,614 @@ async def _execute_bulk_send(
)
+# ============================================================
+# Génération d'email par IA (page Marketing) — jamais d'envoi
+# automatique : le serveur ne fait que produire {subject, html}.
+# ============================================================
+
+MARKETING_PLAN_FILE = config.BASE_DIR / "MARKETING_PLAN.md"
+MARKETING_PLAN_PROMPT_MAX_CHARS = 4000
+GENERATED_SUBJECT_MAX_CHARS = 200
+GENERATED_HTML_MAX_CHARS = 100_000
+GENERATION_TIMEOUT_SECONDS = 90
+
+
+def _load_marketing_plan_excerpt() -> str:
+ """Extrait du plan marketing pour le prompt : positionnement (§1) et
+ forfaits/chiffres autorisés (§2), tronqué (le prompt doit rester léger)."""
+ try:
+ text = MARKETING_PLAN_FILE.read_text(encoding="utf-8")
+ except Exception as e:
+ logger.warning("Lecture MARKETING_PLAN.md impossible: %s", e)
+ return ""
+ match = re.search(r"^## 1\..*?(?=^## 3\.|\Z)", text, re.MULTILINE | re.DOTALL)
+ excerpt = (match.group(0) if match else text).strip()
+ if len(excerpt) > MARKETING_PLAN_PROMPT_MAX_CHARS:
+ excerpt = excerpt[:MARKETING_PLAN_PROMPT_MAX_CHARS] + "\n[…]"
+ return excerpt
+
+
+def _generation_model_for(admin_cfg, provider: str, requested: str) -> str:
+ """Modèle du canal : demande explicite > réglage admin > défaut du canal."""
+ requested = (requested or "").strip()
+ if requested:
+ return requested
+ provider_cfg = getattr(admin_cfg, provider, None)
+ admin_model = (getattr(provider_cfg, "model", "") or "").strip() if provider_cfg else ""
+ return admin_model or LLM_CHANNELS[provider]["default_model"]
+
+
+async def _call_llm_chat(
+ admin_cfg, provider: str, model: str, system_prompt: str, user_prompt: str
+) -> str:
+ """Appel chat/completions (API compatible OpenAI) sur le canal choisi.
+
+ Délai d'attente et erreurs propagées : l'appelant renvoie une erreur 502
+ claire (jamais d'HTTP 500 nu).
+ """
+ import httpx
+
+ from routes.translate_routes import _channel_api_key, _channel_base_url
+
+ api_key = _channel_api_key(admin_cfg, provider)
+ if not api_key:
+ raise RuntimeError(f"Aucune clé API configurée pour le canal « {provider} ».")
+ base_url = _channel_base_url(admin_cfg, provider).rstrip("/")
+ payload = {
+ "model": model,
+ "messages": [
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_prompt},
+ ],
+ "temperature": 0.7,
+ "max_tokens": 4000,
+ }
+ async with httpx.AsyncClient(timeout=GENERATION_TIMEOUT_SECONDS) as client:
+ resp = await client.post(
+ f"{base_url}/chat/completions",
+ headers={"Authorization": f"Bearer {api_key}"},
+ json=payload,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ try:
+ return data["choices"][0]["message"]["content"] or ""
+ except (KeyError, IndexError, TypeError) as e:
+ raise RuntimeError(f"Réponse LLM inattendue: {e}") from e
+
+
+def _build_generation_system_prompt(
+ language: str, tone: str, promo_line: str, promo_code: str = ""
+) -> str:
+ plan_excerpt = _load_marketing_plan_excerpt()
+ tone_line = f"Ton demandé : {tone}. " if tone else ""
+ promo_block = ""
+ if promo_line:
+ site_url = os.getenv("FRONTEND_URL", "http://localhost:3000").rstrip("/")
+ promo_block = (
+ f"\nOffre à mettre en avant :\n{promo_line}\n"
+ f"Termine l'email par un bouton d'appel à l'action bien visible "
+ f"(styles en ligne) dont le lien pointe exactement vers "
+ f"{site_url}/pricing?promo={promo_code} "
+ "— utilise ce lien tel quel, sans en inventer un autre.\n"
+ )
+ if _language_is_rtl(language):
+ promo_block += (
+ "Cette langue se lit de droite à gauche : mets dir=\"rtl\" sur le "
+ "conteneur principal du HTML et aligne le texte à droite.\n"
+ )
+ return (
+ "Tu es le rédacteur marketing de Wordly.art (Office Translator), un "
+ "service de traduction de documents qui préserve la mise en page.\n"
+ "Rédige un email de relance en respectant STRICTEMENT le plan marketing "
+ "ci-dessous : n'invente AUCUN chiffre, prix, pourcentage ou date absent "
+ "du plan ; si une réduction est fournie, cite-la exactement.\n"
+ f"Langue imposée de l'email : {language}. {tone_line}"
+ "Le HTML doit rester simple : structure avec des div et des styles en "
+ "ligne, largeur maximale ~600px, pas d'image externe, pas de "
+ "JavaScript, pas de CSS externe. Ne mets ni pied de page de "
+ "désabonnement ni coordonnées serveur : ils sont ajoutés par le "
+ "système au moment de l'envoi.\n"
+ f"{promo_block}"
+ "\n--- Extrait du plan marketing (source des chiffres autorisés) ---\n"
+ f"{plan_excerpt}\n"
+ "--- Fin de l'extrait ---\n\n"
+ 'Réponds UNIQUEMENT avec un objet JSON : {"subject": "", '
+ '"html": ""}'
+ )
+
+
+def _parse_generated_email(raw: str) -> Optional[tuple]:
+ """Analyse robuste de la sortie du modèle : JSON direct, JSON dans un bloc
+ de code, premier objet {...}, puis repli /corps. Renvoie
+ (subject, html) ou None si rien d'exploitable."""
+ raw = (raw or "").strip()
+ if not raw:
+ return None
+
+ def _from_json(text: str) -> Optional[tuple]:
+ try:
+ data = json.loads(text)
+ except (ValueError, TypeError):
+ return None
+ if not isinstance(data, dict):
+ return None
+ subject = str(data.get("subject") or "").strip()
+ html = str(data.get("html") or "").strip()
+ if subject and html:
+ return (subject, html)
+ return None
+
+ # 1. JSON direct, 2. bloc de code markdown, 3. premier objet {...}
+ parsed = _from_json(raw)
+ if parsed:
+ return parsed
+ stripped = re.sub(r"^```[a-zA-Z]*\s*|\s*```$", "", raw, flags=re.MULTILINE).strip()
+ parsed = _from_json(stripped)
+ if parsed:
+ return parsed
+ brace = re.search(r"\{.*\}", raw, re.DOTALL)
+ if brace:
+ parsed = _from_json(brace.group(0))
+ if parsed:
+ return parsed
+
+ # 4. Repli texte : = sujet, le reste = HTML.
+ title_match = re.search(r"]*>(.*?) ", raw, re.IGNORECASE | re.DOTALL)
+ if title_match:
+ subject = title_match.group(1).strip()
+ html = raw
+ if html.strip():
+ return (subject, html)
+ return None
+
+
+def _sanitize_generated_html(html: str) -> str:
+ """Nettoyage basique du HTML généré : pas de script (fermé ou non), pas
+ d'iframe/object/embed/form, pas de gestionnaires d'événements, pas d'URL
+ ``javascript:`` ni ``data:`` (casse mixte et espaces intrusive neutralisés)."""
+ # Scripts : balise fermée, balise orpheline (pas de ) et casse mixte.
+ html = re.sub(
+ r"|$)",
+ "",
+ html,
+ flags=re.IGNORECASE | re.DOTALL,
+ )
+ html = re.sub(r"?script\b[^>]*>", "", html, flags=re.IGNORECASE)
+ # Éléments à contenu retirés en entier (avec repli si la balise fermante manque).
+ for tag in ("iframe", "object", "embed", "form"):
+ html = re.sub(
+ rf"<{tag}\b[^>]*>.*?(?:{tag}\s*>|$)",
+ "",
+ html,
+ flags=re.IGNORECASE | re.DOTALL,
+ )
+ html = re.sub(rf"?{tag}\b[^>]*>", "", html, flags=re.IGNORECASE)
+ # Gestionnaires d'événements (onclick, onerror…).
+ html = re.sub(
+ r"\son\w+\s*=\s*(\"[^\"]*\"|'[^']*'|[^\s>]+)", "", html, flags=re.IGNORECASE
+ )
+ # URL dangereuses : javascript: quel que soit le style (espaces insérés,
+ # casse mixte) et data: (data:text/html…).
+ html = re.sub(r"(?i)j\s*a\s*v\s*a\s*s\s*c\s*r\s*i\s*p\s*t\s*:", "", html)
+ html = re.sub(r"(?i)d\s*a\s*t\s*a\s*:", "", html)
+ return html
+
+
+_RTL_GENERATION_LANGUAGES = {
+ "ar", "fa", "arabe", "arabic", "العربية", "فارسی", "persan", "persian",
+}
+
+
+def _language_is_rtl(language: str) -> bool:
+ return (language or "").strip().lower() in _RTL_GENERATION_LANGUAGES
+
+
+class MarketingEmailGenerateRequest(BaseModel):
+ provider: str = "openrouter" # canal LLM (voir LLM_CHANNELS)
+ model: str = Field(default="", max_length=120) # vide = modèle par défaut du canal
+ brief: str = Field(..., min_length=1, max_length=4000)
+ language: str = Field(default="Français", max_length=50)
+ tone: str = Field(default="", max_length=50)
+ promo_code: str = Field(default="", max_length=24)
+
+
+@router.post("/marketing/email/generate")
+async def marketing_email_generate(
+ body: MarketingEmailGenerateRequest, admin_id: str = Depends(require_admin)
+):
+ """Génère un email de relance par IA (sujet + HTML), sans aucun envoi.
+
+ Le prompt système est construit depuis le plan marketing (positionnement,
+ forfaits, chiffres autorisés) ; la sortie du modèle est analysée avec
+ repli (JSON → /corps) et nettoyée (pas de script). Une sortie non
+ analysable renvoie 502 AI_GENERATION_FAILED — l'admin relit et envoie
+ depuis la page Marketing, rien n'est envoyé automatiquement.
+ """
+ provider = (body.provider or "").strip().lower()
+ if provider not in LLM_CHANNELS:
+ return JSONResponse(
+ status_code=400,
+ content={
+ "error": "UNKNOWN_CHANNEL",
+ "message": (
+ f"Canal « {provider} » inconnu (canaux : "
+ f"{', '.join(LLM_CHANNELS)})."
+ ),
+ },
+ )
+
+ admin_cfg = load_settings()
+ from routes.translate_routes import _channel_has_key
+
+ if not _channel_has_key(admin_cfg, provider):
+ return JSONResponse(
+ status_code=400,
+ content={
+ "error": "PROVIDER_NOT_CONFIGURED",
+ "message": (
+ f"Aucune clé API configurée pour le canal « {provider} » "
+ "(réglages Fournisseurs ou .env)."
+ ),
+ },
+ )
+
+ # Sans plan marketing lisible, l'IA inventerait des chiffres : on refuse.
+ if not _load_marketing_plan_excerpt().strip():
+ return JSONResponse(
+ status_code=503,
+ content={
+ "error": "MARKETING_PLAN_MISSING",
+ "message": (
+ "MARKETING_PLAN.md est introuvable ou vide à la racine du "
+ "projet : la génération est refusée pour ne pas diffuser de "
+ "chiffres inventés."
+ ),
+ },
+ )
+
+ promo_line = ""
+ promo_wanted = (body.promo_code or "").strip().upper()
+ if promo_wanted:
+ promo = next(
+ (p for p in load_promo_codes() if (p.get("code") or "").upper() == promo_wanted),
+ None,
+ )
+ if not promo:
+ return JSONResponse(
+ status_code=400,
+ content={
+ "error": "PROMO_NOT_FOUND",
+ "message": f"Le code promo « {promo_wanted} » n'existe pas : "
+ "corrigez ou videz le sélecteur de réduction.",
+ },
+ )
+ if not promo.get("active", False):
+ return JSONResponse(
+ status_code=400,
+ content={
+ "error": "PROMO_NOT_FOUND",
+ "message": f"Le code promo « {promo_wanted} » n'est plus actif : "
+ "choisissez une autre réduction.",
+ },
+ )
+ if promo.get("type") == "percent":
+ value_desc = f"-{promo.get('value')} %"
+ else:
+ value_desc = f"-{promo.get('value')} €"
+ promo_line = (
+ f"Code {promo.get('code')} ({value_desc}) — à citer exactement, "
+ f"valable pour : {', '.join(promo.get('plans') or ['all'])}."
+ )
+
+ system_prompt = _build_generation_system_prompt(
+ (body.language or "Français").strip() or "Français",
+ (body.tone or "").strip(),
+ promo_line,
+ promo_wanted,
+ )
+ user_prompt = (
+ f"Brief de l'email :\n{body.brief.strip()}\n\n"
+ "Produis l'objet et le HTML de l'email au format JSON demandé."
+ )
+ model = _generation_model_for(admin_cfg, provider, body.model)
+
+ try:
+ raw = await _call_llm_chat(admin_cfg, provider, model, system_prompt, user_prompt)
+ except Exception as e:
+ logger.error("marketing email generation failed (%s/%s): %s", provider, model, e)
+ return JSONResponse(
+ status_code=502,
+ content={
+ "error": "AI_GENERATION_FAILED",
+ "message": f"La génération a échoué ({str(e)[:300]}). Aucun email envoyé.",
+ },
+ )
+
+ parsed = _parse_generated_email(raw)
+ if not parsed:
+ logger.warning("marketing email generation unparseable output (%s/%s)", provider, model)
+ return JSONResponse(
+ status_code=502,
+ content={
+ "error": "AI_GENERATION_FAILED",
+ "message": (
+ "La réponse du modèle n'a pas pu être analysée (sujet + HTML "
+ "attendus). Aucun email envoyé — réessayez."
+ ),
+ },
+ )
+
+ subject, html = parsed
+ subject = re.sub(r"\s+", " ", subject).strip()[:GENERATED_SUBJECT_MAX_CHARS]
+ html = _sanitize_generated_html(html).strip()
+ # Langues de droite à gauche : on garantit l'enveloppe dir="rtl" même si
+ # le modèle l'a oubliée.
+ if _language_is_rtl((body.language or "").strip()) and 'dir="rtl"' not in html.lower():
+ html = f'{html}
'
+ if not subject or not html or len(html) > GENERATED_HTML_MAX_CHARS:
+ return JSONResponse(
+ status_code=502,
+ content={
+ "error": "AI_GENERATION_FAILED",
+ "message": (
+ "Le contenu généré est vide ou dépasse la longueur maximale. "
+ "Aucun email envoyé — réessayez."
+ ),
+ },
+ )
+
+ logger.info(
+ "marketing email generated channel=%s model=%s by=%s", provider, model, admin_id
+ )
+ return JSONResponse(
+ status_code=200,
+ content={
+ "data": {
+ "subject": subject,
+ "html": html,
+ "provider": provider,
+ "model": model,
+ "language": body.language,
+ },
+ "meta": {},
+ },
+ )
+
+
+# ============================================================
+# Réductions : codes promo (data/promo_codes.json) + Stripe
+# ============================================================
+
+PROMO_CODE_PATTERN = re.compile(r"^[A-Z0-9-]{3,24}$")
+_VALID_PROMO_PLANS = {"all"} | {pt.value for pt in PlanType}
+
+
+class PromoCreateRequest(BaseModel):
+ code: str
+ type: Literal["percent", "amount"] = "percent"
+ value: float
+ plans: List[str] = Field(default_factory=lambda: ["all"])
+ max_redemptions: Optional[int] = None
+ expires_at: Optional[str] = None # ISO (date ou date-heure), futur obligatoire
+
+
+@router.get("/marketing/promos")
+async def marketing_promos_list(admin_id: str = Depends(require_admin)):
+ """Liste des codes promo (compteur d'utilisations inclus, clés Stripe
+ internes incluses : identifiants d'objets, jamais de valeur sensible)."""
+ return JSONResponse(
+ status_code=200,
+ content={"data": {"promos": load_promo_codes()}, "meta": {}},
+ )
+
+
+@router.post("/marketing/promos")
+async def marketing_promos_create(
+ body: PromoCreateRequest, admin_id: str = Depends(require_admin)
+):
+ """Crée un code promo : Coupon + Promotion Code Stripe si Stripe est
+ configuré, sinon un code « local_only » (refusé au checkout avec un
+ message clair). Plafonds posés côté local ET côté Stripe."""
+ code = (body.code or "").strip().upper()
+ if not PROMO_CODE_PATTERN.match(code):
+ return JSONResponse(
+ status_code=400,
+ content={
+ "error": "INVALID_PROMO_CODE",
+ "message": "Le code doit contenir 3 à 24 caractères : lettres majuscules, chiffres ou tirets.",
+ },
+ )
+ if any((p.get("code") or "").upper() == code for p in load_promo_codes()):
+ return JSONResponse(
+ status_code=409,
+ content={
+ "error": "PROMO_ALREADY_EXISTS",
+ "message": f"Le code promo « {code} » existe déjà.",
+ },
+ )
+
+ if body.type == "percent":
+ if not (0 < float(body.value) <= 100):
+ return JSONResponse(
+ status_code=400,
+ content={
+ "error": "INVALID_PROMO_VALUE",
+ "message": "Pour un pourcentage, la valeur doit être entre 1 et 100.",
+ },
+ )
+ else:
+ if not (0.01 <= float(body.value) <= 10000):
+ return JSONResponse(
+ status_code=400,
+ content={
+ "error": "INVALID_PROMO_VALUE",
+ "message": "Pour un montant, la valeur doit être entre 0,01 et 10 000 EUR.",
+ },
+ )
+
+ plans = [p.strip().lower() for p in (body.plans or []) if p and p.strip()]
+ if not plans:
+ plans = ["all"]
+ if "all" in plans:
+ plans = ["all"]
+ invalid = [p for p in plans if p not in _VALID_PROMO_PLANS]
+ if invalid:
+ return JSONResponse(
+ status_code=400,
+ content={
+ "error": "INVALID_PROMO_PLAN",
+ "message": f"Forfaits inconnus : {', '.join(invalid)}.",
+ },
+ )
+ if "free" in plans:
+ return JSONResponse(
+ status_code=400,
+ content={
+ "error": "INVALID_PROMO_PLAN",
+ "message": "Le forfait Gratuit n'est pas éligible aux codes promo "
+ "(un code s'applique aux abonnements payants uniquement).",
+ },
+ )
+
+ if body.max_redemptions is not None and int(body.max_redemptions) < 1:
+ return JSONResponse(
+ status_code=400,
+ content={
+ "error": "INVALID_PROMO_LIMIT",
+ "message": "Le plafond d'utilisations doit être au moins 1.",
+ },
+ )
+
+ expires_at = None
+ if body.expires_at and body.expires_at.strip():
+ try:
+ exp = datetime.fromisoformat(body.expires_at.strip())
+ if exp.tzinfo is None:
+ exp = exp.replace(tzinfo=timezone.utc)
+ if exp <= datetime.now(timezone.utc):
+ return JSONResponse(
+ status_code=400,
+ content={
+ "error": "INVALID_PROMO_EXPIRY",
+ "message": "La date d'expiration doit être dans le futur.",
+ },
+ )
+ expires_at = exp.isoformat()
+ except ValueError:
+ return JSONResponse(
+ status_code=400,
+ content={
+ "error": "INVALID_PROMO_EXPIRY",
+ "message": "Date d'expiration illisible (format ISO attendu).",
+ },
+ )
+
+ try:
+ entry = create_promo_code(
+ code=code,
+ type=body.type,
+ value=float(body.value),
+ plans=plans,
+ max_redemptions=body.max_redemptions,
+ expires_at=expires_at,
+ )
+ except DuplicatePromoError:
+ # Course perdue contre une création simultanée du même code : la
+ # vérification faisant foi est celle sous le verrou (service).
+ return JSONResponse(
+ status_code=409,
+ content={
+ "error": "PROMO_ALREADY_EXISTS",
+ "message": f"Le code promo « {code} » existe déjà.",
+ },
+ )
+ except StripePromoCreationError as e:
+ return JSONResponse(
+ status_code=502,
+ content={
+ "error": "STRIPE_PROMO_FAILED",
+ "message": f"Stripe a refusé la création du code « {code} » : {str(e)[:200]}. "
+ "Rien n'a été enregistré.",
+ },
+ )
+ logger.info("admin_promo_created code=%s by=%s", code, admin_id)
+ return JSONResponse(status_code=201, content={"data": entry, "meta": {}})
+
+
+@router.delete("/marketing/promos/{code}")
+async def marketing_promos_delete(code: str, admin_id: str = Depends(require_admin)):
+ """Supprime un code promo (localement, et la promotion code chez Stripe)."""
+ promo = delete_promo_code(code)
+ if not promo:
+ return JSONResponse(
+ status_code=404,
+ content={
+ "error": "PROMO_NOT_FOUND",
+ "message": f"Le code promo « {code} » n'existe pas.",
+ },
+ )
+ logger.info("admin_promo_deleted code=%s by=%s", code, admin_id)
+ return JSONResponse(status_code=200, content={"data": promo, "meta": {}})
+
+
+@router.post("/marketing/promos/{code}/link-stripe")
+async def marketing_promos_link_stripe(
+ code: str, admin_id: str = Depends(require_admin)
+):
+ """Relie un code « local_only » à Stripe : crée le Coupon + Promotion Code
+ et met à jour l'enregistrement (le code devient applicable au checkout)."""
+ try:
+ promo = link_promo_to_stripe(code)
+ except StripePromoCreationError as e:
+ return JSONResponse(
+ status_code=502,
+ content={
+ "error": "STRIPE_PROMO_FAILED",
+ "message": f"Stripe a refusé la création pour « {code} » : {str(e)[:200]}.",
+ },
+ )
+ except PromoValidationError as e:
+ return JSONResponse(
+ status_code=400,
+ content={"error": "PROMO_INACTIVE", "message": e.message},
+ )
+ except RuntimeError as e:
+ return JSONResponse(
+ status_code=400,
+ content={"error": "STRIPE_NOT_CONFIGURED", "message": str(e)},
+ )
+ if not promo:
+ return JSONResponse(
+ status_code=404,
+ content={
+ "error": "PROMO_NOT_FOUND",
+ "message": f"Le code promo « {code} » n'existe pas.",
+ },
+ )
+ logger.info("admin_promo_linked_stripe code=%s by=%s", code, admin_id)
+ return JSONResponse(status_code=200, content={"data": promo, "meta": {}})
+
+
+@router.post("/marketing/promos/{code}/deactivate")
+async def marketing_promos_deactivate(
+ code: str, admin_id: str = Depends(require_admin)
+):
+ """Désactive un code promo localement ET chez Stripe (la réduction
+ n'est plus applicable au checkout)."""
+ promo = deactivate_promo_code(code)
+ if not promo:
+ return JSONResponse(
+ status_code=404,
+ content={
+ "error": "PROMO_NOT_FOUND",
+ "message": f"Le code promo « {code} » n'existe pas.",
+ },
+ )
+ logger.info("admin_promo_deactivated code=%s by=%s", code, admin_id)
+ return JSONResponse(status_code=200, content={"data": promo, "meta": {}})
+
+
marketing_public_router = APIRouter(prefix="/api/v1/marketing", tags=["Marketing"])
diff --git a/routes/api_v1_router.py b/routes/api_v1_router.py
index ba101bf..950ced0 100644
--- a/routes/api_v1_router.py
+++ b/routes/api_v1_router.py
@@ -10,6 +10,7 @@ router = APIRouter(tags=["API v1"])
from routes.translate_routes import router_v1 as translate_router
from routes.auth_routes import router_v1 as auth_router
+from routes.auth_routes import promos_public_router
from routes.api_key_routes import router as api_key_router
from routes.admin_routes import router as admin_router
from routes.admin_routes import marketing_public_router
@@ -22,6 +23,7 @@ from routes.workspace_routes import router as workspace_router
router.include_router(translate_router, tags=["Translation"])
router.include_router(auth_router, tags=["Authentication"])
+router.include_router(promos_public_router, tags=["Promotions"])
router.include_router(api_key_router, tags=["API Keys"])
router.include_router(admin_router, tags=["Admin"])
router.include_router(marketing_public_router, tags=["Marketing"])
diff --git a/routes/auth_routes.py b/routes/auth_routes.py
index 0a6149e..4e1d519 100644
--- a/routes/auth_routes.py
+++ b/routes/auth_routes.py
@@ -72,6 +72,9 @@ class GoogleAuthRequest(BaseModel):
class CheckoutRequest(BaseModel):
plan: PlanType
billing_period: str = "monthly"
+ # Code promo optionnel — validé localement AVANT toute création de session
+ # Stripe (actif, forfait, expiration, plafond).
+ promo_code: Optional[str] = None
class CreditsCheckoutRequest(BaseModel):
@@ -844,13 +847,19 @@ async def create_checkout_v1(request: CheckoutRequest, user=Depends(require_user
user_id=user.id,
plan=request.plan,
billing_period=request.billing_period,
+ promo_code=(request.promo_code or "").strip(),
)
if "error" in result and not result.get("demo_mode"):
return JSONResponse(
status_code=400,
content={
- "error": "CHECKOUT_FAILED",
+ "error": result.get("error_code") or "CHECKOUT_FAILED",
"message": result["error"],
+ **(
+ {"promo_reason": result["promo_reason"]}
+ if result.get("promo_reason")
+ else {}
+ ),
},
)
return JSONResponse(status_code=200, content={"data": result, "meta": {}})
@@ -934,6 +943,56 @@ async def get_billing_portal_v1(user=Depends(require_user)):
return JSONResponse(status_code=200, content={"data": {"url": url}, "meta": {}})
+# ============== Codes promo (validation publique avant paiement) ==============
+
+promos_public_router = APIRouter(prefix="/api/v1/promos", tags=["Promotions"])
+
+
+@promos_public_router.get(
+ "/validate",
+ summary="Valider un code promo",
+ description=(
+ "Vérifie un code promo pour un forfait donné AVANT l'ouverture du "
+ "paiement : renvoie valid, reason_code (cause explicite) et le montant "
+ "de la remise. Aucune authentification requise."
+ ),
+)
+async def validate_promo_v1(
+ code: str = Query(..., min_length=1, max_length=24),
+ plan: str = Query("pro", min_length=1, max_length=32),
+):
+ from services.payment_service import PromoValidationError, validate_promo_code
+
+ wanted = (code or "").strip().upper()
+ plan_id = (plan or "").strip().lower()
+ try:
+ promo = validate_promo_code(wanted, plan_id)
+ except PromoValidationError as e:
+ return JSONResponse(
+ status_code=200,
+ content={
+ "data": {
+ "valid": False,
+ "reason_code": e.reason,
+ "message": e.message,
+ },
+ "meta": {},
+ },
+ )
+ discount: Dict[str, Any] = {}
+ if promo.get("type") == "percent":
+ discount["percent_off"] = promo.get("value")
+ else:
+ discount["amount_off"] = promo.get("value")
+ return JSONResponse(
+ status_code=200,
+ content={
+ "data": {"valid": True, "reason_code": None, **discount},
+ "meta": {},
+ },
+ )
+
+
# ============== Forgot / Reset password ==============
diff --git a/routes/legacy_routes.py b/routes/legacy_routes.py
index 62da0f0..f926eb4 100644
--- a/routes/legacy_routes.py
+++ b/routes/legacy_routes.py
@@ -88,7 +88,9 @@ async def get_available_providers(
# AI Essentielle (OpenRouter — cheap model / Eco)
if _is_enabled("openrouter", key_var="OPENROUTER_API_KEY"):
- model = _resolve_tier_model(settings, user_plan, premium=False)
+ # La résolution renvoie la route complète (canal, modèle) — le canal
+ # réel (z.ai direct, DeepSeek direct…) peut différer d'openrouter.
+ _prov, model = _resolve_tier_model(settings, user_plan, premium=False)
available.append({
"id": "openrouter",
"label": "Traduction IA Éco",
@@ -117,7 +119,7 @@ async def get_available_providers(
# AI Premium (OpenRouter — premium model)
if _is_enabled("openrouter_premium", key_var="OPENROUTER_API_KEY") or _is_enabled("openrouter", key_var="OPENROUTER_API_KEY"):
- model = _resolve_tier_model(settings, user_plan, premium=True)
+ _prov, model = _resolve_tier_model(settings, user_plan, premium=True)
available.append({
"id": "openrouter_premium",
"label": "Traduction IA Premium",
diff --git a/routes/translate_routes.py b/routes/translate_routes.py
index 7c7e630..7d3897b 100644
--- a/routes/translate_routes.py
+++ b/routes/translate_routes.py
@@ -217,52 +217,190 @@ def _plan_ai_models(plan: PlanType, premium: bool) -> list:
return models
-def _resolve_tier_model(admin_cfg: Any, user_plan: Optional[str], premium: bool) -> str:
- """Modèle réellement routé pour le palier IA du plan.
+def _tier_route_candidates(admin_cfg: Any, premium: bool) -> tuple:
+ """Routes candidates d'un palier : (routes_multi_canaux, réglage_hérité).
- Ordre de résolution (spec « Routage réel par abonnement ») :
- 1. réglages admin ``ai_tiers..default_model`` (changement sans redéploiement) ;
- 2. premier modèle de ``ai_tiers..models`` (ordre = priorité de secours) ;
- 3. défaut du plan (``PLANS[plan]["ai_models_essential"|"ai_models_premium"][0]``).
- Garde d'exécution : un modèle résolu dans la gamme de l'autre palier est
- rejeté et remplacé par le défaut du plan — un utilisateur Pro (moteur
- openrouter = Essentielle) ne peut pas déclencher un modèle Premium.
+ - ``routes`` multi-canaux ``{provider, model}`` — l'ordre est la priorité ;
+ - réglage hérité ``default_model`` puis ``models`` (chaînes OpenRouter),
+ conservé pour compatibilité — routé via openrouter comme avant.
+ Une chaîne simple (« z-ai/glm-5.3-flash ») vaut une route
+ ``{"provider": "openrouter", "model": …}`` (compatibilité ascendante).
"""
- from models.subscription import (
- DEFAULT_AI_MODELS_ESSENTIAL,
- DEFAULT_AI_MODELS_PREMIUM,
+ tier_cfg = getattr(
+ getattr(admin_cfg, "ai_tiers", None), "premium" if premium else "essential", None
)
-
- tier_cfg = getattr(getattr(admin_cfg, "ai_tiers", None), "premium" if premium else "essential", None)
- resolved = ""
+ routes: list = []
+ legacy: list = []
if tier_cfg is not None:
+ for route in (getattr(tier_cfg, "routes", None) or []):
+ provider = (getattr(route, "provider", "") or "").strip().lower()
+ model = (getattr(route, "model", "") or "").strip()
+ if provider and model:
+ routes.append((provider, model))
default = (getattr(tier_cfg, "default_model", "") or "").strip()
if default:
- resolved = default
- else:
- tier_models = [m.strip() for m in (getattr(tier_cfg, "models", None) or []) if m and m.strip()]
- if tier_models:
- resolved = tier_models[0]
- if not resolved:
- resolved = _plan_ai_models(_plan_from_str(user_plan), premium)[0]
+ legacy.append(("openrouter", default))
+ for model in (getattr(tier_cfg, "models", None) or []):
+ model = (model or "").strip()
+ if model:
+ legacy.append(("openrouter", model))
+ return routes, legacy
- # Garde : le modèle résolu doit appartenir au palier demandé.
- if premium and resolved in DEFAULT_AI_MODELS_ESSENTIAL:
+
+def _resolve_tier_model(admin_cfg: Any, user_plan: Optional[str], premium: bool) -> tuple:
+ """Route réellement utilisée pour le palier IA du plan : ``(provider, model)``.
+
+ Fonction de résolution UNIQUE, utilisée par le worker ET par l'affichage
+ public (/providers/available) : elle tient compte des clés disponibles,
+ une route sans clé API étant sautée au profit de la suivante.
+
+ Ordre de résolution (spec « Routage réel par abonnement ») :
+ 1. routes multi-canaux ``ai_tiers..routes`` (openrouter, deepseek,
+ zhipu, minimax, openai, zai) ;
+ 2. ancien réglage hérité ``default_model``/``models`` (= openrouter,
+ compatibilité ascendante) ;
+ 3. défaut du plan (``PLANS[plan]["ai_models_essential"|"ai_models_premium"][0]``).
+ Garde d'exécution inchangée : un modèle openrouter résolu dans la gamme de
+ l'autre palier est rejeté — un utilisateur Pro (moteur openrouter =
+ Essentielle) ne peut pas déclencher un modèle Premium. Les canaux directs
+ ont des identifiants propres : la garde ne s'applique pas à eux.
+ Repli final : ``(openrouter, défaut du plan)`` même sans clé — c'est la
+ référence d'affichage ; à l'exécution, l'instanciation échouera plus loin
+ (moteur indisponible), comme avant.
+ """
+ routes, legacy = _tier_route_candidates(admin_cfg, premium)
+
+ for provider, model in list(routes) + list(legacy):
+ if provider == "openrouter" and _model_in_other_range(model, premium):
+ logger.warning(
+ "ai_tiers.%s route %s belongs to the other model range — skipped.",
+ "premium" if premium else "essential", model,
+ )
+ continue
+ if _channel_has_key(admin_cfg, provider):
+ return (provider, model)
logger.warning(
- "ai_tiers.premium resolved to an Essentielle model (%s) — using the "
- "plan Premium default instead.",
- resolved,
+ "ai_tiers.%s route %s/%s has no API key — falling back to the next route.",
+ "premium" if premium else "essential", provider, model,
)
- resolved = _plan_ai_models(_plan_from_str(user_plan), premium=True)[0]
- elif not premium and resolved in DEFAULT_AI_MODELS_PREMIUM:
- logger.warning(
- "ai_tiers.essential resolved to a Premium model (%s) — refusing: a "
- "Pro subscription must never trigger a Premium model. Using the "
- "plan Essentielle default instead.",
- resolved,
+
+ return ("openrouter", _plan_ai_models(_plan_from_str(user_plan), premium)[0])
+
+
+def _model_in_other_range(model: str, premium: bool) -> bool:
+ """True si un modèle openrouter appartient à la gamme de l'autre palier."""
+ from models.subscription import DEFAULT_AI_MODELS_ESSENTIAL, DEFAULT_AI_MODELS_PREMIUM
+
+ return model in (DEFAULT_AI_MODELS_ESSENTIAL if premium else DEFAULT_AI_MODELS_PREMIUM)
+
+
+def _channel_has_key(admin_cfg: Any, provider: str) -> bool:
+ """Clé API disponible pour un canal (réglages admin, sinon variable d'env) ?"""
+ import os as _os
+
+ from services.providers.config import LLM_CHANNELS
+
+ channel = LLM_CHANNELS.get(provider)
+ if not channel:
+ return False
+ admin_val = ""
+ provider_cfg = getattr(admin_cfg, provider, None)
+ if provider_cfg is not None:
+ admin_val = (getattr(provider_cfg, "api_key", "") or "").strip()
+ env_val = (_os.getenv(channel["key_env"], "") or "").strip()
+ # openrouter_premium partage la clé openrouter.
+ if not admin_val and not env_val and provider == "openrouter_premium":
+ return _channel_has_key(admin_cfg, "openrouter")
+ return bool(admin_val or env_val)
+
+
+def _channel_api_key(admin_cfg: Any, provider: str) -> str:
+ """Clé API d'un canal : réglages admin > variable d'environnement.
+
+ ``openrouter_premium`` hérite de la clé openrouter (même passerelle).
+ """
+ from services.providers.config import LLM_CHANNELS
+
+ channel = LLM_CHANNELS.get(provider)
+ if not channel:
+ return ""
+ provider_cfg = getattr(admin_cfg, provider, None)
+ admin_val = (getattr(provider_cfg, "api_key", "") or "").strip() if provider_cfg else ""
+ if admin_val:
+ return admin_val
+ env_val = (os.getenv(channel["key_env"], "") or "").strip()
+ if env_val:
+ return env_val
+ if provider == "openrouter_premium":
+ return _channel_api_key(admin_cfg, "openrouter")
+ return ""
+
+
+def _channel_base_url(admin_cfg: Any, provider: str) -> str:
+ """URL de base d'un canal : réglages admin > variable d'env > défaut."""
+ from services.providers.config import LLM_CHANNELS
+
+ channel = LLM_CHANNELS.get(provider) or {}
+ provider_cfg = getattr(admin_cfg, provider, None)
+ admin_val = (getattr(provider_cfg, "base_url", "") or "").strip() if provider_cfg else ""
+ if admin_val:
+ return admin_val
+ url_env = channel.get("url_env", "")
+ env_val = (os.getenv(url_env, "") or "").strip() if url_env else ""
+ return env_val or channel.get("base_url", "")
+
+
+def _instantiate_channel_provider(admin_cfg: Any, provider: str, model: str) -> Any:
+ """Instancie le fournisseur d'un canal donné.
+
+ deepseek → ``DeepSeekTranslationProvider`` ; tous les autres canaux
+ (openrouter, openrouter_premium, zhipu, minimax, zai, openai) sont des
+ API compatibles OpenAI → ``OpenAITranslationProvider`` avec leur base_url.
+ Renvoie None si la clé du canal est absente.
+ """
+ api_key = _channel_api_key(admin_cfg, provider)
+ if not api_key:
+ return None
+ base_url = _channel_base_url(admin_cfg, provider)
+ timeout = int(os.getenv(f"{provider.upper()}_TIMEOUT", "60") or 60)
+
+ if provider == "deepseek":
+ from services.providers.deepseek_provider import DeepSeekTranslationProvider
+
+ return DeepSeekTranslationProvider(
+ api_key=api_key, model=model, base_url=base_url, timeout=timeout
)
- resolved = _plan_ai_models(_plan_from_str(user_plan), premium=False)[0]
- return resolved
+ from services.providers.openai_provider import OpenAITranslationProvider
+
+ return OpenAITranslationProvider(
+ api_key=api_key, model=model, base_url=base_url, timeout=timeout
+ )
+
+
+def _instantiate_tier_routes(
+ admin_cfg: Any, user_plan: Optional[str], premium: bool, job_id: str = ""
+) -> tuple:
+ """Choisit la route du palier via la résolution unique (clé-consciente,
+ ``_resolve_tier_model``) et instancie son fournisseur.
+
+ Renvoie ``(provider_instance, channel, model)`` ou ``(None, None, None)``
+ si la route retenue n'a pas de clé utilisable (le job échouera alors
+ comme avant, avec le message « moteur indisponible »).
+ """
+ provider, model = _resolve_tier_model(admin_cfg, user_plan, premium)
+ instance = _instantiate_channel_provider(admin_cfg, provider, model)
+ tier_name = "premium" if premium else "essential"
+ if instance is not None:
+ logger.info(
+ "Job %s: ai_tiers.%s routed via %s/%s",
+ job_id, tier_name, provider, model,
+ )
+ return instance, provider, model
+ logger.warning(
+ "Job %s: ai_tiers.%s route %s/%s has no usable API key — engine unavailable.",
+ job_id, tier_name, provider, model,
+ )
+ return None, None, None
def _image_translation_allowed_for_plan(plan: PlanType) -> bool:
@@ -642,15 +780,23 @@ def _provider_model(provider: Any) -> str:
)
-def _compute_cost_factor(provider: Any, provider_name: str = "") -> int:
- """Billing cost factor (1 = standard, 5 = premium).
+def _compute_cost_factor(
+ provider: Any, provider_name: str = "", premium: Optional[bool] = None
+) -> int:
+ """Billing cost factor (1 = standard/Essentielle, 5 = premium).
- Premium models (Claude, GPT-4 family, etc.) cost more and are billed at a
- higher factor. Cheap variants are explicitly downgraded to 1 (``haiku``,
- and the small GPT-4 models such as ``gpt-4o-mini`` / ``gpt-4o-nano``).
- The provider may be passed by instance (model read off it) or only by
- name (e.g. the ``openrouter_premium`` alias).
+ La facturation suit le PALIER, pas le canal : Premium = 5, Essentielle = 1,
+ quel que soit le canal réellement utilisé (openrouter, z.ai direct, DeepSeek
+ direct…). ``premium`` est imposé par l'appelant quand le palier est connu ;
+ sans information de palier (``premium=None``, moteurs explicites hors
+ palier), l'ancienne heuristique par nom de modèle s'applique (Claude,
+ GPT-4 → 5 ; variantes « mini »/« nano »/« haiku » → 1).
"""
+ if premium is True:
+ return 5
+ if premium is False:
+ return 1
+
model_lower = _provider_model(provider).lower()
provider_lower = (provider_name or "").lower()
@@ -1358,9 +1504,8 @@ async def _run_translation_job(
return (admin_val or "").strip() or os.getenv(env_var, default)
api_key = _cfg(_admin_cfg.openrouter.api_key, "OPENROUTER_API_KEY")
- # Routage réel par abonnement : le modèle vient du palier IA du plan
- # (réglages admin « ai_tiers » > défaut du plan) — voir _resolve_tier_model.
- model = _resolve_tier_model(_admin_cfg, user_plan, premium=False)
+ # Routage réel par abonnement : la route (canal + modèle) vient du
+ # palier IA du plan — voir _resolve_tier_model / _instantiate_tier_routes.
# Story 3.10: Retrieve and format glossary terms for LLM prompt
glossary_terms = None
@@ -1437,26 +1582,18 @@ async def _run_translation_job(
)
logger.info("google_provider_using_legacy", extra={"job_id": job_id})
- elif _p in ("openrouter", "llm") and api_key:
- translation_provider = OpenAITranslationProvider(
- api_key=api_key,
- model=model,
- base_url="https://openrouter.ai/api/v1",
- timeout=int(os.getenv("OPENROUTER_TIMEOUT", "60")),
+ elif _p in ("openrouter", "llm"):
+ # Palier Essentielle : routes multi-canaux (z.ai direct, DeepSeek
+ # direct…) avec repli automatique sur la route suivante si la clé
+ # manque, puis sur l'ancien réglage hérité / le défaut du plan.
+ translation_provider, _channel, _model = _instantiate_tier_routes(
+ _admin_cfg, user_plan, premium=False, job_id=job_id
)
elif _p == "openrouter_premium":
- premium_key = _cfg(_admin_cfg.openrouter_premium.api_key, "OPENROUTER_API_KEY")
- # Palier Premium : résolu par abonnement (défaut anthropic/claude-sonnet-5).
- premium_model = _resolve_tier_model(_admin_cfg, user_plan, premium=True)
- if not premium_key:
- premium_key = api_key # fall back to main openrouter key
- if premium_key:
- translation_provider = OpenAITranslationProvider(
- api_key=premium_key,
- model=premium_model,
- base_url="https://openrouter.ai/api/v1",
- timeout=int(os.getenv("OPENROUTER_TIMEOUT", "60")),
- )
+ # Palier Premium : mêmes routes multi-canaux, gamme Premium.
+ translation_provider, _channel, _model = _instantiate_tier_routes(
+ _admin_cfg, user_plan, premium=True, job_id=job_id
+ )
elif _p == "openai":
openai_key = _cfg(_admin_cfg.openai.api_key, "OPENAI_API_KEY")
openai_model = _cfg(_admin_cfg.openai.model, "OPENAI_MODEL", "gpt-4o-mini")
@@ -1484,7 +1621,7 @@ async def _run_translation_job(
)
elif _p == "minimax":
mm_key = _cfg(getattr(_admin_cfg, "minimax", None) and _admin_cfg.minimax.api_key, "MINIMAX_API_KEY")
- mm_model = _cfg(getattr(_admin_cfg, "minimax", None) and _admin_cfg.minimax.model, "MINIMAX_MODEL", "MiniMax-M1")
+ mm_model = _cfg(getattr(_admin_cfg, "minimax", None) and _admin_cfg.minimax.model, "MINIMAX_MODEL", "MiniMax-M3")
if mm_key:
translation_provider = MinimaxTranslationProvider(
api_key=mm_key,
@@ -1947,10 +2084,17 @@ async def _run_translation_job(
logger.warning(f"Job {job_id}: bilingual output failed: {bi_err}")
if user_id:
- # Determine cost factor based on selected provider and model.
+ # Determine cost factor based on the subscription TIER (Premium = 5,
+ # Essentielle/classique = 1), never on the channel name — a tier
+ # routed via z.ai or DeepSeek direct is billed like its tier.
# _compute_cost_factor reads the model off the provider robustly
# (new-style providers store it in ``_model``, legacy in ``model``).
- cost_factor = _compute_cost_factor(translation_provider, provider or "")
+ if _p in ("openrouter", "llm"):
+ cost_factor = _compute_cost_factor(translation_provider, provider or "", premium=False)
+ elif _p == "openrouter_premium":
+ cost_factor = _compute_cost_factor(translation_provider, provider or "", premium=True)
+ else:
+ cost_factor = _compute_cost_factor(translation_provider, provider or "")
# Persist monthly usage counters in PostgreSQL (docs + pages)
pages = await asyncio.to_thread(
diff --git a/services/payment_service.py b/services/payment_service.py
index 474f985..1a15b5c 100644
--- a/services/payment_service.py
+++ b/services/payment_service.py
@@ -1,9 +1,13 @@
"""
Stripe payment integration for subscriptions and credits
"""
+import json
import os
import logging
-from typing import Optional, Dict, Any
+import re
+import threading
+from pathlib import Path
+from typing import Optional, Dict, List, Any, Tuple
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
@@ -21,6 +25,7 @@ from services.auth_service import get_user_by_id, update_user, add_credits
from services.pricing_config import (
get_subscription_line_amount_eur,
stripe_price_ids_for_plan,
+ stripe_mode,
)
@@ -50,24 +55,389 @@ def is_stripe_configured() -> bool:
return _ensure_stripe_client_configured()
+# ═══════════════════════════════════════════════════════════════════════════
+# Codes promo (réductions) — stockés dans data/promo_codes.json (comme
+# pricing_overrides.json : JSON géré par l'admin, pas de table en base).
+#
+# Chaque entrée :
+# code, active, type ("percent"|"amount"), value, plans (["all"] ou ids),
+# max_redemptions (None = illimité), times_used, expires_at (ISO ou None),
+# stripe_coupon_id / stripe_promotion_code_id (None si créé hors Stripe),
+# stripe_mode ("test"|"live"|"local_only"), created_at, deactivated_at.
+#
+# Double plafond : le compteur local (times_used) est incrémenté uniquement à
+# la complétion du paiement, ET Stripe applique son propre max_redemptions.
+# ═══════════════════════════════════════════════════════════════════════════
+
+try:
+ from config import config as _app_config
+
+ PROMO_CODES_FILE: Path = _app_config.BASE_DIR / "data" / "promo_codes.json"
+except Exception: # pragma: no cover — config toujours disponible en pratique
+ PROMO_CODES_FILE = Path("data") / "promo_codes.json"
+
+PROMO_CODE_RE = re.compile(r"^[A-Z0-9-]{3,24}$")
+_promo_lock = threading.Lock()
+
+
+class PromoValidationError(Exception):
+ """Code promo inutilisable au checkout — la cause est explicite."""
+
+ def __init__(self, reason: str, message: str):
+ self.reason = reason # unknown|inactive|expired|exhausted|plan|local_only
+ self.message = message
+ super().__init__(message)
+
+
+class DuplicatePromoError(Exception):
+ """Un code identique existe déjà (vérification faite SOUS le verrou)."""
+
+
+class StripePromoCreationError(Exception):
+ """Stripe a refusé la création du Coupon/Promotion Code (rien n'est persisté)."""
+
+
+def load_promo_codes() -> List[dict]:
+ try:
+ if PROMO_CODES_FILE.exists():
+ with open(PROMO_CODES_FILE, encoding="utf-8") as f:
+ data = json.load(f)
+ return data if isinstance(data, list) else []
+ except Exception as e:
+ logger.warning("Lecture promo_codes impossible: %s", e)
+ return []
+
+
+def save_promo_codes(entries: List[dict]) -> None:
+ """Écriture atomique : fichier temporaire puis renommage (os.replace) —
+ un plantage pendant l'écriture ne peut pas laisser un JSON tronqué."""
+ PROMO_CODES_FILE.parent.mkdir(parents=True, exist_ok=True)
+ tmp_path = PROMO_CODES_FILE.with_name(PROMO_CODES_FILE.name + ".tmp")
+ with open(tmp_path, "w", encoding="utf-8") as f:
+ json.dump(entries, f, indent=2, ensure_ascii=False)
+ os.replace(tmp_path, PROMO_CODES_FILE)
+
+
+def _find_promo(entries: List[dict], code: str) -> Optional[dict]:
+ wanted = (code or "").strip().upper()
+ for e in entries:
+ if (e.get("code") or "").strip().upper() == wanted:
+ return e
+ return None
+
+
+def validate_promo_code(
+ code: str, plan: str, entries: Optional[List[dict]] = None
+) -> dict:
+ """Valide un code promo AVANT tout appel Stripe — lève PromoValidationError.
+
+ Causes explicites : inconnu, désactivé, expiré, épuisé, forfait non couvert,
+ ou code créé « local_only » (sans Stripe, donc inapplicable au checkout).
+ Une limite ou un compteur illisible (JSON corrompu) est traité par prudence
+ comme un code épuisé, jamais comme illimité.
+ """
+ wanted = (code or "").strip().upper()
+ if not wanted:
+ raise PromoValidationError("unknown", "Aucun code promo fourni.")
+ promo = _find_promo(entries if entries is not None else load_promo_codes(), wanted)
+ if not promo:
+ raise PromoValidationError("unknown", f"Le code promo « {wanted} » n'existe pas.")
+ if not promo.get("active", False):
+ raise PromoValidationError("inactive", f"Le code promo « {wanted} » n'est plus actif.")
+
+ expires_at = promo.get("expires_at")
+ if expires_at:
+ try:
+ exp = datetime.fromisoformat(str(expires_at))
+ if exp.tzinfo is None:
+ exp = exp.replace(tzinfo=timezone.utc)
+ if datetime.now(timezone.utc) >= exp:
+ raise PromoValidationError(
+ "expired", f"Le code promo « {wanted} » a expiré."
+ )
+ except PromoValidationError:
+ raise
+ except (TypeError, ValueError):
+ logger.warning("promo %s: expires_at illisible (%s)", wanted, expires_at)
+
+ max_redemptions = promo.get("max_redemptions")
+ if max_redemptions is not None:
+ try:
+ limit = int(max_redemptions)
+ used = int(promo.get("times_used", 0) or 0)
+ except (TypeError, ValueError):
+ raise PromoValidationError(
+ "exhausted",
+ f"Le code promo « {wanted} » est inutilisable : sa limite "
+ "d'utilisations est illisible.",
+ )
+ if used >= limit:
+ raise PromoValidationError(
+ "exhausted",
+ f"Le code promo « {wanted} » a atteint sa limite d'utilisations.",
+ )
+
+ plans = promo.get("plans") or ["all"]
+ if "all" not in plans and plan not in plans:
+ raise PromoValidationError(
+ "plan",
+ f"Le code promo « {wanted} » n'est pas valable pour le forfait « {plan} ».",
+ )
+
+ if not promo.get("stripe_promotion_code_id"):
+ raise PromoValidationError(
+ "local_only",
+ f"Le code promo « {wanted} » n'est pas relié à Stripe et ne peut pas "
+ "être appliqué au paiement.",
+ )
+ return promo
+
+
+def increment_promo_usage(code: str, session_id: str = "") -> bool:
+ """Incrémente le compteur local d'un code promo (à la complétion SEULEMENT).
+
+ ``session_id`` (identifiant de session Stripe de checkout) protège du double
+ comptage : un webhook suivi d'une synchronisation manuelle du MÊME paiement
+ ne compte qu'une fois, y compris en mode JSON (sans base).
+ """
+ wanted = (code or "").strip().upper()
+ if not wanted:
+ return False
+ sid = (session_id or "").strip()
+ with _promo_lock:
+ entries = load_promo_codes()
+ promo = _find_promo(entries, wanted)
+ if not promo:
+ return False
+ counted = promo.get("counted_sessions") or []
+ if sid and sid in counted:
+ logger.info(
+ "promo %s: checkout session %s déjà comptée — incrément ignoré",
+ wanted, sid,
+ )
+ return False
+ try:
+ promo["times_used"] = int(promo.get("times_used", 0) or 0) + 1
+ except (TypeError, ValueError):
+ promo["times_used"] = 1
+ promo["last_used_at"] = datetime.now(timezone.utc).isoformat()
+ if sid:
+ promo["counted_sessions"] = list(counted) + [sid]
+ save_promo_codes(entries)
+ logger.info("promo_code usage incremented: %s → %s", wanted, promo["times_used"])
+ return True
+
+
+def deactivate_promo_code(code: str) -> Optional[dict]:
+ """Désactive un code localement ET chez Stripe (promotion code désactivée)."""
+ wanted = (code or "").strip().upper()
+ with _promo_lock:
+ entries = load_promo_codes()
+ promo = _find_promo(entries, wanted)
+ if not promo:
+ return None
+ promo["active"] = False
+ promo["deactivated_at"] = datetime.now(timezone.utc).isoformat()
+ if promo.get("stripe_promotion_code_id") and is_stripe_configured():
+ try:
+ stripe.PromotionCode.modify(promo["stripe_promotion_code_id"], active=False)
+ except Exception as e:
+ # Stripe peut être injoignable : la désactivation locale reste
+ # effective et fait foi pour le checkout.
+ logger.error("Stripe PromotionCode.deactivate failed for %s: %s", wanted, e)
+ save_promo_codes(entries)
+ return promo
+
+
+def delete_promo_code(code: str) -> Optional[dict]:
+ """Supprime un code promo (admin). La promotion code Stripe associée est
+ supprimée aussi (repli : désactivée si la suppression est refusée)."""
+ wanted = (code or "").strip().upper()
+ with _promo_lock:
+ entries = load_promo_codes()
+ promo = _find_promo(entries, wanted)
+ if not promo:
+ return None
+ if promo.get("stripe_promotion_code_id") and is_stripe_configured():
+ try:
+ stripe.PromotionCode.delete(promo["stripe_promotion_code_id"])
+ except Exception as e:
+ logger.warning(
+ "Stripe PromotionCode.delete failed for %s (%s) — fallback: deactivate",
+ wanted, e,
+ )
+ try:
+ stripe.PromotionCode.modify(promo["stripe_promotion_code_id"], active=False)
+ except Exception as e2:
+ logger.error("Stripe PromotionCode deactivate fallback failed: %s", e2)
+ entries = [e for e in entries if e is not promo]
+ save_promo_codes(entries)
+ logger.info("promo %s supprimé", wanted)
+ return promo
+
+
+def _create_stripe_refs(
+ code: str, type: str, value: float,
+ max_redemptions: Optional[int], expires_at: Optional[str],
+) -> tuple:
+ """Crée le Coupon + Promotion Code Stripe. En cas d'échec Stripe, supprime
+ un coupon déjà créé (pas d'objet orphelin) et lève StripePromoCreationError."""
+ coupon = None
+ try:
+ coupon_params: Dict[str, Any] = {
+ "duration": "once",
+ "name": f"Reduction {code}",
+ }
+ if type == "percent":
+ coupon_params["percent_off"] = round(float(value), 2)
+ else:
+ coupon_params["amount_off"] = int(round(float(value) * 100))
+ coupon_params["currency"] = "eur"
+ coupon = stripe.Coupon.create(**coupon_params)
+
+ pc_params: Dict[str, Any] = {"coupon": coupon.id}
+ if max_redemptions:
+ pc_params["max_redemptions"] = int(max_redemptions)
+ if expires_at:
+ try:
+ exp = datetime.fromisoformat(str(expires_at))
+ pc_params["expires_at"] = int(
+ exp.replace(tzinfo=exp.tzinfo or timezone.utc).timestamp()
+ )
+ except (TypeError, ValueError):
+ logger.warning("promo %s: expires_at illisible, ignoré côté Stripe", code)
+ promo_code_obj = stripe.PromotionCode.create(**pc_params)
+ return coupon.id, promo_code_obj.id
+ except Exception as e:
+ stripe_err = getattr(stripe, "error", None) if stripe is not None else None
+ if stripe_err is not None and isinstance(e, stripe_err.StripeError):
+ if coupon:
+ try:
+ stripe.Coupon.delete(coupon.id)
+ logger.info("coupon orphelin %s supprimé après échec Stripe", coupon.id)
+ except Exception as del_err:
+ logger.error("suppression du coupon orphelin %s impossible: %s", coupon.id, del_err)
+ raise StripePromoCreationError(str(e)) from e
+ raise
+
+
+def create_promo_code(
+ code: str,
+ type: str,
+ value: float,
+ plans: Optional[List[str]] = None,
+ max_redemptions: Optional[int] = None,
+ expires_at: Optional[str] = None,
+) -> dict:
+ """Crée un code promo localement et, si Stripe est configuré, le Coupon +
+ Promotion Code Stripe associés (double plafond local ET Stripe).
+
+ Sans Stripe, le code est créé en « local_only » : il reste visible dans
+ l'admin mais sera refusé au checkout avec un message clair.
+ Le doublon est vérifié SOUS le verrou : deux créations simultanées du même
+ code aboutissent à un seul enregistrement.
+ """
+ wanted = (code or "").strip().upper()
+ now_iso = datetime.now(timezone.utc).isoformat()
+
+ with _promo_lock:
+ entries = load_promo_codes()
+ if _find_promo(entries, wanted):
+ raise DuplicatePromoError(
+ f"Le code promo « {wanted} » existe déjà."
+ )
+
+ entry: Dict[str, Any] = {
+ "code": wanted,
+ "active": True,
+ "type": type,
+ "value": float(value),
+ "plans": plans or ["all"],
+ "max_redemptions": int(max_redemptions) if max_redemptions else None,
+ "times_used": 0,
+ "counted_sessions": [],
+ "expires_at": expires_at or None,
+ "stripe_coupon_id": None,
+ "stripe_promotion_code_id": None,
+ "stripe_mode": "local_only",
+ "created_at": now_iso,
+ "deactivated_at": None,
+ }
+
+ if is_stripe_configured():
+ coupon_id, promo_code_id = _create_stripe_refs(
+ wanted, type, value, max_redemptions, expires_at
+ )
+ entry["stripe_coupon_id"] = coupon_id
+ entry["stripe_promotion_code_id"] = promo_code_id
+ entry["stripe_mode"] = stripe_mode() if stripe_mode() != "unknown" else "test"
+ logger.info("promo %s créé sur Stripe (coupon=%s)", wanted, coupon_id)
+ else:
+ logger.info("promo %s créé en local_only (Stripe non configuré)", wanted)
+
+ entries.append(entry)
+ save_promo_codes(entries)
+ return entry
+
+
+def link_promo_to_stripe(code: str) -> Optional[dict]:
+ """Relie un code « local_only » à Stripe : crée le Coupon + Promotion Code
+ et met à jour l'enregistrement local. Renvoie None si le code est inconnu."""
+ wanted = (code or "").strip().upper()
+ with _promo_lock:
+ entries = load_promo_codes()
+ promo = _find_promo(entries, wanted)
+ if not promo:
+ return None
+ if not promo.get("active", False):
+ raise PromoValidationError(
+ "inactive", f"Le code promo « {wanted} » est désactivé : réactivez-le d'abord."
+ )
+ if promo.get("stripe_promotion_code_id"):
+ return promo # déjà relié — rien à faire
+ if not is_stripe_configured():
+ raise RuntimeError("Stripe n'est pas configuré (clé secrète absente).")
+ coupon_id, promo_code_id = _create_stripe_refs(
+ wanted, promo.get("type", "percent"), float(promo.get("value", 0)),
+ promo.get("max_redemptions"), promo.get("expires_at"),
+ )
+ promo["stripe_coupon_id"] = coupon_id
+ promo["stripe_promotion_code_id"] = promo_code_id
+ promo["stripe_mode"] = stripe_mode() if stripe_mode() != "unknown" else "test"
+ save_promo_codes(entries)
+ logger.info("promo %s relié à Stripe (coupon=%s)", wanted, coupon_id)
+ return promo
+
+
async def create_checkout_session(
user_id: str,
plan: PlanType,
billing_period: str = "monthly", # monthly or yearly
success_url: str = "",
cancel_url: str = "",
+ promo_code: str = "",
) -> Optional[Dict[str, Any]]:
"""Create a Stripe checkout session for subscription"""
if not is_stripe_configured():
return {"error": "Stripe not configured", "demo_mode": True}
-
+
user = get_user_by_id(user_id)
if not user:
return {"error": "User not found"}
-
+
plan_config = PLANS[plan]
plan_id = plan.value
+ # Réduction : validation locale AVANT Stripe (actif, forfait, expiration,
+ # plafond local). Un code invalide n'entraîne aucune session Stripe.
+ promo_entry: Optional[dict] = None
+ promo_wanted = (promo_code or "").strip().upper()
+ if promo_wanted:
+ try:
+ promo_entry = validate_promo_code(promo_wanted, plan_id)
+ except PromoValidationError as e:
+ return {"error": e.message, "error_code": "PROMO_INVALID", "promo_reason": e.reason}
+
mid, yid = stripe_price_ids_for_plan(plan_id)
price_id = mid if billing_period == "monthly" else yid
@@ -112,22 +482,38 @@ async def create_checkout_session(
update_user(user_id, {"stripe_customer_id": customer_id})
# Create checkout session
- session = stripe.checkout.Session.create(
- customer=customer_id,
- mode="subscription",
- payment_method_types=["card"],
- line_items=[line_item],
- success_url=success_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/checkout/success?session_id={{CHECKOUT_SESSION_ID}}",
- cancel_url=cancel_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/pricing",
- metadata={"user_id": user_id, "plan": plan.value},
- subscription_data={
- "metadata": {"user_id": user_id, "plan": plan.value}
- }
- )
-
+ session_kwargs: Dict[str, Any] = {
+ "customer": customer_id,
+ "mode": "subscription",
+ "payment_method_types": ["card"],
+ "line_items": [line_item],
+ "success_url": success_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/checkout/success?session_id={{CHECKOUT_SESSION_ID}}",
+ "cancel_url": cancel_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/pricing",
+ "metadata": {
+ "user_id": user_id,
+ "plan": plan.value,
+ **({"promo_code": promo_wanted} if promo_entry else {}),
+ },
+ "subscription_data": {
+ "metadata": {
+ "user_id": user_id,
+ "plan": plan.value,
+ **({"promo_code": promo_wanted} if promo_entry else {}),
+ }
+ },
+ }
+ if promo_entry:
+ # Remise Stripe : la Promotion Code créée par l'admin porte les
+ # plafonds Stripe (max_redemptions / expires_at).
+ session_kwargs["discounts"] = [
+ {"promotion_code": promo_entry["stripe_promotion_code_id"]}
+ ]
+ session = stripe.checkout.Session.create(**session_kwargs)
+
return {
"session_id": session.id,
- "url": session.url
+ "url": session.url,
+ **({"promo_code": promo_wanted} if promo_entry else {}),
}
except Exception as e:
return {"error": str(e)}
@@ -415,6 +801,17 @@ async def handle_checkout_completed(session: Dict):
})
logger.info("Checkout synced: user %s → plan=%s tier=%s sub=%s", user_id, plan, tier, subscription_id)
+ # Réduction : le compteur local du code promo n'augmente qu'à la
+ # complétion réelle du paiement (webhook OU synchronisation manuelle),
+ # via la métadonnée posée à la création de la session. Les crédits ne
+ # supportent pas de code promo.
+ promo_used = (metadata.get("promo_code") or "").strip()
+ if promo_used:
+ try:
+ increment_promo_usage(promo_used, session_id=session_id or "")
+ except Exception as promo_err:
+ logger.error("promo usage increment failed for %s: %s", promo_used, promo_err)
+
# Log to PaymentHistory
if db_available and session_id:
try:
diff --git a/services/providers/config.py b/services/providers/config.py
index 05c5e5f..ebbb163 100644
--- a/services/providers/config.py
+++ b/services/providers/config.py
@@ -5,7 +5,7 @@ Loads API keys, URLs, and enable/disable flags from environment variables.
"""
import os
-from typing import List, Optional
+from typing import Dict, List, Optional
from pydantic import BaseModel
@@ -100,6 +100,16 @@ class ProvidersConfig:
MINIMAX_MAX_RETRIES: int = int(os.getenv("MINIMAX_MAX_RETRIES", "3"))
MINIMAX_RETRY_DELAY: float = float(os.getenv("MINIMAX_RETRY_DELAY", "1.0"))
+ # z.ai / Zhipu (modèles GLM — API directe compatible OpenAI)
+ # Clé : https://z.ai/manage-apikey/apikey-list — base par défaut https://api.z.ai/api/paas/v4
+ ZHIPU_ENABLED: bool = os.getenv("ZHIPU_ENABLED", "false").lower() == "true"
+ ZHIPU_API_KEY: str = os.getenv("ZHIPU_API_KEY", "")
+ ZHIPU_MODEL: str = os.getenv("ZHIPU_MODEL", "glm-5.3")
+ ZHIPU_BASE_URL: str = os.getenv("ZHIPU_BASE_URL", "https://api.z.ai/api/paas/v4")
+ ZHIPU_TIMEOUT: int = int(os.getenv("ZHIPU_TIMEOUT", "60"))
+ ZHIPU_MAX_RETRIES: int = int(os.getenv("ZHIPU_MAX_RETRIES", "3"))
+ ZHIPU_RETRY_DELAY: float = float(os.getenv("ZHIPU_RETRY_DELAY", "1.0"))
+
# Fallback chain configuration
#
# IMPORTANT: the registry-based fallback (translate_with_fallback) only
@@ -199,6 +209,12 @@ class ProvidersConfig:
base_url=cls.MINIMAX_BASE_URL,
model=cls.MINIMAX_MODEL,
),
+ "zhipu": ProviderSettings(
+ enabled=cls.ZHIPU_ENABLED,
+ api_key=cls.ZHIPU_API_KEY if cls.ZHIPU_API_KEY else None,
+ base_url=cls.ZHIPU_BASE_URL,
+ model=cls.ZHIPU_MODEL,
+ ),
}
return settings_map.get(provider_name.lower(), ProviderSettings())
@@ -219,7 +235,7 @@ class ProvidersConfig:
return False
# Providers requiring API keys
- providers_requiring_key = {"openai", "openrouter", "google_cloud", "deepseek", "minimax"}
+ providers_requiring_key = {"openai", "openrouter", "google_cloud", "deepseek", "minimax", "zhipu"}
if provider_name.lower() in providers_requiring_key:
return bool(settings.api_key)
@@ -238,3 +254,59 @@ class ProvidersConfig:
providers_config = ProvidersConfig()
+
+
+# Canaux LLM (tous compatibles OpenAI) utilisables dans les routes de palier
+# « ai_tiers » et par la génération d'emails marketing. Pour chaque canal :
+# base_url par défaut, variable d'environnement de la clé API, variable du
+# modèle et modèle par défaut. Les réglages admin (provider_settings.json)
+# priment sur les variables d'environnement.
+LLM_CHANNELS: Dict[str, Dict[str, str]] = {
+ "openrouter": {
+ "base_url": "https://openrouter.ai/api/v1",
+ "key_env": "OPENROUTER_API_KEY",
+ "model_env": "OPENROUTER_MODEL",
+ "default_model": "deepseek/deepseek-v4-flash",
+ },
+ "openrouter_premium": {
+ "base_url": "https://openrouter.ai/api/v1",
+ "key_env": "OPENROUTER_API_KEY",
+ "model_env": "OPENROUTER_PREMIUM_MODEL",
+ "default_model": "anthropic/claude-sonnet-5",
+ },
+ "deepseek": {
+ "base_url": "https://api.deepseek.com/v1",
+ "key_env": "DEEPSEEK_API_KEY",
+ "model_env": "DEEPSEEK_MODEL",
+ "url_env": "DEEPSEEK_BASE_URL",
+ "default_model": "deepseek-chat",
+ },
+ "zhipu": {
+ "base_url": "https://api.z.ai/api/paas/v4",
+ "key_env": "ZHIPU_API_KEY",
+ "model_env": "ZHIPU_MODEL",
+ "url_env": "ZHIPU_BASE_URL",
+ "default_model": "glm-5.3",
+ },
+ "minimax": {
+ "base_url": "https://api.minimax.io/v1",
+ "key_env": "MINIMAX_API_KEY",
+ "model_env": "MINIMAX_MODEL",
+ "url_env": "MINIMAX_BASE_URL",
+ "default_model": "MiniMax-M3",
+ },
+ "openai": {
+ "base_url": "https://api.openai.com/v1",
+ "key_env": "OPENAI_API_KEY",
+ "model_env": "OPENAI_MODEL",
+ "url_env": "OPENAI_BASE_URL",
+ "default_model": "gpt-4o-mini",
+ },
+ "zai": {
+ "base_url": "https://api.x.ai/v1",
+ "key_env": "ZAI_API_KEY",
+ "model_env": "ZAI_MODEL",
+ "url_env": "ZAI_BASE_URL",
+ "default_model": "grok-2-1212",
+ },
+}
diff --git a/tests/test_admin_promos.py b/tests/test_admin_promos.py
new file mode 100644
index 0000000..e1d3f1a
--- /dev/null
+++ b/tests/test_admin_promos.py
@@ -0,0 +1,570 @@
+"""
+Réductions : codes promo (spec « Fournisseur par palier IA »).
+
+- création locale (data/promo_codes.json) + Coupon/Promotion Code Stripe
+ quand Stripe est configuré (sinon « local_only ») ;
+- validation AU CHECKOUT AVANT tout appel Stripe (actif, forfait, expiration,
+ plafond) avec une cause explicite ;
+- incrément du compteur uniquement à la complétion du paiement.
+"""
+
+import json
+import pytest
+from pathlib import Path
+from types import SimpleNamespace
+from datetime import datetime, timedelta, timezone
+
+import services.payment_service as payment_svc
+
+ADMIN_LOGIN_URL = "/api/v1/admin/login"
+PROMOS_URL = "/api/v1/admin/marketing/promos"
+
+
+def _write_json(path: Path, data) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
+
+
+def _future(days: int = 30) -> str:
+ return (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
+
+
+@pytest.fixture
+def promo_file(tmp_path: Path, monkeypatch) -> Path:
+ path = tmp_path / "promo_codes.json"
+ monkeypatch.setattr(payment_svc, "PROMO_CODES_FILE", path)
+ return path
+
+
+@pytest.fixture
+def client(promo_file, monkeypatch, tmp_path: Path):
+ """TestClient avec authentification JSON (sans base de données)."""
+ import services.auth_service as auth_svc
+ from middleware.rate_limiting import RateLimitManager
+
+ monkeypatch.setattr(auth_svc, "USERS_FILE", tmp_path / "users.json")
+ monkeypatch.setattr(auth_svc, "USE_DATABASE", False)
+ monkeypatch.setattr(auth_svc, "DATABASE_AVAILABLE", False)
+
+ async def _check_request_allow(self, request):
+ return True, "ok", "test"
+
+ async def _check_translation_allow(self, request, file_size_mb=0):
+ return True, "ok"
+
+ monkeypatch.setattr(RateLimitManager, "check_request", _check_request_allow)
+ monkeypatch.setattr(RateLimitManager, "check_translation", _check_translation_allow)
+
+ from fastapi.testclient import TestClient
+ from main import app
+
+ return TestClient(app, raise_server_exceptions=True)
+
+
+@pytest.fixture
+def admin_headers(client, monkeypatch):
+ import routes.admin_routes as admin_routes_mod
+
+ monkeypatch.setattr(admin_routes_mod, "ADMIN_USERNAME", "admin")
+ monkeypatch.setattr(admin_routes_mod, "ADMIN_PASSWORD", "admin-secret")
+ monkeypatch.setattr(admin_routes_mod, "ADMIN_PASSWORD_HASH", None)
+ r = client.post(ADMIN_LOGIN_URL, json={"password": "admin-secret"})
+ assert r.status_code == 200, r.text
+ token = r.json()["access_token"]
+ return {"Authorization": f"Bearer {token}"}
+
+
+# ---------------------------------------------------------------------------
+# Création / listage / désactivation (endpoints admin)
+# ---------------------------------------------------------------------------
+def test_create_promo_without_stripe_is_local_only(client, admin_headers, promo_file):
+ r = client.post(
+ PROMOS_URL,
+ json={"code": "lancement-10", "type": "percent", "value": 10,
+ "max_redemptions": 10, "expires_at": _future()},
+ headers=admin_headers,
+ )
+ assert r.status_code == 201, r.text
+ entry = r.json()["data"]
+ assert entry["code"] == "LANCEMENT-10" # normalisé en majuscules
+ assert entry["stripe_mode"] == "local_only"
+ assert entry["stripe_promotion_code_id"] is None
+ assert entry["times_used"] == 0
+
+ r2 = client.get(PROMOS_URL, headers=admin_headers)
+ assert r2.status_code == 200
+ assert [p["code"] for p in r2.json()["data"]["promos"]] == ["LANCEMENT-10"]
+
+
+def test_create_promo_with_stripe_creates_coupon_and_code(
+ client, admin_headers, promo_file, monkeypatch
+):
+ monkeypatch.setattr(payment_svc, "is_stripe_configured", lambda: True)
+
+ class _FakeObj(dict):
+ def __getattr__(self, name):
+ try:
+ return self[name]
+ except KeyError as e: # pragma: no cover
+ raise AttributeError(name) from e
+
+ def _fake_coupon(**kwargs):
+ assert kwargs["percent_off"] == 15
+ assert kwargs["duration"] == "once"
+ return _FakeObj(id="coupon_1")
+
+ def _fake_promo_code(**kwargs):
+ assert kwargs["coupon"] == "coupon_1"
+ assert kwargs["max_redemptions"] == 5
+ assert "expires_at" in kwargs
+ return _FakeObj(id="promo_1")
+
+ monkeypatch.setattr(payment_svc.stripe.Coupon, "create", _fake_coupon)
+ monkeypatch.setattr(payment_svc.stripe.PromotionCode, "create", _fake_promo_code)
+ monkeypatch.setattr(payment_svc, "stripe_mode", lambda: "test")
+
+ r = client.post(
+ PROMOS_URL,
+ json={"code": "PROMO15", "type": "percent", "value": 15,
+ "plans": ["pro", "business"], "max_redemptions": 5,
+ "expires_at": _future()},
+ headers=admin_headers,
+ )
+ assert r.status_code == 201, r.text
+ entry = r.json()["data"]
+ assert entry["stripe_coupon_id"] == "coupon_1"
+ assert entry["stripe_promotion_code_id"] == "promo_1"
+ assert entry["stripe_mode"] == "test"
+
+
+def test_create_promo_rejects_invalid_input(client, admin_headers, promo_file):
+ # code trop court / minuscules après normalisation impossible (pattern)
+ r = client.post(PROMOS_URL, json={"code": "ab", "type": "percent", "value": 10},
+ headers=admin_headers)
+ assert r.status_code == 400
+ assert r.json()["error"] == "INVALID_PROMO_CODE"
+
+ # code avec caractères interdits
+ r = client.post(PROMOS_URL, json={"code": "MAUVAIS CODE!", "type": "percent", "value": 10},
+ headers=admin_headers)
+ assert r.status_code == 400
+
+ # pourcentage hors bornes
+ r = client.post(PROMOS_URL, json={"code": "GRAND", "type": "percent", "value": 150},
+ headers=admin_headers)
+ assert r.status_code == 400
+ assert r.json()["error"] == "INVALID_PROMO_VALUE"
+
+ # montant nul
+ r = client.post(PROMOS_URL, json={"code": "ZERO", "type": "amount", "value": 0},
+ headers=admin_headers)
+ assert r.status_code == 400
+
+ # forfait inconnu
+ r = client.post(PROMOS_URL, json={"code": "PLANX", "type": "percent", "value": 10,
+ "plans": ["platine"]}, headers=admin_headers)
+ assert r.status_code == 400
+ assert r.json()["error"] == "INVALID_PROMO_PLAN"
+
+ # expiration dans le passé
+ r = client.post(PROMOS_URL, json={"code": "HIER", "type": "percent", "value": 10,
+ "expires_at": "2020-01-01T00:00:00+00:00"},
+ headers=admin_headers)
+ assert r.status_code == 400
+ assert r.json()["error"] == "INVALID_PROMO_EXPIRY"
+
+ # plafond inférieur à 1
+ r = client.post(PROMOS_URL, json={"code": "PLAFOND", "type": "percent", "value": 10,
+ "max_redemptions": 0}, headers=admin_headers)
+ assert r.status_code == 400
+
+
+def test_create_duplicate_promo_refused(client, admin_headers, promo_file):
+ body = {"code": "DUP", "type": "percent", "value": 10}
+ assert client.post(PROMOS_URL, json=body, headers=admin_headers).status_code == 201
+ r = client.post(PROMOS_URL, json=body, headers=admin_headers)
+ assert r.status_code == 409
+ assert r.json()["error"] == "PROMO_ALREADY_EXISTS"
+
+
+def test_deactivate_promo(client, admin_headers, promo_file, monkeypatch):
+ client.post(PROMOS_URL, json={"code": "BYE", "type": "percent", "value": 5},
+ headers=admin_headers)
+ r = client.post(f"{PROMOS_URL}/BYE/deactivate", headers=admin_headers)
+ assert r.status_code == 200
+ assert r.json()["data"]["active"] is False
+
+ r2 = client.post(f"{PROMOS_URL}/INCONNU/deactivate", headers=admin_headers)
+ assert r2.status_code == 404
+
+
+def test_promos_require_admin(client):
+ assert client.get(PROMOS_URL).status_code == 401
+
+
+# ---------------------------------------------------------------------------
+# Validation au checkout — AVANT tout appel Stripe
+# ---------------------------------------------------------------------------
+@pytest.fixture
+def checkout_mocks(monkeypatch, promo_file):
+ """Stripe « configuré » mais sous surveillance : toute création de session
+ est capturée (et aurait levé si appelée à tort)."""
+ calls: dict = {"session": 0, "kwargs": None}
+
+ def _fake_session_create(**kwargs):
+ calls["session"] += 1
+ calls["kwargs"] = kwargs
+ return SimpleNamespace(id="cs_test_1", url="https://stripe.test/checkout")
+
+ monkeypatch.setattr(payment_svc, "is_stripe_configured", lambda: True)
+ monkeypatch.setattr(payment_svc.stripe.checkout.Session, "create", _fake_session_create)
+ monkeypatch.setattr(payment_svc, "stripe_price_ids_for_plan", lambda p: ("price_m", "price_y"))
+
+ user = SimpleNamespace(
+ id="u1", email="buyer@example.com", name="Buyer",
+ stripe_customer_id="cus_1", stripe_subscription_id=None,
+ subscription_status="active", plan="free", cancel_at_period_end=False,
+ docs_translated_this_month=0, pages_translated_this_month=0,
+ api_calls_this_month=0, extra_credits=0, subscription_ends_at=None,
+ )
+ monkeypatch.setattr(payment_svc, "get_user_by_id", lambda uid: user if uid == "u1" else None)
+ monkeypatch.setattr(payment_svc, "update_user", lambda uid, data: None)
+
+ # Pas de base de données dans les tests : la vérification d'antidoublon
+ # (PaymentHistory) doit échouer proprement — la fonction la tolère.
+ import database.connection as db_conn
+
+ def _no_db():
+ raise RuntimeError("pas de base de données dans les tests")
+
+ monkeypatch.setattr(db_conn, "get_sync_session", _no_db)
+ return calls
+
+
+@pytest.mark.asyncio
+async def test_checkout_with_valid_promo_applies_discount(
+ checkout_mocks, promo_file, monkeypatch
+):
+ monkeypatch.setattr(payment_svc, "stripe_mode", lambda: "test")
+ monkeypatch.setattr(
+ payment_svc.stripe.Coupon, "create", lambda **kw: SimpleNamespace(id="coupon_9")
+ )
+ monkeypatch.setattr(
+ payment_svc.stripe.PromotionCode,
+ "create",
+ lambda **kw: SimpleNamespace(id="promo_9"),
+ )
+ payment_svc.create_promo_code(
+ code="SOIF10", type="percent", value=10, plans=["pro"],
+ max_redemptions=10, expires_at=_future(),
+ )
+
+ from models.subscription import PlanType
+
+ result = await payment_svc.create_checkout_session(
+ user_id="u1", plan=PlanType.PRO, promo_code="soif10"
+ )
+ assert "error" not in result, result
+ assert result["promo_code"] == "SOIF10"
+ kwargs = checkout_mocks["kwargs"]
+ assert kwargs["discounts"] == [{"promotion_code": "promo_9"}]
+ assert kwargs["metadata"]["promo_code"] == "SOIF10"
+ assert kwargs["subscription_data"]["metadata"]["promo_code"] == "SOIF10"
+
+
+@pytest.mark.asyncio
+async def test_checkout_rejects_expired_promo_before_stripe(
+ checkout_mocks, promo_file
+):
+ payment_svc.save_promo_codes([{
+ "code": "VIEUX", "active": True, "type": "percent", "value": 10,
+ "plans": ["all"], "max_redemptions": None, "times_used": 0,
+ "expires_at": "2020-01-01T00:00:00+00:00",
+ "stripe_coupon_id": "c", "stripe_promotion_code_id": "p",
+ "stripe_mode": "test", "created_at": "t", "deactivated_at": None,
+ }])
+ from models.subscription import PlanType
+
+ result = await payment_svc.create_checkout_session(
+ user_id="u1", plan=PlanType.PRO, promo_code="VIEUX"
+ )
+ assert result.get("error_code") == "PROMO_INVALID"
+ assert result.get("promo_reason") == "expired"
+ assert checkout_mocks["session"] == 0 # aucune session Stripe créée
+
+
+@pytest.mark.asyncio
+async def test_checkout_rejects_exhausted_promo(checkout_mocks, promo_file):
+ """Un code à 10/10 utilisations est refusé avant tout appel Stripe."""
+ payment_svc.save_promo_codes([{
+ "code": "FINI", "active": True, "type": "percent", "value": 10,
+ "plans": ["all"], "max_redemptions": 10, "times_used": 10,
+ "expires_at": None, "stripe_coupon_id": "c",
+ "stripe_promotion_code_id": "p", "stripe_mode": "test",
+ "created_at": "t", "deactivated_at": None,
+ }])
+ from models.subscription import PlanType
+
+ result = await payment_svc.create_checkout_session(
+ user_id="u1", plan=PlanType.FREE, promo_code="FINI"
+ )
+ assert result.get("promo_reason") == "exhausted"
+ assert checkout_mocks["session"] == 0
+
+
+@pytest.mark.asyncio
+async def test_checkout_rejects_promo_for_other_plan(checkout_mocks, promo_file):
+ payment_svc.save_promo_codes([{
+ "code": "PROONLY", "active": True, "type": "percent", "value": 10,
+ "plans": ["pro"], "max_redemptions": None, "times_used": 0,
+ "expires_at": None, "stripe_coupon_id": "c",
+ "stripe_promotion_code_id": "p", "stripe_mode": "test",
+ "created_at": "t", "deactivated_at": None,
+ }])
+ from models.subscription import PlanType
+
+ result = await payment_svc.create_checkout_session(
+ user_id="u1", plan=PlanType.STARTER, promo_code="PROONLY"
+ )
+ assert result.get("promo_reason") == "plan"
+ assert checkout_mocks["session"] == 0
+
+
+@pytest.mark.asyncio
+async def test_checkout_rejects_unknown_and_local_only_promos(
+ checkout_mocks, promo_file
+):
+ from models.subscription import PlanType
+
+ result = await payment_svc.create_checkout_session(
+ user_id="u1", plan=PlanType.PRO, promo_code="NUL"
+ )
+ assert result.get("promo_reason") == "unknown"
+
+ payment_svc.save_promo_codes([{
+ "code": "LOCAL", "active": True, "type": "percent", "value": 10,
+ "plans": ["all"], "max_redemptions": None, "times_used": 0,
+ "expires_at": None, "stripe_coupon_id": None,
+ "stripe_promotion_code_id": None, "stripe_mode": "local_only",
+ "created_at": "t", "deactivated_at": None,
+ }])
+ result2 = await payment_svc.create_checkout_session(
+ user_id="u1", plan=PlanType.PRO, promo_code="LOCAL"
+ )
+ assert result2.get("promo_reason") == "local_only"
+ assert checkout_mocks["session"] == 0
+
+
+@pytest.mark.asyncio
+async def test_completion_increments_promo_counter_once_per_session(
+ checkout_mocks, promo_file, monkeypatch
+):
+ """Le compteur local n'augmente qu'à la complétion ; un webhook suivi d'une
+ synchronisation du MÊME paiement ne compte qu'une fois, même en mode JSON."""
+ monkeypatch.setattr(payment_svc, "stripe_mode", lambda: "test")
+ monkeypatch.setattr(
+ payment_svc.stripe.Coupon, "create", lambda **kw: SimpleNamespace(id="c")
+ )
+ monkeypatch.setattr(
+ payment_svc.stripe.PromotionCode, "create", lambda **kw: SimpleNamespace(id="p")
+ )
+ payment_svc.create_promo_code(
+ code="COMPTE", type="percent", value=10, max_redemptions=10
+ )
+ assert payment_svc.load_promo_codes()[0]["times_used"] == 0
+
+ session = {
+ "id": "cs_1",
+ "metadata": {"user_id": "u1", "plan": "pro", "promo_code": "COMPTE"},
+ "subscription": {"id": "sub_1", "current_period_end": None},
+ "customer": "cus_1",
+ "payment_intent": None,
+ "amount_total": 1900,
+ "currency": "eur",
+ }
+ # La vérification d'antidoublon touche la base : indifférent ici, la
+ # fonction tolère son échec.
+ await payment_svc.handle_checkout_completed(session)
+ await payment_svc.handle_checkout_completed(session) # re-livraison du webhook
+
+ # Synchronisation manuelle du MÊME paiement (même session Stripe) : 0 incrément.
+ await payment_svc.handle_checkout_completed(session)
+
+ promo = payment_svc.load_promo_codes()[0]
+ assert promo["times_used"] == 1
+ assert promo["counted_sessions"] == ["cs_1"]
+ assert promo["last_used_at"]
+
+ # Un AUTRE paiement (autre session) compte bien une seconde fois.
+ session2 = dict(session, id="cs_2")
+ await payment_svc.handle_checkout_completed(session2)
+ assert payment_svc.load_promo_codes()[0]["times_used"] == 2
+
+
+@pytest.mark.asyncio
+async def test_checkout_without_promo_has_no_discount(checkout_mocks, promo_file):
+ from models.subscription import PlanType
+
+ result = await payment_svc.create_checkout_session(
+ user_id="u1", plan=PlanType.PRO, promo_code=""
+ )
+ assert "error" not in result
+ assert "discounts" not in checkout_mocks["kwargs"]
+ assert "promo_code" not in checkout_mocks["kwargs"]["metadata"]
+
+
+# ---------------------------------------------------------------------------
+# Robustesse (relecture) : doublon sous verrou, Stripe en erreur, JSON corrompu
+# ---------------------------------------------------------------------------
+def test_duplicate_checked_under_lock_two_creates_one_409(promo_file):
+ """Deux créations du même code : la seconde est rejetée par la vérification
+ SOUS le verrou du service (une seule entrée persistée)."""
+ payment_svc.create_promo_code(code="RACE1", type="percent", value=10)
+ with pytest.raises(payment_svc.DuplicatePromoError):
+ payment_svc.create_promo_code(code="RACE1", type="percent", value=10)
+ assert [p["code"] for p in payment_svc.load_promo_codes()] == ["RACE1"]
+
+
+def test_stripe_failure_cleans_orphan_coupon_and_persists_nothing(
+ promo_file, monkeypatch
+):
+ """Coupon créé puis PromotionCode refusé : le coupon orphelin est supprimé,
+ l'erreur est typée et RIEN n'est écrit dans promo_codes.json."""
+ monkeypatch.setattr(payment_svc, "is_stripe_configured", lambda: True)
+ monkeypatch.setattr(
+ payment_svc.stripe.Coupon, "create", lambda **kw: SimpleNamespace(id="coupon_X")
+ )
+ deleted: list = []
+ monkeypatch.setattr(
+ payment_svc.stripe.Coupon, "delete", lambda cid: deleted.append(cid)
+ )
+
+ def _boom(**kw):
+ raise payment_svc.stripe.error.StripeError("card_error test")
+
+ monkeypatch.setattr(payment_svc.stripe.PromotionCode, "create", _boom)
+
+ with pytest.raises(payment_svc.StripePromoCreationError):
+ payment_svc.create_promo_code(code="RATEE", type="percent", value=10)
+ assert deleted == ["coupon_X"]
+ assert payment_svc.load_promo_codes() == []
+
+
+def test_unreadable_limit_or_counter_means_exhausted(promo_file):
+ """max_redemptions / times_used illisibles (JSON corrompu) : le code est
+ traité comme épuisé avec un message, jamais comme illimité."""
+ base = {
+ "code": "CORROMPU", "active": True, "type": "percent", "value": 10,
+ "plans": ["all"], "expires_at": None, "stripe_coupon_id": "c",
+ "stripe_promotion_code_id": "p", "stripe_mode": "test",
+ "created_at": "t", "deactivated_at": None,
+ }
+ payment_svc.save_promo_codes([dict(base, max_redemptions="n/a", times_used=0)])
+ with pytest.raises(payment_svc.PromoValidationError) as err:
+ payment_svc.validate_promo_code("CORROMPU", "pro")
+ assert err.value.reason == "exhausted"
+
+ payment_svc.save_promo_codes([dict(base, max_redemptions=10, times_used="beaucoup")])
+ with pytest.raises(payment_svc.PromoValidationError) as err2:
+ payment_svc.validate_promo_code("CORROMPU", "pro")
+ assert err2.value.reason == "exhausted"
+
+
+def test_save_is_atomic_no_temp_file_left(promo_file):
+ payment_svc.save_promo_codes([{
+ "code": "ATOM", "active": True, "type": "percent", "value": 5,
+ }])
+ assert payment_svc.PROMO_CODES_FILE.exists()
+ assert not payment_svc.PROMO_CODES_FILE.with_name(
+ payment_svc.PROMO_CODES_FILE.name + ".tmp"
+ ).exists()
+ # le JSON reste lisible après écriture atomique
+ assert payment_svc.load_promo_codes()[0]["code"] == "ATOM"
+
+
+def test_create_promo_for_free_plan_refused(client, admin_headers, promo_file):
+ r = client.post(
+ PROMOS_URL,
+ json={"code": "FREEBIE", "type": "percent", "value": 10, "plans": ["free"]},
+ headers=admin_headers,
+ )
+ assert r.status_code == 400
+ assert r.json()["error"] == "INVALID_PROMO_PLAN"
+ assert "Gratuit" in r.json()["message"]
+
+
+def test_delete_promo_endpoint(client, admin_headers, promo_file, monkeypatch):
+ client.post(PROMOS_URL, json={"code": "ADIEU", "type": "percent", "value": 5},
+ headers=admin_headers)
+ r = client.delete(f"{PROMOS_URL}/ADIEU", headers=admin_headers)
+ assert r.status_code == 200
+ assert payment_svc.load_promo_codes() == []
+
+ r2 = client.delete(f"{PROMOS_URL}/ADIEU", headers=admin_headers)
+ assert r2.status_code == 404
+
+
+def test_link_local_only_promo_to_stripe(client, admin_headers, promo_file, monkeypatch):
+ monkeypatch.setattr(payment_svc, "is_stripe_configured", lambda: True)
+ monkeypatch.setattr(payment_svc, "stripe_mode", lambda: "test")
+ monkeypatch.setattr(
+ payment_svc.stripe.Coupon, "create", lambda **kw: SimpleNamespace(id="coupon_L")
+ )
+ monkeypatch.setattr(
+ payment_svc.stripe.PromotionCode,
+ "create",
+ lambda **kw: SimpleNamespace(id="promo_L"),
+ )
+
+ client.post(PROMOS_URL, json={"code": "LOCAL1", "type": "amount", "value": 5},
+ headers=admin_headers)
+ r = client.post(f"{PROMOS_URL}/LOCAL1/link-stripe", headers=admin_headers)
+ assert r.status_code == 200, r.text
+ entry = r.json()["data"]
+ assert entry["stripe_coupon_id"] == "coupon_L"
+ assert entry["stripe_promotion_code_id"] == "promo_L"
+ assert entry["stripe_mode"] == "test"
+
+
+# ---------------------------------------------------------------------------
+# Point de contact public : GET /api/v1/promos/validate
+# ---------------------------------------------------------------------------
+def test_public_validate_endpoint(promo_file, client):
+ payment_svc.save_promo_codes([{
+ "code": "VALID10", "active": True, "type": "percent", "value": 10,
+ "plans": ["all"], "max_redemptions": None, "times_used": 0,
+ "expires_at": None, "stripe_coupon_id": "c",
+ "stripe_promotion_code_id": "p", "stripe_mode": "test",
+ "created_at": "t", "deactivated_at": None,
+ }])
+ r = client.get("/api/v1/promos/validate", params={"code": "valid10", "plan": "pro"})
+ assert r.status_code == 200
+ data = r.json()["data"]
+ assert data["valid"] is True
+ assert data["percent_off"] == 10
+
+ # montant en euros
+ payment_svc.save_promo_codes(payment_svc.load_promo_codes() + [{
+ "code": "EUROS5", "active": True, "type": "amount", "value": 5,
+ "plans": ["pro"], "max_redemptions": None, "times_used": 0,
+ "expires_at": None, "stripe_coupon_id": "c",
+ "stripe_promotion_code_id": "p", "stripe_mode": "test",
+ "created_at": "t", "deactivated_at": None,
+ }])
+ r2 = client.get("/api/v1/promos/validate", params={"code": "EUROS5", "plan": "pro"})
+ assert r2.json()["data"]["amount_off"] == 5
+
+ # causes explicites
+ r3 = client.get("/api/v1/promos/validate", params={"code": "INCONNU", "plan": "pro"})
+ assert r3.json()["data"]["valid"] is False
+ assert r3.json()["data"]["reason_code"] == "unknown"
+
+ payment_svc.save_promo_codes([dict(
+ code="EXPIRE1", active=True, type="percent", value=10,
+ plans=["all"], max_redemptions=None, times_used=0,
+ expires_at="2020-01-01T00:00:00+00:00", stripe_coupon_id="c",
+ stripe_promotion_code_id="p", stripe_mode="test",
+ created_at="t", deactivated_at=None,
+ )])
+ r4 = client.get("/api/v1/promos/validate", params={"code": "EXPIRE1", "plan": "pro"})
+ assert r4.json()["data"]["reason_code"] == "expired"
diff --git a/tests/test_ai_tier_routes.py b/tests/test_ai_tier_routes.py
new file mode 100644
index 0000000..a00eed9
--- /dev/null
+++ b/tests/test_ai_tier_routes.py
@@ -0,0 +1,490 @@
+"""
+Routage multi-canaux des paliers IA (spec « Fournisseur par palier IA »).
+
+- Chaque route d'un palier est un couple {provider, model} : une route peut
+ partir par z.ai direct (zhipu), DeepSeek direct, etc. ;
+- une route dont la clé API manque est sautée au profit de la suivante ;
+- l'ancien réglage à chaînes reste routé via openrouter (compatibilité) ;
+- la facturation suit le PALIER (Premium = 5, Essentielle = 1), pas le canal.
+"""
+
+import asyncio
+import pytest
+from pathlib import Path
+
+import routes.translate_routes as tr
+
+
+# ---------------------------------------------------------------------------
+# Résolution unitaire : (provider, model)
+# ---------------------------------------------------------------------------
+def _settings(ai_tiers: dict | None = None, **providers):
+ from routes.admin_routes import SettingsConfig
+
+ payload: dict = {"ai_tiers": ai_tiers} if ai_tiers is not None else {}
+ for provider, cfg in providers.items():
+ payload[provider] = cfg
+ return SettingsConfig.model_validate(payload)
+
+
+def test_route_with_configured_key_is_used(monkeypatch):
+ monkeypatch.delenv("ZHIPU_API_KEY", raising=False)
+ cfg = _settings(
+ {
+ "essential": {
+ "models": [],
+ "default_model": "",
+ "routes": [{"provider": "zhipu", "model": "glm-5.3-flash"}],
+ },
+ "premium": {"models": [], "default_model": ""},
+ },
+ zhipu={"api_key": "zk-test"},
+ )
+ assert tr._resolve_tier_model(cfg, "pro", premium=False) == (
+ "zhipu",
+ "glm-5.3-flash",
+ )
+
+
+def test_route_without_key_falls_back_to_next(monkeypatch):
+ monkeypatch.delenv("ZHIPU_API_KEY", raising=False)
+ monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
+ cfg = _settings(
+ {
+ "essential": {
+ "models": [],
+ "default_model": "",
+ "routes": [
+ {"provider": "zhipu", "model": "glm-5.3-flash"},
+ {"provider": "openrouter", "model": "deepseek/deepseek-v4-flash"},
+ ],
+ },
+ "premium": {"models": [], "default_model": ""},
+ },
+ openrouter={"api_key": "sk-or-test"},
+ )
+ assert tr._resolve_tier_model(cfg, "pro", premium=False) == (
+ "openrouter",
+ "deepseek/deepseek-v4-flash",
+ )
+
+
+def test_legacy_string_models_still_route_via_openrouter(monkeypatch):
+ monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
+ cfg = _settings(
+ {
+ "essential": {"models": ["deepseek/deepseek-v4-flash"], "default_model": ""},
+ "premium": {"models": [], "default_model": ""},
+ },
+ openrouter={"api_key": "sk-or-test"},
+ )
+ provider, model = tr._resolve_tier_model(cfg, "pro", premium=False)
+ assert (provider, model) == ("openrouter", "deepseek/deepseek-v4-flash")
+
+
+def test_premium_route_resolves_premium_tier(monkeypatch):
+ monkeypatch.delenv("ZHIPU_API_KEY", raising=False)
+ cfg = _settings(
+ {
+ "essential": {"models": [], "default_model": ""},
+ "premium": {
+ "models": [],
+ "default_model": "",
+ "routes": [{"provider": "deepseek", "model": "deepseek-reasoner"}],
+ },
+ },
+ deepseek={"api_key": "dk-test"},
+ )
+ assert tr._resolve_tier_model(cfg, "business", premium=True) == (
+ "deepseek",
+ "deepseek-reasoner",
+ )
+
+
+# ---------------------------------------------------------------------------
+# Facturation par palier (pas par canal)
+# ---------------------------------------------------------------------------
+def test_cost_factor_follows_tier_not_channel():
+ assert tr._compute_cost_factor(None, "zhipu", premium=True) == 5
+ assert tr._compute_cost_factor(None, "deepseek", premium=True) == 5
+ assert tr._compute_cost_factor(None, "zhipu", premium=False) == 1
+ assert tr._compute_cost_factor(None, "openrouter_premium", premium=False) == 1
+
+
+def test_cost_factor_legacy_heuristic_kept_without_tier():
+ class _P:
+ _model = "anthropic/claude-sonnet-5"
+
+ assert tr._compute_cost_factor(_P(), "zai") == 5
+ assert tr._compute_cost_factor(None, "openrouter_premium") == 5
+ assert tr._compute_cost_factor(None, "openrouter") == 1
+
+
+# ---------------------------------------------------------------------------
+# Validation admin des routes (normalize_ai_tiers)
+# ---------------------------------------------------------------------------
+def test_normalize_rejects_unknown_route_provider():
+ from routes.admin_routes import normalize_ai_tiers
+
+ cfg = _settings(
+ {
+ "essential": {
+ "models": [],
+ "default_model": "",
+ "routes": [{"provider": "anthropic", "model": "claude"}],
+ },
+ "premium": {"models": [], "default_model": ""},
+ }
+ )
+ with pytest.raises(ValueError, match="inconnu"):
+ normalize_ai_tiers(cfg.ai_tiers)
+
+
+def test_normalize_rejects_route_without_model():
+ from routes.admin_routes import normalize_ai_tiers
+
+ cfg = _settings(
+ {
+ "essential": {
+ "models": [],
+ "default_model": "",
+ "routes": [{"provider": "zhipu", "model": " "}],
+ },
+ "premium": {"models": [], "default_model": ""},
+ }
+ )
+ with pytest.raises(ValueError, match="modèle"):
+ normalize_ai_tiers(cfg.ai_tiers)
+
+
+def test_normalize_deduplicates_routes_and_keeps_order():
+ from routes.admin_routes import normalize_ai_tiers
+
+ cfg = _settings(
+ {
+ "essential": {
+ "models": [],
+ "default_model": "",
+ "routes": [
+ {"provider": "zhipu", "model": "glm-5.3-flash"},
+ {"provider": "zhipu", "model": "glm-5.3-flash", "label": "doublon"},
+ {"provider": "openrouter", "model": "deepseek/deepseek-v4-flash"},
+ ],
+ },
+ "premium": {"models": [], "default_model": ""},
+ }
+ )
+ normalized = normalize_ai_tiers(cfg.ai_tiers)
+ assert [(r.provider, r.model) for r in normalized.essential.routes] == [
+ ("zhipu", "glm-5.3-flash"),
+ ("openrouter", "deepseek/deepseek-v4-flash"),
+ ]
+
+
+def test_normalize_route_cross_tier_guard():
+ """Une route openrouter pointant un modèle Premium dans le palier
+ Essentielle est refusée (anti-croisement inchangé)."""
+ from routes.admin_routes import normalize_ai_tiers
+
+ cfg = _settings(
+ {
+ "essential": {
+ "models": [],
+ "default_model": "",
+ "routes": [{"provider": "openrouter", "model": "anthropic/claude-sonnet-5"}],
+ },
+ "premium": {"models": [], "default_model": ""},
+ }
+ )
+ with pytest.raises(ValueError, match="Premium"):
+ normalize_ai_tiers(cfg.ai_tiers)
+
+
+def test_normalize_empty_routes_and_models_fills_official_range():
+ from routes.admin_routes import normalize_ai_tiers
+ from models.subscription import DEFAULT_AI_MODELS_ESSENTIAL
+
+ cfg = _settings(
+ {
+ "essential": {"models": [], "default_model": ""},
+ "premium": {"models": [], "default_model": ""},
+ }
+ )
+ normalized = normalize_ai_tiers(cfg.ai_tiers)
+ assert normalized.essential.models == list(DEFAULT_AI_MODELS_ESSENTIAL)
+ assert normalized.essential.routes == []
+
+
+# ---------------------------------------------------------------------------
+# Intégration worker : le canal choisi instancie le bon fournisseur
+# ---------------------------------------------------------------------------
+@pytest.fixture
+def minimal_xlsx(tmp_path):
+ try:
+ import openpyxl
+
+ wb = openpyxl.Workbook()
+ wb.active["A1"] = "Hello"
+ p = tmp_path / "minimal.xlsx"
+ wb.save(p)
+ return p
+ except ImportError:
+ pytest.skip("openpyxl required")
+
+
+def _register_job(job_id: str) -> None:
+ tr._translation_jobs[job_id] = {
+ "id": job_id,
+ "status": "processing",
+ "progress_percent": 0,
+ "current_step": "",
+ "provider": "openrouter",
+ "created_at": "2026-09-05T00:00:00+00:00",
+ }
+
+
+def _run_job(monkeypatch, tmp_path, minimal_xlsx, admin_settings, provider: str,
+ user_plan: str, user_id=None, cost_capture: dict | None = None) -> list:
+ """Lance _run_translation_job avec un faux fournisseur et renvoie les
+ instanciations capturées (model, base_url, api_key).
+
+ ``user_id`` + ``cost_capture`` : enregistre le facteur de facturation
+ transmis à ``record_usage`` (substitué — aucun accès à la base)."""
+ import routes.admin_routes as admin_routes_mod
+ import services.providers.openai_provider as openai_provider_mod
+ import services.providers.deepseek_provider as deepseek_provider_mod
+
+ captured: list = []
+
+ class FakeProvider:
+ def __init__(self, api_key=None, model=None, base_url=None, timeout=60, **kwargs):
+ self.model = model
+ self._model = model
+ self.base_url = base_url
+ captured.append({"model": model, "base_url": base_url, "api_key": api_key})
+
+ class FakeExcelTranslator:
+ def __init__(self, provider=None):
+ self.provider = provider
+
+ def translate_file(self, input_path, output_path, target_lang,
+ source_lang="auto", progress_callback=None,
+ translate_images=False, **kwargs):
+ out = Path(output_path)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_bytes(minimal_xlsx.read_bytes())
+
+ def get_translation_stats(self):
+ return {"attempted": 1, "changed": 1}
+
+ def _fake_record_usage(uid, pages, cost_factor, reserved_docs=1):
+ if cost_capture is not None:
+ cost_capture["cost_factor"] = cost_factor
+
+ monkeypatch.setattr(openai_provider_mod, "OpenAITranslationProvider", FakeProvider)
+ monkeypatch.setattr(deepseek_provider_mod, "DeepSeekTranslationProvider", FakeProvider)
+ monkeypatch.setattr(tr, "ExcelTranslator", FakeExcelTranslator)
+ monkeypatch.setattr(tr, "record_usage", _fake_record_usage)
+ monkeypatch.setattr(
+ admin_routes_mod, "load_settings", lambda: admin_settings, raising=True
+ )
+
+ _register_job("job-routes")
+ try:
+ asyncio.run(
+ tr._run_translation_job(
+ job_id="job-routes",
+ input_path=Path(minimal_xlsx),
+ file_extension=".xlsx",
+ target_lang="fr",
+ source_lang="en",
+ provider=provider,
+ user_id=user_id,
+ custom_prompt=None,
+ glossary_id=None,
+ prompt_id=None,
+ webhook_url=None,
+ user_plan=user_plan,
+ )
+ )
+ finally:
+ tr._translation_jobs.pop("job-routes", None)
+ return captured
+
+
+def test_worker_routes_zhipu_channel(monkeypatch, tmp_path, minimal_xlsx):
+ monkeypatch.delenv("ZHIPU_API_KEY", raising=False)
+ settings = _settings(
+ {
+ "essential": {
+ "models": [],
+ "default_model": "",
+ "routes": [{"provider": "zhipu", "model": "glm-5.3-flash"}],
+ },
+ "premium": {"models": [], "default_model": ""},
+ },
+ zhipu={"api_key": "zk-test"},
+ )
+ captured = _run_job(monkeypatch, tmp_path, minimal_xlsx, settings,
+ provider="openrouter", user_plan="PlanType.PRO")
+ assert captured, "le fournisseur n'a pas été instancié"
+ assert captured[0]["model"] == "glm-5.3-flash"
+ assert captured[0]["base_url"] == "https://api.z.ai/api/paas/v4"
+
+
+def test_worker_skips_route_without_key(monkeypatch, tmp_path, minimal_xlsx):
+ """Clé zhipu absente → route suivante (openrouter) comme le veut la spec."""
+ monkeypatch.delenv("ZHIPU_API_KEY", raising=False)
+ settings = _settings(
+ {
+ "essential": {
+ "models": [],
+ "default_model": "",
+ "routes": [
+ {"provider": "zhipu", "model": "glm-5.3-flash"},
+ {"provider": "openrouter", "model": "deepseek/deepseek-v4-flash"},
+ ],
+ },
+ "premium": {"models": [], "default_model": ""},
+ },
+ openrouter={"api_key": "sk-or-test"},
+ )
+ captured = _run_job(monkeypatch, tmp_path, minimal_xlsx, settings,
+ provider="openrouter", user_plan="PlanType.PRO")
+ assert captured[0]["base_url"] == "https://openrouter.ai/api/v1"
+ assert captured[0]["model"] == "deepseek/deepseek-v4-flash"
+
+
+def test_worker_legacy_settings_keep_openrouter(monkeypatch, tmp_path, minimal_xlsx):
+ """Ancien réglage (chaînes) : routé via openrouter comme aujourd'hui."""
+ monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
+ settings = _settings(
+ {
+ "essential": {"models": ["deepseek/deepseek-v4-flash"], "default_model": ""},
+ "premium": {"models": [], "default_model": ""},
+ },
+ openrouter={"api_key": "sk-or-test"},
+ )
+ captured = _run_job(monkeypatch, tmp_path, minimal_xlsx, settings,
+ provider="openrouter", user_plan="PlanType.PRO")
+ assert captured[0]["base_url"] == "https://openrouter.ai/api/v1"
+ assert captured[0]["model"] == "deepseek/deepseek-v4-flash"
+
+
+def test_worker_deepseek_route_uses_deepseek_provider(monkeypatch, tmp_path, minimal_xlsx):
+ monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
+ settings = _settings(
+ {
+ "essential": {
+ "models": [],
+ "default_model": "",
+ "routes": [{"provider": "deepseek", "model": "deepseek-chat"}],
+ },
+ "premium": {"models": [], "default_model": ""},
+ },
+ deepseek={"api_key": "dk-test"},
+ )
+ captured = _run_job(monkeypatch, tmp_path, minimal_xlsx, settings,
+ provider="openrouter", user_plan="PlanType.PRO")
+ assert captured[0]["base_url"] == "https://api.deepseek.com/v1"
+ assert captured[0]["api_key"] == "dk-test"
+
+
+# ---------------------------------------------------------------------------
+# Facturation exécutée dans le worker : palier Essentielle = 1, Premium = 5
+# ---------------------------------------------------------------------------
+def test_worker_bills_essential_tier_at_cost_1(monkeypatch, tmp_path, minimal_xlsx):
+ monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
+ settings = _settings(
+ {
+ "essential": {"models": ["deepseek/deepseek-v4-flash"], "default_model": ""},
+ "premium": {"models": [], "default_model": ""},
+ },
+ openrouter={"api_key": "sk-or-test"},
+ )
+ cost: dict = {}
+ _run_job(monkeypatch, tmp_path, minimal_xlsx, settings,
+ provider="openrouter", user_plan="PlanType.PRO",
+ user_id="u1", cost_capture=cost)
+ assert cost["cost_factor"] == 1
+
+
+def test_worker_bills_premium_tier_at_cost_5(monkeypatch, tmp_path, minimal_xlsx):
+ monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
+ settings = _settings(
+ {
+ "essential": {"models": [], "default_model": ""},
+ "premium": {"models": ["anthropic/claude-sonnet-5"], "default_model": ""},
+ },
+ openrouter={"api_key": "sk-or-test"},
+ )
+ cost: dict = {}
+ _run_job(monkeypatch, tmp_path, minimal_xlsx, settings,
+ provider="openrouter_premium", user_plan="PlanType.BUSINESS",
+ user_id="u1", cost_capture=cost)
+ assert cost["cost_factor"] == 5
+
+
+# ---------------------------------------------------------------------------
+# Première sauvegarde depuis l'éditeur : migration fidèle hérité → routes
+# ---------------------------------------------------------------------------
+def test_first_editor_save_migrates_legacy_settings_without_loss(monkeypatch):
+ """Des réglages à chaînes (models/default_model) convertis en routes par
+ l'éditeur, puis normalisés, doivent résoudre EXACTEMENT les mêmes
+ canaux/modèles qu'avant la migration."""
+ from routes.admin_routes import SettingsConfig, normalize_ai_tiers
+
+ legacy = _settings(
+ {
+ "essential": {
+ "models": ["z-ai/glm-5.3-flash", "deepseek/deepseek-v4-flash"],
+ "default_model": "z-ai/glm-5.3-flash",
+ },
+ "premium": {
+ "models": ["anthropic/claude-sonnet-5", "deepseek/deepseek-v4-pro"],
+ "default_model": "anthropic/claude-sonnet-5",
+ },
+ },
+ openrouter={"api_key": "sk-or-test"},
+ )
+ before_essential = tr._resolve_tier_model(legacy, "pro", premium=False)
+ before_premium = tr._resolve_tier_model(legacy, "business", premium=True)
+
+ def _editor_payload(tier):
+ """Miroir de la conversion faite par la page Modèles : le défaut en
+ tête, puis le reste de la liste — le tout en routes openrouter, avec
+ le miroir hérité conservé (models/default_model)."""
+ ordered = [tier.default_model] + [
+ m for m in tier.models if m != tier.default_model
+ ]
+ routes = [{"provider": "openrouter", "model": m} for m in ordered]
+ return {"models": ordered, "default_model": tier.default_model, "routes": routes}
+
+ payload = SettingsConfig.model_validate(
+ {
+ "openrouter": {"api_key": "sk-or-test"},
+ "ai_tiers": {
+ "essential": _editor_payload(legacy.ai_tiers.essential),
+ "premium": _editor_payload(legacy.ai_tiers.premium),
+ },
+ }
+ )
+ normalized = normalize_ai_tiers(payload.ai_tiers)
+ migrated = SettingsConfig.model_validate(
+ {"openrouter": {"api_key": "sk-or-test"}, "ai_tiers": normalized.model_dump()}
+ )
+
+ after_essential = tr._resolve_tier_model(migrated, "pro", premium=False)
+ after_premium = tr._resolve_tier_model(migrated, "business", premium=True)
+
+ assert after_essential == before_essential == ("openrouter", "z-ai/glm-5.3-flash")
+ assert after_premium == before_premium == ("openrouter", "anthropic/claude-sonnet-5")
+ # Aucune perte : tous les modèles d'origine restent résolubles dans l'ordre.
+ assert [r.model for r in normalized.essential.routes] == [
+ "z-ai/glm-5.3-flash",
+ "deepseek/deepseek-v4-flash",
+ ]
+ assert [r.model for r in normalized.premium.routes] == [
+ "anthropic/claude-sonnet-5",
+ "deepseek/deepseek-v4-pro",
+ ]
diff --git a/tests/test_ai_tier_routing.py b/tests/test_ai_tier_routing.py
index 2378c2f..9c3dc8e 100644
--- a/tests/test_ai_tier_routing.py
+++ b/tests/test_ai_tier_routing.py
@@ -52,9 +52,12 @@ def client(monkeypatch, tmp_path: Path):
def _settings(ai_tiers: dict | None = None):
from routes.admin_routes import SettingsConfig
- if ai_tiers is None:
- return SettingsConfig()
- return SettingsConfig.model_validate({"ai_tiers": ai_tiers})
+ # La résolution est clé-consciente : les tests de résolution partent avec
+ # une clé OpenRouter disponible (comme un serveur configuré).
+ payload = {"openrouter": {"api_key": "sk-or-test"}}
+ if ai_tiers is not None:
+ payload["ai_tiers"] = ai_tiers
+ return SettingsConfig.model_validate(payload)
# ---------------------------------------------------------------------------
@@ -83,17 +86,17 @@ def test_old_single_model_keys_removed():
# ---------------------------------------------------------------------------
-# Résolution par palier
+# Résolution par palier — renvoie la route complète (provider, model)
# ---------------------------------------------------------------------------
def test_pro_resolves_essential_plan_default():
assert _resolve_tier_model(_settings(), "pro", premium=False) == (
- "deepseek/deepseek-v4-flash"
+ ("openrouter", "deepseek/deepseek-v4-flash")
)
def test_business_resolves_premium_plan_default():
assert _resolve_tier_model(_settings(), "business", premium=True) == (
- "anthropic/claude-sonnet-5"
+ ("openrouter", "anthropic/claude-sonnet-5")
)
@@ -101,7 +104,9 @@ def test_admin_tier_default_overrides_plan_default():
cfg = _settings(
{"essential": {"models": [], "default_model": "z-ai/glm-5.3-flash"}}
)
- assert _resolve_tier_model(cfg, "pro", premium=False) == "z-ai/glm-5.3-flash"
+ assert _resolve_tier_model(cfg, "pro", premium=False) == (
+ "openrouter", "z-ai/glm-5.3-flash"
+ )
def test_admin_tier_models_first_used_when_default_empty():
@@ -113,7 +118,9 @@ def test_admin_tier_models_first_used_when_default_empty():
}
}
)
- assert _resolve_tier_model(cfg, "pro", premium=False) == "minimax/minimax-m3"
+ assert _resolve_tier_model(cfg, "pro", premium=False) == (
+ "openrouter", "minimax/minimax-m3"
+ )
def test_pro_never_resolves_premium_model():
@@ -124,7 +131,8 @@ def test_pro_never_resolves_premium_model():
}
)
for plan_str in ("pro", "PlanType.PRO"):
- model = _resolve_tier_model(cfg, plan_str, premium=False)
+ provider, model = _resolve_tier_model(cfg, plan_str, premium=False)
+ assert provider == "openrouter"
assert model in DEFAULT_AI_MODELS_ESSENTIAL
assert model not in DEFAULT_AI_MODELS_PREMIUM
@@ -155,3 +163,68 @@ def test_get_plans_exposes_model_lists(client):
]
assert plans["business"]["ai_models_premium"][0] == "anthropic/claude-sonnet-5"
assert "ai_model_essential" not in plans["pro"]
+
+
+# ---------------------------------------------------------------------------
+# Point de contact : GET /providers/available affiche le modèle (chaîne simple)
+# ---------------------------------------------------------------------------
+def test_available_providers_model_is_a_string(monkeypatch, tmp_path):
+ """``_resolve_tier_model`` renvoie désormais (provider, model) : le champ
+ « model » de /providers/available doit rester une chaîne, égale au modèle
+ attendu pour les deux paliers (appelé ici avec un utilisateur Business,
+ car la liste est filtrée par forfait)."""
+ import routes.legacy_routes as legacy_mod
+ import routes.admin_routes as admin_routes_mod
+
+ settings = _settings(
+ {
+ "essential": {"models": [], "default_model": "z-ai/glm-5.3-flash"},
+ "premium": {"models": [], "default_model": "anthropic/claude-sonnet-5"},
+ }
+ )
+ # Les deux passerelles sont affichées dès lors qu'elles sont actives côté
+ # réglages (indépendamment des variables d'environnement de la machine).
+ settings.openrouter.enabled = True
+ settings.openrouter_premium.enabled = True
+ monkeypatch.setattr(admin_routes_mod, "load_settings", lambda: settings, raising=True)
+
+ from fastapi.testclient import TestClient
+ from middleware.rate_limiting import RateLimitManager
+ from models.subscription import PlanType
+
+ async def _check_request_allow(self, request):
+ return True, "ok", "test"
+
+ async def _check_translation_allow(self, request, file_size_mb=0):
+ return True, "ok"
+
+ monkeypatch.setattr(RateLimitManager, "check_request", _check_request_allow)
+ monkeypatch.setattr(RateLimitManager, "check_translation", _check_translation_allow)
+
+ import services.auth_service as auth_svc
+
+ monkeypatch.setattr(auth_svc, "USERS_FILE", tmp_path / "users.json")
+ monkeypatch.setattr(auth_svc, "USE_DATABASE", False)
+ monkeypatch.setattr(auth_svc, "DATABASE_AVAILABLE", False)
+
+ # Utilisateur Business factice (les moteurs Premium sont filtrés par plan).
+ business_user = type("U", (), {"plan": PlanType.BUSINESS, "id": "u-biz"})()
+
+ async def _fake_user():
+ return business_user
+
+ from main import app
+
+ app.dependency_overrides[legacy_mod.get_authenticated_user] = _fake_user
+ try:
+ with TestClient(app, raise_server_exceptions=True) as tc:
+ r = tc.get("/api/v1/providers/available")
+ finally:
+ app.dependency_overrides.pop(legacy_mod.get_authenticated_user, None)
+
+ assert r.status_code == 200, r.text
+ engines = {e["id"]: e for e in r.json()["providers"]}
+ assert isinstance(engines["openrouter"]["model"], str)
+ assert engines["openrouter"]["model"] == "z-ai/glm-5.3-flash"
+ assert isinstance(engines["openrouter_premium"]["model"], str)
+ assert engines["openrouter_premium"]["model"] == "anthropic/claude-sonnet-5"
diff --git a/tests/test_marketing_email_generation.py b/tests/test_marketing_email_generation.py
new file mode 100644
index 0000000..0d4f533
--- /dev/null
+++ b/tests/test_marketing_email_generation.py
@@ -0,0 +1,460 @@
+"""
+Génération d'email par IA dans la page Marketing (spec « Fournisseur par
+palier IA ») : prompt construit depuis MARKETING_PLAN.md, analyse robuste de
+la sortie, nettoyage basique du HTML, aucun envoi automatique.
+"""
+
+import json
+import pytest
+from pathlib import Path
+
+import routes.admin_routes as admin_routes_mod
+
+ADMIN_LOGIN_URL = "/api/v1/admin/login"
+GENERATE_URL = "/api/v1/admin/marketing/email/generate"
+
+FAKE_PLAN = (
+ "# Plan Marketing — Office Translator (Wordly.art)\n\n"
+ "## 1. Positionnement & Proposition de Valeur\n"
+ "Office Translator (marque Wordly.art) traduit Word, Excel, PowerPoint et "
+ "PDF en préservant la mise en page. USP : « Traduit en place. Zéro perte "
+ "de mise en page. »\n\n"
+ "## 2. Tarification\n"
+ "| Plan | Mensuel |\n| Free | 0 € |\n| Starter | 9 € |\n| Pro | 19 € |\n\n"
+ "## 3. Actifs Marketing en Place\n"
+ "(cette section ne doit PAS figurer dans le prompt)\n"
+)
+
+
+@pytest.fixture
+def promo_file(tmp_path: Path, monkeypatch) -> Path:
+ import services.payment_service as payment_svc
+
+ path = tmp_path / "promo_codes.json"
+ monkeypatch.setattr(payment_svc, "PROMO_CODES_FILE", path)
+ return path
+
+
+@pytest.fixture
+def gen_env(tmp_path: Path, monkeypatch, promo_file):
+ """Plan marketing isolé + aucune clé par défaut (chaque test choisit)."""
+ plan_file = tmp_path / "MARKETING_PLAN.md"
+ plan_file.write_text(FAKE_PLAN, encoding="utf-8")
+ monkeypatch.setattr(admin_routes_mod, "MARKETING_PLAN_FILE", plan_file)
+ monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
+ monkeypatch.delenv("ZHIPU_API_KEY", raising=False)
+ monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
+ return {"plan_file": plan_file}
+
+
+@pytest.fixture
+def client(gen_env, monkeypatch, tmp_path: Path):
+ import services.auth_service as auth_svc
+ from middleware.rate_limiting import RateLimitManager
+
+ monkeypatch.setattr(auth_svc, "USERS_FILE", tmp_path / "users.json")
+ monkeypatch.setattr(auth_svc, "USE_DATABASE", False)
+ monkeypatch.setattr(auth_svc, "DATABASE_AVAILABLE", False)
+
+ async def _check_request_allow(self, request):
+ return True, "ok", "test"
+
+ async def _check_translation_allow(self, request, file_size_mb=0):
+ return True, "ok"
+
+ monkeypatch.setattr(RateLimitManager, "check_request", _check_request_allow)
+ monkeypatch.setattr(RateLimitManager, "check_translation", _check_translation_allow)
+
+ from fastapi.testclient import TestClient
+ from main import app
+
+ return TestClient(app, raise_server_exceptions=True)
+
+
+@pytest.fixture
+def admin_headers(client, monkeypatch):
+ monkeypatch.setattr(admin_routes_mod, "ADMIN_USERNAME", "admin")
+ monkeypatch.setattr(admin_routes_mod, "ADMIN_PASSWORD", "admin-secret")
+ monkeypatch.setattr(admin_routes_mod, "ADMIN_PASSWORD_HASH", None)
+ r = client.post(ADMIN_LOGIN_URL, json={"password": "admin-secret"})
+ assert r.status_code == 200, r.text
+ return {"Authorization": f"Bearer {r.json()['access_token']}"}
+
+
+def _with_openrouter_key(monkeypatch):
+ from routes.admin_routes import SettingsConfig
+
+ monkeypatch.setattr(
+ admin_routes_mod, "load_settings",
+ lambda: SettingsConfig.model_validate({"openrouter": {"api_key": "sk-or-test"}}),
+ raising=True,
+ )
+
+
+# ---------------------------------------------------------------------------
+def test_generate_requires_known_channel(client, admin_headers, gen_env):
+ r = client.post(
+ GENERATE_URL,
+ json={"provider": "mystery", "brief": "Relance d'activation."},
+ headers=admin_headers,
+ )
+ assert r.status_code == 400
+ assert r.json()["error"] == "UNKNOWN_CHANNEL"
+
+
+def test_generate_requires_configured_key(client, admin_headers, gen_env, monkeypatch):
+ monkeypatch.setattr(
+ admin_routes_mod, "load_settings",
+ lambda: admin_routes_mod.SettingsConfig(), raising=True,
+ )
+ r = client.post(
+ GENERATE_URL,
+ json={"provider": "openrouter", "brief": "Relance d'activation."},
+ headers=admin_headers,
+ )
+ assert r.status_code == 400
+ assert r.json()["error"] == "PROVIDER_NOT_CONFIGURED"
+
+
+def test_generate_builds_prompt_from_marketing_plan(client, admin_headers, gen_env, monkeypatch):
+ """Le prompt système embarque le plan marketing (positionnement + tarifs)
+ et le brief ; les sections suivantes du plan n'y figurent pas."""
+ _with_openrouter_key(monkeypatch)
+ captured: dict = {}
+
+ async def _fake_llm(admin_cfg, provider, model, system_prompt, user_prompt):
+ captured["system"] = system_prompt
+ captured["user"] = user_prompt
+ captured["provider"], captured["model"] = provider, model
+ return json.dumps({
+ "subject": "Votre document vous attend",
+ "html": "Terminez votre traduction.
",
+ })
+
+ monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm)
+ r = client.post(
+ GENERATE_URL,
+ json={"provider": "openrouter", "brief": "Relance des inscrits de la liste d'attente."},
+ headers=admin_headers,
+ )
+ assert r.status_code == 200, r.text
+ assert "Wordly.art" in captured["system"]
+ assert "Traduit en place" in captured["system"]
+ assert "19 €" in captured["system"]
+ assert "Actifs Marketing" not in captured["system"]
+ assert "liste d'attente" in captured["user"]
+ assert captured["model"] # modèle par défaut du canal
+
+
+def test_generate_parses_fenced_json(client, admin_headers, gen_env, monkeypatch):
+ _with_openrouter_key(monkeypatch)
+
+ async def _fake_llm(admin_cfg, provider, model, system_prompt, user_prompt):
+ return (
+ "```json\n"
+ '{"subject": "Offre de lancement", "html": "Bonjour !
"}\n'
+ "```"
+ )
+
+ monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm)
+ r = client.post(
+ GENERATE_URL,
+ json={"provider": "openrouter", "brief": "b"},
+ headers=admin_headers,
+ )
+ assert r.status_code == 200
+ data = r.json()["data"]
+ assert data["subject"] == "Offre de lancement"
+ assert data["html"] == "Bonjour !
"
+
+
+def test_generate_falls_back_to_title(client, admin_headers, gen_env, monkeypatch):
+ """Sortie non JSON : repli = sujet, corps = HTML."""
+ _with_openrouter_key(monkeypatch)
+
+ async def _fake_llm(admin_cfg, provider, model, system_prompt, user_prompt):
+ return (
+ "Relance "
+ " Reprenez votre traduction.
"
+ )
+
+ monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm)
+ r = client.post(
+ GENERATE_URL,
+ json={"provider": "openrouter", "brief": "b"},
+ headers=admin_headers,
+ )
+ assert r.status_code == 200
+ data = r.json()["data"]
+ assert data["subject"] == "Relance"
+ assert "Reprenez votre traduction." in data["html"]
+
+
+def test_generate_unparseable_output_returns_502(client, admin_headers, gen_env, monkeypatch):
+ _with_openrouter_key(monkeypatch)
+
+ async def _fake_llm(admin_cfg, provider, model, system_prompt, user_prompt):
+ return "Je ne peux pas répondre à cela."
+
+ monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm)
+ r = client.post(
+ GENERATE_URL,
+ json={"provider": "openrouter", "brief": "b"},
+ headers=admin_headers,
+ )
+ assert r.status_code == 502
+ assert r.json()["error"] == "AI_GENERATION_FAILED"
+
+
+def test_generate_llm_error_returns_502(client, admin_headers, gen_env, monkeypatch):
+ _with_openrouter_key(monkeypatch)
+
+ async def _fake_llm(admin_cfg, provider, model, system_prompt, user_prompt):
+ raise RuntimeError("upstream timeout")
+
+ monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm)
+ r = client.post(
+ GENERATE_URL,
+ json={"provider": "openrouter", "brief": "b"},
+ headers=admin_headers,
+ )
+ assert r.status_code == 502
+ assert r.json()["error"] == "AI_GENERATION_FAILED"
+
+
+def test_generate_sanitizes_html(client, admin_headers, gen_env, monkeypatch):
+ """Nettoyage basique : pas de "
+ "x ",
+ })
+
+ monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm)
+ r = client.post(
+ GENERATE_URL,
+ json={"provider": "openrouter", "brief": "b"},
+ headers=admin_headers,
+ )
+ assert r.status_code == 200
+ html = r.json()["data"]["html"]
+ assert "