diff --git a/.gitignore b/.gitignore index a340ff8..409c402 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,8 @@ data/users.json data/*.db data/*.sqlite data/waitlist.json +data/marketing_emails.json +data/unsubscribes.json # Keep these in git (templates/defaults only) # data/provider_settings.json → commiter uniquement si pas de clés dedans diff --git a/MARKETING_PLAN.md b/MARKETING_PLAN.md index 9ace099..7ca6e7f 100644 --- a/MARKETING_PLAN.md +++ b/MARKETING_PLAN.md @@ -58,8 +58,8 @@ Contrairement à Google Translate (qui aplatit le fichier en texte brut) ou à D |---|---|---|---|---|---|---|---|---| | **Free** | 0 € | — | 2 | 10 | 5 Mo | Google | Non (documents filigranés) | Non | | **Starter** | 9 € | 7,20 €/mois | 50 | 50 | 10 Mo | Google | Non | Non | -| **Pro** ★ populaire | 19 € | 15,20 €/mois | 200 | 200 | 25 Mo | + Google Cloud, OpenRouter | Essentielle (DeepSeek) | Non | -| **Business** | 49 € | 39,20 €/mois | 1 000 | 500 | 50 Mo | + OpenAI, Grok (xAI), premium | Premium (Claude/Gemini) | Oui, 10 000 appels/mois, 5 sièges | +| **Pro** ★ populaire | 19 € | 15,20 €/mois | 200 | 200 | 25 Mo | + Google Cloud, OpenRouter | Essentielle : DeepSeek V4 Flash, GLM-5.3 Flash, MiniMax M3 | Non | +| **Business** | 49 € | 39,20 €/mois | 1 000 | 500 | 50 Mo | + OpenAI, Grok (xAI), premium | Premium : Claude 5 (alternatives DeepSeek V4 Pro et GLM 5.3) | Oui, 10 000 appels/mois, 5 sièges | | **Enterprise** | sur demande | sur demande | illimité | illimité | illimité | + custom | custom | illimitable | **Paquets de crédits** (au-delà des quotas, sans changer d'abonnement) : 50 cr. 5 € · 100 cr. 9 € · 250 cr. 20 € · 500 cr. 35 € · 1 000 cr. 60 € (0,06–0,10 €/page). diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md index 3622a0c..0ff879b 100644 --- a/_bmad-output/implementation-artifacts/deferred-work.md +++ b/_bmad-output/implementation-artifacts/deferred-work.md @@ -55,3 +55,19 @@ Reportés de la revue marketing du 2026-09-05 (spec-hermes-marketing-execution.m - source_spec: `spec-hermes-marketing-execution.md` summary: Définir une convention d'attribution par canal (paramètres de source dans les liens de Product Hunt, Show HN, X, Reddit, e-mails) avant le jour J. evidence: sans paramètre d'attribution, le rapport hebdomadaire ne pourra pas dire quel canal amène les inscriptions ; Vercel Analytics ne fournit que les référents. + +- source_spec: `_bmad-output/implementation-artifacts/spec-strategie-llm-abonnements-admin.md` + summary: Les emails marketing et la page publique de désabonnement sont en français uniquement, sans adaptation de langue. + evidence: Les modèles d'emails d'abonnement existants (services/email_service.py) sont déjà monolingues français ; le multilinguisme des emails est un projet à part (détection de langue par destinataire, templates par langue). +- source_spec: `_bmad-output/implementation-artifacts/spec-strategie-llm-abonnements-admin.md` + summary: Le désabonnement se fait par simple lien GET sans jeton ni confirmation (risque de désabonnement par préchargement des clients de messagerie). + evidence: Constat des relectures ; la solution complète passe par un envoi en un clic conforme RFC 8058 (POST List-Unsubscribe-Post) plus une page de confirmation, à concevoir séparément. +- source_spec: `_bmad-output/implementation-artifacts/spec-strategie-llm-abonnements-admin.md` + summary: GET /api/v1/admin/settings renvoie les clés API en clair alors que le commentaire du code affirme le contraire ; la nouvelle page Modèles repose sur cet aller-retour pour ne pas effacer les clés à l'enregistrement. + evidence: Comportement préexistant à ce chantier, constaté par le relecteur « lacunes de vérification » ; toute correction (masquage) doit prévoir la préservation côté PUT, comme pour le mot de passe SMTP. +- source_spec: `_bmad-output/implementation-artifacts/spec-strategie-llm-abonnements-admin.md` + summary: La branche base de données du calcul des audiences marketing n'est pas testée et retombe silencieusement sur le fichier JSON en cas d'échec. + evidence: Relectures ; conséquence bornée (un repli vide refuse l'envoi plutôt que d'envoyer de travers), mais un test en mode base éviterait les mauvaises surprises. +- source_spec: `_bmad-output/implementation-artifacts/spec-strategie-llm-abonnements-admin.md` + summary: Les cartes de revenus ne distinguent pas « aucune donnée » de « base injoignable » (affichage silencieux de zéro). + evidence: Relecture de contenu ; amélioration d'affichage à prévoir sur RevenueOverview.tsx. diff --git a/_bmad-output/implementation-artifacts/spec-strategie-llm-abonnements-admin.md b/_bmad-output/implementation-artifacts/spec-strategie-llm-abonnements-admin.md new file mode 100644 index 0000000..1c43eb1 --- /dev/null +++ b/_bmad-output/implementation-artifacts/spec-strategie-llm-abonnements-admin.md @@ -0,0 +1,152 @@ +--- +title: 'Stratégie LLM par abonnement + admin (modèles, statistiques, emails marketing)' +type: 'feature' +created: '2026-09-05' +status: 'done' +baseline_commit: '4e6850d6b12f2f97e9ec91a821b51a3b324d8f71' +review_loop_iteration: 0 +context: [] +--- + + + +## Intent + +**Problem :** Les modèles IA affichés sur la page d'accueil et la page tarifs ne correspondent plus à la gamme réelle (Claude Sonnet 4.6, « Claude Haiku », Gemini) ; aucun modèle n'est réellement routé par abonnement (le modèle employé vient des réglages fournisseur) ; l'admin ne peut ni gérer les modèles par type d'abonnement, ni voir de statistiques de revenus, ni envoyer d'emails de relance marketing. + +**Approach :** (1) Nouvelle gamme officielle — IA Essentielle (plan Pro) : `deepseek/deepseek-v4-flash`, `z-ai/glm-5.3-flash`, `minimax/minimax-m3` ; IA Premium (plan Business) : `anthropic/claude-sonnet-5` (remplace le 4.6, $2/$10 /1M), alternatives `deepseek/deepseek-v4-pro` et `z-ai/glm-5.3`. (2) Routage réel : le modèle utilisé par traduction est résolu par le palier IA du plan (réglages admin > défauts des plans). (3) Page admin dédiée « Modèles & abonnements » (matrice plan → palier → modèles actifs + modèle par défaut). (4) Statistiques enrichies : revenus (encaissé, MRR estimé, crédits), conversion, liste d'attente. (5) Page admin « Marketing » : envoi d'emails de relance par audience (liste d'attente, inactifs, par plan) avec aperçu, envoi test, historique et désabonnement — bonnes pratiques type Brevo/Mailchimp. + +## Boundaries & Constraints + +**Always :** Prix/textes en français correct dans l'interface ; les noms de modèles restent en latin tel quel dans les 13 langues ; ne jamais exposer de clé API dans les réponses admin ; un utilisateur Pro ne doit jamais déclencher un modèle Premium ; SMTP existant (`services/email_service.py`) réutilisé, pas de nouveau fournisseur. + +**Ask First :** Changer les prix des plans (19 €/49 €) ; créer une table de BDD nouvelle (préférer les fichiers JSON du dossier `data/` comme le font waitlist et réglages) ; toucher à la chaîne de secours (`FALLBACK_CHAIN`). + +**Never :** Modifier le dossier `office-translator-landing-page/` (prototype hors production) ni `.claude/`/`.agent/` ; supprimer les anciennes clés `ai_model_essential`/`ai_model_premium` utilisées ailleurs ; Stripe en mode live ; envoyer un email en masse sans passer par l'envoi test ; citer DeepL comme moteur. + +## I/O & Edge-Case Matrix + +| Scenario | Input / State | Expected Output / Behavior | Error Handling | +|----------|--------------|---------------------------|----------------| +| Traduction Pro, moteur openrouter | plan=pro, réglages admin essential.default=`z-ai/glm-5.3-flash` | Job exécuté avec ce modèle, facteur coût 1 | Si vide → défaut du plan (`deepseek/deepseek-v4-flash`) | +| Traduction Business, premium | plan=business, openrouter_premium | Modèle = premium.default (`anthropic/claude-sonnet-5`) | Si clé absente → échec gracieux existant | +| Envoi massif partiel | 200 destinataires, 3 échecs SMTP | Réponse : envoyés/échoués détaillés ; historique journalisé | Continuer après échec individuel, jamais d'HTTP 500 | +| Désabonné | email présent dans `data/unsubscribes.json` | Exclu de tout envoi d'audience | N/A | + + + +## Code Map + +- `models/subscription.py` -- PLANS (l.38-187) : clés `ai_model_essential`/`ai_model_premium` à remplacer par listes `ai_models_essential`/`ai_models_premium` (PRO l.100, BUSINESS l.130-131) ; commentaire d'en-tête l.34-37. +- `routes/translate_routes.py` -- résolution modèle l.1277-1280 (openrouter, défaut `google/gemini-3.5-flash` + remap ancien IDs) et l.1364-1375 (openrouter_premium, défaut `anthropic/claude-sonnet-4.6`) ; `_compute_cost_factor` l.562 ; `_allowed_providers_for_plan` l.179. C'est ici que le routage par palier s'applique (via `load_settings` déjà importé l.1269). +- `routes/admin_routes.py` -- `SettingsConfig` l.914-928 (ajouter section `ai_tiers`) ; GET/PUT `/settings` l.956-1054 ; `GET /stats` l.562 (usage seul, à enrichir) ; catalogue modèles OpenRouter `GET /providers/openrouter/models` existant (réutiliser pour la combobox admin) ; `POST /providers/smtp/test-send` l.1332 (exemple d'envoi). +- `services/email_service.py` -- `send_email_async(to, subject, body)` l.94 ; `is_smtp_configured()` l.53. Réutiliser tel quel. +- `routes/waitlist_routes.py` -- `data/waitlist.json` (email, interest, created_at) : audience marketing n°1, motif de fichier JSON à copier (verrou `_write_lock`) pour historique d'envois et désabonnements. +- `database/models.py` -- `PaymentHistory` l.293 (amount_cents, payment_type, status, created_at) et `Translation` l.149 (created_at, provider) : sources des stats de revenus ; `repositories.py` a les accesseurs. +- `services/pricing_config.py` -- `get_effective_monthly_yearly` pour le MRR estimé (prix effectifs, overrides inclus). +- `routes/auth_routes.py` l.763-811 -- `GET /plans` : exposer les listes de modèles (`ai_models_essential`/`premium`) au frontend. +- `frontend/src/components/landing/landing-page.tsx` l.440-582 -- PricingSection : textes via i18n uniquement. +- `frontend/src/app/pricing/page.tsx` -- `STATIC_PLANS` l.62-198 (business.feat3 « Claude Haiku », enterprise.feat2 « Claude Opus 4.6 ») ; section aiModels textée via i18n. +- `frontend/src/lib/i18n/messages//{landing,pricing,admin}.json` (13 langues : fr,en,de,es,it,pt,nl,ar,fa,ja,ko,ru,zh) -- clés à mettre à jour ; structure identique entre langues. +- `frontend/src/app/admin/` -- `constants.ts` (nav, ajouter 2 entrées), `settings/page.tsx` (motif de page avec combobox modèles), `stats/page.tsx` + `useTranslationStats.ts` (motif graphiques), `types.ts`. Nouvelles pages : `models/page.tsx`, `marketing/page.tsx`. +- `tests/` -- pytest, motif `test_admin_*.py`. + +## Tasks & Acceptance + +**Execution :** +- [x] `models/subscription.py` -- remplacer les deux clés modèle par les listes de gamme (Essentielle/Premium ci-dessus) -- source de vérité. +- [x] `routes/auth_routes.py` -- exposer `ai_models_essential`/`ai_models_premium` dans `GET /plans` -- affichage public. +- [x] `routes/translate_routes.py` -- résoudre le modèle par palier : réglages admin (`ai_tiers`) > défaut du plan ; défauts premium → `anthropic/claude-sonnet-5` ; nettoyer le remap l.1279 -- routage réel par abonnement. +- [x] `routes/admin_routes.py` -- `SettingsConfig.ai_tiers` (essential/premium : models[], default_model) avec valeurs par défaut issues de PLANS ; enrichir `GET /stats` (revenus encaissés 30 j + total via PaymentHistory, MRR estimé par plan, crédits achetés, compteur liste d'attente, répartition paliers IA utilisés) ; nouveaux endpoints `GET/POST /marketing/audiences` (comptes par audience), `POST /marketing/email/send` (audience, sujet, html, test_mode ; exclude désabonnés ; historique dans `data/marketing_emails.json`), `GET /marketing/email/history`, et public `GET /api/v1/marketing/unsubscribe` -- gestion + stats + relances. +- [x] `services/email_service.py` -- ne rien changer sauf si un helper pied-de-page désabonnement s'avère nécessaire. +- [x] `frontend/src/app/admin/models/page.tsx` + `marketing/page.tsx` + `constants.ts` + `types.ts` -- deux pages admin (matrice modèles par plan/palier avec défaut radio + combobox du catalogue OpenRouter ; marketing : audience + compteur, sujet/HTML avec aperçu, envoi test, envoi réel, historique) -- l'exigence de Sepehr. +- [x] `frontend/src/app/admin/stats/` -- cartes et graphiques des nouvelles métriques (revenus, MRR, liste d'attente) -- visibilité business. +- [x] `frontend/src/app/pricing/page.tsx` + `components/landing/landing-page.tsx` -- textes statiques à jour (Claude 5, gamme) -- cohérence marketing. +- [ ] i18n `landing.json`/`pricing.json`/`admin.json` (13 langues) -- clés modifiées/nouvelles traduites ; noms de modèles inchangés -- aucune chaîne manquante. +- [x] `tests/` -- tests : routage palier (pro→essential, business→premium, défaut admin), refus modèle premium pour plan pro, endpoint marketing (envoi test, désabonné exclu, historique), stats revenus. + +**Acceptance Criteria :** +- Given un utilisateur Pro, when il traduit avec le moteur openrouter, then le modèle employé est celui du palier Essentielle (jamais un modèle Premium) et la page tarifs affiche la gamme exacte. +- Given l'admin, when il change le modèle par défaut Essentielle puis traduit, then le nouveau modèle est utilisé sans redéploiement. +- Given un désabonné, when un envoi marketing part, then il ne reçoit rien et l'historique trace envoyés/échecs. +- Given `npm run build` et `pytest`, when exécutés, then succès sans erreur ni chaîne i18n manquante. + +## Design Notes + +Matrice admin (bonne pratique type LiteLLM/OpenRouter) : lignes = plans Pro/Business, colonnes = palier, contenu = modèles actifs (ordre = priorité de secours) + radio « par défaut ». Emails : audience = `waitlist` | `inactive_30` | `plan:pro` | `all_users` ; envoi séquentiel 0,2 s d'intervalle dans une tâche de fond ; pied de page avec lien `/api/v1/marketing/unsubscribe?email=`. Chiffres marketing autorisés : Essentielle « à partir de 0,09 $/M jetons (≈3× moins cher qu'annoncé auparavant) », contexte jusqu'à 1,3 M (GLM-5.3 Flash) ; Premium « Claude 5, 1M de contexte ». + +## Verification + +**Commands :** +- `cd frontend && npm run build` -- expected: compilation sans erreur +- `cd frontend && npm run test:run` -- expected: tests vitest verts +- `pytest -x -q` -- expected: nouveaux tests + régression verts +- `node -e "…vérif clés i18n identiques fr/en/…"` -- expected: aucune clé manquante (script de comparaison) + +**Manual checks (if no CLI):** +- Page d'accueil + /pricing : gamme affichée = deepseek-v4-flash, glm-5.3-flash, minimax-m3 / Claude 5, deepseek-v4-pro, glm-5.3 ; plus aucun « Claude Haiku » ni « Sonnet 4.6 ». +- Admin : pages Modèles et Marketing fonctionnelles ; envoi test reçu. + +## Spec Change Log + +## Suggested Review Order + +**Gamme officielle et exposition publique** + +- La source de vérité : les deux gammes officielles (Essentielle Pro / Premium Business) et les défauts. + [`subscription.py:41`](../../models/subscription.py#L41) + +- L'endpoint public des forfaits expose les listes de modèles aux pages tarifs. + [`auth_routes.py:786`](../../routes/auth_routes.py#L786) + +**Routage réel par palier** + +- Résolution du modèle : réglages admin > premier de la liste > défaut du plan, avec garde anti-croisement de palier. + [`translate_routes.py:220`](../../routes/translate_routes.py#L220) + +- Le point d'entrée historique affiche désormais le modèle réellement routé. + [`legacy_routes.py:91`](../../routes/legacy_routes.py#L91) + +**Côté admin : réglages et protections** + +- Bloc `ai_tiers` : défauts, normalisation (dédoublonnage, refus de croisement de gamme). + [`admin_routes.py:1055`](../../routes/admin_routes.py#L1055) + +- Garde « pristine » : une sauvegarde depuis la page Fournisseurs n'écrase plus les paliers personnalisés. + [`admin_routes.py:1232`](../../routes/admin_routes.py#L1232) + +**Marketing : audiences, envoi, désabonnement** + +- En-têtes de délivrabilité et lien de désabonnement par destinataire. + [`admin_routes.py:2388`](../../routes/admin_routes.py#L2388) + +- Audiences avec compteurs (liste d'attente, inactifs 30 jours, par forfait). + [`admin_routes.py:2417`](../../routes/admin_routes.py#L2417) + +- Envoi : test obligatoire (empreinte), historique « running » avant la boucle, verrou anti-campagne simultanée. + [`admin_routes.py:2499`](../../routes/admin_routes.py#L2499) + +**Interfaces** + +- Page « Modèles & abonnements » : matrice forfaits → paliers, défaut radio, catalogue OpenRouter. + [`page.tsx:1`](../../frontend/src/app/admin/models/page.tsx#L1) + +- Page « Marketing » : audience, aperçu, envoi test, historique (textes 100 % i18n). + [`page.tsx:1`](../../frontend/src/app/admin/marketing/page.tsx#L1) + +- Cartes de revenus / MRR / liste d'attente des statistiques. + [`RevenueOverview.tsx:22`](../../frontend/src/app/admin/RevenueOverview.tsx#L22) + +- Listes de modèles côté client alignées sur la gamme. + [`store.ts:10`](../../frontend/src/lib/store.ts#L10) + +**Périphériques** + +- En-têtes optionnels du service SMTP existant (aucun changement pour les appels en place). + [`email_service.py:63`](../../services/email_service.py#L63) + +- Test d'intégration du routage dans la tâche de traduction (faux fournisseur capturant le modèle). + [`test_worker_tier_routing.py:114`](../../tests/test_worker_tier_routing.py#L114) + +- Tests marketing (désabonnement en cours de campagne, campagne simultanée, échappement) et revenus en base. + [`test_admin_marketing.py:1`](../../tests/test_admin_marketing.py#L1) diff --git a/frontend/src/app/admin/RevenueOverview.tsx b/frontend/src/app/admin/RevenueOverview.tsx new file mode 100644 index 0000000..12d8828 --- /dev/null +++ b/frontend/src/app/admin/RevenueOverview.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Euro, TrendingUp, Users, Cpu, Loader2 } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useTranslationStore } from "@/lib/store"; +import { API_BASE } from "@/lib/config"; +import { useI18n } from "@/lib/i18n"; +import type { AdminStatsResponse } from "./types"; + +const getToken = () => useTranslationStore.getState().settings.adminToken ?? ""; + +function formatMoney(value: number, currency: string): string { + const currencyLabel = currency === "EUR" ? "€" : currency === "USD" ? "$" : currency; + return `${value.toLocaleString("fr-FR", { maximumFractionDigits: 2 })} ${currencyLabel}`; +} + +/** + * Cartes business (revenus, MRR estimé, crédits, liste d'attente, paliers IA) + * alimentées par GET /api/v1/admin/stats (champs revenue, mrr_estimated, + * waitlist_count, ai_tier_usage). + */ +export function RevenueOverview() { + const { t } = useI18n(); + const [stats, setStats] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + const load = async () => { + try { + const response = await fetch(`${API_BASE}/api/v1/admin/stats`, { + headers: { Authorization: `Bearer ${getToken()}` }, + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const json = await response.json(); + if (!cancelled) { + setStats(json.data ?? json); + setError(null); + } + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : "Erreur"); + } finally { + if (!cancelled) setIsLoading(false); + } + }; + load(); + const interval = setInterval(load, 30000); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, []); + + if (isLoading && !stats) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + +
+ + +
+ + + ))} +
+ ); + } + + if (error || !stats) { + return ( + + + {t("admin.stats.unavailable", { error: error ?? "" })} + + + ); + } + + const currency = stats.revenue?.currency ?? "EUR"; + const tierTotal = + (stats.ai_tier_usage?.essential ?? 0) + + (stats.ai_tier_usage?.premium ?? 0) + + (stats.ai_tier_usage?.classic ?? 0) + + (stats.ai_tier_usage?.other ?? 0); + + const cards = [ + { + title: t("admin.stats.revenue30"), + icon: , + value: formatMoney(stats.revenue?.collected_30d ?? 0, currency), + sub: t("admin.stats.payments30", { count: stats.revenue?.payments_30d ?? 0 }), + }, + { + title: t("admin.stats.revenueTotal"), + icon: , + value: formatMoney(stats.revenue?.collected_total ?? 0, currency), + sub: t("admin.stats.includingCredits", { + amount: formatMoney(stats.revenue?.credits_purchased ?? 0, currency), + }), + }, + { + title: t("admin.stats.mrr"), + icon: , + value: formatMoney(stats.mrr_estimated?.total ?? 0, currency), + sub: + Object.entries(stats.mrr_estimated?.by_plan ?? {}) + .map(([plan, mrr]) => `${plan} : ${formatMoney(mrr, currency)}`) + .join(" · ") || "—", + }, + { + title: t("admin.stats.waitlist"), + icon: , + value: String(stats.waitlist_count ?? 0), + sub: t("admin.stats.waitlistSub"), + }, + { + title: t("admin.stats.aiTiers"), + icon: , + value: `${stats.ai_tier_usage?.essential ?? 0} / ${stats.ai_tier_usage?.premium ?? 0}`, + sub: t("admin.stats.tiersSub", { + classic: stats.ai_tier_usage?.classic ?? 0, + other: stats.ai_tier_usage?.other ?? 0, + }), + }, + ]; + + return ( +
+
+ {cards.map((c) => ( + + + {c.title} + {c.icon} + + +
{c.value}
+

+ {c.sub} +

+
+
+ ))} +
+ {tierTotal === 0 && ( +

{t("admin.stats.noTranslationsYet")}

+ )} + {isLoading && ( +

+ {t("admin.stats.refreshing")} +

+ )} +
+ ); +} diff --git a/frontend/src/app/admin/constants.ts b/frontend/src/app/admin/constants.ts index 80e20fb..1eeedb2 100644 --- a/frontend/src/app/admin/constants.ts +++ b/frontend/src/app/admin/constants.ts @@ -1,4 +1,4 @@ -import { CreditCard, LayoutDashboard, Settings, FileText, Users, type LucideIcon } from 'lucide-react'; +import { CreditCard, LayoutDashboard, Settings, FileText, Users, Cpu, Megaphone, BarChart3, type LucideIcon } from 'lucide-react'; export interface AdminNavItem { labelKey: string; @@ -8,9 +8,12 @@ export interface AdminNavItem { export const adminNavItems: AdminNavItem[] = [ { labelKey: 'admin.nav.dashboard', href: '/admin', icon: LayoutDashboard }, + { labelKey: 'admin.nav.stats', href: '/admin/stats', icon: BarChart3 }, { labelKey: 'admin.nav.users', href: '/admin/users', icon: Users }, { labelKey: 'admin.nav.pricing', href: '/admin/pricing', icon: CreditCard }, + { labelKey: 'admin.nav.models', href: '/admin/models', icon: Cpu }, { labelKey: 'admin.nav.providers', href: '/admin/settings', icon: Settings }, + { labelKey: 'admin.nav.marketing', href: '/admin/marketing', icon: Megaphone }, { labelKey: 'admin.nav.system', href: '/admin/system', icon: Settings }, { labelKey: 'admin.nav.logs', href: '/admin/logs', icon: FileText }, ]; diff --git a/frontend/src/app/admin/marketing/page.tsx b/frontend/src/app/admin/marketing/page.tsx new file mode 100644 index 0000000..24e4df6 --- /dev/null +++ b/frontend/src/app/admin/marketing/page.tsx @@ -0,0 +1,426 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { + Megaphone, + Loader2, + Send, + FlaskConical, + History, + AlertCircle, + CheckCircle2, + Eye, +} from "lucide-react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { useNotification } from "@/components/ui/notification"; +import { useTranslationStore } from "@/lib/store"; +import { API_BASE } from "@/lib/config"; +import { useI18n } from "@/lib/i18n"; +import type { + MarketingAudiencesResponse, + MarketingEmailHistoryEntry, + MarketingEmailSendResponse, +} from "../types"; + +const getToken = () => useTranslationStore.getState().settings.adminToken ?? ""; + +/** Clés i18n par identifiant d'audience (l'identifiant part en base/historique). */ +const AUDIENCE_LABEL_KEYS: Record = { + waitlist: "admin.marketing.audienceWaitlist", + inactive_30: "admin.marketing.audienceInactive", + "plan:pro": "admin.marketing.audiencePlanPro", + "plan:starter": "admin.marketing.audiencePlanStarter", + "plan:business": "admin.marketing.audiencePlanBusiness", + "plan:enterprise": "admin.marketing.audiencePlanEnterprise", + "plan:free": "admin.marketing.audiencePlanFree", + all_users: "admin.marketing.audienceAllUsers", +}; + +export default function AdminMarketingPage() { + const { t } = useI18n(); + const { success, error } = useNotification(); + const [audiences, setAudiences] = useState>({}); + const [unsubscribedCount, setUnsubscribedCount] = useState(0); + const [audience, setAudience] = useState("waitlist"); + const [subject, setSubject] = useState(""); + const [html, setHtml] = useState(""); + const [testEmail, setTestEmail] = useState(""); + const [showPreview, setShowPreview] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isSendingTest, setIsSendingTest] = useState(false); + const [isSending, setIsSending] = useState(false); + const [history, setHistory] = useState([]); + /** Empreinte SHA-256 du contenu courant (même calcul que le backend). */ + const [contentHash, setContentHash] = useState(null); + /** false si le navigateur n'expose pas crypto.subtle (indicateur absent, + * l'envoi reste possible : le serveur valide l'envoi test). */ + const [hashSupported, setHashSupported] = useState(true); + + useEffect(() => { + if (!crypto?.subtle) { + setHashSupported(false); + setContentHash(null); + return; + } + let cancelled = false; + const data = new TextEncoder().encode(`${subject.trim()}\n${html.trim()}`); + crypto.subtle + .digest("SHA-256", data) + .then((buf) => { + if (cancelled) return; + const hex = Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + setContentHash(hex); + }) + .catch(() => setContentHash(null)); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [subject, html]); + + const loadAudiences = async () => { + const response = await fetch(`${API_BASE}/api/v1/admin/marketing/audiences`, { + headers: { Authorization: `Bearer ${getToken()}` }, + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const json: MarketingAudiencesResponse = await response.json(); + setAudiences(json.data.audiences); + setUnsubscribedCount(json.data.unsubscribed_count); + }; + + const loadHistory = async () => { + const response = await fetch(`${API_BASE}/api/v1/admin/marketing/email/history`, { + headers: { Authorization: `Bearer ${getToken()}` }, + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const json = await response.json(); + setHistory(json.data ?? []); + }; + + useEffect(() => { + setIsLoading(true); + Promise.all([loadAudiences(), loadHistory()]) + .catch((e) => + error({ title: t("admin.marketing.loadErrorTitle"), description: String(e.message ?? e) }) + ) + .finally(() => setIsLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const audienceLabel = (key: string): string => { + const labelKey = AUDIENCE_LABEL_KEYS[key]; + return labelKey ? t(labelKey) : key; + }; + + const recipientCount = audiences[audience] ?? 0; + + /** Simple indicateur (non bloquant) : un test réussi de ce contenu exact + * apparaît dans l'historique chargé. La décision d'autoriser l'envoi réel + * appartient au serveur (l'historique affiché est tronqué à 100 entrées). */ + const testSeenInHistory = + !!contentHash && + history.some( + (h) => h.test_mode && h.sent_count > 0 && h.content_hash === contentHash + ); + + const sendTest = async () => { + if (!subject.trim() || !html.trim()) { + error({ title: t("admin.marketing.incompleteTitle"), description: t("admin.marketing.incompleteDesc") }); + return; + } + setIsSendingTest(true); + try { + const response = await fetch(`${API_BASE}/api/v1/admin/marketing/email/send`, { + method: "POST", + headers: { + Authorization: `Bearer ${getToken()}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + audience, + subject: subject.trim(), + html: html.trim(), + test_mode: true, + test_email: testEmail.trim() || null, + }), + }); + const json = await response.json().catch(() => ({})); + if (response.ok) { + const d: MarketingEmailSendResponse["data"] = json.data; + success({ + title: t("admin.marketing.testDoneTitle"), + description: t("admin.marketing.testDoneDesc", { email: d.recipient ?? "" }), + }); + await loadHistory(); + } else { + error({ + title: t("admin.marketing.testFailedTitle"), + description: json.message || `HTTP ${response.status}`, + }); + await loadHistory(); + } + } catch { + error({ title: t("admin.marketing.loadErrorTitle"), description: t("admin.marketing.networkDesc") }); + } finally { + setIsSendingTest(false); + } + }; + + const sendReal = async () => { + if (!subject.trim() || !html.trim()) { + error({ title: t("admin.marketing.incompleteTitle"), description: t("admin.marketing.incompleteDesc") }); + return; + } + if ( + !window.confirm( + t("admin.marketing.confirmSend", { count: recipientCount, audience: audienceLabel(audience) }) + ) + ) { + return; + } + setIsSending(true); + try { + const response = await fetch(`${API_BASE}/api/v1/admin/marketing/email/send`, { + method: "POST", + headers: { + Authorization: `Bearer ${getToken()}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + audience, + subject: subject.trim(), + html: html.trim(), + test_mode: false, + }), + }); + const json = await response.json().catch(() => ({})); + if (response.ok) { + const d: MarketingEmailSendResponse["data"] = json.data; + success({ + title: t("admin.marketing.sendingTitle"), + description: t("admin.marketing.sendingDesc", { count: d.queued ?? 0 }), + }); + setTimeout(() => { + loadHistory().catch(() => {}); + }, 3000); + } else { + error({ + title: t("admin.marketing.sendRefusedTitle"), + description: json.message || `HTTP ${response.status}`, + }); + } + } catch { + error({ title: t("admin.marketing.loadErrorTitle"), description: t("admin.marketing.networkDesc") }); + } finally { + setIsSending(false); + } + }; + + const previewDoc = useMemo( + () => + html.trim() || + `${t( + "admin.marketing.previewEmpty" + )}`, + [html, t] + ); + + return ( +
+
+
+ +
+
+

{t("admin.marketing.title")}

+

{t("admin.marketing.subtitle")}

+
+
+ + {isLoading ? ( +
+ +
+ ) : ( +
+ {/* ── Composition ── */} + + + {t("admin.marketing.newCampaign")} + + {t("admin.marketing.unsubscribedNote", { count: unsubscribedCount })} + + + +
+
+ + +

+ {t("admin.marketing.recipientsAvailable", { count: recipientCount })} +

+
+
+ + setTestEmail(e.target.value)} + /> +
+
+ +
+ + setSubject(e.target.value)} + maxLength={200} + /> +
+ +
+
+ + +
+