diff --git a/.cursor/hooks/state/continual-learning-index.json b/.cursor/hooks/state/continual-learning-index.json
index fbcdef8..2ac6952 100644
--- a/.cursor/hooks/state/continual-learning-index.json
+++ b/.cursor/hooks/state/continual-learning-index.json
@@ -1,7 +1,7 @@
{
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/0c6fb2d9-1b82-4ca3-b0f4-f8373a62faca/0c6fb2d9-1b82-4ca3-b0f4-f8373a62faca.jsonl": 1778182618469,
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/137b1f4b-59d9-4ce6-8d74-01f7cbae2ba7/137b1f4b-59d9-4ce6-8d74-01f7cbae2ba7.jsonl": 1778966645519,
- "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/16214191-7091-4aef-a309-f922d351d79f.jsonl": 1779909977869,
+ "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/16214191-7091-4aef-a309-f922d351d79f.jsonl": 1779911218415,
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/2e0ce74c-a31e-49d8-a0d0-a8b224813533/2e0ce74c-a31e-49d8-a0d0-a8b224813533.jsonl": 1778188935902,
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/38000361-5c66-4032-8e1e-ef405e843de0/38000361-5c66-4032-8e1e-ef405e843de0.jsonl": 1778968570815,
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/394af47d-c5cd-4cef-bef2-2192717439f8/394af47d-c5cd-4cef-bef2-2192717439f8.jsonl": 1778951280378,
@@ -20,5 +20,6 @@
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/b85430f3-4520-47fd-9b4b-5200ca340a36/b85430f3-4520-47fd-9b4b-5200ca340a36.jsonl": 1779039005865,
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ca85061e-6af9-4250-8dc7-9c3bb4839c48/ca85061e-6af9-4250-8dc7-9c3bb4839c48.jsonl": 1778849848444,
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/d92dfb04-c148-4a14-a48a-39d4c634caee/d92dfb04-c148-4a14-a48a-39d4c634caee.jsonl": 1778861502433,
- "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/e3745f62-c3b9-4a21-8942-71bc6f603f77/e3745f62-c3b9-4a21-8942-71bc6f603f77.jsonl": 1778018654221
+ "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/e3745f62-c3b9-4a21-8942-71bc6f603f77/e3745f62-c3b9-4a21-8942-71bc6f603f77.jsonl": 1778018654221,
+ "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/fb7fd15f-b9ef-490b-a1de-8238ea026e53/fb7fd15f-b9ef-490b-a1de-8238ea026e53.jsonl": 1779998515529
}
diff --git a/.cursor/hooks/state/continual-learning.json b/.cursor/hooks/state/continual-learning.json
index db397af..d47349e 100644
--- a/.cursor/hooks/state/continual-learning.json
+++ b/.cursor/hooks/state/continual-learning.json
@@ -1,8 +1,8 @@
{
"version": 1,
- "lastRunAtMs": 1779909959153,
- "turnsSinceLastRun": 5,
- "lastTranscriptMtimeMs": 1779909958790.594,
- "lastProcessedGenerationId": "dc55cac3-bead-4e51-af66-3b48e77deb37",
+ "lastRunAtMs": 1779998560332,
+ "turnsSinceLastRun": 2,
+ "lastTranscriptMtimeMs": 1779998515529,
+ "lastProcessedGenerationId": "5e947d58-6d03-4f90-ad7c-4a97606f4d11",
"trialStartedAtMs": null
}
diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml
index 1a3df35..e6c6d67 100644
--- a/.gitea/workflows/ci.yaml
+++ b/.gitea/workflows/ci.yaml
@@ -226,7 +226,7 @@ Branch: ${{ github.ref_name }}" \
upsert AUTH_GOOGLE_SECRET "$AUTH_GOOGLE_SECRET"
upsert SOCKET_INTERNAL_KEY "$SOCKET_INTERNAL_KEY"
upsert SOCKET_PORT "$SOCKET_PORT"
- upsert SOCKET_HTTP_PORT "$SOCKET_SOCKET_HTTP_PORT"
+ upsert SOCKET_HTTP_PORT "$SOCKET_HTTP_PORT"
upsert SOCKET_INTERNAL_URL "$SOCKET_INTERNAL_URL"
upsert NEXT_PUBLIC_SOCKET_URL "$NEXT_PUBLIC_SOCKET_URL"
upsert TELEGRAM_BOT_TOKEN "$TELEGRAM_BOT_TOKEN"
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 1a3df35..e6c6d67 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -226,7 +226,7 @@ Branch: ${{ github.ref_name }}" \
upsert AUTH_GOOGLE_SECRET "$AUTH_GOOGLE_SECRET"
upsert SOCKET_INTERNAL_KEY "$SOCKET_INTERNAL_KEY"
upsert SOCKET_PORT "$SOCKET_PORT"
- upsert SOCKET_HTTP_PORT "$SOCKET_SOCKET_HTTP_PORT"
+ upsert SOCKET_HTTP_PORT "$SOCKET_HTTP_PORT"
upsert SOCKET_INTERNAL_URL "$SOCKET_INTERNAL_URL"
upsert NEXT_PUBLIC_SOCKET_URL "$NEXT_PUBLIC_SOCKET_URL"
upsert TELEGRAM_BOT_TOKEN "$TELEGRAM_BOT_TOKEN"
diff --git a/AGENTS.md b/AGENTS.md
index e23d0ff..37afbfb 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.
- Authentification : priorité à l'inscription/connexion via **Google OAuth** (plutôt qu'un compte email/mot de passe) ; exiger une vraie déconnexion (invalidation session/cookies — pas de reconnexion implicite, y compris en navigation privée).
-- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; **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` — panneau gauche lecture seule, note courante à droite), **pas nouvel onglet** ; **ne jamais annuler du code non commité** (`git checkout`, reset fichier) **sans demande explicite** (perte de travail documentée, ex. drag handle éditeur) ; en frustration ou pour déléguer, **prévoir des prompts / briefs d'implémentation détaillés** (autre modèle ou dev), en plus des briefs outil de design.
+- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; **ne jamais affirmer qu'un correctif ou une feature est fait sans vérification réelle** (app, prototype `architectural-grid`, ou test), **notamment navigation recherche/liste notes et vue `/insights` vs fichiers prototype** — l'utilisateur sanctionne fermement les fausses déclarations ; **ouverture note liée depuis l'éditeur** (ex. bloc live « Ouvrir ») : **split peek inline** animé (`lib/note-peek-sync.ts`, `note-editor-split-peek.tsx` — éditeur courant à **gauche**, note liée en lecture seule à **droite** en LTR ; **inversé en RTL** `fa`/`ar`), **pas nouvel onglet** ; **ne jamais annuler du code non commité** (`git checkout`, reset fichier) **sans demande explicite** (perte de travail documentée, ex. drag handle éditeur) ; **correction i18n ou spec doc** : **ne pas refondre logique/UI** hors scope (ex. US-4 `structuredViewBlock` — garder le dual-mode base locale + lien carnet, pas de suppression du mode local) ; en frustration ou pour déléguer, **prévoir des prompts / briefs d'implémentation détaillés** (autre modèle ou dev), en plus des briefs outil de design.
- Livraison : **une user story à la fois**, tester et valider avec l'utilisateur avant la suivante (pas d'auto-validation ni d'enchaînement de code non demandé) ; suivi dans `docs/user-stories.md`.
- Quand demandé, **fournir des briefs pour un outil de design externe** plutôt que produire les maquettes UX soi-même.
@@ -26,6 +26,6 @@
- Production : dépôt `/opt/memento` sur `192.168.1.190`, conteneur `memento-note` sur le port **3000**, URL publique **https://memento-note.com** (nginx + Cloudflare ; ancien domaine note.parsanet.org) ; `NEXTAUTH_URL` aligné sur ce domaine ; email sortant via **Resend** (`SMTP_FROM` ex. `noreply@memento-note.com`, domaine vérifié sur resend.com) ; 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`, deploy sur runner **`docker-host`** (sur le serveur) ; 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` ; 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` ; **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`).
- 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) ; **Living Blocks partiellement livrées** : `data-id`, Smart Paste, nœud `liveBlock`, détacher/supprimer, peek split — **US-NEXTGEN-EDITOR** en cours (`docs/story-nextgen-editor.md`, **US-TEMPORAL reporté**) ; encore en gap : transclusion bidirectionnelle complète, graphe knowledge enrichi (`GraphKnowledgeMap.tsx`), **insights sémantiques** (`InsightsView.tsx`, **`/insights` ≠ `/graph`**) ; publication **Chrome Web Store** : icônes 16/48/128, privacy policy, `host_permissions` prod restreints vs build dev.
diff --git a/docs/story-nextgen-editor.md b/docs/story-nextgen-editor.md
index 919a316..c1eb059 100644
--- a/docs/story-nextgen-editor.md
+++ b/docs/story-nextgen-editor.md
@@ -68,35 +68,40 @@ Pour offrir une expérience de saisie supérieure à celle de Notion (plus perfo
---
-### US-4: Vue Structurée de Carnet Inline *(Redesign — voir [`docs/story-nextgen-editor-us4-redesign.md`](./story-nextgen-editor-us4-redesign.md))*
+### US-4: Base de Données Inline (`structuredViewBlock`)
-> ⚠️ **DEPRECATED** — La spec précédente (bloc "Auteurs & Œuvres") est **rejetée par le fondateur**.
-> Le code legacy (`tiptap-database-block-extension.tsx`, `database-block-editor.tsx`, `database-block-types.ts`) doit être supprimé.
-> La nouvelle spec complète (problème, options produit, Given/When/Then, modèle de données, migration, i18n, QA RTL) est dans [`docs/story-nextgen-editor-us4-redesign.md`](./story-nextgen-editor-us4-redesign.md).
+> ⚠️ **Rejeté et supprimé :** l'ancien bloc marketing « Auteurs & Œuvres » (`tiptap-database-block-extension.tsx`, `database-block-editor.tsx`, `database-block-types.ts`).
+> **Spec historique embed-only carnet :** [`docs/story-nextgen-editor-us4-redesign.md`](./story-nextgen-editor-us4-redesign.md) — **non retenue** ; le produit livré est le **dual-mode** ci-dessous.
-**En tant que** rédacteur dans un carnet structuré,
-**Je veux** insérer une vue en lecture de mon tableau de notes directement dans le corps de ma note,
-**Afin de** voir mes données structurées en contexte, sans quitter l'éditeur.
+**En tant que** rédacteur,
+**Je veux** insérer un tableau interactif type Notion directement dans ma note, avec option de le lier au carnet structuré,
+**Afin de** gérer des données en contexte ou synchroniser avec les notes du carnet.
-#### Critères d'Acceptation (résumé) :
-* **Étant donné** que ma note est dans un carnet structuré, **quand** je tape `/vue`, **alors** un bloc `structuredViewBlock` s'insère avec les données réelles du carnet — aucune donnée de démo.
-* **Étant donné** que le carnet n'a pas de schéma, **alors** le bloc affiche un callout contextuel vers le wizard, pas une erreur.
-* **Étant donné** qu'une note contient un ancien `databaseBlock`, **alors** il est retiré silencieusement sans crash.
+#### Deux modes (même nœud TipTap `structuredViewBlock`) :
-*(Voir `docs/story-nextgen-editor-us4-redesign.md` pour les critères complets, le modèle de données, et la checklist QA FR+fa RTL.)*
+| Mode | Déclencheur par défaut | Données | UI |
+|------|------------------------|---------|-----|
+| **Base locale autonome** | `/database`, `/vue`, menu « Transformer en → Base de données » | Colonnes/lignes dans `localColumnsJson` / `localRowsJson` (attrs TipTap) | Tableau éditable, analyses, Memory Echo, conversion en carnet |
+| **Vue liée au carnet** | Bouton « Lier à un carnet » ou sélecteur de carnet | API schéma + notes du carnet (`notebookId`) | Table / Galerie via `StructuredViewsContainer` |
+
+#### Critères d'Acceptation :
+* **Étant donné** une note ouverte, **quand** je tape `/database` ou `/vue`, **alors** un bloc **base locale autonome** s'insère (tableau vide éditable, pas de démo Auteurs/Œuvres).
+* **Étant donné** un bloc local, **quand** je clique « Lier à un carnet », **alors** je peux afficher la vue structurée d'un carnet (table/galerie, données réelles).
+* **Étant donné** un carnet lié sans schéma, **alors** callout vers le wizard — pas de crash.
+* **Étant donné** un ancien `databaseBlock` HTML, **alors** migration vers bloc local par défaut — note charge sans crash.
+* **Étant donné** n'importe quelle locale (dont `fa`), **alors** tous les libellés UI passent par i18n (`structuredViewBlock.*`) — pas de texte FR en dur dans l'embed.
---
## Spécifications Techniques d'Implémentation
-### 1. Structure du Nœud `structuredViewBlock` (TipTap) — *US-4 Redesign*
+### 1. Structure du Nœud `structuredViewBlock` (TipTap) — *US-4*
-> ⚠️ **DEPRECATED :** L'ancienne section `DatabaseBlock` est remplacée. Voir [`docs/story-nextgen-editor-us4-redesign.md`](./story-nextgen-editor-us4-redesign.md) §7 pour le modèle complet.
-
-Extension TipTap `StructuredViewBlockExtension` dans `tiptap-structured-view-block-extension.tsx` :
-- Attributs : `notebookId` (string), `displayMode` ('table'|'gallery'), `filterJson` (string JSON `{}`)
-- ReactNodeViewRenderer → `structured-view-block-embed.tsx`
-- Aucune donnée de démo — aucun payload JSON d'auteurs/livres dans les attrs.
+Extension `StructuredViewBlockExtension` (`tiptap-structured-view-block-extension.tsx`) :
+- Attributs partagés : `notebookId`, `displayMode` ('table'|'gallery'), `filterJson`
+- Mode local : `isLocal: true`, `localColumnsJson`, `localRowsJson` (insertion par défaut)
+- Mode carnet : `isLocal: false`, `notebookId` renseigné
+- NodeView React → `structured-view-block-embed.tsx` (dual-mode, i18n complet)
### 2. Le Plugin Drag Handle & Gutter en ProseMirror *(US-1, inchangé)*
Développer un plugin custom dans `/components/tiptap-drag-handle-plugin.ts` :
@@ -107,14 +112,13 @@ Développer un plugin custom dans `/components/tiptap-drag-handle-plugin.ts` :
### 3. Fichiers à modifier / créer / supprimer :
* `[NEW]` `memento-note/components/tiptap-drag-handle-plugin.ts` — Plugin Gutter (US-1)
-* `[NEW]` `memento-note/components/tiptap-structured-view-block-extension.tsx` — Nœud Vue Structurée (US-4 redesign)
-* `[NEW]` `memento-note/components/structured-view-block-embed.tsx` — React NodeView avec SWR + états dégradés
-* `[DELETE]` `memento-note/components/tiptap-database-block-extension.tsx` — Legacy rejeté
-* `[DELETE]` `memento-note/components/database-block-editor.tsx` — Legacy rejeté
-* `[DELETE]` `memento-note/lib/editor/database-block-types.ts` — Legacy rejeté
-* `[MODIFY]` `memento-note/components/note-content-area.tsx` — Passer `notebookId` à l'éditeur
-* `[MODIFY]` `memento-note/components/rich-text-editor.tsx` — Swap extension DB → Vue Structurée, mise à jour slash
-* `[MODIFY]` `memento-note/components/block-action-menu.tsx` — Remplacer option "Database" par "Vue structurée"
+* `[LIVRÉ]` `memento-note/components/tiptap-structured-view-block-extension.tsx` — Nœud dual-mode
+* `[LIVRÉ]` `memento-note/components/structured-view-block-embed.tsx` — UI base locale + vue carnet
+* `[DELETE]` legacy `database-block-*` (Auteurs & Œuvres)
+* `[MODIFY]` `memento-note/locales/en.json` + `fr.json` — clés `structuredViewBlock.*` (i18n embed)
+* `[MODIFY]` `memento-note/components/note-content-area.tsx` — `notebookId` → éditeur
+* `[MODIFY]` `memento-note/components/rich-text-editor.tsx` — slash `/database` + `/vue`
+* `[MODIFY]` `memento-note/components/block-action-menu.tsx` — « Transformer en → Base de données »
* `[MODIFY]` `memento-note/app/globals.css` — Gutter, poignée, glassmorphic dropdowns
---
@@ -125,9 +129,9 @@ Développer un plugin custom dans `/components/tiptap-drag-handle-plugin.ts` :
1. **Vérification Gutter :** Survoler des textes longs et vérifier que la poignée se positionne correctement à gauche. Glisser un paragraphe sur un autre et valider le réordonnancement.
2. **Vérification Menu :** Cliquer sur la poignée, dupliquer le bloc, supprimer le bloc, et le transformer en d'autres types.
3. **Vérification Paste :** Copier une référence de bloc, la coller, et vérifier que la transclusion est proposée et s'insère sous forme de `LiveBlock`.
-4. **Vérification Vue Structurée Inline (US-4 redesign) :**
- - Taper `/vue` dans une note de carnet structuré → bloc avec données réelles (pas Jules Verne).
- - Taper `/vue` dans un carnet sans schéma → callout wizard, pas de crash.
- - Ouvrir une note avec ancien `databaseBlock` → note charge sans erreur.
- - Passer la langue en `fa` (persan) → libellés traduits, layout RTL correct.
- - Voir checklist complète dans `docs/story-nextgen-editor-us4-redesign.md` §11.
+4. **Vérification Base inline (US-4) :**
+ - `/database` ou `/vue` → base locale autonome (tableau éditable, design actuel).
+ - « Lier à un carnet » → vue table/galerie du carnet choisi.
+ - Carnet sans schéma → callout wizard.
+ - Ancien `databaseBlock` → charge sans crash.
+ - Locale `fa` → libellés i18n, RTL (`dir="auto"`).
diff --git a/memento-note/app/(main)/settings/billing/page.tsx b/memento-note/app/(main)/settings/billing/page.tsx
index a186c1a..70aab56 100644
--- a/memento-note/app/(main)/settings/billing/page.tsx
+++ b/memento-note/app/(main)/settings/billing/page.tsx
@@ -2,6 +2,8 @@ import { Suspense } from 'react';
import { Loader2 } from 'lucide-react';
import { BillingPlans } from '@/components/settings/billing-plans';
+export const dynamic = 'force-dynamic';
+
export const metadata = {
title: 'Billing',
};
diff --git a/memento-note/app/api/billing/cancel/route.ts b/memento-note/app/api/billing/cancel/route.ts
new file mode 100644
index 0000000..ff1f4fe
--- /dev/null
+++ b/memento-note/app/api/billing/cancel/route.ts
@@ -0,0 +1,30 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { auth } from '@/auth';
+import { cancelSubscription } from '@/lib/billing/cancel-subscription';
+
+export const dynamic = 'force-dynamic';
+
+export async function POST(req: NextRequest) {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const userId = session.user.id;
+
+ try {
+ const result = await cancelSubscription(userId);
+ if (!result.success) {
+ const isNotFound = result.error === 'No active subscription found';
+ return NextResponse.json(
+ { error: result.error },
+ { status: isNotFound ? 404 : 400 }
+ );
+ }
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error('[billing/cancel] Route handler crash:', error);
+ return NextResponse.json({ error: 'Failed to cancel subscription' }, { status: 500 });
+ }
+}
diff --git a/memento-note/app/api/billing/create-checkout/route.ts b/memento-note/app/api/billing/create-checkout/route.ts
index e0766d7..fb119cf 100644
--- a/memento-note/app/api/billing/create-checkout/route.ts
+++ b/memento-note/app/api/billing/create-checkout/route.ts
@@ -31,21 +31,41 @@ export async function POST(req: NextRequest) {
const subscription = await prisma.subscription.findUnique({ where: { userId } });
let customerId = subscription?.stripeCustomerId ?? undefined;
+ if (customerId && customerId.startsWith('cus_mock')) {
+ customerId = undefined;
+ }
+
if (!customerId) {
const customer = await stripe.customers.create({
email: userEmail,
metadata: { userId },
});
customerId = customer.id;
+
+ // Update DB to save the real Stripe customer ID
+ await prisma.subscription.upsert({
+ where: { userId },
+ update: { stripeCustomerId: customerId },
+ create: {
+ userId,
+ stripeCustomerId: customerId,
+ tier: 'BASIC',
+ status: 'ACTIVE',
+ currentPeriodStart: new Date(),
+ currentPeriodEnd: new Date(Date.now() + 30 * 24 * 3600 * 1000), // temp basic dates
+ }
+ });
}
- const origin = req.headers.get('origin') ?? process.env.NEXTAUTH_URL ?? 'http://localhost:3000';
+ const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host') ?? 'localhost:3000';
+ const proto = req.headers.get('x-forwarded-proto') ?? 'http';
+ const origin = `${proto}://${host}`;
const sessionParams = {
customer: customerId,
mode: 'subscription' as const,
line_items: [{ price: priceId, quantity: 1 }],
- ui_mode: 'embedded',
+ ui_mode: 'embedded_page',
return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
metadata: { userId, tier },
subscription_data: { metadata: { userId, tier } },
diff --git a/memento-note/app/api/billing/invoices/route.ts b/memento-note/app/api/billing/invoices/route.ts
index 7535cfd..9a187b1 100644
--- a/memento-note/app/api/billing/invoices/route.ts
+++ b/memento-note/app/api/billing/invoices/route.ts
@@ -3,6 +3,8 @@ import { auth } from '@/auth';
import { stripe } from '@/lib/stripe';
import { prisma } from '@/lib/prisma';
+export const dynamic = 'force-dynamic';
+
export async function GET(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
diff --git a/memento-note/app/api/billing/portal/route.ts b/memento-note/app/api/billing/portal/route.ts
index 5cb2854..6359b12 100644
--- a/memento-note/app/api/billing/portal/route.ts
+++ b/memento-note/app/api/billing/portal/route.ts
@@ -11,13 +11,21 @@ export async function POST(req: NextRequest) {
const userId = session.user.id;
+ let action = 'portal';
+ try {
+ const body = await req.json();
+ if (body?.action) action = body.action;
+ } catch (_) {}
+
try {
const subscription = await prisma.subscription.findUnique({ where: { userId } });
if (!subscription?.stripeCustomerId) {
return NextResponse.json({ error: 'No active subscription found' }, { status: 404 });
}
- const origin = req.headers.get('origin') ?? process.env.NEXTAUTH_URL ?? 'http://localhost:3000';
+ const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host') ?? 'localhost:3000';
+ const proto = req.headers.get('x-forwarded-proto') ?? 'http';
+ const origin = `${proto}://${host}`;
const portalSession = await stripe.billingPortal.sessions.create({
customer: subscription.stripeCustomerId,
diff --git a/memento-note/app/api/billing/status/route.ts b/memento-note/app/api/billing/status/route.ts
index 69cd1c0..dc7674f 100644
--- a/memento-note/app/api/billing/status/route.ts
+++ b/memento-note/app/api/billing/status/route.ts
@@ -1,20 +1,79 @@
-import { NextResponse } from 'next/server';
+import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
+import { stripe } from '@/lib/stripe';
+import type Stripe from 'stripe';
+import { priceIdToTier } from '@/lib/billing/stripe-prices';
-export async function GET() {
+export const dynamic = 'force-dynamic';
+
+export async function GET(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const userId = session.user.id;
+ const { prisma } = await import('@/lib/prisma');
+
+ const sessionId = req.nextUrl.searchParams.get('session_id');
+
+ if (sessionId && sessionId.startsWith('cs_')) {
+ try {
+ const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId);
+ if (checkoutSession.subscription && checkoutSession.status === 'complete') {
+ const subId = typeof checkoutSession.subscription === 'string'
+ ? checkoutSession.subscription
+ : (checkoutSession.subscription as any).id;
+
+ const sub = await stripe.subscriptions.retrieve(subId);
+ const priceId = sub.items.data[0].price.id;
+ const tier = 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),
+ },
+ });
+ }
+ } catch (err) {
+ console.error('[billing/status] Failed to sync Stripe session:', err);
+ }
+ }
try {
const { tier, status, currentPeriodEnd } = await getUserInfo(userId);
const effectiveTier = await getEffectiveTier(userId);
-
- const { prisma } = await import('@/lib/prisma');
const subscription = await prisma.subscription.findUnique({ where: { userId } });
return NextResponse.json({
diff --git a/memento-note/app/api/usage/current/route.ts b/memento-note/app/api/usage/current/route.ts
index 61480f4..dc541d7 100644
--- a/memento-note/app/api/usage/current/route.ts
+++ b/memento-note/app/api/usage/current/route.ts
@@ -2,6 +2,8 @@ import { NextResponse } from 'next/server';
import { auth } from '@/auth';
import { getUserQuotas, getEffectiveTier } from '@/lib/entitlements';
+export const dynamic = 'force-dynamic';
+
export async function GET() {
const session = await auth();
diff --git a/memento-note/app/globals.css b/memento-note/app/globals.css
index 8397f86..50aca53 100644
--- a/memento-note/app/globals.css
+++ b/memento-note/app/globals.css
@@ -1210,295 +1210,6 @@ html.font-system * {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
-/* --- Inline Database Block (US-NEXTGEN-EDITOR) --- */
-.database-block-wrapper {
- margin: 0.75rem 0;
-}
-
-.database-block__inner {
- border: 1px solid #e8e6e3;
- border-radius: 12px;
- background: rgba(255, 255, 255, 0.6);
- padding: 1rem 1.1rem;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
-}
-
-.dark .database-block__inner {
- border-color: rgba(255, 255, 255, 0.08);
- background: rgba(24, 24, 27, 0.5);
-}
-
-.database-block__header {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- justify-content: space-between;
- gap: 0.75rem;
- margin-bottom: 0.35rem;
-}
-
-.database-block__title {
- display: block;
- font-size: 0.9rem;
- font-weight: 700;
- color: var(--color-ink, #1a1a1a);
-}
-
-.database-block__id {
- display: block;
- font-size: 0.65rem;
- font-family: ui-monospace, monospace;
- opacity: 0.45;
- margin-top: 0.1rem;
-}
-
-.database-block__hint {
- font-size: 0.72rem;
- opacity: 0.55;
- margin: 0 0 0.85rem;
-}
-
-.database-block__view-toggle {
- display: inline-flex;
- padding: 2px;
- border-radius: 8px;
- background: rgba(0, 0, 0, 0.04);
- border: 1px solid rgba(0, 0, 0, 0.06);
-}
-
-.dark .database-block__view-toggle {
- background: rgba(255, 255, 255, 0.04);
- border-color: rgba(255, 255, 255, 0.08);
-}
-
-.database-block__view-toggle button {
- border: none;
- background: transparent;
- font-size: 0.68rem;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- padding: 0.35rem 0.65rem;
- border-radius: 6px;
- cursor: pointer;
- opacity: 0.65;
- color: inherit;
-}
-
-.database-block__view-toggle button.is-active {
- background: white;
- opacity: 1;
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
-}
-
-.dark .database-block__view-toggle button.is-active {
- background: rgba(255, 255, 255, 0.12);
-}
-
-.database-block__table-wrap {
- overflow-x: auto;
- border-radius: 8px;
- border: 1px solid rgba(0, 0, 0, 0.06);
-}
-
-.dark .database-block__table-wrap {
- border-color: rgba(255, 255, 255, 0.08);
-}
-
-.database-block__table {
- width: 100%;
- border-collapse: collapse;
- font-size: 0.8rem;
-}
-
-.database-block__table th {
- text-align: left;
- padding: 0.5rem 0.75rem;
- font-size: 0.65rem;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- opacity: 0.55;
- background: rgba(0, 0, 0, 0.02);
- border-bottom: 1px solid rgba(0, 0, 0, 0.06);
-}
-
-.database-block__table td {
- padding: 0.55rem 0.75rem;
- border-bottom: 1px solid rgba(0, 0, 0, 0.04);
- vertical-align: top;
-}
-
-.database-block__works-cell {
- max-width: 220px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- opacity: 0.85;
-}
-
-.database-block__rollup {
- text-align: right;
- font-weight: 700;
- font-variant-numeric: tabular-nums;
-}
-
-.database-block__delete-btn {
- border: none;
- background: transparent;
- font-size: 0.65rem;
- opacity: 0.45;
- cursor: pointer;
- color: #ef4444;
-}
-
-.database-block__delete-btn:hover {
- opacity: 1;
-}
-
-.database-block__inline-form,
-.database-block__book-form {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 0.5rem;
- margin-top: 0.75rem;
- padding-top: 0.75rem;
- border-top: 1px dashed rgba(0, 0, 0, 0.08);
-}
-
-.database-block__book-form {
- flex-direction: column;
- align-items: stretch;
-}
-
-.database-block__form-label,
-.database-block__form-heading {
- font-size: 0.68rem;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- opacity: 0.55;
- width: 100%;
-}
-
-.database-block__input {
- font-size: 0.8rem;
- padding: 0.45rem 0.65rem;
- border-radius: 8px;
- border: 1px solid rgba(0, 0, 0, 0.1);
- background: white;
- color: inherit;
-}
-
-.dark .database-block__input {
- background: rgba(0, 0, 0, 0.25);
- border-color: rgba(255, 255, 255, 0.1);
-}
-
-.database-block__primary-btn,
-.database-block__submit {
- border: none;
- border-radius: 8px;
- background: #3b82f6;
- color: white;
- font-size: 0.75rem;
- font-weight: 600;
- padding: 0.45rem 0.85rem;
- cursor: pointer;
-}
-
-.database-block__primary-btn:hover,
-.database-block__submit:hover {
- background: #2563eb;
-}
-
-.database-block__card {
- position: relative;
- border-radius: 10px;
- overflow: hidden;
- border: 1px solid rgba(0, 0, 0, 0.08);
- background: white;
-}
-
-.dark .database-block__card {
- background: rgba(0, 0, 0, 0.2);
- border-color: rgba(255, 255, 255, 0.08);
-}
-
-.database-block__card-delete {
- position: absolute;
- top: 6px;
- right: 6px;
- z-index: 2;
- border: none;
- border-radius: 6px;
- padding: 4px;
- background: rgba(0, 0, 0, 0.45);
- color: white;
- cursor: pointer;
- opacity: 0;
- transition: opacity 0.15s;
-}
-
-.database-block__card:hover .database-block__card-delete {
- opacity: 1;
-}
-
-.database-block__card-cover {
- aspect-ratio: 4 / 3;
- overflow: hidden;
- background: #f4f4f5;
-}
-
-.database-block__card-cover img {
- width: 100%;
- height: 100%;
- object-fit: cover;
-}
-
-.database-block__card-body {
- padding: 0.55rem 0.65rem 0.65rem;
-}
-
-.database-block__card-title {
- font-size: 0.78rem;
- font-weight: 700;
- line-height: 1.3;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
-}
-
-.database-block__tag {
- font-size: 0.62rem;
- font-weight: 600;
- padding: 0.15rem 0.4rem;
- border-radius: 4px;
-}
-
-.database-block__tag--author {
- background: rgba(59, 130, 246, 0.12);
- color: #2563eb;
-}
-
-.database-block__tag--genre {
- background: rgba(0, 0, 0, 0.06);
- opacity: 0.75;
-}
-
-.database-block__card-placeholder {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- min-height: 140px;
- border-radius: 10px;
- border: 2px dashed rgba(0, 0, 0, 0.08);
- opacity: 0.5;
- text-align: center;
- padding: 1rem;
-}
/* --- Drop Indicator Line --- */
.notion-drop-indicator {
@@ -1622,23 +1333,24 @@ html.font-system * {
}
/* --- Lists --- */
-.notion-editor-wrapper .ProseMirror ul {
+/* TipTap wraps list content in
(block). inside markers force bullet on its own line. */
+.notion-editor-wrapper .ProseMirror ul:not([data-type="taskList"]) {
list-style-type: disc;
- list-style-position: inside;
- padding-inline-start: 0;
+ list-style-position: outside;
+ padding-inline-start: 1.5rem;
margin: 0.25em 0;
}
.notion-editor-wrapper .ProseMirror ol {
list-style-type: decimal;
- list-style-position: inside;
- padding-inline-start: 0;
+ list-style-position: outside;
+ padding-inline-start: 1.5rem;
margin: 0.25em 0;
}
.notion-editor-wrapper .ProseMirror li>p {
- margin: 0.1em 0;
- padding-inline-start: 0.25rem;
+ margin: 0;
+ padding-inline-start: 0;
}
.notion-editor-wrapper .ProseMirror li>ul,
diff --git a/memento-note/components/settings/billing-plans.tsx b/memento-note/components/settings/billing-plans.tsx
index d4b6c48..5fcb4e2 100644
--- a/memento-note/components/settings/billing-plans.tsx
+++ b/memento-note/components/settings/billing-plans.tsx
@@ -24,7 +24,7 @@ interface BillingStatus {
hasStripeSubscription: boolean;
}
-const billingEnabled = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true';
+const billingEnabled = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' || process.env.NODE_ENV === 'development';
let stripePromise: ReturnType | null = null;
function getStripePromise() {
@@ -43,12 +43,14 @@ export function BillingPlans() {
const [isCheckoutOpen, setIsCheckoutOpen] = useState(false);
const [checkoutLoading, setCheckoutLoading] = useState(null);
const [portalLoading, setPortalLoading] = useState(false);
+ const [cancelLoading, setCancelLoading] = useState(false);
const [successBanner, setSuccessBanner] = useState(null);
const { data: status, isLoading } = useQuery({
queryKey: ['billing', 'status'],
queryFn: async () => {
- const res = await fetch('/api/billing/status');
+ const search = typeof window !== 'undefined' ? window.location.search : '';
+ const res = await fetch(`/api/billing/status${search}`);
if (!res.ok) throw new Error('Failed to fetch billing status');
return res.json();
},
@@ -69,6 +71,7 @@ export function BillingPlans() {
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const sessionId = params.get('session_id');
+
if (sessionId) {
const tier = status?.effectiveTier ?? 'Pro';
setSuccessBanner(t('billing.checkoutSuccessBody').replace('{tier}', tier));
@@ -112,10 +115,15 @@ export function BillingPlans() {
}
};
- const handlePortal = async () => {
+ const handlePortal = async (action: 'portal' | 'cancel' | React.MouseEvent = 'portal') => {
+ const actualAction = typeof action === 'string' ? action : 'portal';
setPortalLoading(true);
try {
- const res = await fetch('/api/billing/portal', { method: 'POST' });
+ const res = await fetch('/api/billing/portal', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: actualAction }),
+ });
const data = await res.json();
if (!res.ok) {
toast.error(data.error || 'Failed to open billing portal.');
@@ -130,6 +138,34 @@ export function BillingPlans() {
}
};
+ const handleCancelSubscription = async () => {
+ const confirmMsg = t('billing.cancelConfirm') || "Êtes-vous sûr de vouloir résilier votre abonnement ? Vous conserverez vos accès Pro/Business jusqu'à la fin de la période en cours.";
+ if (!window.confirm(confirmMsg)) {
+ return;
+ }
+
+ setCancelLoading(true);
+ try {
+ const res = await fetch('/api/billing/cancel', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ });
+ const data = await res.json();
+ if (!res.ok) {
+ toast.error(data.error || 'Failed to cancel subscription.');
+ return;
+ }
+ toast.success(t('billing.cancelSuccess') || "Votre abonnement a été résilié avec succès. Il prendra fin à la fin de la période de facturation en cours.");
+ queryClient.invalidateQueries({ queryKey: ['billing', 'status'] });
+ queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
+ } catch (err) {
+ console.error('[BillingPlans] cancel error:', err);
+ toast.error('Failed to cancel subscription.');
+ } finally {
+ setCancelLoading(false);
+ }
+ };
+
const handleCheckoutComplete = useCallback(() => {
setIsCheckoutOpen(false);
setCheckoutClientSecret(null);
@@ -310,38 +346,42 @@ export function BillingPlans() {
{effectiveTier === 'BASIC' ? t('billing.freePlan') : effectiveTier === 'PRO' ? t('billing.proPlan') : effectiveTier === 'BUSINESS' ? t('billing.businessPlan') : t('billing.enterprisePlan')}
-
-
- {status?.status ? t(`billing.${status.status.toLowerCase()}`) || status.status : t('billing.active')}
-
-
+ {isPaid && (
+
+
+ {status?.status ? t(`billing.${status.status.toLowerCase()}`) || status.status : t('billing.active')}
+
+
+ )}
-
-
-
{t('billing.billingPeriod')}
-
- {status?.currentPeriodStart && status?.currentPeriodEnd ? (
- `${formatDate(status.currentPeriodStart)} – ${formatDate(status.currentPeriodEnd)}`
- ) : (
- '—'
- )}
-
+ {isPaid && (
+
+
+
{t('billing.billingPeriod')}
+
+ {status?.currentPeriodStart && status?.currentPeriodEnd ? (
+ `${formatDate(status.currentPeriodStart)} – ${formatDate(status.currentPeriodEnd)}`
+ ) : (
+ '—'
+ )}
+
+
+
+
+ {status?.cancelAtPeriodEnd ? t('billing.expiresOn') : t('billing.nextBillingDate')}
+
+
+ {status?.currentPeriodEnd ? formatDate(status.currentPeriodEnd) : '—'}
+
+
-
-
- {status?.cancelAtPeriodEnd ? t('billing.expiresOn') : t('billing.nextBillingDate')}
-
-
- {status?.currentPeriodEnd ? formatDate(status.currentPeriodEnd) : '—'}
-
-
-
+ )}
{isPaid && (
@@ -355,13 +395,14 @@ export function BillingPlans() {
{t('billing.manageBilling') || 'Gérer la facturation'}
- {status?.hasStripeSubscription && (
+ {status?.hasStripeSubscription && !status?.cancelAtPeriodEnd && (
+ {cancelLoading ? : null}
{t('billing.cancelSubscription') || "Résilier l'abonnement"}
)}
diff --git a/memento-note/components/structured-view-block-embed.tsx b/memento-note/components/structured-view-block-embed.tsx
index 7553616..1d1e36b 100644
--- a/memento-note/components/structured-view-block-embed.tsx
+++ b/memento-note/components/structured-view-block-embed.tsx
@@ -114,10 +114,10 @@ export function StructuredViewBlockEmbed({
if (json.success && Array.isArray(json.data)) {
setNotes(json.data)
} else {
- setNotesError(t('structuredViewBlock.loadError') || 'Failed to load notes')
+ setNotesError(t('structuredViewBlock.loadError'))
}
} catch (e) {
- setNotesError(e instanceof Error ? e.message : 'Error loading notes')
+ setNotesError(e instanceof Error ? e.message : t('structuredViewBlock.notesLoadError'))
} finally {
setNotesLoading(false)
}
@@ -157,7 +157,7 @@ export function StructuredViewBlockEmbed({
const newId = `col-${Date.now()}`
const newCol: LocalColumn = {
id: newId,
- name: `Propriété ${localColumns.length + 1}`,
+ name: t('structuredViewBlock.propertyName', { index: localColumns.length + 1 }),
type: 'text',
}
const updatedCols = [...localColumns, newCol]
@@ -166,7 +166,7 @@ export function StructuredViewBlockEmbed({
values: { ...row.values, [newId]: '' }
}))
updateLocalData(updatedCols, updatedRows)
- toast.success('Colonne ajoutée !')
+ toast.success(t('structuredViewBlock.columnAdded'))
}
const deleteLocalColumn = (colId: string) => {
@@ -177,7 +177,7 @@ export function StructuredViewBlockEmbed({
return { ...row, values: nextValues }
})
updateLocalData(updatedCols, updatedRows)
- toast.success('Colonne supprimée')
+ toast.success(t('structuredViewBlock.columnRemoved'))
}
const updateLocalColumnName = (colId: string, name: string) => {
@@ -190,7 +190,7 @@ export function StructuredViewBlockEmbed({
if (c.id === colId) {
const next: LocalColumn = { ...c, type }
if (type === 'select') {
- next.options = ['Option 1', 'Option 2']
+ next.options = [t('structuredViewBlock.defaultOption1'), t('structuredViewBlock.defaultOption2')]
} else {
delete next.options
}
@@ -255,7 +255,7 @@ export function StructuredViewBlockEmbed({
const handleConvertToNotebook = async () => {
const name = newNotebookName.trim()
if (!name) {
- toast.error('Veuillez entrer un nom pour le carnet.')
+ toast.error(t('structuredViewBlock.convertNotebookNameRequired'))
return
}
setConverting(true)
@@ -268,7 +268,7 @@ export function StructuredViewBlockEmbed({
})
const createdNbJson = await createNbRes.json()
if (!createNbRes.ok || !createdNbJson.success) {
- throw new Error(createdNbJson.error || 'Erreur lors de la création du carnet.')
+ throw new Error(createdNbJson.error || t('structuredViewBlock.convertNotebookError'))
}
const newNotebook = createdNbJson.data
@@ -278,7 +278,7 @@ export function StructuredViewBlockEmbed({
})
const schemaJson = await enableSchemaRes.json()
if (!enableSchemaRes.ok || !schemaJson.success) {
- throw new Error(schemaJson.error || 'Erreur lors de l\'activation de la structure.')
+ throw new Error(schemaJson.error || t('structuredViewBlock.convertSchemaError'))
}
const schema = schemaJson.data.schema
@@ -300,7 +300,7 @@ export function StructuredViewBlockEmbed({
})
const propJson = await propRes.json()
if (!propRes.ok || !propJson.success) {
- throw new Error(propJson.error || 'Erreur d\'ajout de propriété.')
+ throw new Error(propJson.error || t('structuredViewBlock.convertPropertyError'))
}
// Find added property ID in the updated schema
const addedProp = propJson.data.schema.properties.find((p: any) => p.name === col.name)
@@ -311,7 +311,7 @@ export function StructuredViewBlockEmbed({
// 4. Create Notes for each row
for (const row of localRows) {
- const titleVal = row.values['col-title'] || 'Sans titre'
+ const titleVal = row.values['col-title'] || t('structuredViewBlock.untitled')
const noteRes = await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -323,7 +323,7 @@ export function StructuredViewBlockEmbed({
})
const noteJson = await noteRes.json()
if (!noteRes.ok || !noteJson.success) {
- throw new Error('Erreur de création de note.')
+ throw new Error(t('structuredViewBlock.convertNoteError'))
}
const createdNote = noteJson.data
@@ -351,11 +351,11 @@ export function StructuredViewBlockEmbed({
})
await refreshNotebooks()
- toast.success('Conversion réussie ! Base liée créée.')
+ toast.success(t('structuredViewBlock.convertSuccess'))
setShowConvertModal(false)
} catch (e) {
console.error(e)
- toast.error(e instanceof Error ? e.message : 'Une erreur est survenue.')
+ toast.error(e instanceof Error ? e.message : t('structuredViewBlock.convertGenericError'))
} finally {
setConverting(false)
}
@@ -416,7 +416,7 @@ export function StructuredViewBlockEmbed({
const q = (titleVal || '').trim()
if (!q) {
setEchoConnections([])
- setEchoError("Veuillez d'abord saisir un nom pour cette ligne afin de rechercher des résonances sémantiques.")
+ setEchoError(t('structuredViewBlock.echoNameRequired'))
setEchoLoading(false)
return
}
@@ -438,11 +438,11 @@ export function StructuredViewBlockEmbed({
}
if (data.length === 0) {
- setEchoError(`Aucune note correspondante contenant "${q}" n'a été trouvée dans votre espace de travail.`)
+ setEchoError(t('structuredViewBlock.echoNoMatch', { query: q }))
} else {
setEchoConnections(data.map((n: any, idx: number) => ({
noteId: n.id,
- title: n.title || 'Sans titre',
+ title: n.title || t('structuredViewBlock.untitled'),
similarity: Math.round((0.88 - idx * 0.04) * 100),
updatedAt: n.updatedAt,
isTextMatch: true
@@ -450,7 +450,7 @@ export function StructuredViewBlockEmbed({
}
} catch (e) {
console.error(e)
- setEchoError("Une erreur est survenue lors de la recherche.")
+ setEchoError(t('structuredViewBlock.echoSearchError'))
} finally {
setEchoLoading(false)
}
@@ -545,7 +545,7 @@ export function StructuredViewBlockEmbed({
return (
- {t('structuredViewBlock.analyticsNoData') || 'Aucune donnée d\'analyse disponible.'}
+ {t('structuredViewBlock.analyticsNoData')}
)
}
@@ -555,10 +555,10 @@ export function StructuredViewBlockEmbed({
- {t('structuredViewBlock.analyticsTitle') || 'Analyses & Insights'}
+ {t('structuredViewBlock.analyticsTitle')}
- {t('structuredViewBlock.analyticsTotalRows') || 'Total des lignes'} : {total}
+ {t('structuredViewBlock.analyticsTotalRows')}: {total}
@@ -567,7 +567,7 @@ export function StructuredViewBlockEmbed({
{hasCheckboxStats && (
- {t('structuredViewBlock.analyticsCompletion') || 'Taux de complétion'}
+ {t('structuredViewBlock.analyticsCompletion')}
{checkboxStats.map(stat => (
@@ -592,7 +592,7 @@ export function StructuredViewBlockEmbed({
{hasSelectStats && (
- {t('structuredViewBlock.analyticsDistribution') || 'Répartition'}
+ {t('structuredViewBlock.analyticsDistribution')}
{selectStats.map(stat => {
@@ -650,7 +650,7 @@ export function StructuredViewBlockEmbed({
- {t('structuredViewBlock.localDbTitle') || 'Base de Données Autonome'}
+ {t('structuredViewBlock.localDbTitle')}
@@ -663,7 +663,7 @@ export function StructuredViewBlockEmbed({
className="text-[11px] h-7 px-2.5 rounded-lg transition-all flex items-center gap-1.5"
>
-
Analyses
+
{t('structuredViewBlock.analyticsShort')}
@@ -676,7 +676,7 @@ export function StructuredViewBlockEmbed({
className="text-[11px] h-7 px-2.5 rounded-lg border-primary/30 hover:border-primary hover:bg-primary/5 text-primary transition-all flex items-center gap-1.5"
>
-
Convertir en carnet
+
{t('structuredViewBlock.convertToNotebook')}
@@ -688,7 +688,7 @@ export function StructuredViewBlockEmbed({
onClick={() => updateAttributes({ isLocal: false })}
className="text-[11px] h-7 px-2 text-muted-foreground hover:text-foreground"
>
- Lier à un carnet
+ {t('structuredViewBlock.linkToNotebook')}
@@ -702,16 +702,16 @@ export function StructuredViewBlockEmbed({
- Convertir en carnet structuré
+ {t('structuredViewBlock.convertModalTitle')}
- Ce tableau local va être converti en carnet réel. Chaque ligne deviendra une note de votre carnet, et vos colonnes seront configurées comme propriétés réutilisables.
+ {t('structuredViewBlock.convertModalDesc')}
- Nom du nouveau carnet
+ {t('structuredViewBlock.convertNotebookNameLabel')}
setNewNotebookName(e.target.value)}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-xs outline-none focus:ring-1 focus:ring-primary"
@@ -719,15 +719,15 @@ export function StructuredViewBlockEmbed({
setShowConvertModal(false)} disabled={converting}>
- Annuler
+ {t('structuredViewBlock.cancel')}
{converting ? (
<>
- Conversion...
+ {t('structuredViewBlock.converting')}
>
- ) : 'Créer le carnet'}
+ ) : t('structuredViewBlock.createNotebook')}
@@ -758,16 +758,16 @@ export function StructuredViewBlockEmbed({
onChange={(e) => updateLocalColumnType(col.id, e.target.value as any)}
className="text-[9px] bg-transparent border border-border/30 rounded px-1 py-0.5 text-muted-foreground hover:border-border transition-colors outline-none cursor-pointer"
>
-
Texte
-
Case
-
Liste
+
{t('structuredViewBlock.colTypeText')}
+
{t('structuredViewBlock.colTypeCheckbox')}
+
{t('structuredViewBlock.colTypeSelect')}
)}
{/* Display select options configurations inline */}
{col.type === 'select' && (
updateLocalColumnOptions(col.id, e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
@@ -784,7 +784,7 @@ export function StructuredViewBlockEmbed({
deleteLocalColumn(col.id)
}}
className="opacity-40 hover:opacity-100 hover:text-red-500 p-0.5 rounded transition-all ml-auto shrink-0"
- title="Supprimer la colonne"
+ title={t('structuredViewBlock.deleteColumn')}
>
@@ -797,7 +797,7 @@ export function StructuredViewBlockEmbed({
type="button"
onClick={addLocalColumn}
className="p-1 rounded bg-primary/10 hover:bg-primary/20 text-primary transition-all"
- title="Ajouter une colonne"
+ title={t('structuredViewBlock.addColumn')}
>
@@ -815,7 +815,7 @@ export function StructuredViewBlockEmbed({
type="button"
onClick={() => deleteLocalRow(row.id)}
className="opacity-0 group-hover/row:opacity-100 hover:text-red-500 p-1 rounded hover:bg-red-500/10 transition-all"
- title="Supprimer la ligne"
+ title={t('structuredViewBlock.deleteRow')}
>
@@ -828,7 +828,7 @@ export function StructuredViewBlockEmbed({
? "text-purple-400 bg-purple-500/10 opacity-100"
: "hover:text-purple-400 hover:bg-purple-500/10"
)}
- title="Résonances sémantiques"
+ title={t('structuredViewBlock.semanticEcho')}
>
@@ -875,7 +875,7 @@ export function StructuredViewBlockEmbed({
updateLocalCellValue(row.id, col.id, e.target.value)}
onKeyDown={(e) => {
e.stopPropagation()
@@ -902,20 +902,20 @@ export function StructuredViewBlockEmbed({
- {t('structuredViewBlock.echoPopoverTitle') || 'Résonance Sémantique 🔮'}
+ {t('structuredViewBlock.echoPopoverTitle')}
{ setActiveEchoRowId(null); setEchoConnections([]); }}
className="text-[10px] text-muted-foreground hover:text-foreground hover:underline transition-colors"
>
- Fermer
+ {t('structuredViewBlock.close')}
{echoLoading ? (
- {t('structuredViewBlock.echoLoading') || 'Recherche de connexions sémantiques...'}
+ {t('structuredViewBlock.echoLoading')}
) : echoError ? (
@@ -923,16 +923,16 @@ export function StructuredViewBlockEmbed({
{echoError}
- {t('structuredViewBlock.echoUpgradeText') || "Convertissez ce tableau en carnet pour activer l'analyse neuronale de Momento."}
+ {t('structuredViewBlock.echoUpgradeText')}
) : echoConnections.length === 0 ? (
- {t('structuredViewBlock.noEchoFound') || 'Aucune résonance sémantique détectée.'}
+ {t('structuredViewBlock.noEchoFound')}
- {t('structuredViewBlock.echoUpgradeText') || "Convertissez ce tableau en carnet pour activer l'analyse neuronale de Momento."}
+ {t('structuredViewBlock.echoUpgradeText')}
) : (
@@ -959,20 +959,20 @@ export function StructuredViewBlockEmbed({
window.dispatchEvent(new CustomEvent('memento-insert-citation', {
detail: {
noteId: conn.noteId,
- noteTitle: conn.title || 'Sans titre',
+ noteTitle: conn.title || t('structuredViewBlock.untitled'),
excerpt: '',
atEnd: false
}
}))
- toast.success("Citation insérée dans l'éditeur !")
+ toast.success(t('structuredViewBlock.citationInserted'))
}}
className="p-1 text-muted-foreground hover:text-purple-400 hover:bg-purple-500/10 rounded transition-colors"
- title="Insérer le lien dans l'éditeur"
+ title={t('structuredViewBlock.insertCitation')}
>
- {conn.isTextMatch ? 'Mot-clé' : `${conn.similarity}%`}
+ {conn.isTextMatch ? t('structuredViewBlock.keywordMatch') : `${conn.similarity}%`}
@@ -990,7 +990,7 @@ export function StructuredViewBlockEmbed({
{localRows.length === 0 && (
-
Aucune ligne dans le tableau.
+
{t('structuredViewBlock.emptyTable')}
)}
{/* Add row button */}
@@ -1002,7 +1002,7 @@ export function StructuredViewBlockEmbed({
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 h-8 px-2.5 rounded-lg"
>
-
Ajouter une ligne
+
{t('structuredViewBlock.addRow')}
@@ -1023,10 +1023,10 @@ export function StructuredViewBlockEmbed({
>
-
{t('structuredViewBlock.selectNotebook') || 'Lier à un carnet'}
+
{t('structuredViewBlock.selectNotebook')}
- {t('structuredViewBlock.noNotebookDesc') || 'Ce bloc affiche la vue structurée d\'un carnet. Choisissez le carnet à lier :'}
+ {t('structuredViewBlock.noNotebookDesc')}
@@ -1039,7 +1039,7 @@ export function StructuredViewBlockEmbed({
className="rounded-lg border border-border bg-background px-3 py-1.5 text-xs text-foreground outline-none focus:border-primary max-w-xs w-full shadow-sm"
defaultValue=""
>
- -- {t('structuredViewBlock.chooseNotebook') || 'Choisir un carnet'} --
+ -- {t('structuredViewBlock.chooseNotebook')} --
{notebooks.map((nb) => (
{nb.icon ? `${nb.icon} ` : ''}{nb.name}
@@ -1047,7 +1047,7 @@ export function StructuredViewBlockEmbed({
))}
- ou
+ {t('structuredViewBlock.or')}
updateAttributes({ isLocal: true })}
className="text-xs text-primary hover:bg-primary/5"
>
- Créer une base locale autonome
+ {t('structuredViewBlock.createLocalDb')}
@@ -1085,7 +1085,7 @@ export function StructuredViewBlockEmbed({
{schemaHook.error || notesError}
- {t('structuredViewBlock.retry') || 'Réessayer'}
+ {t('structuredViewBlock.retry')}
)
@@ -1100,16 +1100,16 @@ export function StructuredViewBlockEmbed({
>
-
{currentNotebook?.name || t('structuredViewBlock.insertLabel') || 'Vue structurée'}
+
{currentNotebook?.name || t('structuredViewBlock.insertLabel')}
updateAttributes({ notebookId: null })}
className="text-[10px] text-muted-foreground hover:text-foreground hover:underline ml-1.5 transition-colors"
>
- ({t('structuredViewBlock.change') || 'Changer'})
+ ({t('structuredViewBlock.change')})
- {t('structuredViewBlock.noSchema') || 'Ce carnet n\'a pas encore de vue structurée. Configurez-en une depuis l\'en-tête du carnet.'}
+ {t('structuredViewBlock.noSchema')}
router.push(`/home?notebook=${effectiveNotebookId}`)}
className="text-xs flex items-center gap-1.5"
>
- {t('structuredViewBlock.openInNotebook') || 'Ouvrir dans le carnet'}
+ {t('structuredViewBlock.openInNotebook')}
@@ -1149,9 +1149,9 @@ export function StructuredViewBlockEmbed({
updateAttributes({ notebookId: null })}
className="text-[10px] text-muted-foreground hover:text-foreground hover:underline ml-1.5 transition-colors"
- title={t('structuredViewBlock.changeNotebook') || 'Changer de carnet'}
+ title={t('structuredViewBlock.changeNotebook')}
>
- ({t('structuredViewBlock.change') || 'Changer'})
+ ({t('structuredViewBlock.change')})
@@ -1166,7 +1166,7 @@ export function StructuredViewBlockEmbed({
? "bg-muted text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
- title={t('structuredViewBlock.displayModeTable') || 'Tableau'}
+ title={t('structuredViewBlock.displayModeTable')}
>
@@ -1178,7 +1178,7 @@ export function StructuredViewBlockEmbed({
? "bg-muted text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
- title={t('structuredViewBlock.displayModeGallery') || 'Galerie'}
+ title={t('structuredViewBlock.displayModeGallery')}
>
@@ -1195,7 +1195,7 @@ export function StructuredViewBlockEmbed({
)}
>
- Analyses
+ {t('structuredViewBlock.analyticsShort')}
@@ -1205,7 +1205,7 @@ export function StructuredViewBlockEmbed({
onClick={() => router.push(`/home?notebook=${effectiveNotebookId}`)}
className="flex items-center gap-1 text-[11px] font-medium text-muted-foreground hover:text-foreground transition-colors"
>
- {t('structuredViewBlock.openInNotebook') || 'Ouvrir dans le carnet'}
+ {t('structuredViewBlock.openInNotebook')}
@@ -1214,7 +1214,7 @@ export function StructuredViewBlockEmbed({
onClick={() => updateAttributes({ isLocal: true, notebookId: null })}
className="text-[11px] text-primary hover:underline ml-1"
>
- Passer en base locale
+ {t('structuredViewBlock.switchToLocalDb')}
diff --git a/memento-note/lib/billing/cancel-subscription.ts b/memento-note/lib/billing/cancel-subscription.ts
new file mode 100644
index 0000000..a0bebd4
--- /dev/null
+++ b/memento-note/lib/billing/cancel-subscription.ts
@@ -0,0 +1,66 @@
+import { prisma } from '@/lib/prisma';
+import { stripe } from '@/lib/stripe';
+
+export interface CancelSubscriptionResult {
+ success: boolean;
+ error?: string;
+}
+
+/**
+ * Cancels an active user subscription by setting its auto-renew (cancel_at_period_end) status to true
+ * in Stripe and synchronizing the status back into the local Prisma database.
+ *
+ * @param userId - Unique identifier of the user
+ * @returns An object indicating the success or failure of the operation
+ */
+export async function cancelSubscription(userId: string): Promise {
+ if (!userId) {
+ return { success: false, error: 'User ID is required' };
+ }
+
+ try {
+ const subscription = await prisma.subscription.findUnique({
+ where: { userId },
+ });
+
+ if (!subscription) {
+ return { success: false, error: 'No active subscription found' };
+ }
+
+ if (subscription.stripeSubscriptionId) {
+ // Direct call to Stripe API with period end cancellation set to true
+ const updatedSub = await stripe.subscriptions.update(subscription.stripeSubscriptionId, {
+ cancel_at_period_end: true,
+ });
+
+ // Local database synchronization
+ await prisma.subscription.update({
+ where: { userId },
+ data: {
+ cancelAtPeriodEnd: true,
+ canceledAt: updatedSub.canceled_at ? new Date(updatedSub.canceled_at * 1000) : new Date(),
+ currentPeriodEnd: updatedSub.current_period_end ? new Date(updatedSub.current_period_end * 1000) : subscription.currentPeriodEnd,
+ updatedAt: new Date(),
+ },
+ });
+ } else {
+ // Mock mode cancel (e.g. for development / local bypass testing)
+ await prisma.subscription.update({
+ where: { userId },
+ data: {
+ cancelAtPeriodEnd: true,
+ canceledAt: new Date(),
+ updatedAt: new Date(),
+ },
+ });
+ }
+
+ return { success: true };
+ } catch (error: any) {
+ console.error('[cancelSubscription] Error processing cancellation:', error);
+ return {
+ success: false,
+ error: error.message || 'Failed to cancel subscription',
+ };
+ }
+}
diff --git a/memento-note/lib/billing/stripe-prices.ts b/memento-note/lib/billing/stripe-prices.ts
index 64ba9c2..d2ce703 100644
--- a/memento-note/lib/billing/stripe-prices.ts
+++ b/memento-note/lib/billing/stripe-prices.ts
@@ -16,12 +16,22 @@ export function resolvePriceId(tier: BillingTier, interval: BillingInterval): st
};
const priceId = map[tier][interval];
if (!priceId) {
+ const isMock = !process.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder';
+ if (isMock && process.env.NODE_ENV !== 'test') {
+ return `price_mock_${tier.toLowerCase()}_${interval}`;
+ }
throw new Error(`No Stripe price ID configured for ${tier}/${interval}`);
}
return priceId;
}
export function priceIdToTier(priceId: string): SubscriptionTier | null {
+ if (priceId && priceId.startsWith('price_mock_')) {
+ if (priceId.includes('pro')) return 'PRO';
+ if (priceId.includes('business')) return 'BUSINESS';
+ return 'BASIC';
+ }
+
const entries: Array<[string | undefined, SubscriptionTier]> = [
[process.env.STRIPE_PRICE_PRO_MONTHLY, 'PRO'],
[process.env.STRIPE_PRICE_PRO_ANNUAL, 'PRO'],
diff --git a/memento-note/lib/billing/sync-subscription-from-stripe.ts b/memento-note/lib/billing/sync-subscription-from-stripe.ts
index 90ab5db..ff63445 100644
--- a/memento-note/lib/billing/sync-subscription-from-stripe.ts
+++ b/memento-note/lib/billing/sync-subscription-from-stripe.ts
@@ -44,8 +44,19 @@ export async function syncSubscriptionFromStripe(
const status = mapStripeStatus(subscription.status);
- const currentPeriodStart = new Date(((subscription as any).current_period_start as number) * 1000);
- const currentPeriodEnd = new Date(((subscription as any).current_period_end as number) * 1000);
+ const currentPeriodStartTimestamp =
+ subscription.current_period_start ??
+ (subscription as any).items?.data?.[0]?.current_period_start ??
+ (subscription as any).start_date ??
+ Math.floor(Date.now() / 1000);
+
+ const currentPeriodEndTimestamp =
+ subscription.current_period_end ??
+ (subscription as any).items?.data?.[0]?.current_period_end ??
+ (currentPeriodStartTimestamp + 30 * 24 * 3600);
+
+ const currentPeriodStart = new Date(currentPeriodStartTimestamp * 1000);
+ const currentPeriodEnd = new Date(currentPeriodEndTimestamp * 1000);
await prisma.subscription.upsert({
where: { stripeSubscriptionId: subscription.id },
diff --git a/memento-note/locales/en.json b/memento-note/locales/en.json
index 282f718..aceb1b3 100644
--- a/memento-note/locales/en.json
+++ b/memento-note/locales/en.json
@@ -3355,7 +3355,7 @@
"turnInto_taskList": "Task List",
"turnInto_blockquote": "Quote",
"turnInto_codeBlock": "Code Block",
- "turnInto_database": "Database",
+ "turnInto_database": "Inline database",
"copyRef": "Copy block reference",
"copied": "Reference copied!",
"copyRefFailed": "Could not copy block reference",
@@ -3414,6 +3414,52 @@
"analyticsNoData": "No analysis data available.",
"analyticsCompletion": "Completion Rate",
"analyticsDistribution": "Distribution",
- "analyticsTotalRows": "Total Rows"
+ "analyticsTotalRows": "Total Rows",
+ "analyticsShort": "Analytics",
+ "turnIntoLabel": "Inline database",
+ "columnAdded": "Column added!",
+ "columnRemoved": "Column removed",
+ "propertyName": "Property {{index}}",
+ "convertNotebookNameRequired": "Please enter a name for the notebook.",
+ "convertNotebookError": "Could not create the notebook.",
+ "convertSchemaError": "Could not enable structured view.",
+ "convertPropertyError": "Could not add property.",
+ "convertNoteError": "Could not create note.",
+ "convertSuccess": "Conversion complete! Linked notebook created.",
+ "convertGenericError": "Something went wrong.",
+ "echoNameRequired": "Enter a name for this row first to search for semantic connections.",
+ "echoSearchError": "An error occurred while searching.",
+ "echoNoMatch": "No matching notes containing \"{{query}}\" were found in your workspace.",
+ "convertToNotebook": "Convert to notebook",
+ "linkToNotebook": "Link to a notebook",
+ "convertModalTitle": "Convert to structured notebook",
+ "convertModalDesc": "This local table will become a real notebook. Each row becomes a note and your columns become reusable properties.",
+ "convertNotebookNameLabel": "New notebook name",
+ "convertNotebookNamePlaceholder": "e.g. My reading list, Project tracker",
+ "cancel": "Cancel",
+ "converting": "Converting…",
+ "createNotebook": "Create notebook",
+ "deleteColumn": "Delete column",
+ "addColumn": "Add column",
+ "deleteRow": "Delete row",
+ "semanticEcho": "Semantic resonances",
+ "close": "Close",
+ "insertCitation": "Insert link in editor",
+ "keywordMatch": "Keyword",
+ "emptyTable": "No rows in the table.",
+ "addRow": "Add row",
+ "colTypeText": "Text",
+ "colTypeCheckbox": "Checkbox",
+ "colTypeSelect": "Select",
+ "selectOptionsPlaceholder": "Options separated by commas",
+ "namePlaceholder": "Enter a name…",
+ "or": "or",
+ "createLocalDb": "Create a standalone local database",
+ "switchToLocalDb": "Switch to local database",
+ "untitled": "Untitled",
+ "citationInserted": "Link inserted in the editor!",
+ "notesLoadError": "Error loading notes",
+ "defaultOption1": "Option 1",
+ "defaultOption2": "Option 2"
}
}
\ No newline at end of file
diff --git a/memento-note/locales/fr.json b/memento-note/locales/fr.json
index b6b4cd0..86efca5 100644
--- a/memento-note/locales/fr.json
+++ b/memento-note/locales/fr.json
@@ -3359,7 +3359,7 @@
"turnInto_taskList": "Liste de tâches",
"turnInto_blockquote": "Citation",
"turnInto_codeBlock": "Bloc de code",
- "turnInto_database": "Base de données",
+ "turnInto_database": "Base de données inline",
"copyRef": "Copier la référence du bloc",
"copied": "Référence copiée !",
"copyRefFailed": "Impossible de copier la référence du bloc",
@@ -3418,6 +3418,52 @@
"analyticsNoData": "Aucune donnée d'analyse disponible.",
"analyticsCompletion": "Taux de complétion",
"analyticsDistribution": "Répartition",
- "analyticsTotalRows": "Total des lignes"
+ "analyticsTotalRows": "Total des lignes",
+ "analyticsShort": "Analyses",
+ "turnIntoLabel": "Base de données inline",
+ "columnAdded": "Colonne ajoutée !",
+ "columnRemoved": "Colonne supprimée",
+ "propertyName": "Propriété {{index}}",
+ "convertNotebookNameRequired": "Veuillez entrer un nom pour le carnet.",
+ "convertNotebookError": "Erreur lors de la création du carnet.",
+ "convertSchemaError": "Erreur lors de l'activation de la structure.",
+ "convertPropertyError": "Erreur d'ajout de propriété.",
+ "convertNoteError": "Erreur de création de note.",
+ "convertSuccess": "Conversion réussie ! Base liée créée.",
+ "convertGenericError": "Une erreur est survenue.",
+ "echoNameRequired": "Veuillez d'abord saisir un nom pour cette ligne afin de rechercher des résonances sémantiques.",
+ "echoSearchError": "Une erreur est survenue lors de la recherche.",
+ "echoNoMatch": "Aucune note correspondante contenant « {{query}} » n'a été trouvée dans votre espace de travail.",
+ "convertToNotebook": "Convertir en carnet",
+ "linkToNotebook": "Lier à un carnet",
+ "convertModalTitle": "Convertir en carnet structuré",
+ "convertModalDesc": "Ce tableau local va être converti en carnet réel. Chaque ligne deviendra une note et vos colonnes seront configurées comme propriétés réutilisables.",
+ "convertNotebookNameLabel": "Nom du nouveau carnet",
+ "convertNotebookNamePlaceholder": "ex. Mes lectures, Suivi de projets",
+ "cancel": "Annuler",
+ "converting": "Conversion…",
+ "createNotebook": "Créer le carnet",
+ "deleteColumn": "Supprimer la colonne",
+ "addColumn": "Ajouter une colonne",
+ "deleteRow": "Supprimer la ligne",
+ "semanticEcho": "Résonances sémantiques",
+ "close": "Fermer",
+ "insertCitation": "Insérer le lien dans l'éditeur",
+ "keywordMatch": "Mot-clé",
+ "emptyTable": "Aucune ligne dans le tableau.",
+ "addRow": "Ajouter une ligne",
+ "colTypeText": "Texte",
+ "colTypeCheckbox": "Case",
+ "colTypeSelect": "Liste",
+ "selectOptionsPlaceholder": "Options séparées par des virgules",
+ "namePlaceholder": "Saisir un nom…",
+ "or": "ou",
+ "createLocalDb": "Créer une base locale autonome",
+ "switchToLocalDb": "Passer en base locale",
+ "untitled": "Sans titre",
+ "citationInserted": "Citation insérée dans l'éditeur !",
+ "notesLoadError": "Erreur de chargement des notes",
+ "defaultOption1": "Option 1",
+ "defaultOption2": "Option 2"
}
}
\ No newline at end of file
diff --git a/memento-note/tests/unit/billing-cancel.test.ts b/memento-note/tests/unit/billing-cancel.test.ts
new file mode 100644
index 0000000..b18b919
--- /dev/null
+++ b/memento-note/tests/unit/billing-cancel.test.ts
@@ -0,0 +1,136 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// Mock the dependencies before importing the function
+vi.mock('@/lib/prisma', () => ({
+ prisma: {
+ subscription: {
+ findUnique: vi.fn(),
+ update: vi.fn(),
+ },
+ },
+}));
+
+vi.mock('@/lib/stripe', () => ({
+ stripe: {
+ subscriptions: {
+ update: vi.fn(),
+ },
+ },
+}));
+
+import { cancelSubscription } from '@/lib/billing/cancel-subscription';
+import { prisma } from '@/lib/prisma';
+import { stripe } from '@/lib/stripe';
+
+describe('cancelSubscription', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should return error when userId is missing', async () => {
+ const result = await cancelSubscription('');
+ expect(result).toEqual({ success: false, error: 'User ID is required' });
+ expect(prisma.subscription.findUnique).not.toHaveBeenCalled();
+ });
+
+ it('should return error when no subscription is found in db', async () => {
+ vi.mocked(prisma.subscription.findUnique).mockResolvedValue(null);
+
+ const result = await cancelSubscription('user_123');
+ expect(result).toEqual({ success: false, error: 'No active subscription found' });
+ expect(prisma.subscription.findUnique).toHaveBeenCalledWith({
+ where: { userId: 'user_123' },
+ });
+ expect(stripe.subscriptions.update).not.toHaveBeenCalled();
+ expect(prisma.subscription.update).not.toHaveBeenCalled();
+ });
+
+ it('should cancel subscription in Stripe and update DB successfully', async () => {
+ const mockDbSub = {
+ userId: 'user_123',
+ stripeSubscriptionId: 'sub_stripe_123',
+ currentPeriodEnd: new Date(1702678400 * 1000),
+ };
+ const mockStripeSub = {
+ id: 'sub_stripe_123',
+ canceled_at: 1700100000,
+ current_period_end: 1702678400,
+ };
+
+ vi.mocked(prisma.subscription.findUnique).mockResolvedValue(mockDbSub as any);
+ vi.mocked(stripe.subscriptions.update).mockResolvedValue(mockStripeSub as any);
+ vi.mocked(prisma.subscription.update).mockResolvedValue({} as any);
+
+ const result = await cancelSubscription('user_123');
+
+ expect(result).toEqual({ success: true });
+ expect(stripe.subscriptions.update).toHaveBeenCalledWith('sub_stripe_123', {
+ cancel_at_period_end: true,
+ });
+ expect(prisma.subscription.update).toHaveBeenCalledWith({
+ where: { userId: 'user_123' },
+ data: expect.objectContaining({
+ cancelAtPeriodEnd: true,
+ canceledAt: expect.any(Date),
+ currentPeriodEnd: expect.any(Date),
+ }),
+ });
+ });
+
+ it('should support local / mock mode cancel when stripeSubscriptionId is missing', async () => {
+ const mockDbSub = {
+ userId: 'user_123',
+ stripeSubscriptionId: null,
+ };
+
+ vi.mocked(prisma.subscription.findUnique).mockResolvedValue(mockDbSub as any);
+ vi.mocked(prisma.subscription.update).mockResolvedValue({} as any);
+
+ const result = await cancelSubscription('user_123');
+
+ expect(result).toEqual({ success: true });
+ expect(stripe.subscriptions.update).not.toHaveBeenCalled();
+ expect(prisma.subscription.update).toHaveBeenCalledWith({
+ where: { userId: 'user_123' },
+ data: expect.objectContaining({
+ cancelAtPeriodEnd: true,
+ canceledAt: expect.any(Date),
+ }),
+ });
+ });
+
+ it('should return error when Stripe API call fails', async () => {
+ const mockDbSub = {
+ userId: 'user_123',
+ stripeSubscriptionId: 'sub_stripe_123',
+ };
+
+ vi.mocked(prisma.subscription.findUnique).mockResolvedValue(mockDbSub as any);
+ vi.mocked(stripe.subscriptions.update).mockRejectedValue(new Error('Stripe connection error'));
+
+ const result = await cancelSubscription('user_123');
+
+ expect(result).toEqual({ success: false, error: 'Stripe connection error' });
+ expect(prisma.subscription.update).not.toHaveBeenCalled();
+ });
+
+ it('should return error when database write fails', async () => {
+ const mockDbSub = {
+ userId: 'user_123',
+ stripeSubscriptionId: 'sub_stripe_123',
+ };
+ const mockStripeSub = {
+ id: 'sub_stripe_123',
+ canceled_at: 1700100000,
+ current_period_end: 1702678400,
+ };
+
+ vi.mocked(prisma.subscription.findUnique).mockResolvedValue(mockDbSub as any);
+ vi.mocked(stripe.subscriptions.update).mockResolvedValue(mockStripeSub as any);
+ vi.mocked(prisma.subscription.update).mockRejectedValue(new Error('Prisma database error'));
+
+ const result = await cancelSubscription('user_123');
+
+ expect(result).toEqual({ success: false, error: 'Prisma database error' });
+ });
+});