feat(credits): solde IA unique (option M), packs Stripe et polish billing
Un pot de crédits global (BASIC 100 / PRO 1 000 / BUSINESS 4 000) remplace les seaux par fonction côté débit. Packs one-shot S/M/L, admin sans seaux legacy, usage multi-features, hints de coût, anti-flash thème et hydratation admin. Inclut aussi le pipeline slides renforcé et la page publique polishée.
This commit is contained in:
@@ -5,15 +5,15 @@
|
|||||||
- Préfère les échanges en français, avec des explications détaillées et claires (éviter le jargon flou) ; **nom produit** : **Memento** (jamais « Momento ») — libellés, metadata, pages publiques, docs.
|
- Préfère les échanges en français, avec des explications détaillées et claires (éviter le jargon flou) ; **nom produit** : **Memento** (jamais « Momento ») — libellés, metadata, pages publiques, docs.
|
||||||
- Interface : tout libellé via i18n dans les 15 fichiers `memento-note/locales/*.json` (FR et EN comme références de contenu) ; éviter le texte en dur ; traductions **contextuelles** (sens produit, pas mot à mot — ex. « connecter votre propre fournisseur ») ; libellés FR **lisibles** (éviter jargon non expliqué : « wiki », « embed », etc.) et **aide contextuelle** où l'UX l'exige ; lors d'une traduction complète, mettre à jour toutes les locales concernées ; si l'utilisateur demande seulement les **clés i18n**, ajouter les clés (souvent EN/FR) sans remplir les 15 locales — il traduit souvent avec un autre modèle ; **insights Memory Echo** (texte généré/stocké) : langue utilisateur à la génération + fallback i18n à l'affichage (`memoryEcho.defaultInsight`) — jamais d'anglais en dur quand l'UI est FR.
|
- Interface : tout libellé via i18n dans les 15 fichiers `memento-note/locales/*.json` (FR et EN comme références de contenu) ; éviter le texte en dur ; traductions **contextuelles** (sens produit, pas mot à mot — ex. « connecter votre propre fournisseur ») ; libellés FR **lisibles** (éviter jargon non expliqué : « wiki », « embed », etc.) et **aide contextuelle** où l'UX l'exige ; lors d'une traduction complète, mettre à jour toutes les locales concernées ; si l'utilisateur demande seulement les **clés i18n**, ajouter les clés (souvent EN/FR) sans remplir les 15 locales — il traduit souvent avec un autre modèle ; **insights Memory Echo** (texte généré/stocké) : langue utilisateur à la génération + fallback i18n à l'affichage (`memoryEcho.defaultInsight`) — jamais d'anglais en dur quand l'UI est FR.
|
||||||
- Base de données : **INTERDIT TOTALEMENT** de lancer `prisma db push --force-reset`, `prisma migrate reset`, `DROP TABLE`, `TRUNCATE`, `pg_restore` avec clean, ou TOUTE commande qui vide/supprime des données — MÊME SI l'utilisateur est d'accord — sans avoir d'abord : (1) dumpé la base avec `bash /home/devparsa/dev/Memento/dump-db.sh`, (2) vérifié le dump fait au moins 1Mo, (3) obtenu un "OUI" explicite de l'utilisateur. **4 incidents de perte de données documentés (14/05, 15/05 x2, 16/05). NE JAMAIS REFAIRE ÇA.**
|
- Base de données : **INTERDIT TOTALEMENT** de lancer `prisma db push --force-reset`, `prisma migrate reset`, `DROP TABLE`, `TRUNCATE`, `pg_restore` avec clean, ou TOUTE commande qui vide/supprime des données — MÊME SI l'utilisateur est d'accord — sans avoir d'abord : (1) dumpé la base avec `bash /home/devparsa/dev/Memento/dump-db.sh`, (2) vérifié le dump fait au moins 1Mo, (3) obtenu un "OUI" explicite de l'utilisateur. **4 incidents de perte de données documentés (14/05, 15/05 x2, 16/05). NE JAMAIS REFAIRE ÇA.**
|
||||||
- Design produit : migration depuis `architectural-grid1` (base) et `architectural-grid` (prototype UI courant) ; **consulter le prototype avant toute implémentation UI** ; logique liste/carte puis contenu au clic ; parité actions liste/carte (menus « … », déplacer, génération SVG, etc.) ; contraste éditeur clair / sidebar sombre ; retirer thèmes obsolètes ; **pas de refresh/revalidation complets inutiles** (aligné prototype — mutations optimistes, pas de `revalidatePath` systématique ni resync depuis `initialNotes`) ; **Memory Echo en section inline dans l'éditeur** (pas l'ancienne modale) — similarité sur contenu **représentatif** (pas de troncature arbitraire type 200/800 car.) ; **recherche (sidebar / résultats, ex. flux « ouvrir la note ») et navigation liste des notes** (modes affichage, icônes vs initiales…) : suivre **`SearchModal` et les patterns actuels** dans `architectural-grid`, pas une approximation ou un ancien flux ; **sidebar rail** (`sidebar.tsx`) : une seule icône active ; **largeur sidebar** (`components/sidebar.tsx`) — redimensionnement utilisateur par poignée sur le bord droit (desktop `md+`, 280–560px, persisté `localStorage` `memento-sidebar-width`) ; défaut responsive `w-80` puis plus large en `xl`/`2xl` si aucune valeur stockée ; **hauteur zone carnets** ajustable via poignée horizontale (`memento-sidebar-notebooks-height`, min 120px) ; fallback `Suspense` de `app/(main)/layout.tsx` **aligné** sur les largeurs par défaut (rail fixe 54px) ; **pas de bouton « Note du jour »** dans la sidebar (retiré volontairement) ; **dashboard Second Brain** (`/home`, `dashboard-view.tsx`) : vue d'ensemble **interactive** (pas dashboard statique/mort) — layout **v5** configurable (catalogue ~15 widgets, persisté `/api/dashboard/layout`) ; widget **« Prochaines pistes »** (`next-paths`) = recommandations actionnables depuis la dernière note éditée (fast paths briefing puis enrichissement async `/api/briefing/paths`) ; widget **« Revue quotidienne »** = **checklist matinale interactive** (inbox, découvertes IA, connexion, flashcards) — **pas** une revue automatique de contenu ; cartes cliquables, section Memory Echo « L'IA a trouvé » exploitable (pas vide après 1ère visite), consentement/provider IA + **quotas** vérifiés sur chaque action IA (dashboard et agents) ; icônes **Lucide** (pas emojis système type 🎯) ; `MindMapCard` remplace l'ancien `MiniResonanceGraph` D3 (graphe décoratif qui débordait) ; navigation sidebar alignée sur `/home` ; `activeView` synchronisé avec pathname et query (`/insights`, `/revision`, `/home?reminders=1`) ; panneau latéral contextuel par route (pas la liste carnets sur `/insights` ou Rappels) ; **`/insights` (insights sémantiques)** : suivre **`InsightsView.tsx` + graphe réseau associé dans le prototype** (ex. composition type `NetworkGraph.tsx`) ; **distincte de `/graph`** ; ne pas substituer par une UX « géométrique » décorative ou un regroupement par carnet hors spec prototype ; lorsque données clusters en retard ou partiellement périmées, **montrer l’état dégradé exploitable plutôt qu’une page vide** ; bouton Menu immersif `/insights` : **cacher la sidebar ET agrandir le contenu principal** (pas de bandeau beige vide — attention `twMerge` qui peut faire perdre `fixed` au profit de `relative`) ; si l'utilisateur hésite entre variantes UX, **trancher pour le design prototype** plutôt que multiplier les toggles.
|
- Design produit : migration depuis `architectural-grid1` (base) et `architectural-grid` (prototype UI courant) ; **consulter le prototype avant toute implémentation UI** ; logique liste/carte puis contenu au clic ; parité actions liste/carte (menus « … », déplacer, génération SVG, etc.) ; contraste éditeur clair / sidebar sombre ; retirer thèmes obsolètes ; **pas de refresh/revalidation complets inutiles** (aligné prototype — mutations optimistes, pas de `revalidatePath` systématique ni resync depuis `initialNotes`) ; **Memory Echo en section inline dans l'éditeur** (pas l'ancienne modale) — similarité sur contenu **représentatif** (pas de troncature arbitraire type 200/800 car.) ; **recherche (sidebar / résultats, ex. flux « ouvrir la note ») et navigation liste des notes** (modes affichage, icônes vs initiales…) : suivre **`SearchModal` et les patterns actuels** dans `architectural-grid`, pas une approximation ou un ancien flux ; **sidebar rail** (`sidebar.tsx`) : une seule icône active ; **largeur sidebar** (`components/sidebar.tsx`) — redimensionnement utilisateur par poignée sur le bord droit (desktop `md+`, 280–560px, persisté `localStorage` `memento-sidebar-width`) ; défaut responsive `w-80` puis plus large en `xl`/`2xl` si aucune valeur stockée ; **hauteur zone carnets** ajustable via poignée horizontale (`memento-sidebar-notebooks-height`, min 120px) ; fallback `Suspense` de `app/(main)/layout.tsx` **aligné** sur les largeurs par défaut (rail fixe 54px) ; **pas de bouton « Note du jour »** dans la sidebar (retiré volontairement) ; **dashboard Second Brain** (`/home`, `dashboard-view.tsx`) : vue d'ensemble **interactive** (pas dashboard statique/mort) — layout **v5** configurable (catalogue ~15 widgets, persisté `/api/dashboard/layout`) ; widget **« Prochaines pistes »** (`next-paths`) = recommandations actionnables depuis la dernière note éditée (fast paths briefing puis enrichissement async `/api/briefing/paths`) ; widget **« Revue quotidienne »** = **checklist matinale interactive** (inbox, découvertes IA, connexion, flashcards) — **pas** une revue automatique de contenu ; cartes cliquables, section Memory Echo « L'IA a trouvé » exploitable (pas vide après 1ère visite), consentement/provider IA + **quotas** vérifiés sur chaque action IA (dashboard et agents) ; chaque widget : **aide contextuelle « ? » i18n** ; couleurs alignées sur le **thème settings** ; icônes **Lucide** (pas emojis système type 🎯) ; `MindMapCard` remplace l'ancien `MiniResonanceGraph` D3 (graphe décoratif qui débordait) ; navigation sidebar alignée sur `/home` ; `activeView` synchronisé avec pathname et query (`/insights`, `/revision`, `/home?reminders=1`) ; panneau latéral contextuel par route (pas la liste carnets sur `/insights` ou Rappels) ; **`/insights` (insights sémantiques)** : suivre **`InsightsView.tsx` + graphe réseau associé dans le prototype** (ex. composition type `NetworkGraph.tsx`) ; **distincte de `/graph`** ; ne pas substituer par une UX « géométrique » décorative ou un regroupement par carnet hors spec prototype ; lorsque données clusters en retard ou partiellement périmées, **montrer l’état dégradé exploitable plutôt qu’une page vide** ; liens/ponts proposés : **pertinence sémantique réelle** (rejeter associations absurdes cross-domaine) ; **pas d'IDs techniques** type « cluster 15 » exposés à l'utilisateur ; bouton Menu immersif `/insights` : **cacher la sidebar ET agrandir le contenu principal** (pas de bandeau beige vide — attention `twMerge` qui peut faire perdre `fixed` au profit de `relative`) ; si l'utilisateur hésite entre variantes UX, **trancher pour le design prototype** plutôt que multiplier les toggles ; **landing page** (`components/landing-page.tsx`) : style marketing sombre/premium type **x.ai** (pas de fond blanc plat) ; mettre en avant **Second Brain** ; **multilinguisme** avec sélecteur de langue (icône globe — ne pas confondre avec navigation back) ; harmonie dark mode (ex. zone notifications).
|
||||||
- Locale persane : dates en calendrier iranien (conversion), chiffres persans, et vérification RTL/positionnement global (app **et** extension Web Clipper) ; **Memory Echo** et recherche sémantique doivent fonctionner en persan (RTL, embeddings — pas de contournement « EN only ») ; attention à ne pas confondre un nom de carnet (ex. « Persan ») avec le libellé de langue.
|
- Locale persane : dates en calendrier iranien (conversion), chiffres persans, et vérification RTL/positionnement global (app **et** extension Web Clipper) ; **Memory Echo** et recherche sémantique doivent fonctionner en persan (RTL, embeddings — pas de contournement « EN only ») ; attention à ne pas confondre un nom de carnet (ex. « Persan ») avec le libellé de langue.
|
||||||
- Flux Excalidraw / diagrammes générés : accès via notification en plus d'une simple redirection ; priorité à la mise en page et au texte contenu dans les formes ; proposer des modes visuels (ex. coloré vs plus austère) tout en visant un rendu proche du style Excalidraw (polices, look).
|
- Flux Excalidraw / diagrammes générés : accès via notification en plus d'une simple redirection ; priorité à la mise en page et au texte contenu dans les formes ; proposer des modes visuels (ex. coloré vs plus austère) tout en visant un rendu proche du style Excalidraw (polices, look).
|
||||||
- **Interdiction d'écrire des tests** sauf demande explicite ; en CI, seul `npm run test:unit` (`tests/unit/**`) — pas `tests/migration/` ; ne jamais générer de code superflu.
|
- **Interdiction d'écrire des tests** sauf demande explicite ; en CI, seul `npm run test:unit` (`tests/unit/**`) — pas `tests/migration/` ; ne jamais générer de code superflu.
|
||||||
- Déploiement : privilégier le chemin rapide (artifact Next.js en CI + `Dockerfile.prebuilt`) ; CI/CD très robuste (pas d'image Docker obsolète en prod, pas de migrations/schéma DB via le workflow deploy) ; éviter les rebuild Docker complets inutiles (~15 min par itération) ; **ne pas pousser un déploiement quand des features clés sont inachevées** ; ne pas insister sur le déploiement tant que le produit n'est pas fini ou meilleur ; **ne jamais hardcoder d'IP/URL** dans le repo ou les défauts CI — uniquement variables d'env (ex. `GRAFANA_BIND`, `GRAFANA_URL`) ; la config runtime reste sur le serveur dans `.env.docker`. **Machines** : agent/dev = **192.168.1.83** (devSanbox) ; prod = **192.168.1.190** (`/opt/memento`) — **ne jamais confondre** les deux au diagnostic services. **CI/CD Gitea spécifique** : (1) CI `ubuntu-24.04` = lint + tests + build (validation) ; deploy `docker-host` = build sur le serveur → `/tmp/web-artifact.tgz` → `deploy-prod.sh` — **sans** upload/download-artifact inter-runners (stockage Gitea instable ; si artifacts utilisés, **@v3** uniquement — @v4 → `GHESNotSupportedError`) ; (2) `Dockerfile.socket.prebuilt` doit utiliser `--legacy-peer-deps` dans `npm install` (conflit TipTap 3.22.5 vs 3.23.6) ; (3) le serveur de prod (192.168.1.190) **ne peut pas pull Docker Hub** (DNS cassé) — le build Docker complet échoue, seul le chemin prebuilt artifact fonctionne ; (4) `docker-entrypoint.sh` applique les migrations Prisma **avant** de démarrer le serveur Next.js (ordre correct) ; (5) rollback d'urgence : `docker tag memento-memento-note:rollback memento-memento-note:latest && docker compose up -d --force-recreate memento-note` ; (6) `scripts/deploy-prod.sh` charge `/opt/memento/.env.docker` via un **parseur robuste ligne-par-ligne** (jamais `source` — crash `unexpected EOF` sur guillemet non fermé) et teste Postgres avec `pg_isready` + credentials **du conteneur** (`$POSTGRES_USER` interne) ; (7) lint CI = `eslint --max-warnings 9999` : les **warnings ne bloquent pas**, seules les **erreurs** bloquent (ex. `no-html-link-for-pages` → `<Link>` obligatoire pour la navigation interne, pas `<a href="/">`) ; **TRAVAILLER SUR UNE BRANCHE** pendant le dev, ne push sur `main` que quand le code est testé et fonctionnel — **avant push `main`** : exécuter localement `npm run lint`, `npm run test:unit`, `npm run build` (même enchaînement que CI) ; chaque push sur `main` déclenche un déploiement automatique en production.
|
- Déploiement : privilégier le chemin rapide (artifact Next.js en CI + `Dockerfile.prebuilt`) ; CI/CD très robuste (pas d'image Docker obsolète en prod, pas de migrations/schéma DB via le workflow deploy) ; éviter les rebuild Docker complets inutiles (~15 min par itération) ; **ne pas pousser un déploiement quand des features clés sont inachevées** ; ne pas insister sur le déploiement tant que le produit n'est pas fini ou meilleur ; **ne jamais hardcoder d'IP/URL** dans le repo ou les défauts CI — uniquement variables d'env (ex. `GRAFANA_BIND`, `GRAFANA_URL`) ; la config runtime reste sur le serveur dans `.env.docker`. **Machines** : agent/dev = **192.168.1.83** (devSanbox) ; prod = **192.168.1.190** (`/opt/memento`) — **ne jamais confondre** les deux au diagnostic services. **CI/CD Gitea spécifique** : (1) CI `ubuntu-24.04` = lint + tests + build (validation) ; deploy `docker-host` = build sur le serveur → `/tmp/web-artifact.tgz` → `deploy-prod.sh` — **sans** upload/download-artifact inter-runners (stockage Gitea instable ; si artifacts utilisés, **@v3** uniquement — @v4 → `GHESNotSupportedError`) ; (2) `Dockerfile.socket.prebuilt` doit utiliser `--legacy-peer-deps` dans `npm install` (conflit TipTap 3.22.5 vs 3.23.6) ; (3) le serveur de prod (192.168.1.190) **ne peut pas pull Docker Hub** (DNS cassé) — le build Docker complet échoue, seul le chemin prebuilt artifact fonctionne ; (4) `docker-entrypoint.sh` applique les migrations Prisma **avant** de démarrer le serveur Next.js (ordre correct) ; (5) rollback d'urgence : `docker tag memento-memento-note:rollback memento-memento-note:latest && docker compose up -d --force-recreate memento-note` ; (6) `scripts/deploy-prod.sh` charge `/opt/memento/.env.docker` via un **parseur robuste ligne-par-ligne** (jamais `source` — crash `unexpected EOF` sur guillemet non fermé) et teste Postgres avec `pg_isready` + credentials **du conteneur** (`$POSTGRES_USER` interne) ; (7) lint CI = `eslint --max-warnings 9999` : les **warnings ne bloquent pas**, seules les **erreurs** bloquent (ex. `no-html-link-for-pages` → `<Link>` obligatoire pour la navigation interne, pas `<a href="/">`) ; **TRAVAILLER SUR UNE BRANCHE** pendant le dev, ne push sur `main` que quand le code est testé et fonctionnel — **avant push `main`** : exécuter localement `npm run lint`, `npm run test:unit`, `npm run build` (même enchaînement que CI) ; chaque push sur `main` déclenche un déploiement automatique en production.
|
||||||
- Authentification : priorité à l'inscription/connexion via **Google OAuth** (plutôt qu'un compte email/mot de passe) ; exiger une vraie déconnexion (invalidation session/cookies — pas de reconnexion implicite, y compris en navigation privée) ; prod : `AUTH_GOOGLE_ID`/`AUTH_GOOGLE_SECRET` dans `/opt/memento/.env.docker` **et** variables/secrets Gitea Actions (workflow deploy n'écrit pas les valeurs vides) — absence → `invalid_client` ; redirect URI exacte `https://memento-note.com/api/auth/callback/google` ; config via **Google Auth Platform** (Branding → Audience **In production**, pas Testing → Clients Web) ; `ALLOW_REGISTRATION=true` requis pour inscription email (env ou `SystemConfig` en base — la DB peut primer sur l'env).
|
- Authentification : priorité à l'inscription/connexion via **Google OAuth** (plutôt qu'un compte email/mot de passe) ; exiger une vraie déconnexion (invalidation session/cookies — pas de reconnexion implicite, y compris en navigation privée) ; prod : `AUTH_GOOGLE_ID`/`AUTH_GOOGLE_SECRET` dans `/opt/memento/.env.docker` **et** variables/secrets Gitea Actions (workflow deploy n'écrit pas les valeurs vides) — absence → `invalid_client` ; redirect URI exacte `https://memento-note.com/api/auth/callback/google` ; config via **Google Auth Platform** (Branding → Audience **In production**, pas Testing → Clients Web) ; `ALLOW_REGISTRATION=true` requis pour inscription email (env ou `SystemConfig` en base — la DB peut primer sur l'env).
|
||||||
- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; pour les **tâches ops** (config OAuth prod, serveur), préfère un **guidage interactif étape par étape** plutôt qu'un bloc d'instructions monolithique ; **ne jamais affirmer qu'un correctif ou une feature est fait sans vérification réelle** (app, prototype `architectural-grid`, ou test), **notamment navigation recherche/liste notes et vue `/insights` vs fichiers prototype** — l'utilisateur sanctionne fermement les fausses déclarations ; **ouverture note liée depuis l'éditeur** (ex. bloc live « Ouvrir ») : **split peek inline** animé (`lib/note-peek-sync.ts`, `note-editor-split-peek.tsx` — éditeur courant à **gauche**, note liée en lecture seule à **droite** en LTR ; **inversé en RTL** `fa`/`ar`), **pas nouvel onglet** ; **ne jamais annuler du code non commité** (`git checkout`, reset fichier) **sans demande explicite** (perte de travail documentée, ex. drag handle éditeur) ; **ne jamais remettre du code que l'utilisateur a explicitement retiré sans demander d'abord** (ex. `reserveUsageOrThrow` retiré intentionnellement de `organize-notebook.ts` — agent l'a remis sans demander → user mécontent) ; **correction i18n ou spec doc** : **ne pas refondre logique/UI** hors scope (ex. US-4 `structuredViewBlock` — garder le dual-mode base locale + lien carnet, pas de suppression du mode local) ; en frustration ou pour déléguer, **prévoir des prompts / briefs d'implémentation détaillés** (autre modèle ou dev), en plus des briefs outil de design.
|
- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; préférer les **solutions long terme** aux rustines ; si l'utilisateur demande explicitement d'**analyser sans coder**, expliquer d'abord et **ne pas modifier le code** tant qu'il n'a pas validé ; pour les **tâches ops** (config OAuth prod, serveur), préfère un **guidage interactif étape par étape** plutôt qu'un bloc d'instructions monolithique ; **ne jamais affirmer qu'un correctif ou une feature est fait sans vérification réelle** (app, prototype `architectural-grid`, ou test), **notamment navigation recherche/liste notes et vue `/insights` vs fichiers prototype** — l'utilisateur sanctionne fermement les fausses déclarations ; **ouverture note liée depuis l'éditeur** (ex. bloc live « Ouvrir ») : **split peek inline** animé (`lib/note-peek-sync.ts`, `note-editor-split-peek.tsx` — éditeur courant à **gauche**, note liée en lecture seule à **droite** en LTR ; **inversé en RTL** `fa`/`ar`), **pas nouvel onglet** ; **ne jamais annuler du code non commité** (`git checkout`, reset fichier) **sans demande explicite** (perte de travail documentée, ex. drag handle éditeur) ; **ne jamais remettre du code que l'utilisateur a explicitement retiré sans demander d'abord** (ex. `reserveUsageOrThrow` retiré intentionnellement de `organize-notebook.ts` — agent l'a remis sans demander → user mécontent) ; **correction i18n ou spec doc** : **ne pas refondre logique/UI** hors scope (ex. US-4 `structuredViewBlock` — garder le dual-mode base locale + lien carnet, pas de suppression du mode local) ; en frustration ou pour déléguer, **prévoir des prompts / briefs d'implémentation détaillés** (autre modèle ou dev), en plus des briefs outil de design.
|
||||||
- Livraison : **une user story à la fois**, tester et valider avec l'utilisateur avant la suivante (pas d'auto-validation ni d'enchaînement de code non demandé) ; suivi dans `docs/user-stories.md` ; briefs pour outil de design externe sur demande ; **avant de développer une story, vérifier d'abord dans le code si la feature existe déjà** — les docs de stories (`docs/story-nextgen-editor.md`, `docs/user-stories.md`) sont **souvent périmés** (features livrées mais marquées « à faire ») ; **dashboard Second Brain** : même rythme — **une feature à la fois**, validation explicite avant la suivante.
|
- Livraison : **une user story à la fois**, tester et valider avec l'utilisateur avant la suivante (pas d'auto-validation ni d'enchaînement de code non demandé) ; suivi dans `docs/user-stories.md` ; briefs pour outil de design externe sur demande ; **avant de développer une story, vérifier d'abord dans le code si la feature existe déjà** — les docs de stories (`docs/story-nextgen-editor.md`, `docs/user-stories.md`) sont **souvent périmés** (features livrées mais marquées « à faire ») ; **dashboard Second Brain** : même rythme — **une feature à la fois**, validation explicite avant la suivante.
|
||||||
- **Facturation & quotas IA** : limites mensuelles, tiers (BASIC/PRO/BUSINESS/ENTERPRISE) et Price IDs Stripe via **Admin > Facturation & quotas** (`/admin/billing`) — pas via `.env` pour le métier ; secrets Stripe (`STRIPE_SECRET_KEY`, webhook) restent en env serveur ; doc `memento-note/docs/admin-billing-quotas-guide.md` ; **tier BASIC sans accès MCP** ; chaque usage IA (dashboard, agents, MCP si autorisé, etc.) doit **décompter le quota** — ne pas affirmer un déploiement prod sans vérif réelle (ex. travail MCP/quotas encore local).
|
- **Facturation & quotas IA** : limites mensuelles, tiers (BASIC/PRO/BUSINESS/ENTERPRISE) et Price IDs Stripe via **Admin > Facturation & quotas** (`/admin/billing`) — pas via `.env` pour le métier ; secrets Stripe (`STRIPE_SECRET_KEY`, webhook) restent en env serveur ; doc `memento-note/docs/admin-billing-quotas-guide.md` ; **tier BASIC sans accès MCP** ; chaque usage IA (dashboard, agents, MCP si autorisé, etc.) doit **décompter le quota** — mécanisme = **réservation atomique upfront** (Redis/Postgres) **avant** l'appel IA, sans rollback si l'appel échoue ensuite ; BYOK peut contourner selon config — ne pas affirmer un déploiement prod sans vérif réelle (ex. travail MCP/quotas encore local).
|
||||||
|
|
||||||
## Learned Workspace Facts
|
## Learned Workspace Facts
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,16 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { updateBillingConfig, updatePlanEntitlement } from '@/app/actions/admin-billing'
|
import { updateBillingConfig } from '@/app/actions/admin-billing'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { CreditCard, Gauge, Settings2 } from 'lucide-react'
|
import { CreditCard, Gauge, Coins, Package } from 'lucide-react'
|
||||||
|
|
||||||
type EntitlementRow = {
|
|
||||||
tier: string
|
|
||||||
feature: string
|
|
||||||
limitValue: number | null
|
|
||||||
mode: 'limited' | 'unlimited' | 'unavailable'
|
|
||||||
}
|
|
||||||
|
|
||||||
type BillingAdminData = {
|
type BillingAdminData = {
|
||||||
entitlements: EntitlementRow[]
|
|
||||||
billingConfig: Record<string, string>
|
billingConfig: Record<string, string>
|
||||||
usageOverview: {
|
usageOverview: {
|
||||||
period: string
|
period: string
|
||||||
@@ -26,34 +18,55 @@ type BillingAdminData = {
|
|||||||
byFeature: Array<{ feature: string; requests: number; tokens: number; users: number }>
|
byFeature: Array<{ feature: string; requests: number; tokens: number; users: number }>
|
||||||
topUsers: Array<{ userId: string; email: string; name: string | null; requests: number }>
|
topUsers: Array<{ userId: string; email: string; name: string | null; requests: number }>
|
||||||
}
|
}
|
||||||
features: string[]
|
|
||||||
tiers: string[]
|
tiers: string[]
|
||||||
|
/** Allocations mensuelles du solde unique (source de vérité débit) */
|
||||||
|
creditAllocations?: Array<{
|
||||||
|
tier: string
|
||||||
|
monthlyCredits: number | null
|
||||||
|
unlimited: boolean
|
||||||
|
}>
|
||||||
|
creditCosts?: Record<string, number>
|
||||||
|
creditPacks?: Array<{
|
||||||
|
id: string
|
||||||
|
credits: number
|
||||||
|
defaultDisplay: string
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEntitlement(
|
const SUBSCRIPTION_PRICE_KEYS = [
|
||||||
entitlements: EntitlementRow[],
|
'STRIPE_PRICE_PRO_MONTHLY',
|
||||||
tier: string,
|
'STRIPE_PRICE_PRO_ANNUAL',
|
||||||
feature: string,
|
'STRIPE_PRICE_BUSINESS_MONTHLY',
|
||||||
): EntitlementRow | undefined {
|
'STRIPE_PRICE_BUSINESS_ANNUAL',
|
||||||
return entitlements.find((e) => e.tier === tier && e.feature === feature)
|
] as const
|
||||||
|
|
||||||
|
const PACK_PRICE_KEYS = [
|
||||||
|
'STRIPE_PRICE_CREDITS_S',
|
||||||
|
'STRIPE_PRICE_CREDITS_M',
|
||||||
|
'STRIPE_PRICE_CREDITS_L',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** Date stable SSR/client (pas de toLocaleString — mismatch locale + fuseau). */
|
||||||
|
function formatStableUtc(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (Number.isNaN(d.getTime())) return iso
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BillingAdminClient({ initialData }: { initialData: BillingAdminData }) {
|
export function BillingAdminClient({ initialData }: { initialData: BillingAdminData }) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const [activeTier, setActiveTier] = useState(initialData.tiers[0] ?? 'BASIC')
|
|
||||||
const [billingEnabled, setBillingEnabled] = useState(initialData.billingConfig.BILLING_ENABLED === 'true')
|
const [billingEnabled, setBillingEnabled] = useState(initialData.billingConfig.BILLING_ENABLED === 'true')
|
||||||
const [isSavingBilling, setIsSavingBilling] = useState(false)
|
const [isSavingBilling, setIsSavingBilling] = useState(false)
|
||||||
const [savingCell, setSavingCell] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const handleSaveBilling = async (formData: FormData) => {
|
const handleSaveBilling = async (formData: FormData) => {
|
||||||
setIsSavingBilling(true)
|
setIsSavingBilling(true)
|
||||||
try {
|
try {
|
||||||
const data: Record<string, string> = {
|
const data: Record<string, string> = {
|
||||||
BILLING_ENABLED: billingEnabled ? 'true' : 'false',
|
BILLING_ENABLED: billingEnabled ? 'true' : 'false',
|
||||||
STRIPE_PRICE_PRO_MONTHLY: String(formData.get('STRIPE_PRICE_PRO_MONTHLY') ?? ''),
|
}
|
||||||
STRIPE_PRICE_PRO_ANNUAL: String(formData.get('STRIPE_PRICE_PRO_ANNUAL') ?? ''),
|
for (const key of [...SUBSCRIPTION_PRICE_KEYS, ...PACK_PRICE_KEYS]) {
|
||||||
STRIPE_PRICE_BUSINESS_MONTHLY: String(formData.get('STRIPE_PRICE_BUSINESS_MONTHLY') ?? ''),
|
data[key] = String(formData.get(key) ?? '')
|
||||||
STRIPE_PRICE_BUSINESS_ANNUAL: String(formData.get('STRIPE_PRICE_BUSINESS_ANNUAL') ?? ''),
|
|
||||||
}
|
}
|
||||||
await updateBillingConfig(data)
|
await updateBillingConfig(data)
|
||||||
toast.success(t('admin.billing.configSaved'))
|
toast.success(t('admin.billing.configSaved'))
|
||||||
@@ -64,23 +77,6 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEntitlementChange = async (
|
|
||||||
feature: string,
|
|
||||||
mode: 'unavailable' | 'unlimited' | 'limited',
|
|
||||||
limitValue?: number,
|
|
||||||
) => {
|
|
||||||
const key = `${activeTier}:${feature}`
|
|
||||||
setSavingCell(key)
|
|
||||||
try {
|
|
||||||
await updatePlanEntitlement(activeTier, feature, mode, limitValue)
|
|
||||||
toast.success(t('admin.billing.limitSaved'))
|
|
||||||
} catch (e: unknown) {
|
|
||||||
toast.error(e instanceof Error ? e.message : t('admin.billing.limitFailed'))
|
|
||||||
} finally {
|
|
||||||
setSavingCell(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
@@ -98,7 +94,7 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
|||||||
<p className="text-sm text-muted-foreground">{t('admin.billing.stripeConfigDescription')}</p>
|
<p className="text-sm text-muted-foreground">{t('admin.billing.stripeConfigDescription')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveBilling(new FormData(e.currentTarget)) }} className="p-6 space-y-4">
|
<form onSubmit={(e) => { e.preventDefault(); handleSaveBilling(new FormData(e.currentTarget)) }} className="p-6 space-y-6">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="BILLING_ENABLED"
|
id="BILLING_ENABLED"
|
||||||
@@ -107,8 +103,10 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
|||||||
/>
|
/>
|
||||||
<Label htmlFor="BILLING_ENABLED">{t('admin.billing.enableBilling')}</Label>
|
<Label htmlFor="BILLING_ENABLED">{t('admin.billing.enableBilling')}</Label>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-3">{t('admin.billing.subscriptionPricesTitle')}</h3>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
{(['STRIPE_PRICE_PRO_MONTHLY', 'STRIPE_PRICE_PRO_ANNUAL', 'STRIPE_PRICE_BUSINESS_MONTHLY', 'STRIPE_PRICE_BUSINESS_ANNUAL'] as const).map((key) => (
|
{SUBSCRIPTION_PRICE_KEYS.map((key) => (
|
||||||
<div key={key} className="space-y-2">
|
<div key={key} className="space-y-2">
|
||||||
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
|
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -120,69 +118,91 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border/50 pt-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Package className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<h3 className="text-sm font-medium">{t('admin.billing.packPricesTitle')}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-3">{t('admin.billing.packPricesDescription')}</p>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
{PACK_PRICE_KEYS.map((key) => (
|
||||||
|
<div key={key} className="space-y-2">
|
||||||
|
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
|
||||||
|
<Input
|
||||||
|
id={key}
|
||||||
|
name={key}
|
||||||
|
defaultValue={initialData.billingConfig[key] ?? ''}
|
||||||
|
placeholder="price_..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.billing.secretsNote')}</p>
|
<p className="text-xs text-muted-foreground">{t('admin.billing.secretsNote')}</p>
|
||||||
<Button type="submit" disabled={isSavingBilling}>{t('admin.billing.saveConfig')}</Button>
|
<Button type="submit" disabled={isSavingBilling}>{t('admin.billing.saveConfig')}</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Solde unique — source de vérité du débit */}
|
||||||
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||||
<div className="flex items-center gap-3 p-6 border-b border-border">
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||||
<Settings2 className="h-5 w-5" />
|
<Coins className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold">{t('admin.billing.limitsTitle')}</h2>
|
<h2 className="font-semibold">{t('admin.billing.creditsTitle')}</h2>
|
||||||
<p className="text-sm text-muted-foreground">{t('admin.billing.limitsDescription')}</p>
|
<p className="text-sm text-muted-foreground">{t('admin.billing.creditsDescription')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
{initialData.tiers.map((tier) => (
|
{(initialData.creditAllocations ?? []).map((row) => (
|
||||||
<button
|
<div
|
||||||
key={tier}
|
key={row.tier}
|
||||||
type="button"
|
className="rounded-xl border border-border/60 bg-muted/30 p-4 space-y-1"
|
||||||
onClick={() => setActiveTier(tier)}
|
|
||||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
|
|
||||||
activeTier === tier
|
|
||||||
? 'border-primary bg-primary/10 text-primary'
|
|
||||||
: 'border-border text-muted-foreground hover:bg-muted'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{tier}
|
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
</button>
|
{row.tier}
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-semibold tabular-nums">
|
||||||
|
{row.unlimited
|
||||||
|
? t('admin.billing.modeUnlimited')
|
||||||
|
: row.monthlyCredits?.toLocaleString('fr-FR')}
|
||||||
|
</p>
|
||||||
|
{!row.unlimited && (
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{t('admin.billing.creditsPerMonth')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
{(initialData.creditPacks?.length ?? 0) > 0 && (
|
||||||
<table className="w-full text-sm">
|
<div className="border-t border-border/40 pt-3">
|
||||||
<thead>
|
<p className="text-xs font-medium mb-2">{t('admin.billing.packsCatalogTitle')}</p>
|
||||||
<tr className="border-b text-left text-muted-foreground">
|
<div className="flex flex-wrap gap-2">
|
||||||
<th className="py-2 pr-4">{t('admin.billing.feature')}</th>
|
{(initialData.creditPacks ?? []).map((pack) => (
|
||||||
<th className="py-2 pr-4">{t('admin.billing.mode')}</th>
|
<span
|
||||||
<th className="py-2 pr-4">{t('admin.billing.monthlyLimit')}</th>
|
key={pack.id}
|
||||||
<th className="py-2">{t('admin.billing.actions')}</th>
|
className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-muted/20 px-3 py-1.5 text-xs"
|
||||||
</tr>
|
>
|
||||||
</thead>
|
<span className="font-semibold uppercase">{pack.id}</span>
|
||||||
<tbody>
|
<span className="text-muted-foreground">
|
||||||
{initialData.features.map((feature) => {
|
{pack.credits.toLocaleString('fr-FR')} cr. · {pack.defaultDisplay}
|
||||||
const row = getEntitlement(initialData.entitlements, activeTier, feature)
|
</span>
|
||||||
const mode = row?.mode ?? 'unavailable'
|
</span>
|
||||||
const cellKey = `${activeTier}:${feature}`
|
))}
|
||||||
return (
|
|
||||||
<EntitlementRowEditor
|
|
||||||
key={`${activeTier}:${feature}`}
|
|
||||||
feature={feature}
|
|
||||||
mode={mode}
|
|
||||||
limitValue={row?.limitValue ?? null}
|
|
||||||
isSaving={savingCell === cellKey}
|
|
||||||
onSave={handleEntitlementChange}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed border-t border-border/40 pt-3">
|
||||||
|
{t('admin.billing.creditsCostsNote')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.billing.creditsResetHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||||
@@ -195,7 +215,10 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
|||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t('admin.billing.usagePeriod', { period: initialData.usageOverview.period })}
|
{t('admin.billing.usagePeriod', { period: initialData.usageOverview.period })}
|
||||||
{initialData.usageOverview.lastSyncedAt
|
{initialData.usageOverview.lastSyncedAt
|
||||||
? ` · ${t('admin.billing.lastSync', { date: new Date(initialData.usageOverview.lastSyncedAt).toLocaleString() })}`
|
? ` · ${t('admin.billing.lastSync', {
|
||||||
|
// Format fixe UTC (évite mismatch SSR locale/fuseau vs client)
|
||||||
|
date: formatStableUtc(initialData.usageOverview.lastSyncedAt),
|
||||||
|
})}`
|
||||||
: ` · ${t('admin.billing.notSynced')}`}
|
: ` · ${t('admin.billing.notSynced')}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,68 +259,3 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function EntitlementRowEditor({
|
|
||||||
feature,
|
|
||||||
mode: initialMode,
|
|
||||||
limitValue: initialLimit,
|
|
||||||
isSaving,
|
|
||||||
onSave,
|
|
||||||
t,
|
|
||||||
}: {
|
|
||||||
feature: string
|
|
||||||
mode: 'unavailable' | 'unlimited' | 'limited'
|
|
||||||
limitValue: number | null
|
|
||||||
isSaving: boolean
|
|
||||||
onSave: (feature: string, mode: 'unavailable' | 'unlimited' | 'limited', limit?: number) => Promise<void>
|
|
||||||
t: (key: string, params?: Record<string, string | number>) => string
|
|
||||||
}) {
|
|
||||||
const [mode, setMode] = useState(initialMode)
|
|
||||||
const [limit, setLimit] = useState(initialLimit != null ? String(initialLimit) : '50')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMode(initialMode)
|
|
||||||
setLimit(initialLimit != null ? String(initialLimit) : '50')
|
|
||||||
}, [initialMode, initialLimit])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr className="border-b border-border/30">
|
|
||||||
<td className="py-3 pr-4 font-mono text-xs">{feature}</td>
|
|
||||||
<td className="py-3 pr-4">
|
|
||||||
<select
|
|
||||||
value={mode}
|
|
||||||
onChange={(e) => setMode(e.target.value as typeof mode)}
|
|
||||||
className="h-9 rounded-md border border-input bg-background px-2 text-xs"
|
|
||||||
>
|
|
||||||
<option value="unavailable">{t('admin.billing.modeUnavailable')}</option>
|
|
||||||
<option value="limited">{t('admin.billing.modeLimited')}</option>
|
|
||||||
<option value="unlimited">{t('admin.billing.modeUnlimited')}</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 pr-4">
|
|
||||||
{mode === 'limited' ? (
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={limit}
|
|
||||||
onChange={(e) => setLimit(e.target.value)}
|
|
||||||
className="h-9 w-24 text-xs"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground text-xs">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-3">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
disabled={isSaving}
|
|
||||||
onClick={() => onSave(feature, mode, mode === 'limited' ? parseInt(limit, 10) : undefined)}
|
|
||||||
>
|
|
||||||
{isSaving ? '…' : t('admin.billing.saveLimit')}
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -139,11 +139,11 @@ const SUGGESTED_EMBEDDINGS: Record<string, string[]> = {
|
|||||||
zai: ['text-embedding-3-small', 'text-embedding-3-large'],
|
zai: ['text-embedding-3-small', 'text-embedding-3-large'],
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModelPurpose = 'tags' | 'embeddings' | 'chat'
|
type ModelPurpose = 'tags' | 'embeddings' | 'chat' | 'slides'
|
||||||
|
|
||||||
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
|
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const [activeAiTab, setActiveAiTab] = useState<'tags' | 'embeddings' | 'chat'>('tags')
|
const [activeAiTab, setActiveAiTab] = useState<'tags' | 'embeddings' | 'chat' | 'slides'>('tags')
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [isTesting, setIsTesting] = useState(false)
|
const [isTesting, setIsTesting] = useState(false)
|
||||||
|
|
||||||
@@ -168,11 +168,15 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
return PROVIDERS_WITHOUT_EMBEDDINGS.includes(v) ? 'ollama' : v
|
return PROVIDERS_WITHOUT_EMBEDDINGS.includes(v) ? 'ollama' : v
|
||||||
})
|
})
|
||||||
const [chatProvider, setChatProvider] = useState<AIProvider>((config.AI_PROVIDER_CHAT as AIProvider) || 'ollama')
|
const [chatProvider, setChatProvider] = useState<AIProvider>((config.AI_PROVIDER_CHAT as AIProvider) || 'ollama')
|
||||||
|
const [slidesProvider, setSlidesProvider] = useState<AIProvider>(
|
||||||
|
(config.AI_PROVIDER_SLIDES as AIProvider) || (config.AI_PROVIDER_CHAT as AIProvider) || 'ollama',
|
||||||
|
)
|
||||||
|
|
||||||
// Selected Models State
|
// Selected Models State
|
||||||
const [selectedTagsModel, setSelectedTagsModel] = useState<string>(config.AI_MODEL_TAGS || '')
|
const [selectedTagsModel, setSelectedTagsModel] = useState<string>(config.AI_MODEL_TAGS || '')
|
||||||
const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState<string>(config.AI_MODEL_EMBEDDING || '')
|
const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState<string>(config.AI_MODEL_EMBEDDING || '')
|
||||||
const [selectedChatModel, setSelectedChatModel] = useState<string>(config.AI_MODEL_CHAT || '')
|
const [selectedChatModel, setSelectedChatModel] = useState<string>(config.AI_MODEL_CHAT || '')
|
||||||
|
const [selectedSlidesModel, setSelectedSlidesModel] = useState<string>(config.AI_MODEL_SLIDES || '')
|
||||||
|
|
||||||
const [tagsFallbackProvider, setTagsFallbackProvider] = useState<string>(config.AI_PROVIDER_TAGS_FALLBACK || '')
|
const [tagsFallbackProvider, setTagsFallbackProvider] = useState<string>(config.AI_PROVIDER_TAGS_FALLBACK || '')
|
||||||
const [embedFallbackProvider, setEmbedFallbackProvider] = useState<string>(config.AI_PROVIDER_EMBEDDING_FALLBACK || '')
|
const [embedFallbackProvider, setEmbedFallbackProvider] = useState<string>(config.AI_PROVIDER_EMBEDDING_FALLBACK || '')
|
||||||
@@ -186,11 +190,13 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
tags: [],
|
tags: [],
|
||||||
embeddings: [],
|
embeddings: [],
|
||||||
chat: [],
|
chat: [],
|
||||||
|
slides: [],
|
||||||
})
|
})
|
||||||
const [isLoadingModels, setIsLoadingModels] = useState<Record<ModelPurpose, boolean>>({
|
const [isLoadingModels, setIsLoadingModels] = useState<Record<ModelPurpose, boolean>>({
|
||||||
tags: false,
|
tags: false,
|
||||||
embeddings: false,
|
embeddings: false,
|
||||||
chat: false,
|
chat: false,
|
||||||
|
slides: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch models from local providers (Ollama, LM Studio) or cloud providers with /v1/models
|
// Fetch models from local providers (Ollama, LM Studio) or cloud providers with /v1/models
|
||||||
@@ -268,6 +274,16 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
const key = config[API_KEY_CONFIG[chatProvider]] || ''
|
const key = config[API_KEY_CONFIG[chatProvider]] || ''
|
||||||
if (url && key) await fetchModels('chat', chatProvider, url, key)
|
if (url && key) await fetchModels('chat', chatProvider, url, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (slidesProvider === 'ollama') {
|
||||||
|
await fetchModels('slides', 'ollama', config.OLLAMA_BASE_URL_CHAT || config.OLLAMA_BASE_URL || 'http://localhost:11434')
|
||||||
|
} else if (slidesProvider === 'lmstudio') {
|
||||||
|
await fetchModels('slides', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
|
||||||
|
} else if (PROVIDER_META[slidesProvider]?.hasApiKey && slidesProvider !== 'anthropic_custom') {
|
||||||
|
const url = DEFAULT_BASE_URLS[slidesProvider]
|
||||||
|
const key = config[API_KEY_CONFIG[slidesProvider]] || ''
|
||||||
|
if (url && key) await fetchModels('slides', slidesProvider, url, key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fetchInitial()
|
fetchInitial()
|
||||||
|
|
||||||
@@ -408,6 +424,32 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Slides provider (dedicated model for deck generation — never auto-renamed)
|
||||||
|
const slidesProv = formData.get('AI_PROVIDER_SLIDES') as string
|
||||||
|
data.AI_PROVIDER_SLIDES = slidesProv?.trim() ?? ''
|
||||||
|
const slidesModel = formData.get('AI_MODEL_SLIDES') as string
|
||||||
|
data.AI_MODEL_SLIDES = slidesModel?.trim() ?? ''
|
||||||
|
if (slidesProv) {
|
||||||
|
if (slidesProv === 'ollama') {
|
||||||
|
const ollamaUrl = formData.get('BASE_URL_ollama_slides') as string
|
||||||
|
if (ollamaUrl) data.OLLAMA_BASE_URL_CHAT = ollamaUrl
|
||||||
|
} else if (slidesProv === 'lmstudio') {
|
||||||
|
const url = formData.get('BASE_URL_lmstudio_slides') as string
|
||||||
|
if (url) data.LMSTUDIO_BASE_URL = url
|
||||||
|
} else {
|
||||||
|
const apiKeyConfigKey = API_KEY_CONFIG[slidesProv as AIProvider]
|
||||||
|
if (apiKeyConfigKey) {
|
||||||
|
const apiKey = formData.get(`API_KEY_${slidesProv}_slides`) as string
|
||||||
|
if (apiKey) data[apiKeyConfigKey] = apiKey
|
||||||
|
}
|
||||||
|
const baseUrlConfigKey = BASE_URL_CONFIG[slidesProv as AIProvider]
|
||||||
|
if (baseUrlConfigKey) {
|
||||||
|
const baseUrl = formData.get(`BASE_URL_${slidesProv}_slides`) as string
|
||||||
|
if (baseUrl) data[baseUrlConfigKey] = baseUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await updateSystemConfig(data)
|
const result = await updateSystemConfig(data)
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
|
|
||||||
@@ -740,6 +782,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
<button type="button" onClick={() => setActiveAiTab('tags')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'tags' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>🏷️ Tags</button>
|
<button type="button" onClick={() => setActiveAiTab('tags')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'tags' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>🏷️ Tags</button>
|
||||||
<button type="button" onClick={() => setActiveAiTab('embeddings')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'embeddings' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>🔍 Embeddings</button>
|
<button type="button" onClick={() => setActiveAiTab('embeddings')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'embeddings' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>🔍 Embeddings</button>
|
||||||
<button type="button" onClick={() => setActiveAiTab('chat')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'chat' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>💬 Chat</button>
|
<button type="button" onClick={() => setActiveAiTab('chat')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'chat' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>💬 Chat</button>
|
||||||
|
<button type="button" onClick={() => setActiveAiTab('slides')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'slides' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>📑 {t('admin.ai.slidesTab')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
@@ -945,6 +988,44 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Slides Provider — dedicated model for deck generation */}
|
||||||
|
<div className={`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 ${activeAiTab === 'slides' ? 'block' : 'hidden'}`}>
|
||||||
|
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||||
|
<span>📑</span> {t('admin.ai.slidesProvider')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.ai.slidesDescription')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="AI_PROVIDER_SLIDES">{t('admin.ai.provider')}</Label>
|
||||||
|
<select
|
||||||
|
id="AI_PROVIDER_SLIDES"
|
||||||
|
name="AI_PROVIDER_SLIDES"
|
||||||
|
value={slidesProvider}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSlidesProvider(e.target.value as AIProvider)
|
||||||
|
setSelectedSlidesModel('')
|
||||||
|
setDynamicModels((prev) => ({ ...prev, slides: [] }))
|
||||||
|
}}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
{providerOptions.map((opt) => (
|
||||||
|
<option key={`slides-${opt.value}`} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" name="AI_MODEL_SLIDES" value={selectedSlidesModel} />
|
||||||
|
{renderProviderConfig(slidesProvider, 'slides', selectedSlidesModel, setSelectedSlidesModel)}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground border-t border-border/40 pt-3">
|
||||||
|
{t('admin.ai.slidesQuotaNote')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 pb-6 flex flex-col sm:flex-row gap-3 sm:justify-between pt-6">
|
<div className="px-6 pb-6 flex flex-col sm:flex-row gap-3 sm:justify-between pt-6">
|
||||||
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>
|
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { deleteUser, updateUserRole, updateUserSubscription } from '@/app/actions/admin'
|
import { deleteUser, updateUserRole, updateUserSubscription, resetUserQuotas } from '@/app/actions/admin'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Trash2, Shield, ShieldOff, Crown, ChevronDown } from 'lucide-react'
|
import { Trash2, Shield, ShieldOff, Crown, ChevronDown, RotateCcw } from 'lucide-react'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
@@ -91,6 +91,7 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
|||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const [users, setUsers] = useState(initialUsers)
|
const [users, setUsers] = useState(initialUsers)
|
||||||
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
|
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
|
||||||
|
const [resettingId, setResettingId] = useState<string | null>(null)
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!confirm(t('admin.users.confirmDelete'))) return
|
if (!confirm(t('admin.users.confirmDelete'))) return
|
||||||
@@ -103,6 +104,27 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleResetQuotas = async (user: { id: string; email?: string | null; name?: string | null }) => {
|
||||||
|
const label = user.email || user.name || user.id
|
||||||
|
if (!confirm(t('admin.users.confirmResetQuotas', { user: label }))) return
|
||||||
|
setResettingId(user.id)
|
||||||
|
try {
|
||||||
|
const result = await resetUserQuotas(user.id)
|
||||||
|
toast.success(
|
||||||
|
t('admin.users.resetQuotasSuccess', {
|
||||||
|
period: result.period,
|
||||||
|
count: result.featuresReset.length,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(
|
||||||
|
e instanceof Error ? e.message : t('admin.users.resetQuotasFailed'),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setResettingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleRoleToggle = async (user: any) => {
|
const handleRoleToggle = async (user: any) => {
|
||||||
const newRole = user.role === 'ADMIN' ? 'USER' : 'ADMIN'
|
const newRole = user.role === 'ADMIN' ? 'USER' : 'ADMIN'
|
||||||
try {
|
try {
|
||||||
@@ -163,6 +185,16 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
|||||||
<td className="p-4 align-middle">{format(new Date(user.createdAt), 'PP')}</td>
|
<td className="p-4 align-middle">{format(new Date(user.createdAt), 'PP')}</td>
|
||||||
<td className="p-4 align-middle text-right">
|
<td className="p-4 align-middle text-right">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleResetQuotas(user)}
|
||||||
|
disabled={resettingId === user.id}
|
||||||
|
title={t('admin.users.resetQuotas')}
|
||||||
|
className="text-amber-700 hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-200"
|
||||||
|
>
|
||||||
|
<RotateCcw className={`h-4 w-4 ${resettingId === user.id ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -1,27 +1,21 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { LanguageProvider } from '@/lib/i18n/LanguageProvider';
|
import { LanguageProvider, useLanguage } from '@/lib/i18n/LanguageProvider';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Globe } from 'lucide-react';
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
function AuthHeader() {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
export default function AuthLayout({
|
|
||||||
children,
|
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode;
|
|
||||||
}>) {
|
|
||||||
return (
|
return (
|
||||||
<LanguageProvider>
|
|
||||||
<div className="min-h-screen bg-[#FDFCFB] dark:bg-[#0D0D0D] flex flex-col relative overflow-hidden">
|
|
||||||
<div className="absolute top-[-10%] right-[-10%] w-[50%] h-[50%] bg-[var(--color-brand-accent)]/5 blur-[120px] rounded-full pointer-events-none" />
|
|
||||||
<div className="absolute bottom-[-10%] left-[-10%] w-[50%] h-[50%] bg-[#D4A373]/5 blur-[120px] rounded-full pointer-events-none" />
|
|
||||||
|
|
||||||
<header className="p-6 md:p-8 flex justify-between items-center relative z-10">
|
<header className="p-6 md:p-8 flex justify-between items-center relative z-10">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
|
aria-label={t('general.back')}
|
||||||
className="flex items-center gap-2 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors group"
|
className="flex items-center gap-2 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors group"
|
||||||
>
|
>
|
||||||
<div className="w-8 h-8 rounded-full border border-[var(--border)] flex items-center justify-center group-hover:border-[var(--color-brand-accent)] transition-colors">
|
<div className="w-8 h-8 rounded-full border border-[var(--border)] flex items-center justify-center group-hover:border-[var(--color-brand-accent)] transition-colors">
|
||||||
<Globe size={14} className="group-hover:rotate-12 transition-transform" />
|
<ArrowLeft size={14} className="group-hover:-translate-x-0.5 transition-transform rtl:rotate-180" />
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@@ -34,12 +28,27 @@ export default function AuthLayout({
|
|||||||
|
|
||||||
<div className="w-8" />
|
<div className="w-8" />
|
||||||
</header>
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<LanguageProvider>
|
||||||
|
<div className="min-h-screen bg-[#FDFCFB] dark:bg-[#0D0D0D] flex flex-col relative overflow-hidden">
|
||||||
|
<div className="absolute top-[-10%] right-[-10%] w-[50%] h-[50%] bg-[var(--color-brand-accent)]/5 blur-[120px] rounded-full pointer-events-none" />
|
||||||
|
<div className="absolute bottom-[-10%] left-[-10%] w-[50%] h-[50%] bg-[#D4A373]/5 blur-[120px] rounded-full pointer-events-none" />
|
||||||
|
|
||||||
|
<AuthHeader />
|
||||||
|
|
||||||
<main className="flex-1 flex items-center justify-center p-4 md:p-6 relative z-10">
|
<main className="flex-1 flex items-center justify-center p-4 md:p-6 relative z-10">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
{children}
|
{children}
|
||||||
<p className="text-center mt-8 text-[9px] text-[var(--muted-foreground)] font-bold uppercase tracking-[0.3em] opacity-40 select-none">
|
<p className="text-center mt-8 text-[9px] text-[var(--muted-foreground)] font-bold uppercase tracking-[0.3em] opacity-40 select-none">
|
||||||
© 2025 Memento Labs
|
© 2026 Memento
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -35,13 +35,13 @@ export default async function MainLayout({
|
|||||||
initialTranslations={initialTranslations}
|
initialTranslations={initialTranslations}
|
||||||
initialAiProcessingConsent={aiSettings?.aiProcessingConsent === true}
|
initialAiProcessingConsent={aiSettings?.aiProcessingConsent === true}
|
||||||
>
|
>
|
||||||
{/* No top-bar header — sidebar-only navigation (architectural-grid design) */}
|
{/* Fond via --background (suit .dark) — évite le flash beige/clair au changement de route */}
|
||||||
<div className="flex h-screen overflow-hidden bg-memento-desk dark:bg-background">
|
<div className="flex h-screen overflow-hidden bg-background">
|
||||||
<Suspense fallback={<div className="hidden w-80 md:w-[26rem] 2xl:w-[30rem] shrink-0 md:block h-full self-stretch" />}>
|
<Suspense fallback={<div className="hidden w-80 md:w-[26rem] 2xl:w-[30rem] shrink-0 md:block h-full self-stretch bg-sidebar" />}>
|
||||||
<Sidebar user={session?.user} />
|
<Sidebar user={session?.user} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth bg-memento-paper dark:bg-background" id="main-content">
|
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth bg-background" id="main-content">
|
||||||
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:z-[9999] focus:top-2 focus:left-2 focus:bg-brand-accent focus:text-white focus:px-4 focus:py-2 focus:rounded-lg focus:text-sm focus:font-bold">
|
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:z-[9999] focus:top-2 focus:left-2 focus:bg-brand-accent focus:text-white focus:px-4 focus:py-2 focus:rounded-lg focus:text-sm focus:font-bold">
|
||||||
Skip to content
|
Skip to content
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { updateUserSettings } from '@/app/actions/user-settings'
|
|||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Palette, Type, LayoutGrid, Maximize } from 'lucide-react'
|
import { Palette, Type, LayoutGrid, Maximize } from 'lucide-react'
|
||||||
import { applyDocumentTheme, normalizeThemeId, type ThemeId } from '@/lib/apply-document-theme'
|
import { persistThemePreference, normalizeThemeId, type ThemeId } from '@/lib/apply-document-theme'
|
||||||
import {
|
import {
|
||||||
NOTES_LAYOUT_STORAGE_KEY,
|
NOTES_LAYOUT_STORAGE_KEY,
|
||||||
parseNotesLayoutMode,
|
parseNotesLayoutMode,
|
||||||
@@ -65,8 +65,7 @@ export function AppearanceSettingsClient({
|
|||||||
const handleThemeChange = async (value: string) => {
|
const handleThemeChange = async (value: string) => {
|
||||||
const next = normalizeThemeId(value)
|
const next = normalizeThemeId(value)
|
||||||
setTheme(next)
|
setTheme(next)
|
||||||
localStorage.setItem('theme-preference', next)
|
persistThemePreference(next)
|
||||||
applyDocumentTheme(next)
|
|
||||||
await updateUserSettings({ theme: next })
|
await updateUserSettings({ theme: next })
|
||||||
toast.success(t('settings.settingsSaved'))
|
toast.success(t('settings.settingsSaved'))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
'use client'
|
/**
|
||||||
|
* Plus d’animation d’entrée (opacity / translate) :
|
||||||
import { motion } from 'motion/react'
|
* ça laissait voir le fond clair une fraction de seconde en mode sombre.
|
||||||
|
* Conteneur neutre uniquement.
|
||||||
|
*/
|
||||||
export default function MainTemplate({ children }: { children: React.ReactNode }) {
|
export default function MainTemplate({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<div className="flex min-h-0 flex-1 flex-col bg-background text-foreground">
|
||||||
initial={{ opacity: 0, x: 20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
className="flex-1 flex flex-col min-h-0"
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</motion.div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import { detectUserLanguage, parseAcceptLanguage } from '@/lib/i18n/detect-user-
|
|||||||
import { loadTranslations } from '@/lib/i18n/load-translations'
|
import { loadTranslations } from '@/lib/i18n/load-translations'
|
||||||
import { PublicProviders } from '@/components/public-providers'
|
import { PublicProviders } from '@/components/public-providers'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le body racine est en hauteur d’écran avec débordement masqué
|
||||||
|
* (pour l’application connectée). Les pages publiques (accueil, tarifs,
|
||||||
|
* notes publiées) doivent pouvoir défiler : on crée un conteneur de
|
||||||
|
* défilement qui couvre tout l’écran, indépendant du body.
|
||||||
|
*/
|
||||||
export default async function PublicLayout({ children }: { children: React.ReactNode }) {
|
export default async function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||||
const headersList = await headers()
|
const headersList = await headers()
|
||||||
const browserLang = parseAcceptLanguage(headersList.get('accept-language'))
|
const browserLang = parseAcceptLanguage(headersList.get('accept-language'))
|
||||||
@@ -11,7 +17,13 @@ export default async function PublicLayout({ children }: { children: React.React
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PublicProviders initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
|
<PublicProviders initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
|
||||||
|
<div
|
||||||
|
data-public-scroll-root
|
||||||
|
className="fixed inset-0 z-0 overflow-y-auto overflow-x-hidden overscroll-y-contain"
|
||||||
|
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
|
</div>
|
||||||
</PublicProviders>
|
</PublicProviders>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,9 @@ const BILLING_CONFIG_KEYS = [
|
|||||||
'STRIPE_PRICE_PRO_ANNUAL',
|
'STRIPE_PRICE_PRO_ANNUAL',
|
||||||
'STRIPE_PRICE_BUSINESS_MONTHLY',
|
'STRIPE_PRICE_BUSINESS_MONTHLY',
|
||||||
'STRIPE_PRICE_BUSINESS_ANNUAL',
|
'STRIPE_PRICE_BUSINESS_ANNUAL',
|
||||||
|
'STRIPE_PRICE_CREDITS_S',
|
||||||
|
'STRIPE_PRICE_CREDITS_M',
|
||||||
|
'STRIPE_PRICE_CREDITS_L',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const TIERS: TierType[] = ['BASIC', 'PRO', 'BUSINESS', 'ENTERPRISE']
|
const TIERS: TierType[] = ['BASIC', 'PRO', 'BUSINESS', 'ENTERPRISE']
|
||||||
@@ -54,7 +57,42 @@ export async function getBillingAdminData() {
|
|||||||
BILLING_CONFIG_KEYS.map((key) => [key, config[key] ?? '']),
|
BILLING_CONFIG_KEYS.map((key) => [key, config[key] ?? '']),
|
||||||
)
|
)
|
||||||
|
|
||||||
return { entitlements, billingConfig, usageOverview, features: [...VALID_FEATURES], tiers: TIERS }
|
const { CREDIT_ALLOCATIONS, CREDIT_COSTS, slideGenerateCreditCost } = await import('@/lib/credits')
|
||||||
|
const { CREDIT_PACKS, CREDIT_PACK_IDS } = await import('@/lib/billing/credit-packs')
|
||||||
|
|
||||||
|
const creditAllocations = TIERS.map((tier) => {
|
||||||
|
const raw = CREDIT_ALLOCATIONS[tier]
|
||||||
|
return {
|
||||||
|
tier,
|
||||||
|
monthlyCredits: raw === 'unlimited' ? null : raw,
|
||||||
|
unlimited: raw === 'unlimited',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const creditCosts = {
|
||||||
|
...CREDIT_COSTS,
|
||||||
|
slide_generate_example: slideGenerateCreditCost(7),
|
||||||
|
}
|
||||||
|
|
||||||
|
const creditPacks = CREDIT_PACK_IDS.map((id) => {
|
||||||
|
const p = CREDIT_PACKS[id]
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
credits: p.credits,
|
||||||
|
defaultDisplay: p.defaultDisplay,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
entitlements,
|
||||||
|
billingConfig,
|
||||||
|
usageOverview,
|
||||||
|
features: [...VALID_FEATURES],
|
||||||
|
tiers: TIERS,
|
||||||
|
creditAllocations,
|
||||||
|
creditCosts,
|
||||||
|
creditPacks,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePlanEntitlement(
|
export async function updatePlanEntitlement(
|
||||||
|
|||||||
@@ -42,24 +42,47 @@ export async function getSystemConfig() {
|
|||||||
return getCachedConfig()
|
return getCachedConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Keys that may be cleared (empty string) to fall back to chat / defaults. */
|
||||||
|
const CLEARABLE_CONFIG_KEYS = new Set([
|
||||||
|
'AI_PROVIDER_SLIDES',
|
||||||
|
'AI_MODEL_SLIDES',
|
||||||
|
])
|
||||||
|
|
||||||
export async function updateSystemConfig(data: Record<string, string>) {
|
export async function updateSystemConfig(data: Record<string, string>) {
|
||||||
await checkAdmin()
|
await checkAdmin()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Filter out empty values but keep 'false' as valid
|
// Filter out empty values but keep 'false' as valid.
|
||||||
const filteredData = Object.fromEntries(
|
// Slides provider/model may be empty intentionally (= use Chat fallback).
|
||||||
Object.entries(data).filter(([key, value]) => value !== '' && value !== null && value !== undefined)
|
const toUpsert: Record<string, string> = {}
|
||||||
)
|
const toDelete: string[] = []
|
||||||
|
|
||||||
const operations = Object.entries(filteredData).map(([key, value]) =>
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
if (value === null || value === undefined) continue
|
||||||
|
if (value === '' && CLEARABLE_CONFIG_KEYS.has(key)) {
|
||||||
|
toDelete.push(key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (value === '') continue
|
||||||
|
toUpsert[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
const operations = [
|
||||||
|
...Object.entries(toUpsert).map(([key, value]) =>
|
||||||
prisma.systemConfig.upsert({
|
prisma.systemConfig.upsert({
|
||||||
where: { key },
|
where: { key },
|
||||||
update: { value },
|
update: { value },
|
||||||
create: { key, value }
|
create: { key, value },
|
||||||
})
|
}),
|
||||||
)
|
),
|
||||||
|
...toDelete.map((key) =>
|
||||||
|
prisma.systemConfig.deleteMany({ where: { key } }),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (operations.length > 0) {
|
||||||
await prisma.$transaction(operations)
|
await prisma.$transaction(operations)
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -186,3 +186,60 @@ export async function updateUserSubscription(userId: string, tier: string) {
|
|||||||
throw new Error('Failed to update subscription')
|
throw new Error('Failed to update subscription')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remet à zéro les crédits IA + anciens compteurs feature du mois pour un utilisateur.
|
||||||
|
* @param feature (optionnel, legacy) — le reset crédits est global ; feature ignorée pour le solde.
|
||||||
|
*/
|
||||||
|
export async function resetUserQuotas(userId: string, feature?: string) {
|
||||||
|
const session = await checkAdmin()
|
||||||
|
|
||||||
|
if (!userId?.trim()) {
|
||||||
|
throw new Error('Identifiant utilisateur manquant')
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { id: true, email: true },
|
||||||
|
})
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('Utilisateur introuvable')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nouveau système : solde de crédits global
|
||||||
|
const { adminResetCredits } = await import('@/lib/credits')
|
||||||
|
const balance = await adminResetCredits(userId, { clearPurchased: false })
|
||||||
|
|
||||||
|
// Ancien système feature (compat / métriques)
|
||||||
|
const { resetUserUsageForCurrentPeriod } = await import('@/lib/entitlements')
|
||||||
|
const legacy = await resetUserUsageForCurrentPeriod(
|
||||||
|
userId,
|
||||||
|
feature ? { features: [feature] } : undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
const { logAuditEventAsync } = await import('@/lib/audit-log')
|
||||||
|
await logAuditEventAsync({
|
||||||
|
userId: session.user?.id,
|
||||||
|
action: 'QUOTA_RESET',
|
||||||
|
resource: userId,
|
||||||
|
metadata: {
|
||||||
|
targetUserId: userId,
|
||||||
|
targetEmail: user.email,
|
||||||
|
period: balance.period,
|
||||||
|
creditsRemaining: balance.unlimited ? 'unlimited' : balance.totalRemaining,
|
||||||
|
legacyFeaturesReset: legacy.featuresReset,
|
||||||
|
scope: feature || 'all',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/admin')
|
||||||
|
revalidatePath('/admin/users')
|
||||||
|
return {
|
||||||
|
success: true as const,
|
||||||
|
period: balance.period,
|
||||||
|
featuresReset: legacy.featuresReset,
|
||||||
|
redisDeleted: legacy.redisDeleted,
|
||||||
|
dbUpdated: legacy.dbUpdated,
|
||||||
|
creditsRemaining: balance.unlimited ? null : balance.totalRemaining,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
import { QuotaExceededError } from '@/lib/entitlements'
|
||||||
|
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
|
||||||
|
import { slideGenerateCreditCost, resolveCreditCost } from '@/lib/credits'
|
||||||
|
import type { FeatureName } from '@/lib/quota-utils'
|
||||||
import { logAuditEvent, getClientIp } from '@/lib/audit-log'
|
import { logAuditEvent, getClientIp } from '@/lib/audit-log'
|
||||||
|
import {
|
||||||
|
encodeSlideIntentDescription,
|
||||||
|
normalizeSlideIntent,
|
||||||
|
type SlideAudience,
|
||||||
|
type SlidePurpose,
|
||||||
|
} from '@/lib/ai/services/slide-intent'
|
||||||
|
|
||||||
type GenerateType = 'slide-generator' | 'excalidraw-generator'
|
type GenerateType = 'slide-generator' | 'excalidraw-generator'
|
||||||
|
|
||||||
@@ -12,9 +21,10 @@ const TYPE_DEFAULTS: Record<GenerateType, {
|
|||||||
maxSteps: number
|
maxSteps: number
|
||||||
}> = {
|
}> = {
|
||||||
'slide-generator': {
|
'slide-generator': {
|
||||||
role: 'Génère une présentation professionnelle à partir du contenu de la note fournie. Appelle generate_slides avec un objet JSON structuré {title, theme, slides:[...]}.',
|
// tools unused: slide-generator runs a structured-output pipeline (no function calling)
|
||||||
tools: ['note_search', 'note_read', 'generate_slides'],
|
role: 'Génère un deck exécutif de haute qualité (titres d\'action, une idée par slide, pas de filler, graphiques uniquement avec vrais chiffres).',
|
||||||
maxSteps: 6,
|
tools: [],
|
||||||
|
maxSteps: 1,
|
||||||
},
|
},
|
||||||
'excalidraw-generator': {
|
'excalidraw-generator': {
|
||||||
role: 'Génère un diagramme Excalidraw clair et professionnel à partir du contenu de la note fournie.',
|
role: 'Génère un diagramme Excalidraw clair et professionnel à partir du contenu de la note fournie.',
|
||||||
@@ -32,26 +42,46 @@ export async function POST(req: NextRequest) {
|
|||||||
const userId = session.user.id
|
const userId = session.user.id
|
||||||
|
|
||||||
const body = await req.json()
|
const body = await req.json()
|
||||||
const { noteId, type, theme, style, language, template } = body as {
|
const { noteId, type, theme, style, language, template, purpose, audience, slideCount } = body as {
|
||||||
noteId: string
|
noteId: string
|
||||||
type: GenerateType
|
type: GenerateType
|
||||||
theme?: string
|
theme?: string
|
||||||
style?: string
|
style?: string
|
||||||
language?: string
|
language?: string
|
||||||
template?: string
|
template?: string
|
||||||
|
purpose?: SlidePurpose
|
||||||
|
audience?: SlideAudience
|
||||||
|
slideCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!noteId || !type || !TYPE_DEFAULTS[type]) {
|
if (!noteId || !type || !TYPE_DEFAULTS[type]) {
|
||||||
return NextResponse.json({ error: 'Paramètres invalides' }, { status: 400 })
|
return NextResponse.json({ error: 'Paramètres invalides' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quota check — feature key depends on generation type
|
// Crédits globaux (respecte le BYOK) — slides = 1 + N
|
||||||
const featureKey = type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate'
|
const featureKey = (type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate') as FeatureName
|
||||||
|
const creditCost =
|
||||||
|
type === 'slide-generator'
|
||||||
|
? slideGenerateCreditCost(slideCount)
|
||||||
|
: resolveCreditCost(featureKey)
|
||||||
try {
|
try {
|
||||||
await reserveUsageOrThrow(userId, featureKey)
|
await reserveAiUsageOrThrow(userId, featureKey, {
|
||||||
|
amount: creditCost,
|
||||||
|
slideCount: type === 'slide-generator' ? slideCount : undefined,
|
||||||
|
metadata: { noteId, type, slideCount: slideCount ?? null },
|
||||||
|
lane: 'chat',
|
||||||
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof QuotaExceededError) {
|
if (e instanceof QuotaExceededError) {
|
||||||
return NextResponse.json({ error: e.message }, { status: 402 })
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: e.message,
|
||||||
|
code: 'QUOTA_EXCEEDED',
|
||||||
|
feature: featureKey,
|
||||||
|
creditCost,
|
||||||
|
},
|
||||||
|
{ status: 402 },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
@@ -71,25 +101,50 @@ export async function POST(req: NextRequest) {
|
|||||||
if (isEn) {
|
if (isEn) {
|
||||||
if (type === 'slide-generator') {
|
if (type === 'slide-generator') {
|
||||||
const recipeHint = (theme && theme !== 'auto') ? ` Use theme:"${theme}".` : ''
|
const recipeHint = (theme && theme !== 'auto') ? ` Use theme:"${theme}".` : ''
|
||||||
role = `Generate a professional presentation from the provided note content.${recipeHint} Call generate_slides with structured JSON {title, theme, slides:[...]}.`
|
role = `Generate a high-quality executive deck (action titles, one idea per slide, no filler, charts only with real numbers).${recipeHint}`
|
||||||
} else {
|
} else {
|
||||||
role = 'Generate a clear and professional Excalidraw diagram from the provided note content.'
|
role = 'Generate a clear and professional Excalidraw diagram from the provided note content.'
|
||||||
}
|
}
|
||||||
} else if (type === 'slide-generator') {
|
} else if (type === 'slide-generator') {
|
||||||
const recipeHint = (theme && theme !== 'auto') ? ` Utilise le thème "${theme}".` : ''
|
const recipeHint = (theme && theme !== 'auto') ? ` Utilise le thème "${theme}".` : ''
|
||||||
role = `Génère une présentation professionnelle à partir du contenu de la note fournie.${recipeHint} Appelle generate_slides avec le JSON structuré {title, theme, slides:[...]}.`
|
role = `Génère un deck exécutif de haute qualité (titres d'action, une idée par slide, pas de filler, graphiques uniquement avec vrais chiffres).${recipeHint}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const agentName = type === 'slide-generator'
|
const agentName = type === 'slide-generator'
|
||||||
? `${isEn ? 'Slides' : 'Présentation'} — ${(note.title || 'Note').substring(0, 40)}`
|
? `${isEn ? 'Slides' : 'Présentation'} — ${(note.title || 'Note').substring(0, 40)}`
|
||||||
: `${isEn ? 'Diagram' : 'Diagramme'} — ${(note.title || 'Note').substring(0, 40)}`
|
: `${isEn ? 'Diagram' : 'Diagramme'} — ${(note.title || 'Note').substring(0, 40)}`
|
||||||
|
|
||||||
|
const slideIntent =
|
||||||
|
type === 'slide-generator'
|
||||||
|
? normalizeSlideIntent({
|
||||||
|
purpose,
|
||||||
|
audience,
|
||||||
|
slideCount,
|
||||||
|
template: template || 'auto',
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
|
// Map purpose → legacy template when user picks intent without template
|
||||||
|
if (slideIntent && (!slideIntent.template || slideIntent.template === 'auto')) {
|
||||||
|
const purposeToTemplate: Record<string, string> = {
|
||||||
|
board: 'board-update',
|
||||||
|
project: 'project-status',
|
||||||
|
strategy: 'strategy-review',
|
||||||
|
}
|
||||||
|
if (slideIntent.purpose && purposeToTemplate[slideIntent.purpose]) {
|
||||||
|
slideIntent.template = purposeToTemplate[slideIntent.purpose]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const agent = await prisma.agent.create({
|
const agent = await prisma.agent.create({
|
||||||
data: {
|
data: {
|
||||||
name: agentName,
|
name: agentName,
|
||||||
type,
|
type,
|
||||||
role,
|
role,
|
||||||
description: (type === 'slide-generator' && template && template !== 'auto') ? `template:${template}` : undefined,
|
description:
|
||||||
|
type === 'slide-generator' && slideIntent
|
||||||
|
? encodeSlideIntentDescription(slideIntent)
|
||||||
|
: undefined,
|
||||||
tools: JSON.stringify(defaults.tools),
|
tools: JSON.stringify(defaults.tools),
|
||||||
maxSteps: defaults.maxSteps,
|
maxSteps: defaults.maxSteps,
|
||||||
frequency: 'one-shot',
|
frequency: 'one-shot',
|
||||||
@@ -104,8 +159,9 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
// ── Fire and forget — do NOT await so the HTTP response returns immediately ──
|
// ── Fire and forget — do NOT await so the HTTP response returns immediately ──
|
||||||
// In Node.js / Docker self-hosted, the process keeps running after response.
|
// In Node.js / Docker self-hosted, the process keeps running after response.
|
||||||
|
// skipQuota: units already reserved above (avoids double-charge in executeAgent).
|
||||||
import('@/lib/ai/services/agent-executor.service')
|
import('@/lib/ai/services/agent-executor.service')
|
||||||
.then(({ executeAgent }) => executeAgent(agent.id, userId))
|
.then(({ executeAgent }) => executeAgent(agent.id, userId, undefined, { skipQuota: true }))
|
||||||
.catch(err => console.error('[run-for-note] Background agent error:', err))
|
.catch(err => console.error('[run-for-note] Background agent error:', err))
|
||||||
|
|
||||||
logAuditEvent({
|
logAuditEvent({
|
||||||
|
|||||||
135
memento-note/app/api/billing/create-pack-checkout/route.ts
Normal file
135
memento-note/app/api/billing/create-pack-checkout/route.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
import { stripe } from '@/lib/stripe'
|
||||||
|
import { isBillingEnabled } from '@/lib/billing/stripe-prices'
|
||||||
|
import {
|
||||||
|
isCreditPackId,
|
||||||
|
resolvePackPriceId,
|
||||||
|
getCreditPack,
|
||||||
|
} from '@/lib/billing/credit-packs'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
packId: z.enum(['S', 'M', 'L']),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id || !session.user.email) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await isBillingEnabled())) {
|
||||||
|
return NextResponse.json({ error: 'Billing is not enabled' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = process.env.STRIPE_SECRET_KEY
|
||||||
|
if (!secret || secret === 'sk_test_placeholder') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Stripe is not configured (STRIPE_SECRET_KEY missing)' },
|
||||||
|
{ status: 503 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = bodySchema.safeParse(await req.json())
|
||||||
|
if (!parsed.success || !isCreditPackId(parsed.data.packId)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid pack' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const packId = parsed.data.packId
|
||||||
|
const pack = getCreditPack(packId)
|
||||||
|
const userId = session.user.id
|
||||||
|
const userEmail = session.user.email
|
||||||
|
|
||||||
|
try {
|
||||||
|
let priceId: string
|
||||||
|
try {
|
||||||
|
priceId = await resolvePackPriceId(packId)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[billing/create-pack-checkout] price resolve failed:', e)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Credit pack price IDs not configured. Set them in Admin > Billing.' },
|
||||||
|
{ status: 503 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priceId.startsWith('price_mock_')) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Credit pack price IDs not configured. Set them in Admin > Billing.' },
|
||||||
|
{ status: 503 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await prisma.subscription.findUnique({ where: { userId } })
|
||||||
|
let customerId = subscription?.stripeCustomerId ?? undefined
|
||||||
|
|
||||||
|
if (customerId && customerId.startsWith('cus_mock')) {
|
||||||
|
customerId = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customerId) {
|
||||||
|
const customer = await stripe.customers.create({
|
||||||
|
email: userEmail,
|
||||||
|
metadata: { userId },
|
||||||
|
})
|
||||||
|
customerId = customer.id
|
||||||
|
|
||||||
|
await prisma.subscription.upsert({
|
||||||
|
where: { userId },
|
||||||
|
update: { stripeCustomerId: customerId },
|
||||||
|
create: {
|
||||||
|
userId,
|
||||||
|
stripeCustomerId: customerId,
|
||||||
|
tier: 'BASIC',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
currentPeriodStart: new Date(),
|
||||||
|
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 3600 * 1000),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host') ?? 'localhost:3000'
|
||||||
|
const proto = req.headers.get('x-forwarded-proto') ?? 'http'
|
||||||
|
const origin = `${proto}://${host}`
|
||||||
|
|
||||||
|
const checkoutSession = await stripe.checkout.sessions.create({
|
||||||
|
customer: customerId,
|
||||||
|
mode: 'payment',
|
||||||
|
line_items: [{ price: priceId, quantity: 1 }],
|
||||||
|
success_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}&pack=1`,
|
||||||
|
cancel_url: `${origin}/settings/billing?canceled=1`,
|
||||||
|
metadata: {
|
||||||
|
userId,
|
||||||
|
type: 'credit_pack',
|
||||||
|
packId,
|
||||||
|
credits: String(pack.credits),
|
||||||
|
},
|
||||||
|
payment_intent_data: {
|
||||||
|
metadata: {
|
||||||
|
userId,
|
||||||
|
type: 'credit_pack',
|
||||||
|
packId,
|
||||||
|
credits: String(pack.credits),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
customer_update: { address: 'auto' },
|
||||||
|
allow_promotion_codes: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!checkoutSession.url) {
|
||||||
|
return NextResponse.json({ error: 'Checkout session has no URL' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
url: checkoutSession.url,
|
||||||
|
sessionId: checkoutSession.id,
|
||||||
|
packId,
|
||||||
|
credits: pack.credits,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[billing/create-pack-checkout]', error)
|
||||||
|
const msg = error instanceof Error ? error.message : 'Failed to create pack checkout'
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { auth } from '@/auth';
|
import { auth } from '@/auth';
|
||||||
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
|
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
|
||||||
import { stripe } from '@/lib/stripe';
|
import { stripe } from '@/lib/stripe';
|
||||||
import type Stripe from 'stripe';
|
|
||||||
import { priceIdToTier, getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices';
|
import { priceIdToTier, getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -76,6 +75,42 @@ export async function GET(req: NextRequest) {
|
|||||||
const subscription = await prisma.subscription.findUnique({ where: { userId } });
|
const subscription = await prisma.subscription.findUnique({ where: { userId } });
|
||||||
const prices = await getDynamicPrices();
|
const prices = await getDynamicPrices();
|
||||||
const billingEnabled = await isBillingEnabled();
|
const billingEnabled = await isBillingEnabled();
|
||||||
|
const { getPackPublicPrices } = await import('@/lib/billing/credit-packs');
|
||||||
|
const creditPacks = await getPackPublicPrices();
|
||||||
|
|
||||||
|
// Si retour checkout pack : créditer au cas où le webhook n'a pas encore tourné
|
||||||
|
if (sessionId && sessionId.startsWith('cs_')) {
|
||||||
|
try {
|
||||||
|
const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId);
|
||||||
|
if (
|
||||||
|
checkoutSession.mode === 'payment' &&
|
||||||
|
checkoutSession.status === 'complete' &&
|
||||||
|
(checkoutSession.metadata?.type === 'credit_pack' || checkoutSession.metadata?.packId) &&
|
||||||
|
checkoutSession.metadata?.userId === userId
|
||||||
|
) {
|
||||||
|
const { addPurchasedCredits } = await import('@/lib/credits');
|
||||||
|
const {
|
||||||
|
getCreditPack,
|
||||||
|
isCreditPackId,
|
||||||
|
} = await import('@/lib/billing/credit-packs');
|
||||||
|
const packId = checkoutSession.metadata?.packId;
|
||||||
|
let credits = Number(checkoutSession.metadata?.credits ?? 0);
|
||||||
|
if (packId && isCreditPackId(packId) && (!Number.isFinite(credits) || credits <= 0)) {
|
||||||
|
credits = getCreditPack(packId).credits;
|
||||||
|
}
|
||||||
|
if (Number.isFinite(credits) && credits > 0) {
|
||||||
|
await addPurchasedCredits(userId, credits, {
|
||||||
|
stripeSessionId: checkoutSession.id,
|
||||||
|
packId: packId ?? null,
|
||||||
|
type: 'credit_pack',
|
||||||
|
source: 'billing_status_sync',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (packSyncErr) {
|
||||||
|
console.error('[billing/status] pack sync failed:', packSyncErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
tier,
|
tier,
|
||||||
@@ -86,6 +121,7 @@ export async function GET(req: NextRequest) {
|
|||||||
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
|
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
|
||||||
hasStripeSubscription: !!subscription?.stripeSubscriptionId,
|
hasStripeSubscription: !!subscription?.stripeSubscriptionId,
|
||||||
prices,
|
prices,
|
||||||
|
creditPacks,
|
||||||
billingEnabled,
|
billingEnabled,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -6,11 +6,79 @@ import {
|
|||||||
handleSubscriptionDeleted,
|
handleSubscriptionDeleted,
|
||||||
resolveUserIdFromStripeEvent,
|
resolveUserIdFromStripeEvent,
|
||||||
} from '@/lib/billing/sync-subscription-from-stripe';
|
} from '@/lib/billing/sync-subscription-from-stripe';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { addPurchasedCredits } from '@/lib/credits';
|
||||||
|
import {
|
||||||
|
getCreditPack,
|
||||||
|
isCreditPackId,
|
||||||
|
resolvePackFromPriceId,
|
||||||
|
} from '@/lib/billing/credit-packs';
|
||||||
import type Stripe from 'stripe';
|
import type Stripe from 'stripe';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
async function fulfillCreditPackPurchase(session: Stripe.Checkout.Session) {
|
||||||
|
if (session.mode !== 'payment') return;
|
||||||
|
if (session.payment_status && session.payment_status !== 'paid' && session.payment_status !== 'no_payment_required') {
|
||||||
|
console.warn('[billing/webhook] pack checkout not paid yet', session.id, session.payment_status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.metadata?.userId as string | undefined;
|
||||||
|
if (!userId) {
|
||||||
|
console.warn('[billing/webhook] credit pack: no userId', session.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let packId = session.metadata?.packId as string | undefined;
|
||||||
|
let credits = Number(session.metadata?.credits ?? 0);
|
||||||
|
|
||||||
|
if ((!packId || !Number.isFinite(credits) || credits <= 0) && session.line_items == null) {
|
||||||
|
try {
|
||||||
|
const full = await stripe.checkout.sessions.retrieve(session.id, {
|
||||||
|
expand: ['line_items.data.price'],
|
||||||
|
});
|
||||||
|
const priceId = full.line_items?.data?.[0]?.price?.id;
|
||||||
|
if (priceId) {
|
||||||
|
const resolved = await resolvePackFromPriceId(priceId);
|
||||||
|
if (resolved) {
|
||||||
|
packId = resolved.packId;
|
||||||
|
credits = resolved.credits;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[billing/webhook] expand line_items failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packId && isCreditPackId(packId) && (!Number.isFinite(credits) || credits <= 0)) {
|
||||||
|
credits = getCreditPack(packId).credits;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(credits) || credits <= 0) {
|
||||||
|
console.warn('[billing/webhook] credit pack: invalid credits', session.id, { packId, credits });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await addPurchasedCredits(userId, credits, {
|
||||||
|
stripeSessionId: session.id,
|
||||||
|
packId: packId ?? null,
|
||||||
|
type: 'credit_pack',
|
||||||
|
paymentIntent:
|
||||||
|
typeof session.payment_intent === 'string'
|
||||||
|
? session.payment_intent
|
||||||
|
: session.payment_intent?.id ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.applied) {
|
||||||
|
console.info('[billing/webhook] credited pack', {
|
||||||
|
userId,
|
||||||
|
credits: result.units,
|
||||||
|
packId,
|
||||||
|
sessionId: session.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const body = await req.text();
|
const body = await req.text();
|
||||||
const headersList = await headers();
|
const headersList = await headers();
|
||||||
@@ -49,6 +117,11 @@ export async function POST(req: NextRequest) {
|
|||||||
} else {
|
} else {
|
||||||
console.warn('[billing/webhook] checkout.session.completed: no userId in metadata', session.id);
|
console.warn('[billing/webhook] checkout.session.completed: no userId in metadata', session.id);
|
||||||
}
|
}
|
||||||
|
} else if (
|
||||||
|
session.mode === 'payment' &&
|
||||||
|
(session.metadata?.type === 'credit_pack' || session.metadata?.packId)
|
||||||
|
) {
|
||||||
|
await fulfillCreditPackPurchase(session);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,48 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server'
|
||||||
import { auth } from '@/auth';
|
import { auth } from '@/auth'
|
||||||
import { getUserQuotas, getEffectiveTier } from '@/lib/entitlements';
|
import { getCreditUsageSummary, getQuotasCompatFromCredits } from '@/lib/credits'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const session = await auth();
|
const session = await auth()
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [quotas, tier] = await Promise.all([
|
const summary = await getCreditUsageSummary(session.user.id)
|
||||||
getUserQuotas(session.user.id),
|
// Compat anciens clients : quotas dérivés des crédits
|
||||||
getEffectiveTier(session.user.id),
|
const quotas = await getQuotasCompatFromCredits(session.user.id)
|
||||||
]);
|
|
||||||
return NextResponse.json({ quotas, tier });
|
return NextResponse.json({
|
||||||
|
tier: summary.tier,
|
||||||
|
period: summary.period,
|
||||||
|
balance: {
|
||||||
|
totalRemaining: summary.balance.unlimited
|
||||||
|
? null
|
||||||
|
: summary.balance.totalRemaining,
|
||||||
|
subscriptionRemaining: summary.balance.unlimited
|
||||||
|
? null
|
||||||
|
: summary.balance.subscriptionRemaining,
|
||||||
|
purchasedRemaining: summary.balance.purchasedRemaining,
|
||||||
|
subscriptionGranted: summary.balance.unlimited
|
||||||
|
? null
|
||||||
|
: summary.balance.subscriptionGranted,
|
||||||
|
subscriptionSpent: summary.balance.subscriptionSpent,
|
||||||
|
spentThisPeriod: summary.balance.spentThisPeriod,
|
||||||
|
unlimited: summary.balance.unlimited,
|
||||||
|
},
|
||||||
|
breakdown: summary.breakdown,
|
||||||
|
// legacy
|
||||||
|
quotas,
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[usage/current] Failed to fetch quotas:', error);
|
console.error('[usage/current] Failed to fetch usage data:', error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to fetch usage data' },
|
{ error: 'Failed to fetch usage data' },
|
||||||
{ status: 503 },
|
{ status: 503 },
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,21 @@
|
|||||||
@import "vazirmatn/Vazirmatn-font-face.css";
|
@import "vazirmatn/Vazirmatn-font-face.css";
|
||||||
@import "katex/dist/katex.min.css";
|
@import "katex/dist/katex.min.css";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
/* Inclure .dark lui-même + ses descendants (évite styles clairs manquants) */
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
/* Filet de sécurité anti-flash : fond sombre dès que html a .dark,
|
||||||
|
même si une frame Tailwind n’a pas encore appliqué dark: sur un enfant. */
|
||||||
|
html.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
background-color: #202020;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark body {
|
||||||
|
color-scheme: dark;
|
||||||
|
background-color: #202020;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Global UX fixes (UI/UX Pro Max audit) ────────────────────────────── */
|
/* ── Global UX fixes (UI/UX Pro Max audit) ────────────────────────────── */
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,17 @@ import { SessionProviderWrapper } from "@/components/session-provider-wrapper";
|
|||||||
import { getAISettings } from "@/app/actions/ai-settings";
|
import { getAISettings } from "@/app/actions/ai-settings";
|
||||||
import { getUserSettings } from "@/app/actions/user-settings";
|
import { getUserSettings } from "@/app/actions/user-settings";
|
||||||
import { ThemeInitializer } from "@/components/theme-initializer";
|
import { ThemeInitializer } from "@/components/theme-initializer";
|
||||||
|
import { ThemeClassGuard } from "@/components/theme-class-guard";
|
||||||
import { DirectionInitializer } from "@/components/direction-initializer";
|
import { DirectionInitializer } from "@/components/direction-initializer";
|
||||||
import { ErrorReporter } from "@/components/error-reporter";
|
import { ErrorReporter } from "@/components/error-reporter";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { CookieConsentRoot } from "@/components/legal/cookie-consent-root";
|
import { CookieConsentRoot } from "@/components/legal/cookie-consent-root";
|
||||||
import { LanguageProvider } from "@/lib/i18n/LanguageProvider";
|
import { LanguageProvider } from "@/lib/i18n/LanguageProvider";
|
||||||
import Script from "next/script";
|
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { normalizeThemeId } from "@/lib/apply-document-theme";
|
import { cookies } from "next/headers";
|
||||||
|
import { normalizeThemeId, THEME_COOKIE_NAME } from "@/lib/apply-document-theme";
|
||||||
|
import { THEME_INIT_SCRIPT, DIRECTION_INIT_SCRIPT } from "@/lib/inline-boot-scripts";
|
||||||
|
import { SwCleanup } from "@/components/sw-cleanup";
|
||||||
|
|
||||||
import { Inter, Manrope, Playfair_Display, JetBrains_Mono } from "next/font/google";
|
import { Inter, Manrope, Playfair_Display, JetBrains_Mono } from "next/font/google";
|
||||||
|
|
||||||
@@ -76,14 +79,20 @@ export default async function RootLayout({
|
|||||||
const session = await auth();
|
const session = await auth();
|
||||||
const userId = session?.user?.id;
|
const userId = session?.user?.id;
|
||||||
|
|
||||||
const [aiSettings, userSettings] = await Promise.all([
|
const [aiSettings, userSettings, cookieStore] = await Promise.all([
|
||||||
getAISettings(userId),
|
getAISettings(userId),
|
||||||
getUserSettings(userId),
|
getUserSettings(userId),
|
||||||
|
cookies(),
|
||||||
])
|
])
|
||||||
|
|
||||||
const htmlTheme = serverHtmlThemeState(userSettings.theme)
|
// Cookie = préférence immédiate (bascule barre latérale) ; base = profil utilisateur.
|
||||||
|
// Sans cookie, un dark seulement en localStorage était écrasé par le serveur → flash clair.
|
||||||
|
const cookieTheme = cookieStore.get(THEME_COOKIE_NAME)?.value
|
||||||
|
const resolvedTheme = normalizeThemeId(cookieTheme || userSettings.theme || 'light')
|
||||||
|
|
||||||
|
const htmlTheme = serverHtmlThemeState(resolvedTheme)
|
||||||
const serverAccent = userSettings.accentColor ?? '#A47148'
|
const serverAccent = userSettings.accentColor ?? '#A47148'
|
||||||
const serverTheme = normalizeThemeId(userSettings.theme || 'light')
|
const serverTheme = resolvedTheme
|
||||||
const htmlStyle = {
|
const htmlStyle = {
|
||||||
'--color-brand-accent': serverAccent,
|
'--color-brand-accent': serverAccent,
|
||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
@@ -97,14 +106,18 @@ export default async function RootLayout({
|
|||||||
data-server-accent={serverAccent}
|
data-server-accent={serverAccent}
|
||||||
style={htmlStyle}
|
style={htmlStyle}
|
||||||
>
|
>
|
||||||
<body className={`${inter.className} ${inter.variable} ${manrope.variable} ${playfair.variable} ${jetbrainsMono.variable} h-screen overflow-hidden`}>
|
<head>
|
||||||
<Script id="theme-init" src="/scripts/theme-init.js" strategy="beforeInteractive" />
|
{/* Scripts boot inline : anti-flash thème/RTL, sans next/script (React 19). */}
|
||||||
<Script id="direction-init" src="/scripts/direction-init.js" strategy="beforeInteractive" />
|
<script id="theme-init" dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
|
||||||
<Script id="sw-cleanup" src="/scripts/sw-cleanup.js" strategy="afterInteractive" />
|
<script id="direction-init" dangerouslySetInnerHTML={{ __html: DIRECTION_INIT_SCRIPT }} />
|
||||||
|
</head>
|
||||||
|
<body className={`${inter.className} ${inter.variable} ${manrope.variable} ${playfair.variable} ${jetbrainsMono.variable} h-screen overflow-hidden bg-background text-foreground`}>
|
||||||
<SessionProviderWrapper>
|
<SessionProviderWrapper>
|
||||||
|
<SwCleanup />
|
||||||
<ErrorReporter />
|
<ErrorReporter />
|
||||||
<DirectionInitializer />
|
<DirectionInitializer />
|
||||||
<ThemeInitializer theme={userSettings.theme} fontSize={aiSettings.fontSize} fontFamily={aiSettings.fontFamily} accentColor={userSettings.accentColor} />
|
<ThemeInitializer theme={resolvedTheme} fontSize={aiSettings.fontSize} fontFamily={aiSettings.fontFamily} accentColor={userSettings.accentColor} />
|
||||||
|
<ThemeClassGuard />
|
||||||
{children}
|
{children}
|
||||||
<LanguageProvider initialLanguage="en">
|
<LanguageProvider initialLanguage="en">
|
||||||
<CookieConsentRoot />
|
<CookieConsentRoot />
|
||||||
|
|||||||
@@ -406,7 +406,12 @@ export function BlockActionMenu({
|
|||||||
{/* Création de diagramme */}
|
{/* Création de diagramme */}
|
||||||
<button type="button" className="block-action-item" onClick={() => { void handleCreateDiagram() }}>
|
<button type="button" className="block-action-item" onClick={() => { void handleCreateDiagram() }}>
|
||||||
<Sparkles size={16} className="text-amber-500 transition-all duration-200" />
|
<Sparkles size={16} className="text-amber-500 transition-all duration-200" />
|
||||||
<span className="font-medium text-amber-700 dark:text-amber-400">{t('blockAction.createDiagram')}</span>
|
<span className="font-medium text-amber-700 dark:text-amber-400 flex flex-col items-start gap-0.5">
|
||||||
|
<span>{t('blockAction.createDiagram')}</span>
|
||||||
|
<span className="text-[9px] font-normal text-muted-foreground normal-case tracking-normal">
|
||||||
|
{t('blockAction.createDiagramCostHint')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="block-action-separator" />
|
<div className="block-action-separator" />
|
||||||
|
|||||||
@@ -229,6 +229,9 @@ export function ContextualAIChat({
|
|||||||
const [slideTheme, setSlideTheme] = useState('auto')
|
const [slideTheme, setSlideTheme] = useState('auto')
|
||||||
const [slideStyle, setSlideStyle] = useState('professional')
|
const [slideStyle, setSlideStyle] = useState('professional')
|
||||||
const [slideTemplate, setSlideTemplate] = useState('auto')
|
const [slideTemplate, setSlideTemplate] = useState('auto')
|
||||||
|
const [slidePurpose, setSlidePurpose] = useState('auto')
|
||||||
|
const [slideAudience, setSlideAudience] = useState('auto')
|
||||||
|
const [slideCount, setSlideCount] = useState('auto')
|
||||||
const [diagramType, setDiagramType] = useState('logic_flow')
|
const [diagramType, setDiagramType] = useState('logic_flow')
|
||||||
const [diagramStyle, setDiagramStyle] = useState('polished')
|
const [diagramStyle, setDiagramStyle] = useState('polished')
|
||||||
const [diagramEmbedLoading, setDiagramEmbedLoading] = useState(false)
|
const [diagramEmbedLoading, setDiagramEmbedLoading] = useState(false)
|
||||||
@@ -444,6 +447,12 @@ export function ContextualAIChat({
|
|||||||
style: type === 'slides' ? slideStyle : diagramStyle,
|
style: type === 'slides' ? slideStyle : diagramStyle,
|
||||||
language: language === 'fr' ? 'French' : 'English',
|
language: language === 'fr' ? 'French' : 'English',
|
||||||
template: type === 'slides' ? slideTemplate : undefined,
|
template: type === 'slides' ? slideTemplate : undefined,
|
||||||
|
purpose: type === 'slides' ? slidePurpose : undefined,
|
||||||
|
audience: type === 'slides' ? slideAudience : undefined,
|
||||||
|
slideCount:
|
||||||
|
type === 'slides' && slideCount !== 'auto'
|
||||||
|
? parseInt(slideCount, 10)
|
||||||
|
: undefined,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -1105,6 +1114,43 @@ export function ContextualAIChat({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.purpose') || 'Type de deck'}</span>
|
||||||
|
<select value={slidePurpose} onChange={e => setSlidePurpose(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
|
||||||
|
<option value="auto">{t('ai.generate.purposeAuto') || 'Auto (selon la note)'}</option>
|
||||||
|
<option value="course">{t('ai.generate.purposeCourse') || 'Cours / leçon'}</option>
|
||||||
|
<option value="board">{t('ai.generate.purposeBoard') || 'Comité / board'}</option>
|
||||||
|
<option value="project">{t('ai.generate.purposeProject') || 'Suivi projet'}</option>
|
||||||
|
<option value="strategy">{t('ai.generate.purposeStrategy') || 'Stratégie'}</option>
|
||||||
|
<option value="pitch">{t('ai.generate.purposePitch') || 'Pitch'}</option>
|
||||||
|
<option value="summary">{t('ai.generate.purposeSummary') || 'Synthèse courte'}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.audience') || 'Audience'}</span>
|
||||||
|
<select value={slideAudience} onChange={e => setSlideAudience(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
|
||||||
|
<option value="auto">{t('ai.generate.audienceAuto') || 'Auto'}</option>
|
||||||
|
<option value="student">{t('ai.generate.audienceStudent') || 'Étudiants'}</option>
|
||||||
|
<option value="board">{t('ai.generate.audienceBoard') || 'Direction'}</option>
|
||||||
|
<option value="team">{t('ai.generate.audienceTeam') || 'Équipe'}</option>
|
||||||
|
<option value="client">{t('ai.generate.audienceClient') || 'Client'}</option>
|
||||||
|
<option value="general">{t('ai.generate.audienceGeneral') || 'Grand public'}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.slideCount') || 'Nb de slides'}</span>
|
||||||
|
<select value={slideCount} onChange={e => setSlideCount(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
|
||||||
|
<option value="auto">{t('ai.generate.slideCountAuto') || 'Auto (selon longueur)'}</option>
|
||||||
|
<option value="4">4</option>
|
||||||
|
<option value="5">5</option>
|
||||||
|
<option value="6">6</option>
|
||||||
|
<option value="7">7</option>
|
||||||
|
<option value="8">8</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.template')}</span>
|
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.template')}</span>
|
||||||
<select value={slideTemplate} onChange={e => setSlideTemplate(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
|
<select value={slideTemplate} onChange={e => setSlideTemplate(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
|
||||||
@@ -1115,6 +1161,19 @@ export function ContextualAIChat({
|
|||||||
<option value="quarterly-results">{t('ai.generate.templateQuarterly')}</option>
|
<option value="quarterly-results">{t('ai.generate.templateQuarterly')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const n = slideCount === 'auto' ? 6 : Math.min(8, Math.max(3, parseInt(slideCount, 10) || 6))
|
||||||
|
const creditCost = 1 + n
|
||||||
|
return (
|
||||||
|
<p className="text-[10px] text-muted-foreground px-0.5 leading-relaxed">
|
||||||
|
{t('ai.generate.creditCostEstimate', {
|
||||||
|
cost: String(creditCost),
|
||||||
|
slides: String(n),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
<button onClick={() => handleGenerate('slides')} disabled={!!generateLoading} className="w-full py-3.5 bg-brand-accent text-white rounded-xl text-[10px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-brand-accent/20 uppercase tracking-[0.2em] disabled:opacity-50">
|
<button onClick={() => handleGenerate('slides')} disabled={!!generateLoading} className="w-full py-3.5 bg-brand-accent text-white rounded-xl text-[10px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-brand-accent/20 uppercase tracking-[0.2em] disabled:opacity-50">
|
||||||
{generateLoading === 'slides' ? <Loader2 size={14} className="animate-spin" /> : <><Presentation size={14} className="opacity-80" /> {t('ai.generating')}</>}
|
{generateLoading === 'slides' ? <Loader2 size={14} className="animate-spin" /> : <><Presentation size={14} className="opacity-80" /> {t('ai.generating')}</>}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -126,9 +126,12 @@ function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
|
|||||||
function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: string; canvasId?: string | null }) {
|
function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: string; canvasId?: string | null }) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||||
|
const stageRef = useRef<HTMLDivElement>(null)
|
||||||
const [currentSlide, setCurrentSlide] = useState(1)
|
const [currentSlide, setCurrentSlide] = useState(1)
|
||||||
const [isLoaded, setIsLoaded] = useState(!!data.spec) // spec → React renderer, no iframe loading
|
// Always preview the generated HTML (KaTeX, themes, density).
|
||||||
const totalSlides = data.slideCount || 1
|
// React SlidesRenderer used an older layout schema and looked broken vs HTML.
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false)
|
||||||
|
const totalSlides = data.slideCount || data.spec?.slides?.length || 1
|
||||||
|
|
||||||
// Hide the global AI floating button while slides are displayed
|
// Hide the global AI floating button while slides are displayed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -149,10 +152,36 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
|
|||||||
return () => window.removeEventListener('message', handler)
|
return () => window.removeEventListener('message', handler)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Fit a 16:9 stage into the available panel (letterbox, no crop)
|
||||||
|
useEffect(() => {
|
||||||
|
const stage = stageRef.current
|
||||||
|
if (!stage) return
|
||||||
|
const parent = stage.parentElement
|
||||||
|
if (!parent) return
|
||||||
|
|
||||||
|
const fit = () => {
|
||||||
|
const pw = parent.clientWidth
|
||||||
|
const ph = parent.clientHeight
|
||||||
|
if (pw < 32 || ph < 32) return
|
||||||
|
// Design frame 16:9 — pick max size that fits
|
||||||
|
const designW = 1280
|
||||||
|
const designH = 720
|
||||||
|
const scale = Math.min(pw / designW, ph / designH)
|
||||||
|
const w = Math.round(designW * scale)
|
||||||
|
const h = Math.round(designH * scale)
|
||||||
|
stage.style.width = `${w}px`
|
||||||
|
stage.style.height = `${h}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
fit()
|
||||||
|
const ro = new ResizeObserver(fit)
|
||||||
|
ro.observe(parent)
|
||||||
|
return () => ro.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
const navigate = (dir: number) => {
|
const navigate = (dir: number) => {
|
||||||
const iframe = iframeRef.current
|
const iframe = iframeRef.current
|
||||||
if (!iframe) return
|
if (!iframe) return
|
||||||
// Direct contentWindow access (works with allow-same-origin)
|
|
||||||
try {
|
try {
|
||||||
const win = iframe.contentWindow as any
|
const win = iframe.contentWindow as any
|
||||||
if (typeof win?.changeSlide === 'function') {
|
if (typeof win?.changeSlide === 'function') {
|
||||||
@@ -160,7 +189,6 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
// Fallback: postMessage
|
|
||||||
iframe.contentWindow?.postMessage({ type: 'navigate', dir }, '*')
|
iframe.contentWindow?.postMessage({ type: 'navigate', dir }, '*')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +211,9 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
|
|||||||
setTimeout(() => URL.revokeObjectURL(url), 5000)
|
setTimeout(() => URL.revokeObjectURL(url), 5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefer polished HTML; fall back to React only if html is missing
|
||||||
|
const useHtml = Boolean(data.html && data.html.length > 200)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 flex flex-col bg-zinc-950">
|
<div className="absolute inset-0 flex flex-col bg-zinc-950">
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
@@ -192,7 +223,7 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
|
|||||||
<span className="text-sm font-medium truncate">{name}</span>
|
<span className="text-sm font-medium truncate">{name}</span>
|
||||||
{totalSlides > 1 && (
|
{totalSlides > 1 && (
|
||||||
<span className="text-xs bg-white/10 px-2 py-0.5 rounded-full shrink-0 tabular-nums">
|
<span className="text-xs bg-white/10 px-2 py-0.5 rounded-full shrink-0 tabular-nums">
|
||||||
{currentSlide} / {totalSlides}
|
{currentSlide} / {totalSlides}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -226,19 +257,22 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Slides: React renderer (spec) or iframe (HTML fallback) */}
|
|
||||||
<div className="flex-1 relative overflow-hidden group">
|
{/* Stage: 16:9 letterboxed HTML (source of visual truth) */}
|
||||||
{data.spec ? (
|
<div className="flex-1 relative overflow-hidden group flex items-center justify-center bg-zinc-950 p-3 sm:p-4">
|
||||||
<SlidesRenderer spec={data.spec} />
|
{useHtml ? (
|
||||||
) : (
|
|
||||||
<>
|
<>
|
||||||
{/* Loading overlay — visible until iframe fires onLoad */}
|
|
||||||
{!isLoaded && (
|
{!isLoaded && (
|
||||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-zinc-950 gap-3">
|
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-zinc-950 gap-3">
|
||||||
<div className="w-8 h-8 rounded-full border-2 border-white/10 border-t-white/60 animate-spin" />
|
<div className="w-8 h-8 rounded-full border-2 border-white/10 border-t-white/60 animate-spin" />
|
||||||
<span className="text-xs text-white/30">{t('lab.loadingPresentation')}</span>
|
<span className="text-xs text-white/30">{t('lab.loadingPresentation')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div
|
||||||
|
ref={stageRef}
|
||||||
|
className="relative overflow-hidden rounded-lg shadow-2xl shadow-black/50 border border-white/10 bg-black"
|
||||||
|
style={{ width: '100%', maxWidth: '100%', aspectRatio: '16 / 9' }}
|
||||||
|
>
|
||||||
<iframe
|
<iframe
|
||||||
ref={iframeRef}
|
ref={iframeRef}
|
||||||
srcDoc={data.html}
|
srcDoc={data.html}
|
||||||
@@ -248,23 +282,28 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
|
|||||||
allow="fullscreen"
|
allow="fullscreen"
|
||||||
onLoad={() => setIsLoaded(true)}
|
onLoad={() => setIsLoaded(true)}
|
||||||
/>
|
/>
|
||||||
{/* Prev button */}
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/70 backdrop-blur-sm border border-white/10 text-white/40 hover:text-white transition-all opacity-0 group-hover:opacity-100"
|
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 w-10 h-10 flex items-center justify-center rounded-full bg-black/50 hover:bg-black/80 backdrop-blur-sm border border-white/10 text-white/50 hover:text-white transition-all opacity-0 group-hover:opacity-100"
|
||||||
aria-label={t('lab.previousSlide')}
|
aria-label={t('lab.previousSlide')}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-5 h-5" />
|
<ChevronLeft className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
{/* Next button */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(1)}
|
onClick={() => navigate(1)}
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/70 backdrop-blur-sm border border-white/10 text-white/40 hover:text-white transition-all opacity-0 group-hover:opacity-100"
|
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 w-10 h-10 flex items-center justify-center rounded-full bg-black/50 hover:bg-black/80 backdrop-blur-sm border border-white/10 text-white/50 hover:text-white transition-all opacity-0 group-hover:opacity-100"
|
||||||
aria-label={t('lab.nextSlide')}
|
aria-label={t('lab.nextSlide')}
|
||||||
>
|
>
|
||||||
<ChevronRight className="w-5 h-5" />
|
<ChevronRight className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
|
) : data.spec ? (
|
||||||
|
<div className="w-full h-full">
|
||||||
|
<SlidesRenderer spec={data.spec} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-white/40 text-sm">{t('lab.loadingPresentation')}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,31 +1,72 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { motion } from 'motion/react'
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
import {
|
import {
|
||||||
BrainCircuit, Search, MessageSquare, Zap, Cpu, Workflow,
|
ArrowRight, Menu, X, Check, BrainCircuit,
|
||||||
Globe, Shield, ArrowRight, Sparkles, Layers, Activity,
|
Network, GraduationCap, Bot, KeyRound, Globe, ChevronDown
|
||||||
Box
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useRouter } from 'next/navigation'
|
import Link from 'next/link'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { useState } from 'react'
|
import type { SupportedLanguage } from '@/lib/i18n/load-translations'
|
||||||
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
|
|
||||||
export function LandingPage() {
|
const ECHO_LINES = ['echo0', 'echo1', 'echo2'] as const
|
||||||
const { t } = useLanguage()
|
|
||||||
const router = useRouter()
|
|
||||||
const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly')
|
|
||||||
|
|
||||||
const AGENTS = [
|
const LANDING_LANGS: { code: SupportedLanguage; labelKey: string }[] = [
|
||||||
{ key: 'scraper', icon: <Globe size={24} /> },
|
{ code: 'fr', labelKey: 'languages.fr' },
|
||||||
{ key: 'researcher', icon: <Search size={24} /> },
|
{ code: 'en', labelKey: 'languages.en' },
|
||||||
{ key: 'slideGen', icon: <Layers size={24} /> },
|
{ code: 'es', labelKey: 'languages.es' },
|
||||||
{ key: 'monitor', icon: <Activity size={24} /> },
|
{ code: 'de', labelKey: 'languages.de' },
|
||||||
{ key: 'diagramGen', icon: <Box size={24} /> },
|
{ code: 'it', labelKey: 'languages.it' },
|
||||||
{ key: 'custom', icon: <Workflow size={24} /> },
|
{ code: 'pt', labelKey: 'languages.pt' },
|
||||||
|
{ code: 'nl', labelKey: 'languages.nl' },
|
||||||
|
{ code: 'pl', labelKey: 'languages.pl' },
|
||||||
|
{ code: 'ru', labelKey: 'languages.ru' },
|
||||||
|
{ code: 'zh', labelKey: 'languages.zh' },
|
||||||
|
{ code: 'ja', labelKey: 'languages.ja' },
|
||||||
|
{ code: 'ko', labelKey: 'languages.ko' },
|
||||||
|
{ code: 'ar', labelKey: 'languages.ar' },
|
||||||
|
{ code: 'fa', labelKey: 'languages.fa' },
|
||||||
|
{ code: 'hi', labelKey: 'languages.hi' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const BRAINSTORM_ITEMS = ['waveGeneration', 'collaboration', 'export']
|
export function LandingPage() {
|
||||||
|
const { t, language, setLanguage } = useLanguage()
|
||||||
|
const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly')
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
const [langOpen, setLangOpen] = useState(false)
|
||||||
|
const [echoIndex, setEchoIndex] = useState(0)
|
||||||
|
const langRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!langOpen) return
|
||||||
|
const onPointer = (e: MouseEvent) => {
|
||||||
|
if (langRef.current && !langRef.current.contains(e.target as Node)) setLangOpen(false)
|
||||||
|
}
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setLangOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onPointer)
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onPointer)
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
}
|
||||||
|
}, [langOpen])
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setEchoIndex((i) => (i + 1) % ECHO_LINES.length), 3200)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.querySelector<HTMLElement>('[data-public-scroll-root]')
|
||||||
|
if (!root) return
|
||||||
|
const prev = root.style.overflow
|
||||||
|
if (menuOpen) root.style.overflow = 'hidden'
|
||||||
|
else root.style.overflow = prev || ''
|
||||||
|
return () => { root.style.overflow = prev }
|
||||||
|
}, [menuOpen])
|
||||||
|
|
||||||
const PLANS = [
|
const PLANS = [
|
||||||
{ key: 'basic', popular: false, price: t('landing.pricing.basicPrice'), period: '' },
|
{ key: 'basic', popular: false, price: t('landing.pricing.basicPrice'), period: '' },
|
||||||
@@ -34,380 +75,556 @@ export function LandingPage() {
|
|||||||
{ key: 'enterprise', popular: false, price: billingInterval === 'monthly' ? '49,90€' : '39,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perUser') : t('landing.pricing.perUserAnnual') },
|
{ key: 'enterprise', popular: false, price: billingInterval === 'monthly' ? '49,90€' : '39,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perUser') : t('landing.pricing.perUserAnnual') },
|
||||||
]
|
]
|
||||||
|
|
||||||
const TECH_TIERS = [
|
const NAV = [
|
||||||
{ key: 'tags', color: 'bg-brand-accent' },
|
{ href: '#product', label: t('landing.nav.secondBrain') },
|
||||||
{ key: 'embeddings', color: 'bg-ochre' },
|
{ href: '#echo', label: t('landing.nav.echo') },
|
||||||
{ key: 'chatRag', color: 'bg-ink' },
|
{ href: '#agents', label: t('landing.nav.agents') },
|
||||||
|
{ href: '#pricing', label: t('landing.nav.pricing') },
|
||||||
]
|
]
|
||||||
|
|
||||||
const FOOTER_SECTIONS = ['product', 'community', 'legal'] as const
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-paper text-ink font-sans selection:bg-ochre/30 selection:text-ink">
|
<div className="min-h-screen bg-[#0B0A09] text-[#F4F1EA] font-[family-name:var(--font-manrope)] selection:bg-[#D4A373]/40 selection:text-white">
|
||||||
{/* Navigation */}
|
{/* Nav */}
|
||||||
<nav className="fixed top-0 left-0 right-0 z-[100] bg-paper/80 backdrop-blur-md border-b border-border px-4 sm:px-8 py-4 flex items-center justify-between">
|
<nav className="fixed top-0 left-0 right-0 z-[100] px-5 sm:px-8 py-4 flex items-center justify-between bg-[#0B0A09]/70 backdrop-blur-xl border-b border-white/[0.06]">
|
||||||
<div className="flex items-center gap-3">
|
<Link href="/" className="flex items-center gap-2.5 group">
|
||||||
<div className="w-10 h-10 bg-ink flex items-center justify-center rounded-xl shadow-lg rotate-3 group hover:rotate-0 transition-transform cursor-pointer">
|
<div className="w-9 h-9 bg-[#F4F1EA] text-[#0B0A09] flex items-center justify-center rounded-lg transition-transform group-hover:scale-105">
|
||||||
<span className="text-paper font-serif text-2xl font-bold">M</span>
|
<span className="font-serif text-xl font-bold leading-none">M</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-serif text-2xl font-medium tracking-tight">Memento</span>
|
<span className="font-serif text-xl font-medium tracking-tight text-[#F4F1EA]">Memento</span>
|
||||||
|
</Link>
|
||||||
|
<div className="hidden lg:flex items-center gap-8">
|
||||||
|
{NAV.map((l) => (
|
||||||
|
<a key={l.href} href={l.href} className="text-[13px] text-white/55 hover:text-white transition-colors">
|
||||||
|
{l.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
<div className="hidden md:flex items-center gap-10">
|
{/* Language switcher */}
|
||||||
<a href="#features" className="text-[11px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-colors">{t('landing.nav.features')}</a>
|
<div ref={langRef} className="relative">
|
||||||
<a href="#agents" className="text-[11px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-colors">{t('landing.nav.agents')}</a>
|
<button
|
||||||
<a href="#brainstorm" className="text-[11px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-colors">{t('landing.nav.brainstorm')}</a>
|
type="button"
|
||||||
<a href="#pricing" className="text-[11px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-colors">{t('landing.nav.pricing')}</a>
|
onClick={() => setLangOpen((o) => !o)}
|
||||||
<a href="#tech" className="text-[11px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-colors">{t('landing.nav.tech')}</a>
|
aria-expanded={langOpen}
|
||||||
</div>
|
aria-haspopup="listbox"
|
||||||
|
aria-label={t('landing.nav.language')}
|
||||||
<div className="flex items-center gap-4">
|
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-full border border-white/15 text-[12px] text-white/70 hover:text-white hover:border-white/30 transition-colors"
|
||||||
<button onClick={() => router.push('/login')} className="hidden md:block px-6 py-2.5 text-concrete hover:text-ink text-[11px] font-bold uppercase tracking-widest transition-colors">
|
>
|
||||||
{t('landing.nav.login')}
|
<Globe size={14} />
|
||||||
|
<span className="uppercase font-semibold tracking-wide">{language}</span>
|
||||||
|
<ChevronDown size={12} className={`opacity-60 transition-transform ${langOpen ? 'rotate-180' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => router.push('/register')} className="px-6 py-2.5 bg-ink text-paper rounded-full text-[11px] font-bold uppercase tracking-widest hover:opacity-90 transition-all flex items-center gap-2 group shadow-xl shadow-ink/10">
|
<AnimatePresence>
|
||||||
|
{langOpen && (
|
||||||
|
<motion.ul
|
||||||
|
role="listbox"
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 6 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="absolute end-0 mt-2 w-48 max-h-72 overflow-y-auto rounded-2xl border border-white/10 bg-[#141210] shadow-2xl py-1.5 z-[110]"
|
||||||
|
>
|
||||||
|
{LANDING_LANGS.map((lang) => (
|
||||||
|
<li key={lang.code} role="option" aria-selected={language === lang.code}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setLanguage(lang.code)
|
||||||
|
setLangOpen(false)
|
||||||
|
}}
|
||||||
|
className={`w-full text-start px-4 py-2.5 text-[13px] transition-colors ${
|
||||||
|
language === lang.code
|
||||||
|
? 'bg-[#D4A373]/15 text-[#D4A373]'
|
||||||
|
: 'text-white/70 hover:bg-white/5 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(lang.labelKey)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</motion.ul>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link href="/login" className="hidden sm:inline text-[13px] text-white/55 hover:text-white transition-colors px-2">
|
||||||
|
{t('landing.nav.login')}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="hidden sm:inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-[#F4F1EA] text-[#0B0A09] text-[13px] font-semibold hover:bg-white transition-colors"
|
||||||
|
>
|
||||||
{t('landing.nav.cta')}
|
{t('landing.nav.cta')}
|
||||||
<ArrowRight size={14} className="group-hover:translate-x-1 transition-transform" />
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={menuOpen ? t('landing.nav.closeMenu') : t('landing.nav.openMenu')}
|
||||||
|
onClick={() => setMenuOpen((o) => !o)}
|
||||||
|
className="lg:hidden w-10 h-10 rounded-full border border-white/15 flex items-center justify-center text-white/80"
|
||||||
|
>
|
||||||
|
{menuOpen ? <X size={18} /> : <Menu size={18} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Hero */}
|
<AnimatePresence>
|
||||||
<section className="relative pt-40 pb-32 px-8 overflow-hidden">
|
{menuOpen && (
|
||||||
<div className="absolute top-0 right-0 w-[800px] h-[800px] bg-ochre/5 rounded-full blur-[120px] -translate-y-1/2 translate-x-1/4 -z-10" />
|
<motion.div
|
||||||
<div className="absolute bottom-0 left-0 w-[600px] h-[600px] bg-brand-accent/5 rounded-full blur-[100px] translate-y-1/2 -translate-x-1/4 -z-10" />
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
<div className="max-w-6xl mx-auto text-center">
|
exit={{ opacity: 0 }}
|
||||||
<motion.div initial={{ opacity: 0, y: 30 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease: [0.23, 1, 0.32, 1] }}>
|
className="fixed inset-0 z-[99] bg-[#0B0A09] pt-24 px-8 lg:hidden"
|
||||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-ochre/10 border border-ochre/20 text-ochre text-[10px] font-bold uppercase tracking-[0.2em] mb-8">
|
>
|
||||||
<Sparkles size={12} />
|
<div className="flex flex-col gap-1">
|
||||||
{t('landing.hero.badge')}
|
{NAV.map((l) => (
|
||||||
|
<a
|
||||||
|
key={l.href}
|
||||||
|
href={l.href}
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
className="py-4 text-3xl font-serif border-b border-white/10"
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
<Link href="/register" onClick={() => setMenuOpen(false)} className="mt-10 py-4 rounded-2xl bg-[#F4F1EA] text-[#0B0A09] text-center font-semibold">
|
||||||
|
{t('landing.nav.cta')}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-6xl md:text-8xl font-serif font-medium tracking-tight text-ink mb-8 leading-[1.1]">
|
</motion.div>
|
||||||
{t('landing.hero.title1')} <br />
|
)}
|
||||||
<span className="italic">{t('landing.hero.title2')}</span>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* ── HERO ── */}
|
||||||
|
<section className="relative min-h-[100dvh] flex flex-col justify-center pt-28 pb-16 px-5 sm:px-8 overflow-hidden">
|
||||||
|
{/* Atmosphere — warm, not purple neon */}
|
||||||
|
<div className="pointer-events-none absolute inset-0">
|
||||||
|
<div className="absolute -top-32 left-1/2 -translate-x-1/2 w-[900px] h-[500px] bg-[#D4A373]/15 blur-[120px] rounded-full" />
|
||||||
|
<div className="absolute bottom-0 right-0 w-[500px] h-[400px] bg-[#A47148]/10 blur-[100px] rounded-full" />
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-[0.07]"
|
||||||
|
style={{
|
||||||
|
backgroundImage: 'radial-gradient(circle at 1px 1px, #F4F1EA 1px, transparent 0)',
|
||||||
|
backgroundSize: '28px 28px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 max-w-5xl mx-auto w-full text-center">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full border border-[#D4A373]/35 bg-[#D4A373]/10 mb-7">
|
||||||
|
<BrainCircuit size={14} className="text-[#D4A373]" />
|
||||||
|
<span className="text-[12px] tracking-[0.18em] uppercase text-[#D4A373] font-semibold">
|
||||||
|
{t('landing.hero.badge')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[13px] tracking-[0.12em] uppercase text-white/40 mb-5 font-medium">
|
||||||
|
{t('landing.hero.eyebrow')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1 className="font-serif text-[clamp(2.5rem,7.5vw,5.4rem)] font-medium tracking-tight leading-[1.05] text-[#F4F1EA] mb-6">
|
||||||
|
{t('landing.hero.headline')}
|
||||||
|
<br />
|
||||||
|
<span className="italic text-[#D4A373]">{t('landing.hero.headlineAccent')}</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-2xl mx-auto text-lg md:text-xl text-concrete font-light leading-relaxed mb-12">
|
|
||||||
|
<p className="max-w-xl mx-auto text-[17px] sm:text-lg text-white/55 leading-relaxed mb-10">
|
||||||
{t('landing.hero.subtitle')}
|
{t('landing.hero.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<button onClick={() => router.push('/register')} className="px-10 py-5 bg-ink text-paper rounded-2xl text-sm font-bold uppercase tracking-[0.2em] hover:opacity-95 transition-all shadow-2xl shadow-ink/20 flex items-center gap-4 group">
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="group inline-flex items-center justify-center gap-3 px-9 py-4 rounded-full bg-[#F4F1EA] text-[#0B0A09] text-[15px] font-semibold hover:bg-white transition-all hover:scale-[1.02] shadow-[0_0_40px_-8px_rgba(212,163,115,0.45)]"
|
||||||
|
>
|
||||||
{t('landing.hero.cta')}
|
{t('landing.hero.cta')}
|
||||||
<ArrowRight size={18} className="group-hover:translate-x-1 transition-transform" />
|
<ArrowRight size={18} className="transition-transform group-hover:translate-x-0.5" />
|
||||||
</button>
|
</Link>
|
||||||
<a href="#features" className="px-10 py-5 border border-border rounded-2xl text-sm font-bold uppercase tracking-[0.2em] hover:bg-slate-50 transition-all">
|
<p className="text-[12px] text-white/35">{t('landing.hero.ctaHint')}</p>
|
||||||
{t('landing.hero.secondary')}
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* App Preview Mockup */}
|
|
||||||
<motion.div initial={{ opacity: 0, y: 100 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 1, delay: 0.2, ease: [0.23, 1, 0.32, 1] }} className="mt-24 relative">
|
|
||||||
<div className="relative mx-auto max-w-5xl aspect-[16/10] bg-white rounded-[32px] shadow-[0_40px_100px_-20px_rgba(0,0,0,0.15)] border border-border p-4 overflow-hidden group">
|
|
||||||
<Image src="/images/workspace-hero.jpg" alt="Memento Workspace" width={1200} height={750} className="w-full h-full object-cover rounded-2xl filter saturate-[0.8]" priority />
|
|
||||||
<div className="absolute inset-0 bg-ink/10 group-hover:bg-ink/0 transition-colors duration-500" />
|
|
||||||
|
|
||||||
<div className="absolute top-10 right-10 w-64 bg-paper/90 backdrop-blur-xl border border-border p-6 rounded-2xl shadow-2xl">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<div className="w-8 h-8 rounded-full bg-brand-accent/20 flex items-center justify-center text-brand-accent">
|
|
||||||
<BrainCircuit size={16} />
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest">{t('landing.hero.memoryEcho')}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs font-serif italic text-ink/70">{t('landing.hero.memoryEchoText')}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute bottom-10 left-10 w-72 bg-ink text-paper p-6 rounded-2xl shadow-2xl">
|
{/* Product stage */}
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="relative z-10 mt-14 sm:mt-20 max-w-5xl mx-auto w-full">
|
||||||
<Activity size={16} className="text-ochre" />
|
<div className="relative rounded-[20px] sm:rounded-[28px] overflow-hidden border border-white/10 shadow-[0_40px_100px_-20px_rgba(0,0,0,0.7)]">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-ochre">{t('landing.hero.brainstormLive')}</span>
|
<div className="absolute inset-0 bg-gradient-to-t from-[#0B0A09] via-transparent to-transparent z-10 pointer-events-none" />
|
||||||
|
<Image
|
||||||
|
src="/images/workspace-hero.jpg"
|
||||||
|
alt={t('landing.hero.imageAlt')}
|
||||||
|
width={2000}
|
||||||
|
height={1200}
|
||||||
|
priority
|
||||||
|
className="w-full h-auto object-cover aspect-[16/10] opacity-90"
|
||||||
|
/>
|
||||||
|
{/* Live Memory Echo chip */}
|
||||||
|
<div className="absolute bottom-5 left-5 right-5 sm:bottom-8 sm:left-8 sm:right-auto sm:max-w-md z-20">
|
||||||
|
<div className="rounded-2xl border border-white/15 bg-[#0B0A09]/85 backdrop-blur-xl p-4 sm:p-5 text-left shadow-2xl">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#D4A373] opacity-60" />
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-[#D4A373]" />
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[#D4A373]">
|
||||||
|
{t('landing.hero.echoLive')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center -space-x-2">
|
<AnimatePresence mode="wait">
|
||||||
{[1, 2, 3].map(i => (
|
<motion.p
|
||||||
<div key={i} className="w-6 h-6 rounded-full border-2 border-ink bg-concrete text-[8px] flex items-center justify-center font-bold">JD</div>
|
key={ECHO_LINES[echoIndex]}
|
||||||
))}
|
initial={{ opacity: 0, y: 8 }}
|
||||||
<span className="text-[10px] ml-4 text-paper/60">{t('landing.hero.ideasGenerated')}</span>
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -8 }}
|
||||||
|
transition={{ duration: 0.35 }}
|
||||||
|
className="text-sm sm:text-[15px] font-serif italic text-[#F4F1EA]/90 leading-relaxed"
|
||||||
|
>
|
||||||
|
{t(`landing.hero.${ECHO_LINES[echoIndex]}`)}
|
||||||
|
</motion.p>
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Features */}
|
{/* Trust strip */}
|
||||||
<section id="features" className="py-32 px-8 bg-paper">
|
<section className="px-5 sm:px-8 py-10 border-y border-white/[0.06]">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-5xl mx-auto flex flex-wrap items-center justify-center gap-x-10 gap-y-4 text-[12px] sm:text-[13px] text-white/40 tracking-wide">
|
||||||
<div className="mb-24 flex flex-col md:flex-row md:items-end justify-between gap-8">
|
{['trust0', 'trust1', 'trust2', 'trust3'].map((k) => (
|
||||||
<div className="max-w-2xl">
|
<span key={k} className="flex items-center gap-2">
|
||||||
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.features.label')}</span>
|
<Check size={14} className="text-[#D4A373]" />
|
||||||
<h2 className="text-4xl md:text-5xl font-serif tracking-tight text-ink">{t('landing.features.title')} <br />{t('landing.features.title2')}</h2>
|
{t(`landing.trust.${k}`)}
|
||||||
</div>
|
</span>
|
||||||
<div className="text-concrete font-light">{t('landing.features.desc')}</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
|
{/* Pain → Promise */}
|
||||||
{['f1', 'f2', 'f3'].map((f, i) => {
|
<section className="px-5 sm:px-8 py-24 sm:py-32">
|
||||||
const icons = [
|
<div className="max-w-3xl mx-auto text-center">
|
||||||
<Search key="s" className="text-brand-accent" />,
|
<p className="text-[12px] uppercase tracking-[0.25em] text-[#D4A373] mb-5 font-medium">{t('landing.pain.label')}</p>
|
||||||
<MessageSquare key="m" className="text-ochre" />,
|
<h2 className="font-serif text-3xl sm:text-5xl tracking-tight leading-[1.15] mb-6">
|
||||||
<Zap key="z" className="text-ink" />
|
{t('landing.pain.title')}
|
||||||
]
|
</h2>
|
||||||
return (
|
<p className="text-lg text-white/50 leading-relaxed mb-8">{t('landing.pain.desc')}</p>
|
||||||
<div key={f} className="group">
|
<p className="text-base sm:text-lg font-serif italic text-[#D4A373]/90 leading-relaxed">
|
||||||
<div className="w-14 h-14 bg-slate-50 border border-border rounded-2xl flex items-center justify-center mb-8 group-hover:bg-ink group-hover:text-paper transition-all duration-300">
|
{t('landing.pain.secondBrain')}
|
||||||
{icons[i]}
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-serif font-medium mb-4">{t(`landing.features.${f}Title`)}</h3>
|
</section>
|
||||||
<p className="text-sm text-concrete leading-relaxed font-light">{t(`landing.features.${f}Desc`)}</p>
|
|
||||||
|
{/* Product moments — large narrative blocks, not wireframe cards */}
|
||||||
|
<section id="product" className="px-5 sm:px-8 pb-8">
|
||||||
|
<div className="max-w-6xl mx-auto space-y-4">
|
||||||
|
<ProductMoment
|
||||||
|
id="echo"
|
||||||
|
eyebrow={t('landing.moments.echo.eyebrow')}
|
||||||
|
title={t('landing.moments.echo.title')}
|
||||||
|
desc={t('landing.moments.echo.desc')}
|
||||||
|
dark
|
||||||
|
>
|
||||||
|
<div className="space-y-3 p-2">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`rounded-xl border p-4 ${i === 0 ? 'border-[#D4A373]/40 bg-[#D4A373]/10' : 'border-white/10 bg-white/[0.03]'}`}
|
||||||
|
>
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-[#D4A373] mb-1">
|
||||||
|
{t(`landing.moments.echo.card${i}Label`)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-white/75 font-serif italic leading-relaxed">
|
||||||
|
{t(`landing.moments.echo.card${i}`)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
))}
|
||||||
})}
|
</div>
|
||||||
|
</ProductMoment>
|
||||||
|
|
||||||
|
<ProductMoment
|
||||||
|
eyebrow={t('landing.moments.dashboard.eyebrow')}
|
||||||
|
title={t('landing.moments.dashboard.title')}
|
||||||
|
desc={t('landing.moments.dashboard.desc')}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-2 p-2">
|
||||||
|
{['w0', 'w1', 'w2', 'w3'].map((w) => (
|
||||||
|
<div key={w} className="rounded-xl bg-[#0B0A09]/40 border border-black/10 p-4 min-h-[88px]">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-[#A47148] mb-2">{t(`landing.moments.dashboard.${w}Label`)}</p>
|
||||||
|
<p className="text-sm font-medium text-[#0B0A09]/80">{t(`landing.moments.dashboard.${w}`)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ProductMoment>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<ProductMoment
|
||||||
|
compact
|
||||||
|
eyebrow={t('landing.moments.insights.eyebrow')}
|
||||||
|
title={t('landing.moments.insights.title')}
|
||||||
|
desc={t('landing.moments.insights.desc')}
|
||||||
|
dark
|
||||||
|
>
|
||||||
|
<div className="relative h-36 flex items-center justify-center">
|
||||||
|
<Network className="text-[#D4A373]/80" size={48} strokeWidth={1.25} />
|
||||||
|
<p className="absolute bottom-2 left-4 text-[11px] uppercase tracking-wider text-white/35">
|
||||||
|
{t('landing.moments.insights.chip')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ProductMoment>
|
||||||
|
<ProductMoment
|
||||||
|
compact
|
||||||
|
eyebrow={t('landing.moments.revision.eyebrow')}
|
||||||
|
title={t('landing.moments.revision.title')}
|
||||||
|
desc={t('landing.moments.revision.desc')}
|
||||||
|
>
|
||||||
|
<div className="rounded-xl bg-white border border-black/5 p-5 mx-2 mb-2">
|
||||||
|
<GraduationCap className="text-[#A47148] mb-3" size={22} />
|
||||||
|
<p className="font-serif text-[#0B0A09] text-base mb-3">{t('landing.moments.revision.card')}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="flex-1 py-2 rounded-lg bg-[#0B0A09]/5 text-center text-[11px] font-semibold text-[#0B0A09]/50">?</span>
|
||||||
|
<span className="flex-1 py-2 rounded-lg bg-[#0B0A09] text-center text-[11px] font-semibold text-[#F4F1EA]">SM-2</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ProductMoment>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* How it works */}
|
||||||
|
<section className="px-5 sm:px-8 py-28">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<h2 className="font-serif text-3xl sm:text-4xl tracking-tight text-center mb-16">
|
||||||
|
{t('landing.how.title')}
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-10 md:gap-6">
|
||||||
|
{['s0', 's1', 's2'].map((s, i) => (
|
||||||
|
<div key={s} className="relative text-center md:text-left">
|
||||||
|
<div className="text-[13px] font-semibold text-[#D4A373] mb-4 tracking-widest">0{i + 1}</div>
|
||||||
|
<h3 className="font-serif text-xl mb-3">{t(`landing.how.${s}.title`)}</h3>
|
||||||
|
<p className="text-sm text-white/45 leading-relaxed">{t(`landing.how.${s}.desc`)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Agents */}
|
{/* Agents */}
|
||||||
<section id="agents" className="py-32 px-8 bg-ink text-paper overflow-hidden relative">
|
<section id="agents" className="px-5 sm:px-8 py-28 border-t border-white/[0.06]">
|
||||||
<div className="absolute top-0 right-0 w-[1000px] h-[1000px] bg-brand-accent/10 rounded-full blur-[150px] -translate-y-1/2 translate-x-1/2" />
|
<div className="max-w-5xl mx-auto">
|
||||||
<div className="max-w-6xl mx-auto relative z-10">
|
<div className="max-w-2xl mb-14">
|
||||||
<div className="text-center mb-24">
|
<p className="text-[12px] uppercase tracking-[0.25em] text-[#D4A373] mb-4 font-medium">{t('landing.agents.label')}</p>
|
||||||
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.agents.label')}</span>
|
<h2 className="font-serif text-3xl sm:text-5xl tracking-tight mb-5">{t('landing.agents.title')}</h2>
|
||||||
<h2 className="text-4xl md:text-6xl font-serif tracking-tight mb-8">{t('landing.agents.title')}</h2>
|
<p className="text-white/50 text-lg leading-relaxed">{t('landing.agents.desc')}</p>
|
||||||
<p className="text-paper/60 max-w-xl mx-auto font-light">{t('landing.agents.desc')}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
{(['scraper', 'researcher', 'slideGen', 'monitor', 'diagramGen', 'custom'] as const).map((key) => (
|
||||||
{AGENTS.map((agent, i) => (
|
<div key={key} className="p-6 rounded-2xl border border-white/[0.08] bg-white/[0.03] hover:bg-white/[0.06] transition-colors">
|
||||||
<div key={i} className="p-8 rounded-3xl bg-white/5 border border-white/10 hover:bg-white/10 transition-all cursor-default group">
|
<Bot size={18} className="text-[#D4A373] mb-4" />
|
||||||
<div className="text-ochre mb-6 transition-transform group-hover:scale-110 duration-300">{agent.icon}</div>
|
<h4 className="font-serif text-lg mb-2">{t(`landing.agents.${key}.title`)}</h4>
|
||||||
<h4 className="text-xl font-serif font-medium mb-4">{t(`landing.agents.${agent.key}.title`)}</h4>
|
<p className="text-sm text-white/40 leading-relaxed">{t(`landing.agents.${key}.desc`)}</p>
|
||||||
<p className="text-sm text-paper/50 leading-relaxed font-light">{t(`landing.agents.${agent.key}.desc`)}</p>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Brainstorm */}
|
{/* BYOK */}
|
||||||
<section id="brainstorm" className="py-32 px-8 bg-paper">
|
<section className="px-5 sm:px-8 py-20">
|
||||||
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center gap-24">
|
<div className="max-w-5xl mx-auto rounded-[28px] border border-white/10 bg-gradient-to-br from-[#1a1612] to-[#0B0A09] p-8 sm:p-12 flex flex-col md:flex-row gap-10 items-start">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.brainstorm.label')}</span>
|
<div className="flex items-center gap-2 text-[#D4A373] mb-4">
|
||||||
<h2 className="text-4xl md:text-5xl font-serif tracking-tight text-ink mb-8 leading-tight">{t('landing.brainstorm.title')}</h2>
|
<KeyRound size={18} />
|
||||||
<div className="space-y-8">
|
<span className="text-[12px] uppercase tracking-[0.2em] font-semibold">{t('landing.byok.label')}</span>
|
||||||
{BRAINSTORM_ITEMS.map((item, i) => (
|
|
||||||
<div key={item} className="flex gap-6">
|
|
||||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-ochre/10 text-ochre flex items-center justify-center font-bold text-xs">{i + 1}</div>
|
|
||||||
<div>
|
|
||||||
<h5 className="font-bold text-sm mb-1">{t(`landing.brainstorm.${item}.title`)}</h5>
|
|
||||||
<p className="text-sm text-concrete font-light leading-relaxed">{t(`landing.brainstorm.${item}.desc`)}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h3 className="font-serif text-3xl tracking-tight mb-4">{t('landing.byok.title')}</h3>
|
||||||
|
<p className="text-white/50 leading-relaxed">{t('landing.byok.desc')}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="w-full md:w-[320px] font-mono text-[11px] text-white/35 space-y-1.5 rounded-2xl border border-white/10 bg-black/40 p-5">
|
||||||
</div>
|
<p className="text-[#D4A373]">{'{'}</p>
|
||||||
</div>
|
<p className="pl-3">"provider": "anthropic",</p>
|
||||||
<div className="flex-1 relative">
|
<p className="pl-3">"model": "claude-sonnet",</p>
|
||||||
<div className="w-[450px] h-[450px] border-2 border-dashed border-border rounded-full flex items-center justify-center relative">
|
<p className="pl-3 text-[#F4F1EA]/70">"apiKey": "sk-ant-…",</p>
|
||||||
<div className="absolute top-0 right-1/2 translate-x-1/2 -translate-y-1/2 w-4 h-4 bg-ink rounded-full" />
|
<p className="pl-3">"yours": true</p>
|
||||||
<div className="absolute bottom-0 right-1/2 translate-x-1/2 translate-y-1/2 w-4 h-4 bg-ochre rounded-full" />
|
<p className="text-[#D4A373]">{'}'}</p>
|
||||||
<div className="w-[300px] h-[300px] border-2 border-dashed border-border rounded-full flex items-center justify-center">
|
|
||||||
<div className="w-[150px] h-[150px] border-2 border-dashed border-border rounded-full flex items-center justify-center">
|
|
||||||
<div className="w-12 h-12 bg-ink rounded-xl shadow-2xl flex items-center justify-center text-paper font-serif text-xl">M</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="absolute top-10 right-0 p-4 bg-white border border-border rounded-xl shadow-xl">
|
|
||||||
<p className="text-[10px] font-bold text-ochre">{t('landing.brainstorm.disruptionLabel')}</p>
|
|
||||||
<p className="text-xs font-serif italic text-ink">{t('landing.brainstorm.disruptionText')}</p>
|
|
||||||
</div>
|
|
||||||
<div className="absolute bottom-20 -left-10 p-4 bg-white border border-border rounded-xl shadow-xl">
|
|
||||||
<p className="text-[10px] font-bold text-brand-accent">{t('landing.brainstorm.analogyLabel')}</p>
|
|
||||||
<p className="text-xs font-serif italic text-ink">{t('landing.brainstorm.analogyText')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Tech */}
|
|
||||||
<section id="tech" className="py-32 px-8 bg-slate-50 border-y border-border">
|
|
||||||
<div className="max-w-6xl mx-auto text-center">
|
|
||||||
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.tech.label')}</span>
|
|
||||||
<h2 className="text-4xl font-serif tracking-tight mb-16">{t('landing.tech.title')}</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-8 grayscale opacity-50 hover:grayscale-0 hover:opacity-100 transition-all duration-700">
|
|
||||||
{['OpenAI', 'Google', 'Anthropic', 'DeepSeek', 'Mistral', 'Meta', 'Ollama', 'Groq', 'X.AI', 'Custom'].map((brand, i) => (
|
|
||||||
<div key={i} className="flex flex-col items-center gap-3">
|
|
||||||
<div className="w-12 h-12 bg-white rounded-xl border border-border flex items-center justify-center text-xs font-black tracking-tighter">
|
|
||||||
{brand.slice(0, 2).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest">{brand}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-24 max-w-2xl mx-auto p-1 bg-white rounded-3xl border border-border shadow-sm flex flex-col md:flex-row gap-0.5">
|
|
||||||
{TECH_TIERS.map((tier, i) => (
|
|
||||||
<div key={i} className="flex-1 p-6 text-left">
|
|
||||||
<div className={`w-1.5 h-1.5 rounded-full ${tier.color} mb-4`} />
|
|
||||||
<h6 className="text-[10px] font-bold uppercase tracking-widest text-concrete mb-2">{t(`landing.tech.${tier.key}.title`)}</h6>
|
|
||||||
<p className="text-xs font-light text-concrete">{t(`landing.tech.${tier.key}.desc`)}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Pricing */}
|
{/* Pricing */}
|
||||||
<section id="pricing" className="py-32 px-8 bg-paper">
|
<section id="pricing" className="px-5 sm:px-8 py-28 border-t border-white/[0.06]">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.pricing.label')}</span>
|
<h2 className="font-serif text-3xl sm:text-5xl tracking-tight mb-4">{t('landing.pricing.title')}</h2>
|
||||||
<h2 className="text-4xl md:text-5xl font-serif tracking-tight text-ink mb-6">{t('landing.pricing.title')}</h2>
|
<p className="text-white/45 mb-8">{t('landing.pricing.desc')}</p>
|
||||||
<p className="text-concrete font-light max-w-xl mx-auto mb-12">{t('landing.pricing.desc')}</p>
|
<div className="inline-flex p-1 rounded-full border border-white/10 bg-white/[0.03]">
|
||||||
|
<button
|
||||||
<div className="flex items-center justify-center gap-10 mb-8">
|
type="button"
|
||||||
<button onClick={() => setBillingInterval('monthly')} className={`group relative py-2 px-1 transition-all ${billingInterval === 'monthly' ? 'text-ink' : 'text-concrete/40 hover:text-concrete'}`}>
|
onClick={() => setBillingInterval('monthly')}
|
||||||
<span className="text-xs font-black uppercase tracking-[0.2em]">{t('landing.pricing.monthly')}</span>
|
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all ${billingInterval === 'monthly' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/45'}`}
|
||||||
{billingInterval === 'monthly' && (
|
>
|
||||||
<motion.div layoutId="interval-active" className="absolute -inset-x-1 -inset-y-0.5 border border-ochre/60" transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }} />
|
{t('landing.pricing.monthly')}
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
<div className="relative">
|
<button
|
||||||
<button onClick={() => setBillingInterval('annual')} className={`group relative py-2 px-1 transition-all ${billingInterval === 'annual' ? 'text-ink' : 'text-concrete/40 hover:text-concrete'}`}>
|
type="button"
|
||||||
<span className="text-xs font-black uppercase tracking-[0.2em]">{t('landing.pricing.annual')}</span>
|
onClick={() => setBillingInterval('annual')}
|
||||||
{billingInterval === 'annual' && (
|
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all relative ${billingInterval === 'annual' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/45'}`}
|
||||||
<motion.div layoutId="interval-active" className="absolute -inset-x-1 -inset-y-0.5 border border-ochre/60" transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }} />
|
>
|
||||||
)}
|
{t('landing.pricing.annual')}
|
||||||
|
<span className="absolute -top-3 -right-1 text-[10px] text-[#D4A373]">-20%</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap">
|
|
||||||
<span className="text-[9px] font-bold text-ochre uppercase tracking-widest italic animate-pulse">(-20%)</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
</div>
|
{PLANS.map((plan) => (
|
||||||
|
<div
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch">
|
key={plan.key}
|
||||||
{PLANS.map((plan, i) => (
|
className={`rounded-2xl border p-6 flex flex-col ${
|
||||||
<div key={plan.key} className={`relative p-8 rounded-[32px] border flex flex-col transition-all duration-300 hover:shadow-2xl hover:shadow-ink/5 ${plan.popular ? 'bg-ink text-paper border-ink ring-4 ring-ochre/20' : 'bg-white border-border text-ink'}`}>
|
plan.popular
|
||||||
|
? 'border-[#D4A373]/50 bg-[#D4A373]/10'
|
||||||
|
: 'border-white/[0.08] bg-white/[0.02]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{plan.popular && (
|
{plan.popular && (
|
||||||
<div className="absolute -top-4 left-1/2 -translate-x-1/2 px-4 py-1 bg-ochre text-ink text-[10px] font-bold uppercase tracking-widest rounded-full">
|
<span className="text-[10px] font-bold uppercase tracking-widest text-[#D4A373] mb-3">
|
||||||
{t('landing.pricing.popular')}
|
{t('landing.pricing.popular')}
|
||||||
</div>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="mb-8">
|
<h4 className="text-[12px] uppercase tracking-widest text-white/40 mb-2">
|
||||||
<h4 className="text-[11px] font-bold uppercase tracking-widest mb-2 opacity-60">{t(`landing.pricing.${plan.key}.name`)}</h4>
|
{t(`landing.pricing.${plan.key}.name`)}
|
||||||
<div className="flex items-baseline gap-1 mb-4">
|
</h4>
|
||||||
<span className="text-4xl font-serif font-medium">{plan.price}</span>
|
<div className="flex items-baseline gap-1 mb-2">
|
||||||
{plan.period && <span className="text-xs opacity-60">{plan.period}</span>}
|
<span className="text-3xl font-serif">{plan.price}</span>
|
||||||
|
{plan.period && <span className="text-xs text-white/35">{plan.period}</span>}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-light leading-relaxed opacity-80">{t(`landing.pricing.${plan.key}.desc`)}</p>
|
<p className="text-sm text-white/45 mb-6">{t(`landing.pricing.${plan.key}.desc`)}</p>
|
||||||
</div>
|
<ul className="space-y-2.5 mb-8 flex-1">
|
||||||
<div className="flex-1 space-y-4 mb-10">
|
{[0, 1, 2, 3, 4, 5].map((j) => {
|
||||||
{[0, 1, 2, 3, 4, 5].map(j => {
|
|
||||||
const feat = t(`landing.pricing.${plan.key}.feature${j}`)
|
const feat = t(`landing.pricing.${plan.key}.feature${j}`)
|
||||||
if (!feat || feat === `landing.pricing.${plan.key}.feature${j}`) return null
|
if (!feat || feat.startsWith('landing.')) return null
|
||||||
return (
|
return (
|
||||||
<div key={j} className="flex items-start gap-3">
|
<li key={j} className="flex gap-2 text-xs text-white/60">
|
||||||
<div className={`mt-1 rounded-full p-0.5 ${plan.popular ? 'bg-ochre text-ink' : 'bg-brand-accent/10 text-brand-accent'}`}>
|
<Check size={12} className="text-[#D4A373] mt-0.5 shrink-0" />
|
||||||
<Shield size={10} fill="currentColor" />
|
{feat}
|
||||||
</div>
|
</li>
|
||||||
<span className="text-xs font-light">{feat}</span>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</ul>
|
||||||
<button onClick={() => router.push('/register')} className={`w-full py-4 rounded-2xl text-xs font-bold uppercase tracking-widest transition-all ${plan.popular ? 'bg-ochre text-ink hover:opacity-90' : 'bg-ink text-paper hover:bg-ink/90'}`}>
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className={`py-3 rounded-xl text-center text-[12px] font-semibold transition-colors ${
|
||||||
|
plan.popular
|
||||||
|
? 'bg-[#F4F1EA] text-[#0B0A09] hover:bg-white'
|
||||||
|
: 'bg-white/10 text-white hover:bg-white/15'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{t(`landing.pricing.${plan.key}.cta`)}
|
{t(`landing.pricing.${plan.key}.cta`)}
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* BYOK */}
|
|
||||||
<div className="mt-20 p-12 bg-slate-50 border border-border rounded-[40px] flex flex-col md:flex-row items-center gap-12">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-brand-accent/10 text-brand-accent text-[9px] font-bold uppercase tracking-widest mb-6">
|
|
||||||
<Cpu size={12} />
|
|
||||||
{t('landing.byok.label')}
|
|
||||||
</div>
|
|
||||||
<h3 className="text-3xl font-serif font-medium mb-4">{t('landing.byok.title')}</h3>
|
|
||||||
<p className="text-concrete font-light leading-relaxed mb-6">{t('landing.byok.desc')}</p>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="p-4 bg-white rounded-2xl border border-border">
|
|
||||||
<h5 className="text-[10px] font-bold uppercase tracking-widest mb-2">{t('landing.byok.noLockin')}</h5>
|
|
||||||
<p className="text-[10px] text-concrete font-light">{t('landing.byok.noLockinDesc')}</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-white rounded-2xl border border-border">
|
|
||||||
<h5 className="text-[10px] font-bold uppercase tracking-widest mb-2">{t('landing.byok.cost')}</h5>
|
|
||||||
<p className="text-[10px] text-concrete font-light">{t('landing.byok.costDesc')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-full md:w-[400px] bg-ink rounded-3xl p-8 relative overflow-hidden group">
|
|
||||||
<div className="absolute inset-0 bg-brand-accent/10 blur-[50px] group-hover:bg-ochre/10 transition-colors" />
|
|
||||||
<div className="relative z-10 font-mono text-[10px] text-paper/40 space-y-2">
|
|
||||||
<p className="text-ochre">{"{"}</p>
|
|
||||||
<p className="pl-4">"provider": "anthropic",</p>
|
|
||||||
<p className="pl-4">"model": "claude-3-opus",</p>
|
|
||||||
<p className="pl-4 border-l border-brand-accent/30 bg-brand-accent/5">"apiKey": "sk-ant-at03-..."</p>
|
|
||||||
<p className="pl-4">"useSystemKey": false</p>
|
|
||||||
<p className="text-ochre">{"}"}</p>
|
|
||||||
</div>
|
|
||||||
<div className="mt-8 flex items-center justify-between relative z-10">
|
|
||||||
<span className="text-[10px] font-bold text-paper uppercase tracking-widest">{t('landing.byok.configLabel')}</span>
|
|
||||||
<div className="flex gap-1">{[1, 2, 3].map(i => <div key={i} className="w-1 h-1 rounded-full bg-paper/20" />)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Final CTA */}
|
{/* Final CTA */}
|
||||||
<section className="py-40 px-8 text-center bg-paper relative overflow-hidden">
|
<section className="relative px-5 sm:px-8 py-36 text-center overflow-hidden">
|
||||||
<div className="max-w-3xl mx-auto relative z-10">
|
<div className="pointer-events-none absolute inset-0">
|
||||||
<h2 className="text-5xl md:text-7xl font-serif tracking-tight mb-8 leading-tight">{t('landing.cta.title1')} <br /><span className="italic">{t('landing.cta.title2')}</span></h2>
|
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[300px] bg-[#D4A373]/20 blur-[100px] rounded-full" />
|
||||||
<p className="text-lg text-concrete font-light mb-12">{t('landing.cta.desc')}</p>
|
</div>
|
||||||
<button onClick={() => router.push('/register')} className="px-16 py-6 bg-ink text-paper rounded-[32px] text-lg font-bold uppercase tracking-[0.2em] hover:scale-105 transition-all shadow-[0_30px_60px_-15px_rgba(0,0,0,0.3)]">
|
<div className="relative z-10 max-w-2xl mx-auto">
|
||||||
|
<BrainCircuit className="mx-auto text-[#D4A373] mb-6" size={32} strokeWidth={1.25} />
|
||||||
|
<h2 className="font-serif text-4xl sm:text-6xl tracking-tight leading-tight mb-6">
|
||||||
|
{t('landing.cta.title')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-white/50 text-lg mb-10">{t('landing.cta.desc')}</p>
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="inline-flex items-center gap-3 px-10 py-4 rounded-full bg-[#F4F1EA] text-[#0B0A09] text-[15px] font-semibold hover:bg-white transition-all hover:scale-[1.02]"
|
||||||
|
>
|
||||||
{t('landing.cta.button')}
|
{t('landing.cta.button')}
|
||||||
</button>
|
<ArrowRight size={18} />
|
||||||
|
</Link>
|
||||||
|
<p className="mt-4 text-[12px] text-white/30">{t('landing.hero.ctaHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Footer */}
|
<footer className="px-5 sm:px-8 py-14 border-t border-white/[0.06]">
|
||||||
<footer className="py-20 px-8 border-t border-border bg-paper">
|
<div className="max-w-6xl mx-auto flex flex-col md:flex-row justify-between gap-10">
|
||||||
<div className="max-w-6xl mx-auto flex flex-col md:flex-row justify-between gap-12">
|
<div className="max-w-xs">
|
||||||
<div className="space-y-6">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="w-7 h-7 bg-[#F4F1EA] text-[#0B0A09] flex items-center justify-center rounded-md">
|
||||||
<div className="w-8 h-8 bg-ink flex items-center justify-center rounded-lg">
|
<span className="font-serif font-bold text-sm">M</span>
|
||||||
<span className="text-paper font-serif text-lg font-bold">M</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
|
<span className="font-serif text-lg">Memento</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-concrete font-light max-w-xs">{t('landing.footer.desc')}</p>
|
<p className="text-sm text-white/35">{t('landing.footer.desc')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-10 text-sm">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-16">
|
{(['product', 'community', 'legal'] as const).map((section) => (
|
||||||
{FOOTER_SECTIONS.map(section => (
|
<div key={section}>
|
||||||
<div key={section} className="space-y-4">
|
<p className="text-[11px] uppercase tracking-widest text-white/40 mb-3">
|
||||||
<h6 className="text-[10px] font-bold uppercase tracking-widest text-ink">{t(`landing.footer.${section}.title`)}</h6>
|
{t(`landing.footer.${section}.title`)}
|
||||||
<ul className="space-y-2 text-sm text-concrete font-light">
|
</p>
|
||||||
{[0, 1, 2].map(j => {
|
<ul className="space-y-2 text-white/50">
|
||||||
|
{[0, 1, 2].map((j) => {
|
||||||
const label = t(`landing.footer.${section}.link${j}`)
|
const label = t(`landing.footer.${section}.link${j}`)
|
||||||
const href = t(`landing.footer.${section}.link${j}Href`)
|
const href = t(`landing.footer.${section}.link${j}Href`)
|
||||||
if (!label || label.startsWith('landing.')) return null
|
if (!label || label.startsWith('landing.')) return null
|
||||||
return <li key={j}><a href={href} className="hover:text-ink">{label}</a></li>
|
return (
|
||||||
|
<li key={j}>
|
||||||
|
<a href={href} className="hover:text-white transition-colors">{label}</a>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-6xl mx-auto mt-20 pt-10 border-t border-border flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] uppercase font-bold tracking-widest text-concrete">
|
<p className="max-w-6xl mx-auto mt-12 pt-8 border-t border-white/[0.06] text-[11px] text-white/25 tracking-wide">
|
||||||
<div>© 2026 MOMENTO LABS. ALL RIGHTS RESERVED.</div>
|
© 2026 Memento. {t('landing.footer.rights')}
|
||||||
<div className="flex gap-8"><span>DESIGNED BY ANTIGRAVITY</span></div>
|
</p>
|
||||||
</div>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProductMoment({
|
||||||
|
id,
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
desc,
|
||||||
|
children,
|
||||||
|
dark,
|
||||||
|
compact,
|
||||||
|
}: {
|
||||||
|
id?: string
|
||||||
|
eyebrow: string
|
||||||
|
title: string
|
||||||
|
desc: string
|
||||||
|
children: ReactNode
|
||||||
|
dark?: boolean
|
||||||
|
compact?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
id={id}
|
||||||
|
initial={{ opacity: 0, y: 28 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: '-60px' }}
|
||||||
|
transition={{ duration: 0.55 }}
|
||||||
|
className={`rounded-[24px] sm:rounded-[32px] border overflow-hidden ${
|
||||||
|
dark
|
||||||
|
? 'bg-[#141210] border-white/[0.08] text-[#F4F1EA]'
|
||||||
|
: 'bg-[#EDE8DF] border-transparent text-[#0B0A09]'
|
||||||
|
} ${compact ? '' : 'grid grid-cols-1 lg:grid-cols-2'}`}
|
||||||
|
>
|
||||||
|
<div className={`p-8 sm:p-10 flex flex-col justify-center ${compact ? 'pb-4' : ''}`}>
|
||||||
|
<p className={`text-[11px] uppercase tracking-[0.22em] font-semibold mb-3 ${dark ? 'text-[#D4A373]' : 'text-[#A47148]'}`}>
|
||||||
|
{eyebrow}
|
||||||
|
</p>
|
||||||
|
<h3 className="font-serif text-2xl sm:text-3xl tracking-tight mb-3 leading-tight">{title}</h3>
|
||||||
|
<p className={`text-[15px] leading-relaxed ${dark ? 'text-white/50' : 'text-[#0B0A09]/55'}`}>{desc}</p>
|
||||||
|
</div>
|
||||||
|
<div className={`${compact ? 'px-4 pb-6' : 'p-6 sm:p-8'} flex items-center`}>
|
||||||
|
<div className="w-full">{children}</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -761,6 +761,9 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
|||||||
)}
|
)}
|
||||||
{t('richTextEditor.publishWithAi')}
|
{t('richTextEditor.publishWithAi')}
|
||||||
</button>
|
</button>
|
||||||
|
<p className="text-[10px] text-muted-foreground text-center">
|
||||||
|
{t('richTextEditor.publishWithAiCostHint')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Bell, Check, X, Clock, AlertCircle, Circle, Share2, Bot, Download, Pencil, Presentation, Wind, Scissors } from 'lucide-react'
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Bell, Check, X, Clock, AlertCircle, CheckCircle2, Circle, Share2, Bot, Trash2, Download, Pencil, Presentation, Wind, Scissors } from 'lucide-react'
|
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
@@ -46,15 +44,6 @@ interface ReminderNote {
|
|||||||
isReminderDone: boolean
|
isReminderDone: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Memento brand tokens ──────────────────────────────────────────────────────
|
|
||||||
const C = {
|
|
||||||
blue: '#FDFDFE',
|
|
||||||
gold: '#D4A373',
|
|
||||||
green: '#A3B18A',
|
|
||||||
dark: '#1C1C1C',
|
|
||||||
beige: '#FDFDFE',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NotificationPanel() {
|
export function NotificationPanel() {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -171,38 +160,32 @@ export function NotificationPanel() {
|
|||||||
|
|
||||||
const hasContent = requests.length > 0 || brainstormShares.length > 0 || activeReminders.length > 0 || appNotifications.length > 0
|
const hasContent = requests.length > 0 || brainstormShares.length > 0 || activeReminders.length > 0 || appNotifications.length > 0
|
||||||
|
|
||||||
// ── icon bg/color per notification type ──────────────────────────────────
|
// ── icon styles per notification type (theme-aware via opacity) ─────────
|
||||||
const notifIconStyle = (type: string) => {
|
const notifIconClass = (type: string) => {
|
||||||
if (type === 'agent_success') return { bg: 'rgba(164,113,72,0.12)', color: '#A47148' }
|
if (type === 'agent_failure') return 'bg-red-500/15 text-red-500'
|
||||||
if (type === 'agent_slides_ready') return { bg: 'rgba(164,113,72,0.12)', color: '#A47148' }
|
if (type.startsWith('agent')) return 'bg-brand-accent/15 text-brand-accent'
|
||||||
if (type === 'agent_canvas_ready') return { bg: 'rgba(164,113,72,0.12)', color: '#A47148' }
|
if (type.startsWith('brainstorm')) return 'bg-ochre/15 text-ochre'
|
||||||
if (type === 'agent_failure') return { bg: 'rgba(239,68,68,0.12)', color: '#EF4444' }
|
if (type === 'clip') return 'bg-brand-accent/15 text-brand-accent'
|
||||||
if (type === 'brainstorm_invite') return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
|
return 'bg-muted text-muted-foreground'
|
||||||
if (type === 'brainstorm_joined') return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
|
|
||||||
if (type === 'clip') return { bg: 'rgba(99,102,241,0.12)', color: '#6366F1' }
|
|
||||||
return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifLabelColor = (type: string) => {
|
const notifLabelClass = (type: string) => {
|
||||||
if (type.startsWith('agent')) {
|
if (type === 'agent_failure') return 'text-red-500'
|
||||||
if (type === 'agent_failure') return '#EF4444'
|
if (type.startsWith('agent')) return 'text-brand-accent'
|
||||||
if (type === 'brainstorm_invite') return '#A3B18A'
|
if (type.startsWith('brainstorm')) return 'text-ochre'
|
||||||
if (type === 'brainstorm_joined') return '#A3B18A'
|
return 'text-muted-foreground'
|
||||||
return '#A47148'
|
|
||||||
}
|
|
||||||
return '#A3B18A'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button
|
<button
|
||||||
className="relative p-1.5 text-muted-ink hover:text-ink transition-all hover:bg-white/50 dark:hover:bg-white/10 rounded-lg border border-transparent hover:border-border"
|
className="relative p-1.5 text-muted-foreground hover:text-foreground transition-all hover:bg-black/5 dark:hover:bg-white/10 rounded-lg border border-transparent hover:border-border"
|
||||||
>
|
>
|
||||||
<Bell className="h-4 w-4 transition-transform duration-200 hover:scale-110" />
|
<Bell className="h-4 w-4 transition-transform duration-200 hover:scale-110" />
|
||||||
{pendingCount > 0 && (
|
{pendingCount > 0 && (
|
||||||
<span
|
<span
|
||||||
className="absolute -top-1 -right-1 h-4 w-4 flex items-center justify-center rounded-full text-white text-[9px] font-bold border border-white shadow-sm bg-brand-accent"
|
className="absolute -top-1 -right-1 h-4 w-4 flex items-center justify-center rounded-full text-white text-[9px] font-bold border-2 border-background shadow-sm bg-brand-accent"
|
||||||
>
|
>
|
||||||
{pendingCount > 9 ? '9+' : pendingCount}
|
{pendingCount > 9 ? '9+' : pendingCount}
|
||||||
</span>
|
</span>
|
||||||
@@ -210,12 +193,15 @@ export function NotificationPanel() {
|
|||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
||||||
<PopoverContent align="end" className="w-80 p-0 rounded-2xl overflow-hidden shadow-2xl border border-black/20">
|
<PopoverContent
|
||||||
|
align="end"
|
||||||
|
className="w-80 p-0 rounded-2xl overflow-hidden shadow-2xl border border-border bg-popover text-popover-foreground"
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-4 py-3 border-b flex items-center justify-between" style={{ background: '#FDFDFE' }}>
|
<div className="px-4 py-3 border-b border-border bg-muted/40 dark:bg-white/[0.03] flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Bell className="h-4 w-4" style={{ color: C.dark }} />
|
<Bell className="h-4 w-4 text-foreground/70" />
|
||||||
<span className="font-bold text-sm tracking-tight" style={{ color: C.dark }}>
|
<span className="font-bold text-sm tracking-tight text-foreground">
|
||||||
{t('notification.notifications')}
|
{t('notification.notifications')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -223,7 +209,7 @@ export function NotificationPanel() {
|
|||||||
{appNotifications.length > 0 && (
|
{appNotifications.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleMarkAllRead}
|
onClick={handleMarkAllRead}
|
||||||
className="text-[10px] text-foreground/40 hover:text-foreground transition-colors"
|
className="text-muted-foreground hover:text-foreground transition-colors p-1 rounded-md hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
title={t('notification.markAllRead') || 'Mark all read'}
|
title={t('notification.markAllRead') || 'Mark all read'}
|
||||||
>
|
>
|
||||||
<Check className="h-3.5 w-3.5" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
@@ -241,25 +227,24 @@ export function NotificationPanel() {
|
|||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||||
<div className="animate-spin h-6 w-6 border-2 border-t-transparent rounded-full mx-auto mb-2" style={{ borderColor: C.blue, borderTopColor: 'transparent' }} />
|
<div className="animate-spin h-6 w-6 border-2 border-brand-accent border-t-transparent rounded-full mx-auto mb-2" />
|
||||||
</div>
|
</div>
|
||||||
) : !hasContent ? (
|
) : !hasContent ? (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
<Bell className="h-9 w-9 mx-auto mb-3 opacity-20" />
|
<Bell className="h-9 w-9 mx-auto mb-3 text-muted-foreground/40" />
|
||||||
<p className="text-[12px] font-medium text-foreground/40">{t('notification.noNotifications') || 'Aucune notification'}</p>
|
<p className="text-[12px] font-medium text-muted-foreground">{t('notification.noNotifications') || 'Aucune notification'}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-h-96 overflow-y-auto divide-y divide-black/5">
|
<div className="max-h-96 overflow-y-auto divide-y divide-border">
|
||||||
|
|
||||||
{/* ── App notifications (agents, system) ── */}
|
{/* ── App notifications (agents, system) ── */}
|
||||||
{appNotifications.map((notif) => {
|
{appNotifications.map((notif) => {
|
||||||
const isSlides = notif.type === 'agent_slides_ready'
|
const isSlides = notif.type === 'agent_slides_ready'
|
||||||
const isCanvas = notif.type === 'agent_canvas_ready'
|
const isCanvas = notif.type === 'agent_canvas_ready'
|
||||||
const canvasId = notif.relatedId
|
const canvasId = notif.relatedId
|
||||||
const iconStyle = notifIconStyle(notif.type)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={notif.id} className="p-3 hover:bg-black/[0.02] transition-colors">
|
<div key={notif.id} className="p-3 hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-colors">
|
||||||
<div
|
<div
|
||||||
className="flex items-start gap-3 cursor-pointer"
|
className="flex items-start gap-3 cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -270,11 +255,7 @@ export function NotificationPanel() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Icon badge */}
|
<div className={cn('mt-0.5 flex-none rounded-lg p-1.5', notifIconClass(notif.type))}>
|
||||||
<div
|
|
||||||
className="mt-0.5 flex-none rounded-lg p-1.5"
|
|
||||||
style={{ background: iconStyle.bg, color: iconStyle.color }}
|
|
||||||
>
|
|
||||||
{isSlides ? <Presentation className="w-3.5 h-3.5" />
|
{isSlides ? <Presentation className="w-3.5 h-3.5" />
|
||||||
: isCanvas ? <Pencil className="w-3.5 h-3.5" />
|
: isCanvas ? <Pencil className="w-3.5 h-3.5" />
|
||||||
: notif.type === 'brainstorm_invite' ? <Wind className="w-3.5 h-3.5" />
|
: notif.type === 'brainstorm_invite' ? <Wind className="w-3.5 h-3.5" />
|
||||||
@@ -285,10 +266,7 @@ export function NotificationPanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span
|
<span className={cn('text-[9px] font-bold uppercase tracking-[0.2em]', notifLabelClass(notif.type))}>
|
||||||
className="text-[9px] font-bold uppercase tracking-[0.2em]"
|
|
||||||
style={{ color: notifLabelColor(notif.type) }}
|
|
||||||
>
|
|
||||||
{notif.type === 'agent_slides_ready' && (t('notification.slidesReady') || 'Présentation prête')}
|
{notif.type === 'agent_slides_ready' && (t('notification.slidesReady') || 'Présentation prête')}
|
||||||
{notif.type === 'agent_canvas_ready' && (t('notification.canvasReady') || 'Diagramme prêt')}
|
{notif.type === 'agent_canvas_ready' && (t('notification.canvasReady') || 'Diagramme prêt')}
|
||||||
{notif.type === 'agent_success' && (t('notification.agentSuccess') || 'Agent terminé')}
|
{notif.type === 'agent_success' && (t('notification.agentSuccess') || 'Agent terminé')}
|
||||||
@@ -298,11 +276,11 @@ export function NotificationPanel() {
|
|||||||
{notif.type === 'clip' && (t('notification.clipSaved') || 'Web clip')}
|
{notif.type === 'clip' && (t('notification.clipSaved') || 'Web clip')}
|
||||||
{notif.type === 'system' && t('notification.systemNotification')}
|
{notif.type === 'system' && t('notification.systemNotification')}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-[13px] font-semibold truncate mt-0.5">{notif.title}</p>
|
<p className="text-[13px] font-semibold truncate mt-0.5 text-foreground">{notif.title}</p>
|
||||||
{notif.message && (
|
{notif.message && (
|
||||||
<p className="text-[11px] text-foreground/50 mt-0.5 line-clamp-2">{notif.message}</p>
|
<p className="text-[11px] text-muted-foreground mt-0.5 line-clamp-2">{notif.message}</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-1 mt-1 text-[10px] text-foreground/30">
|
<div className="flex items-center gap-1 mt-1 text-[10px] text-muted-foreground/70">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
{formatDistanceToNow(new Date(notif.createdAt), { addSuffix: true })}
|
{formatDistanceToNow(new Date(notif.createdAt), { addSuffix: true })}
|
||||||
</div>
|
</div>
|
||||||
@@ -310,13 +288,12 @@ export function NotificationPanel() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleMarkNotifRead(notif.id) }}
|
onClick={(e) => { e.stopPropagation(); handleMarkNotifRead(notif.id) }}
|
||||||
className="mt-0.5 text-foreground/20 hover:text-foreground transition-colors"
|
className="mt-0.5 text-muted-foreground/50 hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Download PPTX button */}
|
|
||||||
{isSlides && canvasId && (
|
{isSlides && canvasId && (
|
||||||
<div className="mt-2 ml-8">
|
<div className="mt-2 ml-8">
|
||||||
<button
|
<button
|
||||||
@@ -338,8 +315,7 @@ export function NotificationPanel() {
|
|||||||
document.body.removeChild(a); URL.revokeObjectURL(url)
|
document.body.removeChild(a); URL.revokeObjectURL(url)
|
||||||
} catch { toast.error(t('notification.downloadFailed')) }
|
} catch { toast.error(t('notification.downloadFailed')) }
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold rounded-lg text-white uppercase tracking-wide transition-all hover:opacity-90 active:scale-95 shadow-sm"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold rounded-lg bg-brand-accent text-white uppercase tracking-wide transition-all hover:opacity-90 active:scale-95 shadow-sm"
|
||||||
style={{ background: C.blue }}
|
|
||||||
>
|
>
|
||||||
<Download className="w-3 h-3" />
|
<Download className="w-3 h-3" />
|
||||||
{t('notification.downloadPptx') || 'Télécharger .pptx'}
|
{t('notification.downloadPptx') || 'Télécharger .pptx'}
|
||||||
@@ -352,25 +328,24 @@ export function NotificationPanel() {
|
|||||||
|
|
||||||
{/* ── Overdue reminders ── */}
|
{/* ── Overdue reminders ── */}
|
||||||
{overdueReminders.map((note) => (
|
{overdueReminders.map((note) => (
|
||||||
<div key={note.id} className="p-3 hover:bg-black/[0.02] transition-colors">
|
<div key={note.id} className="p-3 hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-colors">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleToggleReminder(note.id, true)}
|
onClick={() => handleToggleReminder(note.id, true)}
|
||||||
className="mt-0.5 flex-none transition-colors hover:opacity-70"
|
className="mt-0.5 flex-none text-ochre transition-colors hover:opacity-70"
|
||||||
style={{ color: C.green }}
|
|
||||||
title={t('reminders.markDone')}
|
title={t('reminders.markDone')}
|
||||||
>
|
>
|
||||||
<Circle className="w-4 h-4" />
|
<Circle className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1.5 mb-0.5">
|
<div className="flex items-center gap-1.5 mb-0.5">
|
||||||
<AlertCircle className="w-3 h-3" style={{ color: C.green }} />
|
<AlertCircle className="w-3 h-3 text-ochre" />
|
||||||
<span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: C.green }}>
|
<span className="text-[9px] font-bold uppercase tracking-[0.2em] text-ochre">
|
||||||
{t('reminders.overdue')}
|
{t('reminders.overdue')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[13px] font-semibold truncate">{note.title || t('notification.untitled')}</p>
|
<p className="text-[13px] font-semibold truncate text-foreground">{note.title || t('notification.untitled')}</p>
|
||||||
<div className="flex items-center gap-1 mt-1 text-[10px] text-foreground/30">
|
<div className="flex items-center gap-1 mt-1 text-[10px] text-muted-foreground/70">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
{note.reminder && formatDistanceToNow(new Date(note.reminder), { addSuffix: true })}
|
{note.reminder && formatDistanceToNow(new Date(note.reminder), { addSuffix: true })}
|
||||||
</div>
|
</div>
|
||||||
@@ -381,12 +356,12 @@ export function NotificationPanel() {
|
|||||||
|
|
||||||
{/* ── Upcoming reminders ── */}
|
{/* ── Upcoming reminders ── */}
|
||||||
{upcomingReminders.slice(0, 5).map((note) => (
|
{upcomingReminders.slice(0, 5).map((note) => (
|
||||||
<div key={note.id} className="p-3 hover:bg-black/[0.02] transition-colors">
|
<div key={note.id} className="p-3 hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-colors">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Clock className="w-4 h-4 mt-0.5 flex-none" style={{ color: C.blue }} />
|
<Clock className="w-4 h-4 mt-0.5 flex-none text-muted-foreground" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-[13px] font-semibold truncate">{note.title || t('notification.untitled')}</p>
|
<p className="text-[13px] font-semibold truncate text-foreground">{note.title || t('notification.untitled')}</p>
|
||||||
<div className="text-[11px] text-foreground/40 mt-0.5">
|
<div className="text-[11px] text-muted-foreground mt-0.5">
|
||||||
{note.reminder && new Date(note.reminder).toLocaleDateString(undefined, {
|
{note.reminder && new Date(note.reminder).toLocaleDateString(undefined, {
|
||||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||||
})}
|
})}
|
||||||
@@ -398,25 +373,22 @@ export function NotificationPanel() {
|
|||||||
|
|
||||||
{/* ── Brainstorm share invites ── */}
|
{/* ── Brainstorm share invites ── */}
|
||||||
{brainstormShares.map((share) => (
|
{brainstormShares.map((share) => (
|
||||||
<div key={share.id} className="p-4 hover:bg-black/[0.02] transition-colors space-y-3">
|
<div key={share.id} className="p-4 hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-colors space-y-3">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div
|
<div className="h-8 w-8 rounded-full flex items-center justify-center text-white font-bold text-[11px] shrink-0 shadow-sm bg-brand-accent">
|
||||||
className="h-8 w-8 rounded-full flex items-center justify-center text-white font-bold text-[11px] shrink-0 shadow-sm"
|
|
||||||
style={{ background: `linear-gradient(135deg, #A47148, #A47148)` }}
|
|
||||||
>
|
|
||||||
{(share.sharer?.name || share.sharer?.email || '?')[0].toUpperCase()}
|
{(share.sharer?.name || share.sharer?.email || '?')[0].toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1.5 mb-0.5">
|
<div className="flex items-center gap-1.5 mb-0.5">
|
||||||
<Wind className="w-3 h-3" style={{ color: '#A47148' }} />
|
<Wind className="w-3 h-3 text-brand-accent" />
|
||||||
<span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: '#A47148' }}>
|
<span className="text-[9px] font-bold uppercase tracking-[0.2em] text-brand-accent">
|
||||||
Brainstorm
|
{t('notification.brainstormInvite')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[13px] font-semibold truncate">
|
<p className="text-[13px] font-semibold truncate text-foreground">
|
||||||
{share.sharer?.name || share.sharer?.email}
|
{share.sharer?.name || share.sharer?.email}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] text-foreground/50 truncate">
|
<p className="text-[11px] text-muted-foreground truncate">
|
||||||
{t('notification.brainstormShared') || 'invited you to a brainstorm'} « {share.session?.seedIdea?.length > 35 ? share.session.seedIdea.substring(0, 35) + '…' : share.session?.seedIdea} »
|
{t('notification.brainstormShared') || 'invited you to a brainstorm'} « {share.session?.seedIdea?.length > 35 ? share.session.seedIdea.substring(0, 35) + '…' : share.session?.seedIdea} »
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -424,7 +396,7 @@ export function NotificationPanel() {
|
|||||||
<div className="flex gap-2 ml-11">
|
<div className="flex gap-2 ml-11">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeclineBrainstorm(share.id)}
|
onClick={() => handleDeclineBrainstorm(share.id)}
|
||||||
className="flex-1 h-7 px-3 text-[11px] font-semibold rounded-lg border border-black/15 text-foreground/60 hover:bg-black/5 transition-all active:scale-95 flex items-center justify-center gap-1"
|
className="flex-1 h-7 px-3 text-[11px] font-semibold rounded-lg border border-border text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground transition-all active:scale-95 flex items-center justify-center gap-1"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
{t('notification.decline') || 'Decline'}
|
{t('notification.decline') || 'Decline'}
|
||||||
@@ -442,26 +414,22 @@ export function NotificationPanel() {
|
|||||||
|
|
||||||
{/* ── Share requests ── */}
|
{/* ── Share requests ── */}
|
||||||
{requests.map((request) => (
|
{requests.map((request) => (
|
||||||
<div key={request.id} className="p-4 hover:bg-black/[0.02] transition-colors space-y-3">
|
<div key={request.id} className="p-4 hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-colors space-y-3">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{/* Avatar */}
|
<div className="h-8 w-8 rounded-full flex items-center justify-center text-white font-bold text-[11px] shrink-0 shadow-sm bg-brand-accent">
|
||||||
<div
|
|
||||||
className="h-8 w-8 rounded-full flex items-center justify-center text-white font-bold text-[11px] shrink-0 shadow-sm"
|
|
||||||
style={{ background: `linear-gradient(135deg, ${C.blue}, ${C.green})` }}
|
|
||||||
>
|
|
||||||
{(request.sharer.name || request.sharer.email)[0].toUpperCase()}
|
{(request.sharer.name || request.sharer.email)[0].toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1.5 mb-0.5">
|
<div className="flex items-center gap-1.5 mb-0.5">
|
||||||
<Share2 className="w-3 h-3" style={{ color: C.blue }} />
|
<Share2 className="w-3 h-3 text-brand-accent" />
|
||||||
<span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: C.blue }}>
|
<span className="text-[9px] font-bold uppercase tracking-[0.2em] text-brand-accent">
|
||||||
Partage
|
{t('notification.shareLabel')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[13px] font-semibold truncate">
|
<p className="text-[13px] font-semibold truncate text-foreground">
|
||||||
{request.sharer.name || request.sharer.email}
|
{request.sharer.name || request.sharer.email}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] text-foreground/50 truncate">
|
<p className="text-[11px] text-muted-foreground truncate">
|
||||||
{t('notification.shared', { title: request.note.title || t('notification.untitled') })}
|
{t('notification.shared', { title: request.note.title || t('notification.untitled') })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -470,15 +438,14 @@ export function NotificationPanel() {
|
|||||||
<div className="flex gap-2 ml-11">
|
<div className="flex gap-2 ml-11">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDecline(request.id)}
|
onClick={() => handleDecline(request.id)}
|
||||||
className="flex-1 h-7 px-3 text-[11px] font-semibold rounded-lg border border-black/15 text-foreground/60 hover:bg-black/5 transition-all active:scale-95 flex items-center justify-center gap-1"
|
className="flex-1 h-7 px-3 text-[11px] font-semibold rounded-lg border border-border text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground transition-all active:scale-95 flex items-center justify-center gap-1"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
{t('notification.decline') || 'Refuser'}
|
{t('notification.decline') || 'Refuser'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAccept(request.id)}
|
onClick={() => handleAccept(request.id)}
|
||||||
className="flex-1 h-7 px-3 text-[11px] font-bold rounded-lg text-white transition-all active:scale-95 flex items-center justify-center gap-1 shadow-sm hover:opacity-90"
|
className="flex-1 h-7 px-3 text-[11px] font-bold rounded-lg text-white bg-brand-accent transition-all active:scale-95 flex items-center justify-center gap-1 shadow-sm hover:opacity-90"
|
||||||
style={{ background: C.blue }}
|
|
||||||
>
|
>
|
||||||
<Check className="h-3 w-3" />
|
<Check className="h-3 w-3" />
|
||||||
{t('notification.accept') || 'Accepter'}
|
{t('notification.accept') || 'Accepter'}
|
||||||
@@ -489,13 +456,11 @@ export function NotificationPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
{activeReminders.length > 0 && (
|
{activeReminders.length > 0 && (
|
||||||
<div className="px-4 py-2.5 border-t bg-black/[0.02]">
|
<div className="px-4 py-2.5 border-t border-border bg-muted/30 dark:bg-white/[0.02]">
|
||||||
<a
|
<a
|
||||||
href="/reminders"
|
href="/reminders"
|
||||||
className="text-[11px] font-semibold hover:opacity-70 transition-opacity"
|
className="text-[11px] font-semibold text-brand-accent hover:opacity-80 transition-opacity"
|
||||||
style={{ color: C.blue }}
|
|
||||||
>
|
>
|
||||||
{t('reminders.viewAll') || 'Voir tous les rappels →'}
|
{t('reminders.viewAll') || 'Voir tous les rappels →'}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
42
memento-note/components/publish/copy-link-button.tsx
Normal file
42
memento-note/components/publish/copy-link-button.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, type CSSProperties } from 'react'
|
||||||
|
import { Check, Link2 } from 'lucide-react'
|
||||||
|
|
||||||
|
export function CopyLinkButton({
|
||||||
|
label = 'Copier le lien',
|
||||||
|
copiedLabel = 'Copié',
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
label?: string
|
||||||
|
copiedLabel?: string
|
||||||
|
className?: string
|
||||||
|
style?: CSSProperties
|
||||||
|
}) {
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
const onCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(window.location.href)
|
||||||
|
setCopied(true)
|
||||||
|
window.setTimeout(() => setCopied(false), 2000)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void onCopy()}
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
aria-label={copied ? copiedLabel : label}
|
||||||
|
title={copied ? copiedLabel : label}
|
||||||
|
>
|
||||||
|
{copied ? <Check size={13} strokeWidth={2.25} /> : <Link2 size={13} strokeWidth={2.25} />}
|
||||||
|
<span>{copied ? copiedLabel : label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
memento-note/components/publish/reading-progress.tsx
Normal file
77
memento-note/components/publish/reading-progress.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin reading progress bar. Tracks the nearest scrollable ancestor
|
||||||
|
* (public layout uses a fixed-height overflow container, not window).
|
||||||
|
*/
|
||||||
|
export function ReadingProgress({ accent = '#A47148' }: { accent?: string }) {
|
||||||
|
const [progress, setProgress] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const findScrollRoot = (): HTMLElement | Window => {
|
||||||
|
let el: HTMLElement | null = document.querySelector('[data-pub-root]') as HTMLElement | null
|
||||||
|
while (el) {
|
||||||
|
const { overflowY } = getComputedStyle(el)
|
||||||
|
if (overflowY === 'auto' || overflowY === 'scroll') return el
|
||||||
|
el = el.parentElement
|
||||||
|
}
|
||||||
|
return window
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = findScrollRoot()
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
let scrollTop: number
|
||||||
|
let scrollHeight: number
|
||||||
|
let clientHeight: number
|
||||||
|
if (root === window) {
|
||||||
|
scrollTop = window.scrollY
|
||||||
|
scrollHeight = document.documentElement.scrollHeight
|
||||||
|
clientHeight = window.innerHeight
|
||||||
|
} else {
|
||||||
|
const node = root as HTMLElement
|
||||||
|
scrollTop = node.scrollTop
|
||||||
|
scrollHeight = node.scrollHeight
|
||||||
|
clientHeight = node.clientHeight
|
||||||
|
}
|
||||||
|
const max = Math.max(1, scrollHeight - clientHeight)
|
||||||
|
setProgress(Math.min(100, Math.max(0, (scrollTop / max) * 100)))
|
||||||
|
}
|
||||||
|
|
||||||
|
update()
|
||||||
|
root.addEventListener('scroll', update, { passive: true })
|
||||||
|
window.addEventListener('resize', update, { passive: true })
|
||||||
|
return () => {
|
||||||
|
root.removeEventListener('scroll', update)
|
||||||
|
window.removeEventListener('resize', update)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 3,
|
||||||
|
zIndex: 100,
|
||||||
|
background: 'transparent',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${progress}%`,
|
||||||
|
background: accent,
|
||||||
|
boxShadow: `0 0 12px ${accent}66`,
|
||||||
|
transition: 'width 80ms linear',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { useState, useEffect, useCallback } from 'react';
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { loadStripe } from '@stripe/stripe-js';
|
import { loadStripe } from '@stripe/stripe-js';
|
||||||
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
|
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
|
||||||
import { Check, Shield, Zap, Crown, X, ExternalLink, Loader2, CheckCircle2, Activity, Clock, ArrowRight } from 'lucide-react';
|
import { Check, Shield, Zap, Crown, X, ExternalLink, Loader2, CheckCircle2, ArrowRight, Coins } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useLanguage } from '@/lib/i18n';
|
import { useLanguage } from '@/lib/i18n';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -33,6 +33,14 @@ interface BillingStatus {
|
|||||||
year: { display: string; amount: number; currency: string };
|
year: { display: string; amount: number; currency: string };
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
creditPacks?: Array<{
|
||||||
|
id: 'S' | 'M' | 'L';
|
||||||
|
credits: number;
|
||||||
|
display: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
configured: boolean;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const billingEnabledEnvFallback = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' || process.env.NODE_ENV === 'development';
|
const billingEnabledEnvFallback = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' || process.env.NODE_ENV === 'development';
|
||||||
@@ -53,6 +61,7 @@ export function BillingPlans() {
|
|||||||
const [checkoutClientSecret, setCheckoutClientSecret] = useState<string | null>(null);
|
const [checkoutClientSecret, setCheckoutClientSecret] = useState<string | null>(null);
|
||||||
const [isCheckoutOpen, setIsCheckoutOpen] = useState(false);
|
const [isCheckoutOpen, setIsCheckoutOpen] = useState(false);
|
||||||
const [checkoutLoading, setCheckoutLoading] = useState<Tier | null>(null);
|
const [checkoutLoading, setCheckoutLoading] = useState<Tier | null>(null);
|
||||||
|
const [packLoading, setPackLoading] = useState<'S' | 'M' | 'L' | null>(null);
|
||||||
const [portalLoading, setPortalLoading] = useState(false);
|
const [portalLoading, setPortalLoading] = useState(false);
|
||||||
const [cancelLoading, setCancelLoading] = useState(false);
|
const [cancelLoading, setCancelLoading] = useState(false);
|
||||||
const [successBanner, setSuccessBanner] = useState<string | null>(null);
|
const [successBanner, setSuccessBanner] = useState<string | null>(null);
|
||||||
@@ -78,16 +87,35 @@ export function BillingPlans() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const quotas = usageData?.quotas;
|
const quotas = usageData?.quotas;
|
||||||
|
const balance = usageData?.balance as
|
||||||
|
| {
|
||||||
|
totalRemaining: number | null
|
||||||
|
subscriptionGranted: number | null
|
||||||
|
purchasedRemaining: number
|
||||||
|
spentThisPeriod: number
|
||||||
|
unlimited: boolean
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
const breakdown = (usageData?.breakdown ?? []) as Array<{
|
||||||
|
feature: string
|
||||||
|
creditsUsed: number
|
||||||
|
actionsCount: number
|
||||||
|
}>
|
||||||
const billingEnabled = status?.billingEnabled ?? billingEnabledEnvFallback;
|
const billingEnabled = status?.billingEnabled ?? billingEnabledEnvFallback;
|
||||||
const stripe = getStripePromise(billingEnabled);
|
const stripe = getStripePromise(billingEnabled);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const sessionId = params.get('session_id');
|
const sessionId = params.get('session_id');
|
||||||
|
const isPack = params.get('pack') === '1';
|
||||||
|
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
|
if (isPack) {
|
||||||
|
setSuccessBanner(t('billing.packCheckoutSuccess'));
|
||||||
|
} else {
|
||||||
const tier = status?.effectiveTier ?? 'Pro';
|
const tier = status?.effectiveTier ?? 'Pro';
|
||||||
setSuccessBanner(t('billing.checkoutSuccessBody').replace('{tier}', tier));
|
setSuccessBanner(t('billing.checkoutSuccessBody').replace('{tier}', tier));
|
||||||
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
|
queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['billing', 'status'] });
|
queryClient.invalidateQueries({ queryKey: ['billing', 'status'] });
|
||||||
window.history.replaceState({}, '', '/settings/billing');
|
window.history.replaceState({}, '', '/settings/billing');
|
||||||
@@ -128,6 +156,30 @@ export function BillingPlans() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBuyPack = async (packId: 'S' | 'M' | 'L') => {
|
||||||
|
if (!billingEnabled) return;
|
||||||
|
setPackLoading(packId);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/billing/create-pack-checkout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ packId }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error ?? 'Failed to create pack checkout');
|
||||||
|
if (data.url) {
|
||||||
|
window.location.href = data.url;
|
||||||
|
} else {
|
||||||
|
throw new Error('No checkout URL');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BillingPlans] pack checkout error:', err);
|
||||||
|
toast.error(t('billing.packCheckoutFailed'));
|
||||||
|
} finally {
|
||||||
|
setPackLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handlePortal = async (action: 'portal' | 'cancel' | React.MouseEvent = 'portal') => {
|
const handlePortal = async (action: 'portal' | 'cancel' | React.MouseEvent = 'portal') => {
|
||||||
const actualAction = typeof action === 'string' ? action : 'portal';
|
const actualAction = typeof action === 'string' ? action : 'portal';
|
||||||
setPortalLoading(true);
|
setPortalLoading(true);
|
||||||
@@ -199,23 +251,19 @@ export function BillingPlans() {
|
|||||||
const effectiveTier = status?.effectiveTier ?? 'BASIC';
|
const effectiveTier = status?.effectiveTier ?? 'BASIC';
|
||||||
const isPaid = effectiveTier !== 'BASIC';
|
const isPaid = effectiveTier !== 'BASIC';
|
||||||
|
|
||||||
const aiUsed = quotas?.semantic_search?.used ?? 0;
|
|
||||||
const aiLimit = quotas?.semantic_search?.limit ?? 50;
|
|
||||||
const aiPct = aiLimit > 0 ? (aiUsed / aiLimit) * 100 : 0;
|
|
||||||
|
|
||||||
const plans = [
|
const plans = [
|
||||||
{
|
{
|
||||||
id: 'free',
|
id: 'free',
|
||||||
name: t('billing.freePlan'),
|
name: t('billing.freePlan'),
|
||||||
price: t('billing.freePrice') || 'Gratuit',
|
price: t('billing.freePrice') || 'Gratuit',
|
||||||
period: '',
|
period: '',
|
||||||
description: t('billing.freeDescription') || 'Pour découvrir la magie de Memento.',
|
description: t('billing.freeDescription') || 'Pour découvrir Memento.',
|
||||||
features: [
|
features: [
|
||||||
t('billing.freeF1') || '100 Notes max',
|
t('billing.freeF1'),
|
||||||
t('billing.freeF2') || '3 Carnets',
|
t('billing.freeF2'),
|
||||||
t('billing.freeF3') || '50 crédits IA (Lifetime)',
|
t('billing.freeF3'),
|
||||||
t('billing.freeF4') || 'Recherche sémantique',
|
t('billing.freeF4'),
|
||||||
t('billing.freeF5') || 'Historique 7 jours',
|
t('billing.freeF5'),
|
||||||
],
|
],
|
||||||
current: effectiveTier === 'BASIC',
|
current: effectiveTier === 'BASIC',
|
||||||
buttonText: effectiveTier === 'BASIC' ? (t('billing.currentPlan') || 'Plan Actuel') : t('billing.startCheckout'),
|
buttonText: effectiveTier === 'BASIC' ? (t('billing.currentPlan') || 'Plan Actuel') : t('billing.startCheckout'),
|
||||||
@@ -232,12 +280,12 @@ export function BillingPlans() {
|
|||||||
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
|
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
|
||||||
description: t('billing.proDescription') || 'Pour les consultants et créateurs exigeants.',
|
description: t('billing.proDescription') || 'Pour les consultants et créateurs exigeants.',
|
||||||
features: [
|
features: [
|
||||||
t('billing.proFeature1') || 'Notes illimitées',
|
t('billing.proFeature1'),
|
||||||
t('billing.proFeature2') || 'BYOK (OpenAI/Anthropic)',
|
t('billing.proFeature2'),
|
||||||
t('billing.proFeature3') || '200 recherches sémantiques',
|
t('billing.proFeature3'),
|
||||||
t('billing.proFeature4') || 'Agents (12 runs/mois)',
|
t('billing.proFeature4'),
|
||||||
t('billing.proFeature5') || 'Historique 30 jours',
|
t('billing.proFeature5'),
|
||||||
t('billing.proFeature6') || 'Support Email',
|
t('billing.proFeature6'),
|
||||||
],
|
],
|
||||||
current: effectiveTier === 'PRO',
|
current: effectiveTier === 'PRO',
|
||||||
popular: true,
|
popular: true,
|
||||||
@@ -254,12 +302,12 @@ export function BillingPlans() {
|
|||||||
(interval === 'month' ? (t('billing.businessPrice') || '29,90€') : (t('billing.businessAnnualPrice') || '299€')),
|
(interval === 'month' ? (t('billing.businessPrice') || '29,90€') : (t('billing.businessAnnualPrice') || '299€')),
|
||||||
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
|
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
|
||||||
features: [
|
features: [
|
||||||
t('billing.businessFeature1') || '10 Collaborateurs inclus',
|
t('billing.businessFeature1'),
|
||||||
t('billing.businessFeature2') || 'BYOK (13 fournisseurs)',
|
t('billing.businessFeature2'),
|
||||||
t('billing.businessFeature3') || '1000 recherches sémantiques',
|
t('billing.businessFeature3'),
|
||||||
t('billing.businessFeature4') || 'Agents (60 runs/mois)',
|
t('billing.businessFeature4'),
|
||||||
t('billing.businessFeature5') || 'Brainstorm illimité',
|
t('billing.businessFeature5'),
|
||||||
t('billing.businessFeature6') || 'Accès API',
|
t('billing.businessFeature6'),
|
||||||
],
|
],
|
||||||
current: effectiveTier === 'BUSINESS',
|
current: effectiveTier === 'BUSINESS',
|
||||||
buttonText: effectiveTier === 'BUSINESS' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.businessCta') || 'Choisir Plan Business'),
|
buttonText: effectiveTier === 'BUSINESS' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.businessCta') || 'Choisir Plan Business'),
|
||||||
@@ -273,13 +321,13 @@ export function BillingPlans() {
|
|||||||
name: t('billing.enterpriseTitle') || 'Enterprise',
|
name: t('billing.enterpriseTitle') || 'Enterprise',
|
||||||
price: t('billing.contactSales') || 'Sur devis',
|
price: t('billing.contactSales') || 'Sur devis',
|
||||||
period: '',
|
period: '',
|
||||||
description: t('billing.enterpriseDescription') || 'Quotas personnalisés, SSO, support prioritaire.',
|
description: t('billing.enterpriseDescription') || 'Crédits illimités ou pool dédié, SSO, support prioritaire.',
|
||||||
features: [
|
features: [
|
||||||
t('billing.enterpriseFeature1') || 'Quotas illimités',
|
t('billing.enterpriseFeature1'),
|
||||||
t('billing.enterpriseFeature2') || 'SSO / SAML',
|
t('billing.enterpriseFeature2'),
|
||||||
t('billing.enterpriseFeature3') || 'Support dédié',
|
t('billing.enterpriseFeature3'),
|
||||||
t('billing.enterpriseFeature4') || 'Facturation personnalisée',
|
t('billing.enterpriseFeature4'),
|
||||||
t('billing.enterpriseFeature5') || 'SLA garanti',
|
t('billing.enterpriseFeature5'),
|
||||||
],
|
],
|
||||||
current: effectiveTier === 'ENTERPRISE',
|
current: effectiveTier === 'ENTERPRISE',
|
||||||
buttonText: effectiveTier === 'ENTERPRISE' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.contactSales') || 'Contact Sales'),
|
buttonText: effectiveTier === 'ENTERPRISE' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.contactSales') || 'Contact Sales'),
|
||||||
@@ -307,18 +355,21 @@ export function BillingPlans() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sommes pour l'usage global agrégé (donut)
|
// Solde crédits global (modèle A) — pas la somme des anciens seaux
|
||||||
let totalUsed = 0;
|
const unlimitedCredits = !!balance?.unlimited
|
||||||
let totalLimit = 0;
|
const totalUsed = balance?.spentThisPeriod ?? 0
|
||||||
if (quotas) {
|
const remainingCredits = unlimitedCredits ? null : (balance?.totalRemaining ?? 0)
|
||||||
Object.entries(quotas as Record<string, any>).forEach(([_, q]) => {
|
const granted = unlimitedCredits
|
||||||
if (q.limit > 0 && q.limit !== Infinity) {
|
? null
|
||||||
totalUsed += q.used;
|
: (balance?.subscriptionGranted ?? 0) + (balance?.purchasedRemaining ?? 0)
|
||||||
totalLimit += q.limit;
|
// pot ≈ restants + déjà utilisés sur la fenêtre courante
|
||||||
}
|
const totalLimit = unlimitedCredits
|
||||||
});
|
? 0
|
||||||
}
|
: Math.max(granted ?? 0, totalUsed + (remainingCredits ?? 0))
|
||||||
const globalPct = totalLimit > 0 ? (totalUsed / totalLimit) * 100 : 0;
|
const globalPct =
|
||||||
|
unlimitedCredits || totalLimit <= 0
|
||||||
|
? 0
|
||||||
|
: Math.min(100, (totalUsed / totalLimit) * 100)
|
||||||
|
|
||||||
// SVG Donut Config
|
// SVG Donut Config
|
||||||
const radius = 28;
|
const radius = 28;
|
||||||
@@ -450,79 +501,160 @@ export function BillingPlans() {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div className="absolute flex flex-col items-center justify-center">
|
<div className="absolute flex flex-col items-center justify-center">
|
||||||
<span className="text-xl font-bold text-ink">{Math.round(globalPct)}%</span>
|
<span className="text-xl font-bold text-ink">
|
||||||
|
{unlimitedCredits ? '∞' : `${Math.round(globalPct)}%`}
|
||||||
|
</span>
|
||||||
<span className="text-[8px] text-concrete uppercase tracking-widest">{t('billing.used')}</span>
|
<span className="text-[8px] text-concrete uppercase tracking-widest">{t('billing.used')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 text-center">
|
<div className="mt-4 text-center">
|
||||||
<p className="text-xs font-bold text-ink">
|
<p className="text-xs font-bold text-ink">
|
||||||
{totalUsed} <span className="text-concrete font-light">/ {totalLimit} {t('billing.aiCredits') || 'crédits IA'}</span>
|
{unlimitedCredits ? (
|
||||||
|
<span className="text-concrete font-light">{t('billing.unlimited')}</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{totalUsed}{' '}
|
||||||
|
<span className="text-concrete font-light">
|
||||||
|
/ {totalLimit} {t('billing.aiCredits') || 'crédits IA'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
{!unlimitedCredits && remainingCredits != null && (
|
||||||
|
<p className="text-[10px] text-concrete mt-1">
|
||||||
|
{remainingCredits} {t('billing.creditsRemaining')?.toLowerCase() || 'restants'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!unlimitedCredits && (balance?.purchasedRemaining ?? 0) > 0 && (
|
||||||
|
<p className="text-[10px] text-brand-accent mt-1">
|
||||||
|
{balance?.purchasedRemaining} {t('billing.creditsFromPacks')?.toLowerCase() || 'de packs'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Usage breakdown per feature */}
|
{/* Packs de crédits (one-shot) */}
|
||||||
|
{!unlimitedCredits && billingEnabled && (status?.creditPacks?.length ?? 0) > 0 && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="p-2.5 bg-brand-accent/10 text-brand-accent rounded-2xl shrink-0">
|
||||||
|
<Coins size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-concrete">
|
||||||
|
{t('billing.packsTitle')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[11px] text-concrete mt-1 leading-relaxed max-w-xl">
|
||||||
|
{t('billing.packsDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
{(status?.creditPacks ?? []).map((pack) => (
|
||||||
|
<div
|
||||||
|
key={pack.id}
|
||||||
|
className="p-6 rounded-3xl border border-border bg-white/40 dark:bg-white/5 flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
|
||||||
|
{t(`billing.pack${pack.id}Name`) || `Pack ${pack.id}`}
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-serif font-bold text-ink mt-1 tabular-nums">
|
||||||
|
{pack.credits.toLocaleString()}
|
||||||
|
<span className="text-sm font-sans font-normal text-concrete ms-1">
|
||||||
|
{t('billing.aiCredits') || 'crédits'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-semibold text-ink mt-2">{pack.display}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleBuyPack(pack.id)}
|
||||||
|
disabled={!pack.configured || packLoading !== null}
|
||||||
|
className="mt-auto w-full py-3 rounded-2xl bg-ink text-white dark:bg-white dark:text-black text-[10px] font-bold uppercase tracking-[0.15em] hover:opacity-90 disabled:opacity-40 transition-all"
|
||||||
|
>
|
||||||
|
{packLoading === pack.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mx-auto" />
|
||||||
|
) : (
|
||||||
|
t('billing.buyPack')
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Répartition crédits par fonction */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-concrete">
|
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-concrete">
|
||||||
{t('billing.usageThisPeriod') || 'Utilisation sur cette période'}
|
{t('billing.creditsWhereTitle') || t('billing.usageThisPeriod')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[10px] text-concrete mt-1">
|
<p className="text-[10px] text-concrete mt-1">
|
||||||
{status?.currentPeriodStart && status?.currentPeriodEnd ? (
|
{t('billing.creditsUsageLegend')}
|
||||||
`${formatDate(status.currentPeriodStart)} – ${formatDate(status.currentPeriodEnd)}`
|
|
||||||
) : (
|
|
||||||
'—'
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{!quotas && (
|
{!usageData && (
|
||||||
<div className="col-span-full flex items-center justify-center py-12 bg-white/40 dark:bg-white/5 border border-border rounded-3xl">
|
<div className="col-span-full flex items-center justify-center py-12 bg-white/40 dark:bg-white/5 border border-border rounded-3xl">
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-brand-accent" />
|
<Loader2 className="h-6 w-6 animate-spin text-brand-accent" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{quotas && Object.entries(quotas as Record<string, any>).map(([key, q]) => {
|
{usageData &&
|
||||||
const pct = q.limit > 0 && q.limit !== Infinity ? (q.used / q.limit) * 100 : 0;
|
(() => {
|
||||||
const isUnlimited = q.limit === Infinity || q.limit <= 0;
|
|
||||||
|
|
||||||
const featureLabels: Record<string, string> = {
|
const featureLabels: Record<string, string> = {
|
||||||
semantic_search: t('usageMeter.featureSearch') || 'Recherche Sémantique',
|
semantic_search: t('usageMeter.featureSearch'),
|
||||||
auto_tag: t('usageMeter.featureTags') || 'Tags Automatiques',
|
auto_tag: t('usageMeter.featureTags'),
|
||||||
auto_title: t('usageMeter.featureTitles') || 'Titres Automatiques',
|
auto_title: t('usageMeter.featureTitles'),
|
||||||
reformulate: t('usageMeter.featureReformulate') || 'Reformulation IA',
|
reformulate: t('usageMeter.featureReformulate'),
|
||||||
chat: t('usageMeter.featureChat') || 'Chat IA',
|
chat: t('usageMeter.featureChat'),
|
||||||
brainstorm_create: t('usageMeter.featureBrainstormCreate') || 'Création de Brainstorm',
|
brainstorm_create: t('usageMeter.featureBrainstormSessions'),
|
||||||
brainstorm_expand: t('usageMeter.featureBrainstormExpand') || 'Expansion de Brainstorm',
|
brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
|
||||||
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich') || 'Enrichissement de Brainstorm',
|
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
|
||||||
};
|
suggest_charts: t('usageMeter.featureCharts'),
|
||||||
|
publish_enhance: t('usageMeter.featurePublishEnhance'),
|
||||||
// Couleur de barre : normal (violet), warning >= 75% (ambre), exhausted >= 100% (rose)
|
ai_flashcard: t('usageMeter.featureFlashcards'),
|
||||||
const barFillColor = pct >= 100 ? 'bg-rose-400' : pct >= 75 ? 'bg-amber-400' : 'bg-gradient-to-r from-violet-400 to-purple-400';
|
voice_transcribe: t('usageMeter.featureVoice'),
|
||||||
|
slide_generate: t('usageMeter.featureSlides'),
|
||||||
|
excalidraw_generate: t('usageMeter.featureDiagrams'),
|
||||||
|
}
|
||||||
|
const byFeature = new Map(breakdown.map((r) => [r.feature, r]))
|
||||||
|
return Object.keys(featureLabels).map((key) => {
|
||||||
|
const row = byFeature.get(key)
|
||||||
|
const used = row?.creditsUsed ?? 0
|
||||||
|
const pct = totalUsed > 0 && used > 0 ? (used / totalUsed) * 100 : 0
|
||||||
|
const barFillColor =
|
||||||
|
pct >= 40
|
||||||
|
? 'bg-gradient-to-r from-violet-400 to-purple-400'
|
||||||
|
: 'bg-gradient-to-r from-violet-300/80 to-purple-300/80'
|
||||||
return (
|
return (
|
||||||
<div key={key} className="p-5 rounded-2xl bg-white/40 dark:bg-white/5 border border-border space-y-3">
|
<div
|
||||||
<div className="flex justify-between items-center">
|
key={key}
|
||||||
|
className="p-5 rounded-2xl bg-white/40 dark:bg-white/5 border border-border space-y-3"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center gap-2">
|
||||||
<span className="text-[11px] font-bold text-ink uppercase tracking-wider truncate">
|
<span className="text-[11px] font-bold text-ink uppercase tracking-wider truncate">
|
||||||
{featureLabels[key] || key}
|
{featureLabels[key]}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] font-medium text-concrete tabular-nums">
|
<span className="text-[11px] font-medium text-concrete tabular-nums shrink-0">
|
||||||
{isUnlimited ? t('billing.unlimited') || 'Illimité' : `${q.used} / ${q.limit}`}
|
{used > 0 ? `${used} cr.` : '0'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-2 w-full bg-secondary/40 rounded-full overflow-hidden">
|
<div className="h-2 w-full bg-secondary/40 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={cn('h-full rounded-full transition-all duration-500', barFillColor)}
|
className={cn('h-full rounded-full transition-all duration-500', barFillColor)}
|
||||||
style={{ width: `${isUnlimited ? 100 : Math.min(pct, 100)}%` }}
|
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
})}
|
})
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,55 +1,84 @@
|
|||||||
'use client';
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation'
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
import { AlertTriangle, Sparkles, Key, X, ArrowRight } from 'lucide-react';
|
import { AlertTriangle, Sparkles, Key, X, ArrowRight } from 'lucide-react'
|
||||||
import { useLanguage } from '@/lib/i18n';
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
interface InlinePaywallProps {
|
interface InlinePaywallProps {
|
||||||
feature: string;
|
/** Action en cours (pour contexte uniquement — le solde est global) */
|
||||||
onDismiss: () => void;
|
feature: string
|
||||||
|
onDismiss: () => void
|
||||||
|
/** Coût de l'action si connu */
|
||||||
|
creditCost?: number
|
||||||
|
/** Solde restant si connu */
|
||||||
|
creditsRemaining?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
|
export function InlinePaywall({
|
||||||
const { t } = useLanguage();
|
feature,
|
||||||
const router = useRouter();
|
onDismiss,
|
||||||
const [timeLeft, setTimeLeft] = useState(10);
|
creditCost,
|
||||||
|
creditsRemaining,
|
||||||
|
}: InlinePaywallProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const router = useRouter()
|
||||||
|
const [timeLeft, setTimeLeft] = useState(12)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (timeLeft <= 0) {
|
if (timeLeft <= 0) {
|
||||||
onDismiss();
|
onDismiss()
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setTimeLeft((prev) => prev - 1);
|
setTimeLeft((prev) => prev - 1)
|
||||||
}, 1000);
|
}, 1000)
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer)
|
||||||
}, [timeLeft, onDismiss]);
|
}, [timeLeft, onDismiss])
|
||||||
|
|
||||||
const handleUpgrade = () => {
|
const handleUpgrade = () => {
|
||||||
router.push('/settings/billing');
|
router.push('/settings/billing')
|
||||||
onDismiss();
|
onDismiss()
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleByok = () => {
|
const handleByok = () => {
|
||||||
router.push('/settings/ai#byok');
|
router.push('/settings/ai#byok')
|
||||||
onDismiss();
|
onDismiss()
|
||||||
};
|
}
|
||||||
|
|
||||||
const featureLabels: Record<string, string> = {
|
const featureLabels: Record<string, string> = {
|
||||||
semantic_search: t('usageMeter.featureSearch') || 'Recherche Sémantique',
|
semantic_search: t('usageMeter.featureSearch'),
|
||||||
auto_tag: t('usageMeter.featureTags') || 'Tags Automatiques',
|
auto_tag: t('usageMeter.featureTags'),
|
||||||
auto_title: t('usageMeter.featureTitles') || 'Titres Automatiques',
|
auto_title: t('usageMeter.featureTitles'),
|
||||||
reformulate: t('usageMeter.featureReformulate') || 'Reformulation',
|
reformulate: t('usageMeter.featureReformulate'),
|
||||||
chat: t('usageMeter.featureChat') || 'Chat IA',
|
chat: t('usageMeter.featureChat'),
|
||||||
brainstorm_create: t('usageMeter.featureBrainstormCreate') || 'Création de Brainstorm',
|
brainstorm_create: t('usageMeter.featureBrainstormSessions'),
|
||||||
brainstorm_expand: t('usageMeter.featureBrainstormExpand') || 'Expansion de Brainstorm',
|
brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
|
||||||
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich') || 'Enrichissement de Brainstorm',
|
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
|
||||||
publish_enhance: t('usageMeter.featurePublishEnhance') || 'Publication IA',
|
publish_enhance: t('usageMeter.featurePublishEnhance'),
|
||||||
};
|
slide_generate: t('usageMeter.featureSlides'),
|
||||||
|
excalidraw_generate: t('usageMeter.featureDiagrams'),
|
||||||
|
ai_flashcard: t('usageMeter.featureFlashcards'),
|
||||||
|
voice_transcribe: t('usageMeter.featureVoice'),
|
||||||
|
credits: t('billing.aiCredits'),
|
||||||
|
}
|
||||||
|
|
||||||
const currentFeatureLabel = featureLabels[feature] || feature;
|
const currentFeatureLabel = featureLabels[feature] || feature
|
||||||
|
|
||||||
|
let description = t('quotaPaywall.description').replace('{feature}', currentFeatureLabel)
|
||||||
|
if (
|
||||||
|
typeof creditCost === 'number' &&
|
||||||
|
typeof creditsRemaining === 'number' &&
|
||||||
|
Number.isFinite(creditCost) &&
|
||||||
|
Number.isFinite(creditsRemaining)
|
||||||
|
) {
|
||||||
|
description = t('quotaPaywall.descriptionWithCost', {
|
||||||
|
remaining: String(creditsRemaining),
|
||||||
|
cost: String(creditCost),
|
||||||
|
feature: currentFeatureLabel,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -66,17 +95,17 @@ export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 space-y-1">
|
<div className="flex-1 space-y-1">
|
||||||
<h4 className="text-sm font-bold text-rose-800 dark:text-rose-400">
|
<h4 className="text-sm font-bold text-rose-800 dark:text-rose-400">
|
||||||
{t('quotaPaywall.title') || 'Limite mensuelle atteinte'}
|
{t('quotaPaywall.title')}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-xs text-rose-700/80 dark:text-rose-400/70 font-light leading-relaxed">
|
<p className="text-xs text-rose-700/80 dark:text-rose-400/70 font-light leading-relaxed">
|
||||||
{(t('quotaPaywall.description') || "Vous avez épuisé vos crédits pour la fonctionnalité {feature} ce mois-ci.").replace('{feature}', currentFeatureLabel)}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
className="text-rose-500/60 hover:text-rose-500 dark:text-rose-400/50 dark:hover:text-rose-400 p-1 hover:bg-rose-500/5 dark:hover:bg-rose-400/5 rounded-lg transition-all"
|
className="text-rose-500/60 hover:text-rose-500 dark:text-rose-400/50 dark:hover:text-rose-400 p-1 hover:bg-rose-500/5 dark:hover:bg-rose-400/5 rounded-lg transition-all"
|
||||||
title={t('quotaPaywall.later') || 'Peut-être plus tard'}
|
title={t('quotaPaywall.later')}
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -89,7 +118,7 @@ export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
|
|||||||
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-[#D4A373] text-white hover:bg-[#C49363] text-xs font-semibold rounded-xl transition-all shadow-sm shadow-[#D4A373]/15"
|
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-[#D4A373] text-white hover:bg-[#C49363] text-xs font-semibold rounded-xl transition-all shadow-sm shadow-[#D4A373]/15"
|
||||||
>
|
>
|
||||||
<Sparkles size={14} />
|
<Sparkles size={14} />
|
||||||
{t('quotaPaywall.upgrade') || 'Passer au plan Pro'}
|
{t('quotaPaywall.upgrade')}
|
||||||
<ArrowRight size={12} className="ml-1" />
|
<ArrowRight size={12} className="ml-1" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -99,7 +128,7 @@ export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
|
|||||||
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 border border-rose-300 dark:border-rose-800/40 text-rose-800 dark:text-rose-400 hover:bg-rose-100/30 dark:hover:bg-rose-400/5 text-xs font-semibold rounded-xl transition-all"
|
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 border border-rose-300 dark:border-rose-800/40 text-rose-800 dark:text-rose-400 hover:bg-rose-100/30 dark:hover:bg-rose-400/5 text-xs font-semibold rounded-xl transition-all"
|
||||||
>
|
>
|
||||||
<Key size={14} />
|
<Key size={14} />
|
||||||
{t('quotaPaywall.useOwnKey') || 'Utiliser ma propre clé'}
|
{t('quotaPaywall.useOwnKey')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="sm:ml-auto flex items-center justify-center gap-1.5 text-[10px] text-rose-500/60 dark:text-rose-400/40 font-mono">
|
<div className="sm:ml-auto flex items-center justify-center gap-1.5 text-[10px] text-rose-500/60 dark:text-rose-400/40 font-mono">
|
||||||
@@ -108,5 +137,5 @@ export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,88 @@
|
|||||||
'use client';
|
'use client'
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Loader2, BarChart2 } from 'lucide-react';
|
import { Loader2, BarChart2 } from 'lucide-react'
|
||||||
import { useLanguage } from '@/lib/i18n';
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface QuotaEntry {
|
interface BalanceData {
|
||||||
remaining: number;
|
totalRemaining: number | null
|
||||||
limit: number;
|
subscriptionRemaining: number | null
|
||||||
used: number;
|
purchasedRemaining: number
|
||||||
|
subscriptionGranted: number | null
|
||||||
|
spentThisPeriod: number
|
||||||
|
unlimited: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type Quotas = Record<string, QuotaEntry>;
|
interface BreakdownRow {
|
||||||
|
feature: string
|
||||||
|
creditsUsed: number
|
||||||
|
actionsCount: number
|
||||||
|
percentOfSpent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toutes les fonctions IA — même liste que le compteur sidebar. */
|
||||||
const FEATURE_LABEL_KEYS: Record<string, string> = {
|
const FEATURE_LABEL_KEYS: Record<string, string> = {
|
||||||
aiSummary: 'sidebar.aiSummary',
|
semantic_search: 'usageMeter.featureSearch',
|
||||||
aiFlashcards: 'sidebar.aiFlashcards',
|
auto_tag: 'usageMeter.featureTags',
|
||||||
aiMindmap: 'sidebar.aiMindmap',
|
auto_title: 'usageMeter.featureTitles',
|
||||||
aiTranscribe: 'sidebar.aiTranscribe',
|
reformulate: 'usageMeter.featureReformulate',
|
||||||
aiDiagram: 'sidebar.aiDiagram',
|
chat: 'usageMeter.featureChat',
|
||||||
aiAgent: 'sidebar.aiAgent',
|
brainstorm_create: 'usageMeter.featureBrainstormSessions',
|
||||||
|
brainstorm_expand: 'usageMeter.featureBrainstormExpand',
|
||||||
|
brainstorm_enrich: 'usageMeter.featureBrainstormEnrich',
|
||||||
|
suggest_charts: 'usageMeter.featureCharts',
|
||||||
publish_enhance: 'usageMeter.featurePublishEnhance',
|
publish_enhance: 'usageMeter.featurePublishEnhance',
|
||||||
};
|
ai_flashcard: 'usageMeter.featureFlashcards',
|
||||||
|
voice_transcribe: 'usageMeter.featureVoice',
|
||||||
function UsageBar({ used, limit, isUnlimited }: { used: number; limit: number; isUnlimited: boolean }) {
|
slide_generate: 'usageMeter.featureSlides',
|
||||||
const pct = isUnlimited ? 0 : Math.min(100, limit > 0 ? (used / limit) * 100 : 0);
|
excalidraw_generate: 'usageMeter.featureDiagrams',
|
||||||
const color =
|
}
|
||||||
pct >= 90 ? 'bg-rose-500' : pct >= 70 ? 'bg-amber-500' : 'bg-primary';
|
|
||||||
|
|
||||||
|
function UsageBar({ percent }: { percent: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="h-1.5 w-full rounded-full bg-foreground/10 overflow-hidden">
|
<div className="h-1.5 w-full rounded-full bg-foreground/10 overflow-hidden">
|
||||||
{!isUnlimited && (
|
|
||||||
<div
|
<div
|
||||||
className={cn('h-full rounded-full transition-all', color)}
|
className={cn(
|
||||||
style={{ width: `${pct}%` }}
|
'h-full rounded-full transition-all',
|
||||||
/>
|
percent >= 40 ? 'bg-primary' : 'bg-primary/60',
|
||||||
)}
|
)}
|
||||||
|
style={{ width: `${Math.min(Math.max(percent, 0), 100)}%` }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UsageBreakdown() {
|
export function UsageBreakdown() {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage()
|
||||||
|
|
||||||
const { data, isLoading } = useQuery<{ quotas: Quotas }>({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['usage', 'current'],
|
queryKey: ['usage', 'current'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/api/usage/current');
|
const res = await fetch('/api/usage/current')
|
||||||
if (!res.ok) throw new Error('Failed to fetch usage');
|
if (!res.ok) throw new Error('Failed to fetch usage')
|
||||||
return res.json();
|
return res.json() as Promise<{
|
||||||
|
balance: BalanceData
|
||||||
|
breakdown: BreakdownRow[]
|
||||||
|
period: string
|
||||||
|
tier: string
|
||||||
|
}>
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
const quotas = data?.quotas ?? {};
|
const balance = data?.balance
|
||||||
const entries = Object.entries(quotas);
|
const byFeature = new Map((data?.breakdown ?? []).map((r) => [r.feature, r]))
|
||||||
|
const spent = balance?.spentThisPeriod ?? 0
|
||||||
|
|
||||||
|
const rows = Object.keys(FEATURE_LABEL_KEYS).map((key) => {
|
||||||
|
const row = byFeature.get(key)
|
||||||
|
return {
|
||||||
|
feature: key,
|
||||||
|
label: t(FEATURE_LABEL_KEYS[key]),
|
||||||
|
creditsUsed: row?.creditsUsed ?? 0,
|
||||||
|
actionsCount: row?.actionsCount ?? 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border/40 bg-paper p-6 space-y-4">
|
<div className="rounded-xl border border-border/40 bg-paper p-6 space-y-4">
|
||||||
@@ -68,33 +97,72 @@ export function UsageBreakdown() {
|
|||||||
<div className="flex justify-center py-4">
|
<div className="flex justify-center py-4">
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
) : entries.length === 0 ? (
|
) : !balance ? (
|
||||||
<p className="text-xs text-muted-foreground py-2">{t('billing.noUsage')}</p>
|
<p className="text-xs text-muted-foreground py-2">{t('billing.noUsage')}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<>
|
||||||
{entries.map(([feature, quota]) => {
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
const isUnlimited = quota.limit === -1 || !isFinite(quota.limit);
|
<div className="rounded-lg border border-border/40 p-3">
|
||||||
const labelKey = FEATURE_LABEL_KEYS[feature];
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
|
||||||
const label = labelKey ? t(labelKey) : feature;
|
{t('billing.creditsRemaining')}
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-semibold tabular-nums mt-1">
|
||||||
|
{balance.unlimited ? t('billing.unlimited') : balance.totalRemaining}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border/40 p-3">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
|
||||||
|
{t('billing.creditsSpent')}
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-semibold tabular-nums mt-1">
|
||||||
|
{balance.spentThisPeriod}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border/40 p-3">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
|
||||||
|
{t('billing.creditsFromPacks')}
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-semibold tabular-nums mt-1">
|
||||||
|
{balance.purchasedRemaining}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs font-medium text-foreground pt-1">
|
||||||
|
{t('billing.creditsWhereTitle')}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||||
|
{t('billing.creditsUsageLegend')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{rows.map((row) => {
|
||||||
|
const barPct =
|
||||||
|
spent > 0 && row.creditsUsed > 0
|
||||||
|
? Math.min(100, (row.creditsUsed / spent) * 100)
|
||||||
|
: 0
|
||||||
return (
|
return (
|
||||||
<div key={feature} className="space-y-1">
|
<div key={row.feature} className="space-y-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="text-xs text-muted-foreground">{label}</span>
|
<span className="text-xs text-muted-foreground">{row.label}</span>
|
||||||
<span className="text-xs font-medium text-foreground tabular-nums">
|
<span
|
||||||
{isUnlimited ? (
|
className={cn(
|
||||||
<span className="text-primary/80 dark:text-primary">{t('billing.unlimited')}</span>
|
'text-xs font-medium tabular-nums shrink-0',
|
||||||
) : (
|
row.creditsUsed > 0 ? 'text-foreground' : 'text-muted-foreground/60',
|
||||||
`${quota.used} / ${quota.limit}`
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
{row.creditsUsed > 0
|
||||||
|
? `${row.creditsUsed} cr. · ${row.actionsCount}×`
|
||||||
|
: '0'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<UsageBar used={quota.used} limit={quota.limit} isUnlimited={isUnlimited} />
|
<UsageBar percent={barPct} />
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import {
|
|||||||
import { useSearchModal } from '@/context/search-modal-context'
|
import { useSearchModal } from '@/context/search-modal-context'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { applyDocumentTheme } from '@/lib/apply-document-theme'
|
import { persistThemePreference } from '@/lib/apply-document-theme'
|
||||||
import { getAllNotes, getTrashCount, getNotesWithReminders, toggleReminderDone, getInboxCount } from '@/app/actions/notes'
|
import { getAllNotes, getTrashCount, getNotesWithReminders, toggleReminderDone, getInboxCount } from '@/app/actions/notes'
|
||||||
import { NOTE_CHANGE_EVENT, type NoteChangeEvent } from '@/lib/note-change-sync'
|
import { NOTE_CHANGE_EVENT, type NoteChangeEvent } from '@/lib/note-change-sync'
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
@@ -676,9 +676,9 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
const toggleTheme = useCallback(() => {
|
const toggleTheme = useCallback(() => {
|
||||||
const next = !isDark
|
const next = !isDark
|
||||||
setIsDark(next)
|
setIsDark(next)
|
||||||
const theme = next ? 'dark' : 'light'
|
// Cookie + localStorage : le serveur doit connaître le dark, sinon flash clair
|
||||||
localStorage.setItem('theme-preference', theme)
|
// à chaque changement de page (layout racine réécrit <html>).
|
||||||
applyDocumentTheme(theme)
|
persistThemePreference(next ? 'dark' : 'light')
|
||||||
}, [isDark])
|
}, [isDark])
|
||||||
const [deletingNotebook, setDeletingNotebook] = useState<Notebook | null>(null)
|
const [deletingNotebook, setDeletingNotebook] = useState<Notebook | null>(null)
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
|||||||
16
memento-note/components/sw-cleanup.tsx
Normal file
16
memento-note/components/sw-cleanup.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
/** Désenregistre d’anciens service workers (remplace /scripts/sw-cleanup.js). */
|
||||||
|
export function SwCleanup() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!('serviceWorker' in navigator)) return
|
||||||
|
void navigator.serviceWorker.getRegistrations().then((rs) => {
|
||||||
|
rs.forEach((r) => {
|
||||||
|
void r.unregister()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
return null
|
||||||
|
}
|
||||||
102
memento-note/components/theme-class-guard.tsx
Normal file
102
memento-note/components/theme-class-guard.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useLayoutEffect } from 'react'
|
||||||
|
import { persistThemePreference, normalizeThemeId } from '@/lib/apply-document-theme'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empêche le flash clair en mode sombre.
|
||||||
|
*
|
||||||
|
* Au changement de route, le layout racine Next peut réécrire la classe de
|
||||||
|
* <html> depuis le serveur (souvent « light »). Sans garde, une frame claire
|
||||||
|
* apparaît avant le prochain effet React.
|
||||||
|
*
|
||||||
|
* useLayoutEffect = avant le paint navigateur.
|
||||||
|
* MutationObserver = remet le thème si React retire « dark » à tort.
|
||||||
|
*/
|
||||||
|
export function ThemeClassGuard() {
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
let applying = false
|
||||||
|
|
||||||
|
const preferredTheme = (): string => {
|
||||||
|
try {
|
||||||
|
const local = localStorage.getItem('theme-preference')
|
||||||
|
if (local) return normalizeThemeId(local)
|
||||||
|
} catch {
|
||||||
|
/* localStorage indisponible */
|
||||||
|
}
|
||||||
|
const server =
|
||||||
|
document.documentElement.getAttribute('data-server-theme') || 'light'
|
||||||
|
return normalizeThemeId(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
const wantsDarkClass = (theme: string): boolean => {
|
||||||
|
const t = normalizeThemeId(theme)
|
||||||
|
if (t === 'dark' || t === 'midnight') return true
|
||||||
|
if (t === 'auto') {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const sync = () => {
|
||||||
|
if (applying) return
|
||||||
|
applying = true
|
||||||
|
try {
|
||||||
|
persistThemePreference(preferredTheme())
|
||||||
|
} finally {
|
||||||
|
// Laisser le navigateur appliquer le DOM avant de réarmer
|
||||||
|
queueMicrotask(() => {
|
||||||
|
applying = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immédiat (avant paint)
|
||||||
|
sync()
|
||||||
|
|
||||||
|
// Après commit React éventuel qui a écrasé <html>
|
||||||
|
const raf1 = requestAnimationFrame(() => {
|
||||||
|
sync()
|
||||||
|
requestAnimationFrame(sync)
|
||||||
|
})
|
||||||
|
|
||||||
|
const root = document.documentElement
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
if (applying) return
|
||||||
|
const theme = preferredTheme()
|
||||||
|
const needDark = wantsDarkClass(theme)
|
||||||
|
const hasDark = root.classList.contains('dark')
|
||||||
|
if (needDark && !hasDark) {
|
||||||
|
sync()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// data-theme retiré à tort pour une palette nommée
|
||||||
|
if (
|
||||||
|
theme !== 'light' &&
|
||||||
|
theme !== 'dark' &&
|
||||||
|
theme !== 'auto' &&
|
||||||
|
root.getAttribute('data-theme') !== theme
|
||||||
|
) {
|
||||||
|
sync()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
observer.observe(root, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class', 'data-theme'],
|
||||||
|
})
|
||||||
|
|
||||||
|
const onStorage = (e: StorageEvent) => {
|
||||||
|
if (e.key === 'theme-preference') sync()
|
||||||
|
}
|
||||||
|
window.addEventListener('storage', onStorage)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf1)
|
||||||
|
observer.disconnect()
|
||||||
|
window.removeEventListener('storage', onStorage)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
import { useLayoutEffect } from 'react'
|
||||||
import { applyDocumentTheme, normalizeThemeId } from '@/lib/apply-document-theme'
|
import { applyDocumentTheme, normalizeThemeId, persistThemePreference } from '@/lib/apply-document-theme'
|
||||||
|
|
||||||
interface ThemeInitializerProps {
|
interface ThemeInitializerProps {
|
||||||
theme?: string
|
theme?: string
|
||||||
@@ -11,7 +11,10 @@ interface ThemeInitializerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeInitializer({ theme, fontSize, fontFamily, accentColor }: ThemeInitializerProps) {
|
export function ThemeInitializer({ theme, fontSize, fontFamily, accentColor }: ThemeInitializerProps) {
|
||||||
useEffect(() => {
|
// useLayoutEffect : avant le paint (useEffect = trop tard = flash clair)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const root = document.documentElement
|
||||||
|
|
||||||
const applyFontSize = (s?: string) => {
|
const applyFontSize = (s?: string) => {
|
||||||
const size = s || 'medium'
|
const size = s || 'medium'
|
||||||
|
|
||||||
@@ -29,25 +32,21 @@ export function ThemeInitializer({ theme, fontSize, fontFamily, accentColor }: T
|
|||||||
'extra-large': 1.25,
|
'extra-large': 1.25,
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = document.documentElement
|
|
||||||
root.style.setProperty('--user-font-size', fontSizeMap[size] || '16px')
|
root.style.setProperty('--user-font-size', fontSizeMap[size] || '16px')
|
||||||
root.style.setProperty('--user-font-size-factor', (fontSizeFactorMap[size] || 1).toString())
|
root.style.setProperty('--user-font-size-factor', (fontSizeFactorMap[size] || 1).toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Préférence locale prioritaire (jamais écrasée par un rendu serveur « light »)
|
||||||
const localTheme = localStorage.getItem('theme-preference')
|
const localTheme = localStorage.getItem('theme-preference')
|
||||||
const effectiveTheme = localTheme || theme || 'light'
|
const effectiveTheme = normalizeThemeId(localTheme || theme || 'light')
|
||||||
|
|
||||||
applyDocumentTheme(effectiveTheme)
|
// Persiste aussi le cookie pour les prochains rendus serveur (anti-flash navigation)
|
||||||
|
persistThemePreference(effectiveTheme)
|
||||||
if (!localTheme && theme) {
|
|
||||||
localStorage.setItem('theme-preference', normalizeThemeId(theme))
|
|
||||||
}
|
|
||||||
|
|
||||||
applyFontSize(fontSize)
|
applyFontSize(fontSize)
|
||||||
|
|
||||||
const localFontFamily = localStorage.getItem('font-family')
|
const localFontFamily = localStorage.getItem('font-family')
|
||||||
const effectiveFontFamily = localFontFamily || fontFamily || 'inter'
|
const effectiveFontFamily = localFontFamily || fontFamily || 'inter'
|
||||||
const root = document.documentElement
|
|
||||||
root.classList.remove('font-system', 'font-playfair', 'font-jetbrains')
|
root.classList.remove('font-system', 'font-playfair', 'font-jetbrains')
|
||||||
if (effectiveFontFamily === 'system') root.classList.add('font-system')
|
if (effectiveFontFamily === 'system') root.classList.add('font-system')
|
||||||
if (effectiveFontFamily === 'playfair') root.classList.add('font-playfair')
|
if (effectiveFontFamily === 'playfair') root.classList.add('font-playfair')
|
||||||
@@ -58,7 +57,7 @@ export function ThemeInitializer({ theme, fontSize, fontFamily, accentColor }: T
|
|||||||
|
|
||||||
const localAccent = localStorage.getItem('accent-color')
|
const localAccent = localStorage.getItem('accent-color')
|
||||||
const effectiveAccent = localAccent || accentColor || '#A47148'
|
const effectiveAccent = localAccent || accentColor || '#A47148'
|
||||||
document.documentElement.style.setProperty('--color-brand-accent', effectiveAccent)
|
root.style.setProperty('--color-brand-accent', effectiveAccent)
|
||||||
if (!localAccent && accentColor) {
|
if (!localAccent && accentColor) {
|
||||||
localStorage.setItem('accent-color', accentColor)
|
localStorage.setItem('accent-color', accentColor)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,123 +1,128 @@
|
|||||||
'use client';
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation'
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils'
|
||||||
import { Sparkles, ChevronDown, X, Crown } from 'lucide-react';
|
import { Sparkles, ChevronDown, X, Crown } from 'lucide-react'
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
import { useLanguage } from '@/lib/i18n';
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
interface QuotaData {
|
interface BalanceData {
|
||||||
remaining: number;
|
totalRemaining: number | null
|
||||||
limit: number;
|
subscriptionRemaining: number | null
|
||||||
used: number;
|
purchasedRemaining: number
|
||||||
|
subscriptionGranted: number | null
|
||||||
|
subscriptionSpent: number
|
||||||
|
spentThisPeriod: number
|
||||||
|
unlimited: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BreakdownRow {
|
||||||
|
feature: string
|
||||||
|
creditsUsed: number
|
||||||
|
actionsCount: number
|
||||||
|
percentOfSpent: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UsageMeterProps {
|
interface UsageMeterProps {
|
||||||
className?: string;
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Même liste que l'ancien compteur — toutes les actions IA, pas seulement les slides. */
|
||||||
|
const FEATURE_LABEL_KEYS: Record<string, string> = {
|
||||||
|
semantic_search: 'usageMeter.featureSearch',
|
||||||
|
auto_tag: 'usageMeter.featureTags',
|
||||||
|
auto_title: 'usageMeter.featureTitles',
|
||||||
|
reformulate: 'usageMeter.featureReformulate',
|
||||||
|
chat: 'usageMeter.featureChat',
|
||||||
|
brainstorm_create: 'usageMeter.featureBrainstormSessions',
|
||||||
|
brainstorm_expand: 'usageMeter.featureBrainstormExpand',
|
||||||
|
brainstorm_enrich: 'usageMeter.featureBrainstormEnrich',
|
||||||
|
suggest_charts: 'usageMeter.featureCharts',
|
||||||
|
publish_enhance: 'usageMeter.featurePublishEnhance',
|
||||||
|
ai_flashcard: 'usageMeter.featureFlashcards',
|
||||||
|
voice_transcribe: 'usageMeter.featureVoice',
|
||||||
|
slide_generate: 'usageMeter.featureSlides',
|
||||||
|
excalidraw_generate: 'usageMeter.featureDiagrams',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UsageMeter({ className }: UsageMeterProps) {
|
export function UsageMeter({ className }: UsageMeterProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage()
|
||||||
const router = useRouter();
|
const router = useRouter()
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient()
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false)
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['usage', 'current'],
|
queryKey: ['usage', 'current'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/api/usage/current');
|
const res = await fetch('/api/usage/current')
|
||||||
if (!res.ok) throw new Error('Failed to fetch quotas');
|
if (!res.ok) throw new Error('Failed to fetch usage')
|
||||||
const json = await res.json();
|
const json = await res.json()
|
||||||
return { quotas: json.quotas as Record<string, QuotaData>, tier: json.tier as string };
|
return {
|
||||||
|
tier: json.tier as string,
|
||||||
|
period: json.period as string,
|
||||||
|
balance: json.balance as BalanceData,
|
||||||
|
breakdown: (json.breakdown ?? []) as BreakdownRow[],
|
||||||
|
}
|
||||||
},
|
},
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
refetchInterval: 5000,
|
refetchInterval: 5000,
|
||||||
});
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = () => queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
|
const handler = () => queryClient.invalidateQueries({ queryKey: ['usage', 'current'] })
|
||||||
window.addEventListener('ai-usage-changed', handler);
|
window.addEventListener('ai-usage-changed', handler)
|
||||||
return () => window.removeEventListener('ai-usage-changed', handler);
|
return () => window.removeEventListener('ai-usage-changed', handler)
|
||||||
}, [queryClient]);
|
}, [queryClient])
|
||||||
|
|
||||||
if (isLoading || !data || !data.quotas) {
|
if (isLoading || !data?.balance) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('px-2 py-2', className)}>
|
<div className={cn('px-2 py-2', className)}>
|
||||||
<div className="h-8 rounded-lg bg-paper/50 animate-pulse" />
|
<div className="h-8 rounded-lg bg-paper/50 animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPackLabel = () => {
|
const getPackLabel = () => {
|
||||||
if (!data.tier) return t('usageMeter.packName');
|
if (!data.tier) return t('usageMeter.packName')
|
||||||
switch (data.tier) {
|
switch (data.tier) {
|
||||||
case 'PRO': return t('usageMeter.packPro');
|
case 'PRO':
|
||||||
case 'BUSINESS': return t('usageMeter.packBusiness');
|
return t('usageMeter.packPro')
|
||||||
case 'ENTERPRISE': return t('usageMeter.packEnterprise');
|
case 'BUSINESS':
|
||||||
default: return t('usageMeter.packName');
|
return t('usageMeter.packBusiness')
|
||||||
|
case 'ENTERPRISE':
|
||||||
|
return t('usageMeter.packEnterprise')
|
||||||
|
default:
|
||||||
|
return t('usageMeter.packName')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const isProPlus = data.tier && data.tier !== 'BASIC';
|
const isProPlus = data.tier && data.tier !== 'BASIC'
|
||||||
|
const unlimited = !!data.balance.unlimited
|
||||||
|
const remaining = unlimited ? Infinity : (data.balance.totalRemaining ?? 0)
|
||||||
|
const granted = unlimited ? Infinity : (data.balance.subscriptionGranted ?? 0)
|
||||||
|
const spent = data.balance.spentThisPeriod ?? 0
|
||||||
|
// Barre = part du forfait consommée (crédits utilisés / allocation du mois)
|
||||||
|
const totalPct =
|
||||||
|
!unlimited && Number.isFinite(granted) && granted > 0
|
||||||
|
? Math.min(100, (spent / granted) * 100)
|
||||||
|
: 0
|
||||||
|
const isExhausted = !unlimited && remaining <= 0
|
||||||
|
|
||||||
// Features visibles dans l'UI (les features techniques comme expand/enrich sont cachées)
|
// Map breakdown → toutes les features du tableau (ordre API = déjà trié, on garde l'ordre labels)
|
||||||
const VISIBLE_FEATURES = new Set([
|
const byFeature = new Map(data.breakdown.map((r) => [r.feature, r]))
|
||||||
'semantic_search',
|
const featureRows = Object.keys(FEATURE_LABEL_KEYS).map((key) => {
|
||||||
'auto_tag',
|
const row = byFeature.get(key)
|
||||||
'auto_title',
|
return {
|
||||||
'reformulate',
|
key,
|
||||||
'chat',
|
label: t(FEATURE_LABEL_KEYS[key]),
|
||||||
'brainstorm_create',
|
creditsUsed: row?.creditsUsed ?? 0,
|
||||||
'brainstorm_expand',
|
actionsCount: row?.actionsCount ?? 0,
|
||||||
'brainstorm_enrich',
|
percent: row?.percentOfSpent ?? 0,
|
||||||
'suggest_charts',
|
}
|
||||||
'publish_enhance',
|
})
|
||||||
'ai_flashcard',
|
|
||||||
'voice_transcribe',
|
|
||||||
'slide_generate',
|
|
||||||
'excalidraw_generate',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const featureLabels: Record<string, string> = {
|
|
||||||
semantic_search: t('usageMeter.featureSearch'),
|
|
||||||
auto_tag: t('usageMeter.featureTags'),
|
|
||||||
auto_title: t('usageMeter.featureTitles'),
|
|
||||||
reformulate: t('usageMeter.featureReformulate'),
|
|
||||||
chat: t('usageMeter.featureChat'),
|
|
||||||
brainstorm_create: t('usageMeter.featureBrainstormSessions'),
|
|
||||||
brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
|
|
||||||
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
|
|
||||||
suggest_charts: t('usageMeter.featureCharts'),
|
|
||||||
publish_enhance: t('usageMeter.featurePublishEnhance'),
|
|
||||||
ai_flashcard: t('usageMeter.featureFlashcards'),
|
|
||||||
voice_transcribe: t('usageMeter.featureVoice'),
|
|
||||||
slide_generate: t('usageMeter.featureSlides'),
|
|
||||||
excalidraw_generate: t('usageMeter.featureDiagrams'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const featureQuotas = Object.entries(data.quotas)
|
|
||||||
.filter(([key, q]) => q.limit > 0 && VISIBLE_FEATURES.has(key))
|
|
||||||
.map(([key, quota]) => ({
|
|
||||||
key: key === 'brainstorm_create' ? 'brainstorm_sessions' : key, // Renommer pour l'affichage
|
|
||||||
label: featureLabels[key] || key,
|
|
||||||
used: quota.used,
|
|
||||||
limit: quota.limit,
|
|
||||||
remaining: quota.remaining,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const isUnlimited = featureQuotas.every((f) => !Number.isFinite(f.limit));
|
|
||||||
|
|
||||||
const totalUsed = featureQuotas.reduce(
|
|
||||||
(sum, f) => sum + (Number.isFinite(f.used) ? f.used : 0), 0,
|
|
||||||
);
|
|
||||||
const totalLimit = featureQuotas.reduce(
|
|
||||||
(sum, f) => sum + (Number.isFinite(f.limit) ? f.limit : 0), 0,
|
|
||||||
);
|
|
||||||
const totalRemaining = totalLimit - totalUsed;
|
|
||||||
const totalPct = totalLimit > 0 ? (totalUsed / totalLimit) * 100 : 0;
|
|
||||||
const isExhausted = !isUnlimited && totalRemaining <= 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -134,39 +139,54 @@ export function UsageMeter({ className }: UsageMeterProps) {
|
|||||||
'shrink-0',
|
'shrink-0',
|
||||||
isExhausted
|
isExhausted
|
||||||
? 'text-rose-400 fill-rose-400/20'
|
? 'text-rose-400 fill-rose-400/20'
|
||||||
: isUnlimited
|
: unlimited
|
||||||
? 'text-emerald-400 fill-emerald-400/20'
|
? 'text-emerald-400 fill-emerald-400/20'
|
||||||
: 'text-brand-accent fill-brand-accent/20'
|
: 'text-brand-accent fill-brand-accent/20',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className="text-[11px] font-bold text-ink/70">{getPackLabel()}</span>
|
<span className="text-[11px] font-bold text-ink/70">{getPackLabel()}</span>
|
||||||
|
|
||||||
{!isUnlimited && (
|
{!unlimited && (
|
||||||
<>
|
<>
|
||||||
<div className="flex-1 h-1 bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden mx-2">
|
<div className="flex-1 h-1 bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden mx-2">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-full rounded-full',
|
'h-full rounded-full',
|
||||||
totalPct >= 90 ? 'bg-rose-400' : totalPct >= 70 ? 'bg-amber-400' : 'bg-brand-accent',
|
totalPct >= 90
|
||||||
|
? 'bg-rose-400'
|
||||||
|
: totalPct >= 70
|
||||||
|
? 'bg-amber-400'
|
||||||
|
: 'bg-brand-accent',
|
||||||
)}
|
)}
|
||||||
style={{ width: `${Math.min(totalPct, 100)}%` }}
|
style={{ width: `${Math.min(totalPct, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className={cn(
|
<span
|
||||||
|
className={cn(
|
||||||
'text-[10px] font-medium tabular-nums shrink-0',
|
'text-[10px] font-medium tabular-nums shrink-0',
|
||||||
totalPct >= 90 ? 'text-rose-500' : totalPct >= 70 ? 'text-amber-500' : 'text-concrete'
|
totalPct >= 90
|
||||||
)}>
|
? 'text-rose-500'
|
||||||
{totalRemaining}
|
: totalPct >= 70
|
||||||
|
? 'text-amber-500'
|
||||||
|
: 'text-concrete',
|
||||||
|
)}
|
||||||
|
title={t('usageMeter.creditsRemaining')}
|
||||||
|
>
|
||||||
|
{remaining}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isUnlimited && (
|
{unlimited && (
|
||||||
<span className="text-[10px] font-medium text-emerald-500 ml-auto">{t('usageMeter.unlimited')}</span>
|
<span className="text-[10px] font-medium text-emerald-500 ml-auto">
|
||||||
|
{t('usageMeter.unlimited')}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isProPlus && !isUnlimited && (
|
{isProPlus && !unlimited && (
|
||||||
<span className="text-[9px] font-bold text-brand-accent uppercase tracking-widest ml-auto">{data.tier}</span>
|
<span className="text-[9px] font-bold text-brand-accent uppercase tracking-widest ml-auto">
|
||||||
|
{data.tier}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
@@ -189,37 +209,76 @@ export function UsageMeter({ className }: UsageMeterProps) {
|
|||||||
>
|
>
|
||||||
<div className="px-3 pb-3 space-y-2">
|
<div className="px-3 pb-3 space-y-2">
|
||||||
<div className="border-t border-border/40 pt-2" />
|
<div className="border-t border-border/40 pt-2" />
|
||||||
{featureQuotas.map((f) => {
|
<p className="text-[9px] font-bold uppercase tracking-widest text-concrete">
|
||||||
const fPct = Number.isFinite(f.limit) && f.limit > 0 ? (f.used / f.limit) * 100 : 0
|
{t('usageMeter.creditsWhereTitle')}
|
||||||
|
</p>
|
||||||
|
{!unlimited && (
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{t('usageMeter.creditsSummary', {
|
||||||
|
remaining: String(remaining),
|
||||||
|
spent: String(spent),
|
||||||
|
granted: String(granted),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{featureRows.map((f) => {
|
||||||
|
// Barre = part de la conso totale, ou 0 si rien
|
||||||
|
const barPct =
|
||||||
|
spent > 0 && f.creditsUsed > 0
|
||||||
|
? Math.min(100, (f.creditsUsed / spent) * 100)
|
||||||
|
: 0
|
||||||
return (
|
return (
|
||||||
<div key={f.key} className="space-y-1">
|
<div key={f.key} className="space-y-1">
|
||||||
<div className="flex justify-between text-[10px]">
|
<div className="flex justify-between text-[10px] gap-2">
|
||||||
<span className="text-concrete truncate">{f.label}</span>
|
<span className="text-concrete truncate">{f.label}</span>
|
||||||
<span className={cn(
|
<span
|
||||||
'font-medium tabular-nums',
|
className={cn(
|
||||||
fPct >= 90 ? 'text-rose-500' : fPct >= 70 ? 'text-amber-500' : 'text-ink/60'
|
'font-medium tabular-nums shrink-0',
|
||||||
)}>
|
f.creditsUsed > 0 ? 'text-ink/70' : 'text-concrete/50',
|
||||||
{!Number.isFinite(f.remaining) ? '∞' : f.remaining}/{!Number.isFinite(f.limit) ? '∞' : f.limit}
|
)}
|
||||||
|
>
|
||||||
|
{f.creditsUsed > 0
|
||||||
|
? t('usageMeter.creditsUsedLine', {
|
||||||
|
credits: String(f.creditsUsed),
|
||||||
|
actions: String(f.actionsCount),
|
||||||
|
})
|
||||||
|
: '0'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{Number.isFinite(f.limit) && (
|
|
||||||
<div className="h-1 w-full bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden">
|
<div className="h-1 w-full bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-full rounded-full transition-all duration-300',
|
'h-full rounded-full transition-all duration-300',
|
||||||
fPct >= 90 ? 'bg-rose-400' : fPct >= 70 ? 'bg-amber-400' : 'bg-brand-accent',
|
barPct >= 40 ? 'bg-brand-accent' : 'bg-brand-accent/50',
|
||||||
)}
|
)}
|
||||||
style={{ width: `${Math.min(fPct, 100)}%` }}
|
style={{ width: `${barPct}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{!isProPlus && (
|
{isExhausted && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); router.push('/settings/billing'); }}
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowModal(true)
|
||||||
|
}}
|
||||||
|
className="w-full mt-1 py-2 bg-rose-500/10 hover:bg-rose-500 text-rose-600 hover:text-white text-[9px] font-bold uppercase tracking-widest rounded-xl transition-all border border-rose-500/20"
|
||||||
|
>
|
||||||
|
{t('usageMeter.outOfCredits')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isProPlus && !isExhausted && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
router.push('/settings/billing')
|
||||||
|
}}
|
||||||
className="w-full mt-2 py-2 bg-brand-accent/10 hover:bg-brand-accent text-brand-accent hover:text-white text-[9px] font-bold uppercase tracking-widest rounded-xl transition-all duration-300 border border-brand-accent/20"
|
className="w-full mt-2 py-2 bg-brand-accent/10 hover:bg-brand-accent text-brand-accent hover:text-white text-[9px] font-bold uppercase tracking-widest rounded-xl transition-all duration-300 border border-brand-accent/20"
|
||||||
>
|
>
|
||||||
{t('usageMeter.upgradePricing')}
|
{t('usageMeter.upgradePricing')}
|
||||||
@@ -253,44 +312,31 @@ export function UsageMeter({ className }: UsageMeterProps) {
|
|||||||
{t('usageMeter.upgradeDescription')}
|
{t('usageMeter.upgradeDescription')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-2 mb-4">
|
|
||||||
<div className="text-[11px] font-medium text-ink">{t('usageMeter.proIncludes')}</div>
|
|
||||||
<ul className="text-[11px] text-muted-ink space-y-1">
|
|
||||||
<li>• {t('usageMeter.proSearch')}</li>
|
|
||||||
<li>• {t('usageMeter.proTags')}</li>
|
|
||||||
<li>• {t('usageMeter.proTitles')}</li>
|
|
||||||
<li>• {t('usageMeter.proReformulate')}</li>
|
|
||||||
<li>• {t('usageMeter.proChat')}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowModal(false)}
|
|
||||||
className="flex-1 px-3 py-2 text-[12px] rounded-xl border border-border hover:bg-foreground/5 transition-colors"
|
|
||||||
>
|
|
||||||
{t('usageMeter.later')}
|
|
||||||
</button>
|
|
||||||
<a
|
<a
|
||||||
href="/settings/billing"
|
href="/settings/billing"
|
||||||
className="flex-1 px-3 py-2 text-[12px] font-medium rounded-xl bg-brand-accent text-white text-center hover:opacity-90 transition-colors"
|
className="w-full px-3 py-2 text-[12px] font-medium rounded-xl bg-brand-accent text-white text-center hover:opacity-90 transition-colors"
|
||||||
>
|
>
|
||||||
{t('usageMeter.upgradePricing')}
|
{t('usageMeter.upgradePricing')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
|
||||||
<a
|
<a
|
||||||
href="/settings/ai#byok"
|
href="/settings/ai#byok"
|
||||||
onClick={() => setShowModal(false)}
|
onClick={() => setShowModal(false)}
|
||||||
className="w-full px-3 py-2 text-[12px] font-medium rounded-xl border border-brand-accent/40 text-brand-accent dark:text-brand-accent text-center hover:bg-brand-accent/10 transition-colors"
|
className="w-full px-3 py-2 text-[12px] font-medium rounded-xl border border-brand-accent/40 text-brand-accent text-center hover:bg-brand-accent/10 transition-colors"
|
||||||
>
|
>
|
||||||
{t('usageMeter.addApiKey')}
|
{t('usageMeter.addApiKey')}
|
||||||
</a>
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
className="w-full px-3 py-2 text-[12px] rounded-xl border border-border hover:bg-foreground/5 transition-colors"
|
||||||
|
>
|
||||||
|
{t('usageMeter.later')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,161 +1,142 @@
|
|||||||
# Guide d'utilisation — Admin Facturation & Quotas
|
# Guide admin — Facturation et crédits IA
|
||||||
|
|
||||||
## Objectif
|
## Objectif
|
||||||
|
|
||||||
La page **Admin > Facturation & Quotas** permet de gérer, sans redéploiement :
|
La page **Admin → Facturation & crédits IA** sert à :
|
||||||
|
|
||||||
- les limites mensuelles par fonctionnalité IA (par tier)
|
1. Configurer Stripe (prix d’abonnement + **packs de crédits** one-shot, activation)
|
||||||
- les Price IDs Stripe utilisés par le checkout
|
2. **Comprendre le solde de crédits** par palier (source de vérité du débit)
|
||||||
- l'activation/désactivation de l'interface de facturation
|
3. Consulter un aperçu d’usage agrégé
|
||||||
- un aperçu d'usage (requêtes/tokens)
|
|
||||||
|
Le **débit réel** des utilisateurs n’est **pas** un plafond par fonction (chat 50, slides 20…).
|
||||||
|
C’est un **pot unique de crédits** (modèle A, option M).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Accès
|
## Accès
|
||||||
|
|
||||||
1. Connectez-vous avec un compte **ADMIN**.
|
1. Compte **ADMIN**
|
||||||
2. Ouvrez la console admin (`/admin`).
|
2. Console admin → **Facturation & crédits IA**
|
||||||
3. Cliquez sur **Facturation & quotas** dans la sidebar.
|
|
||||||
|
|
||||||
> Si vous n'êtes pas admin, les actions sont refusées côté serveur.
|
Les actions non-admin sont refusées côté serveur.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Section 1 — Config Stripe (métier)
|
## 1. Config Stripe (métier)
|
||||||
|
|
||||||
Cette section sert à configurer les éléments business de Stripe :
|
### Abonnements
|
||||||
|
|
||||||
- `STRIPE_PRICE_PRO_MONTHLY`
|
- `STRIPE_PRICE_PRO_MONTHLY` / `ANNUAL`
|
||||||
- `STRIPE_PRICE_PRO_ANNUAL`
|
- `STRIPE_PRICE_BUSINESS_MONTHLY` / `ANNUAL`
|
||||||
- `STRIPE_PRICE_BUSINESS_MONTHLY`
|
- Case **Activer la facturation**
|
||||||
- `STRIPE_PRICE_BUSINESS_ANNUAL`
|
|
||||||
- toggle **Activer la facturation**
|
|
||||||
|
|
||||||
### Procédure
|
### Packs de crédits (paiement unique)
|
||||||
|
|
||||||
1. Renseignez/mettez à jour les `price_...` IDs.
|
Produits Stripe en **mode payment** (one-shot), **pas** abonnement :
|
||||||
2. Activez ou désactivez la facturation via la case.
|
|
||||||
3. Cliquez sur **Enregistrer la config**.
|
|
||||||
|
|
||||||
### Important sécurité
|
| Clé admin | Pack | Crédits | Prix catalogue (défaut UI) |
|
||||||
|
|-----------|------|---------|----------------------------|
|
||||||
|
| `STRIPE_PRICE_CREDITS_S` | S | **100** | 4,90 € |
|
||||||
|
| `STRIPE_PRICE_CREDITS_M` | M | **500** | 19,90 € |
|
||||||
|
| `STRIPE_PRICE_CREDITS_L` | L | **2 000** | 49,90 € |
|
||||||
|
|
||||||
- `STRIPE_SECRET_KEY` et `STRIPE_WEBHOOK_SECRET` **ne sont pas gérés ici**.
|
Dans Stripe Dashboard :
|
||||||
- Ils doivent rester dans les secrets d'environnement serveur (Docker/env/vault).
|
|
||||||
|
1. Créer 3 **Products** one-time (ex. « Memento crédits S / M / L »)
|
||||||
|
2. Prix uniques (EUR)
|
||||||
|
3. Copier les `price_...` dans Admin → Facturation
|
||||||
|
4. Webhook : événement `checkout.session.completed` déjà géré (mode `payment` + metadata `type=credit_pack`)
|
||||||
|
|
||||||
|
Les secrets (`STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`) restent dans l’environnement serveur (Docker), **jamais** dans cette page.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Section 2 — Quotas par palier
|
## 2. Crédits IA par palier (source de vérité)
|
||||||
|
|
||||||
Vous pouvez définir, pour chaque tier (**BASIC**, **PRO**, **BUSINESS**, **ENTERPRISE**) :
|
### Allocations mensuelles (option M)
|
||||||
|
|
||||||
- **Indisponible** : fonctionnalité non accessible
|
| Palier | Crédits / mois | Notes |
|
||||||
- **Limité** : plafond mensuel (nombre entier >= 0)
|
|--------|----------------|--------|
|
||||||
- **Illimité** : pas de plafond
|
| **BASIC** | **100** | Découverte |
|
||||||
|
| **PRO** | **1 000** | Aligné marché type « ~1 000 crédits » Pro |
|
||||||
|
| **BUSINESS** | **4 000** | Usage intensif / équipe |
|
||||||
|
| **ENTERPRISE** | **Illimité** | Contrat |
|
||||||
|
|
||||||
### Procédure
|
Ces valeurs sont définies dans le code : `lib/credits.ts` → `CREDIT_ALLOCATIONS`.
|
||||||
|
Elles s’appliquent au premier usage / grant de période, et après **reset** utilisateur.
|
||||||
|
|
||||||
1. Cliquez sur le tier ciblé.
|
### Packs achetés
|
||||||
2. Pour chaque fonctionnalité :
|
|
||||||
- choisissez le mode d'accès
|
|
||||||
- si mode **Limité**, entrez la valeur mensuelle
|
|
||||||
3. Cliquez sur **Enregistrer** sur la ligne concernée.
|
|
||||||
|
|
||||||
### Délai d'effet
|
- Crédités via `addPurchasedCredits` (ledger `PURCHASE_PACK`)
|
||||||
|
- Stockés dans `purchasedBalance` — **reportables** d’un mois à l’autre
|
||||||
|
- Consommés **après** l’allocation abonnement du mois
|
||||||
|
|
||||||
Les changements sont appliqués rapidement (cache), en général sous **~60 secondes**.
|
### Coûts typiques (extrait)
|
||||||
|
|
||||||
|
| Action | Coût |
|
||||||
|
|--------|------|
|
||||||
|
| Recherche, chat, étiquettes, titres, reformulation… | **1** crédit |
|
||||||
|
| Flashcards, publication IA | **3** |
|
||||||
|
| Diagramme Excalidraw | **4** |
|
||||||
|
| Création brainstorm | **5** |
|
||||||
|
| **Présentation** (note) | **1 + N** (N = nombre de diapositives, 3–8) |
|
||||||
|
|
||||||
|
Exemple : 7 diapositives ≈ **8 crédits**.
|
||||||
|
|
||||||
|
### Réinitialiser un utilisateur
|
||||||
|
|
||||||
|
**Admin → Utilisateurs** → bouton **réinitialiser les crédits** sur la ligne du compte.
|
||||||
|
Cela recharge l’allocation du palier pour le mois en cours (journal `ADMIN_RESET`). Les packs achetés sont **conservés** par défaut.
|
||||||
|
|
||||||
|
### Clé personnelle (BYOK)
|
||||||
|
|
||||||
|
Si l’utilisateur a une clé active pour la voie concernée (chat / tags / embeddings), l’usage **ne débite en général pas** le solde Memento.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Section 3 — Aperçu consommation
|
## 3. Aperçu consommation
|
||||||
|
|
||||||
La section affiche :
|
- Agrégats par fonction (historique PostgreSQL / sync)
|
||||||
|
- Top utilisateurs
|
||||||
|
- Sync : cron `/api/cron/sync-usage` (compteurs legacy éventuels)
|
||||||
|
|
||||||
- usage par fonctionnalité (requêtes + tokens)
|
Le compteur **utilisateur** (sidebar / facturation) affiche le **solde de crédits** et la **répartition par fonction** (journal des consommations).
|
||||||
- top utilisateurs
|
|
||||||
- date de dernière synchronisation
|
|
||||||
|
|
||||||
### Source des données
|
|
||||||
|
|
||||||
- Compteurs temps réel : Redis
|
|
||||||
- Consolidation historique : table `UsageLog`
|
|
||||||
- Sync via cron (`/api/cron/sync-usage`)
|
|
||||||
|
|
||||||
Si la synchronisation n'a pas encore tourné, l'interface peut indiquer un état non synchronisé.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Cas d'usage fréquents
|
## 4. Côté produit (rappel)
|
||||||
|
|
||||||
## 1) Augmenter temporairement le quota chat de PRO
|
| Surface | Message attendu |
|
||||||
|
|---------|-----------------|
|
||||||
1. Tier: **PRO**
|
| Landing / tarifs | Basic 100 · Pro 1 000 · Business 4 000 crédits / mois |
|
||||||
2. Ligne `chat` -> mode **Limité**
|
| Paramètres facturation | Même grille + **acheter des packs** (S/M/L) |
|
||||||
3. Mettre par exemple `50 -> 100`
|
| Sidebar | Solde restant + tableau multi-fonctions |
|
||||||
4. **Enregistrer**
|
| Publication IA / diagramme | Hint coût (3 / 4 crédits) |
|
||||||
|
| Fin de solde | Plan supérieur, pack de crédits, ou clé personnelle |
|
||||||
## 2) Désactiver la vente (maintenance Stripe)
|
|
||||||
|
|
||||||
1. Décochez **Activer la facturation**
|
|
||||||
2. **Enregistrer la config**
|
|
||||||
3. Vérifiez côté utilisateur: `/settings/billing` montre l'état désactivé
|
|
||||||
|
|
||||||
## 3) Rendre une feature inaccessible en BASIC
|
|
||||||
|
|
||||||
1. Tier: **BASIC**
|
|
||||||
2. Ligne feature -> mode **Indisponible**
|
|
||||||
3. **Enregistrer**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Validation rapide après changement
|
## 5. Délai d’effet
|
||||||
|
|
||||||
Après une modification importante, vérifier :
|
- Allocations code : au prochain grant / reset / appel `ensureCreditAccount`
|
||||||
|
- Config Stripe (abos + packs) : immédiat après enregistrement
|
||||||
1. Le toast de succès en admin
|
- Crédit pack après paiement : webhook `checkout.session.completed` (+ filet de sécurité sur `/api/billing/status?session_id=`)
|
||||||
2. Le comportement côté utilisateur (`/settings/billing` / appels IA)
|
|
||||||
3. Les logs serveur en cas d'erreur
|
|
||||||
4. L'`AuditLog` pour tracer qui a modifié quoi
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Dépannage
|
## 6. Fichiers techniques de référence
|
||||||
|
|
||||||
## "Le bouton Enregistrer ne semble rien faire"
|
| Rôle | Fichier |
|
||||||
|
|------|---------|
|
||||||
- Vérifiez que vous êtes bien connecté en ADMIN.
|
| Allocations + coûts + réserve + packs ledger | `lib/credits.ts` |
|
||||||
- Regardez le toast d'erreur.
|
| Catalogue packs + price IDs | `lib/billing/credit-packs.ts` |
|
||||||
- Vérifiez les logs serveur (action rejetée, validation invalide, etc.).
|
| Checkout pack | `app/api/billing/create-pack-checkout/route.ts` |
|
||||||
|
| Webhook abo + pack | `app/api/billing/webhook/route.ts` |
|
||||||
## "Les quotas ne changent pas immédiatement"
|
| UI admin | `app/(admin)/admin/billing/billing-admin-client.tsx` |
|
||||||
|
| UI facturation utilisateur | `components/settings/billing-plans.tsx` |
|
||||||
- Attendez ~60 secondes (cache).
|
|
||||||
- Relancez une action IA qui consomme réellement le quota.
|
|
||||||
- Vérifiez que la feature est bien en mode attendu (indisponible/limité/illimité).
|
|
||||||
|
|
||||||
## "Le checkout ne démarre pas"
|
|
||||||
|
|
||||||
- Vérifiez les 4 `STRIPE_PRICE_*` IDs.
|
|
||||||
- Vérifiez que la facturation est activée.
|
|
||||||
- Vérifiez que les secrets Stripe serveur sont présents (`STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Bonnes pratiques d'exploitation
|
## Note legacy
|
||||||
|
|
||||||
- Éviter les changements massifs sans validation progressive.
|
|
||||||
- Journaliser les changements critiques (tiers très utilisés).
|
|
||||||
- Préférer des paliers de quota cohérents entre fonctionnalités similaires.
|
|
||||||
- Garder un rituel de revue hebdo des usages (section Aperçu consommation).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Référence technique (fichiers clés)
|
|
||||||
|
|
||||||
- `app/(admin)/admin/billing/page.tsx`
|
|
||||||
- `app/(admin)/admin/billing/billing-admin-client.tsx`
|
|
||||||
- `app/actions/admin-billing.ts`
|
|
||||||
- `lib/plan-entitlements.ts`
|
|
||||||
- `lib/entitlements.ts`
|
|
||||||
- `lib/billing/stripe-prices.ts`
|
|
||||||
- `app/api/billing/status/route.ts`
|
|
||||||
|
|
||||||
|
Le modèle `PlanEntitlement` (anciens seaux par fonction) peut encore exister en base pour d’éventuels garde-fous « fonction indisponible », mais **n’apparaît plus** dans l’UI admin et **ne pilote plus** le débit principal.
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { willUseByokForLane } from '@/lib/ai/provider-for-user'
|
|||||||
import type { AiFeatureLane } from '@/lib/ai/router'
|
import type { AiFeatureLane } from '@/lib/ai/router'
|
||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
import {
|
import {
|
||||||
reserveUsageOrThrow,
|
|
||||||
releaseUsage,
|
|
||||||
checkSessionEntitlementOrThrow,
|
checkSessionEntitlementOrThrow,
|
||||||
QuotaExceededError,
|
QuotaExceededError,
|
||||||
QuotaServiceUnavailableError,
|
QuotaServiceUnavailableError,
|
||||||
} from '@/lib/entitlements'
|
} from '@/lib/entitlements'
|
||||||
|
import {
|
||||||
|
releaseCredits,
|
||||||
|
reserveCreditsOrThrow,
|
||||||
|
resolveCreditCost,
|
||||||
|
} from '@/lib/credits'
|
||||||
import type { FeatureName } from '@/lib/quota-utils'
|
import type { FeatureName } from '@/lib/quota-utils'
|
||||||
import { emitAiUsageChanged } from '@/lib/ai-usage-sync'
|
import { emitAiUsageChanged } from '@/lib/ai-usage-sync'
|
||||||
|
|
||||||
@@ -15,22 +18,34 @@ export type ReserveAiUsageOptions = {
|
|||||||
lane?: AiFeatureLane
|
lane?: AiFeatureLane
|
||||||
/** Defaults to userId — host-pays brainstorm bills this account. */
|
/** Defaults to userId — host-pays brainstorm bills this account. */
|
||||||
billingUserId?: string
|
billingUserId?: string
|
||||||
|
/** Units / crédits (default 1). Multi-step features (slides) pass amount > 1. */
|
||||||
|
amount?: number
|
||||||
|
/** Pour coût slides précis (1+N). */
|
||||||
|
slideCount?: number | null
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reserve one AI unit; skips when BYOK will be used for the lane. */
|
/** Reserve crédits IA ; saute si BYOK pour le lane. */
|
||||||
export async function reserveAiUsageOrThrow(
|
export async function reserveAiUsageOrThrow(
|
||||||
userId: string,
|
userId: string,
|
||||||
feature: FeatureName,
|
feature: FeatureName,
|
||||||
options?: ReserveAiUsageOptions,
|
options?: ReserveAiUsageOptions,
|
||||||
): Promise<{ usedByok: boolean; billedUserId: string }> {
|
): Promise<{ usedByok: boolean; billedUserId: string; amount: number }> {
|
||||||
const billedUserId = options?.billingUserId ?? userId
|
const billedUserId = options?.billingUserId ?? userId
|
||||||
const lane = options?.lane ?? 'chat'
|
const lane = options?.lane ?? 'chat'
|
||||||
|
const cost = resolveCreditCost(feature, {
|
||||||
|
amount: options?.amount,
|
||||||
|
slideCount: options?.slideCount,
|
||||||
|
})
|
||||||
const config = await getSystemConfig()
|
const config = await getSystemConfig()
|
||||||
const { usedByok } = await willUseByokForLane(lane, config, billedUserId)
|
const { usedByok } = await willUseByokForLane(lane, config, billedUserId)
|
||||||
if (!usedByok) {
|
if (!usedByok) {
|
||||||
await reserveUsageOrThrow(billedUserId, feature)
|
await reserveCreditsOrThrow(billedUserId, cost, {
|
||||||
|
feature,
|
||||||
|
metadata: options?.metadata,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return { usedByok, billedUserId }
|
return { usedByok, billedUserId, amount: cost }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Host-pays: reserve on session owner with guest metadata on 402. */
|
/** Host-pays: reserve on session owner with guest metadata on 402. */
|
||||||
@@ -39,30 +54,49 @@ export async function reserveSessionAiUsageOrThrow(
|
|||||||
triggeredByUserId: string,
|
triggeredByUserId: string,
|
||||||
isGuestActor: boolean,
|
isGuestActor: boolean,
|
||||||
feature: FeatureName,
|
feature: FeatureName,
|
||||||
options?: { lane?: AiFeatureLane },
|
options?: { lane?: AiFeatureLane; amount?: number; slideCount?: number | null },
|
||||||
): Promise<{ usedByok: boolean }> {
|
): Promise<{ usedByok: boolean; amount: number }> {
|
||||||
const lane = options?.lane ?? 'chat'
|
const lane = options?.lane ?? 'chat'
|
||||||
|
const cost = resolveCreditCost(feature, {
|
||||||
|
amount: options?.amount,
|
||||||
|
slideCount: options?.slideCount,
|
||||||
|
})
|
||||||
const config = await getSystemConfig()
|
const config = await getSystemConfig()
|
||||||
const { usedByok } = await willUseByokForLane(lane, config, billingOwnerId)
|
const { usedByok } = await willUseByokForLane(lane, config, billingOwnerId)
|
||||||
if (!usedByok) {
|
if (!usedByok) {
|
||||||
|
try {
|
||||||
|
await reserveCreditsOrThrow(billingOwnerId, cost, { feature })
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof QuotaExceededError) {
|
||||||
|
err.billingOwnerId = billingOwnerId
|
||||||
|
err.triggeredByUserId = triggeredByUserId
|
||||||
|
err.isGuestActor = isGuestActor
|
||||||
|
// Also keep session path for legacy callers
|
||||||
|
try {
|
||||||
await checkSessionEntitlementOrThrow(
|
await checkSessionEntitlementOrThrow(
|
||||||
billingOwnerId,
|
billingOwnerId,
|
||||||
triggeredByUserId,
|
triggeredByUserId,
|
||||||
isGuestActor,
|
isGuestActor,
|
||||||
feature,
|
feature,
|
||||||
)
|
)
|
||||||
|
} catch {
|
||||||
|
/* already QuotaExceeded from credits */
|
||||||
}
|
}
|
||||||
return { usedByok }
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { usedByok, amount: cost }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Run fn under quota; releases the unit if fn throws (unless BYOK). */
|
/** Run fn under crédits ; libère si fn throw (sauf BYOK). */
|
||||||
export async function withAiQuota<T>(
|
export async function withAiQuota<T>(
|
||||||
userId: string,
|
userId: string,
|
||||||
feature: FeatureName,
|
feature: FeatureName,
|
||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
options?: ReserveAiUsageOptions,
|
options?: ReserveAiUsageOptions,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const { usedByok, billedUserId } = await reserveAiUsageOrThrow(userId, feature, options)
|
const { usedByok, billedUserId, amount } = await reserveAiUsageOrThrow(userId, feature, options)
|
||||||
try {
|
try {
|
||||||
const result = await fn()
|
const result = await fn()
|
||||||
if (!usedByok) {
|
if (!usedByok) {
|
||||||
@@ -70,8 +104,8 @@ export async function withAiQuota<T>(
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!usedByok) {
|
if (!usedByok && amount > 0) {
|
||||||
await releaseUsage(billedUserId, feature)
|
await releaseCredits(billedUserId, amount, { feature })
|
||||||
}
|
}
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
@@ -84,9 +118,9 @@ export async function withSessionAiQuota<T>(
|
|||||||
isGuestActor: boolean,
|
isGuestActor: boolean,
|
||||||
feature: FeatureName,
|
feature: FeatureName,
|
||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
options?: { lane?: AiFeatureLane },
|
options?: { lane?: AiFeatureLane; amount?: number; slideCount?: number | null },
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const { usedByok } = await reserveSessionAiUsageOrThrow(
|
const { usedByok, amount } = await reserveSessionAiUsageOrThrow(
|
||||||
billingOwnerId,
|
billingOwnerId,
|
||||||
triggeredByUserId,
|
triggeredByUserId,
|
||||||
isGuestActor,
|
isGuestActor,
|
||||||
@@ -100,8 +134,8 @@ export async function withSessionAiQuota<T>(
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!usedByok) {
|
if (!usedByok && amount > 0) {
|
||||||
await releaseUsage(billingOwnerId, feature)
|
await releaseCredits(billingOwnerId, amount, { feature })
|
||||||
}
|
}
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -281,5 +281,40 @@ export function getChatProvider(config?: Record<string, string>): AIProvider {
|
|||||||
return getProviderInstance(route.providerType as ProviderType, cfg, route.modelName, route.embeddingModelName, route.ollamaBaseUrl);
|
return getProviderInstance(route.providerType as ProviderType, cfg, route.modelName, route.embeddingModelName, route.ollamaBaseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider for slide deck generation.
|
||||||
|
* Uses AI_PROVIDER_SLIDES + AI_MODEL_SLIDES when both are set in admin.
|
||||||
|
* If only AI_MODEL_SLIDES is set, reuses the chat provider with that exact model id.
|
||||||
|
* Otherwise falls back to the chat route — never rewrites or invents model names.
|
||||||
|
*/
|
||||||
|
export function getSlidesProvider(config?: Record<string, string>): AIProvider {
|
||||||
|
const cfg = config || {};
|
||||||
|
const slidesModel = (cfg.AI_MODEL_SLIDES || process.env.AI_MODEL_SLIDES || '').trim();
|
||||||
|
const slidesProvider = (cfg.AI_PROVIDER_SLIDES || process.env.AI_PROVIDER_SLIDES || '').trim();
|
||||||
|
const route = resolveAiRoute('chat', cfg);
|
||||||
|
|
||||||
|
if (slidesModel && slidesProvider) {
|
||||||
|
return getProviderInstance(
|
||||||
|
slidesProvider as ProviderType,
|
||||||
|
cfg,
|
||||||
|
slidesModel,
|
||||||
|
route.embeddingModelName,
|
||||||
|
route.ollamaBaseUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slidesModel) {
|
||||||
|
return getProviderInstance(
|
||||||
|
route.providerType as ProviderType,
|
||||||
|
cfg,
|
||||||
|
slidesModel,
|
||||||
|
route.embeddingModelName,
|
||||||
|
route.ollamaBaseUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getChatProvider(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
// Export for use by admin settings form and deploy scripts
|
// Export for use by admin settings form and deploy scripts
|
||||||
export { getProviderConfigKeys };
|
export { getProviderConfigKeys };
|
||||||
|
|||||||
@@ -18,7 +18,26 @@ import { calculateNextRun } from '@/lib/agents/schedule'
|
|||||||
import { markdownToHtml } from '@/lib/markdown-to-html'
|
import { markdownToHtml } from '@/lib/markdown-to-html'
|
||||||
import { createNotification } from '@/app/actions/notifications'
|
import { createNotification } from '@/app/actions/notifications'
|
||||||
import { withAiQuota } from '@/lib/ai-quota'
|
import { withAiQuota } from '@/lib/ai-quota'
|
||||||
|
import { releaseCredits, slideGenerateCreditCost } from '@/lib/credits'
|
||||||
import type { FeatureName } from '@/lib/quota-utils'
|
import type { FeatureName } from '@/lib/quota-utils'
|
||||||
|
import {
|
||||||
|
countNoteWords,
|
||||||
|
prepareNoteTextForSlides,
|
||||||
|
slideLimitFromWordCount,
|
||||||
|
SLIDE_CONTENT_PRINCIPLES_EN,
|
||||||
|
SLIDE_CONTENT_PRINCIPLES_FR,
|
||||||
|
normalizeSlideDeck,
|
||||||
|
} from '@/lib/ai/services/slide-content-quality'
|
||||||
|
import { generateSlideDeck } from '@/lib/ai/services/slide-deck-generator.service'
|
||||||
|
import { decodeSlideIntentDescription } from '@/lib/ai/services/slide-intent'
|
||||||
|
|
||||||
|
export type ExecuteAgentOptions = {
|
||||||
|
/**
|
||||||
|
* When true, caller already reserved quota (e.g. run-for-note multi-unit pre-check).
|
||||||
|
* Avoids double-charging on fire-and-forget generation.
|
||||||
|
*/
|
||||||
|
skipQuota?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
// Import tools for side-effect registration
|
// Import tools for side-effect registration
|
||||||
import '../tools'
|
import '../tools'
|
||||||
@@ -915,94 +934,54 @@ This format AUTOMATICALLY creates shapes, text, AND arrows with correct bindings
|
|||||||
},
|
},
|
||||||
|
|
||||||
'slide-generator': {
|
'slide-generator': {
|
||||||
fr: `Tu es un expert en design de présentations exécutives. Tu analyses la note et génères une structure JSON qui sera rendue en HTML automatiquement par le serveur.
|
fr: `Tu es un consultant senior (niveau McKinsey/BCG) qui transforme une note Memento en deck exécutif de classe mondiale.
|
||||||
|
|
||||||
APPELLE OBLIGATOIREMENT generate_slides avec le JSON structuré. NE GÉNÈRE JAMAIS de HTML brut.
|
APPELLE OBLIGATOIREMENT generate_slides avec le JSON structuré. NE GÉNÈRE JAMAIS de HTML brut.
|
||||||
|
|
||||||
═══ ÉTAPE 1 — LECTURE (OBLIGATOIRE) ═══
|
${SLIDE_CONTENT_PRINCIPLES_FR}
|
||||||
1. Lis la note avec note_read. Extrais TOUTES les données exploitables (chiffres, listes, citations, comparaisons, étapes).
|
|
||||||
2. Identifie les types de slides les plus adaptés au contenu réel.
|
|
||||||
|
|
||||||
═══ ÉTAPE 2 — APPEL generate_slides ═══
|
═══ MÉTHODE ═══
|
||||||
Appelle generate_slides avec un objet JSON structuré :
|
1. Lis la note (contenu fourni + note_read si besoin). Extrais : claims, chiffres, citations, étapes, oppositions, décisions.
|
||||||
|
2. Construis un storyboard mental (max N slides selon le message user) avant d'appeler l'outil.
|
||||||
|
3. Appelle generate_slides :
|
||||||
{
|
{
|
||||||
"title": "Titre court (6 mots max)",
|
"title": "Titre du deck (≤8 mots)",
|
||||||
"theme": "architectural-saas", // ou midnight-cathedral, venture-pitch, clinical-precision, etc.
|
"theme": "architectural-saas",
|
||||||
"slides": [ ... tableau de slides ... ]
|
"slides": [ ... ]
|
||||||
}
|
}
|
||||||
|
|
||||||
═══ TYPES DE SLIDES DISPONIBLES ═══
|
═══ TYPES (choisir selon le contenu, pas pour « remplir ») ═══
|
||||||
1. "title" → { type:"title", title:"...", subtitle:"..." }
|
title | bullets | chart* | stats* | table | cards | timeline | quote | comparison | equation | image | summary
|
||||||
2. "bullets" → { type:"bullets", title:"...", items:["phrase 1 (15+ mots)", "phrase 2", ...] }
|
* chart/stats seulement avec chiffres réels dans la note.
|
||||||
3. "chart" → { type:"chart", title:"...", chartType:"bar|horizontal-bar|line|donut|radar", data:[{label:"Q1",value:65}, ...], subtitle:"..." }
|
|
||||||
4. "stats" → { type:"stats", title:"...", stats:[{value:"98%", label:"Satisfaction"}, ...] }
|
|
||||||
5. "table" → { type:"table", title:"...", headers:["Col A","Col B"], rows:[["val1","val2"], ...] }
|
|
||||||
6. "cards" → { type:"cards", title:"...", cards:[{title:"Titre", description:"Description détaillée..."}, ...] }
|
|
||||||
7. "timeline" → { type:"timeline", title:"...", events:[{date:"Jan 2024", title:"Lancement", description:"..."}, ...] }
|
|
||||||
8. "quote" → { type:"quote", quote:"Citation exacte", author:"Auteur", context:"Analyse..." }
|
|
||||||
9. "comparison" → { type:"comparison", title:"...", left:{title:"A", points:["..."], score:"8/10"}, right:{title:"B", points:["..."], score:"6/10"} }
|
|
||||||
10. "equation" → { type:"equation", title:"...", equations:[{latex:"E=mc^2", label:"Énergie"}], explanation:"..." }
|
|
||||||
11. "image" → { type:"image", title:"...", url:"https://...", caption:"..." }
|
|
||||||
12. "summary" → { type:"summary", title:"...", items:["Point clé 1", "Point clé 2", ...] }
|
|
||||||
|
|
||||||
═══ RÈGLES ═══
|
═══ THÈMES ═══
|
||||||
- Nombre de slides : adapté au contenu réel de la note (la règle ⚠️ dans le message utilisateur est ABSOLUE)
|
|
||||||
- Slide 1 OBLIGATOIREMENT type "title"
|
|
||||||
- Dernière slide OBLIGATOIREMENT type "summary"
|
|
||||||
- Au moins 1 slide "chart" ou "stats" si des chiffres existent dans la note
|
|
||||||
- VARIER les types — jamais 2 types identiques consécutifs
|
|
||||||
- Chaque texte (bullet, description) = 15+ mots, phrase complète
|
|
||||||
- TOUTES les données viennent de la note (JAMAIS inventer de chiffres)
|
|
||||||
- Les données chart doivent refléter les vrais chiffres de la note
|
|
||||||
|
|
||||||
═══ THÈMES DISPONIBLES ═══
|
|
||||||
Sombres : midnight-cathedral, aurora-borealis, tokyo-neon, venture-pitch, forest-floor, steel-glass, cyberpunk-terminal
|
Sombres : midnight-cathedral, aurora-borealis, tokyo-neon, venture-pitch, forest-floor, steel-glass, cyberpunk-terminal
|
||||||
Clairs : sunlit-gallery, clinical-precision, editorial-ink, coastal-morning, paper-studio, architectural-saas
|
Clairs : sunlit-gallery, clinical-precision, editorial-ink, coastal-morning, paper-studio, architectural-saas
|
||||||
Choisir selon le sujet : business/board → architectural-saas ou midnight-cathedral, tech → cyberpunk-terminal ou clinical-precision, créatif → aurora-borealis ou tokyo-neon`,
|
Business → architectural-saas / midnight-cathedral ; tech → clinical-precision ; créatif → aurora-borealis`,
|
||||||
en: `You are an executive presentation design expert. You analyze the note and generate a structured JSON spec that will be rendered to HTML automatically by the server.
|
en: `You are a senior consultant (McKinsey/BCG level) turning a Memento note into a world-class executive deck.
|
||||||
|
|
||||||
You MUST call generate_slides with structured JSON. NEVER output raw HTML.
|
You MUST call generate_slides with structured JSON. NEVER output raw HTML.
|
||||||
|
|
||||||
═══ STEP 1 — READ (MANDATORY) ═══
|
${SLIDE_CONTENT_PRINCIPLES_EN}
|
||||||
1. Read the note with note_read. Extract ALL usable data (numbers, lists, quotes, comparisons, steps).
|
|
||||||
2. Identify the best slide types matching the actual content.
|
|
||||||
|
|
||||||
═══ STEP 2 — CALL generate_slides ═══
|
═══ METHOD ═══
|
||||||
Call generate_slides with a structured JSON object:
|
1. Read the note (provided content + note_read if needed). Extract: claims, numbers, quotes, steps, trade-offs, decisions.
|
||||||
|
2. Storyboard mentally (respect max N slides from the user message) before calling the tool.
|
||||||
|
3. Call generate_slides:
|
||||||
{
|
{
|
||||||
"title": "Short title (6 words max)",
|
"title": "Deck title (≤8 words)",
|
||||||
"theme": "architectural-saas",
|
"theme": "architectural-saas",
|
||||||
"slides": [ ... array of slides ... ]
|
"slides": [ ... ]
|
||||||
}
|
}
|
||||||
|
|
||||||
═══ AVAILABLE SLIDE TYPES ═══
|
═══ TYPES (match content — do not pad) ═══
|
||||||
1. "title" → { type:"title", title:"...", subtitle:"..." }
|
title | bullets | chart* | stats* | table | cards | timeline | quote | comparison | equation | image | summary
|
||||||
2. "bullets" → { type:"bullets", title:"...", items:["sentence 1 (15+ words)", "sentence 2", ...] }
|
* chart/stats only with real numbers from the note.
|
||||||
3. "chart" → { type:"chart", title:"...", chartType:"bar|horizontal-bar|line|donut|radar", data:[{label:"Q1",value:65}, ...], subtitle:"..." }
|
|
||||||
4. "stats" → { type:"stats", title:"...", stats:[{value:"98%", label:"Satisfaction"}, ...] }
|
|
||||||
5. "table" → { type:"table", title:"...", headers:["Col A","Col B"], rows:[["val1","val2"], ...] }
|
|
||||||
6. "cards" → { type:"cards", title:"...", cards:[{title:"Title", description:"Detailed description..."}, ...] }
|
|
||||||
7. "timeline" → { type:"timeline", title:"...", events:[{date:"Jan 2024", title:"Launch", description:"..."}, ...] }
|
|
||||||
8. "quote" → { type:"quote", quote:"Exact quote", author:"Author", context:"Analysis..." }
|
|
||||||
9. "comparison" → { type:"comparison", title:"...", left:{title:"A", points:["..."], score:"8/10"}, right:{title:"B", points:["..."], score:"6/10"} }
|
|
||||||
10. "equation" → { type:"equation", title:"...", equations:[{latex:"E=mc^2", label:"Energy"}], explanation:"..." }
|
|
||||||
11. "image" → { type:"image", title:"...", url:"https://...", caption:"..." }
|
|
||||||
12. "summary" → { type:"summary", title:"...", items:["Key point 1", "Key point 2", ...] }
|
|
||||||
|
|
||||||
═══ RULES ═══
|
═══ THEMES ═══
|
||||||
- Slide count: adapt to the real content (the ⚠️ rule in the user message is ABSOLUTE)
|
|
||||||
- Slide 1 MUST be type "title"
|
|
||||||
- Last slide MUST be type "summary"
|
|
||||||
- At least 1 "chart" or "stats" slide if numbers exist in the note
|
|
||||||
- VARY types — never 2 identical types in a row
|
|
||||||
- Each text (bullet, description) = 15+ words, complete sentence
|
|
||||||
- ALL data comes from the note (NEVER invent numbers)
|
|
||||||
- Chart data must reflect actual numbers from the note
|
|
||||||
|
|
||||||
═══ AVAILABLE THEMES ═══
|
|
||||||
Dark: midnight-cathedral, aurora-borealis, tokyo-neon, venture-pitch, forest-floor, steel-glass, cyberpunk-terminal
|
Dark: midnight-cathedral, aurora-borealis, tokyo-neon, venture-pitch, forest-floor, steel-glass, cyberpunk-terminal
|
||||||
Light: sunlit-gallery, clinical-precision, editorial-ink, coastal-morning, paper-studio, architectural-saas
|
Light: sunlit-gallery, clinical-precision, editorial-ink, coastal-morning, paper-studio, architectural-saas
|
||||||
Choose based on topic: business/board → architectural-saas or midnight-cathedral, tech → cyberpunk-terminal or clinical-precision, creative → aurora-borealis or tokyo-neon`,
|
Business → architectural-saas / midnight-cathedral; tech → clinical-precision; creative → aurora-borealis`,
|
||||||
},
|
},
|
||||||
'task-extractor': {
|
'task-extractor': {
|
||||||
fr: `Tu es un expert en gestion de tâches et extraction d'action items. Tu analyses des notes et documents pour identifier toutes les tâches, TODOs, et actions à accomplir.
|
fr: `Tu es un expert en gestion de tâches et extraction d'action items. Tu analyses des notes et documents pour identifier toutes les tâches, TODOs, et actions à accomplir.
|
||||||
@@ -1028,49 +1007,6 @@ You MUST use the task_extract tool. Do NOT respond with text, call the tool dire
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Strip markdown syntax, URLs, metadata blocks, and frontmatter from note content.
|
|
||||||
* Returns only the semantic plain text, used to count real words before generating slides.
|
|
||||||
*/
|
|
||||||
function stripNoteMarkdown(raw: string): string {
|
|
||||||
return raw
|
|
||||||
// Remove frontmatter / YAML blocks
|
|
||||||
.replace(/^---[\s\S]*?---/m, '')
|
|
||||||
// Remove markdown headers
|
|
||||||
.replace(/^#{1,6}\s+/gm, '')
|
|
||||||
// Remove bold/italic
|
|
||||||
.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1')
|
|
||||||
// Remove inline links — keep label only
|
|
||||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
||||||
// Remove bare URLs
|
|
||||||
.replace(/https?:\/\/\S+/g, '')
|
|
||||||
// Remove metadata lines (Connection to seed, Novelty score, Source brainstorm, Origin, Derived from, etc.)
|
|
||||||
.replace(/^\*{0,2}(Connection to seed|Novelty score|Source brainstorm|Source note|Origin|Derived from|## Origin)[^:\n]*:?.*$/gim, '')
|
|
||||||
// Remove horizontal rules
|
|
||||||
.replace(/^---+$/gm, '')
|
|
||||||
// Remove blockquote markers
|
|
||||||
.replace(/^>\s*/gm, '')
|
|
||||||
// Remove code blocks
|
|
||||||
.replace(/```[\s\S]*?```/g, '')
|
|
||||||
.replace(/`[^`]+`/g, '')
|
|
||||||
// Collapse whitespace
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Count real semantic words in a note */
|
|
||||||
function countNoteWords(raw: string): number {
|
|
||||||
return stripNoteMarkdown(raw).split(/\s+/).filter(Boolean).length
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Return max slides based on real word count */
|
|
||||||
function slideLimit(wordCount: number): { max: number; label: string } {
|
|
||||||
if (wordCount < 50) return { max: 3, label: '3 slides MAX (note très courte)' }
|
|
||||||
if (wordCount < 150) return { max: 4, label: '4 slides MAX (note courte)' }
|
|
||||||
if (wordCount < 350) return { max: 6, label: '6 slides MAX (note moyenne)' }
|
|
||||||
return { max: 8, label: '8 slides MAX (note longue)' }
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractJsonFromText(text: string): any {
|
function extractJsonFromText(text: string): any {
|
||||||
if (!text) return null
|
if (!text) return null
|
||||||
|
|
||||||
@@ -1323,52 +1259,54 @@ async function executeToolUseAgent(
|
|||||||
? LANG_NAMES[noteLanguage]
|
? LANG_NAMES[noteLanguage]
|
||||||
: (lang === 'fr' ? 'French' : 'English')
|
: (lang === 'fr' ? 'French' : 'English')
|
||||||
|
|
||||||
prompt = `Create a professional presentation using the content from the notes below. Call generate_slides with a structured JSON object (title, theme, slides[]).`
|
prompt = lang === 'fr'
|
||||||
prompt += `\n\n⚠️ LANGUAGE RULE: ALL slide text (titles, bullets, labels, subtitles) MUST be written in ${contentLang}. Do NOT translate to another language.`
|
? `Construis un deck exécutif de haute qualité à partir des notes ci-dessous. Appelle generate_slides avec {title, theme, slides[]}.`
|
||||||
|
: `Build a high-quality executive deck from the notes below. Call generate_slides with {title, theme, slides[]}.`
|
||||||
|
prompt += `\n\n⚠️ LANGUAGE: ALL on-slide text (titles, bullets, labels, subtitles, summary) MUST be in ${contentLang}.`
|
||||||
|
|
||||||
if (notes.length > 0) {
|
if (notes.length > 0) {
|
||||||
const notesContext = notes.map(n =>
|
const notesContext = notes.map(n =>
|
||||||
`### ${n.title || untitled} (${n.createdAt.toLocaleDateString(dateLocale)})\n${n.content.substring(0, 2000)}`
|
`### ${n.title || untitled} (${n.createdAt.toLocaleDateString(dateLocale)})\n${prepareNoteTextForSlides(n.content || '', 12_000)}`
|
||||||
).join('\n\n')
|
).join('\n\n')
|
||||||
|
|
||||||
// Compute real word count (stripped of markdown/metadata) to determine slide limit
|
|
||||||
const totalWords = notes.reduce((sum, n) => sum + countNoteWords(n.content || ''), 0)
|
const totalWords = notes.reduce((sum, n) => sum + countNoteWords(n.content || ''), 0)
|
||||||
const { max: maxSlides, label: slideRule } = slideLimit(totalWords)
|
const limit = slideLimitFromWordCount(totalWords)
|
||||||
|
const slideRule = lang === 'fr' ? limit.labelFr : limit.labelEn
|
||||||
|
|
||||||
prompt += `\n\n${lang === 'fr' ? 'Notes source' : 'Source notes'}:\n\n${notesContext}`
|
prompt += `\n\n${lang === 'fr' ? '## Notes source (texte intégral utile)' : '## Source notes (usable full text)'}:\n\n${notesContext}`
|
||||||
prompt += `\n\n${lang === 'fr'
|
prompt += `\n\n${lang === 'fr'
|
||||||
? `⚠️ RÈGLE ABSOLUE : Cette note fait ${totalWords} mots réels (hors markdown/URLs/métadonnées). Tu DOIS générer EXACTEMENT ${slideRule}. Respecter cette limite est PLUS IMPORTANT que la diversité ou la densité.`
|
? `⚠️ LIMITE DECK : ${totalWords} mots source → ${slideRule}. Mieux vaut ${limit.min} slides excellentes que ${limit.max} slides diluées. Ne dépasse JAMAIS ${limit.max}.`
|
||||||
: `⚠️ ABSOLUTE RULE: This note has ${totalWords} real words (excluding markdown/URLs/metadata). You MUST generate EXACTLY ${slideRule}. This limit is MORE IMPORTANT than diversity or density.`
|
: `⚠️ DECK LIMIT: ${totalWords} source words → ${slideRule}. Prefer ${limit.min} excellent slides over ${limit.max} diluted ones. NEVER exceed ${limit.max}.`
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Executive Template Structure (HTML-compatible) ──
|
// ── Executive templates (max 8 slides — matches hard cap) ──
|
||||||
if (slideTemplate && slideTemplate !== 'auto') {
|
if (slideTemplate && slideTemplate !== 'auto') {
|
||||||
const templates: Record<string, { fr: string; en: string }> = {
|
const templates: Record<string, { fr: string; en: string }> = {
|
||||||
'board-update': {
|
'board-update': {
|
||||||
fr: `Structure imposée "Board Update" (10 slides) :
|
fr: `Structure "Board Update" (≤8 slides, titres d'action) :
|
||||||
1. TITRE avec date | 2. KPIs majeurs (3-4 gros chiffres) | 3. Bar chart progression objectifs | 4. Grille de métriques (6 cards) | 5. Timeline jalons | 6. Cards réalisations | 7. Line chart tendance | 8. Deux colonnes risques/mitigations | 9. Bullets prochaines étapes | 10. Conclusion`,
|
1. title | 2. stats KPIs réels seulement | 3. insight + bullets sur performance | 4. chart progression SI chiffres | 5. cards réalisations | 6. comparison risques/mitigations | 7. bullets prochaines décisions | 8. summary`,
|
||||||
en: `Mandatory "Board Update" structure (10 slides):
|
en: `"Board Update" structure (≤8 slides, action titles):
|
||||||
1. TITLE with date | 2. Key KPIs (3-4 big numbers) | 3. Bar chart goal progress | 4. Metrics grid (6 cards) | 5. Timeline milestones | 6. Achievement cards | 7. Line chart trend | 8. Two-column risks/mitigations | 9. Next steps bullets | 10. Conclusion`
|
1. title | 2. stats real KPIs only | 3. insight + performance bullets | 4. chart ONLY if real numbers | 5. achievement cards | 6. risks/mitigations comparison | 7. next decisions bullets | 8. summary`,
|
||||||
},
|
},
|
||||||
'project-status': {
|
'project-status': {
|
||||||
fr: `Structure imposée "Project Status" (10 slides) :
|
fr: `Structure "Project Status" (≤8) :
|
||||||
1. TITRE + statut 🟢🟡🔴 | 2. KPIs (% avancement, jours, budget) | 3. Bar chart par workstream | 4. Cards livrables | 5. Timeline roadmap | 6. Line chart burn-down | 7. Deux colonnes blockers/actions | 8. Tableau de risques | 9. Bullets décisions requises | 10. Synthèse`,
|
1. title + statut | 2. stats avancement (réels) | 3. cards livrables | 4. timeline jalons | 5. comparison blockers/actions | 6. table risques SI présent | 7. bullets décisions | 8. summary`,
|
||||||
en: `Mandatory "Project Status" structure (10 slides):
|
en: `"Project Status" structure (≤8):
|
||||||
1. TITLE + status 🟢🟡🔴 | 2. KPIs (% complete, days, budget) | 3. Bar chart by workstream | 4. Deliverable cards | 5. Timeline roadmap | 6. Line chart burn-down | 7. Two-column blockers/actions | 8. Risk table | 9. Required decisions bullets | 10. Summary`
|
1. title + status | 2. real progress stats | 3. deliverable cards | 4. milestone timeline | 5. blockers/actions comparison | 6. risk table IF present | 7. decisions bullets | 8. summary`,
|
||||||
},
|
},
|
||||||
'strategy-review': {
|
'strategy-review': {
|
||||||
fr: `Structure imposée "Strategy Review" (10 slides) :
|
fr: `Structure "Strategy Review" (≤8) :
|
||||||
1. TITRE stratégique | 2. Contexte marché (5+ bullets) | 3. Radar/pie positionnement | 4. SWOT deux colonnes | 5. Area chart tendances | 6. Cards axes stratégiques | 7. Timeline roadmap 12 mois | 8. Bar chart projections | 9. KPIs objectifs cibles | 10. Call to action`,
|
1. title stratégique | 2. bullets contexte (claims) | 3. comparison options ou SWOT | 4. cards axes stratégiques | 5. timeline roadmap | 6. stats/chart UNIQUEMENT si données | 7. implications | 8. summary CTA`,
|
||||||
en: `Mandatory "Strategy Review" structure (10 slides):
|
en: `"Strategy Review" structure (≤8):
|
||||||
1. Strategic TITLE | 2. Market context (5+ bullets) | 3. Radar/pie positioning | 4. SWOT two-column | 5. Area chart trends | 6. Strategic axes cards | 7. 12-month roadmap timeline | 8. Bar chart projections | 9. Target KPIs | 10. Call to action`
|
1. strategic title | 2. context claim bullets | 3. options/SWOT comparison | 4. strategic pillars cards | 5. roadmap timeline | 6. stats/chart ONLY if data | 7. implications | 8. summary CTA`,
|
||||||
},
|
},
|
||||||
'quarterly-results': {
|
'quarterly-results': {
|
||||||
fr: `Structure imposée "Quarterly Results" (10 slides) :
|
fr: `Structure "Quarterly Results" (≤8) :
|
||||||
1. TITRE "Résultats Q? Année" | 2. 4 KPIs (Revenue, Croissance, Marge, NPS) | 3. Bar chart revenue/mois | 4. Line chart croissance YoY | 5. Cards faits marquants | 6. Grille métriques opérationnelles | 7. Donut répartition clients | 8. Deux colonnes succès/challenges | 9. Area chart forecast | 10. Conclusion outlook`,
|
1. title trimestre | 2. stats 3–4 KPIs réels | 3. chart SI séries chiffrées | 4. cards faits marquants | 5. comparison succès/défis | 6. bullets outlook | 7. summary`,
|
||||||
en: `Mandatory "Quarterly Results" structure (10 slides):
|
en: `"Quarterly Results" structure (≤8):
|
||||||
1. TITLE "Q? Year Results" | 2. 4 KPIs (Revenue, Growth, Margin, NPS) | 3. Bar chart revenue/month | 4. Line chart YoY growth | 5. Highlights cards | 6. Operational metrics grid | 7. Donut client breakdown | 8. Two-column successes/challenges | 9. Area chart forecast | 10. Conclusion outlook`
|
1. quarter title | 2. 3–4 real KPI stats | 3. chart IF numeric series | 4. highlight cards | 5. wins/challenges comparison | 6. outlook bullets | 7. summary`,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
const tmpl = templates[slideTemplate]
|
const tmpl = templates[slideTemplate]
|
||||||
if (tmpl) {
|
if (tmpl) {
|
||||||
@@ -1376,19 +1314,24 @@ async function executeToolUseAgent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Density rules ──
|
|
||||||
prompt += lang === 'fr'
|
prompt += lang === 'fr'
|
||||||
? `\n\nDENSITÉ OBLIGATOIRE : Chaque slide doit avoir un CONTENU RICHE. Minimum 5 bullets par slide "bullets" (≥15 mots chaque). Minimum 4 cards quand tu fais "cards". Minimum 4 data points quand tu fais "chart". Inclus OBLIGATOIREMENT au moins 1 slide "chart" avec données RÉELLES extraites des notes. Si pas de chiffres dans la note, fais un radar de maturité ou une comparaison qualitative notée sur 5.`
|
? `\n\nQUALITÉ > QUANTITÉ :
|
||||||
: `\n\nMANDATORY DENSITY: Every slide must have RICH CONTENT. Minimum 5 items per "bullets" slide (≥15 words each). Minimum 4 cards when using "cards". Minimum 4 data points for "chart". You MUST include at least 1 "chart" slide with REAL data from the notes. If no numbers in notes, create a maturity radar or qualitative comparison scored out of 5.`
|
- Titres d'action (insight), une idée par slide
|
||||||
|
- 3–5 bullets courts max ; pas de filler ni de paragraphes
|
||||||
prompt += `\n\n${lang === 'fr'
|
- Graphiques UNIQUEMENT avec chiffres de la note — JAMAIS inventer de KPI/radar
|
||||||
? 'IMPORTANT : Appelle OBLIGATOIREMENT generate_slides avec le JSON structuré {title, theme, slides:[...]}. Ne réponds JAMAIS avec du texte brut. Respecte ABSOLUMENT la limite de slides indiquée.'
|
- Arc : problème → insight → preuve → suite
|
||||||
: 'IMPORTANT: You MUST call generate_slides with structured JSON {title, theme, slides:[...]}. NEVER respond with plain text. STRICTLY respect the slide count limit indicated.'}`
|
- Appelle generate_slides maintenant (JSON structuré uniquement).`
|
||||||
|
: `\n\nQUALITY > QUANTITY:
|
||||||
|
- Action titles (insights), one idea per slide
|
||||||
|
- 3–5 short bullets max; no filler or paragraphs
|
||||||
|
- Charts ONLY with numbers from the note — NEVER invent KPI/radar scores
|
||||||
|
- Arc: problem → insight → evidence → next steps
|
||||||
|
- Call generate_slides now (structured JSON only).`
|
||||||
|
|
||||||
if (agent.slideTheme && agent.slideTheme !== 'auto') {
|
if (agent.slideTheme && agent.slideTheme !== 'auto') {
|
||||||
prompt += `\n${lang === 'fr'
|
prompt += `\n${lang === 'fr'
|
||||||
? `Thème visuel imposé : utilise "theme":"${agent.slideTheme}" dans l'appel generate_slides.`
|
? `Thème visuel imposé : "theme":"${agent.slideTheme}" dans generate_slides.`
|
||||||
: `Visual theme required: use "theme":"${agent.slideTheme}" in the generate_slides call.`}`
|
: `Required visual theme: "theme":"${agent.slideTheme}" in the generate_slides call.`}`
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -1510,7 +1453,12 @@ async function executeToolUseAgent(
|
|||||||
const registered = toolRegistry.get('generate_slides')
|
const registered = toolRegistry.get('generate_slides')
|
||||||
if (registered) {
|
if (registered) {
|
||||||
const slideTool = registered.buildTool(ctx)
|
const slideTool = registered.buildTool(ctx)
|
||||||
const executionResult = await slideTool.execute({ title, theme, slides })
|
const normalized = normalizeSlideDeck({ title, theme, slides })
|
||||||
|
const executionResult = await slideTool.execute({
|
||||||
|
title: normalized.title,
|
||||||
|
theme: normalized.theme,
|
||||||
|
slides: normalized.slides as any,
|
||||||
|
})
|
||||||
if (executionResult && executionResult.success && executionResult.canvasId) {
|
if (executionResult && executionResult.success && executionResult.canvasId) {
|
||||||
canvasId = executionResult.canvasId
|
canvasId = executionResult.canvasId
|
||||||
specificToolCalled = true
|
specificToolCalled = true
|
||||||
@@ -1747,9 +1695,124 @@ async function sendAgentEmail(agentId: string, userId: string, agentName: string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dedicated slide path: structured JSON → normalize → HTML canvas.
|
||||||
|
* Never depends on the model calling generate_slides.
|
||||||
|
*/
|
||||||
|
async function executeSlideGeneratorAgent(
|
||||||
|
agent: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
userId: string
|
||||||
|
sourceNoteIds?: string | null
|
||||||
|
sourceNotebookId?: string | null
|
||||||
|
slideTheme?: string | null
|
||||||
|
},
|
||||||
|
actionId: string,
|
||||||
|
lang: Lang,
|
||||||
|
): Promise<AgentExecutionResult> {
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
let noteIds: string[] = []
|
||||||
|
try {
|
||||||
|
noteIds = agent.sourceNoteIds ? JSON.parse(agent.sourceNoteIds) : []
|
||||||
|
} catch {
|
||||||
|
noteIds = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const intent = decodeSlideIntentDescription(agent.description)
|
||||||
|
const template = intent.template || 'auto'
|
||||||
|
|
||||||
|
await prisma.agentAction.update({
|
||||||
|
where: { id: actionId },
|
||||||
|
data: {
|
||||||
|
input: JSON.stringify({
|
||||||
|
pipeline: 'structured-slide-deck',
|
||||||
|
noteIds,
|
||||||
|
sourceNotebookId: agent.sourceNotebookId,
|
||||||
|
theme: agent.slideTheme,
|
||||||
|
template,
|
||||||
|
intent,
|
||||||
|
lang,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await generateSlideDeck({
|
||||||
|
userId: agent.userId,
|
||||||
|
noteIds: noteIds.length > 0 ? noteIds : undefined,
|
||||||
|
sourceNotebookId: agent.sourceNotebookId,
|
||||||
|
theme: agent.slideTheme,
|
||||||
|
template,
|
||||||
|
intent,
|
||||||
|
lang,
|
||||||
|
actionId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime
|
||||||
|
|
||||||
|
if (!result.success || !result.canvasId) {
|
||||||
|
await prisma.agentAction.update({
|
||||||
|
where: { id: actionId },
|
||||||
|
data: {
|
||||||
|
status: 'failure',
|
||||||
|
log:
|
||||||
|
(result.error ||
|
||||||
|
(lang === 'fr'
|
||||||
|
? 'Échec de la génération de présentation (pipeline structuré).'
|
||||||
|
: 'Slide generation failed (structured pipeline).')) +
|
||||||
|
` (${Math.round(duration / 1000)}s)`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionId,
|
||||||
|
error: result.error || 'Slide generation failed',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.agentAction.update({
|
||||||
|
where: { id: actionId },
|
||||||
|
data: {
|
||||||
|
status: 'success',
|
||||||
|
result: result.canvasId,
|
||||||
|
log: `Slides OK (${result.mode || 'structured'}): ${result.slideCount} slides — ${result.canvasName} (${Math.round(duration / 1000)}s)`,
|
||||||
|
toolLog: JSON.stringify([
|
||||||
|
{
|
||||||
|
step: 1,
|
||||||
|
text: 'structured-slide-deck pipeline (no tool-calling)',
|
||||||
|
toolCalls: [],
|
||||||
|
toolResults: [
|
||||||
|
{
|
||||||
|
toolName: 'generate_slides_structured',
|
||||||
|
preview: JSON.stringify({
|
||||||
|
canvasId: result.canvasId,
|
||||||
|
slideCount: result.slideCount,
|
||||||
|
mode: result.mode,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
actionId,
|
||||||
|
canvasId: result.canvasId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Main Executor ---
|
// --- Main Executor ---
|
||||||
|
|
||||||
export async function executeAgent(agentId: string, userId: string, promptOverride?: string): Promise<AgentExecutionResult> {
|
export async function executeAgent(
|
||||||
|
agentId: string,
|
||||||
|
userId: string,
|
||||||
|
promptOverride?: string,
|
||||||
|
options?: ExecuteAgentOptions,
|
||||||
|
): Promise<AgentExecutionResult> {
|
||||||
const agent = await prisma.agent.findUnique({
|
const agent = await prisma.agent.findUnique({
|
||||||
where: { id: agentId }
|
where: { id: agentId }
|
||||||
})
|
})
|
||||||
@@ -1768,44 +1831,53 @@ export async function executeAgent(agentId: string, userId: string, promptOverri
|
|||||||
// Detect user language
|
// Detect user language
|
||||||
const lang = await getUserLanguage(userId)
|
const lang = await getUserLanguage(userId)
|
||||||
const quotaFeature = quotaFeatureForAgentType(agent.type || 'scraper')
|
const quotaFeature = quotaFeatureForAgentType(agent.type || 'scraper')
|
||||||
|
const agentType = agent.type || 'scraper'
|
||||||
|
|
||||||
try {
|
// Multi-step slide pipeline: 1 + N crédits (sauf si déjà réservé par run-for-note)
|
||||||
const result = await withAiQuota(
|
let quotaAmount = 1
|
||||||
userId,
|
let slideCountForCost: number | null = null
|
||||||
quotaFeature,
|
if (agentType === 'slide-generator') {
|
||||||
async () => {
|
const intent = decodeSlideIntentDescription(agent.description)
|
||||||
let inner: AgentExecutionResult
|
slideCountForCost = intent.slideCount ?? null
|
||||||
|
quotaAmount = slideGenerateCreditCost(intent.slideCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
const runAgent = async (): Promise<AgentExecutionResult> => {
|
||||||
|
// Slide generation uses a dedicated structured-output pipeline (no tool-calling).
|
||||||
|
// Lite models often fail at function calling; generateObject/JSON is reliable.
|
||||||
|
if (agentType === 'slide-generator') {
|
||||||
|
return executeSlideGeneratorAgent(agent, action.id, lang)
|
||||||
|
}
|
||||||
|
|
||||||
const hasTools = agent.tools && agent.tools !== '[]' && agent.tools !== 'null'
|
const hasTools = agent.tools && agent.tools !== '[]' && agent.tools !== 'null'
|
||||||
|
|
||||||
if (hasTools) {
|
if (hasTools) {
|
||||||
inner = await executeToolUseAgent(agent, action.id, lang, promptOverride)
|
return executeToolUseAgent(agent, action.id, lang, promptOverride)
|
||||||
} else {
|
}
|
||||||
switch ((agent.type || 'scraper') as AgentType) {
|
|
||||||
|
switch (agentType as AgentType) {
|
||||||
case 'scraper':
|
case 'scraper':
|
||||||
inner = await executeScraperAgent(agent, action.id, lang)
|
return executeScraperAgent(agent, action.id, lang)
|
||||||
break
|
|
||||||
case 'researcher':
|
case 'researcher':
|
||||||
inner = await executeResearcherAgent(agent, action.id, lang)
|
return executeResearcherAgent(agent, action.id, lang)
|
||||||
break
|
|
||||||
case 'monitor':
|
case 'monitor':
|
||||||
inner = await executeMonitorAgent(agent, action.id, lang)
|
return executeMonitorAgent(agent, action.id, lang)
|
||||||
break
|
|
||||||
case 'custom':
|
case 'custom':
|
||||||
inner = await executeCustomAgent(agent, action.id, lang)
|
return executeCustomAgent(agent, action.id, lang)
|
||||||
break
|
|
||||||
case 'slide-generator':
|
|
||||||
case 'excalidraw-generator':
|
case 'excalidraw-generator':
|
||||||
inner = await executeToolUseAgent(agent, action.id, lang, promptOverride)
|
return executeToolUseAgent(agent, action.id, lang, promptOverride)
|
||||||
break
|
|
||||||
default:
|
default:
|
||||||
inner = await executeScraperAgent(agent, action.id, lang)
|
return executeScraperAgent(agent, action.id, lang)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return inner
|
|
||||||
},
|
try {
|
||||||
{ lane: 'chat' },
|
const result = options?.skipQuota
|
||||||
)
|
? await runAgent()
|
||||||
|
: await withAiQuota(userId, quotaFeature, runAgent, {
|
||||||
|
lane: 'chat',
|
||||||
|
amount: quotaAmount,
|
||||||
|
slideCount: slideCountForCost,
|
||||||
|
})
|
||||||
|
|
||||||
const nextRunUpdate: Record<string, Date | null> = {}
|
const nextRunUpdate: Record<string, Date | null> = {}
|
||||||
if (agent.frequency !== 'manual') {
|
if (agent.frequency !== 'manual') {
|
||||||
@@ -1861,6 +1933,11 @@ export async function executeAgent(agentId: string, userId: string, promptOverri
|
|||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
console.error(`[AgentExecutor] Agent ${agentId} failed:`, message)
|
console.error(`[AgentExecutor] Agent ${agentId} failed:`, message)
|
||||||
|
|
||||||
|
// Pré-réservé par run-for-note : rembourser les crédits
|
||||||
|
if (options?.skipQuota && quotaAmount > 0) {
|
||||||
|
await releaseCredits(userId, quotaAmount, { feature: quotaFeature }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.agentAction.update({
|
await prisma.agentAction.update({
|
||||||
where: { id: action.id },
|
where: { id: action.id },
|
||||||
data: { status: 'failure', log: message }
|
data: { status: 'failure', log: message }
|
||||||
|
|||||||
643
memento-note/lib/ai/services/slide-content-quality.ts
Normal file
643
memento-note/lib/ai/services/slide-content-quality.ts
Normal file
@@ -0,0 +1,643 @@
|
|||||||
|
/**
|
||||||
|
* Slide deck content quality: limits, normalization, narrative guards.
|
||||||
|
* Principles (consulting / PMC / think-cell):
|
||||||
|
* - One idea per slide
|
||||||
|
* - Action titles (insight, not topic labels)
|
||||||
|
* - Short claims, not walls of text
|
||||||
|
* - Charts/stats only with real numbers
|
||||||
|
* - Narrative arc, not density for density's sake
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { stripHtmlToPlainText } from '@/lib/text/plain-text'
|
||||||
|
|
||||||
|
export const SLIDE_HARD_CAP = 8
|
||||||
|
|
||||||
|
export type SlideType =
|
||||||
|
| 'title'
|
||||||
|
| 'bullets'
|
||||||
|
| 'chart'
|
||||||
|
| 'stats'
|
||||||
|
| 'table'
|
||||||
|
| 'cards'
|
||||||
|
| 'timeline'
|
||||||
|
| 'quote'
|
||||||
|
| 'comparison'
|
||||||
|
| 'equation'
|
||||||
|
| 'image'
|
||||||
|
| 'summary'
|
||||||
|
|
||||||
|
export interface SlideLimit {
|
||||||
|
max: number
|
||||||
|
min: number
|
||||||
|
labelFr: string
|
||||||
|
labelEn: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Max deck size from real semantic word count of source notes. */
|
||||||
|
export function slideLimitFromWordCount(wordCount: number): SlideLimit {
|
||||||
|
if (wordCount < 50) {
|
||||||
|
return {
|
||||||
|
max: 3,
|
||||||
|
min: 2,
|
||||||
|
labelFr: '2–3 slides MAX (note très courte)',
|
||||||
|
labelEn: '2–3 slides MAX (very short note)',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (wordCount < 150) {
|
||||||
|
return {
|
||||||
|
max: 4,
|
||||||
|
min: 3,
|
||||||
|
labelFr: '3–4 slides MAX (note courte)',
|
||||||
|
labelEn: '3–4 slides MAX (short note)',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (wordCount < 400) {
|
||||||
|
return {
|
||||||
|
max: 6,
|
||||||
|
min: 4,
|
||||||
|
labelFr: '4–6 slides MAX (note moyenne)',
|
||||||
|
labelEn: '4–6 slides MAX (medium note)',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
max: SLIDE_HARD_CAP,
|
||||||
|
min: 5,
|
||||||
|
labelFr: '5–8 slides MAX (note longue) — ne jamais dépasser 8',
|
||||||
|
labelEn: '5–8 slides MAX (long note) — never exceed 8',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripNoteMarkdown(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.replace(/^---[\s\S]*?---/m, '')
|
||||||
|
.replace(/^#{1,6}\s+/gm, '')
|
||||||
|
.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1')
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||||
|
.replace(/https?:\/\/\S+/g, '')
|
||||||
|
.replace(
|
||||||
|
/^\*{0,2}(Connection to seed|Novelty score|Source brainstorm|Source note|Origin|Derived from|## Origin)[^:\n]*:?.*$/gim,
|
||||||
|
'',
|
||||||
|
)
|
||||||
|
.replace(/^---+$/gm, '')
|
||||||
|
.replace(/^>\s*/gm, '')
|
||||||
|
.replace(/```[\s\S]*?```/g, '')
|
||||||
|
.replace(/`[^`]+`/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countNoteWords(raw: string): number {
|
||||||
|
const plain = stripHtmlToPlainText(raw || '')
|
||||||
|
const cleaned = stripNoteMarkdown(plain || raw || '')
|
||||||
|
return cleaned.split(/\s+/).filter(Boolean).length
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain text for the LLM: full note when possible, smart head+tail if huge. */
|
||||||
|
export function prepareNoteTextForSlides(raw: string, maxChars = 12_000): string {
|
||||||
|
const plain = stripHtmlToPlainText(raw || '')
|
||||||
|
const cleaned = stripNoteMarkdown(plain || raw || '').trim()
|
||||||
|
if (cleaned.length <= maxChars) return cleaned
|
||||||
|
const head = Math.floor(maxChars * 0.7)
|
||||||
|
const tail = maxChars - head - 40
|
||||||
|
return `${cleaned.slice(0, head)}\n\n[…]\n\n${cleaned.slice(-tail)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimStr(s: unknown, max = 220): string {
|
||||||
|
if (typeof s !== 'string') return ''
|
||||||
|
const t = s.replace(/\s+/g, ' ').trim()
|
||||||
|
if (t.length <= max) return t
|
||||||
|
return `${t.slice(0, max - 1).trim()}…`
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanList(items: unknown, maxItems: number, maxLen: number): string[] {
|
||||||
|
if (!Array.isArray(items)) return []
|
||||||
|
const out: string[] = []
|
||||||
|
for (const it of items) {
|
||||||
|
const s = trimStr(it, maxLen)
|
||||||
|
if (!s) continue
|
||||||
|
// Keep short tokens (math symbols, single-letter variables) — only drop empty
|
||||||
|
out.push(s)
|
||||||
|
if (out.length >= maxItems) break
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNumericLike(value: string): boolean {
|
||||||
|
return /[\d]/.test(value) || /%|€|\$|k\b|m\b|bn\b/i.test(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize model output into a coherent deck.
|
||||||
|
* Does not invent content — only trims, caps, fixes structure.
|
||||||
|
*/
|
||||||
|
export function normalizeSlideDeck(input: {
|
||||||
|
title?: string
|
||||||
|
theme?: string
|
||||||
|
slides?: unknown[]
|
||||||
|
}): { title: string; theme: string; slides: Record<string, unknown>[] } {
|
||||||
|
const title = trimStr(input.title || 'Présentation', 80) || 'Présentation'
|
||||||
|
const theme = (typeof input.theme === 'string' && input.theme.trim()) || 'architectural-saas'
|
||||||
|
const raw = Array.isArray(input.slides) ? input.slides : []
|
||||||
|
|
||||||
|
let slides = raw
|
||||||
|
.filter((s): s is Record<string, unknown> => Boolean(s) && typeof s === 'object')
|
||||||
|
.map((s) => normalizeOneSlide(s))
|
||||||
|
.filter(Boolean) as Record<string, unknown>[]
|
||||||
|
|
||||||
|
slides = slides.slice(0, SLIDE_HARD_CAP)
|
||||||
|
|
||||||
|
// Ensure title first
|
||||||
|
if (slides.length === 0) {
|
||||||
|
slides = [
|
||||||
|
{ type: 'title', title, subtitle: '' },
|
||||||
|
{ type: 'summary', title: 'Points clés', items: ['Contenu source insuffisant pour un deck détaillé.'] },
|
||||||
|
]
|
||||||
|
} else if (slides[0]?.type !== 'title') {
|
||||||
|
slides.unshift({
|
||||||
|
type: 'title',
|
||||||
|
title,
|
||||||
|
subtitle: typeof slides[0]?.title === 'string' ? String(slides[0].title) : '',
|
||||||
|
})
|
||||||
|
slides = slides.slice(0, SLIDE_HARD_CAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure summary last (if ≥2 slides)
|
||||||
|
if (slides.length >= 2 && slides[slides.length - 1]?.type !== 'summary') {
|
||||||
|
if (slides.length >= SLIDE_HARD_CAP) {
|
||||||
|
slides[slides.length - 1] = {
|
||||||
|
type: 'summary',
|
||||||
|
title: 'À retenir',
|
||||||
|
items: extractKeyClaims(slides).slice(0, 4),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
slides.push({
|
||||||
|
type: 'summary',
|
||||||
|
title: 'À retenir',
|
||||||
|
items: extractKeyClaims(slides).slice(0, 4),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop empty / invalid charts (no inventing)
|
||||||
|
slides = slides.map((s) => {
|
||||||
|
if (s.type === 'chart') {
|
||||||
|
const data = Array.isArray(s.data) ? s.data : []
|
||||||
|
const valid = data.filter(
|
||||||
|
(d: any) => d && typeof d.label === 'string' && typeof d.value === 'number' && Number.isFinite(d.value),
|
||||||
|
)
|
||||||
|
if (valid.length < 2) {
|
||||||
|
return {
|
||||||
|
type: 'bullets',
|
||||||
|
title: trimStr(s.title || 'Données', 90),
|
||||||
|
items: extractKeyClaims([s]).slice(0, 4).length
|
||||||
|
? extractKeyClaims([s]).slice(0, 4)
|
||||||
|
: ['Pas de données numériques exploitables dans la source pour un graphique.'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...s, data: valid.slice(0, 8) }
|
||||||
|
}
|
||||||
|
if (s.type === 'stats') {
|
||||||
|
const stats = Array.isArray(s.stats) ? s.stats : []
|
||||||
|
const valid = stats.filter(
|
||||||
|
(st: any) => st && typeof st.value === 'string' && typeof st.label === 'string' && isNumericLike(String(st.value)),
|
||||||
|
)
|
||||||
|
if (valid.length === 0) {
|
||||||
|
return {
|
||||||
|
type: 'bullets',
|
||||||
|
title: trimStr(s.title || 'Indicateurs', 90),
|
||||||
|
items: cleanList(
|
||||||
|
stats.map((st: any) => (st?.label ? `${st.label}: ${st.value || ''}` : st?.value)).filter(Boolean),
|
||||||
|
4,
|
||||||
|
120,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...s, stats: valid.slice(0, 4) }
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
})
|
||||||
|
|
||||||
|
// Avoid two identical types in a row when possible (keep content, only if we can skip empty)
|
||||||
|
// No reordering that would break narrative — just leave as is after structural fixes.
|
||||||
|
|
||||||
|
return { title, theme, slides }
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOneSlide(s: Record<string, unknown>): Record<string, unknown> | null {
|
||||||
|
const type = String(s.type || 'bullets') as SlideType
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'title':
|
||||||
|
return {
|
||||||
|
type: 'title',
|
||||||
|
title: trimStr(s.title || 'Présentation', 80),
|
||||||
|
subtitle: trimStr(s.subtitle, 140) || undefined,
|
||||||
|
notes: trimStr(s.notes, 400) || undefined,
|
||||||
|
}
|
||||||
|
case 'bullets':
|
||||||
|
return {
|
||||||
|
type: 'bullets',
|
||||||
|
title: trimStr(s.title || 'Points clés', 90),
|
||||||
|
items: cleanList(s.items, 5, 140),
|
||||||
|
notes: trimStr(s.notes, 400) || undefined,
|
||||||
|
}
|
||||||
|
case 'summary':
|
||||||
|
return {
|
||||||
|
type: 'summary',
|
||||||
|
title: trimStr(s.title || 'À retenir', 90),
|
||||||
|
items: cleanList(s.items, 5, 140),
|
||||||
|
notes: trimStr(s.notes, 400) || undefined,
|
||||||
|
}
|
||||||
|
case 'cards': {
|
||||||
|
const cards = Array.isArray(s.cards) ? s.cards : []
|
||||||
|
const cleaned = cards
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((c: any) => ({
|
||||||
|
title: trimStr(c?.title, 60),
|
||||||
|
description: trimStr(c?.description, 160),
|
||||||
|
}))
|
||||||
|
.filter((c) => c.title || c.description)
|
||||||
|
return {
|
||||||
|
type: 'cards',
|
||||||
|
title: trimStr(s.title || 'Piliers', 90),
|
||||||
|
cards: cleaned,
|
||||||
|
notes: trimStr(s.notes, 400) || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'chart':
|
||||||
|
return {
|
||||||
|
type: 'chart',
|
||||||
|
title: trimStr(s.title || 'Données', 90),
|
||||||
|
chartType: ['bar', 'horizontal-bar', 'line', 'donut', 'radar'].includes(String(s.chartType))
|
||||||
|
? s.chartType
|
||||||
|
: 'bar',
|
||||||
|
data: Array.isArray(s.data) ? s.data : [],
|
||||||
|
subtitle: trimStr(s.subtitle, 120) || undefined,
|
||||||
|
notes: trimStr(s.notes, 400) || undefined,
|
||||||
|
}
|
||||||
|
case 'stats':
|
||||||
|
return {
|
||||||
|
type: 'stats',
|
||||||
|
title: trimStr(s.title || 'Chiffres clés', 90),
|
||||||
|
stats: Array.isArray(s.stats)
|
||||||
|
? s.stats.slice(0, 4).map((st: any) => ({
|
||||||
|
value: trimStr(st?.value, 24),
|
||||||
|
label: trimStr(st?.label, 48),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
notes: trimStr(s.notes, 400) || undefined,
|
||||||
|
}
|
||||||
|
case 'table':
|
||||||
|
return {
|
||||||
|
type: 'table',
|
||||||
|
title: trimStr(s.title || 'Tableau', 90),
|
||||||
|
headers: cleanList(s.headers, 6, 40),
|
||||||
|
rows: Array.isArray(s.rows)
|
||||||
|
? s.rows.slice(0, 8).map((r: unknown) => cleanList(r, 6, 60))
|
||||||
|
: [],
|
||||||
|
notes: trimStr(s.notes, 400) || undefined,
|
||||||
|
}
|
||||||
|
case 'timeline': {
|
||||||
|
const events = Array.isArray(s.events) ? s.events : []
|
||||||
|
return {
|
||||||
|
type: 'timeline',
|
||||||
|
title: trimStr(s.title || 'Chronologie', 90),
|
||||||
|
events: events.slice(0, 6).map((e: any) => ({
|
||||||
|
date: trimStr(e?.date, 40),
|
||||||
|
title: trimStr(e?.title, 80),
|
||||||
|
description: trimStr(e?.description, 140) || undefined,
|
||||||
|
})),
|
||||||
|
notes: trimStr(s.notes, 400) || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'quote':
|
||||||
|
return {
|
||||||
|
type: 'quote',
|
||||||
|
quote: trimStr(s.quote, 280),
|
||||||
|
author: trimStr(s.author, 60) || undefined,
|
||||||
|
context: trimStr(s.context, 160) || undefined,
|
||||||
|
notes: trimStr(s.notes, 400) || undefined,
|
||||||
|
}
|
||||||
|
case 'comparison': {
|
||||||
|
const left = (s.left && typeof s.left === 'object' ? s.left : {}) as any
|
||||||
|
const right = (s.right && typeof s.right === 'object' ? s.right : {}) as any
|
||||||
|
return {
|
||||||
|
type: 'comparison',
|
||||||
|
title: trimStr(s.title || 'Comparaison', 90),
|
||||||
|
left: {
|
||||||
|
title: trimStr(left.title || 'A', 40),
|
||||||
|
points: cleanList(left.points, 4, 100),
|
||||||
|
score: trimStr(left.score, 16) || undefined,
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
title: trimStr(right.title || 'B', 40),
|
||||||
|
points: cleanList(right.points, 4, 100),
|
||||||
|
score: trimStr(right.score, 16) || undefined,
|
||||||
|
},
|
||||||
|
notes: trimStr(s.notes, 400) || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'equation':
|
||||||
|
return {
|
||||||
|
type: 'equation',
|
||||||
|
title: trimStr(s.title || 'Formules', 90),
|
||||||
|
equations: Array.isArray(s.equations)
|
||||||
|
? s.equations.slice(0, 4).map((eq: any) => ({
|
||||||
|
latex: trimStr(eq?.latex, 120),
|
||||||
|
label: trimStr(eq?.label, 60) || undefined,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
explanation: trimStr(s.explanation, 200) || undefined,
|
||||||
|
notes: trimStr(s.notes, 400) || undefined,
|
||||||
|
}
|
||||||
|
case 'image':
|
||||||
|
return {
|
||||||
|
type: 'image',
|
||||||
|
title: trimStr(s.title || 'Illustration', 90),
|
||||||
|
url: typeof s.url === 'string' ? s.url.trim() : undefined,
|
||||||
|
caption: trimStr(s.caption, 140) || undefined,
|
||||||
|
notes: trimStr(s.notes, 400) || undefined,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
type: 'bullets',
|
||||||
|
title: trimStr(s.title || 'Points', 90),
|
||||||
|
items: cleanList(s.items || s.content, 5, 140),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A body slide is empty if it has a title but no real payload
|
||||||
|
* (the bug that shipped title-only decks).
|
||||||
|
*/
|
||||||
|
export function isSlideBodyEmpty(slide: Record<string, unknown>): boolean {
|
||||||
|
const type = String(slide.type || '')
|
||||||
|
if (type === 'title') {
|
||||||
|
// Title slides need a non-empty title (and ideally a subtitle, not required)
|
||||||
|
return !String(slide.title || '').trim()
|
||||||
|
}
|
||||||
|
switch (type) {
|
||||||
|
case 'bullets':
|
||||||
|
case 'summary':
|
||||||
|
return !Array.isArray(slide.items) || slide.items.filter((x) => String(x || '').trim()).length < 2
|
||||||
|
case 'cards':
|
||||||
|
return (
|
||||||
|
!Array.isArray(slide.cards) ||
|
||||||
|
slide.cards.filter((c: any) => String(c?.title || c?.description || '').trim()).length < 2
|
||||||
|
)
|
||||||
|
case 'stats':
|
||||||
|
return (
|
||||||
|
!Array.isArray(slide.stats) ||
|
||||||
|
slide.stats.filter((s: any) => String(s?.value || '').trim() && String(s?.label || '').trim()).length < 2
|
||||||
|
)
|
||||||
|
case 'chart':
|
||||||
|
return (
|
||||||
|
!Array.isArray(slide.data) ||
|
||||||
|
slide.data.filter((d: any) => d && typeof d.value === 'number' && String(d.label || '').trim()).length < 2
|
||||||
|
)
|
||||||
|
case 'table':
|
||||||
|
return !Array.isArray(slide.rows) || slide.rows.length < 1
|
||||||
|
case 'timeline':
|
||||||
|
return (
|
||||||
|
!Array.isArray(slide.events) ||
|
||||||
|
slide.events.filter((e: any) => String(e?.title || '').trim()).length < 2
|
||||||
|
)
|
||||||
|
case 'quote':
|
||||||
|
return !String(slide.quote || '').trim()
|
||||||
|
case 'comparison': {
|
||||||
|
const left = (slide.left || {}) as any
|
||||||
|
const right = (slide.right || {}) as any
|
||||||
|
const lp = Array.isArray(left.points) ? left.points.filter(Boolean).length : 0
|
||||||
|
const rp = Array.isArray(right.points) ? right.points.filter(Boolean).length : 0
|
||||||
|
return lp < 1 || rp < 1
|
||||||
|
}
|
||||||
|
case 'equation':
|
||||||
|
return !Array.isArray(slide.equations) || slide.equations.length < 1
|
||||||
|
case 'image':
|
||||||
|
// image without URL is weak but caption-only is still something; require title at least
|
||||||
|
return !String(slide.title || '').trim()
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listEmptyBodySlides(slides: Record<string, unknown>[]): number[] {
|
||||||
|
const empty: number[] = []
|
||||||
|
slides.forEach((s, i) => {
|
||||||
|
if (s.type === 'title') return
|
||||||
|
if (isSlideBodyEmpty(s)) empty.push(i)
|
||||||
|
})
|
||||||
|
return empty
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard gate: refuse to ship a deck that is mostly titles.
|
||||||
|
* Content slides (non-title) must have real bodies.
|
||||||
|
* STEM: if formulas were extracted from the note, deck MUST include equation slides with latex.
|
||||||
|
*/
|
||||||
|
export function assertDeckHasSubstance(
|
||||||
|
deck: {
|
||||||
|
title?: string
|
||||||
|
slides: Record<string, unknown>[]
|
||||||
|
},
|
||||||
|
opts?: { requireFormulas?: string[] },
|
||||||
|
): { ok: true } | { ok: false; reason: string; emptyIndexes: number[] } {
|
||||||
|
const slides = deck.slides || []
|
||||||
|
if (slides.length < 2) {
|
||||||
|
return { ok: false, reason: 'Deck has fewer than 2 slides', emptyIndexes: [] }
|
||||||
|
}
|
||||||
|
const bodySlides = slides.filter((s) => s.type !== 'title')
|
||||||
|
if (bodySlides.length === 0) {
|
||||||
|
return { ok: false, reason: 'Deck has no body slides', emptyIndexes: [] }
|
||||||
|
}
|
||||||
|
const emptyIndexes = listEmptyBodySlides(slides)
|
||||||
|
if (emptyIndexes.length > 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: `${emptyIndexes.length} body slide(s) have titles only (no bullets/cards/stats/…)`,
|
||||||
|
emptyIndexes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// At least 2 content claims across the deck
|
||||||
|
const claims = extractKeyClaims(slides)
|
||||||
|
if (claims.length < 2) {
|
||||||
|
return { ok: false, reason: 'Deck has insufficient claims/body text', emptyIndexes: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEM gate: note had formulas → at least one equation slide with non-empty latex
|
||||||
|
const required = opts?.requireFormulas?.filter((f) => f && f.trim()) || []
|
||||||
|
if (required.length > 0) {
|
||||||
|
const eqSlides = slides.filter((s) => s.type === 'equation')
|
||||||
|
const latexCount = eqSlides.reduce((n, s) => {
|
||||||
|
const eqs = Array.isArray(s.equations) ? s.equations : []
|
||||||
|
return (
|
||||||
|
n +
|
||||||
|
eqs.filter((e: any) => typeof e?.latex === 'string' && e.latex.trim().length >= 2).length
|
||||||
|
)
|
||||||
|
}, 0)
|
||||||
|
if (latexCount < 1) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: `STEM gate: note has ${required.length} formula(s) but deck has no equation slides with LaTeX`,
|
||||||
|
emptyIndexes: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inject equation slides from extracted formulas (deterministic STEM fix). */
|
||||||
|
export function injectEquationSlidesFromFormulas(
|
||||||
|
deck: { title: string; theme: string; slides: Record<string, unknown>[] },
|
||||||
|
formulas: string[],
|
||||||
|
opts?: { lang?: 'fr' | 'en' },
|
||||||
|
): { title: string; theme: string; slides: Record<string, unknown>[] } {
|
||||||
|
const forms = formulas.map((f) => f.trim()).filter((f) => f.length >= 2).slice(0, 8)
|
||||||
|
if (forms.length === 0) return deck
|
||||||
|
|
||||||
|
const hasEq = deck.slides.some((s) => {
|
||||||
|
if (s.type !== 'equation') return false
|
||||||
|
const eqs = Array.isArray(s.equations) ? s.equations : []
|
||||||
|
return eqs.some((e: any) => String(e?.latex || '').trim().length >= 2)
|
||||||
|
})
|
||||||
|
if (hasEq) return deck
|
||||||
|
|
||||||
|
const lang = opts?.lang || 'fr'
|
||||||
|
const titleSlide = deck.slides.find((s) => s.type === 'title')
|
||||||
|
const summarySlide = deck.slides.find((s) => s.type === 'summary')
|
||||||
|
const middle = deck.slides.filter((s) => s.type !== 'title' && s.type !== 'summary')
|
||||||
|
|
||||||
|
const groups: string[][] = []
|
||||||
|
for (let i = 0; i < forms.length; i += 2) {
|
||||||
|
groups.push(forms.slice(i, i + 2))
|
||||||
|
}
|
||||||
|
const eqSlides = groups.slice(0, 2).map((group, gi) => ({
|
||||||
|
type: 'equation',
|
||||||
|
title:
|
||||||
|
gi === 0
|
||||||
|
? lang === 'fr'
|
||||||
|
? 'Équations fondamentales'
|
||||||
|
: 'Core equations'
|
||||||
|
: lang === 'fr'
|
||||||
|
? 'Formules complémentaires'
|
||||||
|
: 'Additional formulas',
|
||||||
|
equations: group.map((latex, i) => ({
|
||||||
|
latex,
|
||||||
|
label: lang === 'fr' ? `Formule ${gi * 2 + i + 1}` : `Formula ${gi * 2 + i + 1}`,
|
||||||
|
})),
|
||||||
|
explanation:
|
||||||
|
lang === 'fr'
|
||||||
|
? 'Formules extraites de la note source'
|
||||||
|
: 'Formulas extracted from the source note',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const slides = [
|
||||||
|
titleSlide || { type: 'title', title: deck.title },
|
||||||
|
...eqSlides,
|
||||||
|
...middle,
|
||||||
|
summarySlide || {
|
||||||
|
type: 'summary',
|
||||||
|
title: lang === 'fr' ? 'À retenir' : 'Key takeaways',
|
||||||
|
items:
|
||||||
|
lang === 'fr'
|
||||||
|
? ['Revoir les formules', 'Appliquer sur un exemple', 'Vérifier les conditions']
|
||||||
|
: ['Review formulas', 'Apply on an example', 'Check conditions'],
|
||||||
|
},
|
||||||
|
].slice(0, SLIDE_HARD_CAP)
|
||||||
|
|
||||||
|
return normalizeSlideDeck({ title: deck.title, theme: deck.theme, slides })
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractKeyClaims(slides: Record<string, unknown>[]): string[] {
|
||||||
|
const claims: string[] = []
|
||||||
|
for (const s of slides) {
|
||||||
|
if (s.type === 'title' || s.type === 'summary') continue
|
||||||
|
if (Array.isArray(s.items)) {
|
||||||
|
for (const it of s.items) {
|
||||||
|
if (typeof it === 'string' && it.trim()) claims.push(trimStr(it, 120))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(s.cards)) {
|
||||||
|
for (const c of s.cards as any[]) {
|
||||||
|
const line = [c?.title, c?.description].filter(Boolean).join(' — ')
|
||||||
|
if (line) claims.push(trimStr(line, 120))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof s.title === 'string' && s.type !== 'bullets') {
|
||||||
|
claims.push(trimStr(s.title, 100))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...new Set(claims)].filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** System-prompt quality rules (shared FR/EN cores built in agent-executor). */
|
||||||
|
export const SLIDE_CONTENT_PRINCIPLES_FR = `
|
||||||
|
═══ QUALITÉ DU CONTENU (OBLIGATOIRE) ═══
|
||||||
|
Tu es un consultant McKinsey/BCG qui structure un deck exécutif. Le public lit les titres d'abord.
|
||||||
|
|
||||||
|
1. UNE IDÉE PAR SLIDE
|
||||||
|
- Le "title" de chaque slide est un TITRE D'ACTION : une phrase d'insight, pas un libellé de sujet.
|
||||||
|
- Mauvais : "Contexte" / "Analyse" / "Résultats"
|
||||||
|
- Bon : "Le délai de livraison a doublé en 6 mois" / "Trois leviers expliquent 80 % du retard"
|
||||||
|
|
||||||
|
2. ARC NARRATIF (dans l'ordre)
|
||||||
|
title → contexte/problème → insight principal → preuves (chiffres ou exemples) → implications/options → summary (next steps)
|
||||||
|
Ne pas coller une liste de sujets sans fil conducteur.
|
||||||
|
|
||||||
|
3. TEXTE COURT ET UTILE
|
||||||
|
- Bullets : 3 à 5 MAX, 1 claim distinct par ligne (8–18 mots idéalement).
|
||||||
|
- INTERDIT : paragraphes, répétition du titre, filler ("Il est important de noter…", "En conclusion on peut dire…").
|
||||||
|
- Chaque bullet doit apporter une information nouvelle tirée de la note.
|
||||||
|
|
||||||
|
4. DONNÉES HONNÊTES
|
||||||
|
- "chart" / "stats" UNIQUEMENT si des chiffres REELS figurent dans la note.
|
||||||
|
- INTERDIT d'inventer des KPI, scores radar, pourcentages ou tendances.
|
||||||
|
- Sans chiffres : cards, comparison, timeline, quote, bullets — jamais de faux graphiques.
|
||||||
|
|
||||||
|
5. VARIÉTÉ UTILE
|
||||||
|
- Varier les types selon le contenu (pas 4× bullets d'affilée si d'autres formats collent mieux).
|
||||||
|
- Premier slide = "title", dernier = "summary" avec 3–5 takeaways actionnables.
|
||||||
|
|
||||||
|
6. FIDÉLITÉ À LA SOURCE
|
||||||
|
- Reformuler pour la clarté, ne pas inventer de faits, noms, dates ou conclusions absents de la note.
|
||||||
|
- Si la note est courte : peu de slides, contenu dense et fidèle — pas de padding.
|
||||||
|
|
||||||
|
7. NOTES ORATEUR (optionnel)
|
||||||
|
- Champ "notes" : 1–2 phrases pour le présentateur (non affiché sur la slide).
|
||||||
|
`
|
||||||
|
|
||||||
|
export const SLIDE_CONTENT_PRINCIPLES_EN = `
|
||||||
|
═══ CONTENT QUALITY (MANDATORY) ═══
|
||||||
|
You are a McKinsey/BCG consultant building an executive deck. Audiences read titles first.
|
||||||
|
|
||||||
|
1. ONE IDEA PER SLIDE
|
||||||
|
- Each slide "title" is an ACTION TITLE: an insight sentence, not a topic label.
|
||||||
|
- Bad: "Context" / "Analysis" / "Results"
|
||||||
|
- Good: "Delivery time doubled in 6 months" / "Three levers explain 80% of the delay"
|
||||||
|
|
||||||
|
2. NARRATIVE ARC (in order)
|
||||||
|
title → context/problem → key insight → evidence (numbers or examples) → implications/options → summary (next steps)
|
||||||
|
Do not dump a topic list without a storyline.
|
||||||
|
|
||||||
|
3. SHORT, USEFUL TEXT
|
||||||
|
- Bullets: 3–5 MAX, one distinct claim per line (ideally 8–18 words).
|
||||||
|
- FORBIDDEN: paragraphs, restating the title, filler ("It is important to note…").
|
||||||
|
- Each bullet must add new information from the note.
|
||||||
|
|
||||||
|
4. HONEST DATA
|
||||||
|
- "chart" / "stats" ONLY if REAL numbers appear in the note.
|
||||||
|
- NEVER invent KPIs, radar scores, percentages, or trends.
|
||||||
|
- No numbers: use cards, comparison, timeline, quote, bullets — never fake charts.
|
||||||
|
|
||||||
|
5. USEFUL VARIETY
|
||||||
|
- Vary types based on content (not 4× bullets if another type fits better).
|
||||||
|
- First slide = "title", last = "summary" with 3–5 actionable takeaways.
|
||||||
|
|
||||||
|
6. FIDELITY TO SOURCE
|
||||||
|
- Rephrase for clarity; do not invent facts, names, dates, or conclusions absent from the note.
|
||||||
|
- Short notes → few slides, faithful content — no padding.
|
||||||
|
|
||||||
|
7. SPEAKER NOTES (optional)
|
||||||
|
- "notes" field: 1–2 sentences for the presenter (not shown on the slide).
|
||||||
|
`
|
||||||
692
memento-note/lib/ai/services/slide-deck-generator.service.ts
Normal file
692
memento-note/lib/ai/services/slide-deck-generator.service.ts
Normal file
@@ -0,0 +1,692 @@
|
|||||||
|
/**
|
||||||
|
* Slide deck generation — DeckForge / PPTAgent / Hermes-inspired pipeline.
|
||||||
|
*
|
||||||
|
* Sources of truth (research):
|
||||||
|
* - DeckForge: intent → outline → expand PER SLIDE → refine → validate IR
|
||||||
|
* https://github.com/Whatsonyourmind/deckforge
|
||||||
|
* - PPTAgent: outline with retrieved document content, then iterative fill
|
||||||
|
* https://github.com/icip-cas/PPTAgent arXiv:2501.03936
|
||||||
|
* - Hermes PowerPoint skill: domain palettes, no empty slides, QA before ship
|
||||||
|
* https://hermes-agent.nousresearch.com/docs/user-guide/skills/bundled/productivity/productivity-powerpoint
|
||||||
|
* - OpenClaw ppt-html-pipeline: per-page blueprint, no large blank regions
|
||||||
|
* https://github.com/Yuancircle/openclaw-ppt-html-pipeline
|
||||||
|
*
|
||||||
|
* NEVER persists a deck that fails substance validation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { generateObject, generateText } from 'ai'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { buildPresentationHTML } from '@/lib/ai/tools/slides-html-builder'
|
||||||
|
import {
|
||||||
|
assertDeckHasSubstance,
|
||||||
|
countNoteWords,
|
||||||
|
injectEquationSlidesFromFormulas,
|
||||||
|
normalizeSlideDeck,
|
||||||
|
prepareNoteTextForSlides,
|
||||||
|
slideLimitFromWordCount,
|
||||||
|
SLIDE_HARD_CAP,
|
||||||
|
} from '@/lib/ai/services/slide-content-quality'
|
||||||
|
import { extractSourceAssets, type SourceAssets } from '@/lib/ai/services/slide-source-assets'
|
||||||
|
import {
|
||||||
|
intentPromptHints,
|
||||||
|
normalizeSlideIntent,
|
||||||
|
type SlideIntent,
|
||||||
|
} from '@/lib/ai/services/slide-intent'
|
||||||
|
import { getSystemConfig } from '@/lib/config'
|
||||||
|
import { getSlidesProvider } from '@/lib/ai/factory'
|
||||||
|
|
||||||
|
// ── Types catalog (includes equation — critical for STEM notes) ──────────────
|
||||||
|
|
||||||
|
const SlideTypeEnum = z.enum([
|
||||||
|
'title',
|
||||||
|
'bullets',
|
||||||
|
'equation',
|
||||||
|
'cards',
|
||||||
|
'comparison',
|
||||||
|
'timeline',
|
||||||
|
'stats',
|
||||||
|
'chart',
|
||||||
|
'table',
|
||||||
|
'quote',
|
||||||
|
'summary',
|
||||||
|
])
|
||||||
|
|
||||||
|
const OutlineSlideSchema = z.object({
|
||||||
|
position: z.number(),
|
||||||
|
type: SlideTypeEnum,
|
||||||
|
/** Assertive headline, max ~10 words (DeckForge: max 8) */
|
||||||
|
headline: z.string(),
|
||||||
|
/** 2–5 key points that MUST appear on the slide body */
|
||||||
|
keyPoints: z.array(z.string()).min(1).max(6),
|
||||||
|
/** For equation slides: latex strings to include */
|
||||||
|
formulas: z.array(z.string()).optional(),
|
||||||
|
narrativeRole: z.enum(['opening', 'evidence', 'transition', 'conclusion', 'data']).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const OutlineSchema = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
narrativeArc: z.enum(['pyramid', 'scr', 'mece', 'chronological', 'pedagogical']).optional(),
|
||||||
|
slides: z.array(OutlineSlideSchema).min(2).max(SLIDE_HARD_CAP),
|
||||||
|
})
|
||||||
|
|
||||||
|
const ExpandedSlideSchema = z.object({
|
||||||
|
type: SlideTypeEnum,
|
||||||
|
title: z.string(),
|
||||||
|
subtitle: z.string().optional(),
|
||||||
|
items: z.array(z.string()).optional(),
|
||||||
|
equations: z.array(z.object({ latex: z.string(), label: z.string().optional() })).optional(),
|
||||||
|
explanation: z.string().optional(),
|
||||||
|
cards: z.array(z.object({ title: z.string(), description: z.string() })).optional(),
|
||||||
|
left: z
|
||||||
|
.object({ title: z.string(), points: z.array(z.string()), score: z.string().optional() })
|
||||||
|
.optional(),
|
||||||
|
right: z
|
||||||
|
.object({ title: z.string(), points: z.array(z.string()), score: z.string().optional() })
|
||||||
|
.optional(),
|
||||||
|
events: z
|
||||||
|
.array(z.object({ date: z.string(), title: z.string(), description: z.string().optional() }))
|
||||||
|
.optional(),
|
||||||
|
stats: z.array(z.object({ value: z.string(), label: z.string() })).optional(),
|
||||||
|
chartType: z.enum(['bar', 'horizontal-bar', 'line', 'donut', 'radar']).optional(),
|
||||||
|
data: z.array(z.object({ label: z.string(), value: z.number() })).optional(),
|
||||||
|
headers: z.array(z.string()).optional(),
|
||||||
|
rows: z.array(z.array(z.string())).optional(),
|
||||||
|
quote: z.string().optional(),
|
||||||
|
author: z.string().optional(),
|
||||||
|
context: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface GenerateSlideDeckParams {
|
||||||
|
userId: string
|
||||||
|
noteIds?: string[]
|
||||||
|
sourceNotebookId?: string | null
|
||||||
|
theme?: string | null
|
||||||
|
template?: string | null
|
||||||
|
lang?: 'fr' | 'en'
|
||||||
|
contentLanguage?: string | null
|
||||||
|
actionId?: string | null
|
||||||
|
/** Product intent: purpose, audience, slide count */
|
||||||
|
intent?: SlideIntent | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateSlideDeckResult {
|
||||||
|
success: boolean
|
||||||
|
canvasId?: string
|
||||||
|
canvasName?: string
|
||||||
|
slideCount?: number
|
||||||
|
error?: string
|
||||||
|
mode?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const LANG_NAMES: Record<string, string> = {
|
||||||
|
fr: 'French',
|
||||||
|
en: 'English',
|
||||||
|
es: 'Spanish',
|
||||||
|
de: 'German',
|
||||||
|
it: 'Italian',
|
||||||
|
pt: 'Portuguese',
|
||||||
|
nl: 'Dutch',
|
||||||
|
pl: 'Polish',
|
||||||
|
ru: 'Russian',
|
||||||
|
zh: 'Chinese',
|
||||||
|
ja: 'Japanese',
|
||||||
|
ko: 'Korean',
|
||||||
|
ar: 'Arabic',
|
||||||
|
fa: 'Persian (Farsi)',
|
||||||
|
hi: 'Hindi',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LLM helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function extractJsonPayload(text: string): unknown | null {
|
||||||
|
if (!text) return null
|
||||||
|
const raw = text.trim()
|
||||||
|
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i)
|
||||||
|
const candidate = fenced?.[1]?.trim() || raw
|
||||||
|
const tryParse = (s: string) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(s)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let parsed = tryParse(candidate)
|
||||||
|
if (parsed) return parsed
|
||||||
|
const brace = candidate.match(/\{[\s\S]*\}/)
|
||||||
|
if (brace) parsed = tryParse(brace[0])
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
async function llmObject<T>(opts: {
|
||||||
|
model: any
|
||||||
|
schema: z.ZodType<T>
|
||||||
|
system: string
|
||||||
|
prompt: string
|
||||||
|
}): Promise<T> {
|
||||||
|
const { model, schema, system, prompt } = opts
|
||||||
|
try {
|
||||||
|
const { object } = await generateObject({ model, schema, system, prompt })
|
||||||
|
return object as T
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[SlideDeck] generateObject failed → text JSON:', (err as Error)?.message)
|
||||||
|
const { text } = await generateText({
|
||||||
|
model,
|
||||||
|
system,
|
||||||
|
prompt: `${prompt}\n\nOutput ONE JSON object only.`,
|
||||||
|
})
|
||||||
|
const raw = extractJsonPayload(text)
|
||||||
|
if (!raw) throw new Error(`Non-JSON model output: ${String(text).slice(0, 180)}`)
|
||||||
|
const parsed = schema.safeParse(raw)
|
||||||
|
if (parsed.success) return parsed.data
|
||||||
|
return raw as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notes load ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadNotes(params: GenerateSlideDeckParams) {
|
||||||
|
if (params.noteIds?.length) {
|
||||||
|
return prisma.note.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: params.noteIds },
|
||||||
|
userId: params.userId,
|
||||||
|
isArchived: false,
|
||||||
|
trashedAt: null,
|
||||||
|
},
|
||||||
|
select: { id: true, title: true, content: true, createdAt: true, language: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (params.sourceNotebookId) {
|
||||||
|
return prisma.note.findMany({
|
||||||
|
where: {
|
||||||
|
notebookId: params.sourceNotebookId,
|
||||||
|
userId: params.userId,
|
||||||
|
isArchived: false,
|
||||||
|
trashedAt: null,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 15,
|
||||||
|
select: { id: true, title: true, content: true, createdAt: true, language: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage prompts (DeckForge-style) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function outlineSystem(lang: 'fr' | 'en', maxSlides: number, assets: SourceAssets): string {
|
||||||
|
const mathRule = assets.hasMath
|
||||||
|
? lang === 'fr'
|
||||||
|
? `DOMAINE MATH/STEM DÉTECTÉ: tu DOIS inclure au moins ${Math.min(2, Math.max(1, assets.formulas.length))} slides de type "equation" qui portent les formules extraites. Ne remplace PAS les formules par des bullets vagues.`
|
||||||
|
: `MATH/STEM DOMAIN DETECTED: you MUST include at least ${Math.min(2, Math.max(1, assets.formulas.length))} slides of type "equation" carrying the extracted formulas. Do NOT replace formulas with vague bullets.`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
if (lang === 'fr') {
|
||||||
|
return `Tu es l'architecte narratif DeckForge/PPTAgent pour Memento.
|
||||||
|
Tu produis UNIQUEMENT un outline JSON (pas le corps final des slides).
|
||||||
|
|
||||||
|
Règles (DeckForge + think-cell):
|
||||||
|
- Max ${maxSlides} slides
|
||||||
|
- Headline ASSERTIVE, max 10 mots (pas "Contexte", "Introduction")
|
||||||
|
- keyPoints: 2–5 faits de la note par slide
|
||||||
|
- Arc pédagogique pour cours: title → définitions/équations → propriétés → exemples → summary
|
||||||
|
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | summary
|
||||||
|
- Slide 1 = title, dernière = summary
|
||||||
|
${mathRule}
|
||||||
|
Réponds en JSON OutlineSchema.`
|
||||||
|
}
|
||||||
|
return `You are DeckForge/PPTAgent narrative architect for Memento.
|
||||||
|
Output ONLY outline JSON (not full slide bodies).
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Max ${maxSlides} slides
|
||||||
|
- ASSERTIVE headlines, max 10 words
|
||||||
|
- keyPoints: 2–5 facts from the note per slide
|
||||||
|
- Pedagogical arc for lessons: title → definitions/equations → properties → examples → summary
|
||||||
|
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | summary
|
||||||
|
- First=title, last=summary
|
||||||
|
${mathRule}
|
||||||
|
JSON OutlineSchema only.`
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandOneSystem(lang: 'fr' | 'en'): string {
|
||||||
|
if (lang === 'fr') {
|
||||||
|
return `Tu es le rédacteur de slides DeckForge. Tu EXPANDES UNE slide à la fois.
|
||||||
|
|
||||||
|
INTERDIT: slide avec seulement un titre.
|
||||||
|
OBLIGATOIRE selon type:
|
||||||
|
- title: title + subtitle (1 phrase)
|
||||||
|
- equation: title + equations[{latex,label?}] (min 1, latex fidèle à la note) + explanation courte
|
||||||
|
- bullets: title + items[3-5] claims ≤18 mots
|
||||||
|
- cards: title + cards[2-4] {title, description}
|
||||||
|
- comparison: title + left/right avec points[2-4] chacun
|
||||||
|
- timeline: title + events[2-5]
|
||||||
|
- stats: title + stats[2-4] SEULEMENT si chiffres réels fournis
|
||||||
|
- chart: title + data[2+] SEULEMENT si chiffres réels
|
||||||
|
- summary: title + items[3-5] actionnables
|
||||||
|
- quote: quote non vide
|
||||||
|
|
||||||
|
JSON ExpandedSlide uniquement.`
|
||||||
|
}
|
||||||
|
return `You are DeckForge slide writer. Expand ONE slide only.
|
||||||
|
|
||||||
|
FORBIDDEN: title-only slides.
|
||||||
|
REQUIRED by type:
|
||||||
|
- title: title + subtitle
|
||||||
|
- equation: title + equations[{latex,label?}] min 1 + short explanation
|
||||||
|
- bullets: title + items[3-5] ≤18 words each
|
||||||
|
- cards: title + cards[2-4]
|
||||||
|
- comparison: left/right points[2-4] each
|
||||||
|
- timeline: events[2-5]
|
||||||
|
- stats/chart: only with real numbers provided
|
||||||
|
- summary: items[3-5]
|
||||||
|
- quote: non-empty quote
|
||||||
|
|
||||||
|
ExpandedSlide JSON only.`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force math slides into outline when server extracted formulas (PPTAgent retrieval). */
|
||||||
|
function enforceMathOutline(
|
||||||
|
outline: z.infer<typeof OutlineSchema>,
|
||||||
|
assets: SourceAssets,
|
||||||
|
maxSlides: number,
|
||||||
|
): z.infer<typeof OutlineSchema> {
|
||||||
|
if (!assets.hasMath || assets.formulas.length === 0) return outline
|
||||||
|
const hasEq = outline.slides.some((s) => s.type === 'equation')
|
||||||
|
if (hasEq) {
|
||||||
|
// Ensure equation slides list formulas
|
||||||
|
return {
|
||||||
|
...outline,
|
||||||
|
slides: outline.slides.map((s, i) => {
|
||||||
|
if (s.type !== 'equation') return s
|
||||||
|
const chunk = assets.formulas.slice(i % assets.formulas.length, (i % assets.formulas.length) + 2)
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
formulas: s.formulas?.length ? s.formulas : chunk.length ? chunk : assets.formulas.slice(0, 2),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject equation slides after title
|
||||||
|
const title = outline.slides.find((s) => s.type === 'title') || {
|
||||||
|
position: 1,
|
||||||
|
type: 'title' as const,
|
||||||
|
headline: outline.title,
|
||||||
|
keyPoints: assets.keySentences.slice(0, 2),
|
||||||
|
narrativeRole: 'opening' as const,
|
||||||
|
}
|
||||||
|
const rest = outline.slides.filter((s) => s.type !== 'title' && s.type !== 'summary')
|
||||||
|
const summary = outline.slides.find((s) => s.type === 'summary') || {
|
||||||
|
position: 99,
|
||||||
|
type: 'summary' as const,
|
||||||
|
headline: 'Points clés',
|
||||||
|
keyPoints: assets.keySentences.slice(0, 3),
|
||||||
|
narrativeRole: 'conclusion' as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
const eqSlides: z.infer<typeof OutlineSlideSchema>[] = []
|
||||||
|
const groups = Math.min(2, Math.ceil(assets.formulas.length / 2))
|
||||||
|
for (let g = 0; g < groups; g++) {
|
||||||
|
const formulas = assets.formulas.slice(g * 2, g * 2 + 2)
|
||||||
|
eqSlides.push({
|
||||||
|
position: g + 2,
|
||||||
|
type: 'equation',
|
||||||
|
headline: g === 0 ? 'Équations fondamentales' : 'Formules complémentaires',
|
||||||
|
keyPoints: formulas.map((f) => f.slice(0, 80)),
|
||||||
|
formulas,
|
||||||
|
narrativeRole: 'evidence',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let slides = [title, ...eqSlides, ...rest, summary].slice(0, maxSlides)
|
||||||
|
// Ensure last is summary
|
||||||
|
if (slides[slides.length - 1]?.type !== 'summary') {
|
||||||
|
slides = [...slides.slice(0, maxSlides - 1), summary]
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...outline,
|
||||||
|
slides: slides.map((s, i) => ({ ...s, position: i + 1 })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackExpandFromOutline(
|
||||||
|
o: z.infer<typeof OutlineSlideSchema>,
|
||||||
|
assets: SourceAssets,
|
||||||
|
): z.infer<typeof ExpandedSlideSchema> {
|
||||||
|
const type = o.type
|
||||||
|
const title = o.headline
|
||||||
|
if (type === 'title') {
|
||||||
|
return {
|
||||||
|
type: 'title',
|
||||||
|
title,
|
||||||
|
subtitle: o.keyPoints[0] || assets.keySentences[0] || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type === 'equation') {
|
||||||
|
const forms = o.formulas?.length ? o.formulas : assets.formulas.slice(0, 3)
|
||||||
|
return {
|
||||||
|
type: 'equation',
|
||||||
|
title,
|
||||||
|
equations: (forms.length ? forms : ['f(x) = ?']).map((latex, i) => ({
|
||||||
|
latex,
|
||||||
|
label: o.keyPoints[i] || `Formule ${i + 1}`,
|
||||||
|
})),
|
||||||
|
explanation: o.keyPoints.join(' · ').slice(0, 200),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type === 'cards') {
|
||||||
|
const pts = o.keyPoints.length >= 2 ? o.keyPoints : assets.keySentences.slice(0, 3)
|
||||||
|
return {
|
||||||
|
type: 'cards',
|
||||||
|
title,
|
||||||
|
cards: pts.slice(0, 4).map((p, i) => ({
|
||||||
|
title: `Point ${i + 1}`,
|
||||||
|
description: p,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type === 'summary') {
|
||||||
|
return {
|
||||||
|
type: 'summary',
|
||||||
|
title,
|
||||||
|
items: (o.keyPoints.length ? o.keyPoints : assets.keySentences).slice(0, 5),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type === 'stats' && assets.numbers.length >= 2) {
|
||||||
|
return {
|
||||||
|
type: 'stats',
|
||||||
|
title,
|
||||||
|
stats: assets.numbers.slice(0, 4).map((n) => ({
|
||||||
|
value: n.raw,
|
||||||
|
label: n.label || title,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type === 'chart' && assets.numbers.length >= 2) {
|
||||||
|
return {
|
||||||
|
type: 'chart',
|
||||||
|
title,
|
||||||
|
chartType: 'bar',
|
||||||
|
data: assets.numbers.slice(0, 6).map((n) => ({
|
||||||
|
label: n.label.slice(0, 20) || n.raw,
|
||||||
|
value: n.value,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// default bullets — use keyPoints + sentences to fill density
|
||||||
|
const items = [
|
||||||
|
...o.keyPoints,
|
||||||
|
...assets.keySentences.filter((s) => !o.keyPoints.some((k) => s.includes(k.slice(0, 20)))),
|
||||||
|
].slice(0, 5)
|
||||||
|
while (items.length < 3 && assets.keySentences[items.length]) {
|
||||||
|
items.push(assets.keySentences[items.length]!)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: type === 'quote' ? 'bullets' : type === 'comparison' ? 'bullets' : type === 'timeline' ? 'bullets' : 'bullets',
|
||||||
|
title,
|
||||||
|
items: items.length >= 2 ? items : [title, assets.keySentences[0] || 'Voir la note source'].filter(Boolean),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Persist ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function persist(
|
||||||
|
userId: string,
|
||||||
|
deck: ReturnType<typeof normalizeSlideDeck>,
|
||||||
|
actionId: string | null | undefined,
|
||||||
|
mode: string,
|
||||||
|
) {
|
||||||
|
const html = buildPresentationHTML({
|
||||||
|
title: deck.title,
|
||||||
|
theme: deck.theme,
|
||||||
|
slides: deck.slides as any,
|
||||||
|
})
|
||||||
|
const canvas = await prisma.canvas.create({
|
||||||
|
data: {
|
||||||
|
name: deck.title || 'Présentation',
|
||||||
|
data: JSON.stringify({
|
||||||
|
type: 'slides',
|
||||||
|
title: deck.title,
|
||||||
|
html,
|
||||||
|
slideCount: deck.slides.length,
|
||||||
|
theme: deck.theme,
|
||||||
|
mode,
|
||||||
|
spec: { title: deck.title, theme: deck.theme, slides: deck.slides },
|
||||||
|
}),
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (actionId) {
|
||||||
|
await prisma.agentAction
|
||||||
|
.update({
|
||||||
|
where: { id: actionId },
|
||||||
|
data: {
|
||||||
|
status: 'success',
|
||||||
|
result: canvas.id,
|
||||||
|
log: `Slides OK (${mode}): ${deck.slides.length} slides, ${Math.round(html.length / 1024)}KB, substance OK`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
return canvas
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function generateSlideDeck(params: GenerateSlideDeckParams): Promise<GenerateSlideDeckResult> {
|
||||||
|
const lang = params.lang === 'en' ? 'en' : 'fr'
|
||||||
|
const notes = await loadNotes(params)
|
||||||
|
if (!notes.length) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: lang === 'fr' ? 'Aucune note source.' : 'No source notes.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const noteLang = notes[0]?.language
|
||||||
|
const contentLang =
|
||||||
|
params.contentLanguage ||
|
||||||
|
(noteLang && LANG_NAMES[noteLang] ? LANG_NAMES[noteLang] : lang === 'fr' ? 'French' : 'English')
|
||||||
|
|
||||||
|
const combined = notes.map((n) => n.content || '').join('\n\n')
|
||||||
|
const assets = extractSourceAssets(combined)
|
||||||
|
const notesText = notes
|
||||||
|
.map((n) => `### ${n.title || 'Note'}\n${prepareNoteTextForSlides(n.content || '', 10_000)}`)
|
||||||
|
.join('\n\n')
|
||||||
|
const wordCount = notes.reduce((s, n) => s + countNoteWords(n.content || ''), 0)
|
||||||
|
const limit = slideLimitFromWordCount(wordCount)
|
||||||
|
const intent = normalizeSlideIntent(params.intent || { template: params.template || 'auto' })
|
||||||
|
// User-chosen count overrides word-count heuristic
|
||||||
|
const targetMax = intent.slideCount || limit.max
|
||||||
|
const targetMin = intent.slideCount || limit.min
|
||||||
|
const theme =
|
||||||
|
params.theme && params.theme !== 'auto'
|
||||||
|
? params.theme
|
||||||
|
: assets.hasMath || intent.purpose === 'course'
|
||||||
|
? 'clinical-precision'
|
||||||
|
: 'architectural-saas'
|
||||||
|
|
||||||
|
// Admin-configured slides model (or chat if slides not set). Never rename model IDs.
|
||||||
|
const sysConfig = await getSystemConfig()
|
||||||
|
const model = getSlidesProvider(sysConfig).getModel()
|
||||||
|
|
||||||
|
const intentHints = intentPromptHints(intent, lang)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ── Stage 1: Outline (DeckForge outliner) ──
|
||||||
|
const assetsBlock = [
|
||||||
|
assets.formulas.length
|
||||||
|
? `FORMULES EXTRAITES (à placer dans des slides equation):\n${assets.formulas.map((f, i) => `${i + 1}. ${f}`).join('\n')}`
|
||||||
|
: '',
|
||||||
|
assets.numbers.length
|
||||||
|
? `CHIFFRES:\n${assets.numbers
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((n) => `- ${n.label}: ${n.raw}`)
|
||||||
|
.join('\n')}`
|
||||||
|
: '',
|
||||||
|
assets.keySentences.length
|
||||||
|
? `PHRASES CLÉS:\n${assets.keySentences
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((s) => `- ${s}`)
|
||||||
|
.join('\n')}`
|
||||||
|
: '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n')
|
||||||
|
|
||||||
|
let outline = await llmObject({
|
||||||
|
model,
|
||||||
|
schema: OutlineSchema,
|
||||||
|
system: outlineSystem(lang, targetMax, assets),
|
||||||
|
prompt:
|
||||||
|
lang === 'fr'
|
||||||
|
? `Langue du deck: ${contentLang}.\nNombre de slides cible: ${targetMax} (min ${targetMin}).\nThème: ${theme}.\n${intentHints ? `\n## Intent produit\n${intentHints}\n` : ''}\n## Assets serveur\n${assetsBlock || '(aucun)'}\n\n## Note\n${notesText}\n\nProduis l'outline JSON.`
|
||||||
|
: `Deck language: ${contentLang}.\nTarget slides: ${targetMax} (min ${targetMin}).\nTheme: ${theme}.\n${intentHints ? `\n## Product intent\n${intentHints}\n` : ''}\n## Server assets\n${assetsBlock || '(none)'}\n\n## Note\n${notesText}\n\nProduce outline JSON.`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// STEM or course purpose → force equation slides when formulas exist
|
||||||
|
if (assets.hasMath || intent.purpose === 'course') {
|
||||||
|
outline = enforceMathOutline(outline, assets, targetMax)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage 2: Expand PER SLIDE (DeckForge SlideWriter loop) ──
|
||||||
|
const expanded: z.infer<typeof ExpandedSlideSchema>[] = []
|
||||||
|
for (const slideOutline of outline.slides) {
|
||||||
|
try {
|
||||||
|
const formulaHint =
|
||||||
|
slideOutline.type === 'equation'
|
||||||
|
? `\nFORMULES OBLIGATOIRES pour cette slide:\n${(slideOutline.formulas || assets.formulas).slice(0, 4).join('\n')}`
|
||||||
|
: assets.formulas.length && slideOutline.type === 'bullets'
|
||||||
|
? `\n(Formules dispo si besoin: ${assets.formulas.slice(0, 2).join(' ; ')})`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const one = await llmObject({
|
||||||
|
model,
|
||||||
|
schema: ExpandedSlideSchema,
|
||||||
|
system: expandOneSystem(lang),
|
||||||
|
prompt:
|
||||||
|
lang === 'fr'
|
||||||
|
? `Langue: ${contentLang}.
|
||||||
|
${intentHints ? `Intent: ${intentHints}\n` : ''}Slide ${slideOutline.position}/${outline.slides.length}
|
||||||
|
type: ${slideOutline.type}
|
||||||
|
headline: ${slideOutline.headline}
|
||||||
|
keyPoints: ${JSON.stringify(slideOutline.keyPoints)}
|
||||||
|
role: ${slideOutline.narrativeRole || 'evidence'}
|
||||||
|
${formulaHint}
|
||||||
|
|
||||||
|
Contexte note (extrait):\n${notesText.slice(0, 6000)}
|
||||||
|
|
||||||
|
Expand cette slide en JSON ExpandedSlide COMPLET (corps non vide).`
|
||||||
|
: `Language: ${contentLang}.
|
||||||
|
${intentHints ? `Intent: ${intentHints}\n` : ''}Slide ${slideOutline.position}/${outline.slides.length}
|
||||||
|
type: ${slideOutline.type}
|
||||||
|
headline: ${slideOutline.headline}
|
||||||
|
keyPoints: ${JSON.stringify(slideOutline.keyPoints)}
|
||||||
|
${formulaHint}
|
||||||
|
|
||||||
|
Note excerpt:\n${notesText.slice(0, 6000)}
|
||||||
|
|
||||||
|
Expand into full ExpandedSlide JSON (non-empty body).`,
|
||||||
|
})
|
||||||
|
// Force type/title from outline if model drifts
|
||||||
|
one.type = slideOutline.type
|
||||||
|
if (!one.title) one.title = slideOutline.headline
|
||||||
|
// Inject formulas if equation empty
|
||||||
|
if (one.type === 'equation' && (!one.equations || one.equations.length === 0)) {
|
||||||
|
const forms = slideOutline.formulas?.length ? slideOutline.formulas : assets.formulas
|
||||||
|
one.equations = forms.slice(0, 4).map((latex, i) => ({
|
||||||
|
latex,
|
||||||
|
label: slideOutline.keyPoints[i] || `Eq. ${i + 1}`,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
expanded.push(one)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[SlideDeck] expand failed for slide, using deterministic fallback', e)
|
||||||
|
expanded.push(fallbackExpandFromOutline(slideOutline, assets))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage 3: Normalize + STEM inject + substance gate ──
|
||||||
|
let normalized = normalizeSlideDeck({
|
||||||
|
title: outline.title,
|
||||||
|
theme,
|
||||||
|
slides: expanded as unknown[],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Always inject formulas if missing (deterministic — never ship STEM without equations)
|
||||||
|
if (assets.formulas.length > 0) {
|
||||||
|
normalized = injectEquationSlidesFromFormulas(normalized, assets.formulas, { lang })
|
||||||
|
}
|
||||||
|
|
||||||
|
const stemOpts = {
|
||||||
|
requireFormulas: assets.formulas.length > 0 ? assets.formulas : undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
let gate = assertDeckHasSubstance(normalized, stemOpts)
|
||||||
|
let mode = 'outline+per-slide-expand'
|
||||||
|
|
||||||
|
if (!gate.ok) {
|
||||||
|
// Deterministic fill for empty body slides using outline + assets
|
||||||
|
const slides = normalized.slides.map((s, i) => {
|
||||||
|
if (s.type === 'title') return s
|
||||||
|
if (!gate.ok && gate.emptyIndexes.includes(i)) {
|
||||||
|
const o = outline.slides[Math.min(i, outline.slides.length - 1)]!
|
||||||
|
return fallbackExpandFromOutline(o, assets) as unknown as Record<string, unknown>
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
})
|
||||||
|
normalized = normalizeSlideDeck({
|
||||||
|
title: outline.title,
|
||||||
|
theme,
|
||||||
|
slides,
|
||||||
|
})
|
||||||
|
if (assets.formulas.length > 0) {
|
||||||
|
normalized = injectEquationSlidesFromFormulas(normalized, assets.formulas, { lang })
|
||||||
|
}
|
||||||
|
gate = assertDeckHasSubstance(normalized, stemOpts)
|
||||||
|
mode = 'outline+expand+deterministic-fill'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gate.ok) {
|
||||||
|
const msg =
|
||||||
|
lang === 'fr'
|
||||||
|
? `Refus de livrer un deck incomplet: ${gate.reason}`
|
||||||
|
: `Refusing incomplete deck: ${gate.reason}`
|
||||||
|
if (params.actionId) {
|
||||||
|
await prisma.agentAction
|
||||||
|
.update({ where: { id: params.actionId }, data: { status: 'failure', log: msg } })
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
return { success: false, error: msg, mode }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalized.theme) normalized.theme = theme
|
||||||
|
|
||||||
|
const canvas = await persist(params.userId, normalized, params.actionId, mode)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
canvasId: canvas.id,
|
||||||
|
canvasName: canvas.name,
|
||||||
|
slideCount: normalized.slides.length,
|
||||||
|
mode,
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[SlideDeck] FATAL', e)
|
||||||
|
const message = e?.message || String(e)
|
||||||
|
if (params.actionId) {
|
||||||
|
await prisma.agentAction
|
||||||
|
.update({
|
||||||
|
where: { id: params.actionId },
|
||||||
|
data: {
|
||||||
|
status: 'failure',
|
||||||
|
log: lang === 'fr' ? `Échec slides: ${message}` : `Slide fail: ${message}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
return { success: false, error: message }
|
||||||
|
}
|
||||||
|
}
|
||||||
151
memento-note/lib/ai/services/slide-intent.ts
Normal file
151
memento-note/lib/ai/services/slide-intent.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Slide generation intent (Sprint A product layer).
|
||||||
|
* Passed from UI → API → agent.description → generateSlideDeck.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SlidePurpose =
|
||||||
|
| 'auto'
|
||||||
|
| 'course'
|
||||||
|
| 'board'
|
||||||
|
| 'project'
|
||||||
|
| 'strategy'
|
||||||
|
| 'pitch'
|
||||||
|
| 'summary'
|
||||||
|
|
||||||
|
export type SlideAudience = 'auto' | 'student' | 'board' | 'team' | 'client' | 'general'
|
||||||
|
|
||||||
|
export interface SlideIntent {
|
||||||
|
purpose?: SlidePurpose
|
||||||
|
audience?: SlideAudience
|
||||||
|
/** Target body slides including title+summary, clamped 3–8 */
|
||||||
|
slideCount?: number
|
||||||
|
/** Existing executive template id or auto */
|
||||||
|
template?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const PURPOSE_SET = new Set<string>([
|
||||||
|
'auto',
|
||||||
|
'course',
|
||||||
|
'board',
|
||||||
|
'project',
|
||||||
|
'strategy',
|
||||||
|
'pitch',
|
||||||
|
'summary',
|
||||||
|
])
|
||||||
|
|
||||||
|
const AUDIENCE_SET = new Set<string>([
|
||||||
|
'auto',
|
||||||
|
'student',
|
||||||
|
'board',
|
||||||
|
'team',
|
||||||
|
'client',
|
||||||
|
'general',
|
||||||
|
])
|
||||||
|
|
||||||
|
export function clampSlideCount(n: unknown): number | undefined {
|
||||||
|
const v = typeof n === 'number' ? n : typeof n === 'string' ? parseInt(n, 10) : NaN
|
||||||
|
if (!Number.isFinite(v)) return undefined
|
||||||
|
return Math.min(8, Math.max(3, Math.round(v)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSlideIntent(raw: Partial<SlideIntent> | null | undefined): SlideIntent {
|
||||||
|
if (!raw || typeof raw !== 'object') return {}
|
||||||
|
const purpose = raw.purpose && PURPOSE_SET.has(raw.purpose) ? raw.purpose : 'auto'
|
||||||
|
const audience = raw.audience && AUDIENCE_SET.has(raw.audience) ? raw.audience : 'auto'
|
||||||
|
const slideCount = clampSlideCount(raw.slideCount)
|
||||||
|
const template =
|
||||||
|
typeof raw.template === 'string' && raw.template.trim() ? raw.template.trim() : 'auto'
|
||||||
|
return { purpose, audience, slideCount, template }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encode intent into agent.description (backward compatible with template:xxx). */
|
||||||
|
export function encodeSlideIntentDescription(intent: SlideIntent): string {
|
||||||
|
const n = normalizeSlideIntent(intent)
|
||||||
|
// Prefer structured JSON for the structured pipeline
|
||||||
|
return `slide-intent:${JSON.stringify(n)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode agent.description → intent */
|
||||||
|
export function decodeSlideIntentDescription(description?: string | null): SlideIntent {
|
||||||
|
if (!description) return {}
|
||||||
|
if (description.startsWith('slide-intent:')) {
|
||||||
|
try {
|
||||||
|
return normalizeSlideIntent(JSON.parse(description.slice('slide-intent:'.length)))
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (description.startsWith('template:')) {
|
||||||
|
const template = description.replace('template:', '').trim() || 'auto'
|
||||||
|
// Map legacy templates to purpose
|
||||||
|
const purposeMap: Record<string, SlidePurpose> = {
|
||||||
|
'board-update': 'board',
|
||||||
|
'project-status': 'project',
|
||||||
|
'strategy-review': 'strategy',
|
||||||
|
'quarterly-results': 'board',
|
||||||
|
}
|
||||||
|
return normalizeSlideIntent({
|
||||||
|
template,
|
||||||
|
purpose: purposeMap[template] || 'auto',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human guidance injected into outline/expand prompts */
|
||||||
|
export function intentPromptHints(intent: SlideIntent, lang: 'fr' | 'en'): string {
|
||||||
|
const n = normalizeSlideIntent(intent)
|
||||||
|
const lines: string[] = []
|
||||||
|
|
||||||
|
if (n.purpose && n.purpose !== 'auto') {
|
||||||
|
const mapFr: Record<SlidePurpose, string> = {
|
||||||
|
auto: '',
|
||||||
|
course: 'Type: COURS / leçon — arc pédagogique (définitions → formules → exemples → résumé). Priorité aux slides equation si maths.',
|
||||||
|
board: 'Type: COMITÉ / board — KPIs, décisions, risques, next steps.',
|
||||||
|
project: 'Type: SUIVI PROJET — avancement, blockers, livrables, roadmap.',
|
||||||
|
strategy: 'Type: STRATÉGIE — options, trade-offs, recommandations.',
|
||||||
|
pitch: 'Type: PITCH — problème, solution, preuve, call to action.',
|
||||||
|
summary: 'Type: SYNTHÈSE — peu de slides, dense, takeaways actionnables.',
|
||||||
|
}
|
||||||
|
const mapEn: Record<SlidePurpose, string> = {
|
||||||
|
auto: '',
|
||||||
|
course: 'Type: LESSON — pedagogical arc (defs → formulas → examples → summary). Prefer equation slides for math.',
|
||||||
|
board: 'Type: BOARD — KPIs, decisions, risks, next steps.',
|
||||||
|
project: 'Type: PROJECT STATUS — progress, blockers, deliverables, roadmap.',
|
||||||
|
strategy: 'Type: STRATEGY — options, trade-offs, recommendations.',
|
||||||
|
pitch: 'Type: PITCH — problem, solution, proof, CTA.',
|
||||||
|
summary: 'Type: SUMMARY — few dense slides, actionable takeaways.',
|
||||||
|
}
|
||||||
|
lines.push(lang === 'fr' ? mapFr[n.purpose] : mapEn[n.purpose])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (n.audience && n.audience !== 'auto') {
|
||||||
|
const mapFr: Record<SlideAudience, string> = {
|
||||||
|
auto: '',
|
||||||
|
student: 'Audience: étudiants — clarté, définitions, exemples, formules explicites.',
|
||||||
|
board: 'Audience: comité de direction — concis, chiffres, décisions.',
|
||||||
|
team: 'Audience: équipe — opérationnel, actions, responsabilités.',
|
||||||
|
client: 'Audience: client — bénéfices, preuves, sans jargon interne.',
|
||||||
|
general: 'Audience: grand public — langage simple.',
|
||||||
|
}
|
||||||
|
const mapEn: Record<SlideAudience, string> = {
|
||||||
|
auto: '',
|
||||||
|
student: 'Audience: students — clarity, definitions, examples, explicit formulas.',
|
||||||
|
board: 'Audience: board — concise, numbers, decisions.',
|
||||||
|
team: 'Audience: team — operational, actions, ownership.',
|
||||||
|
client: 'Audience: client — benefits, proof, no internal jargon.',
|
||||||
|
general: 'Audience: general — plain language.',
|
||||||
|
}
|
||||||
|
lines.push(lang === 'fr' ? mapFr[n.audience] : mapEn[n.audience])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (n.slideCount) {
|
||||||
|
lines.push(
|
||||||
|
lang === 'fr'
|
||||||
|
? `Nombre de slides imposé: EXACTEMENT ${n.slideCount} (title + corps + summary inclus).`
|
||||||
|
: `Slide count required: EXACTLY ${n.slideCount} (including title + body + summary).`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.filter(Boolean).join('\n')
|
||||||
|
}
|
||||||
103
memento-note/lib/ai/services/slide-source-assets.ts
Normal file
103
memento-note/lib/ai/services/slide-source-assets.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Server-side extraction of usable assets from a note BEFORE any slide LLM call.
|
||||||
|
* Inspired by PPTAgent (retrieve relevant content per slide) and DeckForge (data_needs).
|
||||||
|
* Critical for STEM notes: formulas must be harvested, not hoped for from the model.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SourceAssets {
|
||||||
|
formulas: string[]
|
||||||
|
numbers: Array<{ label: string; value: number; raw: string }>
|
||||||
|
keySentences: string[]
|
||||||
|
hasMath: boolean
|
||||||
|
hasNumbers: boolean
|
||||||
|
wordCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract LaTeX / equation-like fragments from note plain text or HTML. */
|
||||||
|
export function extractFormulas(raw: string): string[] {
|
||||||
|
if (!raw) return []
|
||||||
|
const found: string[] = []
|
||||||
|
const push = (s: string) => {
|
||||||
|
const t = s.replace(/\s+/g, ' ').trim()
|
||||||
|
if (t.length >= 2 && t.length <= 280 && !found.includes(t)) found.push(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// $$ ... $$
|
||||||
|
for (const m of raw.matchAll(/\$\$([\s\S]+?)\$\$/g)) push(m[1] || '')
|
||||||
|
// \[ ... \]
|
||||||
|
for (const m of raw.matchAll(/\\\[([\s\S]+?)\\\]/g)) push(m[1] || '')
|
||||||
|
// \( ... \)
|
||||||
|
for (const m of raw.matchAll(/\\\(([\s\S]+?)\\\)/g)) push(m[1] || '')
|
||||||
|
// $ ... $ (single line)
|
||||||
|
for (const m of raw.matchAll(/(?<![\\$])\$([^$\n]{2,120})\$(?!\$)/g)) push(m[1] || '')
|
||||||
|
// Common latex commands in free text
|
||||||
|
for (const m of raw.matchAll(
|
||||||
|
/(?:^|[\s(])((?:\\frac\{[^}]+\}\{[^}]+\}|\\partial|\\nabla|\\sum|\\int|\\frac|\\cdot|\\times|\\left|\\right|y'|y''|d[xy]\/d[xy]|∂)[^\n]{0,100})/g,
|
||||||
|
)) {
|
||||||
|
push(m[1] || '')
|
||||||
|
}
|
||||||
|
// Equation-like: f(x) = ..., dy/dx = ...
|
||||||
|
for (const m of raw.matchAll(
|
||||||
|
/(?:^|\n)\s*([A-Za-zΑ-ω∂∇][A-Za-z0-9_'"′]*\s*(?:\([^)]*\))?\s*[=≈≡]\s*[^\n]{3,100})/g,
|
||||||
|
)) {
|
||||||
|
const line = (m[1] || '').trim()
|
||||||
|
if (/[=≈]/.test(line) && /[a-zA-Z0-9]/.test(line)) push(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return found.slice(0, 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractNumbers(raw: string): Array<{ label: string; value: number; raw: string }> {
|
||||||
|
if (!raw) return []
|
||||||
|
const out: Array<{ label: string; value: number; raw: string }> = []
|
||||||
|
const re =
|
||||||
|
/(?:^|[^\d])((?:≈|~)?\s*-?\d+(?:[.,]\d+)?)\s*(%|€|\$|k|m|bn|ms|s|°C)?\b/gi
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
while ((m = re.exec(raw)) !== null && out.length < 20) {
|
||||||
|
const numStr = (m[1] || '').replace(/[≈~\s]/g, '').replace(',', '.')
|
||||||
|
const value = parseFloat(numStr)
|
||||||
|
if (!Number.isFinite(value)) continue
|
||||||
|
const unit = m[2] || ''
|
||||||
|
// crude label: 40 chars before
|
||||||
|
const start = Math.max(0, m.index - 40)
|
||||||
|
const ctx = raw.slice(start, m.index).replace(/\s+/g, ' ').trim()
|
||||||
|
const label = ctx.split(/[.;:!?\n]/).pop()?.trim().slice(-32) || `n${out.length + 1}`
|
||||||
|
out.push({ label, value: unit === '%' ? value : value, raw: `${numStr}${unit}` })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractKeySentences(raw: string, max = 16): string[] {
|
||||||
|
if (!raw) return []
|
||||||
|
const plain = raw
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/\$\$[\s\S]+?\$\$/g, ' ')
|
||||||
|
.replace(/\\\[[\s\S]+?\\\]/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
const parts = plain
|
||||||
|
.split(/(?<=[.!?])\s+/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length >= 40 && s.length <= 220)
|
||||||
|
const out: string[] = []
|
||||||
|
for (const p of parts) {
|
||||||
|
if (out.length >= max) break
|
||||||
|
if (!out.some((x) => x.slice(0, 40) === p.slice(0, 40))) out.push(p)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractSourceAssets(raw: string): SourceAssets {
|
||||||
|
const formulas = extractFormulas(raw)
|
||||||
|
const numbers = extractNumbers(raw)
|
||||||
|
const keySentences = extractKeySentences(raw)
|
||||||
|
const wordCount = raw.replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length
|
||||||
|
return {
|
||||||
|
formulas,
|
||||||
|
numbers,
|
||||||
|
keySentences,
|
||||||
|
hasMath: formulas.length > 0 || /équat|equat|différen|differen|dériv|deriv|intégr|integr|EDO|ODE|PDE|latex/i.test(raw),
|
||||||
|
hasNumbers: numbers.length >= 2,
|
||||||
|
wordCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,17 +72,19 @@ function renderTitle(slide: SlideTitle, r: Recipe, idx: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderBullets(slide: SlideBullets, r: Recipe, idx: number): string {
|
function renderBullets(slide: SlideBullets, r: Recipe, idx: number): string {
|
||||||
const items = slide.items.map((item, i) => `
|
const list = (slide.items || []).filter(Boolean)
|
||||||
<div class="reveal" style="display:flex;align-items:flex-start;gap:16px;padding:8px 0;">
|
// Dense layout: 2-col grid when 4+ bullets (Hermes/OpenClaw: avoid sparse empty slides)
|
||||||
<span style="width:8px;height:8px;border-radius:50%;background:${r.accent1};margin-top:10px;flex-shrink:0;"></span>
|
const twoCol = list.length >= 4
|
||||||
<span style="font-size:1.1rem;line-height:1.65;color:${r.text};">${esc(item)}</span>
|
const items = list.map((item) => `
|
||||||
|
<div class="reveal" style="display:flex;align-items:flex-start;gap:14px;padding:12px 14px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:12px;">
|
||||||
|
<span style="width:8px;height:8px;border-radius:50%;background:${r.accent1};margin-top:8px;flex-shrink:0;"></span>
|
||||||
|
<span style="font-size:clamp(0.95rem,1.6vw,1.15rem);line-height:1.55;color:${r.text};">${esc(item)}</span>
|
||||||
</div>`).join('')
|
</div>`).join('')
|
||||||
return `<div class="slide" data-slide="${idx}">
|
return `<div class="slide" data-slide="${idx}">
|
||||||
${mesh(r)}
|
${mesh(r)}
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.6rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
|
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 20px;">${esc(slide.title)}</h2>
|
||||||
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:32px;"></div>
|
<div style="display:grid;grid-template-columns:${twoCol ? '1fr 1fr' : '1fr'};gap:12px;">${items}</div>
|
||||||
<div style="display:flex;flex-direction:column;gap:6px;">${items}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>`
|
</div>`
|
||||||
}
|
}
|
||||||
@@ -109,8 +111,11 @@ function renderChart(slide: SlideChart, r: Recipe, idx: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderStats(slide: SlideStats, r: Recipe, idx: number): string {
|
function renderStats(slide: SlideStats, r: Recipe, idx: number): string {
|
||||||
const cols = Math.min(slide.stats.length, 4)
|
const stats = (slide.stats || []).filter((s) => s && (s.value || s.label))
|
||||||
const items = slide.stats.map(s => `
|
// Never force empty columns: 1 stat = full width, 2 = half, 3–4 = equal
|
||||||
|
const n = Math.max(1, stats.length)
|
||||||
|
const cols = n === 1 ? 1 : n === 2 ? 2 : n === 3 ? 3 : 4
|
||||||
|
const items = stats.map(s => `
|
||||||
<div class="reveal" style="text-align:center;padding:28px 16px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:16px;">
|
<div class="reveal" style="text-align:center;padding:28px 16px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:16px;">
|
||||||
<div style="font-size:clamp(2.2rem,4vw,3.5rem);font-weight:900;line-height:1;background:linear-gradient(135deg,${r.accent1},${r.accent2});-webkit-background-clip:text;-webkit-text-fill-color:transparent;" data-count="${extractNum(s.value)}" data-suffix="${extractSuffix(s.value)}">${esc(s.value)}</div>
|
<div style="font-size:clamp(2.2rem,4vw,3.5rem);font-weight:900;line-height:1;background:linear-gradient(135deg,${r.accent1},${r.accent2});-webkit-background-clip:text;-webkit-text-fill-color:transparent;" data-count="${extractNum(s.value)}" data-suffix="${extractSuffix(s.value)}">${esc(s.value)}</div>
|
||||||
<div style="font-size:0.8rem;color:${r.textMuted};margin-top:10px;letter-spacing:0.12em;text-transform:uppercase;font-weight:600;">${esc(s.label)}</div>
|
<div style="font-size:0.8rem;color:${r.textMuted};margin-top:10px;letter-spacing:0.12em;text-transform:uppercase;font-weight:600;">${esc(s.label)}</div>
|
||||||
@@ -118,9 +123,8 @@ function renderStats(slide: SlideStats, r: Recipe, idx: number): string {
|
|||||||
return `<div class="slide" data-slide="${idx}">
|
return `<div class="slide" data-slide="${idx}">
|
||||||
${mesh(r)}
|
${mesh(r)}
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.6rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
|
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 24px;">${esc(slide.title)}</h2>
|
||||||
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:32px;"></div>
|
<div style="display:grid;grid-template-columns:repeat(${cols},minmax(0,1fr));gap:16px;">${items}</div>
|
||||||
<div style="display:grid;grid-template-columns:repeat(${cols},1fr);gap:20px;">${items}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>`
|
</div>`
|
||||||
}
|
}
|
||||||
@@ -144,20 +148,22 @@ function renderTable(slide: SlideTable, r: Recipe, idx: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderCards(slide: SlideCards, r: Recipe, idx: number): string {
|
function renderCards(slide: SlideCards, r: Recipe, idx: number): string {
|
||||||
const cols = slide.cards.length <= 2 ? 2 : slide.cards.length === 4 ? 2 : 3
|
const cards = (slide.cards || []).filter((c) => c && (c.title || c.description))
|
||||||
const items = slide.cards.map((c, i) => `
|
// 1 card → full width (no empty second column). 2 → 2 cols. 3 → 3. 4+ → 2x2
|
||||||
<div class="reveal" style="padding:24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:14px;position:relative;overflow:hidden;">
|
const n = Math.max(1, cards.length)
|
||||||
|
const cols = n === 1 ? 1 : n === 2 ? 2 : n === 3 ? 3 : 2
|
||||||
|
const items = cards.map((c, i) => `
|
||||||
|
<div class="reveal" style="padding:24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:14px;position:relative;overflow:hidden;min-height:120px;">
|
||||||
<span style="position:absolute;top:10px;right:14px;font-size:1.8rem;font-weight:900;color:${r.accent1};opacity:0.12;">${String(i + 1).padStart(2, '0')}</span>
|
<span style="position:absolute;top:10px;right:14px;font-size:1.8rem;font-weight:900;color:${r.accent1};opacity:0.12;">${String(i + 1).padStart(2, '0')}</span>
|
||||||
<div style="width:24px;height:3px;background:${r.accent1};border-radius:2px;margin-bottom:12px;"></div>
|
<div style="width:24px;height:3px;background:${r.accent1};border-radius:2px;margin-bottom:12px;"></div>
|
||||||
<div style="font-size:1rem;font-weight:700;color:${r.text};margin-bottom:8px;">${esc(c.title)}</div>
|
<div style="font-size:1.05rem;font-weight:700;color:${r.text};margin-bottom:8px;">${esc(c.title)}</div>
|
||||||
<div style="font-size:0.88rem;color:${r.textSecondary};line-height:1.6;">${esc(c.description)}</div>
|
<div style="font-size:0.9rem;color:${r.textSecondary};line-height:1.6;">${esc(c.description)}</div>
|
||||||
</div>`).join('')
|
</div>`).join('')
|
||||||
return `<div class="slide" data-slide="${idx}">
|
return `<div class="slide" data-slide="${idx}">
|
||||||
${mesh(r)}
|
${mesh(r)}
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.6rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
|
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 24px;">${esc(slide.title)}</h2>
|
||||||
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:32px;"></div>
|
<div style="display:grid;grid-template-columns:repeat(${cols},minmax(0,1fr));gap:16px;">${items}</div>
|
||||||
<div style="display:grid;grid-template-columns:repeat(${cols},1fr);gap:16px;">${items}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>`
|
</div>`
|
||||||
}
|
}
|
||||||
@@ -214,18 +220,22 @@ function renderComparison(slide: SlideComparison, r: Recipe, idx: number): strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderEquation(slide: SlideEquation, r: Recipe, idx: number): string {
|
function renderEquation(slide: SlideEquation, r: Recipe, idx: number): string {
|
||||||
const eqs = slide.equations.map(eq => `
|
const eqs = (slide.equations || []).map((eq) => {
|
||||||
<div class="reveal" style="text-align:center;padding:24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:12px;margin-bottom:12px;">
|
// data-latex for KaTeX client render; also show fallback monospaced text
|
||||||
<div style="font-size:clamp(1.4rem,3vw,2.2rem);font-family:'KaTeX_Main',serif;color:${r.text};letter-spacing:0.02em;">${esc(eq.latex)}</div>
|
const latex = eq.latex || ''
|
||||||
${eq.label ? `<div style="font-size:0.8rem;color:${r.textMuted};margin-top:8px;">${esc(eq.label)}</div>` : ''}
|
return `
|
||||||
</div>`).join('')
|
<div class="reveal" style="text-align:center;padding:22px 24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:14px;margin-bottom:14px;">
|
||||||
|
<div class="eq-latex" data-latex="${esc(latex)}" style="font-size:clamp(1.25rem,2.6vw,1.85rem);color:${r.text};overflow-x:auto;padding:8px 0;"></div>
|
||||||
|
<noscript><code style="font-size:1.1rem;">${esc(latex)}</code></noscript>
|
||||||
|
${eq.label ? `<div style="font-size:0.85rem;color:${r.textMuted};margin-top:10px;font-weight:600;">${esc(eq.label)}</div>` : ''}
|
||||||
|
</div>`
|
||||||
|
}).join('')
|
||||||
return `<div class="slide" data-slide="${idx}">
|
return `<div class="slide" data-slide="${idx}">
|
||||||
${mesh(r)}
|
${mesh(r)}
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
|
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 20px;">${esc(slide.title)}</h2>
|
||||||
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:28px;"></div>
|
${eqs || `<div class="reveal" style="color:${r.textMuted};">Aucune équation</div>`}
|
||||||
${eqs}
|
${slide.explanation ? `<p class="reveal" style="font-size:1rem;color:${r.textSecondary};line-height:1.7;margin-top:18px;max-width:780px;">${esc(slide.explanation)}</p>` : ''}
|
||||||
${slide.explanation ? `<p class="reveal" style="font-size:0.95rem;color:${r.textSecondary};line-height:1.7;margin-top:16px;text-align:center;max-width:700px;margin-inline:auto;">${esc(slide.explanation)}</p>` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
</div>`
|
</div>`
|
||||||
}
|
}
|
||||||
@@ -429,14 +439,19 @@ export function buildPresentationHTML(input: PresentationInput): string {
|
|||||||
<title>${esc(input.title)}</title>
|
<title>${esc(input.title)}</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="${r.fontUrl}" rel="stylesheet">
|
<link href="${r.fontUrl}" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
:root{--color-bg:${r.bg};--color-text:${r.text};--color-text-secondary:${r.textSecondary};--color-text-muted:${r.textMuted};--color-accent-1:${r.accent1};--color-accent-2:${r.accent2};--color-glass-bg:${r.glassBg};--color-glass-border:${r.glassBorder};--svg-grid-line:${r.svgGrid};--font-display:${r.fontDisplay},serif;--font-body:${r.fontBody},system-ui,sans-serif;}
|
:root{--color-bg:${r.bg};--color-text:${r.text};--color-text-secondary:${r.textSecondary};--color-text-muted:${r.textMuted};--color-accent-1:${r.accent1};--color-accent-2:${r.accent2};--color-glass-bg:${r.glassBg};--color-glass-border:${r.glassBorder};--svg-grid-line:${r.svgGrid};--font-display:${r.fontDisplay},serif;--font-body:${r.fontBody},system-ui,sans-serif;}
|
||||||
*,*::before,*::after{box-sizing:border-box;}
|
*,*::before,*::after{box-sizing:border-box;}
|
||||||
html,body{margin:0;padding:0;overflow:hidden;background:var(--color-bg);color:var(--color-text);font-family:var(--font-body);-webkit-font-smoothing:antialiased;}
|
html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:var(--color-bg);color:var(--color-text);font-family:var(--font-body);-webkit-font-smoothing:antialiased;}
|
||||||
.deck{width:100vw;height:100vh;position:relative;}
|
/* Fill iframe/container — use % not 100vw (vw breaks lab preview letterboxing) */
|
||||||
.slide{position:absolute;top:0;left:0;width:100%;height:100%;background:var(--color-bg);opacity:0;transform:scale(0.96);transition:opacity 0.6s ease,transform 0.6s ease;pointer-events:none;overflow:hidden;display:flex;align-items:center;justify-content:center;}
|
.deck{width:100%;height:100%;position:relative;overflow:hidden;}
|
||||||
|
.slide{position:absolute;top:0;left:0;width:100%;height:100%;background:var(--color-bg);opacity:0;transform:scale(0.98);transition:opacity 0.45s ease,transform 0.45s ease;pointer-events:none;overflow:hidden;display:flex;align-items:center;justify-content:center;}
|
||||||
.slide.active{opacity:1;transform:scale(1);pointer-events:all;}
|
.slide.active{opacity:1;transform:scale(1);pointer-events:all;}
|
||||||
.slide>.content{position:relative;z-index:2;width:100%;max-width:1100px;padding:clamp(1.5rem,4vw,4rem);}
|
.slide>.content{position:relative;z-index:2;width:100%;max-width:min(1100px,94%);height:100%;max-height:100%;padding:clamp(1.25rem,3.5vmin,3.5rem);display:flex;flex-direction:column;justify-content:center;box-sizing:border-box;}
|
||||||
|
.eq-latex{max-width:100%;}
|
||||||
|
.eq-latex .katex-display{margin:0.4em 0;overflow-x:auto;overflow-y:hidden;}
|
||||||
.gradient-mesh{position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:0;}
|
.gradient-mesh{position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:0;}
|
||||||
.blob{position:absolute;border-radius:50%;filter:blur(80px);animation:float-slow var(--dur,18s) ease-in-out infinite;}
|
.blob{position:absolute;border-radius:50%;filter:blur(80px);animation:float-slow var(--dur,18s) ease-in-out infinite;}
|
||||||
@keyframes float-slow{0%{transform:translate(0,0) scale(1);}50%{transform:translate(-40px,40px) scale(0.92);}100%{transform:translate(0,0) scale(1);}}
|
@keyframes float-slow{0%{transform:translate(0,0) scale(1);}50%{transform:translate(-40px,40px) scale(0.92);}100%{transform:translate(0,0) scale(1);}}
|
||||||
@@ -464,13 +479,14 @@ ${slidesHtml}
|
|||||||
<script>
|
<script>
|
||||||
var current=1,slides=document.querySelectorAll('.slide'),total=slides.length;
|
var current=1,slides=document.querySelectorAll('.slide'),total=slides.length;
|
||||||
(function(){var d=document.getElementById('nav-dots');for(var i=1;i<=total;i++){var b=document.createElement('button');b.className='nav-dot'+(i===1?' active':'');(function(n){b.addEventListener('click',function(){goToSlide(n)});})(i);d.appendChild(b);}updateNav();})();
|
(function(){var d=document.getElementById('nav-dots');for(var i=1;i<=total;i++){var b=document.createElement('button');b.className='nav-dot'+(i===1?' active':'');(function(n){b.addEventListener('click',function(){goToSlide(n)});})(i);d.appendChild(b);}updateNav();})();
|
||||||
function goToSlide(n){if(n<1||n>total||n===current)return;var p=document.querySelector('.slide.active'),nx=document.querySelector('.slide[data-slide="'+n+'"]');if(!nx)return;if(p)p.classList.remove('active');nx.classList.add('active');current=n;updateNav();animateSlide(nx);}
|
function renderKatex(){if(typeof katex==='undefined')return;document.querySelectorAll('.eq-latex[data-latex]').forEach(function(el){var tex=el.getAttribute('data-latex')||'';try{katex.render(tex,el,{throwOnError:false,displayMode:true});}catch(e){el.textContent=tex;}});}
|
||||||
|
function goToSlide(n){if(n<1||n>total||n===current)return;var p=document.querySelector('.slide.active'),nx=document.querySelector('.slide[data-slide="'+n+'"]');if(!nx)return;if(p)p.classList.remove('active');nx.classList.add('active');current=n;updateNav();animateSlide(nx);setTimeout(renderKatex,40);}
|
||||||
function changeSlide(dir){var n=current+dir;if(n<1)n=total;if(n>total)n=1;goToSlide(n);}
|
function changeSlide(dir){var n=current+dir;if(n<1)n=total;if(n>total)n=1;goToSlide(n);}
|
||||||
function updateNav(){document.querySelectorAll('.nav-dot').forEach(function(d,i){d.classList.toggle('active',(i+1)===current);});var c=document.getElementById('slide-counter');if(c)c.textContent=current+' / '+total;try{parent.postMessage({type:'slideChange',current:current,total:total},'*');}catch(e){}}
|
function updateNav(){document.querySelectorAll('.nav-dot').forEach(function(d,i){d.classList.toggle('active',(i+1)===current);});var c=document.getElementById('slide-counter');if(c)c.textContent=current+' / '+total;try{parent.postMessage({type:'slideChange',current:current,total:total},'*');}catch(e){}}
|
||||||
document.addEventListener('keydown',function(e){if(e.key==='ArrowRight'||e.key===' ')changeSlide(1);if(e.key==='ArrowLeft')changeSlide(-1);});
|
document.addEventListener('keydown',function(e){if(e.key==='ArrowRight'||e.key===' ')changeSlide(1);if(e.key==='ArrowLeft')changeSlide(-1);});
|
||||||
var tx=0;document.addEventListener('touchstart',function(e){tx=e.touches[0].clientX;},{passive:true});document.addEventListener('touchend',function(e){var dx=tx-e.changedTouches[0].clientX;if(Math.abs(dx)>50)changeSlide(dx>0?1:-1);},{passive:true});
|
var tx=0;document.addEventListener('touchstart',function(e){tx=e.touches[0].clientX;},{passive:true});document.addEventListener('touchend',function(e){var dx=tx-e.changedTouches[0].clientX;if(Math.abs(dx)>50)changeSlide(dx>0?1:-1);},{passive:true});
|
||||||
function animateSlide(s){s.querySelectorAll('.reveal').forEach(function(el,i){el.style.transition='none';el.style.opacity='0';el.style.transform='translateY(18px)';el.offsetHeight;el.style.transition='opacity 0.35s ease '+(i*0.07)+'s, transform 0.35s ease '+(i*0.07)+'s';el.style.opacity='1';el.style.transform='translateY(0)';});s.querySelectorAll('.bar[data-height]').forEach(function(b){b.style.height='0%';setTimeout(function(){b.style.height=b.dataset.height+'%';},100);});s.querySelectorAll('.bar-fill[data-width]').forEach(function(b){b.style.width='0%';setTimeout(function(){b.style.width=b.dataset.width+'%';},100);});s.querySelectorAll('.line-path').forEach(function(p){var l=p.getTotalLength?p.getTotalLength():2000;p.style.strokeDasharray=l;p.style.strokeDashoffset=l;setTimeout(function(){p.style.strokeDashoffset='0';},100);});s.querySelectorAll('[data-count]').forEach(function(el){var t=parseFloat(el.dataset.count),sf=el.dataset.suffix||'',st=30,inc=t/st,i=0,v=0;var iv=setInterval(function(){v+=inc;i++;el.textContent=(i>=st?t:Math.round(v))+sf;if(i>=st)clearInterval(iv);},30);});}
|
function animateSlide(s){s.querySelectorAll('.reveal').forEach(function(el,i){el.style.transition='none';el.style.opacity='0';el.style.transform='translateY(18px)';el.offsetHeight;el.style.transition='opacity 0.35s ease '+(i*0.07)+'s, transform 0.35s ease '+(i*0.07)+'s';el.style.opacity='1';el.style.transform='translateY(0)';});s.querySelectorAll('.bar[data-height]').forEach(function(b){b.style.height='0%';setTimeout(function(){b.style.height=b.dataset.height+'%';},100);});s.querySelectorAll('.bar-fill[data-width]').forEach(function(b){b.style.width='0%';setTimeout(function(){b.style.width=b.dataset.width+'%';},100);});s.querySelectorAll('.line-path').forEach(function(p){var l=p.getTotalLength?p.getTotalLength():2000;p.style.strokeDasharray=l;p.style.strokeDashoffset=l;setTimeout(function(){p.style.strokeDashoffset='0';},100);});s.querySelectorAll('[data-count]').forEach(function(el){var t=parseFloat(el.dataset.count),sf=el.dataset.suffix||'',st=30,inc=t/st,i=0,v=0;var iv=setInterval(function(){v+=inc;i++;el.textContent=(i>=st?t:Math.round(v))+sf;if(i>=st)clearInterval(iv);},30);});}
|
||||||
var first=document.querySelector('.slide[data-slide="1"]');if(first){first.classList.add('active');setTimeout(function(){animateSlide(first);},300);}
|
var first=document.querySelector('.slide[data-slide="1"]');if(first){first.classList.add('active');setTimeout(function(){animateSlide(first);renderKatex();},300);}else{setTimeout(renderKatex,400);}
|
||||||
// Particles
|
// Particles
|
||||||
document.querySelectorAll('canvas[id^="particles-"]').forEach(function(c){c.width=window.innerWidth;c.height=window.innerHeight;var ctx=c.getContext('2d'),pts=[];for(var i=0;i<50;i++)pts.push({x:Math.random()*c.width,y:Math.random()*c.height,vx:(Math.random()-0.5)*0.3,vy:(Math.random()-0.5)*0.3,r:Math.random()*2+0.5});function draw(){ctx.clearRect(0,0,c.width,c.height);pts.forEach(function(p){p.x+=p.vx;p.y+=p.vy;if(p.x<0)p.x=c.width;if(p.x>c.width)p.x=0;if(p.y<0)p.y=c.height;if(p.y>c.height)p.y=0;ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,Math.PI*2);ctx.fillStyle='${r.accent1}80';ctx.fill();});requestAnimationFrame(draw);}draw();});
|
document.querySelectorAll('canvas[id^="particles-"]').forEach(function(c){c.width=window.innerWidth;c.height=window.innerHeight;var ctx=c.getContext('2d'),pts=[];for(var i=0;i<50;i++)pts.push({x:Math.random()*c.width,y:Math.random()*c.height,vx:(Math.random()-0.5)*0.3,vy:(Math.random()-0.5)*0.3,r:Math.random()*2+0.5});function draw(){ctx.clearRect(0,0,c.width,c.height);pts.forEach(function(p){p.x+=p.vx;p.y+=p.vy;if(p.x<0)p.x=c.width;if(p.x>c.width)p.x=0;if(p.y<0)p.y=c.height;if(p.y>c.height)p.y=0;ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,Math.PI*2);ctx.fillStyle='${r.accent1}80';ctx.fill();});requestAnimationFrame(draw);}draw();});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,100 +5,193 @@ import { z } from 'zod'
|
|||||||
import { toolRegistry } from './registry'
|
import { toolRegistry } from './registry'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { buildPresentationHTML } from './slides-html-builder'
|
import { buildPresentationHTML } from './slides-html-builder'
|
||||||
|
import { normalizeSlideDeck, SLIDE_HARD_CAP } from '@/lib/ai/services/slide-content-quality'
|
||||||
|
|
||||||
|
const notesField = z.string().optional().describe('Optional speaker notes (1–2 sentences for the presenter, not shown on slide)')
|
||||||
|
|
||||||
const slideSchema = z.discriminatedUnion('type', [
|
const slideSchema = z.discriminatedUnion('type', [
|
||||||
z.object({ type: z.literal('title'), title: z.string(), subtitle: z.string().optional() }),
|
z.object({
|
||||||
z.object({ type: z.literal('bullets'), title: z.string(), items: z.array(z.string()) }),
|
type: z.literal('title'),
|
||||||
z.object({ type: z.literal('chart'), title: z.string(), chartType: z.enum(['bar', 'horizontal-bar', 'line', 'donut', 'radar']), data: z.array(z.object({ label: z.string(), value: z.number() })), subtitle: z.string().optional() }),
|
title: z.string().describe('Action title / deck name'),
|
||||||
z.object({ type: z.literal('stats'), title: z.string(), stats: z.array(z.object({ value: z.string(), label: z.string() })) }),
|
subtitle: z.string().optional().describe('One-line context or audience promise'),
|
||||||
z.object({ type: z.literal('table'), title: z.string(), headers: z.array(z.string()), rows: z.array(z.array(z.string())) }),
|
notes: notesField,
|
||||||
z.object({ type: z.literal('cards'), title: z.string(), cards: z.array(z.object({ title: z.string(), description: z.string() })) }),
|
}),
|
||||||
z.object({ type: z.literal('timeline'), title: z.string(), events: z.array(z.object({ date: z.string(), title: z.string(), description: z.string().optional() })) }),
|
z.object({
|
||||||
z.object({ type: z.literal('quote'), quote: z.string(), author: z.string().optional(), context: z.string().optional() }),
|
type: z.literal('bullets'),
|
||||||
z.object({ type: z.literal('comparison'), title: z.string(), left: z.object({ title: z.string(), points: z.array(z.string()), score: z.string().optional() }), right: z.object({ title: z.string(), points: z.array(z.string()), score: z.string().optional() }) }),
|
title: z.string().describe('Action title = the insight, not a topic label'),
|
||||||
z.object({ type: z.literal('equation'), title: z.string(), equations: z.array(z.object({ latex: z.string(), label: z.string().optional() })), explanation: z.string().optional() }),
|
items: z.array(z.string()).max(5).describe('3–5 short claims (8–18 words), each distinct, from the note'),
|
||||||
z.object({ type: z.literal('image'), title: z.string(), url: z.string().optional(), caption: z.string().optional() }),
|
notes: notesField,
|
||||||
z.object({ type: z.literal('summary'), title: z.string(), items: z.array(z.string()) }),
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('chart'),
|
||||||
|
title: z.string().describe('Action title stating what the chart proves'),
|
||||||
|
chartType: z.enum(['bar', 'horizontal-bar', 'line', 'donut', 'radar']),
|
||||||
|
data: z.array(z.object({ label: z.string(), value: z.number() })).min(2).max(8),
|
||||||
|
subtitle: z.string().optional(),
|
||||||
|
notes: notesField,
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('stats'),
|
||||||
|
title: z.string(),
|
||||||
|
stats: z.array(z.object({ value: z.string(), label: z.string() })).min(2).max(4),
|
||||||
|
notes: notesField,
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('table'),
|
||||||
|
title: z.string(),
|
||||||
|
headers: z.array(z.string()).max(6),
|
||||||
|
rows: z.array(z.array(z.string())).max(8),
|
||||||
|
notes: notesField,
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('cards'),
|
||||||
|
title: z.string(),
|
||||||
|
cards: z
|
||||||
|
.array(z.object({ title: z.string(), description: z.string() }))
|
||||||
|
.min(2)
|
||||||
|
.max(6)
|
||||||
|
.describe('2–4 cards preferred; short titles + 1–2 sentence descriptions'),
|
||||||
|
notes: notesField,
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('timeline'),
|
||||||
|
title: z.string(),
|
||||||
|
events: z
|
||||||
|
.array(z.object({ date: z.string(), title: z.string(), description: z.string().optional() }))
|
||||||
|
.min(2)
|
||||||
|
.max(6),
|
||||||
|
notes: notesField,
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('quote'),
|
||||||
|
quote: z.string().describe('Exact or faithful quote from the note'),
|
||||||
|
author: z.string().optional(),
|
||||||
|
context: z.string().optional().describe('Why this quote matters'),
|
||||||
|
notes: notesField,
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('comparison'),
|
||||||
|
title: z.string().describe('Action title of the comparison insight'),
|
||||||
|
left: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
points: z.array(z.string()).max(4),
|
||||||
|
score: z.string().optional(),
|
||||||
|
}),
|
||||||
|
right: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
points: z.array(z.string()).max(4),
|
||||||
|
score: z.string().optional(),
|
||||||
|
}),
|
||||||
|
notes: notesField,
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('equation'),
|
||||||
|
title: z.string(),
|
||||||
|
equations: z.array(z.object({ latex: z.string(), label: z.string().optional() })).max(4),
|
||||||
|
explanation: z.string().optional(),
|
||||||
|
notes: notesField,
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('image'),
|
||||||
|
title: z.string(),
|
||||||
|
url: z.string().optional().describe('Only if a real image URL exists in the note'),
|
||||||
|
caption: z.string().optional(),
|
||||||
|
notes: notesField,
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('summary'),
|
||||||
|
title: z.string().describe('e.g. Next steps / Key takeaways'),
|
||||||
|
items: z.array(z.string()).max(5).describe('3–5 actionable takeaways'),
|
||||||
|
notes: notesField,
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
toolRegistry.register({
|
toolRegistry.register({
|
||||||
name: 'generate_slides',
|
name: 'generate_slides',
|
||||||
description: 'Renders a structured presentation from JSON data into a full animated HTML file and saves it.',
|
description: `Create an executive presentation from structured slide data (rendered to HTML by the server).
|
||||||
|
|
||||||
|
CONTENT RULES (quality > quantity):
|
||||||
|
- One idea per slide; titles are ACTION TITLES (insights), not topic labels like "Context" or "Analysis"
|
||||||
|
- Narrative: title → problem/context → insight → evidence → implications → summary
|
||||||
|
- Bullets: 3–5 max, short claims (≈8–18 words), no filler paragraphs
|
||||||
|
- chart/stats ONLY with real numbers from the source note — NEVER invent KPIs or radar scores
|
||||||
|
- First slide type "title", last type "summary"
|
||||||
|
- Hard limit: ${SLIDE_HARD_CAP} slides; fewer for short notes
|
||||||
|
- All facts from the note; optional "notes" field for speaker cues
|
||||||
|
|
||||||
|
Slide types: title | bullets | chart | stats | table | cards | timeline | quote | comparison | equation | image | summary`,
|
||||||
isInternal: true,
|
isInternal: true,
|
||||||
buildTool: (ctx) =>
|
buildTool: (ctx) =>
|
||||||
tool({
|
tool({
|
||||||
description: `Create a presentation from structured slide data. Each slide has a type and corresponding content.
|
description: `Create a high-quality executive deck. Prefer short action titles and sparse, high-signal content over dense filler.
|
||||||
|
|
||||||
Available slide types:
|
Types:
|
||||||
- "title": title, subtitle (opening slide with particles)
|
- title: title, subtitle?
|
||||||
- "bullets": title, items[] (bullet list with 4-6 items of 15+ words each)
|
- bullets: title (insight), items[3-5] short claims
|
||||||
- "chart": title, chartType (bar|horizontal-bar|line|donut|radar), data[{label,value}], subtitle
|
- chart: ONLY real numbers — chartType bar|horizontal-bar|line|donut|radar, data[{label,value}]
|
||||||
- "stats": title, stats[{value:"98%", label:"KPI name"}] (3-4 animated KPIs)
|
- stats: 2–4 real KPIs {value,label}
|
||||||
- "table": title, headers[], rows[][] (data table)
|
- table / cards / timeline / quote / comparison / equation / image / summary
|
||||||
- "cards": title, cards[{title, description}] (3-6 info cards)
|
|
||||||
- "timeline": title, events[{date, title, description}] (chronological events)
|
|
||||||
- "quote": quote, author, context (citation with analysis)
|
|
||||||
- "comparison": title, left{title, points[], score}, right{...} (A vs B)
|
|
||||||
- "equation": title, equations[{latex, label}], explanation (math formulas)
|
|
||||||
- "image": title, url, caption (image slide)
|
|
||||||
- "summary": title, items[] (conclusion with checkmarks)
|
|
||||||
|
|
||||||
RULES:
|
Rules: first=title, last=summary, max ${SLIDE_HARD_CAP} slides, no invented data, no fake charts.`,
|
||||||
- Count the plain-text words in the note (ignore markdown, URLs, metadata, headers)
|
|
||||||
- <50 words → 2-3 slides MAXIMUM (title + 1 content + summary)
|
|
||||||
- 50-150 words → 3-4 slides max
|
|
||||||
- 150-400 words → 5-6 slides max
|
|
||||||
- >400 words → 7-8 slides max — HARD LIMIT, NEVER exceed 8
|
|
||||||
- First slide MUST be type "title"
|
|
||||||
- Last slide MUST be type "summary"
|
|
||||||
- Include "chart" ONLY if real numeric data exists in the note — otherwise FORBIDDEN
|
|
||||||
- Use VARIED types — never 2 identical types in a row
|
|
||||||
- All text must come from the source note — never invent data`,
|
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
title: z.string().describe('Short presentation title (6 words max)'),
|
title: z.string().describe('Short deck title (max ~8 words)'),
|
||||||
theme: z.string().optional().describe('Visual recipe: architectural-saas, midnight-cathedral, aurora-borealis, venture-pitch, clinical-precision, coastal-morning, etc.'),
|
theme: z
|
||||||
slides: z.array(slideSchema).max(8).describe('Array of slide objects, 3-8 slides MAX'),
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Visual recipe: architectural-saas, midnight-cathedral, aurora-borealis, venture-pitch, clinical-precision, coastal-morning, etc.',
|
||||||
|
),
|
||||||
|
slides: z.array(slideSchema).max(SLIDE_HARD_CAP).describe(`Slide array, 2–${SLIDE_HARD_CAP} items`),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
execute: async ({ title, theme, slides }) => {
|
execute: async ({ title, theme, slides }) => {
|
||||||
try {
|
try {
|
||||||
// Hard cap: never more than 8 slides regardless of what the model outputs
|
const normalized = normalizeSlideDeck({ title, theme, slides: slides as unknown[] })
|
||||||
const cappedSlides = slides.slice(0, 8)
|
|
||||||
|
|
||||||
const html = buildPresentationHTML({ title, theme, slides: cappedSlides as any })
|
const html = buildPresentationHTML({
|
||||||
|
title: normalized.title,
|
||||||
|
theme: normalized.theme,
|
||||||
|
slides: normalized.slides as any,
|
||||||
|
})
|
||||||
|
|
||||||
const canvas = await prisma.canvas.create({
|
const canvas = await prisma.canvas.create({
|
||||||
data: {
|
data: {
|
||||||
name: title || 'Présentation',
|
name: normalized.title || 'Présentation',
|
||||||
data: JSON.stringify({
|
data: JSON.stringify({
|
||||||
type: 'slides',
|
type: 'slides',
|
||||||
title: title || 'Présentation',
|
title: normalized.title || 'Présentation',
|
||||||
html,
|
html,
|
||||||
slideCount: cappedSlides.length,
|
slideCount: normalized.slides.length,
|
||||||
theme: theme || 'architectural-saas',
|
theme: normalized.theme || 'architectural-saas',
|
||||||
spec: { title, theme, slides: cappedSlides },
|
spec: {
|
||||||
|
title: normalized.title,
|
||||||
|
theme: normalized.theme,
|
||||||
|
slides: normalized.slides,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
if (ctx.actionId) {
|
if (ctx.actionId) {
|
||||||
await prisma.agentAction.update({
|
await prisma.agentAction
|
||||||
|
.update({
|
||||||
where: { id: ctx.actionId },
|
where: { id: ctx.actionId },
|
||||||
data: {
|
data: {
|
||||||
status: 'success',
|
status: 'success',
|
||||||
result: canvas.id,
|
result: canvas.id,
|
||||||
log: `Slides generated: ${cappedSlides.length} slides, ${Math.round(html.length / 1024)}KB`,
|
log: `Slides generated: ${normalized.slides.length} slides, ${Math.round(html.length / 1024)}KB (quality-normalized)`,
|
||||||
},
|
},
|
||||||
}).catch(err => console.error('[Slides Tool] Failed to update action status:', err))
|
})
|
||||||
|
.catch((err) => console.error('[Slides Tool] Failed to update action status:', err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
canvasId: canvas.id,
|
canvasId: canvas.id,
|
||||||
canvasName: canvas.name,
|
canvasName: canvas.name,
|
||||||
slideCount: cappedSlides.length,
|
slideCount: normalized.slides.length,
|
||||||
message: `Presentation "${canvas.name}" created with ${cappedSlides.length} slides.`,
|
message: `Presentation "${canvas.name}" created with ${normalized.slides.length} slides.`,
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('[Slides Tool] FATAL:', e)
|
console.error('[Slides Tool] FATAL:', e)
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ export interface ToolUseOptions {
|
|||||||
systemPrompt?: string
|
systemPrompt?: string
|
||||||
messages?: any[]
|
messages?: any[]
|
||||||
prompt?: string
|
prompt?: string
|
||||||
|
/**
|
||||||
|
* Force tool usage when models (e.g. lite Gemini) prefer free text.
|
||||||
|
* - 'auto' | 'required' | 'none'
|
||||||
|
* - { type: 'tool', toolName: 'generate_slides' }
|
||||||
|
*/
|
||||||
|
toolChoice?: any
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolCallResult {
|
export interface ToolCallResult {
|
||||||
|
|||||||
@@ -43,41 +43,62 @@ export function normalizeThemeId(raw: string | null | undefined): ThemeId {
|
|||||||
return 'light'
|
return 'light'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cookie lu par le layout serveur pour éviter le flash clair au changement de route. */
|
||||||
|
export const THEME_COOKIE_NAME = 'memento-theme'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applique le thème sur `<html>` : `class="dark"` et/ou `data-theme`.
|
* Applique le thème sur `<html>` : `class="dark"` et/ou `data-theme`.
|
||||||
* - `light` → papier par défaut (`:root`), pas de `data-theme`
|
* - `light` → papier par défaut (`:root`), pas de `data-theme`
|
||||||
* - `dark` → sombre global (`.dark`)
|
* - `dark` → sombre global (`.dark`)
|
||||||
* - `auto` → `.dark` si prefers-color-scheme: dark
|
* - `auto` → `.dark` si prefers-color-scheme: dark
|
||||||
* - palettes nommées → `data-theme="<id>"` ; `midnight` force aussi `.dark` (variante sombre du thème)
|
* - palettes nommées → `data-theme="<id>"` ; `midnight` force aussi `.dark` (variante sombre du thème)
|
||||||
|
*
|
||||||
|
* Important : on n’enlève JAMAIS `dark` avant de le remettre (sinon flash clair
|
||||||
|
* d’une frame à chaque navigation / re-render de ThemeInitializer).
|
||||||
*/
|
*/
|
||||||
export function applyDocumentTheme(theme: string): void {
|
export function applyDocumentTheme(theme: string): void {
|
||||||
if (typeof document === 'undefined') return
|
if (typeof document === 'undefined') return
|
||||||
|
|
||||||
const t = normalizeThemeId(theme)
|
const t = normalizeThemeId(theme)
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
|
|
||||||
|
const wantsDark =
|
||||||
|
t === 'dark' ||
|
||||||
|
t === 'midnight' ||
|
||||||
|
(t === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
|
|
||||||
|
// Classe dark : bascule atomique (pas de remove puis add)
|
||||||
|
if (wantsDark) {
|
||||||
|
root.classList.add('dark')
|
||||||
|
} else {
|
||||||
root.classList.remove('dark')
|
root.classList.remove('dark')
|
||||||
|
}
|
||||||
|
|
||||||
|
// data-theme pour les palettes nommées uniquement
|
||||||
|
if (t === 'auto' || t === 'dark' || t === 'light') {
|
||||||
root.removeAttribute('data-theme')
|
root.removeAttribute('data-theme')
|
||||||
|
} else if ((NAMED_PALETTE_IDS as readonly string[]).includes(t)) {
|
||||||
if (t === 'auto') {
|
|
||||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
||||||
root.classList.add('dark')
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (t === 'dark') {
|
|
||||||
root.classList.add('dark')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (t === 'light') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((NAMED_PALETTE_IDS as readonly string[]).includes(t)) {
|
|
||||||
root.setAttribute('data-theme', t)
|
root.setAttribute('data-theme', t)
|
||||||
if (t === 'midnight') {
|
} else {
|
||||||
root.classList.add('dark')
|
root.removeAttribute('data-theme')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
root.dataset.appliedTheme = t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Persiste le thème (localStorage + cookie) pour que le serveur connaisse le dark. */
|
||||||
|
export function persistThemePreference(theme: string): void {
|
||||||
|
const t = normalizeThemeId(theme)
|
||||||
|
try {
|
||||||
|
localStorage.setItem('theme-preference', t)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// 1 an — lu par le layout racine via cookies()
|
||||||
|
document.cookie = `${THEME_COOKIE_NAME}=${encodeURIComponent(t)}; path=/; max-age=31536000; SameSite=Lax`
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
applyDocumentTheme(t)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type AuditAction =
|
|||||||
| 'SUBSCRIPTION_OVERRIDE'
|
| 'SUBSCRIPTION_OVERRIDE'
|
||||||
| 'BILLING_CONFIG_UPDATED'
|
| 'BILLING_CONFIG_UPDATED'
|
||||||
| 'PLAN_ENTITLEMENT_UPDATED'
|
| 'PLAN_ENTITLEMENT_UPDATED'
|
||||||
|
| 'QUOTA_RESET'
|
||||||
|
|
||||||
export interface AuditLogParams {
|
export interface AuditLogParams {
|
||||||
userId?: string | null
|
userId?: string | null
|
||||||
|
|||||||
158
memento-note/lib/billing/credit-packs.ts
Normal file
158
memento-note/lib/billing/credit-packs.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Packs de crédits IA (achat one-shot Stripe, mode payment).
|
||||||
|
* Les crédits achetés s’ajoutent à `purchasedBalance` (reportables entre mois).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getConfigValue } from '@/lib/config'
|
||||||
|
import { stripe } from '@/lib/stripe'
|
||||||
|
|
||||||
|
export type CreditPackId = 'S' | 'M' | 'L'
|
||||||
|
|
||||||
|
export type CreditPackDef = {
|
||||||
|
id: CreditPackId
|
||||||
|
credits: number
|
||||||
|
/** Prix d’affichage par défaut (si Stripe non configuré) */
|
||||||
|
defaultDisplay: string
|
||||||
|
defaultAmount: number
|
||||||
|
currency: string
|
||||||
|
priceConfigKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Catalogue fixe — crédits par pack (option M). */
|
||||||
|
export const CREDIT_PACKS: Record<CreditPackId, CreditPackDef> = {
|
||||||
|
S: {
|
||||||
|
id: 'S',
|
||||||
|
credits: 100,
|
||||||
|
defaultDisplay: '4,90 €',
|
||||||
|
defaultAmount: 4.9,
|
||||||
|
currency: 'EUR',
|
||||||
|
priceConfigKey: 'STRIPE_PRICE_CREDITS_S',
|
||||||
|
},
|
||||||
|
M: {
|
||||||
|
id: 'M',
|
||||||
|
credits: 500,
|
||||||
|
defaultDisplay: '19,90 €',
|
||||||
|
defaultAmount: 19.9,
|
||||||
|
currency: 'EUR',
|
||||||
|
priceConfigKey: 'STRIPE_PRICE_CREDITS_M',
|
||||||
|
},
|
||||||
|
L: {
|
||||||
|
id: 'L',
|
||||||
|
credits: 2000,
|
||||||
|
defaultDisplay: '49,90 €',
|
||||||
|
defaultAmount: 49.9,
|
||||||
|
currency: 'EUR',
|
||||||
|
priceConfigKey: 'STRIPE_PRICE_CREDITS_L',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CREDIT_PACK_IDS = Object.keys(CREDIT_PACKS) as CreditPackId[]
|
||||||
|
|
||||||
|
export function isCreditPackId(value: string): value is CreditPackId {
|
||||||
|
return value === 'S' || value === 'M' || value === 'L'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCreditPack(id: CreditPackId): CreditPackDef {
|
||||||
|
return CREDIT_PACKS[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolvePackPriceId(packId: CreditPackId): Promise<string> {
|
||||||
|
const pack = CREDIT_PACKS[packId]
|
||||||
|
const fromDb = await getConfigValue(pack.priceConfigKey, '')
|
||||||
|
const priceId = fromDb || process.env[pack.priceConfigKey] || ''
|
||||||
|
if (priceId) return priceId
|
||||||
|
|
||||||
|
const isMock =
|
||||||
|
!process.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder'
|
||||||
|
if (isMock && process.env.NODE_ENV !== 'test') {
|
||||||
|
return `price_mock_credits_${packId.toLowerCase()}`
|
||||||
|
}
|
||||||
|
throw new Error(`No Stripe price ID configured for credit pack ${packId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PackPublicPrice = {
|
||||||
|
id: CreditPackId
|
||||||
|
credits: number
|
||||||
|
display: string
|
||||||
|
amount: number
|
||||||
|
currency: string
|
||||||
|
configured: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(amount: number, currency: string): string {
|
||||||
|
const cur = currency.toUpperCase()
|
||||||
|
if (cur === 'EUR') {
|
||||||
|
return `${amount.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })} €`
|
||||||
|
}
|
||||||
|
if (cur === 'USD') {
|
||||||
|
return `$${amount.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
|
||||||
|
}
|
||||||
|
if (cur === 'GBP') {
|
||||||
|
return `£${amount.toLocaleString('en-GB', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
|
||||||
|
}
|
||||||
|
return `${amount} ${cur}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPackPublicPrices(): Promise<PackPublicPrice[]> {
|
||||||
|
const isMock =
|
||||||
|
!process.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder'
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
CREDIT_PACK_IDS.map(async (id) => {
|
||||||
|
const pack = CREDIT_PACKS[id]
|
||||||
|
let display = pack.defaultDisplay
|
||||||
|
let amount = pack.defaultAmount
|
||||||
|
let currency = pack.currency
|
||||||
|
let configured = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const priceId = await resolvePackPriceId(id)
|
||||||
|
if (priceId && !priceId.startsWith('price_mock_')) {
|
||||||
|
configured = true
|
||||||
|
if (!isMock) {
|
||||||
|
const price = await stripe.prices.retrieve(priceId)
|
||||||
|
if (price.unit_amount != null) {
|
||||||
|
amount = price.unit_amount / 100
|
||||||
|
currency = price.currency.toUpperCase()
|
||||||
|
display = formatMoney(amount, currency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* pack non configuré — on garde le prix catalogue */
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
credits: pack.credits,
|
||||||
|
display,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
configured: configured || isMock,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retrouve packId + crédits depuis un price ID Stripe (webhook). */
|
||||||
|
export async function resolvePackFromPriceId(
|
||||||
|
priceId: string,
|
||||||
|
): Promise<{ packId: CreditPackId; credits: number } | null> {
|
||||||
|
if (!priceId) return null
|
||||||
|
if (priceId.startsWith('price_mock_credits_')) {
|
||||||
|
const suffix = priceId.replace('price_mock_credits_', '').toUpperCase()
|
||||||
|
if (isCreditPackId(suffix)) {
|
||||||
|
return { packId: suffix, credits: CREDIT_PACKS[suffix].credits }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of CREDIT_PACK_IDS) {
|
||||||
|
const pack = CREDIT_PACKS[id]
|
||||||
|
const fromDb = await getConfigValue(pack.priceConfigKey, '')
|
||||||
|
const configured = fromDb || process.env[pack.priceConfigKey] || ''
|
||||||
|
if (configured && configured === priceId) {
|
||||||
|
return { packId: id, credits: pack.credits }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ const ENV_FALLBACKS: Record<string, string> = {
|
|||||||
AI_MODEL_CHAT: process.env.AI_MODEL_CHAT || '',
|
AI_MODEL_CHAT: process.env.AI_MODEL_CHAT || '',
|
||||||
AI_PROVIDER_CHAT_FALLBACK: process.env.AI_PROVIDER_CHAT_FALLBACK || '',
|
AI_PROVIDER_CHAT_FALLBACK: process.env.AI_PROVIDER_CHAT_FALLBACK || '',
|
||||||
AI_MODEL_CHAT_FALLBACK: process.env.AI_MODEL_CHAT_FALLBACK || '',
|
AI_MODEL_CHAT_FALLBACK: process.env.AI_MODEL_CHAT_FALLBACK || '',
|
||||||
|
// Slides generation — optional dedicated model (never auto-renamed)
|
||||||
|
AI_PROVIDER_SLIDES: process.env.AI_PROVIDER_SLIDES || '',
|
||||||
|
AI_MODEL_SLIDES: process.env.AI_MODEL_SLIDES || '',
|
||||||
AI_PROVIDER_TAGS_FALLBACK: process.env.AI_PROVIDER_TAGS_FALLBACK || '',
|
AI_PROVIDER_TAGS_FALLBACK: process.env.AI_PROVIDER_TAGS_FALLBACK || '',
|
||||||
AI_MODEL_TAGS_FALLBACK: process.env.AI_MODEL_TAGS_FALLBACK || '',
|
AI_MODEL_TAGS_FALLBACK: process.env.AI_MODEL_TAGS_FALLBACK || '',
|
||||||
AI_PROVIDER_EMBEDDING_FALLBACK: process.env.AI_PROVIDER_EMBEDDING_FALLBACK || '',
|
AI_PROVIDER_EMBEDDING_FALLBACK: process.env.AI_PROVIDER_EMBEDDING_FALLBACK || '',
|
||||||
|
|||||||
753
memento-note/lib/credits.ts
Normal file
753
memento-note/lib/credits.ts
Normal file
@@ -0,0 +1,753 @@
|
|||||||
|
/**
|
||||||
|
* Crédits IA globaux (modèle A).
|
||||||
|
* Un seul solde ; chaque action a un coût ; le tableau d'usage agrège le ledger par feature.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { redis } from '@/lib/redis'
|
||||||
|
import { getCurrentPeriodKey, VALID_FEATURES, type FeatureName } from '@/lib/quota-utils'
|
||||||
|
import type { SubscriptionTier } from '@/lib/plan-entitlements'
|
||||||
|
|
||||||
|
// Imports dynamiques vers entitlements pour éviter les cycles
|
||||||
|
// (entitlements délègue aussi vers credits pour reserveUsageOrThrow).
|
||||||
|
|
||||||
|
async function getEffectiveTier(userId: string): Promise<SubscriptionTier> {
|
||||||
|
const { getEffectiveTier: fn } = await import('@/lib/entitlements')
|
||||||
|
return fn(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeQuotaExceeded(
|
||||||
|
feature: string,
|
||||||
|
limit: number,
|
||||||
|
used: number,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
const { QuotaExceededError } = await import('@/lib/entitlements')
|
||||||
|
const tier = await getEffectiveTier(userId)
|
||||||
|
return new QuotaExceededError('PRO', feature, limit, used, false, { currentTier: tier })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allocations mensuelles — option M (alignée marché IA 2025–2026).
|
||||||
|
* Sources marché : enveloppes type 100 / ~1 000 / ~4 000 (agrégateurs multi-modèles,
|
||||||
|
* ordres de grandeur Copilot ~1 000 crédits Pro, grilles Starter/Pro/Ultra).
|
||||||
|
* BASIC 100 · PRO 1 000 · BUSINESS 4 000 · ENTERPRISE illimité.
|
||||||
|
*/
|
||||||
|
export const CREDIT_ALLOCATIONS: Record<SubscriptionTier, number | 'unlimited'> = {
|
||||||
|
BASIC: 100,
|
||||||
|
PRO: 1000,
|
||||||
|
BUSINESS: 4000,
|
||||||
|
ENTERPRISE: 'unlimited',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coût unitaire fixe par feature (slides : voir slideGenerateCreditCost). */
|
||||||
|
export const CREDIT_COSTS: Record<string, number> = {
|
||||||
|
semantic_search: 1,
|
||||||
|
auto_tag: 1,
|
||||||
|
auto_title: 1,
|
||||||
|
reformulate: 1,
|
||||||
|
chat: 1,
|
||||||
|
brainstorm_create: 5,
|
||||||
|
brainstorm_expand: 1,
|
||||||
|
brainstorm_enrich: 1,
|
||||||
|
suggest_charts: 1,
|
||||||
|
publish_enhance: 3,
|
||||||
|
ai_flashcard: 3,
|
||||||
|
voice_transcribe: 1,
|
||||||
|
excalidraw_generate: 4,
|
||||||
|
slide_generate: 7, // défaut si pas de slideCount (1+6)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function slideGenerateCreditCost(slideCount?: number | null): number {
|
||||||
|
const n =
|
||||||
|
typeof slideCount === 'number' && Number.isFinite(slideCount)
|
||||||
|
? Math.min(8, Math.max(3, Math.round(slideCount)))
|
||||||
|
: 6
|
||||||
|
return 1 + n
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notebookSlideCreditCost(opts: {
|
||||||
|
slideCount?: number | null
|
||||||
|
noteCount?: number | null
|
||||||
|
}): number {
|
||||||
|
const n =
|
||||||
|
typeof opts.slideCount === 'number' && Number.isFinite(opts.slideCount)
|
||||||
|
? Math.min(12, Math.max(5, Math.round(opts.slideCount)))
|
||||||
|
: 8
|
||||||
|
const notes =
|
||||||
|
typeof opts.noteCount === 'number' && Number.isFinite(opts.noteCount)
|
||||||
|
? Math.max(0, Math.round(opts.noteCount))
|
||||||
|
: 0
|
||||||
|
return 2 + n + Math.floor(notes / 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCreditCost(
|
||||||
|
feature: string,
|
||||||
|
options?: { amount?: number; slideCount?: number | null },
|
||||||
|
): number {
|
||||||
|
if (feature === 'slide_generate') {
|
||||||
|
if (options?.slideCount != null) return slideGenerateCreditCost(options.slideCount)
|
||||||
|
if (options?.amount != null && options.amount > 1) return Math.max(1, Math.floor(options.amount))
|
||||||
|
return slideGenerateCreditCost(null)
|
||||||
|
}
|
||||||
|
if (options?.amount != null && options.amount > 1) {
|
||||||
|
return Math.max(1, Math.floor(options.amount))
|
||||||
|
}
|
||||||
|
return CREDIT_COSTS[feature] ?? 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type CreditBalance = {
|
||||||
|
period: string
|
||||||
|
unlimited: boolean
|
||||||
|
totalRemaining: number
|
||||||
|
subscriptionRemaining: number
|
||||||
|
purchasedRemaining: number
|
||||||
|
subscriptionGranted: number
|
||||||
|
subscriptionSpent: number
|
||||||
|
spentThisPeriod: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreditBreakdownRow = {
|
||||||
|
feature: string
|
||||||
|
creditsUsed: number
|
||||||
|
actionsCount: number
|
||||||
|
percentOfSpent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreditUsageSummary = {
|
||||||
|
tier: SubscriptionTier
|
||||||
|
period: string
|
||||||
|
balance: CreditBalance
|
||||||
|
breakdown: CreditBreakdownRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const TTL_SECONDS = 90 * 24 * 60 * 60
|
||||||
|
|
||||||
|
function redisKeys(userId: string, period: string) {
|
||||||
|
return {
|
||||||
|
subSpent: `credits:${userId}:${period}:sub_spent`,
|
||||||
|
purchased: `credits:${userId}:${period}:purchased`,
|
||||||
|
granted: `credits:${userId}:${period}:granted`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ensure account / monthly grant ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function ensureCreditAccount(userId: string): Promise<{
|
||||||
|
periodKey: string
|
||||||
|
subscriptionGranted: number
|
||||||
|
subscriptionSpent: number
|
||||||
|
purchasedBalance: number
|
||||||
|
unlimited: boolean
|
||||||
|
}> {
|
||||||
|
const periodKey = getCurrentPeriodKey()
|
||||||
|
const tier = await getEffectiveTier(userId)
|
||||||
|
const allocation = CREDIT_ALLOCATIONS[tier]
|
||||||
|
const unlimited = allocation === 'unlimited'
|
||||||
|
const grant = unlimited ? 0 : (allocation as number)
|
||||||
|
|
||||||
|
let account = await prisma.aiCreditAccount.findUnique({ where: { userId } })
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
account = await prisma.aiCreditAccount.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
periodKey,
|
||||||
|
subscriptionGranted: grant,
|
||||||
|
subscriptionSpent: 0,
|
||||||
|
purchasedBalance: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!unlimited && grant > 0) {
|
||||||
|
await appendLedger(userId, {
|
||||||
|
kind: 'GRANT_SUBSCRIPTION',
|
||||||
|
amount: grant,
|
||||||
|
feature: null,
|
||||||
|
balanceAfter: grant,
|
||||||
|
metadata: { periodKey, tier },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await syncRedisFromAccount(userId, account)
|
||||||
|
return {
|
||||||
|
periodKey,
|
||||||
|
subscriptionGranted: account.subscriptionGranted,
|
||||||
|
subscriptionSpent: account.subscriptionSpent,
|
||||||
|
purchasedBalance: account.purchasedBalance,
|
||||||
|
unlimited,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nouvelle période : expire reliquat abonnement, conserve packs, re-grant
|
||||||
|
if (account.periodKey !== periodKey) {
|
||||||
|
const purchased = account.purchasedBalance
|
||||||
|
account = await prisma.aiCreditAccount.update({
|
||||||
|
where: { userId },
|
||||||
|
data: {
|
||||||
|
periodKey,
|
||||||
|
subscriptionGranted: grant,
|
||||||
|
subscriptionSpent: 0,
|
||||||
|
purchasedBalance: purchased,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!unlimited && grant > 0) {
|
||||||
|
await appendLedger(userId, {
|
||||||
|
kind: 'GRANT_SUBSCRIPTION',
|
||||||
|
amount: grant,
|
||||||
|
feature: null,
|
||||||
|
balanceAfter: grant + purchased,
|
||||||
|
metadata: { periodKey, tier, rolledPurchased: purchased },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await syncRedisFromAccount(userId, account)
|
||||||
|
} else if (!unlimited && account.subscriptionGranted !== grant) {
|
||||||
|
// Palier changé en cours de mois : met à jour l'allocation (ne retire pas le déjà consommé)
|
||||||
|
account = await prisma.aiCreditAccount.update({
|
||||||
|
where: { userId },
|
||||||
|
data: { subscriptionGranted: grant },
|
||||||
|
})
|
||||||
|
await syncRedisFromAccount(userId, account)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
periodKey: account.periodKey,
|
||||||
|
subscriptionGranted: account.subscriptionGranted,
|
||||||
|
subscriptionSpent: account.subscriptionSpent,
|
||||||
|
purchasedBalance: account.purchasedBalance,
|
||||||
|
unlimited,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncRedisFromAccount(
|
||||||
|
userId: string,
|
||||||
|
account: {
|
||||||
|
periodKey: string
|
||||||
|
subscriptionGranted: number
|
||||||
|
subscriptionSpent: number
|
||||||
|
purchasedBalance: number
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const k = redisKeys(userId, account.periodKey)
|
||||||
|
try {
|
||||||
|
await redis
|
||||||
|
.multi()
|
||||||
|
.set(k.granted, String(account.subscriptionGranted), 'EX', TTL_SECONDS)
|
||||||
|
.set(k.subSpent, String(account.subscriptionSpent), 'EX', TTL_SECONDS)
|
||||||
|
.set(k.purchased, String(account.purchasedBalance), 'EX', TTL_SECONDS)
|
||||||
|
.exec()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[credits] Redis sync failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appendLedger(
|
||||||
|
userId: string,
|
||||||
|
entry: {
|
||||||
|
kind: string
|
||||||
|
amount: number
|
||||||
|
feature: string | null
|
||||||
|
balanceAfter: number | null
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await prisma.aiCreditLedger.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
kind: entry.kind,
|
||||||
|
amount: entry.amount,
|
||||||
|
feature: entry.feature,
|
||||||
|
balanceAfter: entry.balanceAfter,
|
||||||
|
metadata: entry.metadata ? JSON.stringify(entry.metadata) : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[credits] ledger write failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Balance ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getCreditBalance(userId: string): Promise<CreditBalance> {
|
||||||
|
const acc = await ensureCreditAccount(userId)
|
||||||
|
const period = acc.periodKey
|
||||||
|
|
||||||
|
if (acc.unlimited) {
|
||||||
|
return {
|
||||||
|
period,
|
||||||
|
unlimited: true,
|
||||||
|
totalRemaining: Infinity,
|
||||||
|
subscriptionRemaining: Infinity,
|
||||||
|
purchasedRemaining: acc.purchasedBalance,
|
||||||
|
subscriptionGranted: Infinity,
|
||||||
|
subscriptionSpent: acc.subscriptionSpent,
|
||||||
|
spentThisPeriod: acc.subscriptionSpent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer Redis if present
|
||||||
|
const k = redisKeys(userId, period)
|
||||||
|
try {
|
||||||
|
const [subSpentRaw, purchasedRaw, grantedRaw] = await redis.mget(
|
||||||
|
k.subSpent,
|
||||||
|
k.purchased,
|
||||||
|
k.granted,
|
||||||
|
)
|
||||||
|
if (subSpentRaw != null || purchasedRaw != null) {
|
||||||
|
const subscriptionSpent = Math.max(0, parseInt(subSpentRaw || '0', 10) || 0)
|
||||||
|
const purchasedRemaining = Math.max(0, parseInt(purchasedRaw || '0', 10) || 0)
|
||||||
|
const subscriptionGranted = Math.max(
|
||||||
|
0,
|
||||||
|
parseInt(grantedRaw || String(acc.subscriptionGranted), 10) || acc.subscriptionGranted,
|
||||||
|
)
|
||||||
|
const subscriptionRemaining = Math.max(0, subscriptionGranted - subscriptionSpent)
|
||||||
|
return {
|
||||||
|
period,
|
||||||
|
unlimited: false,
|
||||||
|
totalRemaining: subscriptionRemaining + purchasedRemaining,
|
||||||
|
subscriptionRemaining,
|
||||||
|
purchasedRemaining,
|
||||||
|
subscriptionGranted,
|
||||||
|
subscriptionSpent,
|
||||||
|
spentThisPeriod: subscriptionSpent, // packs spend tracked separately via ledger
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* fall through to DB */
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionRemaining = Math.max(0, acc.subscriptionGranted - acc.subscriptionSpent)
|
||||||
|
return {
|
||||||
|
period,
|
||||||
|
unlimited: false,
|
||||||
|
totalRemaining: subscriptionRemaining + acc.purchasedBalance,
|
||||||
|
subscriptionRemaining,
|
||||||
|
purchasedRemaining: acc.purchasedBalance,
|
||||||
|
subscriptionGranted: acc.subscriptionGranted,
|
||||||
|
subscriptionSpent: acc.subscriptionSpent,
|
||||||
|
spentThisPeriod: acc.subscriptionSpent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reserve / release ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const RESERVE_CREDITS_LUA = `
|
||||||
|
local cost = tonumber(ARGV[1]) or 1
|
||||||
|
local granted = tonumber(redis.call('GET', KEYS[1]) or '0')
|
||||||
|
local subSpent = tonumber(redis.call('GET', KEYS[2]) or '0')
|
||||||
|
local purchased = tonumber(redis.call('GET', KEYS[3]) or '0')
|
||||||
|
local ttl = tonumber(ARGV[2]) or 7776000
|
||||||
|
if cost < 1 then cost = 1 end
|
||||||
|
local subRem = math.max(0, granted - subSpent)
|
||||||
|
local total = subRem + purchased
|
||||||
|
if total < cost then
|
||||||
|
return {-1, subSpent, purchased}
|
||||||
|
end
|
||||||
|
local fromSub = math.min(subRem, cost)
|
||||||
|
local fromPurch = cost - fromSub
|
||||||
|
subSpent = subSpent + fromSub
|
||||||
|
purchased = purchased - fromPurch
|
||||||
|
redis.call('SET', KEYS[2], tostring(subSpent))
|
||||||
|
redis.call('EXPIRE', KEYS[2], ttl)
|
||||||
|
redis.call('SET', KEYS[3], tostring(purchased))
|
||||||
|
redis.call('EXPIRE', KEYS[3], ttl)
|
||||||
|
if redis.call('TTL', KEYS[1]) < 0 then
|
||||||
|
redis.call('EXPIRE', KEYS[1], ttl)
|
||||||
|
end
|
||||||
|
return {cost, subSpent, purchased, fromSub, fromPurch}
|
||||||
|
`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réserve `cost` crédits. Lance QuotaExceededError si insuffisant.
|
||||||
|
* Enterprise unlimited : no-op.
|
||||||
|
*/
|
||||||
|
export async function reserveCreditsOrThrow(
|
||||||
|
userId: string,
|
||||||
|
cost: number,
|
||||||
|
options?: { feature?: string; metadata?: Record<string, unknown> },
|
||||||
|
): Promise<{ cost: number; unlimited: boolean }> {
|
||||||
|
const units = Math.max(1, Math.floor(Number(cost) || 1))
|
||||||
|
const acc = await ensureCreditAccount(userId)
|
||||||
|
|
||||||
|
if (acc.unlimited) {
|
||||||
|
// Journal informatif (montant 0) pour le tableau si feature fournie
|
||||||
|
if (options?.feature) {
|
||||||
|
await appendLedger(userId, {
|
||||||
|
kind: 'CONSUME',
|
||||||
|
amount: 0,
|
||||||
|
feature: options.feature,
|
||||||
|
balanceAfter: null,
|
||||||
|
metadata: { ...(options.metadata || {}), unlimited: true, notionalCost: units },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { cost: 0, unlimited: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const period = acc.periodKey
|
||||||
|
const k = redisKeys(userId, period)
|
||||||
|
|
||||||
|
// Ensure granted key is set
|
||||||
|
try {
|
||||||
|
const g = await redis.get(k.granted)
|
||||||
|
if (g == null) {
|
||||||
|
await syncRedisFromAccount(userId, {
|
||||||
|
periodKey: period,
|
||||||
|
subscriptionGranted: acc.subscriptionGranted,
|
||||||
|
subscriptionSpent: acc.subscriptionSpent,
|
||||||
|
purchasedBalance: acc.purchasedBalance,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* continue */
|
||||||
|
}
|
||||||
|
|
||||||
|
let fromSub = units
|
||||||
|
let fromPurch = 0
|
||||||
|
let newSubSpent = acc.subscriptionSpent + units
|
||||||
|
let newPurchased = acc.purchasedBalance
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = (await redis.eval(
|
||||||
|
RESERVE_CREDITS_LUA,
|
||||||
|
3,
|
||||||
|
k.granted,
|
||||||
|
k.subSpent,
|
||||||
|
k.purchased,
|
||||||
|
String(units),
|
||||||
|
String(TTL_SECONDS),
|
||||||
|
)) as number[]
|
||||||
|
|
||||||
|
if (!raw || raw[0] === -1) {
|
||||||
|
const bal = await getCreditBalance(userId)
|
||||||
|
throw await makeQuotaExceeded(
|
||||||
|
options?.feature || 'credits',
|
||||||
|
bal.subscriptionGranted + bal.purchasedRemaining,
|
||||||
|
bal.spentThisPeriod,
|
||||||
|
userId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
newSubSpent = Number(raw[1]) || 0
|
||||||
|
newPurchased = Number(raw[2]) || 0
|
||||||
|
fromSub = Number(raw[3]) ?? units
|
||||||
|
fromPurch = Number(raw[4]) ?? 0
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof QuotaExceededError) throw err
|
||||||
|
|
||||||
|
// DB fallback
|
||||||
|
const subRem = Math.max(0, acc.subscriptionGranted - acc.subscriptionSpent)
|
||||||
|
const total = subRem + acc.purchasedBalance
|
||||||
|
if (total < units) {
|
||||||
|
throw await makeQuotaExceeded(
|
||||||
|
options?.feature || 'credits',
|
||||||
|
acc.subscriptionGranted + acc.purchasedBalance,
|
||||||
|
acc.subscriptionSpent,
|
||||||
|
userId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fromSub = Math.min(subRem, units)
|
||||||
|
fromPurch = units - fromSub
|
||||||
|
newSubSpent = acc.subscriptionSpent + fromSub
|
||||||
|
newPurchased = acc.purchasedBalance - fromPurch
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.aiCreditAccount.update({
|
||||||
|
where: { userId },
|
||||||
|
data: {
|
||||||
|
subscriptionSpent: newSubSpent,
|
||||||
|
purchasedBalance: newPurchased,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const remaining =
|
||||||
|
Math.max(0, acc.subscriptionGranted - newSubSpent) + Math.max(0, newPurchased)
|
||||||
|
|
||||||
|
await appendLedger(userId, {
|
||||||
|
kind: 'CONSUME',
|
||||||
|
amount: -units,
|
||||||
|
feature: options?.feature ?? null,
|
||||||
|
balanceAfter: remaining,
|
||||||
|
metadata: {
|
||||||
|
...(options?.metadata || {}),
|
||||||
|
fromSubscription: fromSub,
|
||||||
|
fromPurchased: fromPurch,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { cost: units, unlimited: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remet des crédits après échec (best-effort).
|
||||||
|
* Simplification : recrédite d'abord l'abonnement (réduit subscriptionSpent).
|
||||||
|
*/
|
||||||
|
export async function releaseCredits(
|
||||||
|
userId: string,
|
||||||
|
cost: number,
|
||||||
|
options?: { feature?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const units = Math.max(1, Math.floor(Number(cost) || 1))
|
||||||
|
const acc = await ensureCreditAccount(userId)
|
||||||
|
if (acc.unlimited) return
|
||||||
|
|
||||||
|
const period = acc.periodKey
|
||||||
|
const k = redisKeys(userId, period)
|
||||||
|
|
||||||
|
let newSubSpent = Math.max(0, acc.subscriptionSpent - units)
|
||||||
|
// Si on a plus dépensé en abonnement que available, le reste va en packs
|
||||||
|
let refundPurch = 0
|
||||||
|
const subRefund = Math.min(units, acc.subscriptionSpent)
|
||||||
|
refundPurch = units - subRefund
|
||||||
|
newSubSpent = acc.subscriptionSpent - subRefund
|
||||||
|
const newPurchased = acc.purchasedBalance + refundPurch
|
||||||
|
|
||||||
|
try {
|
||||||
|
await redis
|
||||||
|
.multi()
|
||||||
|
.set(k.subSpent, String(newSubSpent), 'EX', TTL_SECONDS)
|
||||||
|
.set(k.purchased, String(newPurchased), 'EX', TTL_SECONDS)
|
||||||
|
.exec()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[credits] release Redis failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.aiCreditAccount.update({
|
||||||
|
where: { userId },
|
||||||
|
data: {
|
||||||
|
subscriptionSpent: newSubSpent,
|
||||||
|
purchasedBalance: newPurchased,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const remaining =
|
||||||
|
Math.max(0, acc.subscriptionGranted - newSubSpent) + Math.max(0, newPurchased)
|
||||||
|
|
||||||
|
await appendLedger(userId, {
|
||||||
|
kind: 'RELEASE',
|
||||||
|
amount: units,
|
||||||
|
feature: options?.feature ?? null,
|
||||||
|
balanceAfter: remaining,
|
||||||
|
metadata: { refundSubscription: subRefund, refundPurchased: refundPurch },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addPurchasedCredits(
|
||||||
|
userId: string,
|
||||||
|
amount: number,
|
||||||
|
metadata?: Record<string, unknown>,
|
||||||
|
): Promise<{ applied: boolean; units: number }> {
|
||||||
|
const units = Math.max(1, Math.floor(amount))
|
||||||
|
|
||||||
|
// Idempotence webhook : un même checkout Stripe ne crédite qu'une fois
|
||||||
|
const stripeSessionId =
|
||||||
|
typeof metadata?.stripeSessionId === 'string' ? metadata.stripeSessionId : null
|
||||||
|
if (stripeSessionId) {
|
||||||
|
const existing = await prisma.aiCreditLedger.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
kind: 'PURCHASE_PACK',
|
||||||
|
metadata: { contains: stripeSessionId },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (existing) {
|
||||||
|
return { applied: false, units }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const acc = await ensureCreditAccount(userId)
|
||||||
|
const newPurchased = acc.purchasedBalance + units
|
||||||
|
|
||||||
|
await prisma.aiCreditAccount.update({
|
||||||
|
where: { userId },
|
||||||
|
data: { purchasedBalance: newPurchased },
|
||||||
|
})
|
||||||
|
|
||||||
|
const period = acc.periodKey
|
||||||
|
const k = redisKeys(userId, period)
|
||||||
|
try {
|
||||||
|
await redis.set(k.purchased, String(newPurchased), 'EX', TTL_SECONDS)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
const bal = await getCreditBalance(userId)
|
||||||
|
await appendLedger(userId, {
|
||||||
|
kind: 'PURCHASE_PACK',
|
||||||
|
amount: units,
|
||||||
|
feature: null,
|
||||||
|
balanceAfter: bal.unlimited ? null : bal.totalRemaining,
|
||||||
|
metadata,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { applied: true, units }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset admin : remet conso à 0 et re-grant allocation, packs optionnels conservés. */
|
||||||
|
export async function adminResetCredits(
|
||||||
|
userId: string,
|
||||||
|
options?: { clearPurchased?: boolean },
|
||||||
|
): Promise<CreditBalance> {
|
||||||
|
const periodKey = getCurrentPeriodKey()
|
||||||
|
const tier = await getEffectiveTier(userId)
|
||||||
|
const allocation = CREDIT_ALLOCATIONS[tier]
|
||||||
|
const unlimited = allocation === 'unlimited'
|
||||||
|
const grant = unlimited ? 0 : (allocation as number)
|
||||||
|
|
||||||
|
const existing = await prisma.aiCreditAccount.findUnique({ where: { userId } })
|
||||||
|
const purchased = options?.clearPurchased ? 0 : (existing?.purchasedBalance ?? 0)
|
||||||
|
|
||||||
|
const account = await prisma.aiCreditAccount.upsert({
|
||||||
|
where: { userId },
|
||||||
|
create: {
|
||||||
|
userId,
|
||||||
|
periodKey,
|
||||||
|
subscriptionGranted: grant,
|
||||||
|
subscriptionSpent: 0,
|
||||||
|
purchasedBalance: purchased,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
periodKey,
|
||||||
|
subscriptionGranted: grant,
|
||||||
|
subscriptionSpent: 0,
|
||||||
|
purchasedBalance: purchased,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await syncRedisFromAccount(userId, account)
|
||||||
|
|
||||||
|
await appendLedger(userId, {
|
||||||
|
kind: 'ADMIN_RESET',
|
||||||
|
amount: grant,
|
||||||
|
feature: null,
|
||||||
|
balanceAfter: grant + purchased,
|
||||||
|
metadata: { clearPurchased: !!options?.clearPurchased, tier },
|
||||||
|
})
|
||||||
|
|
||||||
|
return getCreditBalance(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Breakdown for usage table ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date de début pour le détail d'usage : après le dernier ADMIN_RESET du mois,
|
||||||
|
* sinon début de période. Évite « 300 restants + 16 utilisés » après un reset admin.
|
||||||
|
*/
|
||||||
|
async function usageWindowStart(userId: string): Promise<Date> {
|
||||||
|
const period = getCurrentPeriodKey()
|
||||||
|
const periodStart = new Date(`${period}-01T00:00:00.000Z`)
|
||||||
|
const lastReset = await prisma.aiCreditLedger.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
kind: 'ADMIN_RESET',
|
||||||
|
createdAt: { gte: periodStart },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: { createdAt: true },
|
||||||
|
})
|
||||||
|
return lastReset?.createdAt ?? periodStart
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCreditBreakdown(userId: string): Promise<CreditBreakdownRow[]> {
|
||||||
|
const since = await usageWindowStart(userId)
|
||||||
|
|
||||||
|
const rows = await prisma.aiCreditLedger.groupBy({
|
||||||
|
by: ['feature'],
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
kind: 'CONSUME',
|
||||||
|
createdAt: { gte: since },
|
||||||
|
feature: { not: null },
|
||||||
|
},
|
||||||
|
_sum: { amount: true },
|
||||||
|
_count: { _all: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Toutes les fonctionnalités connues (comme l'ancien tableau) — pas seulement slides
|
||||||
|
const usedMap = new Map<string, { creditsUsed: number; actionsCount: number }>()
|
||||||
|
for (const feature of VALID_FEATURES) {
|
||||||
|
usedMap.set(feature, { creditsUsed: 0, actionsCount: 0 })
|
||||||
|
}
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!r.feature) continue
|
||||||
|
const creditsUsed = Math.abs(r._sum.amount ?? 0)
|
||||||
|
usedMap.set(r.feature, {
|
||||||
|
creditsUsed,
|
||||||
|
actionsCount: r._count._all,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped: CreditBreakdownRow[] = []
|
||||||
|
let totalSpent = 0
|
||||||
|
for (const [feature, v] of usedMap) {
|
||||||
|
totalSpent += v.creditsUsed
|
||||||
|
mapped.push({
|
||||||
|
feature,
|
||||||
|
creditsUsed: v.creditsUsed,
|
||||||
|
actionsCount: v.actionsCount,
|
||||||
|
percentOfSpent: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const r of mapped) {
|
||||||
|
r.percentOfSpent =
|
||||||
|
totalSpent > 0 ? Math.round((r.creditsUsed / totalSpent) * 1000) / 10 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tri : d'abord celles avec usage, puis ordre alphabétique du feature key
|
||||||
|
mapped.sort((a, b) => {
|
||||||
|
if (b.creditsUsed !== a.creditsUsed) return b.creditsUsed - a.creditsUsed
|
||||||
|
if (b.actionsCount !== a.actionsCount) return b.actionsCount - a.actionsCount
|
||||||
|
return a.feature.localeCompare(b.feature)
|
||||||
|
})
|
||||||
|
return mapped
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCreditUsageSummary(userId: string): Promise<CreditUsageSummary> {
|
||||||
|
const tier = await getEffectiveTier(userId)
|
||||||
|
const balance = await getCreditBalance(userId)
|
||||||
|
const breakdown = await getCreditBreakdown(userId)
|
||||||
|
|
||||||
|
// Conso affichée = somme réelle des CONSUME dans la fenêtre (après dernier reset)
|
||||||
|
const spentFromBreakdown = breakdown.reduce((s, r) => s + r.creditsUsed, 0)
|
||||||
|
balance.spentThisPeriod = spentFromBreakdown
|
||||||
|
|
||||||
|
return {
|
||||||
|
tier,
|
||||||
|
period: balance.period,
|
||||||
|
balance,
|
||||||
|
breakdown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compat : fabrique un objet quotas-like pour l'ancien UI le temps de la migration. */
|
||||||
|
export async function getQuotasCompatFromCredits(
|
||||||
|
userId: string,
|
||||||
|
): Promise<Record<string, { remaining: number; limit: number; used: number }>> {
|
||||||
|
const summary = await getCreditUsageSummary(userId)
|
||||||
|
const result: Record<string, { remaining: number; limit: number; used: number }> = {}
|
||||||
|
|
||||||
|
// Lignes du breakdown
|
||||||
|
const usedByFeature = new Map(summary.breakdown.map((b) => [b.feature, b.creditsUsed]))
|
||||||
|
|
||||||
|
for (const feature of VALID_FEATURES) {
|
||||||
|
const used = usedByFeature.get(feature) ?? 0
|
||||||
|
if (summary.balance.unlimited) {
|
||||||
|
result[feature] = { remaining: Infinity, limit: Infinity, used }
|
||||||
|
} else {
|
||||||
|
// Pas de plafond par feature : on expose used + remaining global pour info
|
||||||
|
result[feature] = {
|
||||||
|
used,
|
||||||
|
limit: summary.balance.subscriptionGranted + summary.balance.purchasedRemaining + used,
|
||||||
|
remaining: summary.balance.totalRemaining,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCreditFeature(feature: string): feature is FeatureName {
|
||||||
|
return (VALID_FEATURES as readonly string[]).includes(feature as FeatureName)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
getRedisKey,
|
getRedisKey,
|
||||||
parseRedisInt,
|
parseRedisInt,
|
||||||
isValidFeature,
|
isValidFeature,
|
||||||
|
VALID_FEATURES,
|
||||||
} from './quota-utils';
|
} from './quota-utils';
|
||||||
import {
|
import {
|
||||||
getLimitAsync,
|
getLimitAsync,
|
||||||
@@ -138,15 +139,17 @@ async function reserveUsageInDatabase(
|
|||||||
userId: string,
|
userId: string,
|
||||||
feature: string,
|
feature: string,
|
||||||
limit: number,
|
limit: number,
|
||||||
|
amount: number = 1,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const { periodStart, periodEnd } = getPeriodDates();
|
const { periodStart, periodEnd } = getPeriodDates();
|
||||||
|
const units = Math.max(1, Math.floor(amount));
|
||||||
|
|
||||||
return prisma.$transaction(async (tx) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
const existing = await tx.usageLog.findUnique({
|
const existing = await tx.usageLog.findUnique({
|
||||||
where: { userId_feature_periodStart: { userId, feature, periodStart } },
|
where: { userId_feature_periodStart: { userId, feature, periodStart } },
|
||||||
});
|
});
|
||||||
const current = existing?.requestsCount ?? 0;
|
const current = existing?.requestsCount ?? 0;
|
||||||
if (current >= limit) return -1;
|
if (current + units > limit) return -1;
|
||||||
|
|
||||||
const updated = await tx.usageLog.upsert({
|
const updated = await tx.usageLog.upsert({
|
||||||
where: { userId_feature_periodStart: { userId, feature, periodStart } },
|
where: { userId_feature_periodStart: { userId, feature, periodStart } },
|
||||||
@@ -155,12 +158,12 @@ async function reserveUsageInDatabase(
|
|||||||
feature,
|
feature,
|
||||||
periodStart,
|
periodStart,
|
||||||
periodEnd,
|
periodEnd,
|
||||||
requestsCount: 1,
|
requestsCount: units,
|
||||||
tokensUsed: 0,
|
tokensUsed: 0,
|
||||||
syncedAt: new Date(),
|
syncedAt: new Date(),
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
requestsCount: { increment: 1 },
|
requestsCount: { increment: units },
|
||||||
syncedAt: new Date(),
|
syncedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -191,53 +194,37 @@ return newCount
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const RELEASE_LUA = `
|
const RELEASE_LUA = `
|
||||||
|
local amount = tonumber(ARGV[1]) or 1
|
||||||
|
if amount < 1 then amount = 1 end
|
||||||
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
|
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
|
||||||
if current <= 0 then return 0 end
|
if current <= 0 then return 0 end
|
||||||
redis.call('DECR', KEYS[1])
|
local dec = math.min(amount, current)
|
||||||
|
redis.call('DECRBY', KEYS[1], dec)
|
||||||
return tonumber(redis.call('GET', KEYS[1]) or '0')
|
return tonumber(redis.call('GET', KEYS[1]) or '0')
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export async function releaseUsage(userId: string, feature: string): Promise<void> {
|
/** Libère des crédits précédemment réservés (modèle A). */
|
||||||
|
export async function releaseUsage(
|
||||||
|
userId: string,
|
||||||
|
feature: string,
|
||||||
|
amount: number = 1,
|
||||||
|
): Promise<void> {
|
||||||
if (!isValidFeature(feature)) return;
|
if (!isValidFeature(feature)) return;
|
||||||
|
const units = Math.max(1, Math.floor(Number(amount) || 1));
|
||||||
const tier = await getEffectiveTier(userId);
|
const { releaseCredits } = await import('@/lib/credits');
|
||||||
const limit = await getLimitAsync(tier, feature);
|
await releaseCredits(userId, units, { feature });
|
||||||
if (limit === undefined || limit === Infinity) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const key = getRedisKey(userId, feature);
|
|
||||||
const raw = await redis.eval(RELEASE_LUA, 1, key);
|
|
||||||
const newCount = parseReserveResult(raw);
|
|
||||||
if (Number.isFinite(newCount) && newCount >= 0) {
|
|
||||||
await mirrorUsageCountToDatabase(userId, feature, newCount);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[entitlements] Redis release failed, trying DB fallback:', err);
|
|
||||||
try {
|
|
||||||
const { periodStart } = getPeriodDates();
|
|
||||||
const existing = await prisma.usageLog.findUnique({
|
|
||||||
where: { userId_feature_periodStart: { userId, feature, periodStart } },
|
|
||||||
});
|
|
||||||
const current = existing?.requestsCount ?? 0;
|
|
||||||
if (current <= 0) return;
|
|
||||||
await prisma.usageLog.update({
|
|
||||||
where: { userId_feature_periodStart: { userId, feature, periodStart } },
|
|
||||||
data: { requestsCount: current - 1, syncedAt: new Date() },
|
|
||||||
});
|
|
||||||
} catch (dbErr) {
|
|
||||||
console.error('[entitlements] DB release fallback failed:', dbErr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const RESERVE_LUA = `
|
const RESERVE_LUA = `
|
||||||
local limit = tonumber(ARGV[1])
|
local limit = tonumber(ARGV[1])
|
||||||
local ttl = tonumber(ARGV[2])
|
local ttl = tonumber(ARGV[2])
|
||||||
|
local amount = tonumber(ARGV[3]) or 1
|
||||||
|
if amount < 1 then amount = 1 end
|
||||||
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
|
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
|
||||||
if current >= limit then
|
if current + amount > limit then
|
||||||
return -1
|
return -1
|
||||||
end
|
end
|
||||||
redis.call('INCRBY', KEYS[1], 1)
|
redis.call('INCRBY', KEYS[1], amount)
|
||||||
local ttlResult = redis.call('TTL', KEYS[1])
|
local ttlResult = redis.call('TTL', KEYS[1])
|
||||||
if ttlResult == -1 then
|
if ttlResult == -1 then
|
||||||
redis.call('EXPIRE', KEYS[1], ttl)
|
redis.call('EXPIRE', KEYS[1], ttl)
|
||||||
@@ -370,86 +357,47 @@ export async function checkEntitlementOrThrow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mappe une feature de quota vers la voie IA (pour le BYOK). */
|
||||||
|
function featureToAiLane(feature: string): 'chat' | 'tags' | 'embedding' {
|
||||||
|
if (feature === 'auto_tag' || feature === 'auto_title') return 'tags';
|
||||||
|
if (feature === 'semantic_search') return 'embedding';
|
||||||
|
return 'chat';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réserve des crédits globaux (modèle A).
|
||||||
|
* `amount` = coût en crédits (ex. slides multi-étapes : 1+N).
|
||||||
|
* Si l'utilisateur a une clé perso (BYOK) pour la voie concernée, aucun débit.
|
||||||
|
* Conserve le nom historique pour ne pas casser tous les appelants.
|
||||||
|
*/
|
||||||
export async function reserveUsageOrThrow(
|
export async function reserveUsageOrThrow(
|
||||||
userId: string,
|
userId: string,
|
||||||
feature: string,
|
feature: string,
|
||||||
|
amount: number = 1,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!isValidFeature(feature)) {
|
if (!isValidFeature(feature)) {
|
||||||
throw new QuotaExceededError('PRO', feature, 0, 0, false, { currentTier: 'BASIC' });
|
throw new QuotaExceededError('PRO', feature, 0, 0, false, { currentTier: 'BASIC' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const tier = await getEffectiveTier(userId);
|
const { getSystemConfig } = await import('@/lib/config');
|
||||||
const limit = await getLimitAsync(tier, feature);
|
const { willUseByokForLane } = await import('@/lib/ai/provider-for-user');
|
||||||
|
const config = await getSystemConfig();
|
||||||
|
const lane = featureToAiLane(feature);
|
||||||
|
const { usedByok } = await willUseByokForLane(lane, config, userId);
|
||||||
|
if (usedByok) return;
|
||||||
|
|
||||||
if (limit === undefined) {
|
const { reserveCreditsOrThrow, resolveCreditCost } = await import('@/lib/credits');
|
||||||
throw new QuotaExceededError(
|
const cost = resolveCreditCost(feature, { amount });
|
||||||
tier === 'BASIC' ? 'PRO' : 'BUSINESS',
|
await reserveCreditsOrThrow(userId, cost, { feature });
|
||||||
feature,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
false,
|
|
||||||
{ currentTier: tier },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (limit === Infinity) return;
|
/** Units charged for one multi-step slide generation (outline + per-slide expand). */
|
||||||
|
export function slideGenerateQuotaUnits(slideCount?: number | null): number {
|
||||||
try {
|
// 1 outline + N expand calls (clamped). Reflects real multi-call cost.
|
||||||
const key = getRedisKey(userId, feature);
|
const n = typeof slideCount === 'number' && Number.isFinite(slideCount)
|
||||||
const raw = await redis.eval(
|
? Math.min(8, Math.max(3, Math.round(slideCount)))
|
||||||
RESERVE_LUA,
|
: 6;
|
||||||
1,
|
return 1 + n;
|
||||||
key,
|
|
||||||
String(limit),
|
|
||||||
String(TTL_SECONDS),
|
|
||||||
);
|
|
||||||
const newCount = parseReserveResult(raw);
|
|
||||||
|
|
||||||
if (newCount === -1) {
|
|
||||||
const byokConfigured = await hasAnyActiveByok(userId);
|
|
||||||
throw new QuotaExceededError(
|
|
||||||
tier === 'BASIC' ? 'PRO' : 'BUSINESS',
|
|
||||||
feature,
|
|
||||||
limit,
|
|
||||||
limit,
|
|
||||||
byokConfigured,
|
|
||||||
{ currentTier: tier },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Number.isFinite(newCount) || newCount < 1) {
|
|
||||||
throw new Error(`Invalid Redis reserve result: ${String(raw)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await mirrorUsageCountToDatabase(userId, feature, newCount);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof QuotaExceededError) throw err;
|
|
||||||
|
|
||||||
console.error('[entitlements] Redis reserve failed, trying DB fallback:', err);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const dbCount = await reserveUsageInDatabase(userId, feature, limit);
|
|
||||||
if (dbCount === -1) {
|
|
||||||
const byokConfigured = await hasAnyActiveByok(userId);
|
|
||||||
throw new QuotaExceededError(
|
|
||||||
tier === 'BASIC' ? 'PRO' : 'BUSINESS',
|
|
||||||
feature,
|
|
||||||
limit,
|
|
||||||
limit,
|
|
||||||
byokConfigured,
|
|
||||||
{ currentTier: tier },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
} catch (dbErr) {
|
|
||||||
if (dbErr instanceof QuotaExceededError) throw dbErr;
|
|
||||||
console.error('[entitlements] DB reserve fallback failed:', dbErr);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldFailClosedOnRedisError()) {
|
|
||||||
throw new QuotaServiceUnavailableError();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Host-pays: bill session owner, attach actor metadata for 402 responses (Story 3.4). */
|
/** Host-pays: bill session owner, attach actor metadata for 402 responses (Story 3.4). */
|
||||||
@@ -480,6 +428,68 @@ export function incrementUsageAsync(userId: string, feature: string, count: numb
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ResetUserUsageOptions = {
|
||||||
|
/** Si fourni, ne remet à zéro que ces fonctionnalités. Sinon : toutes. */
|
||||||
|
features?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remet à zéro la consommation IA d’un utilisateur pour la période mensuelle en cours
|
||||||
|
* (compteurs Redis + lignes UsageLog en base).
|
||||||
|
* Réservé aux actions admin.
|
||||||
|
*/
|
||||||
|
export async function resetUserUsageForCurrentPeriod(
|
||||||
|
userId: string,
|
||||||
|
options?: ResetUserUsageOptions,
|
||||||
|
): Promise<{ period: string; featuresReset: string[]; redisDeleted: number; dbUpdated: number }> {
|
||||||
|
if (!userId?.trim()) {
|
||||||
|
throw new Error('userId required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const period = getCurrentPeriodKey();
|
||||||
|
const { periodStart } = getPeriodDates();
|
||||||
|
|
||||||
|
const features = (options?.features?.length
|
||||||
|
? options.features.filter((f) => isValidFeature(f))
|
||||||
|
: [...VALID_FEATURES]) as string[];
|
||||||
|
|
||||||
|
if (features.length === 0) {
|
||||||
|
return { period, featuresReset: [], redisDeleted: 0, dbUpdated: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = features.map((f) => getRedisKey(userId, f));
|
||||||
|
let redisDeleted = 0;
|
||||||
|
try {
|
||||||
|
if (keys.length === 1) {
|
||||||
|
redisDeleted = await redis.del(keys[0]);
|
||||||
|
} else {
|
||||||
|
redisDeleted = await redis.del(...keys);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[entitlements] resetUserUsage Redis del failed:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbResult = await prisma.usageLog.updateMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
periodStart,
|
||||||
|
feature: { in: features },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
requestsCount: 0,
|
||||||
|
tokensUsed: 0,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
period,
|
||||||
|
featuresReset: features,
|
||||||
|
redisDeleted,
|
||||||
|
dbUpdated: dbResult.count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUserQuotas(
|
export async function getUserQuotas(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<Record<string, { remaining: number; limit: number; used: number }>> {
|
): Promise<Record<string, { remaining: number; limit: number; used: number }>> {
|
||||||
|
|||||||
58
memento-note/lib/inline-boot-scripts.ts
Normal file
58
memento-note/lib/inline-boot-scripts.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Scripts boot inline dans le <head> (évite next/script + warning React 19
|
||||||
|
* « Scripts inside React components are never executed… »).
|
||||||
|
* Doivent rester synchrones et sans import.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const THEME_INIT_SCRIPT = `(function () {
|
||||||
|
try {
|
||||||
|
var root = document.documentElement;
|
||||||
|
var fallback = root.getAttribute('data-server-theme') || 'light';
|
||||||
|
var defaultAccent = root.getAttribute('data-server-accent') || '#A47148';
|
||||||
|
var stored = localStorage.getItem('theme-preference');
|
||||||
|
var raw = stored || fallback;
|
||||||
|
if (raw === 'slate') raw = 'light';
|
||||||
|
var allowed = {
|
||||||
|
light: 1, dark: 1, auto: 1, sepia: 1, midnight: 1,
|
||||||
|
rose: 1, green: 1, lavender: 1, sand: 1, ocean: 1, sunset: 1, blue: 1
|
||||||
|
};
|
||||||
|
var theme = allowed[raw] ? raw : 'light';
|
||||||
|
try {
|
||||||
|
document.cookie = 'memento-theme=' + encodeURIComponent(theme) + '; path=/; max-age=31536000; SameSite=Lax';
|
||||||
|
if (!stored) localStorage.setItem('theme-preference', theme);
|
||||||
|
} catch (e) {}
|
||||||
|
var wantsDark = false;
|
||||||
|
if (theme === 'auto') {
|
||||||
|
wantsDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
} else if (theme === 'dark' || theme === 'midnight') {
|
||||||
|
wantsDark = true;
|
||||||
|
}
|
||||||
|
if (wantsDark) root.classList.add('dark');
|
||||||
|
else root.classList.remove('dark');
|
||||||
|
if (theme === 'auto' || theme === 'dark' || theme === 'light') {
|
||||||
|
root.removeAttribute('data-theme');
|
||||||
|
} else {
|
||||||
|
root.setAttribute('data-theme', theme);
|
||||||
|
}
|
||||||
|
root.setAttribute('data-applied-theme', theme);
|
||||||
|
var accentStored = localStorage.getItem('accent-color');
|
||||||
|
root.style.setProperty('--color-brand-accent', accentStored || defaultAccent);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Theme script error', e);
|
||||||
|
}
|
||||||
|
})();`
|
||||||
|
|
||||||
|
export const DIRECTION_INIT_SCRIPT = `(function () {
|
||||||
|
try {
|
||||||
|
var lang = localStorage.getItem('user-language');
|
||||||
|
if (!lang) {
|
||||||
|
var c = document.cookie.split(';').map(function (s) { return s.trim(); })
|
||||||
|
.find(function (s) { return s.startsWith('user-language='); });
|
||||||
|
if (c) lang = c.split('=')[1];
|
||||||
|
}
|
||||||
|
if (lang === 'fa' || lang === 'ar') {
|
||||||
|
document.documentElement.dir = 'rtl';
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
})();`
|
||||||
@@ -19,6 +19,9 @@ export const FALLBACK_TIER_LIMITS: Record<
|
|||||||
brainstorm_expand: 10,
|
brainstorm_expand: 10,
|
||||||
brainstorm_enrich: 20,
|
brainstorm_enrich: 20,
|
||||||
suggest_charts: 5,
|
suggest_charts: 5,
|
||||||
|
// Multi-step pipeline charges ~4–9 units per deck → low monthly budget
|
||||||
|
slide_generate: 10,
|
||||||
|
excalidraw_generate: 5,
|
||||||
ai_flashcard: 5,
|
ai_flashcard: 5,
|
||||||
voice_transcribe: 20,
|
voice_transcribe: 20,
|
||||||
publish_enhance: 2,
|
publish_enhance: 2,
|
||||||
@@ -33,8 +36,9 @@ export const FALLBACK_TIER_LIMITS: Record<
|
|||||||
brainstorm_expand: 100,
|
brainstorm_expand: 100,
|
||||||
brainstorm_enrich: 200,
|
brainstorm_enrich: 200,
|
||||||
suggest_charts: 50,
|
suggest_charts: 50,
|
||||||
slide_generate: 20,
|
// ~7 units/deck → ~20 decks/month at 140 units
|
||||||
excalidraw_generate: 20,
|
slide_generate: 140,
|
||||||
|
excalidraw_generate: 40,
|
||||||
ai_flashcard: 100,
|
ai_flashcard: 100,
|
||||||
voice_transcribe: 500,
|
voice_transcribe: 500,
|
||||||
publish_enhance: 15,
|
publish_enhance: 15,
|
||||||
@@ -49,8 +53,9 @@ export const FALLBACK_TIER_LIMITS: Record<
|
|||||||
brainstorm_expand: 500,
|
brainstorm_expand: 500,
|
||||||
brainstorm_enrich: 1000,
|
brainstorm_enrich: 1000,
|
||||||
suggest_charts: 200,
|
suggest_charts: 200,
|
||||||
slide_generate: 100,
|
// ~7 units/deck → ~70 decks/month
|
||||||
excalidraw_generate: 100,
|
slide_generate: 500,
|
||||||
|
excalidraw_generate: 200,
|
||||||
ai_flashcard: 'unlimited',
|
ai_flashcard: 'unlimited',
|
||||||
voice_transcribe: 'unlimited',
|
voice_transcribe: 'unlimited',
|
||||||
publish_enhance: 100,
|
publish_enhance: 100,
|
||||||
|
|||||||
@@ -23,27 +23,29 @@ export const REWRITE_SHARED_CSS = `
|
|||||||
${KATEX_PUBLISH_CSS}
|
${KATEX_PUBLISH_CSS}
|
||||||
/* ─── Résumé introductif ─────────────────────────────── */
|
/* ─── Résumé introductif ─────────────────────────────── */
|
||||||
.pub-rewrite-summary {
|
.pub-rewrite-summary {
|
||||||
font-size: 1.2em;
|
font-size: 1.18em;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: var(--pub-summary-color, #444);
|
color: var(--pub-summary-color, #444);
|
||||||
margin-bottom: 2em;
|
margin-bottom: 2em;
|
||||||
padding-bottom: 1.5em;
|
padding: 0 0 1.5em;
|
||||||
border-bottom: 2px solid var(--pub-accent, #A47148);
|
border-bottom: 2px solid var(--pub-accent, #A47148);
|
||||||
|
text-wrap: pretty;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Exercices ──────────────────────────────────────── */
|
/* ─── Exercices ──────────────────────────────────────── */
|
||||||
.pub-rewrite-body .pub-exercise {
|
.pub-rewrite-body .pub-exercise {
|
||||||
border: 2px solid var(--pub-exercise-border, #e0d4c3);
|
border: 1px solid var(--pub-exercise-border, #e0d4c3);
|
||||||
border-radius: 12px;
|
border-radius: 14px;
|
||||||
margin: 1.75em 0;
|
margin: 1.75em 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
box-shadow: 0 8px 28px rgba(0,0,0,0.04);
|
||||||
}
|
}
|
||||||
.pub-rewrite-body .pub-exercise-header {
|
.pub-rewrite-body .pub-exercise-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px 18px;
|
padding: 12px 18px;
|
||||||
background: var(--pub-exercise-header-bg, #f6f1ea);
|
background: var(--pub-exercise-header-bg, #f6f1ea);
|
||||||
border-bottom: 1px solid var(--pub-exercise-border, #e0d4c3);
|
border-bottom: 1px solid var(--pub-exercise-border, #e0d4c3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,18 +16,18 @@ export function getThemeScript(serverTheme: string = 'light', serverAccentColor:
|
|||||||
var allowed = { light:1, dark:1, auto:1, sepia:1, midnight:1, rose:1, green:1, lavender:1, sand:1, ocean:1, sunset:1, blue:1 };
|
var allowed = { light:1, dark:1, auto:1, sepia:1, midnight:1, rose:1, green:1, lavender:1, sand:1, ocean:1, sunset:1, blue:1 };
|
||||||
var theme = allowed[raw] ? raw : 'light';
|
var theme = allowed[raw] ? raw : 'light';
|
||||||
var root = document.documentElement;
|
var root = document.documentElement;
|
||||||
root.classList.remove('dark');
|
try {
|
||||||
root.removeAttribute('data-theme');
|
document.cookie = 'memento-theme=' + encodeURIComponent(theme) + '; path=/; max-age=31536000; SameSite=Lax';
|
||||||
if (theme === 'auto') {
|
if (!stored) localStorage.setItem('theme-preference', theme);
|
||||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark');
|
} catch (e) {}
|
||||||
} else if (theme === 'dark') {
|
var wantsDark = theme === 'dark' || theme === 'midnight' || (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
root.classList.add('dark');
|
if (wantsDark) root.classList.add('dark');
|
||||||
} else if (theme === 'light') {
|
else root.classList.remove('dark');
|
||||||
/* :root papier */
|
if (theme === 'auto' || theme === 'dark' || theme === 'light') root.removeAttribute('data-theme');
|
||||||
} else {
|
else {
|
||||||
root.setAttribute('data-theme', theme);
|
root.setAttribute('data-theme', theme);
|
||||||
if (theme === 'midnight') root.classList.add('dark');
|
|
||||||
}
|
}
|
||||||
|
root.setAttribute('data-applied-theme', theme);
|
||||||
var accentStored = localStorage.getItem('accent-color');
|
var accentStored = localStorage.getItem('accent-color');
|
||||||
var effectiveAccent = accentStored || ${JSON.stringify(defaultAccent)};
|
var effectiveAccent = accentStored || ${JSON.stringify(defaultAccent)};
|
||||||
root.style.setProperty('--color-brand-accent', effectiveAccent);
|
root.style.setProperty('--color-brand-accent', effectiveAccent);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -336,7 +336,9 @@
|
|||||||
"heading2": "Heading 2",
|
"heading2": "Heading 2",
|
||||||
"heading3": "Heading 3",
|
"heading3": "Heading 3",
|
||||||
"importSuccess": "{{count}} notes imported!",
|
"importSuccess": "{{count}} notes imported!",
|
||||||
"unpublished": "Note unpublished"
|
"unpublished": "Note unpublished",
|
||||||
|
"deleteError": "Failed to delete illustration",
|
||||||
|
"uploadSuccess": "Image added"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"previous": "←",
|
"previous": "←",
|
||||||
@@ -621,6 +623,24 @@
|
|||||||
"templateProject": "Project Status",
|
"templateProject": "Project Status",
|
||||||
"templateStrategy": "Strategy Review",
|
"templateStrategy": "Strategy Review",
|
||||||
"templateQuarterly": "Quarterly Results",
|
"templateQuarterly": "Quarterly Results",
|
||||||
|
"purpose": "Deck type",
|
||||||
|
"purposeAuto": "Auto (from note)",
|
||||||
|
"purposeCourse": "Lesson / course",
|
||||||
|
"purposeBoard": "Board update",
|
||||||
|
"purposeProject": "Project status",
|
||||||
|
"purposeStrategy": "Strategy",
|
||||||
|
"purposePitch": "Pitch",
|
||||||
|
"purposeSummary": "Short summary",
|
||||||
|
"audience": "Audience",
|
||||||
|
"audienceAuto": "Auto",
|
||||||
|
"audienceStudent": "Students",
|
||||||
|
"audienceBoard": "Leadership",
|
||||||
|
"audienceTeam": "Team",
|
||||||
|
"audienceClient": "Client",
|
||||||
|
"audienceGeneral": "General public",
|
||||||
|
"slideCount": "Slide count",
|
||||||
|
"slideCountAuto": "Auto (from length)",
|
||||||
|
"creditCostEstimate": "Estimated cost: {cost} credits (1 outline + {slides} slides).",
|
||||||
"diagram": "Generate Diagram",
|
"diagram": "Generate Diagram",
|
||||||
"diagramReadyHint": "Convert note into visual flow",
|
"diagramReadyHint": "Convert note into visual flow",
|
||||||
"diagramType": "Diagram Type",
|
"diagramType": "Diagram Type",
|
||||||
@@ -741,7 +761,7 @@
|
|||||||
"inlineSummaryMarkdown": "**Summary:**",
|
"inlineSummaryMarkdown": "**Summary:**",
|
||||||
"networkErrorShort": "Network error.",
|
"networkErrorShort": "Network error.",
|
||||||
"featureLocked": "This feature requires the PRO plan or higher.",
|
"featureLocked": "This feature requires the PRO plan or higher.",
|
||||||
"quotaExceeded": "Monthly limit reached. Resets next month.",
|
"quotaExceeded": "Not enough AI credits. Upgrade your plan, or use your own API key.",
|
||||||
"noHistory": "No history",
|
"noHistory": "No history",
|
||||||
"webSearchDisabled": "Web search disabled",
|
"webSearchDisabled": "Web search disabled",
|
||||||
"webSearchEnabled": "Web search enabled",
|
"webSearchEnabled": "Web search enabled",
|
||||||
@@ -908,7 +928,8 @@
|
|||||||
"systemNotification": "System",
|
"systemNotification": "System",
|
||||||
"clipSaved": "Web clip saved",
|
"clipSaved": "Web clip saved",
|
||||||
"downloadFailed": "Download failed",
|
"downloadFailed": "Download failed",
|
||||||
"brainstormShared": "Brainstorm shared"
|
"brainstormShared": "Brainstorm shared",
|
||||||
|
"shareLabel": "Share"
|
||||||
},
|
},
|
||||||
"clipper": {
|
"clipper": {
|
||||||
"webClipper": "Web Clipper",
|
"webClipper": "Web Clipper",
|
||||||
@@ -1297,6 +1318,10 @@
|
|||||||
"embeddingsDescription": "AI provider for semantic search embeddings. Recommended: OpenAI (best quality).",
|
"embeddingsDescription": "AI provider for semantic search embeddings. Recommended: OpenAI (best quality).",
|
||||||
"chatProvider": "Chat Provider",
|
"chatProvider": "Chat Provider",
|
||||||
"chatDescription": "AI provider for the chat assistant. Falls back to Tags provider if not configured.",
|
"chatDescription": "AI provider for the chat assistant. Falls back to Tags provider if not configured.",
|
||||||
|
"slidesTab": "Slides",
|
||||||
|
"slidesProvider": "Slides model",
|
||||||
|
"slidesDescription": "Dedicated provider and model for presentation generation. If the model is empty, the Chat model is used. Model IDs are never renamed automatically. The multi-step pipeline uses several credits from the global AI balance (1 outline + 1 credit per slide).",
|
||||||
|
"slidesQuotaNote": "Credit cost: 1 + slide count (e.g. 7 slides ≈ 8 credits), taken from the single balance (BASIC 100 · PRO 1,000 · BUSINESS 4,000 / month). Admin → Users to reset a user's balance.",
|
||||||
"provider": "Provider",
|
"provider": "Provider",
|
||||||
"baseUrl": "Base URL",
|
"baseUrl": "Base URL",
|
||||||
"model": "Model",
|
"model": "Model",
|
||||||
@@ -1433,6 +1458,10 @@
|
|||||||
"roleUpdateFailed": "Failed to update role",
|
"roleUpdateFailed": "Failed to update role",
|
||||||
"tierUpdateSuccess": "Subscription updated to {tier}",
|
"tierUpdateSuccess": "Subscription updated to {tier}",
|
||||||
"tierUpdateFailed": "Failed to update subscription",
|
"tierUpdateFailed": "Failed to update subscription",
|
||||||
|
"resetQuotas": "Reset AI credits (current month)",
|
||||||
|
"confirmResetQuotas": "Reset the AI credit balance to the plan allocation for {user} (BASIC 100 · PRO 1,000 · BUSINESS 4,000)? All AI features become available again within that balance.",
|
||||||
|
"resetQuotasSuccess": "AI credits reset for period {period} ({count} counters).",
|
||||||
|
"resetQuotasFailed": "Failed to reset credits",
|
||||||
"demote": "Demote to User",
|
"demote": "Demote to User",
|
||||||
"promote": "Promote to Admin",
|
"promote": "Promote to Admin",
|
||||||
"confirmDelete": "Are you sure? This action cannot be undone.",
|
"confirmDelete": "Are you sure? This action cannot be undone.",
|
||||||
@@ -1531,31 +1560,32 @@
|
|||||||
},
|
},
|
||||||
"settingsDescription": "Configure application-wide settings",
|
"settingsDescription": "Configure application-wide settings",
|
||||||
"billing": {
|
"billing": {
|
||||||
"title": "Billing & Quotas",
|
"title": "Billing & AI credits",
|
||||||
"description": "Manage subscription tiers, AI quotas, and Stripe business settings without redeploying.",
|
"description": "Stripe settings, monthly credit allocations by tier, and advanced tools. User spend follows the single credit balance.",
|
||||||
"stripeConfigTitle": "Stripe business config",
|
"stripeConfigTitle": "Stripe business config",
|
||||||
"stripeConfigDescription": "Price IDs and billing toggle. Secret keys stay in server environment only.",
|
"stripeConfigDescription": "Price IDs and billing toggle. Secret keys stay in server environment only.",
|
||||||
"enableBilling": "Enable billing UI and checkout",
|
"enableBilling": "Enable billing UI and checkout",
|
||||||
|
"subscriptionPricesTitle": "Subscriptions (price IDs)",
|
||||||
"STRIPE_PRICE_PRO_MONTHLY": "Pro — monthly price ID",
|
"STRIPE_PRICE_PRO_MONTHLY": "Pro — monthly price ID",
|
||||||
"STRIPE_PRICE_PRO_ANNUAL": "Pro — annual price ID",
|
"STRIPE_PRICE_PRO_ANNUAL": "Pro — annual price ID",
|
||||||
"STRIPE_PRICE_BUSINESS_MONTHLY": "Business — monthly price ID",
|
"STRIPE_PRICE_BUSINESS_MONTHLY": "Business — monthly price ID",
|
||||||
"STRIPE_PRICE_BUSINESS_ANNUAL": "Business — annual price ID",
|
"STRIPE_PRICE_BUSINESS_ANNUAL": "Business — annual price ID",
|
||||||
|
"packPricesTitle": "Credit packs (one-shot price IDs)",
|
||||||
|
"packPricesDescription": "Stripe one-time payment products (payment mode). Purchased credits add to the balance and roll over across months.",
|
||||||
|
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
||||||
|
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
||||||
|
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
||||||
|
"packsCatalogTitle": "Pack catalogue (code)",
|
||||||
"secretsNote": "STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET must remain in Docker/env secrets — never stored here.",
|
"secretsNote": "STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET must remain in Docker/env secrets — never stored here.",
|
||||||
"saveConfig": "Save billing config",
|
"saveConfig": "Save billing config",
|
||||||
"configSaved": "Billing configuration saved",
|
"configSaved": "Billing configuration saved",
|
||||||
"configFailed": "Failed to save billing configuration",
|
"configFailed": "Failed to save billing configuration",
|
||||||
"limitsTitle": "Tier quotas",
|
"creditsTitle": "AI credits by tier (option M)",
|
||||||
"limitsDescription": "Monthly request limits per feature. Changes apply within ~60 seconds.",
|
"creditsDescription": "Source of truth for billing usage: one balance for all AI actions. Values live in code (lib/credits.ts).",
|
||||||
"feature": "Feature",
|
"creditsPerMonth": "credits / month",
|
||||||
"mode": "Access",
|
"creditsCostsNote": "Typical costs: search, chat, tags = 1 credit; diagram = 4; AI publish = 3; presentation = 1 + slide count (e.g. 7 slides ≈ 8 credits).",
|
||||||
"monthlyLimit": "Monthly limit",
|
"creditsResetHint": "To refill a user: Admin → Users → reset AI credits button.",
|
||||||
"actions": "Actions",
|
|
||||||
"modeUnavailable": "Not available",
|
|
||||||
"modeLimited": "Limited",
|
|
||||||
"modeUnlimited": "Unlimited",
|
"modeUnlimited": "Unlimited",
|
||||||
"saveLimit": "Save",
|
|
||||||
"limitSaved": "Quota updated",
|
|
||||||
"limitFailed": "Failed to update quota",
|
|
||||||
"usageTitle": "Usage overview",
|
"usageTitle": "Usage overview",
|
||||||
"usagePeriod": "Period {period}",
|
"usagePeriod": "Period {period}",
|
||||||
"lastSync": "Last sync {date}",
|
"lastSync": "Last sync {date}",
|
||||||
@@ -1747,8 +1777,8 @@
|
|||||||
"accentColorDescription": "Set the main color of your workspace"
|
"accentColorDescription": "Set the main color of your workspace"
|
||||||
},
|
},
|
||||||
"usageMeter": {
|
"usageMeter": {
|
||||||
"packName": "AI Discovery Pack",
|
"packName": "Basic plan",
|
||||||
"packPro": "Pro Pack",
|
"packPro": "Pro plan",
|
||||||
"packBusiness": "Business Pack",
|
"packBusiness": "Business Pack",
|
||||||
"packEnterprise": "Enterprise Pack",
|
"packEnterprise": "Enterprise Pack",
|
||||||
"featureSearch": "Search",
|
"featureSearch": "Search",
|
||||||
@@ -1757,13 +1787,13 @@
|
|||||||
"unlimited": "Unlimited",
|
"unlimited": "Unlimited",
|
||||||
"remaining": "{count} left",
|
"remaining": "{count} left",
|
||||||
"upgradeTitle": "Upgrade to Pro",
|
"upgradeTitle": "Upgrade to Pro",
|
||||||
"upgradeDescription": "You've used all your AI Discovery Pack credits. Upgrade to Pro for higher limits and additional features.",
|
"upgradeDescription": "Your AI credit balance is empty. Upgrade for more monthly credits, or use your own API key (BYOK).",
|
||||||
"proIncludes": "Pro includes:",
|
"proIncludes": "Pro includes:",
|
||||||
"proSearch": "100 semantic searches / month",
|
"proSearch": "1,000 AI credits / month",
|
||||||
"proTags": "200 auto-tags / month",
|
"proTags": "BYOK (your own keys)",
|
||||||
"proTitles": "200 auto-titles / month",
|
"proTitles": "Agents (use credits)",
|
||||||
"proReformulate": "50 reformulations / month",
|
"proReformulate": "Unlimited notes",
|
||||||
"proChat": "100 chat messages / month",
|
"proChat": "Email support",
|
||||||
"later": "Later",
|
"later": "Later",
|
||||||
"upgradePricing": "Upgrade to Pro",
|
"upgradePricing": "Upgrade to Pro",
|
||||||
"addApiKey": "Use your own API key (BYOK)",
|
"addApiKey": "Use your own API key (BYOK)",
|
||||||
@@ -1777,8 +1807,15 @@
|
|||||||
"featureBrainstormEnrich": "Brainstorm enrichments",
|
"featureBrainstormEnrich": "Brainstorm enrichments",
|
||||||
"featureFlashcards": "AI flashcards",
|
"featureFlashcards": "AI flashcards",
|
||||||
"featureVoice": "Voice transcription",
|
"featureVoice": "Voice transcription",
|
||||||
"featureSlides": "Slide generation",
|
"featureSlides": "Presentations",
|
||||||
"featureDiagrams": "Diagram generation"
|
"featureDiagrams": "Diagrams",
|
||||||
|
"creditsRemaining": "Credits remaining",
|
||||||
|
"creditsWhereTitle": "Credit usage by feature",
|
||||||
|
"creditsSummary": "{remaining} left · {spent} used of {granted} from plan",
|
||||||
|
"creditsUsedLine": "{credits} cr. · {actions}×",
|
||||||
|
"actionsOnly": "{actions} actions",
|
||||||
|
"noCreditUsage": "No credit usage this month",
|
||||||
|
"outOfCredits": "Out of credits — options"
|
||||||
},
|
},
|
||||||
"generalSettings": {
|
"generalSettings": {
|
||||||
"title": "General",
|
"title": "General",
|
||||||
@@ -2438,8 +2475,8 @@
|
|||||||
"noNotesFoundForContext": "No relevant notes found for this question. Answer with your general knowledge.",
|
"noNotesFoundForContext": "No relevant notes found for this question. Answer with your general knowledge.",
|
||||||
"webSearch": "Web Search",
|
"webSearch": "Web Search",
|
||||||
"timeoutWarning": "Response is taking longer than expected...",
|
"timeoutWarning": "Response is taking longer than expected...",
|
||||||
"quotaExceededBasic": "AI Chat is available from the PRO plan onwards.",
|
"quotaExceededBasic": "AI chat requires the PRO plan or higher (or use your own API key).",
|
||||||
"quotaExceededTier": "Monthly quota reached for {tier} plan. It will reset next month."
|
"quotaExceededTier": "AI credit balance empty on the {tier} plan. Upgrade or use your own API key."
|
||||||
},
|
},
|
||||||
"labHeader": {
|
"labHeader": {
|
||||||
"title": "The Workshop",
|
"title": "The Workshop",
|
||||||
@@ -2673,6 +2710,7 @@
|
|||||||
"publishSimpleHint": "Free — your note content as-is",
|
"publishSimpleHint": "Free — your note content as-is",
|
||||||
"publishWithAi": "Publish with AI",
|
"publishWithAi": "Publish with AI",
|
||||||
"publishWithAiHint": "Enhanced layout · {count} credits left",
|
"publishWithAiHint": "Enhanced layout · {count} credits left",
|
||||||
|
"publishWithAiCostHint": "Estimated cost: 3 AI credits",
|
||||||
"publishWithAiLocked": "Available on Pro plan",
|
"publishWithAiLocked": "Available on Pro plan",
|
||||||
"publishWithAiGenerating": "Generating layout…",
|
"publishWithAiGenerating": "Generating layout…",
|
||||||
"publishTemplateMagazine": "Magazine article",
|
"publishTemplateMagazine": "Magazine article",
|
||||||
@@ -2784,7 +2822,8 @@
|
|||||||
"slashVideo": "Video",
|
"slashVideo": "Video",
|
||||||
"startVoice": "Start voice input",
|
"startVoice": "Start voice input",
|
||||||
"stopVoice": "Stop voice input",
|
"stopVoice": "Stop voice input",
|
||||||
"collapsibleSection": "Collapsible section"
|
"collapsibleSection": "Collapsible section",
|
||||||
|
"deleteImage": "Delete image"
|
||||||
},
|
},
|
||||||
"flashcards": {
|
"flashcards": {
|
||||||
"generateTitle": "Generate flashcards",
|
"generateTitle": "Generate flashcards",
|
||||||
@@ -3129,8 +3168,8 @@
|
|||||||
"seedNodeBadge": "SEED",
|
"seedNodeBadge": "SEED",
|
||||||
"originalSeedDescription": "Original seed idea",
|
"originalSeedDescription": "Original seed idea",
|
||||||
"convertedToNoteStatus": "Converted to note",
|
"convertedToNoteStatus": "Converted to note",
|
||||||
"quotaGuest": "The session host has reached their AI limit. Ask them to upgrade their plan.",
|
"quotaGuest": "The session host has no AI credits left. Ask them to upgrade or use their own API key.",
|
||||||
"quotaHost": "You've reached your AI limit for this brainstorm. Upgrade your plan to continue.",
|
"quotaHost": "No AI credits left for this brainstorm. Upgrade your plan or use your own API key to continue.",
|
||||||
"toastExpandSuccess": "Ideas expanded!",
|
"toastExpandSuccess": "Ideas expanded!",
|
||||||
"toastExpandFailed": "Failed to expand",
|
"toastExpandFailed": "Failed to expand",
|
||||||
"toastDismissSuccess": "Idea dismissed",
|
"toastDismissSuccess": "Idea dismissed",
|
||||||
@@ -3176,7 +3215,7 @@
|
|||||||
},
|
},
|
||||||
"byokSettings": {
|
"byokSettings": {
|
||||||
"title": "Your API keys (BYOK)",
|
"title": "Your API keys (BYOK)",
|
||||||
"description": "Connect your own LLM provider keys to bypass Discovery Pack quotas. Keys are encrypted at rest.",
|
"description": "Connect your own provider keys. With BYOK, usage typically does not consume your Memento credits. Keys are encrypted at rest.",
|
||||||
"badgeActive": "BYOK active",
|
"badgeActive": "BYOK active",
|
||||||
"tierRequired": "BYOK requires a Pro plan or higher. Upgrade to connect your API keys.",
|
"tierRequired": "BYOK requires a Pro plan or higher. Upgrade to connect your API keys.",
|
||||||
"provider": "Provider",
|
"provider": "Provider",
|
||||||
@@ -3231,16 +3270,16 @@
|
|||||||
"businessPrice": "€29.90",
|
"businessPrice": "€29.90",
|
||||||
"proAnnualPrice": "€99",
|
"proAnnualPrice": "€99",
|
||||||
"businessAnnualPrice": "€299",
|
"businessAnnualPrice": "€299",
|
||||||
"proFeature1": "100 semantic searches / month",
|
"proFeature1": "Unlimited notes",
|
||||||
"proFeature2": "200 auto-tags / month",
|
"proFeature2": "1,000 AI credits / month",
|
||||||
"proFeature3": "50 reformulations / month",
|
"proFeature3": "BYOK (your own keys)",
|
||||||
"proFeature4": "100 chat messages / month",
|
"proFeature4": "Agents (use credits)",
|
||||||
"businessFeature1": "1,000 semantic searches / month",
|
"businessFeature1": "10 collaborators included",
|
||||||
"businessFeature2": "1,000 auto-tags / month",
|
"businessFeature2": "4,000 AI credits / month",
|
||||||
"businessFeature3": "500 reformulations / month",
|
"businessFeature3": "BYOK · 13 providers",
|
||||||
"businessFeature4": "1,000 chat messages / month",
|
"businessFeature4": "Agents & brainstorm (credits)",
|
||||||
"enterpriseTitle": "Enterprise",
|
"enterpriseTitle": "Enterprise",
|
||||||
"enterpriseDescription": "Custom quotas, SSO, priority support.",
|
"enterpriseDescription": "Unlimited credits or dedicated pool, SSO, priority support.",
|
||||||
"contactSales": "Contact us",
|
"contactSales": "Contact us",
|
||||||
"startCheckout": "Get Started",
|
"startCheckout": "Get Started",
|
||||||
"checkoutLoading": "Loading checkout…",
|
"checkoutLoading": "Loading checkout…",
|
||||||
@@ -3257,6 +3296,19 @@
|
|||||||
"periodRange": "Period",
|
"periodRange": "Period",
|
||||||
"unlimited": "Unlimited",
|
"unlimited": "Unlimited",
|
||||||
"noUsage": "No usage data",
|
"noUsage": "No usage data",
|
||||||
|
"creditsRemaining": "Credits remaining",
|
||||||
|
"creditsSpent": "Credits used this month",
|
||||||
|
"creditsFromPacks": "Pack credits",
|
||||||
|
"packsTitle": "Buy credits",
|
||||||
|
"packsDescription": "One-time packs. Purchased credits add to your balance and roll over across months (unlike the subscription allocation).",
|
||||||
|
"packSName": "Starter pack",
|
||||||
|
"packMName": "Standard pack",
|
||||||
|
"packLName": "Power pack",
|
||||||
|
"buyPack": "Buy",
|
||||||
|
"packCheckoutSuccess": "Credit pack added to your balance!",
|
||||||
|
"packCheckoutFailed": "Could not start pack purchase. Check Stripe config or try again.",
|
||||||
|
"creditsWhereTitle": "Credit usage by feature",
|
||||||
|
"creditsUsageLegend": "Every AI action draws from the same credit balance (search, chat, tags, slides, diagrams, etc.).",
|
||||||
"billingHistory": "Billing History",
|
"billingHistory": "Billing History",
|
||||||
"viewInvoices": "Manage invoices in portal",
|
"viewInvoices": "Manage invoices in portal",
|
||||||
"cancelSubscription": "Cancel subscription",
|
"cancelSubscription": "Cancel subscription",
|
||||||
@@ -3277,26 +3329,26 @@
|
|||||||
"renewal": "Renewal",
|
"renewal": "Renewal",
|
||||||
"paidPlanDesc": "Your subscription renews automatically.",
|
"paidPlanDesc": "Your subscription renews automatically.",
|
||||||
"businessDescription": "For teams and product leaders.",
|
"businessDescription": "For teams and product leaders.",
|
||||||
"enterpriseFeature1": "Unlimited quotas",
|
"enterpriseFeature1": "Unlimited AI credits or dedicated pool",
|
||||||
"enterpriseFeature2": "SSO / SAML",
|
"enterpriseFeature2": "SSO / SAML",
|
||||||
"enterpriseFeature3": "Dedicated support",
|
"enterpriseFeature3": "Dedicated support",
|
||||||
"enterpriseFeature4": "Custom invoicing",
|
"enterpriseFeature4": "Custom invoicing",
|
||||||
"enterpriseFeature5": "Guaranteed SLA",
|
"enterpriseFeature5": "Guaranteed SLA",
|
||||||
"subtitle": "Choose the plan that suits you",
|
"subtitle": "Choose the plan that suits you",
|
||||||
"freeDescription": "To discover Memento",
|
"freeDescription": "To discover Memento",
|
||||||
"freeF1": "30 semantic searches",
|
"freeF1": "Up to 100 notes",
|
||||||
"freeF2": "15 auto tags",
|
"freeF2": "3 notebooks",
|
||||||
"freeF3": "5 auto titles",
|
"freeF3": "100 AI credits / month",
|
||||||
"freeF4": "100 notes",
|
"freeF4": "Search, chat, tags (via credits)",
|
||||||
"freeF5": "3 notebooks",
|
"freeF5": "7-day history",
|
||||||
"freePlanDesc": "Perfect to discover Memento",
|
"freePlanDesc": "Perfect to discover Memento",
|
||||||
"freePrice": "Free",
|
"freePrice": "Free",
|
||||||
"proDescription": "For consultants and freelancers",
|
"proDescription": "For consultants and freelancers",
|
||||||
"proFeature5": "50 reformulations/month",
|
"proFeature5": "30-day history",
|
||||||
"proFeature6": "50 AI messages/month",
|
"proFeature6": "Email support",
|
||||||
"proCta": "Upgrade to Pro",
|
"proCta": "Upgrade to Pro",
|
||||||
"businessFeature5": "500 reformulations/month",
|
"businessFeature5": "API / MCP",
|
||||||
"businessFeature6": "500 AI messages/month",
|
"businessFeature6": "Priority support",
|
||||||
"businessCta": "Upgrade to Business",
|
"businessCta": "Upgrade to Business",
|
||||||
"recommended": "Recommended",
|
"recommended": "Recommended",
|
||||||
"instantActivation": "Instant activation",
|
"instantActivation": "Instant activation",
|
||||||
@@ -3316,214 +3368,232 @@
|
|||||||
"cancelFailed": "Failed to cancel subscription.",
|
"cancelFailed": "Failed to cancel subscription.",
|
||||||
"downloadPdf": "Download PDF",
|
"downloadPdf": "Download PDF",
|
||||||
"fetchStatusFailed": "Failed to fetch billing status",
|
"fetchStatusFailed": "Failed to fetch billing status",
|
||||||
"fetchQuotasFailed": "Failed to fetch quotas",
|
"fetchQuotasFailed": "Failed to load credit usage",
|
||||||
"fetchInvoicesFailed": "Failed to load billing history.",
|
"fetchInvoicesFailed": "Failed to load billing history.",
|
||||||
"savePercent": "Save ~17%"
|
"savePercent": "Save ~17%"
|
||||||
},
|
},
|
||||||
"quotaPaywall": {
|
"quotaPaywall": {
|
||||||
"title": "Monthly limit reached",
|
"title": "Out of AI credits",
|
||||||
"description": "You've used all your {feature} credits for this month.",
|
"description": "Your credit balance is too low for this action ({feature}). All AI actions share the same balance. Upgrade your plan or use your own API key.",
|
||||||
"upgrade": "Upgrade plan",
|
"descriptionWithCost": "You have {remaining} credit(s) left; this action ({feature}) costs {cost}. Upgrade your plan or use your own API key.",
|
||||||
"useOwnKey": "Use your own API key",
|
"upgrade": "View plans",
|
||||||
"later": "Maybe later",
|
"useOwnKey": "Use my own key",
|
||||||
|
"later": "Later",
|
||||||
"closingIn": "Closing in {n}s"
|
"closingIn": "Closing in {n}s"
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"nav": {
|
"nav": {
|
||||||
"features": "Features",
|
"products": "Product",
|
||||||
"agents": "AI Agents",
|
"secondBrain": "Second Brain",
|
||||||
"brainstorm": "Brainstorm",
|
"echo": "Memory Echo",
|
||||||
|
"agents": "Agents",
|
||||||
"pricing": "Pricing",
|
"pricing": "Pricing",
|
||||||
"tech": "Architecture",
|
|
||||||
"login": "Log in",
|
"login": "Log in",
|
||||||
"cta": "Get started"
|
"cta": "Start free",
|
||||||
|
"openMenu": "Open menu",
|
||||||
|
"closeMenu": "Close menu",
|
||||||
|
"language": "Language"
|
||||||
},
|
},
|
||||||
"hero": {
|
"hero": {
|
||||||
"badge": "Powered by Artificial Intelligence",
|
"badge": "Second Brain",
|
||||||
"title1": "Your second brain,",
|
"eyebrow": "Not another notes app — a living second brain",
|
||||||
"title2": "finally amplified.",
|
"headline": "Your second brain.",
|
||||||
"subtitle": "Memento is more than a note-taking app. It's an intelligent ecosystem that connects, analyzes and develops your ideas in real time with 6 types of AI agents and cutting-edge semantic search.",
|
"headlineAccent": "It remembers what you forgot.",
|
||||||
"cta": "Sign up now",
|
"subtitle": "Memento is your Second Brain: it connects ideas across months, surfaces hidden links, and turns notebooks into a system you can search, brief, and act on every morning.",
|
||||||
"secondary": "See features",
|
"cta": "Build my second brain",
|
||||||
"memoryEcho": "Memory Echo",
|
"ctaHint": "Free to start · Google sign-in · No credit card",
|
||||||
"memoryEchoText": "\"Connection detected with your sustainable design project from March 2024...\"",
|
"imageAlt": "Memento Second Brain workspace",
|
||||||
"brainstormLive": "Brainstorm Live",
|
"echoLive": "Memory Echo",
|
||||||
"ideasGenerated": "+12 ideas generated"
|
"echo0": "\"Linked to your March design brief — same constraint, new angle.\"",
|
||||||
|
"echo1": "\"This echoes a research note from 8 months ago. Want to open it?\"",
|
||||||
|
"echo2": "\"Three notebooks touch this idea. Here's the bridge note.\""
|
||||||
},
|
},
|
||||||
"features": {
|
"trust": {
|
||||||
"label": "AI Capabilities",
|
"trust0": "Your Second Brain, not a folder dump",
|
||||||
"title": "Fluid intelligence,",
|
"trust1": "Bring your own AI keys",
|
||||||
"title2": "woven into every word.",
|
"trust2": "15 languages, including Persian RTL",
|
||||||
"desc": "Memento orchestrates your ideas through a multi-provider architecture.",
|
"trust3": "Agents that write into your notebooks"
|
||||||
"f1Title": "Semantic Search",
|
},
|
||||||
"f1Desc": "Stop searching by keywords. Find by concept. Our hybrid Vector + FTS engine understands the intent behind your notes.",
|
"pain": {
|
||||||
"f2Title": "Contextual RAG Chat",
|
"label": "The real problem",
|
||||||
"f2Desc": "Chat with your knowledge. Our agents read your notes, explore the web and analyze your documents to respond with precision.",
|
"title": "You don't need more notes. You need a second brain.",
|
||||||
"f3Title": "Augmented Writing",
|
"desc": "Folders fill up. Search fails. Ideas die in silence. A Second Brain finds the threads between what you capture — so yesterday's thinking powers tomorrow's decisions.",
|
||||||
"f3Desc": "Reformulation, title suggestions, auto-tagging and summaries. AI works in the background to structure your thinking."
|
"secondBrain": "That's Memento: a Second Brain that connects itself while you write."
|
||||||
|
},
|
||||||
|
"moments": {
|
||||||
|
"echo": {
|
||||||
|
"eyebrow": "Memory Echo",
|
||||||
|
"title": "The moment your Second Brain talks back.",
|
||||||
|
"desc": "While you write, Memento detects semantic links across notebooks — not keyword matches, real conceptual bridges you can open in a split peek.",
|
||||||
|
"card0Label": "Just detected",
|
||||||
|
"card0": "\"Your note on pricing mirrors the competitor teardown from last autumn.\"",
|
||||||
|
"card1Label": "Bridge",
|
||||||
|
"card1": "Shared theme: positioning under constraint",
|
||||||
|
"card2Label": "Action",
|
||||||
|
"card2": "Open both notes side by side — keep writing"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"eyebrow": "Second Brain dashboard",
|
||||||
|
"title": "A morning that tells you what matters.",
|
||||||
|
"desc": "Your Second Brain briefing: next paths, checklist, discoveries, revisions — widgets you arrange yourself.",
|
||||||
|
"w0Label": "Next paths",
|
||||||
|
"w0": "3 actions from your last note",
|
||||||
|
"w1Label": "Daily review",
|
||||||
|
"w1": "Inbox · AI · Flashcards",
|
||||||
|
"w2Label": "Memory Echo",
|
||||||
|
"w2": "2 new connections overnight",
|
||||||
|
"w3Label": "Agents",
|
||||||
|
"w3": "1 suggestion ready to run"
|
||||||
|
},
|
||||||
|
"insights": {
|
||||||
|
"eyebrow": "Insights",
|
||||||
|
"title": "See the architecture of your Second Brain.",
|
||||||
|
"desc": "Semantic clusters and bridge notes — a living map of how your knowledge actually connects.",
|
||||||
|
"chip": "Cluster network"
|
||||||
|
},
|
||||||
|
"revision": {
|
||||||
|
"eyebrow": "Revision",
|
||||||
|
"title": "Remember on purpose.",
|
||||||
|
"desc": "Generate flashcards from any note. SM-2 spaced repetition built in — knowledge that sticks in your Second Brain.",
|
||||||
|
"card": "What connects Memory Echo to your notes?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"how": {
|
||||||
|
"title": "Three steps. Then your Second Brain compounds.",
|
||||||
|
"s0": {
|
||||||
|
"title": "Capture freely",
|
||||||
|
"desc": "Write in a next-gen editor — blocks, structured views, smart paste. Clip the web when you need it."
|
||||||
|
},
|
||||||
|
"s1": {
|
||||||
|
"title": "Let it connect",
|
||||||
|
"desc": "Memory Echo and semantic search weave links you didn't plan. Your past work becomes fuel."
|
||||||
|
},
|
||||||
|
"s2": {
|
||||||
|
"title": "Act every morning",
|
||||||
|
"desc": "Dashboard paths, agents, and flashcards turn a pile of notes into Second Brain momentum."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"agents": {
|
"agents": {
|
||||||
"label": "Specialized Agents",
|
"label": "AI agents",
|
||||||
"title": "Delegate the complex work.",
|
"title": "Delegate the heavy lifting.",
|
||||||
"desc": "6 types of autonomous AI agents to automate your research, summaries and presentations.",
|
"desc": "Research, scrape, slides, diagrams, monitoring — agents that write into your Second Brain, not another chat tab you'll forget.",
|
||||||
"scraper": {
|
"scraper": {
|
||||||
"title": "Scraper",
|
"title": "Scraper",
|
||||||
"desc": "Scrapes URLs, parses RSS feeds and synthesizes information with smart image placement."
|
"desc": "URLs & RSS → synthesized notes with smart images."
|
||||||
},
|
},
|
||||||
"researcher": {
|
"researcher": {
|
||||||
"title": "Researcher",
|
"title": "Researcher",
|
||||||
"desc": "Generates complex queries, explores web sources and writes structured research notes."
|
"desc": "Deep queries, sources, structured research notes."
|
||||||
},
|
},
|
||||||
"slideGen": {
|
"slideGen": {
|
||||||
"title": "Slide Gen",
|
"title": "Slide Gen",
|
||||||
"desc": "Transforms your notes into professional PowerPoint presentations or interactive HTML Slides."
|
"desc": "Notes → decks or interactive HTML slides."
|
||||||
},
|
},
|
||||||
"monitor": {
|
"monitor": {
|
||||||
"title": "Monitor",
|
"title": "Monitor",
|
||||||
"desc": "Continuously analyzes your notebooks to detect trends and new insights."
|
"desc": "Watch notebooks for trends and new insights."
|
||||||
},
|
},
|
||||||
"diagramGen": {
|
"diagramGen": {
|
||||||
"title": "Diagram Gen",
|
"title": "Diagram Gen",
|
||||||
"desc": "Converts your ideas into fluid Excalidraw diagrams (Mindmaps, Flowcharts) with auto-layout."
|
"desc": "Ideas → Excalidraw mind maps & flows."
|
||||||
},
|
},
|
||||||
"custom": {
|
"custom": {
|
||||||
"title": "Custom",
|
"title": "Custom",
|
||||||
"desc": "Define your own agents with specific roles and data sources."
|
"desc": "Your roles, sources, and schedules."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"brainstorm": {
|
"byok": {
|
||||||
"label": "Thought Waves",
|
"label": "No lock-in",
|
||||||
"title": "Real-time radial brainstorming.",
|
"title": "Your keys. Your models. Your Second Brain.",
|
||||||
"waveGeneration": {
|
"desc": "Use Memento credits or plug OpenAI, Anthropic, Google and more. Switch providers in a click — the product stays yours."
|
||||||
"title": "Wave Generation",
|
|
||||||
"desc": "Variations, Analogies, then Disruptions. AI pushes your initial concept to its limits."
|
|
||||||
},
|
|
||||||
"collaboration": {
|
|
||||||
"title": "Native Collaboration",
|
|
||||||
"desc": "AI ghost cursors, synced avatars and real-time node movement."
|
|
||||||
},
|
|
||||||
"export": {
|
|
||||||
"title": "Semantic Export",
|
|
||||||
"desc": "Convert your entire brainstorm into structured notes in one click."
|
|
||||||
},
|
|
||||||
"disruptionLabel": "DISRUPTION",
|
|
||||||
"disruptionText": "Modular Architecture 2.0",
|
|
||||||
"analogyLabel": "ANALOGY",
|
|
||||||
"analogyText": "The tidal cycle"
|
|
||||||
},
|
|
||||||
"tech": {
|
|
||||||
"label": "Architecture & Providers",
|
|
||||||
"title": "Connect your own AI model.",
|
|
||||||
"tags": {
|
|
||||||
"title": "Tags",
|
|
||||||
"desc": "Independently configurable with any model."
|
|
||||||
},
|
|
||||||
"embeddings": {
|
|
||||||
"title": "Embeddings",
|
|
||||||
"desc": "Independently configurable with any model."
|
|
||||||
},
|
|
||||||
"chatRag": {
|
|
||||||
"title": "Chat RAG",
|
|
||||||
"desc": "Independently configurable with any model."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
"label": "Plans & Pricing",
|
"label": "Pricing",
|
||||||
"title": "Choose your level of amplification.",
|
"title": "Start free. Grow your Second Brain when it clicks.",
|
||||||
"desc": "Flexible options for creative minds, from individual use to large organizations.",
|
"desc": "Clear tiers — from first notes to organizational memory.",
|
||||||
"monthly": "Monthly",
|
"monthly": "Monthly",
|
||||||
"annual": "Annual",
|
"annual": "Annual",
|
||||||
"perMonth": "/month",
|
"perMonth": "/mo",
|
||||||
"perMonthAnnual": "/month, billed annually",
|
"perMonthAnnual": "/mo, billed yearly",
|
||||||
"perUser": "+ 3.90€/user",
|
"perUser": "+ €3.90/user",
|
||||||
"perUserAnnual": "+ 2.90€/user, billed annually",
|
"perUserAnnual": "+ €2.90/user, yearly",
|
||||||
"popular": "Most popular",
|
"popular": "Most chosen",
|
||||||
"basic": {
|
"basic": {
|
||||||
"name": "Basic",
|
"name": "Basic",
|
||||||
"desc": "Discover the magic of Memento.",
|
"desc": "Start your Second Brain.",
|
||||||
"cta": "Get started",
|
"cta": "Start free",
|
||||||
"feature0": "100 Notes max",
|
"feature0": "Up to 100 notes",
|
||||||
"feature1": "3 Notebooks",
|
"feature1": "3 notebooks",
|
||||||
"feature2": "50 AI credits (Lifetime)",
|
"feature2": "100 AI credits / month",
|
||||||
"feature3": "Semantic search",
|
"feature3": "Search, chat, tags (via credits)",
|
||||||
"feature4": "7-day history"
|
"feature4": "7-day history"
|
||||||
},
|
},
|
||||||
"pro": {
|
"pro": {
|
||||||
"name": "Pro",
|
"name": "Pro",
|
||||||
"desc": "For demanding consultants and creators.",
|
"desc": "For serious thinkers.",
|
||||||
"cta": "Upgrade to Pro",
|
"cta": "Go Pro",
|
||||||
"feature0": "Unlimited notes",
|
"feature0": "Unlimited notes",
|
||||||
"feature1": "BYOK (OpenAI/Anthropic)",
|
"feature1": "1,000 AI credits / month",
|
||||||
"feature2": "200 semantic searches",
|
"feature2": "BYOK (your own keys)",
|
||||||
"feature3": "Agents (12 runs/month)",
|
"feature3": "Agents (use credits)",
|
||||||
"feature4": "30-day history",
|
"feature4": "30-day history",
|
||||||
"feature5": "Email support"
|
"feature5": "Email support"
|
||||||
},
|
},
|
||||||
"business": {
|
"business": {
|
||||||
"name": "Business",
|
"name": "Business",
|
||||||
"desc": "For teams and product managers.",
|
"desc": "Team Second Brain.",
|
||||||
"cta": "Choose Business",
|
"cta": "Choose Business",
|
||||||
"feature0": "10 Collaborators included",
|
"feature0": "10 collaborators",
|
||||||
"feature1": "BYOK (13 providers)",
|
"feature1": "4,000 AI credits / month",
|
||||||
"feature2": "1000 semantic searches",
|
"feature2": "BYOK · 13 providers",
|
||||||
"feature3": "Agents (60 runs/month)",
|
"feature3": "Agents & brainstorm (credits)",
|
||||||
"feature4": "Unlimited brainstorming",
|
"feature4": "API / MCP",
|
||||||
"feature5": "API access"
|
"feature5": "Priority support"
|
||||||
},
|
},
|
||||||
"enterprise": {
|
"enterprise": {
|
||||||
"name": "Enterprise",
|
"name": "Enterprise",
|
||||||
"desc": "Secure organizational memory.",
|
"desc": "Org-wide Second Brain.",
|
||||||
"cta": "Contact Sales",
|
"cta": "Talk to us",
|
||||||
"feature0": "Everything in Business",
|
"feature0": "Unlimited AI credits or dedicated pool",
|
||||||
"feature1": "Unlimited agents",
|
"feature1": "Everything in Business",
|
||||||
"feature2": "SSO / SAML",
|
"feature2": "SSO / SAML",
|
||||||
"feature3": "Audit Logs & SLA",
|
"feature3": "Audit logs & SLA",
|
||||||
"feature4": "Dedicated support",
|
"feature4": "Dedicated support",
|
||||||
"feature5": "Live onboarding"
|
"feature5": "Live onboarding"
|
||||||
},
|
},
|
||||||
"basicPrice": "Free"
|
"basicPrice": "Free"
|
||||||
},
|
},
|
||||||
"byok": {
|
|
||||||
"label": "Open Cloud Technology",
|
|
||||||
"title": "The BYOK Strategy",
|
|
||||||
"desc": "Already have OpenAI, Anthropic or Google API keys? Connect them directly to Memento. Use AI without imposed credit limits, paying only what you actually consume from your favorite provider.",
|
|
||||||
"noLockin": "No lock-in",
|
|
||||||
"noLockinDesc": "Switch providers in 1 click.",
|
|
||||||
"cost": "Optimized costs",
|
|
||||||
"costDesc": "Pay the direct API price.",
|
|
||||||
"configLabel": "Multi-Provider Config"
|
|
||||||
},
|
|
||||||
"cta": {
|
"cta": {
|
||||||
"title1": "Ready to unlock your",
|
"title": "Stop losing your best ideas.",
|
||||||
"title2": "full potential?",
|
"desc": "Open Memento. Write one note. Watch your Second Brain find the ones you forgot.",
|
||||||
"desc": "Join thousands of researchers, designers and thinkers already using Memento to build their future.",
|
"button": "Create my free Second Brain"
|
||||||
"button": "Launch Memento"
|
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"desc": "The AI-amplified second brain. Designed for creative minds.",
|
"desc": "Your AI-amplified Second Brain — it connects itself.",
|
||||||
|
"rights": "All rights reserved.",
|
||||||
"product": {
|
"product": {
|
||||||
"title": "Product",
|
"title": "Product",
|
||||||
"link0": "Changelog",
|
"link0": "Second Brain",
|
||||||
"link1": "Documentation",
|
"link1": "Pricing",
|
||||||
"link2": "Roadmap",
|
"link2": "Agents",
|
||||||
"link0Href": "#",
|
"link0Href": "#product",
|
||||||
"link1Href": "#",
|
"link1Href": "#pricing",
|
||||||
"link2Href": "#"
|
"link2Href": "#agents"
|
||||||
},
|
},
|
||||||
"community": {
|
"community": {
|
||||||
"title": "Community",
|
"title": "Account",
|
||||||
"link0": "Discord",
|
"link0": "Log in",
|
||||||
"link1": "Twitter / X",
|
"link1": "Sign up",
|
||||||
"link2": "LinkedIn",
|
"link2": "Support",
|
||||||
"link0Href": "#",
|
"link0Href": "/login",
|
||||||
"link1Href": "#",
|
"link1Href": "/register",
|
||||||
"link2Href": "#"
|
"link2Href": "/support"
|
||||||
},
|
},
|
||||||
"legal": {
|
"legal": {
|
||||||
"title": "Legal",
|
"title": "Legal",
|
||||||
"link0": "Privacy Policy",
|
"link0": "Privacy",
|
||||||
"link1": "Terms of Service",
|
"link1": "Terms",
|
||||||
"link2": "Cookie Policy",
|
"link2": "Cookies",
|
||||||
"link0Href": "#",
|
"link0Href": "#",
|
||||||
"link1Href": "#",
|
"link1Href": "#",
|
||||||
"link2Href": "#"
|
"link2Href": "#"
|
||||||
@@ -3800,6 +3870,7 @@
|
|||||||
"copyRefUnsupported": "This block type cannot be referenced yet",
|
"copyRefUnsupported": "This block type cannot be referenced yet",
|
||||||
"createDiagram": "Create diagram",
|
"createDiagram": "Create diagram",
|
||||||
"createDiagramDesc": "Create an Excalidraw diagram from this block",
|
"createDiagramDesc": "Create an Excalidraw diagram from this block",
|
||||||
|
"createDiagramCostHint": "≈ 4 AI credits",
|
||||||
"createDiagramLoading": "Generating Excalidraw diagram by AI...",
|
"createDiagramLoading": "Generating Excalidraw diagram by AI...",
|
||||||
"createDiagramSuccess": "Diagram generated and inserted successfully!",
|
"createDiagramSuccess": "Diagram generated and inserted successfully!",
|
||||||
"createDiagramEmpty": "The block text is empty or too short to generate a diagram."
|
"createDiagramEmpty": "The block text is empty or too short to generate a diagram."
|
||||||
@@ -3927,8 +3998,8 @@
|
|||||||
"badge_credits": "⚡ {count} credits left",
|
"badge_credits": "⚡ {count} credits left",
|
||||||
"badge_upgrade": "Upgrade to Pro →",
|
"badge_upgrade": "Upgrade to Pro →",
|
||||||
"no_results": "No results — try another query.",
|
"no_results": "No results — try another query.",
|
||||||
"search_credit_used": "1 search used",
|
"search_credit_used": "1 credit used (search)",
|
||||||
"quota_exceeded": "Search quota reached — upgrade to Pro for unlimited.",
|
"quota_exceeded": "Out of AI credits — upgrade your plan or use your own API key.",
|
||||||
"step_aha_search_button": "Search",
|
"step_aha_search_button": "Search",
|
||||||
"step_aha_search_aria": "Search your notes",
|
"step_aha_search_aria": "Search your notes",
|
||||||
"step_notes_hint": "💡 These notes will power the AI search demo in the next step.",
|
"step_notes_hint": "💡 These notes will power the AI search demo in the next step.",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -342,7 +342,9 @@
|
|||||||
"heading2": "Titre 2",
|
"heading2": "Titre 2",
|
||||||
"heading3": "Titre 3",
|
"heading3": "Titre 3",
|
||||||
"importSuccess": "{{count}} notes importées !",
|
"importSuccess": "{{count}} notes importées !",
|
||||||
"unpublished": "Note dépubliée"
|
"unpublished": "Note dépubliée",
|
||||||
|
"deleteError": "Échec de la suppression de l'illustration",
|
||||||
|
"uploadSuccess": "Image ajoutée"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"previous": "←",
|
"previous": "←",
|
||||||
@@ -627,6 +629,24 @@
|
|||||||
"templateProject": "Statut de projet",
|
"templateProject": "Statut de projet",
|
||||||
"templateStrategy": "Revue stratégique",
|
"templateStrategy": "Revue stratégique",
|
||||||
"templateQuarterly": "Résultats trimestriels",
|
"templateQuarterly": "Résultats trimestriels",
|
||||||
|
"purpose": "Type de présentation",
|
||||||
|
"purposeAuto": "Auto (selon la note)",
|
||||||
|
"purposeCourse": "Cours / leçon",
|
||||||
|
"purposeBoard": "Comité / board",
|
||||||
|
"purposeProject": "Suivi projet",
|
||||||
|
"purposeStrategy": "Stratégie",
|
||||||
|
"purposePitch": "Pitch",
|
||||||
|
"purposeSummary": "Synthèse courte",
|
||||||
|
"audience": "Audience",
|
||||||
|
"audienceAuto": "Auto",
|
||||||
|
"audienceStudent": "Étudiants",
|
||||||
|
"audienceBoard": "Direction",
|
||||||
|
"audienceTeam": "Équipe",
|
||||||
|
"audienceClient": "Client",
|
||||||
|
"audienceGeneral": "Grand public",
|
||||||
|
"slideCount": "Nombre de diapositives",
|
||||||
|
"slideCountAuto": "Auto (selon la longueur)",
|
||||||
|
"creditCostEstimate": "Coût estimé : {cost} crédits (1 plan + {slides} diapositives).",
|
||||||
"diagram": "Générer Diagramme",
|
"diagram": "Générer Diagramme",
|
||||||
"diagramReadyHint": "Convertir en flux visuel",
|
"diagramReadyHint": "Convertir en flux visuel",
|
||||||
"diagramType": "Type de Diagramme",
|
"diagramType": "Type de Diagramme",
|
||||||
@@ -747,7 +767,7 @@
|
|||||||
"inlineSummaryMarkdown": "**Résumé :**",
|
"inlineSummaryMarkdown": "**Résumé :**",
|
||||||
"networkErrorShort": "Erreur réseau.",
|
"networkErrorShort": "Erreur réseau.",
|
||||||
"featureLocked": "Cette fonctionnalité nécessite le plan PRO ou supérieur.",
|
"featureLocked": "Cette fonctionnalité nécessite le plan PRO ou supérieur.",
|
||||||
"quotaExceeded": "Limite mensuelle atteinte. Se réinitialise le mois prochain.",
|
"quotaExceeded": "Solde de crédits IA insuffisant. Rechargez via un plan supérieur, ou utilisez votre propre clé.",
|
||||||
"noHistory": "Aucun historique",
|
"noHistory": "Aucun historique",
|
||||||
"webSearchDisabled": "Recherche web désactivée",
|
"webSearchDisabled": "Recherche web désactivée",
|
||||||
"webSearchEnabled": "Recherche web activée",
|
"webSearchEnabled": "Recherche web activée",
|
||||||
@@ -914,7 +934,8 @@
|
|||||||
"systemNotification": "Système",
|
"systemNotification": "Système",
|
||||||
"clipSaved": "Clip web enregistré",
|
"clipSaved": "Clip web enregistré",
|
||||||
"downloadFailed": "Échec du téléchargement",
|
"downloadFailed": "Échec du téléchargement",
|
||||||
"brainstormShared": "Brainstorm partagé"
|
"brainstormShared": "Brainstorm partagé",
|
||||||
|
"shareLabel": "Partage"
|
||||||
},
|
},
|
||||||
"clipper": {
|
"clipper": {
|
||||||
"webClipper": "Web Clipper",
|
"webClipper": "Web Clipper",
|
||||||
@@ -1303,6 +1324,10 @@
|
|||||||
"embeddingsDescription": "Fournisseur IA pour la recherche sémantique. Recommandé : OpenAI (meilleure qualité).",
|
"embeddingsDescription": "Fournisseur IA pour la recherche sémantique. Recommandé : OpenAI (meilleure qualité).",
|
||||||
"chatProvider": "Fournisseur de chat",
|
"chatProvider": "Fournisseur de chat",
|
||||||
"chatDescription": "Fournisseur IA pour l'assistant chat. Utilise le fournisseur d'étiquettes si non configuré.",
|
"chatDescription": "Fournisseur IA pour l'assistant chat. Utilise le fournisseur d'étiquettes si non configuré.",
|
||||||
|
"slidesTab": "Slides",
|
||||||
|
"slidesProvider": "Modèle slides",
|
||||||
|
"slidesDescription": "Fournisseur et modèle dédiés à la génération de présentations. Si le modèle est vide, le modèle Chat est utilisé. Les identifiants de modèle ne sont jamais renommés automatiquement. Le pipeline multi-étapes consomme plusieurs crédits du solde IA global (1 plan + 1 crédit par diapositive).",
|
||||||
|
"slidesQuotaNote": "Coût en crédits : 1 + nombre de diapositives (ex. 7 diapositives ≈ 8 crédits), prélevé sur le solde unique (BASIC 100 · PRO 1 000 · BUSINESS 4 000 / mois). Admin → Utilisateurs pour réinitialiser le solde d’un compte.",
|
||||||
"provider": "Fournisseur",
|
"provider": "Fournisseur",
|
||||||
"baseUrl": "URL de base",
|
"baseUrl": "URL de base",
|
||||||
"model": "Modèle",
|
"model": "Modèle",
|
||||||
@@ -1439,6 +1464,10 @@
|
|||||||
"roleUpdateFailed": "Échec de la mise à jour du rôle",
|
"roleUpdateFailed": "Échec de la mise à jour du rôle",
|
||||||
"tierUpdateSuccess": "Abonnement mis à jour à {tier}",
|
"tierUpdateSuccess": "Abonnement mis à jour à {tier}",
|
||||||
"tierUpdateFailed": "Échec de la mise à jour de l'abonnement",
|
"tierUpdateFailed": "Échec de la mise à jour de l'abonnement",
|
||||||
|
"resetQuotas": "Réinitialiser les crédits IA (mois en cours)",
|
||||||
|
"confirmResetQuotas": "Remettre le solde de crédits IA à l’allocation du plan pour {user} (BASIC 100 · PRO 1 000 · BUSINESS 4 000) ? Toutes les fonctions IA redeviennent disponibles selon ce solde.",
|
||||||
|
"resetQuotasSuccess": "Crédits IA réinitialisés pour la période {period} ({count} compteurs).",
|
||||||
|
"resetQuotasFailed": "Échec de la réinitialisation des crédits",
|
||||||
"demote": "Rétrograder en utilisateur",
|
"demote": "Rétrograder en utilisateur",
|
||||||
"promote": "Promouvoir en admin",
|
"promote": "Promouvoir en admin",
|
||||||
"confirmDelete": "Êtes-vous sûr ? Cette action est irréversible.",
|
"confirmDelete": "Êtes-vous sûr ? Cette action est irréversible.",
|
||||||
@@ -1537,31 +1566,32 @@
|
|||||||
},
|
},
|
||||||
"settingsDescription": "Configurer les paramètres de l'application",
|
"settingsDescription": "Configurer les paramètres de l'application",
|
||||||
"billing": {
|
"billing": {
|
||||||
"title": "Facturation & quotas",
|
"title": "Facturation & crédits IA",
|
||||||
"description": "Gérez les paliers, quotas IA et la config Stripe métier sans redéploiement.",
|
"description": "Stripe métier, solde de crédits par palier, et outils avancés. Le débit utilisateur suit le solde unique de crédits.",
|
||||||
"stripeConfigTitle": "Config Stripe (métier)",
|
"stripeConfigTitle": "Config Stripe (métier)",
|
||||||
"stripeConfigDescription": "Identifiants de prix et activation de la facturation. Les clés secrètes restent dans l'environnement serveur.",
|
"stripeConfigDescription": "Identifiants de prix et activation de la facturation. Les clés secrètes restent dans l'environnement serveur.",
|
||||||
"enableBilling": "Activer l'interface et le paiement Stripe",
|
"enableBilling": "Activer l'interface et le paiement Stripe",
|
||||||
|
"subscriptionPricesTitle": "Abonnements (price IDs)",
|
||||||
"STRIPE_PRICE_PRO_MONTHLY": "Pro — prix mensuel (price ID)",
|
"STRIPE_PRICE_PRO_MONTHLY": "Pro — prix mensuel (price ID)",
|
||||||
"STRIPE_PRICE_PRO_ANNUAL": "Pro — prix annuel (price ID)",
|
"STRIPE_PRICE_PRO_ANNUAL": "Pro — prix annuel (price ID)",
|
||||||
"STRIPE_PRICE_BUSINESS_MONTHLY": "Business — prix mensuel (price ID)",
|
"STRIPE_PRICE_BUSINESS_MONTHLY": "Business — prix mensuel (price ID)",
|
||||||
"STRIPE_PRICE_BUSINESS_ANNUAL": "Business — prix annuel (price ID)",
|
"STRIPE_PRICE_BUSINESS_ANNUAL": "Business — prix annuel (price ID)",
|
||||||
|
"packPricesTitle": "Packs de crédits (price IDs one-shot)",
|
||||||
|
"packPricesDescription": "Produits Stripe en paiement unique (mode payment). Les crédits achetés s’ajoutent au solde et se reportent d’un mois à l’autre.",
|
||||||
|
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 crédits (price ID)",
|
||||||
|
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 crédits (price ID)",
|
||||||
|
"STRIPE_PRICE_CREDITS_L": "Pack L — 2 000 crédits (price ID)",
|
||||||
|
"packsCatalogTitle": "Catalogue packs (code)",
|
||||||
"secretsNote": "STRIPE_SECRET_KEY et STRIPE_WEBHOOK_SECRET doivent rester dans Docker/env — jamais stockés ici.",
|
"secretsNote": "STRIPE_SECRET_KEY et STRIPE_WEBHOOK_SECRET doivent rester dans Docker/env — jamais stockés ici.",
|
||||||
"saveConfig": "Enregistrer la config",
|
"saveConfig": "Enregistrer la config",
|
||||||
"configSaved": "Configuration enregistrée",
|
"configSaved": "Configuration enregistrée",
|
||||||
"configFailed": "Échec de l'enregistrement",
|
"configFailed": "Échec de l'enregistrement",
|
||||||
"limitsTitle": "Quotas par palier",
|
"creditsTitle": "Crédits IA par palier (option M)",
|
||||||
"limitsDescription": "Limites mensuelles par fonctionnalité. Prise en effet en ~60 s.",
|
"creditsDescription": "Source de vérité du débit : un seul solde pour toutes les actions IA. Valeurs dans le code (lib/credits.ts).",
|
||||||
"feature": "Fonctionnalité",
|
"creditsPerMonth": "crédits / mois",
|
||||||
"mode": "Accès",
|
"creditsCostsNote": "Coûts typiques : recherche, chat, tags = 1 crédit ; diagramme = 4 ; publication IA = 3 ; présentation = 1 + nombre de diapositives (ex. 7 diapositives ≈ 8 crédits).",
|
||||||
"monthlyLimit": "Limite mensuelle",
|
"creditsResetHint": "Pour recharger un utilisateur : Admin → Utilisateurs → bouton réinitialiser les crédits.",
|
||||||
"actions": "Actions",
|
|
||||||
"modeUnavailable": "Indisponible",
|
|
||||||
"modeLimited": "Limité",
|
|
||||||
"modeUnlimited": "Illimité",
|
"modeUnlimited": "Illimité",
|
||||||
"saveLimit": "Enregistrer",
|
|
||||||
"limitSaved": "Quota mis à jour",
|
|
||||||
"limitFailed": "Échec de la mise à jour",
|
|
||||||
"usageTitle": "Aperçu consommation",
|
"usageTitle": "Aperçu consommation",
|
||||||
"usagePeriod": "Période {period}",
|
"usagePeriod": "Période {period}",
|
||||||
"lastSync": "Dernière sync {date}",
|
"lastSync": "Dernière sync {date}",
|
||||||
@@ -1753,8 +1783,8 @@
|
|||||||
"accentColorDescription": "Définissez la couleur principale de votre espace de travail"
|
"accentColorDescription": "Définissez la couleur principale de votre espace de travail"
|
||||||
},
|
},
|
||||||
"usageMeter": {
|
"usageMeter": {
|
||||||
"packName": "Pack découverte IA",
|
"packName": "Plan Basic",
|
||||||
"packPro": "Pack Pro",
|
"packPro": "Plan Pro",
|
||||||
"packBusiness": "Pack Business",
|
"packBusiness": "Pack Business",
|
||||||
"packEnterprise": "Pack Enterprise",
|
"packEnterprise": "Pack Enterprise",
|
||||||
"featureSearch": "Recherche",
|
"featureSearch": "Recherche",
|
||||||
@@ -1763,13 +1793,13 @@
|
|||||||
"unlimited": "Illimité",
|
"unlimited": "Illimité",
|
||||||
"remaining": "{count} restants",
|
"remaining": "{count} restants",
|
||||||
"upgradeTitle": "Passer à Pro",
|
"upgradeTitle": "Passer à Pro",
|
||||||
"upgradeDescription": "Vous avez utilisé tous les crédits du pack découverte IA. Passez à Pro pour des plafonds plus élevés et des fonctionnalités supplémentaires.",
|
"upgradeDescription": "Votre solde de crédits IA est épuisé. Passez à un plan supérieur (plus de crédits mensuels) ou utilisez votre propre clé (BYOK).",
|
||||||
"proIncludes": "Pro inclut :",
|
"proIncludes": "Pro inclut :",
|
||||||
"proSearch": "100 recherches sémantiques / mois",
|
"proSearch": "1 000 crédits IA / mois",
|
||||||
"proTags": "200 étiquettes auto / mois",
|
"proTags": "BYOK (vos propres clés)",
|
||||||
"proTitles": "200 titres auto / mois",
|
"proTitles": "Agents (consomment des crédits)",
|
||||||
"proReformulate": "50 reformulations / mois",
|
"proReformulate": "Notes illimitées",
|
||||||
"proChat": "100 messages de chat / mois",
|
"proChat": "Support e-mail",
|
||||||
"later": "Plus tard",
|
"later": "Plus tard",
|
||||||
"upgradePricing": "Passer à Pro",
|
"upgradePricing": "Passer à Pro",
|
||||||
"addApiKey": "Utiliser votre propre clé API (BYOK)",
|
"addApiKey": "Utiliser votre propre clé API (BYOK)",
|
||||||
@@ -1783,8 +1813,15 @@
|
|||||||
"featureBrainstormEnrich": "Enrichissements brainstorm",
|
"featureBrainstormEnrich": "Enrichissements brainstorm",
|
||||||
"featureFlashcards": "Flashcards IA",
|
"featureFlashcards": "Flashcards IA",
|
||||||
"featureVoice": "Transcription vocale",
|
"featureVoice": "Transcription vocale",
|
||||||
"featureSlides": "Génération de diapositives",
|
"featureSlides": "Présentations",
|
||||||
"featureDiagrams": "Génération de diagrammes"
|
"featureDiagrams": "Diagrammes",
|
||||||
|
"creditsRemaining": "Crédits restants",
|
||||||
|
"creditsWhereTitle": "Utilisation des crédits par fonction",
|
||||||
|
"creditsSummary": "{remaining} restants · {spent} utilisés sur {granted} du forfait",
|
||||||
|
"creditsUsedLine": "{credits} cr. · {actions}×",
|
||||||
|
"actionsOnly": "{actions} actions",
|
||||||
|
"noCreditUsage": "Aucune consommation de crédits ce mois-ci",
|
||||||
|
"outOfCredits": "Plus de crédits — options"
|
||||||
},
|
},
|
||||||
"generalSettings": {
|
"generalSettings": {
|
||||||
"title": "Généraux",
|
"title": "Généraux",
|
||||||
@@ -2444,8 +2481,8 @@
|
|||||||
"noNotesFoundForContext": "Aucune note pertinente trouvée pour cette question. Réponds avec tes connaissances générales.",
|
"noNotesFoundForContext": "Aucune note pertinente trouvée pour cette question. Réponds avec tes connaissances générales.",
|
||||||
"webSearch": "Recherche web",
|
"webSearch": "Recherche web",
|
||||||
"timeoutWarning": "La réponse met plus de temps que prévu...",
|
"timeoutWarning": "La réponse met plus de temps que prévu...",
|
||||||
"quotaExceededBasic": "Le chat IA est réservé au plan PRO et supérieur.",
|
"quotaExceededBasic": "Le chat IA est réservé au plan PRO ou supérieur (ou utilisez votre propre clé).",
|
||||||
"quotaExceededTier": "Limite mensuelle atteinte pour le plan {tier}. Elle se réinitialise le mois prochain."
|
"quotaExceededTier": "Solde de crédits IA épuisé pour le plan {tier}. Passez à un plan supérieur ou utilisez votre propre clé."
|
||||||
},
|
},
|
||||||
"labHeader": {
|
"labHeader": {
|
||||||
"title": "L'Atelier",
|
"title": "L'Atelier",
|
||||||
@@ -2679,6 +2716,7 @@
|
|||||||
"publishSimpleHint": "Gratuit — le contenu de votre note",
|
"publishSimpleHint": "Gratuit — le contenu de votre note",
|
||||||
"publishWithAi": "Publier avec l'IA",
|
"publishWithAi": "Publier avec l'IA",
|
||||||
"publishWithAiHint": "Mise en page soignée · {count} crédits restants",
|
"publishWithAiHint": "Mise en page soignée · {count} crédits restants",
|
||||||
|
"publishWithAiCostHint": "Coût estimé : 3 crédits IA",
|
||||||
"publishWithAiLocked": "Disponible avec le plan Pro",
|
"publishWithAiLocked": "Disponible avec le plan Pro",
|
||||||
"publishWithAiGenerating": "Génération de la mise en page…",
|
"publishWithAiGenerating": "Génération de la mise en page…",
|
||||||
"publishTemplateMagazine": "Article magazine",
|
"publishTemplateMagazine": "Article magazine",
|
||||||
@@ -2790,7 +2828,8 @@
|
|||||||
"slashVideo": "Vidéo",
|
"slashVideo": "Vidéo",
|
||||||
"startVoice": "Démarrer la dictée vocale",
|
"startVoice": "Démarrer la dictée vocale",
|
||||||
"stopVoice": "Arrêter la dictée vocale",
|
"stopVoice": "Arrêter la dictée vocale",
|
||||||
"collapsibleSection": "Section repliable"
|
"collapsibleSection": "Section repliable",
|
||||||
|
"deleteImage": "Supprimer l'image"
|
||||||
},
|
},
|
||||||
"flashcards": {
|
"flashcards": {
|
||||||
"generateTitle": "Générer des flashcards",
|
"generateTitle": "Générer des flashcards",
|
||||||
@@ -3135,8 +3174,8 @@
|
|||||||
"seedNodeBadge": "GRAINE",
|
"seedNodeBadge": "GRAINE",
|
||||||
"originalSeedDescription": "Idée source initiale",
|
"originalSeedDescription": "Idée source initiale",
|
||||||
"convertedToNoteStatus": "Convertie en note",
|
"convertedToNoteStatus": "Convertie en note",
|
||||||
"quotaGuest": "L'hôte de la session a atteint sa limite d'IA. Demandez-lui de mettre à niveau son forfait.",
|
"quotaGuest": "L'hôte de la session n'a plus de crédits IA. Demandez-lui de passer à un plan supérieur ou d'utiliser sa propre clé.",
|
||||||
"quotaHost": "Vous avez atteint votre limite d'IA pour ce brainstorm. Passez à un forfait supérieur pour continuer.",
|
"quotaHost": "Plus de crédits IA pour ce brainstorm. Passez à un plan supérieur ou utilisez votre propre clé pour continuer.",
|
||||||
"toastExpandSuccess": "Idées élargies !",
|
"toastExpandSuccess": "Idées élargies !",
|
||||||
"toastExpandFailed": "Échec de l'élargissement",
|
"toastExpandFailed": "Échec de l'élargissement",
|
||||||
"toastDismissSuccess": "Idée écartée",
|
"toastDismissSuccess": "Idée écartée",
|
||||||
@@ -3182,7 +3221,7 @@
|
|||||||
},
|
},
|
||||||
"byokSettings": {
|
"byokSettings": {
|
||||||
"title": "Vos clés API (BYOK)",
|
"title": "Vos clés API (BYOK)",
|
||||||
"description": "Connectez vos propres clés fournisseur pour contourner les quotas du pack Découverte. Les clés sont chiffrées au repos.",
|
"description": "Connectez vos propres clés fournisseur. Avec le BYOK, l’usage ne consomme en général pas vos crédits Memento. Les clés sont chiffrées au repos.",
|
||||||
"badgeActive": "BYOK actif",
|
"badgeActive": "BYOK actif",
|
||||||
"tierRequired": "Le BYOK nécessite un abonnement Pro ou supérieur.",
|
"tierRequired": "Le BYOK nécessite un abonnement Pro ou supérieur.",
|
||||||
"provider": "Fournisseur",
|
"provider": "Fournisseur",
|
||||||
@@ -3237,16 +3276,16 @@
|
|||||||
"businessPrice": "29,90 €",
|
"businessPrice": "29,90 €",
|
||||||
"proAnnualPrice": "99 €",
|
"proAnnualPrice": "99 €",
|
||||||
"businessAnnualPrice": "299 €",
|
"businessAnnualPrice": "299 €",
|
||||||
"proFeature1": "100 recherches sémantiques / mois",
|
"proFeature1": "Notes illimitées",
|
||||||
"proFeature2": "200 tags automatiques / mois",
|
"proFeature2": "1 000 crédits IA / mois",
|
||||||
"proFeature3": "50 reformulations / mois",
|
"proFeature3": "BYOK (vos propres clés)",
|
||||||
"proFeature4": "100 messages de chat / mois",
|
"proFeature4": "Agents (consomment des crédits)",
|
||||||
"businessFeature1": "1 000 recherches sémantiques / mois",
|
"businessFeature1": "10 collaborateurs inclus",
|
||||||
"businessFeature2": "1 000 tags automatiques / mois",
|
"businessFeature2": "4 000 crédits IA / mois",
|
||||||
"businessFeature3": "500 reformulations / mois",
|
"businessFeature3": "BYOK · 13 fournisseurs",
|
||||||
"businessFeature4": "1 000 messages de chat / mois",
|
"businessFeature4": "Agents & brainstorm (crédits)",
|
||||||
"enterpriseTitle": "Entreprise",
|
"enterpriseTitle": "Entreprise",
|
||||||
"enterpriseDescription": "Quotas personnalisés, SSO, support prioritaire.",
|
"enterpriseDescription": "Crédits illimités ou pool dédié, SSO, support prioritaire.",
|
||||||
"contactSales": "Contactez-nous",
|
"contactSales": "Contactez-nous",
|
||||||
"startCheckout": "Commencer",
|
"startCheckout": "Commencer",
|
||||||
"checkoutLoading": "Chargement du paiement…",
|
"checkoutLoading": "Chargement du paiement…",
|
||||||
@@ -3263,6 +3302,19 @@
|
|||||||
"periodRange": "Période",
|
"periodRange": "Période",
|
||||||
"unlimited": "Illimité",
|
"unlimited": "Illimité",
|
||||||
"noUsage": "Aucune donnée d'utilisation",
|
"noUsage": "Aucune donnée d'utilisation",
|
||||||
|
"creditsRemaining": "Crédits restants",
|
||||||
|
"creditsSpent": "Crédits utilisés ce mois",
|
||||||
|
"creditsFromPacks": "Crédits de packs",
|
||||||
|
"packsTitle": "Acheter des crédits",
|
||||||
|
"packsDescription": "Packs à usage unique. Les crédits achetés s’ajoutent à votre solde et se reportent d’un mois à l’autre (contrairement à l’allocation d’abonnement).",
|
||||||
|
"packSName": "Pack découverte",
|
||||||
|
"packMName": "Pack standard",
|
||||||
|
"packLName": "Pack intensif",
|
||||||
|
"buyPack": "Acheter",
|
||||||
|
"packCheckoutSuccess": "Pack de crédits ajouté à votre solde !",
|
||||||
|
"packCheckoutFailed": "Échec de l’achat du pack. Vérifiez la config Stripe ou réessayez.",
|
||||||
|
"creditsWhereTitle": "Utilisation des crédits par fonction",
|
||||||
|
"creditsUsageLegend": "Chaque action IA consomme des crédits du même solde (recherche, chat, étiquettes, diapositives, diagrammes, etc.).",
|
||||||
"billingHistory": "Historique de facturation",
|
"billingHistory": "Historique de facturation",
|
||||||
"viewInvoices": "Gérer les factures dans le portail",
|
"viewInvoices": "Gérer les factures dans le portail",
|
||||||
"cancelSubscription": "Résilier l'abonnement",
|
"cancelSubscription": "Résilier l'abonnement",
|
||||||
@@ -3283,26 +3335,26 @@
|
|||||||
"renewal": "Renouvellement",
|
"renewal": "Renouvellement",
|
||||||
"paidPlanDesc": "Votre abonnement se renouvelle automatiquement.",
|
"paidPlanDesc": "Votre abonnement se renouvelle automatiquement.",
|
||||||
"businessDescription": "Pour les équipes et chefs de produit.",
|
"businessDescription": "Pour les équipes et chefs de produit.",
|
||||||
"enterpriseFeature1": "Quotas illimités",
|
"enterpriseFeature1": "Crédits IA illimités ou pool dédié",
|
||||||
"enterpriseFeature2": "SSO / SAML",
|
"enterpriseFeature2": "SSO / SAML",
|
||||||
"enterpriseFeature3": "Support dédié",
|
"enterpriseFeature3": "Support dédié",
|
||||||
"enterpriseFeature4": "Facturation personnalisée",
|
"enterpriseFeature4": "Facturation personnalisée",
|
||||||
"enterpriseFeature5": "SLA garanti",
|
"enterpriseFeature5": "SLA garanti",
|
||||||
"subtitle": "Choisissez le plan qui vous convient",
|
"subtitle": "Choisissez le plan qui vous convient",
|
||||||
"freeDescription": "Pour découvrir Memento",
|
"freeDescription": "Pour découvrir Memento",
|
||||||
"freeF1": "30 recherches sémantiques",
|
"freeF1": "Jusqu'à 100 notes",
|
||||||
"freeF2": "15 étiquettes auto",
|
"freeF2": "3 carnets",
|
||||||
"freeF3": "5 titres auto",
|
"freeF3": "100 crédits IA / mois",
|
||||||
"freeF4": "100 notes",
|
"freeF4": "Recherche, chat, tags (via crédits)",
|
||||||
"freeF5": "3 carnets",
|
"freeF5": "Historique 7 jours",
|
||||||
"freePlanDesc": "Idéal pour découvrir Memento",
|
"freePlanDesc": "Idéal pour découvrir Memento",
|
||||||
"freePrice": "Gratuit",
|
"freePrice": "Gratuit",
|
||||||
"proDescription": "Pour les consultants et freelances",
|
"proDescription": "Pour les consultants et freelances",
|
||||||
"proFeature5": "50 reformulations/mois",
|
"proFeature5": "Historique 30 jours",
|
||||||
"proFeature6": "50 messages IA/mois",
|
"proFeature6": "Support e-mail",
|
||||||
"proCta": "Passer à Pro",
|
"proCta": "Passer à Pro",
|
||||||
"businessFeature5": "500 reformulations/mois",
|
"businessFeature5": "API / MCP",
|
||||||
"businessFeature6": "500 messages IA/mois",
|
"businessFeature6": "Support prioritaire",
|
||||||
"businessCta": "Passer à Business",
|
"businessCta": "Passer à Business",
|
||||||
"recommended": "Recommandé",
|
"recommended": "Recommandé",
|
||||||
"instantActivation": "Activation instantanée",
|
"instantActivation": "Activation instantanée",
|
||||||
@@ -3322,213 +3374,231 @@
|
|||||||
"cancelFailed": "Échec de l'annulation de l'abonnement.",
|
"cancelFailed": "Échec de l'annulation de l'abonnement.",
|
||||||
"downloadPdf": "Télécharger le PDF",
|
"downloadPdf": "Télécharger le PDF",
|
||||||
"fetchStatusFailed": "Échec du chargement des informations de facturation",
|
"fetchStatusFailed": "Échec du chargement des informations de facturation",
|
||||||
"fetchQuotasFailed": "Échec du chargement des quotas",
|
"fetchQuotasFailed": "Échec du chargement des crédits",
|
||||||
"fetchInvoicesFailed": "Impossible de charger l'historique de facturation.",
|
"fetchInvoicesFailed": "Impossible de charger l'historique de facturation.",
|
||||||
"savePercent": "Économisez ~17%"
|
"savePercent": "Économisez ~17%"
|
||||||
},
|
},
|
||||||
"quotaPaywall": {
|
"quotaPaywall": {
|
||||||
"title": "Limite mensuelle atteinte",
|
"title": "Plus de crédits IA",
|
||||||
"description": "Vous avez épuisé vos crédits pour la fonctionnalité {feature} ce mois-ci.",
|
"description": "Votre solde de crédits est insuffisant pour cette action ({feature}). Toutes les actions IA partagent le même solde. Passez à un plan supérieur ou utilisez votre propre clé.",
|
||||||
"upgrade": "Passer au plan supérieur",
|
"descriptionWithCost": "Il vous reste {remaining} crédit(s) ; cette action ({feature}) en coûte {cost}. Passez à un plan supérieur ou utilisez votre propre clé.",
|
||||||
"useOwnKey": "Clé API perso",
|
"upgrade": "Voir les plans",
|
||||||
"later": "Peut-être plus tard",
|
"useOwnKey": "Utiliser ma propre clé",
|
||||||
|
"later": "Plus tard",
|
||||||
"closingIn": "Fermeture dans {n}s"
|
"closingIn": "Fermeture dans {n}s"
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"nav": {
|
"nav": {
|
||||||
"features": "Fonctionnalités",
|
"products": "Produit",
|
||||||
"agents": "Agents IA",
|
"secondBrain": "Second Brain",
|
||||||
"brainstorm": "Brainstorm",
|
"echo": "Memory Echo",
|
||||||
|
"agents": "Agents",
|
||||||
"pricing": "Tarifs",
|
"pricing": "Tarifs",
|
||||||
"tech": "Architecture",
|
|
||||||
"login": "Se connecter",
|
"login": "Se connecter",
|
||||||
"cta": "Commencez"
|
"cta": "Essayer gratuitement",
|
||||||
|
"openMenu": "Ouvrir le menu",
|
||||||
|
"closeMenu": "Fermer le menu",
|
||||||
|
"language": "Langue"
|
||||||
},
|
},
|
||||||
"hero": {
|
"hero": {
|
||||||
"badge": "Augmenté par l'Intelligence Artificielle",
|
"badge": "Second Brain",
|
||||||
"title1": "Votre second cerveau,",
|
"eyebrow": "Pas une app de notes de plus — un second cerveau vivant",
|
||||||
"title2": "enfin amplifié.",
|
"headline": "Votre second cerveau.",
|
||||||
"subtitle": "Memento n'est pas qu'une simple application de notes. C'est un écosystème intelligent qui connecte, analyse et développe vos idées en temps réel grâce à 6 types d'agents IA et une recherche sémantique de pointe.",
|
"headlineAccent": "Il se souvient de ce que vous avez oublié.",
|
||||||
"cta": "S'inscrire maintenant",
|
"subtitle": "Memento, c'est votre Second Brain : il relie vos idées à travers les mois, fait émerger les liens cachés, et transforme vos carnets en système que vous pouvez chercher, briefer et actionner chaque matin.",
|
||||||
"secondary": "Voir les fonctionnalités",
|
"cta": "Créer mon second cerveau",
|
||||||
"memoryEcho": "Memory Echo",
|
"ctaHint": "Gratuit pour commencer · Google · Sans carte bancaire",
|
||||||
"memoryEchoText": "\"Connexion détectée avec votre projet de design durable de Mars 2024...\"",
|
"imageAlt": "Espace Second Brain Memento",
|
||||||
"brainstormLive": "Brainstorm Live",
|
"echoLive": "Memory Echo",
|
||||||
"ideasGenerated": "+12 idées générées"
|
"echo0": "« Lié à votre brief design de mars — même contrainte, nouvel angle. »",
|
||||||
|
"echo1": "« Ça fait écho à une note de recherche d'il y a 8 mois. L'ouvrir ? »",
|
||||||
|
"echo2": "« Trois carnets touchent cette idée. Voici la note pont. »"
|
||||||
},
|
},
|
||||||
"features": {
|
"trust": {
|
||||||
"label": "Capacités IA",
|
"trust0": "Un vrai Second Brain, pas un tas de dossiers",
|
||||||
"title": "Une intelligence fluide,",
|
"trust1": "Vos propres clés IA (BYOK)",
|
||||||
"title2": "intégrée à chaque mot.",
|
"trust2": "15 langues, dont le persan RTL",
|
||||||
"desc": "Memento orchestre vos idées grâce à une architecture multi-fournisseurs.",
|
"trust3": "Des agents qui écrivent dans vos carnets"
|
||||||
"f1Title": "Recherche Sémantique",
|
},
|
||||||
"f1Desc": "Ne cherchez plus par mots-clés. Trouvez par concept. Notre moteur hybride Vector + FTS comprend l'intention derrière vos notes.",
|
"pain": {
|
||||||
"f2Title": "Chat RAG Contextuel",
|
"label": "Le vrai problème",
|
||||||
"f2Desc": "Discutez avec votre savoir. Nos agents lisent vos notes, explorent le web et analysent vos documents pour répondre avec précision.",
|
"title": "Vous n'avez pas besoin de plus de notes. Vous avez besoin d'un second cerveau.",
|
||||||
"f3Title": "Écriture Augmentée",
|
"desc": "Les dossiers débordent. La recherche rate. Les idées meurent en silence. Un Second Brain retrouve les fils entre ce que vous capturez — pour que la pensée d'hier alimente les décisions de demain.",
|
||||||
"f3Desc": "Reformulation, suggestions de titres, tagging automatique et résumés. L'IA travaille en arrière-plan pour structurer votre pensée."
|
"secondBrain": "C'est Memento : un Second Brain qui se connecte pendant que vous écrivez."
|
||||||
|
},
|
||||||
|
"moments": {
|
||||||
|
"echo": {
|
||||||
|
"eyebrow": "Memory Echo",
|
||||||
|
"title": "Le moment où votre Second Brain vous répond.",
|
||||||
|
"desc": "Pendant que vous écrivez, Memento détecte des liens sémantiques entre carnets — pas des mots-clés, de vrais ponts conceptuels, ouvrables en aperçu partagé.",
|
||||||
|
"card0Label": "À l'instant",
|
||||||
|
"card0": "« Votre note sur le pricing fait écho à l'analyse concurrente de l'automne. »",
|
||||||
|
"card1Label": "Pont",
|
||||||
|
"card1": "Thème partagé : positionnement sous contrainte",
|
||||||
|
"card2Label": "Action",
|
||||||
|
"card2": "Ouvrir les deux notes côte à côte — continuez d'écrire"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"eyebrow": "Dashboard Second Brain",
|
||||||
|
"title": "Un matin qui vous dit ce qui compte.",
|
||||||
|
"desc": "Le briefing de votre Second Brain : prochaines pistes, checklist, découvertes, révisions — des widgets que vous arrangez.",
|
||||||
|
"w0Label": "Prochaines pistes",
|
||||||
|
"w0": "3 actions depuis votre dernière note",
|
||||||
|
"w1Label": "Revue du jour",
|
||||||
|
"w1": "Inbox · IA · Flashcards",
|
||||||
|
"w2Label": "Memory Echo",
|
||||||
|
"w2": "2 nouvelles connexions cette nuit",
|
||||||
|
"w3Label": "Agents",
|
||||||
|
"w3": "1 suggestion prête à lancer"
|
||||||
|
},
|
||||||
|
"insights": {
|
||||||
|
"eyebrow": "Insights",
|
||||||
|
"title": "Voyez l'architecture de votre Second Brain.",
|
||||||
|
"desc": "Clusters sémantiques et notes pont — la carte vivante de vos connexions.",
|
||||||
|
"chip": "Réseau de clusters"
|
||||||
|
},
|
||||||
|
"revision": {
|
||||||
|
"eyebrow": "Révision",
|
||||||
|
"title": "Mémorisez exprès.",
|
||||||
|
"desc": "Générez des flashcards depuis n'importe quelle note. Répétition espacée SM-2 — le savoir qui tient dans votre Second Brain.",
|
||||||
|
"card": "Qu'est-ce qui relie Memory Echo à vos notes ?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"how": {
|
||||||
|
"title": "Trois étapes. Puis votre Second Brain compound.",
|
||||||
|
"s0": {
|
||||||
|
"title": "Capturez librement",
|
||||||
|
"desc": "Écrivez dans un éditeur nouvelle génération — blocs, vues structurées, collage intelligent. Clippez le web si besoin."
|
||||||
|
},
|
||||||
|
"s1": {
|
||||||
|
"title": "Laissez connecter",
|
||||||
|
"desc": "Memory Echo et la recherche sémantique tissent des liens que vous n'aviez pas prévus. Votre passé devient du carburant."
|
||||||
|
},
|
||||||
|
"s2": {
|
||||||
|
"title": "Agissez chaque matin",
|
||||||
|
"desc": "Pistes du dashboard, agents et flashcards transforment une pile de notes en élan Second Brain."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"agents": {
|
"agents": {
|
||||||
"label": "Agents Spécialisés",
|
"label": "Agents IA",
|
||||||
"title": "Déléguez le travail complexe.",
|
"title": "Déléguez le gros du travail.",
|
||||||
"desc": "6 types d'agents IA autonomes pour automatiser vos recherches, vos résumés et vos présentations.",
|
"desc": "Recherche, scrape, slides, diagrammes, veille — des agents qui écrivent dans votre Second Brain, pas un chat de plus que vous oublierez.",
|
||||||
"scraper": {
|
"scraper": {
|
||||||
"title": "Scraper",
|
"title": "Scraper",
|
||||||
"desc": "Scrape des URLs, parse les flux RSS et synthétise l'info avec placement d'images intelligent."
|
"desc": "URL & RSS → notes synthétisées avec images."
|
||||||
},
|
},
|
||||||
"researcher": {
|
"researcher": {
|
||||||
"title": "Researcher",
|
"title": "Researcher",
|
||||||
"desc": "Génère des requêtes complexes, explore les sources web et rédige des notes de recherche structurées."
|
"desc": "Requêtes profondes, sources, notes structurées."
|
||||||
},
|
},
|
||||||
"slideGen": {
|
"slideGen": {
|
||||||
"title": "Slide Gen",
|
"title": "Slide Gen",
|
||||||
"desc": "Transforme vos notes en présentations PowerPoint professionnelles ou Slides HTML Interactives."
|
"desc": "Notes → decks ou slides HTML interactives."
|
||||||
},
|
},
|
||||||
"monitor": {
|
"monitor": {
|
||||||
"title": "Monitor",
|
"title": "Monitor",
|
||||||
"desc": "Analyse continuellement vos carnets pour détecter les tendances et les nouveaux insights."
|
"desc": "Surveille vos carnets : tendances et insights."
|
||||||
},
|
},
|
||||||
"diagramGen": {
|
"diagramGen": {
|
||||||
"title": "Diagram Gen",
|
"title": "Diagram Gen",
|
||||||
"desc": "Convertit vos idées en diagrammes Excalidraw fluides (Mindmaps, Flowcharts) avec auto-layout."
|
"desc": "Idées → mindmaps & flows Excalidraw."
|
||||||
},
|
},
|
||||||
"custom": {
|
"custom": {
|
||||||
"title": "Custom",
|
"title": "Custom",
|
||||||
"desc": "Définissez vos propres agents avec des rôles et des sources de données spécifiques."
|
"desc": "Vos rôles, sources et planifications."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"brainstorm": {
|
"byok": {
|
||||||
"label": "Vagues de Pensée",
|
"label": "Sans lock-in",
|
||||||
"title": "Brainstorming radial en temps réel.",
|
"title": "Vos clés. Vos modèles. Votre Second Brain.",
|
||||||
"waveGeneration": {
|
"desc": "Crédits Memento ou OpenAI, Anthropic, Google… Changez de fournisseur en un clic — le produit reste le vôtre."
|
||||||
"title": "Génération par Vagues",
|
|
||||||
"desc": "Variations, Analogies, puis Disruptions. L'IA pousse votre concept initial dans ses retranchements."
|
|
||||||
},
|
|
||||||
"collaboration": {
|
|
||||||
"title": "Collaboration Native",
|
|
||||||
"desc": "Curseurs fantômes IA, avatars synchronisés et déplacement de nœuds en temps réel."
|
|
||||||
},
|
|
||||||
"export": {
|
|
||||||
"title": "Export Sémantique",
|
|
||||||
"desc": "Convertissez tout votre brainstorm en notes structurées d'un seul clic."
|
|
||||||
},
|
|
||||||
"disruptionLabel": "DISRUPTION",
|
|
||||||
"disruptionText": "Architecture Modulaire 2.0",
|
|
||||||
"analogyLabel": "ANALOGIE",
|
|
||||||
"analogyText": "Le cycle des marées"
|
|
||||||
},
|
|
||||||
"tech": {
|
|
||||||
"label": "Architecture & Fournisseurs",
|
|
||||||
"title": "Connectez votre propre intelligence.",
|
|
||||||
"tags": {
|
|
||||||
"title": "Tags",
|
|
||||||
"desc": "Indépendamment configurable avec n'importe quel modèle."
|
|
||||||
},
|
|
||||||
"embeddings": {
|
|
||||||
"title": "Embeddings",
|
|
||||||
"desc": "Indépendamment configurable avec n'importe quel modèle."
|
|
||||||
},
|
|
||||||
"chatRag": {
|
|
||||||
"title": "Chat RAG",
|
|
||||||
"desc": "Indépendamment configurable avec n'importe quel modèle."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
"label": "Plans & Tarification",
|
"label": "Tarifs",
|
||||||
"title": "Choisissez votre niveau d'amplification.",
|
"title": "Gratuit pour commencer. Faites grandir votre Second Brain.",
|
||||||
"desc": "Des options flexibles pour les esprits créatifs, de l'usage individuel aux grandes organisations.",
|
"desc": "Des paliers clairs — des premières notes à la mémoire d'équipe.",
|
||||||
"monthly": "Mensuel",
|
"monthly": "Mensuel",
|
||||||
"annual": "Annuel",
|
"annual": "Annuel",
|
||||||
"perMonth": "/mois",
|
"perMonth": "/mois",
|
||||||
"perMonthAnnual": "/mois, facturé annuellement",
|
"perMonthAnnual": "/mois, facturé à l'année",
|
||||||
"perUser": "+ 3,90€/user",
|
"perUser": "+ 3,90€/user",
|
||||||
"perUserAnnual": "+ 2,90€/user, facturé annuellement",
|
"perUserAnnual": "+ 2,90€/user, à l'année",
|
||||||
"popular": "Le plus populaire",
|
"popular": "Le plus choisi",
|
||||||
"basic": {
|
"basic": {
|
||||||
"name": "Basic",
|
"name": "Basic",
|
||||||
"desc": "Pour découvrir la magie de Memento.",
|
"desc": "Démarrez votre Second Brain.",
|
||||||
"cta": "Commencer",
|
"cta": "Commencer gratuit",
|
||||||
"feature0": "100 Notes max",
|
"feature0": "Jusqu'à 100 notes",
|
||||||
"feature1": "3 Carnets",
|
"feature1": "3 carnets",
|
||||||
"feature2": "50 crédits IA (Lifetime)",
|
"feature2": "100 crédits IA / mois",
|
||||||
"feature3": "Recherche sémantique",
|
"feature3": "Recherche, chat, tags (via crédits)",
|
||||||
"feature4": "Historique 7 jours"
|
"feature4": "Historique 7 jours"
|
||||||
},
|
},
|
||||||
"pro": {
|
"pro": {
|
||||||
"name": "Pro",
|
"name": "Pro",
|
||||||
"desc": "Pour les consultants et créateurs exigeants.",
|
"desc": "Pour les esprits exigeants.",
|
||||||
"cta": "Passer Pro",
|
"cta": "Passer Pro",
|
||||||
"feature0": "Notes illimitées",
|
"feature0": "Notes illimitées",
|
||||||
"feature1": "BYOK (OpenAI/Anthropic)",
|
"feature1": "1 000 crédits IA / mois",
|
||||||
"feature2": "200 recherches sémantiques",
|
"feature2": "BYOK (vos propres clés)",
|
||||||
"feature3": "Agents (12 runs/mois)",
|
"feature3": "Agents (consomment des crédits)",
|
||||||
"feature4": "Historique 30 jours",
|
"feature4": "Historique 30 jours",
|
||||||
"feature5": "Support Email"
|
"feature5": "Support e-mail"
|
||||||
},
|
},
|
||||||
"business": {
|
"business": {
|
||||||
"name": "Business",
|
"name": "Business",
|
||||||
"desc": "Pour les équipes et chefs de produit.",
|
"desc": "Second Brain d'équipe.",
|
||||||
"cta": "Choisir Business",
|
"cta": "Choisir Business",
|
||||||
"feature0": "10 Collaborateurs inclus",
|
"feature0": "10 collaborateurs",
|
||||||
"feature1": "BYOK (13 fournisseurs)",
|
"feature1": "4 000 crédits IA / mois",
|
||||||
"feature2": "1000 recherches sémantiques",
|
"feature2": "BYOK · 13 fournisseurs",
|
||||||
"feature3": "Agents (60 runs/mois)",
|
"feature3": "Agents & brainstorm (crédits)",
|
||||||
"feature4": "Brainstorm illimité",
|
"feature4": "API / MCP",
|
||||||
"feature5": "Accès API"
|
"feature5": "Support prioritaire"
|
||||||
},
|
},
|
||||||
"enterprise": {
|
"enterprise": {
|
||||||
"name": "Enterprise",
|
"name": "Enterprise",
|
||||||
"desc": "Mémoire organisationnelle sécurisée.",
|
"desc": "Second Brain organisationnel.",
|
||||||
"cta": "Contacter Ventes",
|
"cta": "Nous parler",
|
||||||
"feature0": "Tout Business",
|
"feature0": "Crédits IA illimités ou pool dédié",
|
||||||
"feature1": "Agents illimités",
|
"feature1": "Tout Business",
|
||||||
"feature2": "SSO / SAML",
|
"feature2": "SSO / SAML",
|
||||||
"feature3": "Audit Logs & SLA",
|
"feature3": "Audit logs & SLA",
|
||||||
"feature4": "Support Dédié",
|
"feature4": "Support dédié",
|
||||||
"feature5": "Onboarding Live"
|
"feature5": "Onboarding live"
|
||||||
},
|
},
|
||||||
"basicPrice": "Gratuit"
|
"basicPrice": "Gratuit"
|
||||||
},
|
},
|
||||||
"byok": {
|
|
||||||
"label": "Technologie Cloud Ouverte",
|
|
||||||
"title": "La stratégie BYOK",
|
|
||||||
"desc": "Vous possédez déjà des clés API OpenAI, Anthropic ou Google ? Connectez-les directement à Memento. Utilisez l'IA sans limites de crédits imposées, en payant uniquement ce que vous consommez chez votre fournisseur favori.",
|
|
||||||
"noLockin": "Pas de lock-in",
|
|
||||||
"noLockinDesc": "Changez de fournisseur en 1 clic.",
|
|
||||||
"cost": "Coûts optimisés",
|
|
||||||
"costDesc": "Payez le prix direct API.",
|
|
||||||
"configLabel": "Config Multi-Fournisseurs"
|
|
||||||
},
|
|
||||||
"cta": {
|
"cta": {
|
||||||
"title1": "Prêt à libérer votre",
|
"title": "Arrêtez de perdre vos meilleures idées.",
|
||||||
"title2": "plein potentiel ?",
|
"desc": "Ouvrez Memento. Écrivez une note. Regardez votre Second Brain retrouver celles que vous aviez oubliées.",
|
||||||
"desc": "Rejoignez des milliers de chercheurs, designers et penseurs qui utilisent déjà Memento pour construire leur futur.",
|
"button": "Créer mon Second Brain gratuit"
|
||||||
"button": "Lancer Memento"
|
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"desc": "Le second cerveau amplifié par l'IA. Pensé pour les esprits créatifs.",
|
"desc": "Votre Second Brain amplifié par l'IA — il se connecte tout seul.",
|
||||||
|
"rights": "Tous droits réservés.",
|
||||||
"product": {
|
"product": {
|
||||||
"title": "Produit",
|
"title": "Produit",
|
||||||
"link0": "Changelog",
|
"link0": "Second Brain",
|
||||||
"link1": "Documentation",
|
"link1": "Tarifs",
|
||||||
"link2": "Roadmap",
|
"link2": "Agents",
|
||||||
"link0Href": "#",
|
"link0Href": "#product",
|
||||||
"link1Href": "#",
|
"link1Href": "#pricing",
|
||||||
"link2Href": "#"
|
"link2Href": "#agents"
|
||||||
},
|
},
|
||||||
"community": {
|
"community": {
|
||||||
"title": "Communauté",
|
"title": "Compte",
|
||||||
"link0": "Discord",
|
"link0": "Se connecter",
|
||||||
"link1": "Twitter / X",
|
"link1": "S'inscrire",
|
||||||
"link2": "LinkedIn",
|
"link2": "Support",
|
||||||
"link0Href": "#",
|
"link0Href": "/login",
|
||||||
"link1Href": "#",
|
"link1Href": "/register",
|
||||||
"link2Href": "#"
|
"link2Href": "/support"
|
||||||
},
|
},
|
||||||
"legal": {
|
"legal": {
|
||||||
"title": "Légal",
|
"title": "Légal",
|
||||||
"link0": "Politique de confidentialité",
|
"link0": "Confidentialité",
|
||||||
"link1": "Conditions d'utilisation",
|
"link1": "Conditions",
|
||||||
"link2": "Cookies",
|
"link2": "Cookies",
|
||||||
"link0Href": "#",
|
"link0Href": "#",
|
||||||
"link1Href": "#",
|
"link1Href": "#",
|
||||||
@@ -3806,6 +3876,7 @@
|
|||||||
"copyRefUnsupported": "Ce type de bloc ne peut pas encore être référencé",
|
"copyRefUnsupported": "Ce type de bloc ne peut pas encore être référencé",
|
||||||
"createDiagram": "Créer un diagramme",
|
"createDiagram": "Créer un diagramme",
|
||||||
"createDiagramDesc": "Créer un diagramme Excalidraw à partir de ce bloc",
|
"createDiagramDesc": "Créer un diagramme Excalidraw à partir de ce bloc",
|
||||||
|
"createDiagramCostHint": "≈ 4 crédits IA",
|
||||||
"createDiagramLoading": "Génération du diagramme Excalidraw par l'IA...",
|
"createDiagramLoading": "Génération du diagramme Excalidraw par l'IA...",
|
||||||
"createDiagramSuccess": "Diagramme généré et inséré avec succès !",
|
"createDiagramSuccess": "Diagramme généré et inséré avec succès !",
|
||||||
"createDiagramEmpty": "Le texte du bloc est vide ou trop court pour générer un diagramme."
|
"createDiagramEmpty": "Le texte du bloc est vide ou trop court pour générer un diagramme."
|
||||||
@@ -3930,11 +4001,11 @@
|
|||||||
"progress": "{current} sur {total}",
|
"progress": "{current} sur {total}",
|
||||||
"creating_demo_notes": "Création des notes d'exemple...",
|
"creating_demo_notes": "Création des notes d'exemple...",
|
||||||
"demo_notes_ready": "5 notes d'exemple créées !",
|
"demo_notes_ready": "5 notes d'exemple créées !",
|
||||||
"badge_credits": "⚡ {count} crédits restants",
|
"badge_credits": "{count} crédits restants",
|
||||||
"badge_upgrade": "Passer en Pro →",
|
"badge_upgrade": "Passer en Pro →",
|
||||||
"no_results": "Aucun résultat — essayez une autre requête.",
|
"no_results": "Aucun résultat — essayez une autre requête.",
|
||||||
"search_credit_used": "1 recherche utilisée",
|
"search_credit_used": "1 crédit utilisé (recherche)",
|
||||||
"quota_exceeded": "Quota de recherche atteint — passez en Pro pour illimité.",
|
"quota_exceeded": "Plus de crédits IA — passez à un plan supérieur ou utilisez votre propre clé.",
|
||||||
"step_aha_search_button": "Chercher",
|
"step_aha_search_button": "Chercher",
|
||||||
"step_aha_search_aria": "Rechercher dans vos notes",
|
"step_aha_search_aria": "Rechercher dans vos notes",
|
||||||
"step_notes_hint": "💡 Ces notes alimenteront la démo de recherche IA à l'étape suivante.",
|
"step_notes_hint": "💡 Ces notes alimenteront la démo de recherche IA à l'étape suivante.",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
|||||||
|
-- Crédits IA globaux (modèle A) — tables additives uniquement
|
||||||
|
CREATE TABLE IF NOT EXISTS "AiCreditAccount" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"periodKey" TEXT NOT NULL,
|
||||||
|
"subscriptionGranted" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"subscriptionSpent" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"purchasedBalance" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "AiCreditAccount_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "AiCreditAccount_userId_key" ON "AiCreditAccount"("userId");
|
||||||
|
CREATE INDEX IF NOT EXISTS "AiCreditAccount_periodKey_idx" ON "AiCreditAccount"("periodKey");
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "AiCreditLedger" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"kind" TEXT NOT NULL,
|
||||||
|
"amount" INTEGER NOT NULL,
|
||||||
|
"feature" TEXT,
|
||||||
|
"balanceAfter" INTEGER,
|
||||||
|
"metadata" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "AiCreditLedger_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "AiCreditLedger_userId_createdAt_idx" ON "AiCreditLedger"("userId", "createdAt");
|
||||||
|
CREATE INDEX IF NOT EXISTS "AiCreditLedger_userId_feature_createdAt_idx" ON "AiCreditLedger"("userId", "feature", "createdAt");
|
||||||
|
CREATE INDEX IF NOT EXISTS "AiCreditLedger_userId_kind_createdAt_idx" ON "AiCreditLedger"("userId", "kind", "createdAt");
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'AiCreditAccount_userId_fkey'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "AiCreditAccount"
|
||||||
|
ADD CONSTRAINT "AiCreditAccount_userId_fkey"
|
||||||
|
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'AiCreditLedger_userId_fkey'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "AiCreditLedger"
|
||||||
|
ADD CONSTRAINT "AiCreditLedger_userId_fkey"
|
||||||
|
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@@ -60,6 +60,8 @@ model User {
|
|||||||
flashcardDecks FlashcardDeck[]
|
flashcardDecks FlashcardDeck[]
|
||||||
errorLogs ErrorLog[]
|
errorLogs ErrorLog[]
|
||||||
gmailScanHistory GmailScanHistory[]
|
gmailScanHistory GmailScanHistory[]
|
||||||
|
aiCreditAccount AiCreditAccount?
|
||||||
|
aiCreditLedger AiCreditLedger[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Account {
|
model Account {
|
||||||
@@ -890,6 +892,49 @@ model PlanEntitlement {
|
|||||||
@@index([tier])
|
@@index([tier])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== CRÉDITS IA GLOBAUX (modèle A) =====
|
||||||
|
|
||||||
|
/// Solde de crédits par utilisateur pour la période mensuelle en cours.
|
||||||
|
model AiCreditAccount {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
/// Clé de période YYYY-MM
|
||||||
|
periodKey String
|
||||||
|
/// Allocation abonnement du mois (ex. 300 pour PRO)
|
||||||
|
subscriptionGranted Int @default(0)
|
||||||
|
/// Crédits d'abonnement déjà consommés ce mois
|
||||||
|
subscriptionSpent Int @default(0)
|
||||||
|
/// Crédits de packs achetés encore disponibles (reportables)
|
||||||
|
purchasedBalance Int @default(0)
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([periodKey])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Journal immuable des mouvements de crédits (transparence d'usage).
|
||||||
|
model AiCreditLedger {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
/// GRANT_SUBSCRIPTION | PURCHASE_PACK | CONSUME | RELEASE | ADMIN_ADJUST | EXPIRE_SUBSCRIPTION | ADMIN_RESET
|
||||||
|
kind String
|
||||||
|
/// Positif = crédit, négatif = débit
|
||||||
|
amount Int
|
||||||
|
/// Feature technique pour le tableau de répartition (ex. slide_generate)
|
||||||
|
feature String?
|
||||||
|
balanceAfter Int?
|
||||||
|
metadata String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId, createdAt])
|
||||||
|
@@index([userId, feature, createdAt])
|
||||||
|
@@index([userId, kind, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
// ===== CLUSTERING & BRIDGE NOTES =====
|
// ===== CLUSTERING & BRIDGE NOTES =====
|
||||||
|
|
||||||
model NoteCluster {
|
model NoteCluster {
|
||||||
|
|||||||
@@ -21,18 +21,40 @@
|
|||||||
blue: 1,
|
blue: 1,
|
||||||
};
|
};
|
||||||
var theme = allowed[raw] ? raw : 'light';
|
var theme = allowed[raw] ? raw : 'light';
|
||||||
root.classList.remove('dark');
|
|
||||||
root.removeAttribute('data-theme');
|
// Synchroniser le cookie pour les prochains rendus serveur (anti-flash)
|
||||||
|
try {
|
||||||
|
document.cookie =
|
||||||
|
'memento-theme=' +
|
||||||
|
encodeURIComponent(theme) +
|
||||||
|
'; path=/; max-age=31536000; SameSite=Lax';
|
||||||
|
if (!stored) localStorage.setItem('theme-preference', theme);
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ne jamais retirer .dark avant de le remettre
|
||||||
|
var wantsDark = false;
|
||||||
if (theme === 'auto') {
|
if (theme === 'auto') {
|
||||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark');
|
wantsDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
} else if (theme === 'dark') {
|
} else if (theme === 'dark' || theme === 'midnight') {
|
||||||
|
wantsDark = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wantsDark) {
|
||||||
root.classList.add('dark');
|
root.classList.add('dark');
|
||||||
} else if (theme === 'light') {
|
} else {
|
||||||
/* :root papier */
|
root.classList.remove('dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (theme === 'auto' || theme === 'dark' || theme === 'light') {
|
||||||
|
root.removeAttribute('data-theme');
|
||||||
} else {
|
} else {
|
||||||
root.setAttribute('data-theme', theme);
|
root.setAttribute('data-theme', theme);
|
||||||
if (theme === 'midnight') root.classList.add('dark');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
root.setAttribute('data-applied-theme', theme);
|
||||||
|
|
||||||
var accentStored = localStorage.getItem('accent-color');
|
var accentStored = localStorage.getItem('accent-color');
|
||||||
var effectiveAccent = accentStored || defaultAccent;
|
var effectiveAccent = accentStored || defaultAccent;
|
||||||
root.style.setProperty('--color-brand-accent', effectiveAccent);
|
root.style.setProperty('--color-brand-accent', effectiveAccent);
|
||||||
|
|||||||
94
memento-note/tests/unit/slide-deck-substance.test.ts
Normal file
94
memento-note/tests/unit/slide-deck-substance.test.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
assertDeckHasSubstance,
|
||||||
|
isSlideBodyEmpty,
|
||||||
|
listEmptyBodySlides,
|
||||||
|
normalizeSlideDeck,
|
||||||
|
} from '@/lib/ai/services/slide-content-quality'
|
||||||
|
|
||||||
|
describe('slide deck substance gate', () => {
|
||||||
|
it('detects title-only body slides (the bug that shipped empty decks)', () => {
|
||||||
|
const emptyBullets = { type: 'bullets', title: 'Contexte marché', items: [] }
|
||||||
|
expect(isSlideBodyEmpty(emptyBullets)).toBe(true)
|
||||||
|
|
||||||
|
const withItems = {
|
||||||
|
type: 'bullets',
|
||||||
|
title: 'Les délais ont doublé',
|
||||||
|
items: ['Lead time 12→24 jours', 'Priorités instables en sprint'],
|
||||||
|
}
|
||||||
|
expect(isSlideBodyEmpty(withItems)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects decks that only have titles', () => {
|
||||||
|
const bad = normalizeSlideDeck({
|
||||||
|
title: 'Test',
|
||||||
|
slides: [
|
||||||
|
{ type: 'title', title: 'Test' },
|
||||||
|
{ type: 'bullets', title: 'Point A', items: [] },
|
||||||
|
{ type: 'bullets', title: 'Point B' },
|
||||||
|
{ type: 'summary', title: 'Fin', items: [] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const gate = assertDeckHasSubstance(bad)
|
||||||
|
expect(gate.ok).toBe(false)
|
||||||
|
if (!gate.ok) {
|
||||||
|
expect(gate.emptyIndexes.length).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts a minimal solid deck', () => {
|
||||||
|
const good = normalizeSlideDeck({
|
||||||
|
title: 'Accélérer la livraison',
|
||||||
|
slides: [
|
||||||
|
{
|
||||||
|
type: 'title',
|
||||||
|
title: 'Accélérer la livraison',
|
||||||
|
subtitle: 'Trois leviers en 90 jours',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'bullets',
|
||||||
|
title: 'Les délais ont doublé faute de priorisation',
|
||||||
|
items: [
|
||||||
|
'Lead time passé de 12 à 24 jours',
|
||||||
|
'40% des tickets changent de priorité en sprint',
|
||||||
|
'Dépendances inter-équipes bloquent 1 livraison sur 3',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'cards',
|
||||||
|
title: 'Trois leviers actionnables',
|
||||||
|
cards: [
|
||||||
|
{ title: 'WIP limit', description: 'Max 2 items en cours par personne' },
|
||||||
|
{ title: 'Backlog unique', description: 'Un seul ordre partagé produit/tech' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'summary',
|
||||||
|
title: 'Prochaines décisions',
|
||||||
|
items: [
|
||||||
|
'Valider WIP limit cette semaine',
|
||||||
|
'Fusionner les backlogs sous 10 jours',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const gate = assertDeckHasSubstance(good)
|
||||||
|
expect(gate.ok).toBe(true)
|
||||||
|
expect(listEmptyBodySlides(good.slides)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags empty charts and empty stats as empty bodies', () => {
|
||||||
|
expect(isSlideBodyEmpty({ type: 'chart', title: 'KPI', data: [] })).toBe(true)
|
||||||
|
expect(
|
||||||
|
isSlideBodyEmpty({
|
||||||
|
type: 'chart',
|
||||||
|
title: 'KPI',
|
||||||
|
data: [
|
||||||
|
{ label: 'Q1', value: 10 },
|
||||||
|
{ label: 'Q2', value: 20 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
expect(isSlideBodyEmpty({ type: 'stats', title: 'Chiffres', stats: [] })).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
133
memento-note/tests/unit/slide-source-assets.test.ts
Normal file
133
memento-note/tests/unit/slide-source-assets.test.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { extractFormulas, extractSourceAssets } from '@/lib/ai/services/slide-source-assets'
|
||||||
|
import {
|
||||||
|
assertDeckHasSubstance,
|
||||||
|
injectEquationSlidesFromFormulas,
|
||||||
|
normalizeSlideDeck,
|
||||||
|
} from '@/lib/ai/services/slide-content-quality'
|
||||||
|
import { buildPresentationHTML } from '@/lib/ai/tools/slides-html-builder'
|
||||||
|
import { encodeSlideIntentDescription, decodeSlideIntentDescription } from '@/lib/ai/services/slide-intent'
|
||||||
|
|
||||||
|
describe('extractFormulas for STEM notes', () => {
|
||||||
|
it('extracts display math $$...$$', () => {
|
||||||
|
const text = 'On étudie $$\\frac{dy}{dx} + P(x)y = Q(x)$$ et la solution.'
|
||||||
|
const f = extractFormulas(text)
|
||||||
|
expect(f.some((x) => x.includes('dy') || x.includes('frac'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts equation-like lines', () => {
|
||||||
|
const text = 'La dérivée:\ny\' + 2y = e^x\nPuis on intègre.'
|
||||||
|
const f = extractFormulas(text)
|
||||||
|
expect(f.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags math domain for differential equations notes', () => {
|
||||||
|
const assets = extractSourceAssets(
|
||||||
|
'Cours sur les équations différentielles. $$y\' + P(x)y = Q(x)$$. Méthode du facteur intégrant.',
|
||||||
|
)
|
||||||
|
expect(assets.hasMath).toBe(true)
|
||||||
|
expect(assets.formulas.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('equation slides render with katex hooks', () => {
|
||||||
|
it('emits data-latex and katex CDN for equation slides', () => {
|
||||||
|
const deck = normalizeSlideDeck({
|
||||||
|
title: 'EDO',
|
||||||
|
theme: 'clinical-precision',
|
||||||
|
slides: [
|
||||||
|
{ type: 'title', title: 'Équations différentielles', subtitle: 'Cours' },
|
||||||
|
{
|
||||||
|
type: 'equation',
|
||||||
|
title: 'Forme générale',
|
||||||
|
equations: [{ latex: "y' + P(x)y = Q(x)", label: 'Linéaire 1er ordre' }],
|
||||||
|
explanation: 'Forme standard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'bullets',
|
||||||
|
title: 'Méthode',
|
||||||
|
items: ['Identifier P et Q', 'Calculer le facteur intégrant', 'Intégrer les deux membres'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'summary',
|
||||||
|
title: 'À retenir',
|
||||||
|
items: ['Forme standard', 'Facteur intégrant', 'Condition initiale'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(assertDeckHasSubstance(deck).ok).toBe(true)
|
||||||
|
const html = buildPresentationHTML({
|
||||||
|
title: deck.title,
|
||||||
|
theme: deck.theme,
|
||||||
|
slides: deck.slides as any,
|
||||||
|
})
|
||||||
|
expect(html).toContain('katex')
|
||||||
|
expect(html).toContain('data-latex')
|
||||||
|
expect(html).toContain("y' + P(x)y = Q(x)")
|
||||||
|
// cards grid must not force 2 empty columns for 1 card
|
||||||
|
const oneCard = buildPresentationHTML({
|
||||||
|
title: 't',
|
||||||
|
theme: 'architectural-saas',
|
||||||
|
slides: [
|
||||||
|
{ type: 'title', title: 'T' },
|
||||||
|
{
|
||||||
|
type: 'cards',
|
||||||
|
title: 'One',
|
||||||
|
cards: [{ title: 'Seul', description: 'Plein largeur' }],
|
||||||
|
},
|
||||||
|
{ type: 'summary', title: 'S', items: ['a', 'b'] },
|
||||||
|
] as any,
|
||||||
|
})
|
||||||
|
expect(oneCard).toContain('repeat(1,minmax(0,1fr))')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('STEM gate + model + intent', () => {
|
||||||
|
it('fails substance when formulas required but no equation latex', () => {
|
||||||
|
const deck = normalizeSlideDeck({
|
||||||
|
title: 'EDO',
|
||||||
|
slides: [
|
||||||
|
{ type: 'title', title: 'EDO' },
|
||||||
|
{ type: 'bullets', title: 'Intro', items: ['a', 'b', 'c'] },
|
||||||
|
{ type: 'summary', title: 'Fin', items: ['x', 'y'] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const gate = assertDeckHasSubstance(deck, {
|
||||||
|
requireFormulas: ["y' + P y = Q"],
|
||||||
|
})
|
||||||
|
expect(gate.ok).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('injects equation slides from formulas', () => {
|
||||||
|
const thin = normalizeSlideDeck({
|
||||||
|
title: 'EDO',
|
||||||
|
theme: 'clinical-precision',
|
||||||
|
slides: [
|
||||||
|
{ type: 'title', title: 'EDO', subtitle: 'Cours' },
|
||||||
|
{ type: 'bullets', title: 'Méthode', items: ['A', 'B', 'C'] },
|
||||||
|
{ type: 'summary', title: 'Fin', items: ['1', '2'] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const filled = injectEquationSlidesFromFormulas(thin, ["y' + P(x)y = Q(x)", '\\mu(x) = e^{\\int P}'], {
|
||||||
|
lang: 'fr',
|
||||||
|
})
|
||||||
|
expect(filled.slides.some((s) => s.type === 'equation')).toBe(true)
|
||||||
|
const gate = assertDeckHasSubstance(filled, {
|
||||||
|
requireFormulas: ["y' + P(x)y = Q(x)"],
|
||||||
|
})
|
||||||
|
expect(gate.ok).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('encodes and decodes slide intent', () => {
|
||||||
|
const enc = encodeSlideIntentDescription({
|
||||||
|
purpose: 'course',
|
||||||
|
audience: 'student',
|
||||||
|
slideCount: 6,
|
||||||
|
template: 'auto',
|
||||||
|
})
|
||||||
|
const dec = decodeSlideIntentDescription(enc)
|
||||||
|
expect(dec.purpose).toBe('course')
|
||||||
|
expect(dec.audience).toBe('student')
|
||||||
|
expect(dec.slideCount).toBe(6)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user