diff --git a/.impeccable/config.json b/.impeccable/config.json new file mode 100644 index 0000000..e3f8ebb --- /dev/null +++ b/.impeccable/config.json @@ -0,0 +1,45 @@ +{ + "buildPath": "comp", + "detector": { + "ignoreRules": [], + "ignoreFiles": [], + "ignoreValues": [ + { + "rule": "ai-color-palette", + "value": "*", + "files": [ + "memento-note/components/intelligence-hub.tsx" + ], + "createdAt": "2026-08-26T18:03:40.105Z", + "reason": "Palette violette déjà en place sur le widget ponts du dashboard (incumbent), pas une nouvelle teinte IA." + }, + { + "rule": "side-tab", + "value": "*", + "files": [ + "memento-note/components/note-editor/note-editor-toolbar.tsx" + ], + "createdAt": "2026-08-26T18:14:54.764Z", + "reason": "Bordure existante de la toolbar éditeur (incumbent), pas une nouvelle carte AI." + }, + { + "rule": "border-accent-on-rounded", + "value": "*", + "files": [ + "memento-note/app/(main)/insights/page.tsx" + ], + "createdAt": "2026-08-26T18:23:06.241Z", + "reason": "Styles existants de /insights (incumbent), pas une nouvelle carte." + }, + { + "rule": "gray-on-color", + "value": "*", + "files": [ + "memento-note/app/(main)/insights/page.tsx" + ], + "createdAt": "2026-08-26T18:23:06.270Z", + "reason": "Styles existants de /insights (incumbent), pas un nouveau texte gris." + } + ] + } +} diff --git a/AGENTS.md b/AGENTS.md index 83c2172..76189d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ - **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` → `` obligatoire pour la navigation interne, pas ``) ; **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) ; inscription **email/mdp** : **email de confirmation obligatoire** (`lib/auth/email-verification.ts`, envoi à l'inscription) — login bloqué tant que `emailVerified` est null ; OAuth Google marque l'email comme vérifié automatiquement ; 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) ; **navigateur intégré Cursor peu fiable pour le login** (focus volé, cookies isolés, OAuth Google souvent bloqué) — préférer un vrai navigateur pour la connexion manuelle, et pour l'automation (démos/Playwright) une **session JWT/cookies stockée** plutôt que de rejouer le login dans le browser Cursor. -- 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** ; **Interactive Demo** (player TipTap) : sélecteur de vitesse **visible** (`0.5x` / `1x` / `2x` / `4x` — pas un bouton cycle cryptique) ; scènes **dignes du contenu** (pas de boîtes jouet 2 nœuds / texte plat) — labels et équations issus de la note (TipTap math `data-latex` / KaTeX) ; génération IA via le **modèle slides** (`getSlidesProvider`), **pas** la lane chat ; **Page interactive** (explainer publié, **≠** Interactive Demo TipTap) : pipeline note → PageSpec → `/p/{slug}` ; priorité **vitesse/fiabilité** de génération (chemin **déterministe** `buildPageFromNote` / démo déterministe plutôt que LLM lent qui timeoute ~150 s vs abort client) ; simulateurs (ex. cycle de Carnot) : flèches correctement orientées, têtes SVG non déformées, flux branchés sur la machine (pas de flèches flottantes) ; **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 ; **vidéos promo** : VO **anglaise** ; montrer de **vraies features authentifiées** (dashboard, Memory Echo, Insights, Revision, Agents, menu `/` éditeur…) — pas un slideshow landing seul ; démos note/éditeur : **masquer sidebar/carnets** pour focaliser la note ; style kinetic OK (coupes rapides + texte à l'écran) mais **pas de fade-to-black** entre plans (clignote) — **crossfades** ; après saisie, montrer le **sparkle** titre IA ; **interdit Ken Burns / `zoompan`** (surtout sur fonds pointillés → moiré / tremblement) — images fixes, fond uni stable à la capture. +- 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** ; **Interactive Demo** (player TipTap) : sélecteur de vitesse **visible** (`0.5x` / `1x` / `2x` / `4x` — pas un bouton cycle cryptique) ; scènes **dignes du contenu** (pas de boîtes jouet 2 nœuds / texte plat) — labels et équations issus de la note (TipTap math `data-latex` / KaTeX) ; génération IA via le **modèle slides** (`getSlidesProvider`), **pas** la lane chat ; **Page interactive** (explainer publié, **≠** Interactive Demo TipTap) : pipeline note → PageSpec → `/p/{slug}` ; priorité **vitesse/fiabilité** de génération (chemin **déterministe** `buildPageFromNote` / démo déterministe plutôt que LLM lent qui timeoute ~150 s vs abort client) ; simulateurs (ex. cycle de Carnot) : flèches correctement orientées, têtes SVG non déformées, flux branchés sur la machine (pas de flèches flottantes) ; unités température commutables **K / °C / °F** ; distinguer **énergie (kJ)** et **puissance (W)** ; modes **frigo / PAC / moteur** ; **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 ; **vidéos promo** : VO **anglaise** ; montrer de **vraies features authentifiées** (dashboard, Memory Echo, Insights, Revision, Agents, menu `/` éditeur…) — pas un slideshow landing seul ; démos note/éditeur : **masquer sidebar/carnets** pour focaliser la note ; style kinetic OK (coupes rapides + texte à l'écran) mais **pas de fade-to-black** entre plans (clignote) — **crossfades** ; après saisie, montrer le **sparkle** titre IA ; **interdit Ken Burns / `zoompan`** (surtout sur fonds pointillés → moiré / tremblement) — images fixes, fond uni stable à la capture. - 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` ; **essai gratuit abonnement = 7 jours** (`SUBSCRIPTION_TRIAL_DAYS` dans `lib/billing/trial-constants.ts` — pas 14) : afficher clairement sur **landing** et UI facturation (**i18n partout**) ; **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). @@ -26,6 +26,6 @@ - **Hosts** : machine agent/dev (**192.168.1.83**, Vibe/annotations locales) ≠ prod (**192.168.1.190**, dépôt `/opt/memento`) — ne jamais diagnostiquer l'un pour l'autre. Production : conteneur `memento-note` port **3000**, URL publique **https://memento-note.com** (nginx + Cloudflare ; ancien domaine note.parsanet.org) ; `NEXTAUTH_URL` aligné sur ce domaine ; auth prod requiert `AUTH_GOOGLE_ID`/`AUTH_GOOGLE_SECRET` dans `.env.docker` (sinon OAuth `invalid_client`) et `ALLOW_REGISTRATION=true` pour l'inscription email ; email sortant via **Resend** (`SMTP_FROM` ex. `noreply@memento-note.com`, domaine vérifié sur resend.com) ; monitoring Grafana via `GRAFANA_BIND` / `GRAFANA_URL` dans `.env.docker` (pas d'IP en dur dans le compose) ; deploy (`deploy.yaml` / `deploy-prod.sh`) **sans toucher Postgres** (pas de `postgresql-client`, pas de migrations auto en prod). - CI/CD Gitea : `.gitea/workflows/ci.yaml` — CI sur `ubuntu-24.04` (lint + tests + build validation), deploy sur runner **`docker-host`** (build local `/tmp/web-artifact.tgz`, sans artifacts inter-runners) ; deploy manuel via `.gitea/workflows/deploy.yaml` ou `bash scripts/deploy-prod.sh`. - Migrations prebuilt + vérif deploy : `docker compose exec memento-note node ./node_modules/prisma/build/index.js migrate deploy` (pas `npx prisma`) ; helper `scripts/migrate-docker.sh` ; `GET /api/build-info` (SHA Git) ; comparer `127.0.0.1:3000` et domaine Cloudflare — purger cache si versions divergent ; 403 `/api/manifest` côté domaine = souvent Cloudflare. -- Éditeur riche : `rich-text-editor.tsx` — `immediatelyRender: false` ; activer **`shouldRerenderOnTransaction: false`** (quick win perf TipTap 2.5) ; **drag handle / menu bloc** via **`@tiptap/extension-drag-handle-react`** (spec officielle — pas de double plugin `DragHandleExtension` + composant React, pas de repositionnement maison) ; poignée dans **colonne gutter fixe** du wrapper (padding + `getReferencedVirtualElement`), pas sur le bord des listes/numéros ; CSS : **pas `opacity:0` sur `.drag-handle`** (visibilité gérée par le plugin) ; config/callbacks **stables hors composant** ; fondation blocs : `tiptap-unique-id-extension.ts` / **`data-id` persisté à la sauvegarde** (références « Copier la référence ») ; **Smart Paste** : `lib/editor/smart-paste-extension.ts` ; **peek split** note source : `lib/note-peek-sync.ts` + `note-editor-split-peek.tsx` ; **Interactive Demo** : JSON déclaratif allowlisté + player TipTap (`lib/interactive-demo/` — `validate.ts` / `resolve.ts` / schéma ; `components/interactive-demo/` ; extension `tiptap-interactive-demo-extension` ; API `/api/ai/interactive-demo` via `getSlidesProvider` + extraction formules type slides, labels KaTeX) — le player **consomme** `resolve.ts` (pas de logique de portée dans React) ; **Page interactive** (explainer) : `lib/interactive-page/` (PageSpecV1, schema/validate/normalize, fixtures thermo/Carnot) + `components/interactive-page/` (`page-view`, `sim-block`, publish dialog) + API `/api/ai/interactive-page` ; preview `/dev/interactive-page` ; publication publique `/p/{slug}` ; **conversion markdown → texte enrichi** : un **seul** convertisseur `lib/markdown-to-html.ts` (`markdownToHtml`, gfm+breaks) + une **action atomique** `convertToRichText(html)` dans `note-editor-context.tsx` (applique le HTML **immédiatement** via `setContentImmediate` — pas le `setContent` débouncé 800ms — et bascule `isMarkdown=false`) ; toolbar (`handleConvertToRichtext`) **et** chat IA contextuel (`contextual-ai-chat.tsx`, action `toRichText` → `/api/ai/convert-markdown`) passent par cette action unique (ne pas dupliquer la conversion ni oublier de basculer `isMarkdown`) ; **US-4 `structuredViewBlock`** (`tiptap-structured-view-block-extension.tsx`, `structured-view-block-embed.tsx`) : **dual-mode** — base locale autonome par défaut (`/database`, `/vue`, `isLocal: true`) + option « Lier à un carnet » (Structured Views) ; i18n `structuredViewBlock.*` ; **rejeté** : ancien `databaseBlock` « Auteurs & Œuvres » et spec embed-only `docs/story-nextgen-editor-us4-redesign.md` ; epic active `docs/story-nextgen-editor.md` — priorité **PERF > NEXTGEN > UX > MOBILE > MARKDOWN**. +- Éditeur riche : `rich-text-editor.tsx` — `immediatelyRender: false` ; activer **`shouldRerenderOnTransaction: false`** (quick win perf TipTap 2.5) ; **drag handle / menu bloc** via **`@tiptap/extension-drag-handle-react`** (spec officielle — pas de double plugin `DragHandleExtension` + composant React, pas de repositionnement maison) ; poignée dans **colonne gutter fixe** du wrapper (padding + `getReferencedVirtualElement`), pas sur le bord des listes/numéros ; CSS : **pas `opacity:0` sur `.drag-handle`** (visibilité gérée par le plugin) ; config/callbacks **stables hors composant** ; fondation blocs : `tiptap-unique-id-extension.ts` / **`data-id` persisté à la sauvegarde** (références « Copier la référence ») ; **Smart Paste** : `lib/editor/smart-paste-extension.ts` ; **peek split** note source : `lib/note-peek-sync.ts` + `note-editor-split-peek.tsx` ; **Interactive Demo** : JSON déclaratif allowlisté + player TipTap (`lib/interactive-demo/` — `validate.ts` / `resolve.ts` / schéma ; `components/interactive-demo/` ; extension `tiptap-interactive-demo-extension` ; API `/api/ai/interactive-demo` via `getSlidesProvider` + extraction formules type slides, labels KaTeX) — le player **consomme** `resolve.ts` (pas de logique de portée dans React) ; **Page interactive** (explainer) : `lib/interactive-page/` (PageSpecV1, schema/validate/normalize, fixtures thermo/Carnot) + `components/interactive-page/` (`page-view`, `sim-block`, publish dialog) + API `/api/ai/interactive-page` ; preview `/dev/interactive-page` ; publication publique `/p/{slug}` ; **simulateur Carnot** : `lib/simulators/carnot-cycle*.ts` + `components/simulators/carnot-cycle*.tsx` (modes frigo/PAC/moteur, unités K/°C/°F, énergie kJ vs puissance W) ; **conversion markdown → texte enrichi** : un **seul** convertisseur `lib/markdown-to-html.ts` (`markdownToHtml`, gfm+breaks) + une **action atomique** `convertToRichText(html)` dans `note-editor-context.tsx` (applique le HTML **immédiatement** via `setContentImmediate` — pas le `setContent` débouncé 800ms — et bascule `isMarkdown=false`) ; toolbar (`handleConvertToRichtext`) **et** chat IA contextuel (`contextual-ai-chat.tsx`, action `toRichText` → `/api/ai/convert-markdown`) passent par cette action unique (ne pas dupliquer la conversion ni oublier de basculer `isMarkdown`) ; **US-4 `structuredViewBlock`** (`tiptap-structured-view-block-extension.tsx`, `structured-view-block-embed.tsx`) : **dual-mode** — base locale autonome par défaut (`/database`, `/vue`, `isLocal: true`) + option « Lier à un carnet » (Structured Views) ; i18n `structuredViewBlock.*` ; **rejeté** : ancien `databaseBlock` « Auteurs & Œuvres » et spec embed-only `docs/story-nextgen-editor-us4-redesign.md` ; epic active `docs/story-nextgen-editor.md` — priorité **PERF > NEXTGEN > UX > MOBILE > MARKDOWN**. - Sync mutations notes entre composants : `memento-note/lib/note-change-sync.ts` (`emitNoteChange`, événement `NOTE_CHANGE_EVENT`) ; tracking quotas IA : événement `ai-usage-changed` (window) — doit être dispatché depuis **tous** les points d'appel IA (recherche sémantique, auto-tag, auto-title, chat, reformulation, analyse carnet, etc.) ; UI quotas : `usage-meter.tsx` (polling 5s + écoute événement). - Roadmap / écart prototype vs prod : Web Clipper — **`ClipperSimulator.tsx` = référence design uniquement** (pas de simulateur en prod) ; extension **`memento-note/extension/`** v0.3 **Side Panel** (clip page/sélection/lien ; popup Chrome se ferme au clic page — Side Panel pour la sélection) ; i18n extension **15 langues** (`_locales/`, détection locale navigateur ; script `extension/i18n/generate-translations.cjs`) ; **`host_permissions`** incl. LAN ; **URL serveur configurable en dev**, adresse prod figée en release ; cookies/session alignés avec l'instance cible ; **Flashcards IA SM-2 livrées** : `/revision`, `/api/flashcards/*`, génération depuis l'éditeur (GraduationCap) — réf. prototype `RevisionView.tsx` ; **Structured Views partiellement livrées** : schéma par carnet, Table/Kanban, champs partagés et valeurs par note (`/home` + toolbar carnet) — **suivi de tâches par carnet via Kanban structuré** (pas de vue agrégée Notes/Tâches sur la home ; cases à cocher inline dans les notes) ; **Éditeur Next-Gen livré** (doc `docs/story-nextgen-editor.md` **périmé** — vérifier le code) : US-1 poignée gutter unique (`editor-block-drag-handle.tsx` + `lib/editor/global-drag-handle-extension.ts`, extension Novel `tiptap-extension-global-drag-handle`), US-2 menu action bloc (`block-action-menu.tsx`), US-3 Smart Paste transclusion (`smart-paste-extension.ts` + `smart-paste-menu.tsx`), US-4 database inline (`structuredViewBlock`), plus `data-id` / `liveBlock` / peek split ; **`/insights` livré** : `app/(main)/insights/page.tsx` + `network-graph.tsx` (clusters sémantiques, bridge notes ; **≠ `/graph`**) ; **US-TEMPORAL reporté** ; encore en gap : transclusion bidirectionnelle complète, graphe knowledge enrichi (`GraphKnowledgeMap.tsx`) ; publication **Chrome Web Store** : icônes 16/48/128, privacy policy, `host_permissions` prod restreints vs build dev ; **Wizards** : `NotebookOrganizerDialog` (tags/doublons) branché via bouton "Tags IA" dans `home-client.tsx` — `StructuredViewsWizard` encore **orphelin** (pas de point d'entrée UI) ; **Publication web** (`note-editor-toolbar.tsx`) : deux modes — simple (copie directe) + IA (2-3 templates **visuellement distincts**, reformulation/mise en page **adaptée au contenu** — exercices, toggles, callouts — **images incluses**, KaTeX pour équations) — quota IA consommé uniquement sur publication IA ; l'utilisateur rejette les rendus « copie du texte » / mise en page nulle ; **champs DB publication** sur modèle `Note` : `publicSlug` (pas `publishedSlug`), `publishedContent` (pas `publishedHtml`) — `publishMode` est paramètre API uniquement (`mode: 'simple' | 'ai'`), **pas** un champ en base ; **Second Brain dashboard** (`/home`, `dashboard-view.tsx`) **livré** : layout v5 configurable (~15 widgets, `/api/dashboard/layout`), briefing agrégé (`/api/briefing`), pistes fast+enrich (`/api/briefing/paths`, `paths-fast.ts`), suggestions agents, scan Gmail (`GmailScanHistory`), navigation sidebar `/home` ; bento interactif — `MindMapCard` (clusters), suggestions agents (`AgentSuggestion`, cron), Memory Echo, sentiment, inbox/révisions ; **Gmail OAuth** (`/api/integrations/gmail/*`, pattern Calendar) — scan vols/colis/abonnements → notes + rappels ; crons entrypoint : agents, agent-suggestions, clusters, reminders, gmail-scan, sync-usage ; **vidéos promo** : pipeline `promo-video/` (`make_promo.py`, `make_promo_features.py`, `make_promo_slash.py`, `make_promo_slash_kinetic.py`, `SCENARIO.md`) — captures app authentifiée (Playwright + session JWT), VO EN (edge-tts), montage stable sans zoom (crossfades si kinetic). diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..95fba6c --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,88 @@ +# Product + + + +## Platform + +web + +## Users + +Personnes qui capturent des idées, notes et savoirs au fil du temps et ont besoin de les retrouver, les relier et les actionner — pas seulement de les stocker. + +Audiences confirmées (sans hiérarchie exclusive) : +- étudiants et chercheurs (cours, papers, synthèses, révision) ; +- freelances et professionnels indépendants (projets, briefs, clients) ; +- équipes (partage de contexte, agents, Second Brain collectif selon le palier) ; +- personnes « lambda » qui veulent un second cerveau accessible, pas un outil expert-only. + +Situation type : trop de notes / dossiers, recherche trop littérale, idées perdues ; le job est de transformer la capture quotidienne en système vivant (liens, briefing, révision, actions). + +## Product Purpose + +**Memento** est une application de prise de notes intelligente positionnée comme **Second Brain** : elle relie les idées dans le temps, fait émerger des connexions cachées, et transforme les carnets en système searchable, briefable et actionnable (matin / routines). + +Succès produit : l’utilisateur écrit dans Memento et le système lui rend de la valeur *pendant* et *après* l’écriture (Memory Echo, insights, dashboard, flashcards, agents) — pas une pile morte de fichiers. + +## Positioning + +Pas « une app de notes de plus ». Mécanisme différenciant : un Second Brain qui **se connecte pendant que vous écrivez** — similarité sémantique réelle, ponts entre notes/carnets, aperçu côte à côte, dashboard Second Brain (briefing, pistes, checklist), agents qui **écrivent dans les carnets**, recherche sémantique, révision espacée — plutôt qu’un chat jetable ou un classement manuel de dossiers. + +Un concurrent « Keep/Notion générique » ne peut pas honnêtement reprendre la promesse *Second Brain vivant qui se connecte tout seul* sans ces mécanismes. + +## Operating Context + +- App web principale : `memento-note/` (Next.js App Router), URL prod `https://memento-note.com`. +- Capture & édition : notes riche / Markdown, carnets, tags, structured views (table/kanban), peek split note liée, publication web, pages interactives. +- Second Brain au quotidien : dashboard `/home`, Memory Echo dans l’éditeur, `/insights` (clusters sémantiques), `/revision` (flashcards SM-2), agents, rappels, scan Gmail (vols/colis/abonnements). +- Référence UX métier / prototype : `architectural-grid/` (et base `architectural-grid1/`) — à consulter avant implémentation UI significative ; la vérité produit d’interface suit le prototype quand il y a conflit d’interprétation. +- Extension navigateur (Web Clipper / Side Panel), serveur MCP, intégrations (Calendar/Gmail pattern). +- Companion mobile Expo (`memento-mobile/`) existe ; **l’autorité design Impeccable pour ce PRODUCT est le web** (PWA / responsive inclus). Le natif partage la vérité produit mais n’impose pas un langage UI `adaptive` tant qu’il n’est pas le surface principal de craft. +- Auth : priorité Google OAuth ; email/mdp avec email vérifié obligatoire. +- i18n : 15 langues ; calendrier iranien + chiffres persans + RTL pour le persan (et arabe) — app et extension. +- Facturation : paliers BASIC/PRO/BUSINESS/ENTERPRISE, quotas IA, essai abonnement **7 jours** ; BASIC sans MCP ; BYOK possible selon config. + +## Capabilities and Constraints + +**Capacités confirmées (non exhaustif)** : notes/carnets, recherche sémantique, Memory Echo, insights réseau, dashboard Second Brain configurable, agents, flashcards SM-2, structured views, Web Clipper, MCP (hors BASIC), publication simple/IA, pages interactives, multilinguisme. + +**Contraintes durables** : +- Nom produit : **Memento** uniquement (jamais « Momento ») dans libellés, metadata, pages publiques, docs. +- Libellés UI via i18n (`memento-note/locales/*.json`) — pas de texte en dur ; pas de marques tierces dans les libellés utilisateur. +- Qualité UX prioritaire ; solutions long terme plutôt que rustines. +- Pas de perte de données : jamais de reset/drop/truncate DB sans backup vérifié + confirmation explicite (voir `CLAUDE.md` / règles projet). +- Quotas IA : réservation atomique avant appel ; chaque usage IA décompte. +- Une user story / feature à la fois, validation utilisateur avant la suivante. +- Docs de stories souvent périmées — vérifier le code avant de « (re)développer ». + +**Ouvert / hors scope Impeccable init** : détails de roadmap feature-par-feature ; choix esthétiques (monde visuel → `new-work` / `document`). + +## Brand Commitments + +- Marque : **Memento** ; entité visible « Memento Labs » dans certains pied-de-page. +- Promesse marketing : Second Brain — « Il se souvient de ce que vous avez oublié » / se connecte pendant que vous écrivez. +- Voix : claire, concrète, orientée bénéfice utilisateur ; français correct en communication produit FR ; éviter le jargon non expliqué dans l’UI. +- Référence prototype `architectural-grid` pour les flux métier (liste/carte notes, recherche, insights, sidebar, etc.) — engagement produit confirmé, pas une esthétique figée dans ce fichier. + +## Evidence on Hand + +- Copy landing et produit : `memento-note/locales/*.json` (ex. `landing.*`), `components/landing-page.tsx`. +- README produit : `README.fr.md` / `README.md`, guide utilisateur `docs/guide-utilisateur/`. +- Prototypes UI : `architectural-grid/`, `architectural-grid1/`. +- Extension : `memento-note/extension/`. +- Assets promo / démos : `promo-video/` (captures authentifiées, VO EN). +- **Ne pas inventer** : témoignages clients, logos presse, benchmarks chiffrés non présents dans le repo. + +## Product Principles + +1. **Second Brain d’abord** — chaque surface renforce connexion, rappel et action ; pas le stockage pour le stockage. +2. **Valeur pendant l’écriture** — Memory Echo, peek, agents et briefing doivent être exploitables dans le flux réel, pas décoratifs. +3. **Accessible à la personne lambda, assez profond pour le chercheur** — clarté et aide contextuelle avant jargon ; puissance sans exiger d’être power-user dès le jour 1. +4. **Monde multilingue réel** — RTL, calendriers et embeddings inclus ; pas d’anglais hardcodé quand l’UI est dans une autre langue. +5. **Preuve avant promesse** — s’appuyer sur features et copy existantes ; ne pas fabriquer de preuves marketing. + +## Accessibility & Inclusion + +- Multilinguisme et RTL (fa/ar) sont des exigences produit, pas un bonus. +- Aide contextuelle (« ? ») et libellés lisibles attendus là où l’UX le demande. +- Standard WCAG formel non fixé dans cet init : à traiter comme **ouvert** ; viser une UI utilisable clavier / contraste correcte par défaut sur le web sans inventer une certification. diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md index fbff48a..539d964 100644 --- a/_bmad-output/implementation-artifacts/deferred-work.md +++ b/_bmad-output/implementation-artifacts/deferred-work.md @@ -31,3 +31,17 @@ All BYOK items implemented on 2026-05-30: - **`lib/export/zip-builder.ts` non extrait** — logique inline dans la route ; fonctionnel mais écarte la structure prévue par la story. - **Rate limiting absent sur `GET /api/user/export`** — vecteur d’abus (exports répétés) ; hardening ultérieur. + +## Deferred — revue page interactive (2026-07-24) + +- Régénération section/démo (§7.3 spec origine) + UI auteur associée + tarif regen. +- Page publique interactive : `ReadingProgress` + `CopyLinkButton` (régression UX vs templates magazine/brief/essay). +- Fallback déterministe : libellés complets dans les 13 autres locales + `dir="rtl"` pour fa/ar (actuellement FR/EN seulement). +- `normalize.ts` : caps 2 démos/5 sections plus restrictifs que le validateur (5/8) — aligner si latence LLM acceptable. +- `validateImageSrc` : autoriser uniquement `/uploads` + https (bloquer http:// et //domaine externe — pixel de tracking sur page publique). +- Quality gate « règle d'or » appliquée aussi au `pageSpec` posté directement à `/api/notes/publish` (pas seulement au flux LLM). +- `resolve.ts` : numérotation des badges par démo (pas réinitialisée par acte). +- `demo-scene-view.tsx:479` : warning eslint exhaustive-deps (`edges` dans useMemo). +- `page-view.tsx` useScrollReveal : scoper les `querySelectorAll` via ref (multi-instance). +- Test `rejects hex color` : le faire échouer via le scan sémantique (pas l'enum Zod) pour prouver le scan. +- Page publiée sans JS : démos à l'état final au premier paint (spec §9.5) — nécessite SSR static + swap hydratation. diff --git a/docs/stripe-billing-guide.md b/docs/stripe-billing-guide.md index 8e928b5..eef6ee7 100644 --- a/docs/stripe-billing-guide.md +++ b/docs/stripe-billing-guide.md @@ -145,6 +145,7 @@ Dans **Stripe Dashboard** → **Développeurs** → **Webhooks** : - `customer.subscription.created` - `customer.subscription.updated` - `customer.subscription.deleted` + - `customer.subscription.trial_will_end` (rappel email ~3 jours avant fin d’essai) - `invoice.payment_failed` 4. Copier la **signature secrète** → `STRIPE_WEBHOOK_SECRET` (commence par `whsec_...`) @@ -246,11 +247,12 @@ Utilisateur Frontend Backend | Événement Stripe | Action Memento | Statut Prisma | |------------------|---------------|---------------| -| `checkout.session.completed` | Upsert subscription avec tier/periode | `ACTIVE` | +| `checkout.session.completed` | Upsert subscription avec tier/periode | Selon Stripe (`TRIALING` ou `ACTIVE`) | | `customer.subscription.created` | Upsert (nouvelle souscription) | Selon Stripe | | `customer.subscription.updated` | Upsert (changement de plan, etc.) | Selon Stripe | | `customer.subscription.deleted` | Marquer annulé | `CANCELED` | | `invoice.payment_failed` | Sync subscription (passage en `PAST_DUE`) | `PAST_DUE` | +| `customer.subscription.trial_will_end` | Sync + email rappel fin d’essai | `TRIALING` | ### 3.3 Mapping statuts Stripe → Prisma diff --git a/mcp-server/README.md b/mcp-server/README.md index f361094..1d94b2a 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -6,7 +6,7 @@ Model Context Protocol (MCP) server for integrating Memento note-taking app with ## Features -- ✅ **22 Tools** for notes, notebooks, labels, and reminders +- ✅ **29 Tools** for notes, notebooks, labels, reminders, semantic similarity, Memory Echo insights, and statistics - 🔒 **API Key Authentication** with secure storage - 🚀 **Performance Optimized** with connection pooling and caching - 📊 **Observability** with Prometheus metrics export @@ -61,7 +61,7 @@ Generate API keys from the Memento web UI: **Settings > MCP**. curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health ``` -## Available Tools (22) +## Available Tools (29) ### Notes (13) @@ -81,7 +81,7 @@ curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health | `batch_move_notes` | Move multiple notes at once | | `batch_delete_notes` | Delete multiple notes at once | -### Notebooks (6) +### Notebooks (7) | Tool | Description | |------|-------------| @@ -102,11 +102,28 @@ curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health | `update_label` | Update a label | | `delete_label` | Delete a label | -### Reminders (1) +### Reminders (3) | Tool | Description | |------|-------------| | `get_due_reminders` | Get due reminders | +| `get_upcoming_reminders` | Get reminders due in the next N hours | +| `update_reminder` | Set, update, or clear a note reminder | + +### Semantic & AI (3) + +| Tool | Description | +|------|-------------| +| `find_similar_notes` | Find notes semantically similar to a given note | +| `get_memory_echo_insights` | List AI-discovered note connections | +| `dismiss_memory_echo_insight` | Mark a Memory Echo insight as viewed/dismissed | + +### Filters & Statistics (2) + +| Tool | Description | +|------|-------------| +| `get_notes_by_label` | List notes by label/tag | +| `get_note_statistics` | Get counts and breakdowns for notes, notebooks, labels | ### Utilities (2) diff --git a/mcp-server/config.js b/mcp-server/config.js index b48a3f7..26fb230 100644 --- a/mcp-server/config.js +++ b/mcp-server/config.js @@ -197,8 +197,9 @@ export function validateConfig() { } } - // Port validation - const portValidation = validatePort(config.port); + // Port validation (check raw value before clamping) + const rawPort = Number.parseInt(env('PORT', '3001'), 10); + const portValidation = validatePort(Number.isNaN(rawPort) ? config.port : rawPort); if (!portValidation.valid) { errors.push({ key: 'PORT', message: portValidation.error, critical: true }); } diff --git a/mcp-server/index-sse.js b/mcp-server/index-sse.js index 4e39557..7ab9289 100644 --- a/mcp-server/index-sse.js +++ b/mcp-server/index-sse.js @@ -394,7 +394,7 @@ app.get('/', (req, res) => { sessions: '/sessions', }, auth: { enabled: config.requireAuth }, - tools: 22, + tools: 29, uptime: process.uptime(), }); }); @@ -445,25 +445,23 @@ app.all( } // Validate tool input if present - if (req.body?.method) { - const toolName = req.body.method; - if (req.body?.params) { - const validation = validateAndSanitize(toolName, req.body.params); - if (!validation.success) { - log('warn', `Validation failed for ${toolName}:`, validation.errors); - return res - .status(400) - .json( - mcpError(McpErrors.INVALID_PARAMS.code, { - detail: 'Input validation failed', - field: validation.errors[0]?.field, - context: { toolName, errors: validation.errors }, - }) - ); - } - // Update request with sanitized data - req.body.params = validation.data; + if (req.body?.method === 'tools/call' && req.body?.params?.name && req.body?.params?.arguments) { + const toolName = req.body.params.name; + const validation = validateAndSanitize(toolName, req.body.params.arguments); + if (!validation.success) { + log('warn', `Validation failed for ${toolName}:`, validation.errors); + return res + .status(400) + .json( + mcpError(McpErrors.INVALID_PARAMS.code, { + detail: 'Input validation failed', + field: validation.errors[0]?.field, + context: { toolName, errors: validation.errors }, + }) + ); } + // Update request with sanitized data + req.body.params.arguments = validation.data; } const ctx = { userId: req.userSession?.userId || null }; @@ -539,7 +537,7 @@ async function main() { Auth: ${config.requireAuth ? 'ENABLED' : 'DISABLED (dev)'} Timeout: ${config.requestTimeout}ms Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'} - Tools: 22 + Tools: 29 Features: ${config.enableMetrics ? 'Metrics' : ''}${config.enableAuditLog ? ', Audit Log' : ''} `); }); diff --git a/mcp-server/index.js b/mcp-server/index.js index f5c732c..0847b0d 100644 --- a/mcp-server/index.js +++ b/mcp-server/index.js @@ -176,7 +176,7 @@ async function main() { Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'} User: ${config.userId || 'all'} Log Level: ${config.logLevel} - Tools: 22 + Tools: 29 `); } diff --git a/mcp-server/package.json b/mcp-server/package.json index d352084..bd99002 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -1,7 +1,7 @@ { "name": "memento-mcp-server", "version": "3.2.0", - "description": "MCP Server for Memento - AI-powered note-taking app. Enhanced with error handling, metrics, rate limiting, and input validation. Provides 22 tools for notes, notebooks, labels, and reminders.", + "description": "MCP Server for Memento - AI-powered note-taking app. Enhanced with error handling, metrics, rate limiting, and input validation. Provides 29 tools for notes, notebooks, labels, reminders, semantic similarity, Memory Echo insights, and statistics.", "type": "module", "main": "index.js", "scripts": { diff --git a/mcp-server/test/test.js b/mcp-server/test/test.js index 053cf02..be033ae 100644 --- a/mcp-server/test/test.js +++ b/mcp-server/test/test.js @@ -5,7 +5,7 @@ * Run with: npm test */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; @@ -124,9 +124,8 @@ describe('MCP Server - Input Validation', () => { it('should allow safe HTML', () => { const xss = checkXSS({ content: 'Hello world' }); - // This will be true because we check for any HTML tags - // In production, you might want more sophisticated checking - expect(xss).toBe(true); + // Safe formatting tags are not flagged as XSS + expect(xss).toBe(false); }); it('should sanitize input', () => { @@ -206,11 +205,18 @@ describe('MCP Server - Tool Definitions', () => { 'update_label', 'delete_label', 'get_due_reminders', + 'get_upcoming_reminders', + 'update_reminder', + 'find_similar_notes', + 'get_memory_echo_insights', + 'dismiss_memory_echo_insight', + 'get_notes_by_label', + 'get_note_statistics', 'export_notes', 'import_notes', ]; - it('should have all expected tools with schemas', () => { + it('should have all expected tools with schemas', async () => { const { toolSchemas } = await import('../validation.js'); for (const toolName of toolNames) { diff --git a/mcp-server/tools.js b/mcp-server/tools.js index 71ac5b2..9da6c8c 100644 --- a/mcp-server/tools.js +++ b/mcp-server/tools.js @@ -19,7 +19,7 @@ const DEFAULT_NOTES_LIMIT = 100; const MAX_NOTES_LIMIT = 500; const NOTE_COLORS = 'default, red, orange, yellow, green, teal, blue, purple, pink, gray'; -const LABEL_COLORS = 'red, orange, yellow, green, teal, blue, purple, pink, gray'; +const LABEL_COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray']; export function parseNote(dbNote) { if (!dbNote) return null; @@ -463,6 +463,100 @@ const toolDefinitions = [ description: 'Get notes with due reminders. Designed for cron/automation.', inputSchema: { type: 'object', properties: {} }, }, + { + name: 'get_upcoming_reminders', + description: 'Get notes with reminders due in the next N hours (default 24).', + inputSchema: { + type: 'object', + properties: { + hours: { type: 'number', description: 'Number of hours ahead to look', default: 24 }, + includeDone: { type: 'boolean', description: 'Include already-done reminders', default: false }, + limit: { type: 'number', description: 'Max results', default: 100 }, + }, + }, + }, + { + name: 'update_reminder', + description: 'Set, update, or clear a reminder on a note.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Note ID' }, + reminder: { type: 'string', description: 'ISO 8601 datetime, or null to clear', nullable: true }, + isReminderDone: { type: 'boolean' }, + reminderRecurrence: { type: 'string', description: 'daily, weekly, monthly, yearly', nullable: true }, + reminderLocation: { type: 'string', nullable: true }, + }, + required: ['id'], + }, + }, + + // ═══ SEMANTIC / AI ═══ + { + name: 'find_similar_notes', + description: 'Find notes semantically similar to a given note using its vector embedding. Requires the note to have an embedding.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Source note ID' }, + limit: { type: 'number', description: 'Max results', default: 10 }, + threshold: { type: 'number', description: 'Minimum cosine similarity (0-1)', default: 0.7 }, + }, + required: ['id'], + }, + }, + { + name: 'get_memory_echo_insights', + description: 'List Memory Echo insights: AI-discovered connections between notes.', + inputSchema: { + type: 'object', + properties: { + includeViewed: { type: 'boolean', description: 'Include already viewed insights', default: false }, + includeDismissed: { type: 'boolean', description: 'Include dismissed insights', default: false }, + limit: { type: 'number', description: 'Max results', default: 20 }, + }, + }, + }, + { + name: 'dismiss_memory_echo_insight', + description: 'Mark a Memory Echo insight as viewed or dismissed.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Insight ID' }, + viewed: { type: 'boolean', description: 'Mark as viewed', default: true }, + dismissed: { type: 'boolean', description: 'Mark as dismissed', default: true }, + }, + required: ['id'], + }, + }, + + // ═══ FILTERS & STATISTICS ═══ + { + name: 'get_notes_by_label', + description: 'List notes that have a specific label/tag.', + inputSchema: { + type: 'object', + properties: { + label: { type: 'string', description: 'Label text to match' }, + notebookId: { type: 'string', description: 'Filter by notebook', nullable: true }, + includeArchived: { type: 'boolean', default: false }, + limit: { type: 'number', description: 'Max results', default: 100 }, + }, + required: ['label'], + }, + }, + { + name: 'get_note_statistics', + description: 'Get statistics about notes, notebooks, labels, reminders, and trash.', + inputSchema: { + type: 'object', + properties: { + includeArchived: { type: 'boolean', description: 'Include archived notes in counts', default: false }, + includeTrashed: { type: 'boolean', description: 'Include trashed notes in counts', default: false }, + }, + }, + }, ]; // ─── Tool Handlers ────────────────────────────────────────────────────────── @@ -502,7 +596,7 @@ export function registerTools(server, prisma) { title: args.title || null, content: args.content, color: args.color || 'default', - type: args.type || 'text', + type: args.type || 'richtext', checkItems: args.checkItems ?? null, labels: args.labels ?? null, isPinned: args.isPinned || false, @@ -1003,6 +1097,201 @@ export function registerTools(server, prisma) { return textResult({ count: reminders.length, reminders }); } + case 'get_upcoming_reminders': { + const now = new Date(); + const horizon = new Date(now.getTime() + (args.hours || 24) * 60 * 60 * 1000); + const extra = { + reminder: { not: null, gte: now, lte: horizon }, + isArchived: false, + }; + if (!args.includeDone) extra.isReminderDone = false; + + const reminders = await prisma.note.findMany({ + where: noteWhere(uid, extra), + select: { id: true, title: true, content: true, reminder: true, isReminderDone: true, notebookId: true }, + orderBy: { reminder: 'asc' }, + take: args.limit || 100, + }); + + return textResult({ count: reminders.length, horizon, reminders }); + } + + case 'update_reminder': { + const d = { updatedAt: new Date() }; + if ('reminder' in args) d.reminder = args.reminder ? new Date(args.reminder) : null; + if ('isReminderDone' in args) d.isReminderDone = args.isReminderDone; + if ('reminderRecurrence' in args) d.reminderRecurrence = args.reminderRecurrence || null; + if ('reminderLocation' in args) d.reminderLocation = args.reminderLocation || null; + + const note = await prisma.note.update({ + where: { id: args.id, userId: uid, trashedAt: null }, + data: d, + }); + return textResult(parseNote(note)); + } + + // ═══ SEMANTIC / AI ═══ + case 'find_similar_notes': { + const source = await prisma.note.findUnique({ + where: { id: args.id, userId: uid, trashedAt: null }, + select: { id: true, title: true }, + }); + if (!source) throw new McpError(ErrorCode.InvalidRequest, 'Note not found'); + + const sourceEmbedding = await prisma.noteEmbedding.findUnique({ + where: { noteId: args.id }, + select: { embedding: true }, + }); + if (!sourceEmbedding) { + return textResult({ + noteId: args.id, + error: 'Source note has no embedding. Index it first via the Memento app.', + results: [], + }); + } + + const embeddingArray = Array.isArray(sourceEmbedding.embedding) + ? sourceEmbedding.embedding + : String(sourceEmbedding.embedding).replace(/^\[|\]$/g, '').split(',').map(Number); + const vecStr = `[${embeddingArray.join(',')}]`; + const limit = Math.min(args.limit || 10, 50); + const threshold = args.threshold ?? 0.7; + + const rows = await prisma.$queryRawUnsafe( + `SELECT n.id, n.title, n.content, n.color, n.type, n."isPinned", n."isArchived", + n."notebookId", n."createdAt", n."updatedAt", + 1 - (e."embedding"::vector <=> $1::vector) AS similarity + FROM "Note" n + INNER JOIN "NoteEmbedding" e ON e."noteId" = n.id + WHERE n."trashedAt" IS NULL + AND n."isArchived" = false + AND n."userId" = $2 + AND n.id != $3 + AND 1 - (e."embedding"::vector <=> $1::vector) >= $4 + ORDER BY e."embedding"::vector <=> $1::vector ASC + LIMIT $5`, + vecStr, + uid, + args.id, + threshold, + limit, + ); + + return textResult({ + noteId: args.id, + sourceTitle: source.title || 'Untitled', + count: rows.length, + results: rows.map(r => ({ ...parseNoteLightweight(r), similarity: Number(r.similarity) })), + }); + } + + case 'get_memory_echo_insights': { + const where = { userId: uid }; + if (!args.includeViewed) where.viewed = false; + if (!args.includeDismissed) where.dismissed = false; + + const insights = await prisma.memoryEchoInsight.findMany({ + where, + orderBy: { similarityScore: 'desc' }, + take: Math.min(args.limit || 20, 100), + include: { + note1: { select: { id: true, title: true } }, + note2: { select: { id: true, title: true } }, + }, + }); + + return textResult({ + count: insights.length, + insights: insights.map(i => ({ + id: i.id, + insight: i.insight, + similarityScore: i.similarityScore, + insightDate: i.insightDate, + viewed: i.viewed, + dismissed: i.dismissed, + note1: i.note1, + note2: i.note2, + })), + }); + } + + case 'dismiss_memory_echo_insight': { + const d = {}; + if ('viewed' in args) d.viewed = args.viewed; + if ('dismissed' in args) d.dismissed = args.dismissed; + + const insight = await prisma.memoryEchoInsight.update({ + where: { id: args.id, userId: uid }, + data: d, + }); + return textResult({ success: true, id: insight.id, viewed: insight.viewed, dismissed: insight.dismissed }); + } + + // ═══ FILTERS & STATISTICS ═══ + case 'get_notes_by_label': { + const label = args.label.trim(); + const extra = {}; + if (!args.includeArchived) extra.isArchived = false; + if (args.notebookId) { + extra.notebookId = args.notebookId === 'inbox' ? null : args.notebookId; + } + + // Labels are stored as a JSON string like ["tag1","tag2"]. + const notes = await prisma.note.findMany({ + where: { + ...noteWhere(uid, extra), + labels: { contains: label }, + }, + orderBy: [{ isPinned: 'desc' }, { updatedAt: 'desc' }], + take: Math.min(args.limit || 100, MAX_NOTES_LIMIT), + }); + + return textResult({ label, count: notes.length, notes: notes.map(parseNoteLightweight) }); + } + + case 'get_note_statistics': { + const baseWhere = { userId: uid }; + const activeWhere = { ...baseWhere, trashedAt: null }; + + const [ + totalNotes, + archivedNotes, + trashedNotes, + notebookCount, + labelCount, + notesWithReminders, + notesWithImages, + notesWithCheckItems, + notesByType, + notesByColor, + ] = await Promise.all([ + prisma.note.count({ where: activeWhere }), + prisma.note.count({ where: { ...activeWhere, isArchived: true } }), + prisma.note.count({ where: { ...baseWhere, trashedAt: { not: null } } }), + prisma.notebook.count({ where: { userId: uid } }), + prisma.label.count({ where: { notebook: { userId: uid } } }), + prisma.note.count({ where: { ...activeWhere, reminder: { not: null } } }), + prisma.note.count({ where: { ...activeWhere, images: { not: null } } }), + prisma.note.count({ where: { ...activeWhere, checkItems: { not: null } } }), + prisma.note.groupBy({ by: ['type'], where: activeWhere, _count: { type: true } }), + prisma.note.groupBy({ by: ['color'], where: activeWhere, _count: { color: true } }), + ]); + + return textResult({ + totalNotes, + archivedNotes, + trashedNotes, + activeNotes: totalNotes - archivedNotes, + notebookCount, + labelCount, + notesWithReminders, + notesWithImages, + notesWithCheckItems, + byType: notesByType.map(t => ({ type: t.type, count: t._count.type })), + byColor: notesByColor.map(c => ({ color: c.color, count: c._count.color })), + }); + } + // ═══ TRASH ═══ case 'trash_note': { const note = await prisma.note.update({ diff --git a/mcp-server/validation.js b/mcp-server/validation.js index 14019e3..02307f8 100644 --- a/mcp-server/validation.js +++ b/mcp-server/validation.js @@ -179,7 +179,6 @@ export const getNotesSchema = z.object({ notebookId: idSchema.optional().nullable(), fullDetails: boolSchema(false), limit: z.number().int().min(1).max(500).default(100), - offset: z.number().int().min(0).default(0), }); /** @@ -225,7 +224,6 @@ export const deleteNoteSchema = z.object({ */ export const searchNotesSchema = z.object({ query: z.string().min(1).max(500), - limit: z.number().int().min(1).max(100).default(50), notebookId: idSchema.optional().nullable(), includeArchived: boolSchema(false), }); @@ -243,7 +241,6 @@ export const moveNoteSchema = z.object({ */ export const togglePinSchema = z.object({ id: idSchema, - pinned: z.boolean().optional(), }); /** @@ -251,14 +248,13 @@ export const togglePinSchema = z.object({ */ export const toggleArchiveSchema = z.object({ id: idSchema, - archived: z.boolean().optional(), }); /** * batch_move_notes input schema */ export const batchMoveNotesSchema = z.object({ - noteIds: z.array(idSchema).min(1).max(100), + ids: z.array(idSchema).min(1).max(100), notebookId: idSchema.optional().nullable(), }); @@ -266,7 +262,7 @@ export const batchMoveNotesSchema = z.object({ * batch_delete_notes input schema */ export const batchDeleteNotesSchema = z.object({ - noteIds: z.array(idSchema).min(1).max(100), + ids: z.array(idSchema).min(1).max(100), }); /** @@ -274,18 +270,16 @@ export const batchDeleteNotesSchema = z.object({ */ export const createNotebookSchema = z.object({ name: z.string().min(1).max(200), - color: colorSchema.default('default'), + color: z.string().max(50).default('#3B82F6'), icon: z.string().max(50).optional().nullable(), parentId: idSchema.optional().nullable(), + order: z.number().int().optional(), }); /** * get_notebooks input schema */ -export const getNotebooksSchema = z.object({ - includeHierarchy: boolSchema(false), - includeTrashed: boolSchema(false), -}); +export const getNotebooksSchema = z.object({}); /** * get_notebook input schema @@ -300,8 +294,9 @@ export const getNotebookSchema = z.object({ export const updateNotebookSchema = z.object({ id: idSchema, name: z.string().min(1).max(200).optional(), - color: colorSchema.optional(), + color: z.string().max(50).optional().nullable(), icon: z.string().max(50).optional().nullable(), + order: z.number().int().optional(), parentId: idSchema.optional().nullable(), }); @@ -332,14 +327,15 @@ export const getNotebookHierarchySchema = z.object({ */ export const createLabelSchema = z.object({ name: z.string().min(1).max(100), - color: colorSchema.default('default'), + color: z.string().max(50).optional().nullable(), + notebookId: idSchema, }); /** * get_labels input schema */ export const getLabelsSchema = z.object({ - limit: z.number().int().min(1).max(500).default(100), + notebookId: idSchema.optional().nullable(), }); /** @@ -361,37 +357,90 @@ export const deleteLabelSchema = z.object({ /** * get_due_reminders input schema */ -export const getDueRemindersSchema = z.object({ - before: isoDateSchema.optional().nullable(), - after: isoDateSchema.optional().nullable(), - includeDone: boolSchema(false), - limit: z.number().int().min(1).max(500).default(100), -}); +export const getDueRemindersSchema = z.object({}); /** * export_notes input schema */ -export const exportNotesSchema = z.object({ - notebookId: idSchema.optional().nullable(), - includeArchived: boolSchema(false), - format: z.enum(['json', 'markdown']).default('json'), -}); +export const exportNotesSchema = z.object({}); /** * import_notes input schema */ export const importNotesSchema = z.object({ - notes: z.array( - z.object({ - title: z.string().optional(), - content: z.string(), - color: colorSchema.optional(), - labels: labelsSchema, - notebookId: idSchema.optional().nullable(), - }) - ).min(1).max(100), + data: z.object({ + version: z.string().optional(), + data: z.object({ + notes: z.array(z.any()).optional(), + labels: z.array(z.any()).optional(), + notebooks: z.array(z.any()).optional(), + }).optional(), + }), +}); + +/** + * find_similar_notes input schema + */ +export const findSimilarNotesSchema = z.object({ + id: idSchema, + limit: z.number().int().min(1).max(50).default(10), + threshold: z.number().min(0).max(1).default(0.7), +}); + +/** + * get_memory_echo_insights input schema + */ +export const getMemoryEchoInsightsSchema = z.object({ + includeViewed: boolSchema(false), + includeDismissed: boolSchema(false), + limit: z.number().int().min(1).max(100).default(20), +}); + +/** + * dismiss_memory_echo_insight input schema + */ +export const dismissMemoryEchoInsightSchema = z.object({ + id: idSchema, + viewed: boolSchema(true), + dismissed: boolSchema(true), +}); + +/** + * get_notes_by_label input schema + */ +export const getNotesByLabelSchema = z.object({ + label: z.string().min(1).max(100), notebookId: idSchema.optional().nullable(), - overwrite: z.boolean().optional().default(false), + includeArchived: boolSchema(false), + limit: z.number().int().min(1).max(500).default(100), +}); + +/** + * get_note_statistics input schema + */ +export const getNoteStatisticsSchema = z.object({ + includeArchived: boolSchema(false), + includeTrashed: boolSchema(false), +}); + +/** + * get_upcoming_reminders input schema + */ +export const getUpcomingRemindersSchema = z.object({ + hours: z.number().int().min(1).max(168).default(24), + includeDone: boolSchema(false), + limit: z.number().int().min(1).max(500).default(100), +}); + +/** + * update_reminder input schema + */ +export const updateReminderSchema = z.object({ + id: idSchema, + reminder: isoDateSchema, + isReminderDone: z.boolean().optional(), + reminderRecurrence: recurrenceSchema, + reminderLocation: z.string().max(500).optional().nullable(), }); // ═══════════════════════════════════════════════════════════════ @@ -427,6 +476,13 @@ export const toolSchemas = { get_due_reminders: getDueRemindersSchema, export_notes: exportNotesSchema, import_notes: importNotesSchema, + find_similar_notes: findSimilarNotesSchema, + get_memory_echo_insights: getMemoryEchoInsightsSchema, + dismiss_memory_echo_insight: dismissMemoryEchoInsightSchema, + get_notes_by_label: getNotesByLabelSchema, + get_note_statistics: getNoteStatisticsSchema, + get_upcoming_reminders: getUpcomingRemindersSchema, + update_reminder: updateReminderSchema, }; // ═══════════════════════════════════════════════════════════════ @@ -457,7 +513,7 @@ export function validateToolInput(toolName, input) { if (error instanceof z.ZodError) { return { success: false, - errors: error.errors.map((e) => ({ + errors: error.issues.map((e) => ({ field: e.path.join('.'), message: e.message, code: e.code, diff --git a/mcp-server/vitest.config.js b/mcp-server/vitest.config.js new file mode 100644 index 0000000..e6f7826 --- /dev/null +++ b/mcp-server/vitest.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/test.js', 'test/**/*.test.js'], + }, +}); diff --git a/memento-note/app/(admin)/admin/billing/billing-admin-client.tsx b/memento-note/app/(admin)/admin/billing/billing-admin-client.tsx index 6743ae7..3fcc156 100644 --- a/memento-note/app/(admin)/admin/billing/billing-admin-client.tsx +++ b/memento-note/app/(admin)/admin/billing/billing-admin-client.tsx @@ -8,7 +8,32 @@ import { Checkbox } from '@/components/ui/checkbox' import { updateBillingConfig } from '@/app/actions/admin-billing' import { toast } from 'sonner' import { useLanguage } from '@/lib/i18n' -import { CreditCard, Gauge, Coins, Package } from 'lucide-react' +import { + CreditCard, + Gauge, + Coins, + Package, + Activity, + CheckCircle2, + XCircle, + AlertTriangle, + Users, + BookOpen, + ChevronDown, + ChevronUp, +} from 'lucide-react' +import { cn } from '@/lib/utils' + +type PriceHealth = { + key: string + priceId: string + configured: boolean + source: string + stripeAmount: number | null + stripeCurrency: string | null + stripeActive: boolean | null + error: string | null +} type BillingAdminData = { billingConfig: Record @@ -19,7 +44,6 @@ type BillingAdminData = { topUsers: Array<{ userId: string; email: string; name: string | null; requests: number }> } tiers: string[] - /** Allocations mensuelles du solde unique (source de vérité débit) */ creditAllocations?: Array<{ tier: string monthlyCredits: number | null @@ -31,6 +55,35 @@ type BillingAdminData = { credits: number defaultDisplay: string }> + stripeHealth?: { + secretConfigured: boolean + secretMode: 'test' | 'live' | 'missing' | 'placeholder' + publishableConfigured: boolean + webhookSecretConfigured: boolean + billingEnabled: boolean + trialDays: number + prices: PriceHealth[] + } + subscriptionStats?: { + byTier: Record + byStatus: Record + cancelAtPeriodEnd: number + usersWithoutSub: number + trialing: number + pastDue: number + paidActive: number + recent: Array<{ + email: string + name: string | null + tier: string + status: string + trialEndsAt: string | null + currentPeriodEnd: string | null + cancelAtPeriodEnd: boolean + hasStripeSub: boolean + updatedAt: string + }> + } } const SUBSCRIPTION_PRICE_KEYS = [ @@ -46,7 +99,6 @@ const PACK_PRICE_KEYS = [ '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 @@ -54,10 +106,21 @@ function formatStableUtc(iso: string): string { return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC` } +function StatusDot({ ok, warn }: { ok: boolean; warn?: boolean }) { + if (ok) return + if (warn) return + return +} + export function BillingAdminClient({ initialData }: { initialData: BillingAdminData }) { const { t } = useLanguage() const [billingEnabled, setBillingEnabled] = useState(initialData.billingConfig.BILLING_ENABLED === 'true') const [isSavingBilling, setIsSavingBilling] = useState(false) + const [showTestGuide, setShowTestGuide] = useState(true) + + const health = initialData.stripeHealth + const stats = initialData.subscriptionStats + const priceMap = Object.fromEntries((health?.prices ?? []).map((p) => [p.key, p])) const handleSaveBilling = async (formData: FormData) => { setIsSavingBilling(true) @@ -77,6 +140,15 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD } } + const modeLabel = + health?.secretMode === 'test' + ? t('admin.billing.modeTest') + : health?.secretMode === 'live' + ? t('admin.billing.modeLive') + : health?.secretMode === 'placeholder' + ? t('admin.billing.modePlaceholder') + : t('admin.billing.modeMissing') + return (
@@ -84,6 +156,246 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD

{t('admin.billing.description')}

+ {/* Stripe health */} + {health && ( +
+
+
+ +
+
+

{t('admin.billing.healthTitle')}

+

{t('admin.billing.healthDescription')}

+
+
+
+
+ +
+

{t('admin.billing.healthSecret')}

+

{modeLabel}

+
+
+
+ +
+

{t('admin.billing.healthPublishable')}

+

+ {health.publishableConfigured ? t('admin.billing.configured') : t('admin.billing.missing')} +

+
+
+
+ +
+

{t('admin.billing.healthWebhook')}

+

+ {health.webhookSecretConfigured ? t('admin.billing.configured') : t('admin.billing.missing')} +

+
+
+
+ +
+

{t('admin.billing.healthBillingFlag')}

+

+ {health.billingEnabled ? t('admin.billing.enabled') : t('admin.billing.disabled')} +

+
+
+
+ +
+

{t('admin.billing.healthTrial')}

+

+ {t('admin.billing.trialDaysValue', { days: health.trialDays })} +

+
+
+
+
+

{t('admin.billing.priceStatusTitle')}

+
+ + + + + + + + + + + {(health.prices ?? []).map((row) => ( + + + + + + + ))} + +
{t('admin.billing.colKey')}{t('admin.billing.colPriceId')}{t('admin.billing.colSource')}{t('admin.billing.colStripe')}
{t(`admin.billing.${row.key}`)} + {row.configured ? row.priceId : '—'} + {row.source} + {!row.configured ? ( + {t('admin.billing.missing')} + ) : row.error ? ( + {t('admin.billing.priceError')} + ) : row.stripeAmount != null ? ( + + {row.stripeAmount.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} {row.stripeCurrency} + {row.stripeActive === false ? ` (${t('admin.billing.inactive')})` : ''} + + ) : ( + {t('admin.billing.notChecked')} + )} +
+
+
+
+ )} + + {/* Subscription stats */} + {stats && ( +
+
+
+ +
+
+

{t('admin.billing.subsTitle')}

+

{t('admin.billing.subsDescription')}

+
+
+
+
+

{t('admin.billing.statPaid')}

+

{stats.paidActive}

+
+
+

{t('admin.billing.statTrialing')}

+

{stats.trialing}

+
+
+

{t('admin.billing.statPastDue')}

+

{stats.pastDue}

+
+
+

{t('admin.billing.statCanceling')}

+

{stats.cancelAtPeriodEnd}

+
+
+
+
+

{t('admin.billing.byTier')}

+
    + {Object.entries(stats.byTier).map(([tier, count]) => ( +
  • + {tier} + {count} +
  • + ))} +
  • + {t('admin.billing.usersWithoutSub')} + {stats.usersWithoutSub} +
  • +
+
+
+

{t('admin.billing.byStatus')}

+
    + {Object.keys(stats.byStatus).length === 0 ? ( +
  • {t('admin.billing.noSubs')}
  • + ) : ( + Object.entries(stats.byStatus).map(([status, count]) => ( +
  • + {status} + {count} +
  • + )) + )} +
+
+
+
+

{t('admin.billing.recentSubs')}

+ {stats.recent.length === 0 ? ( +

{t('admin.billing.noSubs')}

+ ) : ( +
+ + + + + + + + + + + {stats.recent.map((row) => ( + + + + + + + ))} + +
{t('admin.billing.colUser')}{t('admin.billing.colTier')}{t('admin.billing.colStatus')}{t('admin.billing.colPeriod')}
{row.email}{row.tier} + {row.status} + {row.cancelAtPeriodEnd ? ` · ${t('admin.billing.canceling')}` : ''} + {!row.hasStripeSub ? ` · ${t('admin.billing.manualTier')}` : ''} + + {row.trialEndsAt + ? t('admin.billing.trialUntil', { date: formatStableUtc(row.trialEndsAt) }) + : row.currentPeriodEnd + ? formatStableUtc(row.currentPeriodEnd) + : '—'} +
+
+ )} +
+
+ )} + + {/* How to test Stripe */} +
+ + {showTestGuide && ( +
+
    +
  1. {t('admin.billing.testStep1')}
  2. +
  3. {t('admin.billing.testStep2')}
  4. +
  5. + stripe listen --forward-to localhost:3000/api/billing/webhook + {' — '}{t('admin.billing.testStep3')} +
  6. +
  7. {t('admin.billing.testStep4')}
  8. +
  9. {t('admin.billing.testStep5')}
  10. +
  11. {t('admin.billing.testStep6')}
  12. +
+

+ {t('admin.billing.testCardHint')} +

+
+ )} +
+
@@ -106,17 +418,28 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD

{t('admin.billing.subscriptionPricesTitle')}

- {SUBSCRIPTION_PRICE_KEYS.map((key) => ( -
- - -
- ))} + {SUBSCRIPTION_PRICE_KEYS.map((key) => { + const meta = priceMap[key] + return ( +
+ + + {meta?.stripeAmount != null && ( +

+ Stripe: {meta.stripeAmount.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} {meta.stripeCurrency} +

+ )} + {meta?.error && ( +

{meta.error}

+ )} +
+ ) + })}
@@ -144,7 +467,6 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
- {/* Solde unique — source de vérité du débit */}
@@ -216,7 +538,6 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD {t('admin.billing.usagePeriod', { period: initialData.usageOverview.period })} {initialData.usageOverview.lastSyncedAt ? ` · ${t('admin.billing.lastSync', { - // Format fixe UTC (évite mismatch SSR locale/fuseau vs client) date: formatStableUtc(initialData.usageOverview.lastSyncedAt), })}` : ` · ${t('admin.billing.notSynced')}`} diff --git a/memento-note/app/(auth)/check-email/page.tsx b/memento-note/app/(auth)/check-email/page.tsx new file mode 100644 index 0000000..7d30838 --- /dev/null +++ b/memento-note/app/(auth)/check-email/page.tsx @@ -0,0 +1,92 @@ +'use client' + +import { Suspense, useState } from 'react' +import Link from 'next/link' +import { useSearchParams } from 'next/navigation' +import { Mail, ArrowLeft, Sparkles } from 'lucide-react' +import { useLanguage } from '@/lib/i18n' +import { resendSignupVerification } from '@/app/actions/auth-verify' +import { toast } from 'sonner' + +function CheckEmailContent() { + const { t, language } = useLanguage() + const searchParams = useSearchParams() + const initialEmail = searchParams.get('email') ?? '' + const [email, setEmail] = useState(initialEmail) + const [sending, setSending] = useState(false) + + const handleResend = async () => { + const target = email.trim() + if (!target) { + toast.error(t('auth.verifyMissingEmail')) + return + } + setSending(true) + const result = await resendSignupVerification(target, language) + setSending(false) + if (result.success) { + toast.success(t('auth.verifyResent')) + } else { + toast.error(t('auth.verifyResendFailed')) + } + } + + return ( +
+
+
+ +
+
+

+ {t('auth.checkEmailTitle')} +

+

+ {initialEmail + ? t('auth.checkEmailDescription', { email: initialEmail }) + : t('auth.checkEmailDescriptionGeneric')} +

+
+ +
+ + setEmail(e.target.value)} + placeholder={t('auth.emailPlaceholder')} + className="w-full bg-slate-50 dark:bg-white/5 border border-[var(--border)] rounded-2xl py-4 px-4 text-sm outline-none focus:border-[var(--color-brand-accent)] focus:ring-4 ring-[var(--color-brand-accent)]/5 transition-all" + /> +
+ + + + + + {t('auth.backToLogin')} + +
+
+ ) +} + +export default function CheckEmailPage() { + return ( +
}> + + + ) +} diff --git a/memento-note/app/(auth)/verify-email/page.tsx b/memento-note/app/(auth)/verify-email/page.tsx new file mode 100644 index 0000000..1fdcdc1 --- /dev/null +++ b/memento-note/app/(auth)/verify-email/page.tsx @@ -0,0 +1,97 @@ +'use client' + +import { Suspense, useEffect, useState } from 'react' +import Link from 'next/link' +import { useSearchParams } from 'next/navigation' +import { CheckCircle2, AlertCircle, Sparkles } from 'lucide-react' +import { useLanguage } from '@/lib/i18n' +import { confirmEmail } from '@/app/actions/auth-verify' + +function VerifyEmailContent() { + const { t } = useLanguage() + const searchParams = useSearchParams() + const token = searchParams.get('token') + const [status, setStatus] = useState<'loading' | 'ok' | 'invalid' | 'expired'>('loading') + + useEffect(() => { + if (!token) { + setStatus('invalid') + return + } + let cancelled = false + ;(async () => { + const result = await confirmEmail(token) + if (cancelled) return + if (result.success) setStatus('ok') + else setStatus(result.error === 'expired' ? 'expired' : 'invalid') + })() + return () => { + cancelled = true + } + }, [token]) + + if (status === 'loading') { + return ( +
+ +

{t('auth.verifyLoading')}

+
+ ) + } + + if (status === 'ok') { + return ( +
+
+ +
+
+

{t('auth.verifySuccessTitle')}

+

{t('auth.verifySuccessDescription')}

+
+ + + +
+ ) + } + + return ( +
+
+ +
+
+

+ {status === 'expired' ? t('auth.verifyExpiredTitle') : t('auth.verifyInvalidTitle')} +

+

+ {status === 'expired' + ? t('auth.verifyExpiredDescription') + : t('auth.verifyInvalidDescription')} +

+
+ + + +
+ ) +} + +export default function VerifyEmailPage() { + return ( +
}> + + + ) +} diff --git a/memento-note/app/(main)/insights/page.tsx b/memento-note/app/(main)/insights/page.tsx index cda27c7..31ca8d8 100644 --- a/memento-note/app/(main)/insights/page.tsx +++ b/memento-note/app/(main)/insights/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react' import dynamic from 'next/dynamic' -import { useRouter } from 'next/navigation' +import { useRouter, useSearchParams } from 'next/navigation' import { useLanguage } from '@/lib/i18n' import { motion, AnimatePresence, useReducedMotion } from 'motion/react' import { @@ -84,6 +84,7 @@ const COLOR_PALETTE = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '# export default function InsightsPage() { const router = useRouter() + const searchParams = useSearchParams() const { t, language: locale } = useLanguage() const formatSyncTime = useCallback( @@ -129,6 +130,15 @@ export default function InsightsPage() { loadInitialData() }, []) + useEffect(() => { + const clusterParam = searchParams.get('cluster') + if (!clusterParam || clusters.length === 0) return + const match = clusters.find( + c => c.id === clusterParam || String(c.clusterId) === clusterParam, + ) + if (match) setSelectedClusterId(match.id) + }, [searchParams, clusters]) + // ─── Données calculées ─────────────────────────────────────────────────────── const selectedCluster = useMemo( diff --git a/memento-note/app/(public)/pricing/page.tsx b/memento-note/app/(public)/pricing/page.tsx index d779778..a2441f7 100644 --- a/memento-note/app/(public)/pricing/page.tsx +++ b/memento-note/app/(public)/pricing/page.tsx @@ -4,18 +4,38 @@ import { motion } from 'motion/react' import { Shield } from 'lucide-react' import { useRouter } from 'next/navigation' import { useLanguage } from '@/lib/i18n' +import { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants' import { useState } from 'react' export default function PricingPage() { const { t } = useLanguage() const router = useRouter() const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly') + const trialDays = SUBSCRIPTION_TRIAL_DAYS const PLANS = [ - { key: 'basic', popular: false, price: t('landing.pricing.basicPrice'), period: '' }, - { key: 'pro', popular: true, price: billingInterval === 'monthly' ? '9,90€' : '7,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') }, - { key: 'business', popular: false, price: billingInterval === 'monthly' ? '29,90€' : '23,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') }, - { key: 'enterprise', popular: false, price: billingInterval === 'monthly' ? '49,90€' : '39,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perUser') : t('landing.pricing.perUserAnnual') }, + { key: 'basic', popular: false, hasTrial: false, price: t('landing.pricing.basicPrice'), period: '' }, + { + key: 'pro', + popular: true, + hasTrial: true, + price: billingInterval === 'monthly' ? t('landing.pricing.proMonthly') : t('landing.pricing.proAnnualMonthly'), + period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual'), + }, + { + key: 'business', + popular: false, + hasTrial: true, + price: billingInterval === 'monthly' ? t('landing.pricing.businessMonthly') : t('landing.pricing.businessAnnualMonthly'), + period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual'), + }, + { + key: 'enterprise', + popular: false, + hasTrial: false, + price: t('landing.pricing.enterprisePrice'), + period: '', + }, ] return ( @@ -42,7 +62,9 @@ export default function PricingPage() { )}
- (-20%) + + {t('landing.pricing.savePercent')} +
@@ -62,9 +84,22 @@ export default function PricingPage() { {plan.price} {plan.period && {plan.period}}
+ {plan.hasTrial && ( +

+ {t('landing.pricing.trialBadge', { days: trialDays })} +

+ )}

{t(`landing.pricing.${plan.key}.desc`)}

+ {plan.hasTrial && ( +
+
+ +
+ {t('landing.pricing.trialFeature', { days: trialDays })} +
+ )} {[0, 1, 2, 3, 4, 5].map(j => { const feat = t(`landing.pricing.${plan.key}.feature${j}`) if (!feat || feat === `landing.pricing.${plan.key}.feature${j}`) return null @@ -82,7 +117,9 @@ export default function PricingPage() { 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'}`} > - {t(`landing.pricing.${plan.key}.cta`)} + {plan.hasTrial + ? t('landing.pricing.trialCta', { days: trialDays }) + : t(`landing.pricing.${plan.key}.cta`)}
))} diff --git a/memento-note/app/(public)/privacy/page.tsx b/memento-note/app/(public)/privacy/page.tsx index 0e996de..559c20d 100644 --- a/memento-note/app/(public)/privacy/page.tsx +++ b/memento-note/app/(public)/privacy/page.tsx @@ -2,13 +2,24 @@ import { headers } from 'next/headers' import Link from 'next/link' import { parseAcceptLanguage } from '@/lib/i18n/detect-user-language' -export const metadata = { - title: 'Privacy Policy — Memento', - description: 'How Memento handles your data, the Chrome and Firefox web clipper extension, and AI features.', - alternates: { canonical: 'https://memento-note.com/privacy' }, - robots: { index: true, follow: true }, +export async function generateMetadata({ + searchParams, +}: { + searchParams?: Promise<{ lang?: string | string[] }> +}) { + const sp = searchParams ? (await searchParams) : undefined + const locale = await pickLocale(sp?.lang) + return { + title: locale === 'fr' ? 'Politique de confidentialité — Memento' : 'Privacy Policy — Memento', + description: locale === 'fr' + ? "Comment Memento gère vos données, l'extension de capture web Chrome et Firefox, et les fonctionnalités d'IA." + : 'How Memento handles your data, the Chrome and Firefox web clipper extension, and AI features.', + alternates: { canonical: 'https://memento-note.com/privacy' }, + robots: { index: true, follow: true }, + } } + type Locale = 'en' | 'fr' const SUPPORTED: Locale[] = ['en', 'fr'] const RTL: Locale[] = [] @@ -353,7 +364,9 @@ export default async function PrivacyPage({
-

Privacy

+

+ {locale === 'fr' ? 'Confidentialité' : 'Privacy'} +

{doc.title}

{doc.lastUpdated}

{doc.intro}

diff --git a/memento-note/app/actions/admin-billing.ts b/memento-note/app/actions/admin-billing.ts index d36b163..419815e 100644 --- a/memento-note/app/actions/admin-billing.ts +++ b/memento-note/app/actions/admin-billing.ts @@ -46,6 +46,152 @@ function assertValidTier(tier: string): asserts tier is TierType { } } +async function getStripeHealth(billingConfig: Record) { + const secret = process.env.STRIPE_SECRET_KEY ?? '' + const publishable = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? '' + const webhook = process.env.STRIPE_WEBHOOK_SECRET ?? '' + + let secretMode: 'test' | 'live' | 'missing' | 'placeholder' = 'missing' + if (!secret) secretMode = 'missing' + else if (secret === 'sk_test_placeholder' || secret.includes('placeholder')) secretMode = 'placeholder' + else if (secret.startsWith('sk_live_')) secretMode = 'live' + else if (secret.startsWith('sk_test_')) secretMode = 'test' + else secretMode = 'placeholder' + + const { isBillingEnabled } = await import('@/lib/billing/stripe-prices') + const { SUBSCRIPTION_TRIAL_DAYS } = await import('@/lib/billing/trial-constants') + const billingEnabled = await isBillingEnabled() + + const priceKeys = [ + ...BILLING_CONFIG_KEYS.filter((k) => k.startsWith('STRIPE_PRICE_')), + ] as string[] + + const canCallStripe = secretMode === 'test' || secretMode === 'live' + let stripe: Awaited> | null = null + if (canCallStripe) { + try { + const { getStripe } = await import('@/lib/stripe') + stripe = getStripe() + } catch { + stripe = null + } + } + + const prices = await Promise.all( + priceKeys.map(async (key) => { + const priceId = (billingConfig[key] || process.env[key] || '').trim() + const configured = Boolean(priceId) && !priceId.startsWith('price_mock_') + let stripeAmount: number | null = null + let stripeCurrency: string | null = null + let stripeActive: boolean | null = null + let error: string | null = null + + if (configured && stripe) { + try { + const price = await stripe.prices.retrieve(priceId) + stripeAmount = price.unit_amount != null ? price.unit_amount / 100 : null + stripeCurrency = price.currency?.toUpperCase() ?? null + stripeActive = price.active + } catch (e) { + error = e instanceof Error ? e.message : 'Stripe price lookup failed' + } + } + + return { + key, + priceId: configured ? priceId : '', + configured, + source: billingConfig[key] ? 'db' : process.env[key] ? 'env' : 'missing', + stripeAmount, + stripeCurrency, + stripeActive, + error, + } + }), + ) + + return { + secretConfigured: secretMode === 'test' || secretMode === 'live', + secretMode, + publishableConfigured: Boolean(publishable) && publishable.startsWith('pk_'), + webhookSecretConfigured: Boolean(webhook) && webhook.startsWith('whsec_'), + billingEnabled, + trialDays: SUBSCRIPTION_TRIAL_DAYS, + prices, + } +} + +async function getSubscriptionStats() { + const [byTierRaw, byStatusRaw, cancelAtPeriodEnd, recent] = await Promise.all([ + prisma.subscription.groupBy({ + by: ['tier'], + _count: { _all: true }, + }), + prisma.subscription.groupBy({ + by: ['status'], + _count: { _all: true }, + }), + prisma.subscription.count({ where: { cancelAtPeriodEnd: true } }), + prisma.subscription.findMany({ + where: { + OR: [ + { tier: { in: ['PRO', 'BUSINESS', 'ENTERPRISE'] } }, + { status: { in: ['TRIALING', 'PAST_DUE', 'ACTIVE'] } }, + ], + }, + orderBy: { updatedAt: 'desc' }, + take: 15, + select: { + tier: true, + status: true, + trialEndsAt: true, + currentPeriodEnd: true, + cancelAtPeriodEnd: true, + updatedAt: true, + stripeSubscriptionId: true, + user: { select: { email: true, name: true } }, + }, + }), + ]) + + const byTier: Record = Object.fromEntries(TIERS.map((t) => [t, 0])) + for (const row of byTierRaw) { + byTier[row.tier] = row._count._all + } + + const byStatus: Record = {} + for (const row of byStatusRaw) { + byStatus[row.status] = row._count._all + } + + const usersWithoutSub = await prisma.user.count({ + where: { subscription: null }, + }) + + return { + byTier, + byStatus, + cancelAtPeriodEnd, + usersWithoutSub, + trialing: byStatus.TRIALING ?? 0, + pastDue: byStatus.PAST_DUE ?? 0, + paidActive: + (byStatus.ACTIVE ?? 0) + + (byStatus.TRIALING ?? 0), + recent: recent.map((s) => ({ + email: s.user.email, + name: s.user.name, + tier: s.tier, + status: s.status, + trialEndsAt: s.trialEndsAt?.toISOString() ?? null, + currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? null, + cancelAtPeriodEnd: s.cancelAtPeriodEnd, + hasStripeSub: Boolean(s.stripeSubscriptionId), + updatedAt: s.updatedAt.toISOString(), + })), + } +} + export async function getBillingAdminData() { await checkAdmin() const { getSystemConfig } = await import('@/lib/config') @@ -83,6 +229,11 @@ export async function getBillingAdminData() { } }) + const [stripeHealth, subscriptionStats] = await Promise.all([ + getStripeHealth(billingConfig), + getSubscriptionStats(), + ]) + return { entitlements, billingConfig, @@ -92,6 +243,8 @@ export async function getBillingAdminData() { creditAllocations, creditCosts, creditPacks, + stripeHealth, + subscriptionStats, } } diff --git a/memento-note/app/actions/auth-verify.ts b/memento-note/app/actions/auth-verify.ts new file mode 100644 index 0000000..29439d0 --- /dev/null +++ b/memento-note/app/actions/auth-verify.ts @@ -0,0 +1,11 @@ +'use server' + +import { verifyEmailToken, resendVerificationEmail } from '@/lib/auth/email-verification' + +export async function confirmEmail(token: string) { + return verifyEmailToken(token) +} + +export async function resendSignupVerification(email: string, locale?: string) { + return resendVerificationEmail(email, locale) +} diff --git a/memento-note/app/actions/auth.ts b/memento-note/app/actions/auth.ts index d2c8540..e10226a 100644 --- a/memento-note/app/actions/auth.ts +++ b/memento-note/app/actions/auth.ts @@ -2,20 +2,49 @@ import { signIn } from '@/auth'; import { AuthError } from 'next-auth'; +import bcrypt from 'bcryptjs'; +import prisma from '@/lib/prisma'; export async function authenticate( prevState: string | undefined, formData: FormData, ) { + const emailRaw = formData.get('email'); + const passwordRaw = formData.get('password'); + const email = typeof emailRaw === 'string' ? emailRaw.toLowerCase().trim() : ''; + const password = typeof passwordRaw === 'string' ? passwordRaw : ''; + + // Surface a clear message when credentials are valid but email is unverified. + if (email && password.length >= 6) { + try { + const user = await prisma.user.findUnique({ where: { email } }); + if (user?.password) { + const match = await bcrypt.compare(password, user.password); + if (match && !user.emailVerified) { + return 'EMAIL_NOT_VERIFIED'; + } + } + } catch (preCheckErr) { + console.error('[authenticate] emailVerified pre-check failed:', preCheckErr); + } + } + try { await signIn('credentials', { - email: formData.get('email'), - password: formData.get('password'), + email, + password, redirectTo: '/home', }); } catch (error) { if (error instanceof AuthError) { console.error('AuthError details:', error.type, error.message); + if ( + error.type === 'CredentialsSignin' && + (error.message?.includes('EMAIL_NOT_VERIFIED') || + (error.cause as { err?: Error } | undefined)?.err?.message === 'EMAIL_NOT_VERIFIED') + ) { + return 'EMAIL_NOT_VERIFIED'; + } switch (error.type) { case 'CredentialsSignin': return 'Invalid credentials.'; diff --git a/memento-note/app/actions/register.ts b/memento-note/app/actions/register.ts index 3d28e02..5a186bc 100644 --- a/memento-note/app/actions/register.ts +++ b/memento-note/app/actions/register.ts @@ -5,6 +5,7 @@ import prisma from '@/lib/prisma'; import { z } from 'zod'; import { redirect } from 'next/navigation'; import { getSystemConfig } from '@/lib/config'; +import { sendVerificationEmail } from '@/lib/auth/email-verification'; const RegisterSchema = z.object({ email: z.string().email(), @@ -17,9 +18,8 @@ const RegisterSchema = z.object({ }); export async function register(prevState: string | undefined, formData: FormData) { - // Check if registration is allowed const config = await getSystemConfig(); - const allowRegister = config.ALLOW_REGISTRATION !== 'false' || process.env.ALLOW_REGISTRATION !== 'false'; + const allowRegister = config.ALLOW_REGISTRATION !== 'false' && process.env.ALLOW_REGISTRATION !== 'false'; if (!allowRegister) { return 'Registration is currently disabled by the administrator.'; @@ -37,36 +37,48 @@ export async function register(prevState: string | undefined, formData: FormData } const { email, password, name } = validatedFields.data; + const normalizedEmail = email.toLowerCase(); + const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase(); + const isAdmin = Boolean(adminEmail && normalizedEmail === adminEmail); try { - const existingUser = await prisma.user.findUnique({ where: { email: email.toLowerCase() } }); + const existingUser = await prisma.user.findUnique({ where: { email: normalizedEmail } }); if (existingUser) { return 'User already exists.'; } const hashedPassword = await bcrypt.hash(password, 10); - - const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase(); - const role = adminEmail && email.toLowerCase() === adminEmail ? 'ADMIN' : 'USER'; + const role = isAdmin ? 'ADMIN' : 'USER'; await prisma.user.create({ data: { - email: email.toLowerCase(), + email: normalizedEmail, password: hashedPassword, name, role, + // Admin bootstrap + Google OAuth are trusted; everyone else must verify. + emailVerified: isAdmin ? new Date() : null, }, }); - - // Attempt to sign in immediately after registration - // We cannot import signIn here directly if it causes circular deps or issues, - // but usually it works. If not, redirecting to login is fine. - // Let's stick to redirecting to login but with a clear success message? - // Or better: lowercase the email to fix the potential bug. + + if (!isAdmin) { + const mailResult = await sendVerificationEmail({ + email: normalizedEmail, + name, + }); + + if (!mailResult.success) { + console.error('[register] verification email failed:', mailResult.error); + } + } } catch (error) { console.error('Registration Error:', error); return 'Database Error: Failed to create user.'; } - redirect('/login'); + if (isAdmin) { + redirect('/login?verified=1'); + } + + redirect(`/check-email?email=${encodeURIComponent(normalizedEmail)}`); } diff --git a/memento-note/app/api/ai/interactive-page/route.ts b/memento-note/app/api/ai/interactive-page/route.ts index 209d397..fac2cda 100644 --- a/memento-note/app/api/ai/interactive-page/route.ts +++ b/memento-note/app/api/ai/interactive-page/route.ts @@ -17,14 +17,14 @@ export const maxDuration = 60 const sectionPlanSchema = z.object({ title: z.string().min(1), goal: z.string().min(1), - demoKind: z.enum(['svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none']), - demoGoal: z.string().optional(), + demoKind: z.enum(['steps', 'svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none']), + demoGoal: z.string().nullish(), }) const requestSchema = z.object({ /** undefined = legacy deterministic full-page (fallback path) */ action: z.enum(['plan', 'section']).optional(), - content: z.string().min(40), + content: z.string().min(40).max(500_000), lang: z.string().optional(), noteId: z.string().optional(), notebookId: z.string().optional(), @@ -44,7 +44,10 @@ export async function POST(req: NextRequest) { return aiConsentForbiddenResponse() } - const body = await req.json() + const body = await req.json().catch(() => null) + if (!body || typeof body !== 'object') { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } const parsed = requestSchema.parse(body) const wordCount = parsed.content .replace(/<[^>]+>/g, ' ') @@ -61,11 +64,12 @@ export async function POST(req: NextRequest) { const provider = getSlidesProvider(config) const lang = parsed.lang || 'fr' - // ── LLM plan (billed: page = 20 crédits, spec §8.5) ────────────────── + // ── LLM plan (billed 5/20 crédits — le reste est facturé par section) ── if (parsed.action === 'plan') { try { await reserveAiUsageOrThrow(session.user.id, 'interactive_page', { lane: 'chat', + amount: 5, }) } catch (err) { if (err instanceof QuotaExceededError) { @@ -97,7 +101,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ plan: result.plan, attempts: result.attempts }) } - // ── LLM single section (already billed at plan time) ──────────────── + // ── LLM single section (billed 3/20 crédits per section — no free LLM) ── if (parsed.action === 'section') { if (!parsed.section || !parsed.sectionId || !parsed.pageTitle) { return NextResponse.json( @@ -105,6 +109,26 @@ export async function POST(req: NextRequest) { { status: 400 } ) } + try { + await reserveAiUsageOrThrow(session.user.id, 'interactive_page', { + lane: 'chat', + amount: 3, + }) + } catch (err) { + if (err instanceof QuotaExceededError) { + return NextResponse.json(err.toJSON(), { status: 402 }) + } + if ( + err instanceof QuotaServiceUnavailableError || + process.env.NODE_ENV === 'production' + ) { + return NextResponse.json( + { error: 'QUOTA_SERVICE_UNAVAILABLE' }, + { status: 503 } + ) + } + console.error('[/api/ai/interactive-page] Quota check error (fail-open):', err) + } const result = await generatePageSection({ content: parsed.content, lang, @@ -171,7 +195,16 @@ export async function POST(req: NextRequest) { }) } catch (error: unknown) { if (error instanceof z.ZodError) { - return NextResponse.json({ error: error.issues }, { status: 400 }) + return NextResponse.json( + { + error: 'Requête invalide', + issues: error.issues.slice(0, 10).map((i) => ({ + path: i.path.join('.'), + message: i.message, + })), + }, + { status: 400 } + ) } const message = error instanceof Error ? error.message : 'Erreur génération interactive page' diff --git a/memento-note/app/api/billing/create-checkout/route.ts b/memento-note/app/api/billing/create-checkout/route.ts index 7062b59..ad928c8 100644 --- a/memento-note/app/api/billing/create-checkout/route.ts +++ b/memento-note/app/api/billing/create-checkout/route.ts @@ -2,6 +2,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@/auth'; import { stripe } from '@/lib/stripe'; import { isBillingEnabled, resolvePriceId } from '@/lib/billing/stripe-prices'; +import { + shouldOfferSubscriptionTrial, + SUBSCRIPTION_TRIAL_DAYS, +} from '@/lib/billing/trial'; import { prisma } from '@/lib/prisma'; import { z } from 'zod'; @@ -91,6 +95,17 @@ export async function POST(req: NextRequest) { const proto = req.headers.get('x-forwarded-proto') ?? 'http'; const origin = `${proto}://${host}`; + const offerTrial = await shouldOfferSubscriptionTrial(userId); + const subscriptionData: { + metadata: { userId: string; tier: string }; + trial_period_days?: number; + } = { + metadata: { userId, tier }, + }; + if (offerTrial) { + subscriptionData.trial_period_days = SUBSCRIPTION_TRIAL_DAYS; + } + // Hosted Checkout is the most reliable path (redirect). Embedded is optional. if (preferredMode === 'embedded') { try { @@ -100,8 +115,8 @@ export async function POST(req: NextRequest) { line_items: [{ price: priceId, quantity: 1 }], ui_mode: 'embedded' as any, return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`, - metadata: { userId, tier }, - subscription_data: { metadata: { userId, tier } }, + metadata: { userId, tier, trial: offerTrial ? '1' : '0' }, + subscription_data: subscriptionData, customer_update: { address: 'auto' }, allow_promotion_codes: true, } as any); @@ -109,6 +124,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ clientSecret: embedded.client_secret, sessionId: embedded.id, + trialDays: offerTrial ? SUBSCRIPTION_TRIAL_DAYS : 0, }); } } catch (embeddedErr) { @@ -122,8 +138,8 @@ export async function POST(req: NextRequest) { line_items: [{ price: priceId, quantity: 1 }], success_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${origin}/settings/billing?canceled=1`, - metadata: { userId, tier }, - subscription_data: { metadata: { userId, tier } }, + metadata: { userId, tier, trial: offerTrial ? '1' : '0' }, + subscription_data: subscriptionData, customer_update: { address: 'auto' }, allow_promotion_codes: true, }); @@ -132,7 +148,11 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Checkout session has no URL' }, { status: 500 }); } - return NextResponse.json({ url: checkoutSession.url, sessionId: checkoutSession.id }); + return NextResponse.json({ + url: checkoutSession.url, + sessionId: checkoutSession.id, + trialDays: offerTrial ? SUBSCRIPTION_TRIAL_DAYS : 0, + }); } catch (error) { console.error('[billing/create-checkout]', error); const msg = error instanceof Error ? error.message : 'Failed to create checkout session'; diff --git a/memento-note/app/api/billing/status/route.ts b/memento-note/app/api/billing/status/route.ts index 96fdc8f..9d76de9 100644 --- a/memento-note/app/api/billing/status/route.ts +++ b/memento-note/app/api/billing/status/route.ts @@ -2,7 +2,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@/auth'; import { getUserInfo, getEffectiveTier } from '@/lib/entitlements'; import { stripe } from '@/lib/stripe'; -import { priceIdToTier, getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices'; +import { getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices'; +import { syncSubscriptionFromStripe } from '@/lib/billing/sync-subscription-from-stripe'; +import { shouldOfferSubscriptionTrial, SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial'; export const dynamic = 'force-dynamic'; @@ -23,47 +25,16 @@ export async function GET(req: NextRequest) { if (checkoutSession.subscription && checkoutSession.status === 'complete') { const subId = typeof checkoutSession.subscription === 'string' ? checkoutSession.subscription - : (checkoutSession.subscription as any).id; + : (checkoutSession.subscription as { id: string }).id; - const sub = await stripe.subscriptions.retrieve(subId) as any; - const priceId = sub.items.data[0].price.id; - const tier = (await priceIdToTier(priceId)) || (checkoutSession.metadata?.tier as any) || 'PRO'; - - const currentPeriodStartTimestamp = - sub.current_period_start ?? - sub.items?.data?.[0]?.current_period_start ?? - sub.start_date ?? - Math.floor(Date.now() / 1000); - - const currentPeriodEndTimestamp = - sub.current_period_end ?? - sub.items?.data?.[0]?.current_period_end ?? - (currentPeriodStartTimestamp + 30 * 24 * 3600); - - await prisma.subscription.upsert({ - where: { userId }, - update: { - tier, - status: 'ACTIVE', - stripeCustomerId: checkoutSession.customer as string, - stripeSubscriptionId: sub.id, - stripePriceId: priceId, - currentPeriodStart: new Date(currentPeriodStartTimestamp * 1000), - currentPeriodEnd: new Date(currentPeriodEndTimestamp * 1000), - canceledAt: sub.canceled_at ? new Date(sub.canceled_at * 1000) : null, - cancelAtPeriodEnd: sub.cancel_at_period_end, - }, - create: { - userId, - tier, - status: 'ACTIVE', - stripeCustomerId: checkoutSession.customer as string, - stripeSubscriptionId: sub.id, - stripePriceId: priceId, - currentPeriodStart: new Date(currentPeriodStartTimestamp * 1000), - currentPeriodEnd: new Date(currentPeriodEndTimestamp * 1000), - }, - }); + const sub = await stripe.subscriptions.retrieve(subId); + const syncUserId = + (checkoutSession.metadata?.userId as string | undefined) ?? + (sub.metadata?.userId as string | undefined) ?? + userId; + if (syncUserId === userId) { + await syncSubscriptionFromStripe(sub, userId); + } } } catch (err) { console.error('[billing/status] Failed to sync Stripe session:', err); @@ -112,6 +83,8 @@ export async function GET(req: NextRequest) { } } + const trialEligible = await shouldOfferSubscriptionTrial(userId); + return NextResponse.json({ tier, effectiveTier, @@ -120,6 +93,9 @@ export async function GET(req: NextRequest) { currentPeriodStart: subscription?.currentPeriodStart ?? null, cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false, hasStripeSubscription: !!subscription?.stripeSubscriptionId, + trialEndsAt: subscription?.trialEndsAt?.toISOString() ?? null, + trialEligible, + trialDays: trialEligible ? SUBSCRIPTION_TRIAL_DAYS : 0, prices, creditPacks, billingEnabled, diff --git a/memento-note/app/api/billing/webhook/route.ts b/memento-note/app/api/billing/webhook/route.ts index ae2509b..1116df6 100644 --- a/memento-note/app/api/billing/webhook/route.ts +++ b/memento-note/app/api/billing/webhook/route.ts @@ -12,6 +12,8 @@ import { isCreditPackId, resolvePackFromPriceId, } from '@/lib/billing/credit-packs'; +import { sendTrialEndingReminder } from '@/lib/billing/trial-reminder-email'; +import { prisma } from '@/lib/prisma'; import type Stripe from 'stripe'; export const runtime = 'nodejs'; @@ -159,6 +161,48 @@ export async function POST(req: NextRequest) { break; } + case 'customer.subscription.trial_will_end': { + const subscription = event.data.object as Stripe.Subscription; + const userId = await resolveUserIdFromStripeEvent(subscription); + if (!userId) { + console.warn('[billing/webhook] trial_will_end: no userId', subscription.id); + break; + } + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + email: true, + name: true, + aiSettings: { select: { preferredLanguage: true } }, + }, + }); + if (!user?.email) break; + + await syncSubscriptionFromStripe(subscription, userId); + + const trialEndsAt = subscription.trial_end + ? new Date(subscription.trial_end * 1000) + : new Date(Date.now() + 3 * 24 * 3600 * 1000); + + const appUrl = + process.env.NEXTAUTH_URL?.replace(/\/$/, '') || + process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, '') || + 'https://memento-note.com'; + + const preferred = user.aiSettings?.preferredLanguage ?? 'en'; + const mailResult = await sendTrialEndingReminder({ + to: user.email, + name: user.name, + trialEndsAt, + billingUrl: `${appUrl}/settings/billing`, + locale: preferred === 'auto' ? 'en' : preferred, + }); + if (!mailResult.success) { + console.error('[billing/webhook] trial reminder email failed:', mailResult.error); + } + break; + } + default: break; } diff --git a/memento-note/app/api/briefing/route.ts b/memento-note/app/api/briefing/route.ts index afb786e..bff0b26 100644 --- a/memento-note/app/api/briefing/route.ts +++ b/memento-note/app/api/briefing/route.ts @@ -59,6 +59,7 @@ export async function GET() { const [ recentNotes, inboxCount, + inboxPreview, dueFlashcards, upcomingReminders, unviewedInsights, @@ -83,6 +84,13 @@ export async function GET() { where: { userId, notebookId: null, isArchived: false, trashedAt: null }, }), + prisma.note.findMany({ + where: { userId, notebookId: null, isArchived: false, trashedAt: null }, + select: { id: true, title: true, notebookId: true, updatedAt: true }, + orderBy: { updatedAt: 'desc' }, + take: 3, + }), + prisma.flashcard.count({ where: { deck: { userId }, nextReviewAt: { lte: now } }, }), @@ -177,6 +185,12 @@ export async function GET() { notebook: n.notebookId ? notebookMap.get(n.notebookId) || null : null, })), inboxCount, + inboxPreview: inboxPreview.map(n => ({ + id: n.id, + title: n.title, + notebookId: n.notebookId, + updatedAt: n.updatedAt.toISOString(), + })), dueFlashcards, upcomingReminders: upcomingReminders.map(r => ({ id: r.id, diff --git a/memento-note/app/api/notes/publish/route.ts b/memento-note/app/api/notes/publish/route.ts index ec7feb4..e2505f3 100644 --- a/memento-note/app/api/notes/publish/route.ts +++ b/memento-note/app/api/notes/publish/route.ts @@ -7,8 +7,7 @@ import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements' import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent' import { isPublishTemplateId, isInteractivePageTemplate } from '@/lib/publish/types' import { computePublishedSourceHash, renderPublishedTemplate, renderRewrittenTemplate } from '@/lib/publish/template-render' -import { validateInteractivePage } from '@/lib/interactive-page' -import { reserveAiUsageOrThrow } from '@/lib/ai-quota' +import { validateInteractivePage, type PageSpecV1, type PageValidationResult } from '@/lib/interactive-page' import { getSystemConfig } from '@/lib/config' import { getSlidesProvider } from '@/lib/ai/factory' import { generateInteractivePageFromContent } from '@/lib/ai/services/interactive-page-generate.service' @@ -93,11 +92,61 @@ async function updateNotePublishState(noteId: string, data: PublishUpdateData) { } } +/** All human-facing text of a PageSpecV1 — fed to moderation. */ +function collectPageText(page: PageSpecV1): string[] { + const out: string[] = [ + page.hero.kicker, + page.hero.title, + page.hero.subtitle ?? '', + page.hero.meta ?? '', + page.footer ?? '', + ] + if (page.overview) { + out.push(page.overview.lead) + for (const c of page.overview.cards) out.push(c.badge, c.title, c.body) + } + for (const section of page.sections) { + out.push(section.title) + for (const b of section.blocks) { + if (b.type === 'prose') out.push(b.md) + else if (b.type === 'formula') out.push(b.caption ?? '') + else if (b.type === 'callout') out.push(b.title, b.md) + else if (b.type === 'demo') { + out.push(b.caption ?? '', b.demo.disclaimer ?? '') + for (const act of b.demo.acts) { + out.push(act.title) + for (const st of act.steps) out.push(st.speak) + } + for (const panel of b.demo.scene.panels) { + if (panel.type === 'svg-scene') { + for (const n of panel.payload.nodes) out.push(n.label ?? '') + } + } + } else if (b.type === 'chart') { + out.push(b.caption ?? '') + for (const s of b.payload.series) out.push(s.label ?? '') + } else if (b.type === 'stats') { + for (const it of b.items) out.push(it.value, it.label) + } else if (b.type === 'table') { + out.push(b.caption ?? '', ...b.columns, ...b.rows.flat()) + } else if (b.type === 'image') { + out.push(b.alt, b.caption ?? '') + } else if (b.type === 'sim') { + out.push(b.caption ?? '', b.sim.title ?? '', b.sim.disclaimer ?? '') + } + } + } + return out.filter((s) => s && s.trim()) +} + export async function POST(request: NextRequest) { const session = await auth() if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - const body = await request.json() + const body = await request.json().catch(() => null) + if (!body || typeof body !== 'object') { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } const { noteId, action, mode, template, language, rewrite, pageSpec } = body as { noteId?: string action?: string @@ -123,9 +172,21 @@ export async function POST(request: NextRequest) { return aiConsentForbiddenResponse() } - let validatedPage = pageSpec ? validateInteractivePage(pageSpec) : null + let validatedPage: PageValidationResult | null = null + if (pageSpec) { + // Client-provided page (from the preview dialog): must validate as-is. + // Never silently substitute a different page than the one previewed. + const checked = validateInteractivePage(pageSpec) + if (!checked.ok) { + return NextResponse.json( + { error: 'invalid_page_spec', issues: checked.issues.slice(0, 12) }, + { status: 422 } + ) + } + validatedPage = checked + } - if (!validatedPage?.ok) { + if (!validatedPage) { // Deterministic generate (no LLM quota) const config = await getSystemConfig() const provider = getSlidesProvider(config) @@ -144,22 +205,12 @@ export async function POST(request: NextRequest) { { status: 422 } ) } - validatedPage = { ok: true, page: generated.page } + validatedPage = { ok: true as const, page: generated.page } } - // Guaranteed valid PageSpec after generate-or-validate above - if (!validatedPage?.ok) { - return NextResponse.json({ error: 'invalid_page_spec' }, { status: 422 }) - } - - const textForModeration = [ - validatedPage.page.hero.title, - validatedPage.page.hero.subtitle, - validatedPage.page.overview?.lead, - ...validatedPage.page.sections.map((s) => s.title), - ] - .filter(Boolean) - .join('\n') + // Moderate ALL human-facing text of the page (prose, callouts, demo + // narration, tables, captions) — not just titles. + const textForModeration = collectPageText(validatedPage.page).join('\n') const moderation = await moderateWithFallback( note.title || '', diff --git a/memento-note/auth.config.ts b/memento-note/auth.config.ts index 17c61e3..20cf0f1 100644 --- a/memento-note/auth.config.ts +++ b/memento-note/auth.config.ts @@ -34,6 +34,8 @@ export const authConfig = { nextUrl.pathname === '/login' || nextUrl.pathname === '/register' || nextUrl.pathname === '/forgot-password' || + nextUrl.pathname === '/check-email' || + nextUrl.pathname === '/verify-email' || nextUrl.pathname.startsWith('/reset-password'); if (isAdminPage) { diff --git a/memento-note/components/dashboard-agent-carousel.tsx b/memento-note/components/dashboard-agent-carousel.tsx index fdbd931..187df86 100644 --- a/memento-note/components/dashboard-agent-carousel.tsx +++ b/memento-note/components/dashboard-agent-carousel.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useState, useRef } from 'react' import { motion, AnimatePresence } from 'motion/react' import { Search, Bot, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react' import { useLanguage } from '@/lib/i18n' @@ -35,12 +35,22 @@ export function DashboardAgentCarousel({ }: DashboardAgentCarouselProps) { const { t } = useLanguage() const [idx, setIdx] = useState(0) + const directionRef = useRef(1) + + const goPrev = () => { + directionRef.current = -1 + setIdx(i => Math.max(0, i - 1)) + } + const goNext = () => { + directionRef.current = 1 + setIdx(i => Math.min(suggestions.length - 1, i + 1)) + } const navActions = suggestions.length > 1 ? (
+
+ {notes.map((note, idx) => ( + onSelect(note.id, note.notebookId)} + initial={reduced ? false : { opacity: 0, x: 8 }} + animate={{ opacity: 1, x: 0 }} + transition={{ duration: reduced ? 0 : 0.22, delay: reduced ? 0 : Math.min(idx * 0.05, 0.15), ease: [0.16, 1, 0.3, 1] }} + className="w-full text-start p-2.5 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all" + > +

+ {note.title || t('homeDashboard.untitled')} +

+
+ ))} + +
)} ) diff --git a/memento-note/components/dashboard-mind-orbit.tsx b/memento-note/components/dashboard-mind-orbit.tsx index 4211fe7..caa68fc 100644 --- a/memento-note/components/dashboard-mind-orbit.tsx +++ b/memento-note/components/dashboard-mind-orbit.tsx @@ -19,12 +19,22 @@ interface BridgeNote { } const CLUSTER_COLORS = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF'] +const EASE = [0.16, 1, 0.3, 1] as const + +function clusterPoint(index: number, count: number): { x: number; y: number } { + if (count === 1) return { x: 50, y: 42 } + const angle = -Math.PI / 2 + (index * 2 * Math.PI) / count + const rx = count <= 3 ? 28 : 36 + const ry = count <= 3 ? 24 : 30 + return { x: 50 + Math.cos(angle) * rx, y: 44 + Math.sin(angle) * ry } +} export interface DashboardMindOrbitProps { clusters: Cluster[] bridgeNotes: BridgeNote[] loading?: boolean onOpenInsights: () => void + onOpenCluster?: (clusterId: number) => void onNoteSelect: (id: string) => void prefersReducedMotion?: boolean } @@ -34,6 +44,7 @@ export function DashboardMindOrbit({ bridgeNotes, loading, onOpenInsights, + onOpenCluster, onNoteSelect, prefersReducedMotion, }: DashboardMindOrbitProps) { @@ -43,6 +54,8 @@ export function DashboardMindOrbit({ .slice(0, 5) const maxCount = topClusters[0]?.noteIds.length || 1 const topBridge = bridgeNotes[0] + const reduced = !!prefersReducedMotion + const points = topClusters.map((_, idx) => clusterPoint(idx, topClusters.length)) if (loading) { return
@@ -80,21 +93,64 @@ export function DashboardMindOrbit({ )} /> -
+ + + {points.map((point, idx) => { + const color = CLUSTER_COLORS[topClusters[idx].clusterId % CLUSTER_COLORS.length] + return ( + + ) + })} + + +
+ {topClusters.map((cluster, idx) => { const color = CLUSTER_COLORS[cluster.clusterId % CLUSTER_COLORS.length] const scale = 0.65 + (cluster.noteIds.length / maxCount) * 0.55 - const size = Math.round(56 * scale) + const size = Math.round(52 * scale) const label = cluster.name || `${t('homeDashboard.theme')} ${cluster.clusterId + 1}` + const point = points[idx] return ( (onOpenCluster ? onOpenCluster(cluster.clusterId) : onOpenInsights())} + className="absolute flex flex-col items-center gap-1 group" + style={{ + left: `${point.x}%`, + top: `${point.y}%`, + width: size + 16, + }} + initial={reduced ? { x: '-50%', y: '-50%' } : { x: '-50%', y: '-50%', scale: 0.82, opacity: 0.35 }} + animate={{ x: '-50%', y: '-50%', scale: 1, opacity: 1 }} + transition={{ duration: reduced ? 0 : 0.38, delay: reduced ? 0 : 0.18 + idx * 0.05, ease: EASE }} >
) })} -
+ {topBridge?.note && ( - +
)}
) diff --git a/memento-note/components/dashboard-next-paths.tsx b/memento-note/components/dashboard-next-paths.tsx index 2cdf49a..17deeb8 100644 --- a/memento-note/components/dashboard-next-paths.tsx +++ b/memento-note/components/dashboard-next-paths.tsx @@ -41,7 +41,8 @@ export function DashboardNextPaths({ onAction, prefersReducedMotion, }: DashboardNextPathsProps) { - const { t } = useLanguage() + const { t, language } = useLanguage() + const rtl = language === 'ar' || language === 'fa' if (loading) { return ( @@ -84,6 +85,8 @@ export function DashboardNextPaths({ const hero = paths[0] const rest = paths.slice(1, 5) const HeroIcon = TYPE_META[hero.type].Icon + const reduced = !!prefersReducedMotion + const ease = [0.16, 1, 0.3, 1] as const return (
@@ -109,7 +112,11 @@ export function DashboardNextPaths({ onAction(hero)} - whileHover={prefersReducedMotion ? undefined : { y: -1 }} + whileHover={reduced ? undefined : { y: -1 }} + key={hero.id} + initial={reduced ? false : { clipPath: rtl ? 'inset(0 0 0 72%)' : 'inset(0 72% 0 0)', opacity: 0.55 }} + animate={{ clipPath: 'inset(0 0% 0 0)', opacity: 1 }} + transition={{ duration: reduced ? 0 : 0.48, ease }} className="w-full text-start px-5 py-4 hover:bg-brand-accent/[0.03] transition-colors border-b border-border/10" >
@@ -137,14 +144,17 @@ export function DashboardNextPaths({ {rest.length > 0 && (
- {rest.map(path => { + {rest.map((path, idx) => { const meta = TYPE_META[path.type] const Icon = meta.Icon return ( - + ) })}
diff --git a/memento-note/components/dashboard-view.tsx b/memento-note/components/dashboard-view.tsx index ebd9a91..ebe1af2 100644 --- a/memento-note/components/dashboard-view.tsx +++ b/memento-note/components/dashboard-view.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useCallback, useMemo, type ReactNode } from 'react' import { useRouter } from 'next/navigation' import { useReducedMotion } from 'motion/react' -import { Inbox, Send, Bell, Mail } from 'lucide-react' +import { Inbox, Send, Bell, Mail, Loader2 } from 'lucide-react' import { useLanguage } from '@/lib/i18n' import { useAiConsent } from '@/components/legal/ai-consent-provider' import { redirectToAiConsentSettings } from '@/lib/consent/ai-consent-redirect' @@ -152,7 +152,7 @@ interface MindMapData { } interface DashboardViewProps { - onNoteSelect: (noteId: string, notebookId: string | null) => void + onNoteSelect: (noteId: string, notebookId: string | null, peekNoteId?: string | null) => void } interface GmailStatus { @@ -217,6 +217,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) { const [data, setData] = useState<{ recentNotes: BriefingNote[] inboxCount: number + inboxPreview?: Array<{ id: string; title: string | null; notebookId: string | null }> dueFlashcards: number upcomingReminders: BriefingReminder[] insights: BriefingInsight[] @@ -491,9 +492,14 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) { } : prev) }, []) - const handleOpenFromInsight = useCallback(async (insight: BriefingInsight, noteId: string) => { + const handleOpenFromInsight = useCallback(async ( + insight: BriefingInsight, + noteId: string, + peekNoteId?: string | null, + ) => { await markInsightViewed(insight.id) - onNoteSelect(noteId, null) + const peek = peekNoteId && peekNoteId !== noteId ? peekNoteId : undefined + onNoteSelect(noteId, null, peek) }, [markInsightViewed, onNoteSelect]) const handleDismissInsight = useCallback(async (insight: BriefingInsight) => { @@ -636,18 +642,14 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) { if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null) break case 'compare': - if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null) - if (path.note2Id) toast.info(t('homeDashboard.pathCompareHint', { title: path.title })) + if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null, path.note2Id) break case 'addLink': - if (path.noteId) { - onNoteSelect(path.noteId, path.notebookId ?? null) - toast.info(t('homeDashboard.pathAddLinkHint', { link: path.title })) - } + if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null, path.note2Id) break case 'openInsight': { const insight = insights.find(i => i.id === path.insightId) - if (insight && path.noteId) handleOpenFromInsight(insight, path.noteId) + if (insight && path.noteId) handleOpenFromInsight(insight, path.noteId, path.note2Id) break } case 'createBridge': { @@ -772,8 +774,11 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) { onClick={handleCapture} disabled={!captureText.trim() || capturing} className="absolute bottom-2.5 end-2.5 p-2 bg-ink text-white dark:bg-white dark:text-black rounded-lg disabled:opacity-25 hover:scale-105 active:scale-95 transition-all shadow-sm" + aria-busy={capturing} > - + {capturing + ? + : }
, ) @@ -952,6 +957,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) { bridgeNotes={mindMap?.bridgeNotes ?? []} loading={mindMapLoading} onOpenInsights={() => router.push('/insights')} + onOpenCluster={(clusterId) => router.push(`/insights?cluster=${clusterId}`)} onNoteSelect={(nid) => onNoteSelect(nid, null)} prefersReducedMotion={!!prefersReducedMotion} />, @@ -983,8 +989,10 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) { return wrap( router.push('/home?forceList=1')} + onSelect={onNoteSelect} />, ) case 'revision': @@ -1114,7 +1122,17 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
- + { + if (briefingLoading) return false + if (id === 'sentiment') return !sentimentLoading && (!sentiment?.available || !sentiment?.dominantEmotion) + if (id === 'reminders') return reminders.length === 0 + if (id === 'revision') return dueFlashcards === 0 + if (id === 'inbox') return inboxCount === 0 + return false + }} + />
diff --git a/memento-note/components/dashboard-widget-grid.tsx b/memento-note/components/dashboard-widget-grid.tsx index 46b50c6..824bfbb 100644 --- a/memento-note/components/dashboard-widget-grid.tsx +++ b/memento-note/components/dashboard-widget-grid.tsx @@ -40,6 +40,7 @@ import { toast } from 'sonner' interface DashboardWidgetGridProps { renderWidget: (id: DashboardWidgetId) => React.ReactNode + isWidgetEmpty?: (id: DashboardWidgetId) => boolean } function SortableWidget({ @@ -139,7 +140,7 @@ function ZoneColumn({ ) } -export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps) { +export function DashboardWidgetGrid({ renderWidget, isWidgetEmpty }: DashboardWidgetGridProps) { const { t } = useLanguage() const [layout, setLayout] = useState(() => getDefaultDashboardLayout()) const [editMode, setEditMode] = useState(false) @@ -228,8 +229,11 @@ export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps) }, [persistLayout, t]) const fullWidgets = visibleWidgetsInZone(layout, 'full') + .filter(w => editMode || !isWidgetEmpty?.(w.id)) const mainWidgets = visibleWidgetsInZone(layout, 'main') + .filter(w => editMode || !isWidgetEmpty?.(w.id)) const sideWidgets = visibleWidgetsInZone(layout, 'side') + .filter(w => editMode || !isWidgetEmpty?.(w.id)) const hidden = hiddenWidgetIds(layout) const catalog = catalogByCategory(layout) const hasVisibleWidgets = fullWidgets.length + mainWidgets.length + sideWidgets.length > 0 diff --git a/memento-note/components/home-client.tsx b/memento-note/components/home-client.tsx index 8d7c5fc..a2f5076 100644 --- a/memento-note/components/home-client.tsx +++ b/memento-note/components/home-client.tsx @@ -657,7 +657,10 @@ export function HomeClient({ // Garder openNote dans l'URL tant que l'éditeur est ouvert → le sidebar peut surligner la note (comme activeNoteId dans la ref.) useEffect(() => { const openNoteId = searchParams.get('openNote') - if (!openNoteId) return + if (!openNoteId) { + setEditingNote(null) + return + } let cancelled = false const run = async () => { @@ -816,8 +819,15 @@ export function HomeClient({ const handleEditorClose = useCallback(() => { setEditingNote(null) + // Ouvert depuis le dashboard (Comparer / Lier / clic note) → revenir au dashboard, pas à la liste des carnets. + if (searchParams.get('from') === 'dashboard' || searchParams.get('peekNote')) { + router.replace('/home', { scroll: false }) + return + } const params = new URLSearchParams(searchParams.toString()) params.delete('openNote') + params.delete('peekNote') + params.delete('from') const qs = params.toString() router.replace(qs ? `/home?${qs}` : '/home', { scroll: false }) }, [router, searchParams]) @@ -831,10 +841,17 @@ export function HomeClient({ // Show dashboard when no active filter/view params const showDashboard = !editingNote && isDashboardHomeRoute('/home', searchParams) - const handleDashboardNoteSelect = useCallback((noteId: string, notebookId: string | null) => { + const handleDashboardNoteSelect = useCallback(( + noteId: string, + notebookId: string | null, + peekNoteId?: string | null, + ) => { const params = new URLSearchParams() params.set('openNote', noteId) - if (notebookId) params.set('notebook', notebookId) + params.set('from', 'dashboard') + // Avec aperçu split, ne pas ouvrir le panneau carnets : il mange la largeur des deux notes. + if (notebookId && !peekNoteId) params.set('notebook', notebookId) + if (peekNoteId && peekNoteId !== noteId) params.set('peekNote', peekNoteId) router.push(`/home?${params.toString()}`) }, [router]) diff --git a/memento-note/components/intelligence-hub.tsx b/memento-note/components/intelligence-hub.tsx index 7374068..70cec12 100644 --- a/memento-note/components/intelligence-hub.tsx +++ b/memento-note/components/intelligence-hub.tsx @@ -149,7 +149,7 @@ export interface IntelligenceHubProps { onDismissInsight: (insight: IntelBriefingInsight) => void onDismissBridgeSuggestion: (s: IntelBridgeSuggestion) => void onCreateBridgeSuggestion: (s: IntelBridgeSuggestion) => void - onOpenInsightNote: (insight: IntelBriefingInsight, noteId: string) => void + onOpenInsightNote: (insight: IntelBriefingInsight, noteId: string, peekNoteId?: string | null) => void dismissingInsightId: string | null actingBridgeSuggestionKey: string | null prefersReducedMotion: boolean @@ -334,7 +334,7 @@ export function IntelligenceHub({
@@ -493,8 +515,19 @@ export function LandingPage() { {plan.price} {plan.period && {plan.period}}
+ {plan.hasTrial && ( +

+ {t('landing.pricing.trialBadge', { days: trialDays })} +

+ )}

{t(`landing.pricing.${plan.key}.desc`)}