feat: dashboard Second Brain, essai 7 jours et vérification e-mail
Rendre le dashboard actionnable (inbox, peek, carte mentale), aligner la facturation sur l’essai 7 jours, et bloquer le login e-mail tant que l’adresse n’est pas confirmée. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
45
.impeccable/config.json
Normal file
45
.impeccable/config.json
Normal file
@@ -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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
- **Interdiction d'écrire des tests** sauf demande explicite ; en CI, seul `npm run test:unit` (`tests/unit/**`) — pas `tests/migration/` ; ne jamais générer de code superflu.
|
||||||
- Déploiement : privilégier le chemin rapide (artifact Next.js en CI + `Dockerfile.prebuilt`) ; CI/CD très robuste (pas d'image Docker obsolète en prod, pas de migrations/schéma DB via le workflow deploy) ; éviter les rebuild Docker complets inutiles (~15 min par itération) ; **ne pas pousser un déploiement quand des features clés sont inachevées** ; ne pas insister sur le déploiement tant que le produit n'est pas fini ou meilleur ; **ne jamais hardcoder d'IP/URL** dans le repo ou les défauts CI — uniquement variables d'env (ex. `GRAFANA_BIND`, `GRAFANA_URL`) ; la config runtime reste sur le serveur dans `.env.docker`. **Machines** : agent/dev = **192.168.1.83** (devSanbox) ; prod = **192.168.1.190** (`/opt/memento`) — **ne jamais confondre** les deux au diagnostic services. **CI/CD Gitea spécifique** : (1) CI `ubuntu-24.04` = lint + tests + build (validation) ; deploy `docker-host` = build sur le serveur → `/tmp/web-artifact.tgz` → `deploy-prod.sh` — **sans** upload/download-artifact inter-runners (stockage Gitea instable ; si artifacts utilisés, **@v3** uniquement — @v4 → `GHESNotSupportedError`) ; (2) `Dockerfile.socket.prebuilt` doit utiliser `--legacy-peer-deps` dans `npm install` (conflit TipTap 3.22.5 vs 3.23.6) ; (3) le serveur de prod (192.168.1.190) **ne peut pas pull Docker Hub** (DNS cassé) — le build Docker complet échoue, seul le chemin prebuilt artifact fonctionne ; (4) `docker-entrypoint.sh` applique les migrations Prisma **avant** de démarrer le serveur Next.js (ordre correct) ; (5) rollback d'urgence : `docker tag memento-memento-note:rollback memento-memento-note:latest && docker compose up -d --force-recreate memento-note` ; (6) `scripts/deploy-prod.sh` charge `/opt/memento/.env.docker` via un **parseur robuste ligne-par-ligne** (jamais `source` — crash `unexpected EOF` sur guillemet non fermé) et teste Postgres avec `pg_isready` + credentials **du conteneur** (`$POSTGRES_USER` interne) ; (7) lint CI = `eslint --max-warnings 9999` : les **warnings ne bloquent pas**, seules les **erreurs** bloquent (ex. `no-html-link-for-pages` → `<Link>` obligatoire pour la navigation interne, pas `<a href="/">`) ; **TRAVAILLER SUR UNE BRANCHE** pendant le dev, ne push sur `main` que quand le code est testé et fonctionnel — **avant push `main`** : exécuter localement `npm run lint`, `npm run test:unit`, `npm run build` (même enchaînement que CI) ; chaque push sur `main` déclenche un déploiement automatique en production.
|
- Déploiement : privilégier le chemin rapide (artifact Next.js en CI + `Dockerfile.prebuilt`) ; CI/CD très robuste (pas d'image Docker obsolète en prod, pas de migrations/schéma DB via le workflow deploy) ; éviter les rebuild Docker complets inutiles (~15 min par itération) ; **ne pas pousser un déploiement quand des features clés sont inachevées** ; ne pas insister sur le déploiement tant que le produit n'est pas fini ou meilleur ; **ne jamais hardcoder d'IP/URL** dans le repo ou les défauts CI — uniquement variables d'env (ex. `GRAFANA_BIND`, `GRAFANA_URL`) ; la config runtime reste sur le serveur dans `.env.docker`. **Machines** : agent/dev = **192.168.1.83** (devSanbox) ; prod = **192.168.1.190** (`/opt/memento`) — **ne jamais confondre** les deux au diagnostic services. **CI/CD Gitea spécifique** : (1) CI `ubuntu-24.04` = lint + tests + build (validation) ; deploy `docker-host` = build sur le serveur → `/tmp/web-artifact.tgz` → `deploy-prod.sh` — **sans** upload/download-artifact inter-runners (stockage Gitea instable ; si artifacts utilisés, **@v3** uniquement — @v4 → `GHESNotSupportedError`) ; (2) `Dockerfile.socket.prebuilt` doit utiliser `--legacy-peer-deps` dans `npm install` (conflit TipTap 3.22.5 vs 3.23.6) ; (3) le serveur de prod (192.168.1.190) **ne peut pas pull Docker Hub** (DNS cassé) — le build Docker complet échoue, seul le chemin prebuilt artifact fonctionne ; (4) `docker-entrypoint.sh` applique les migrations Prisma **avant** de démarrer le serveur Next.js (ordre correct) ; (5) rollback d'urgence : `docker tag memento-memento-note:rollback memento-memento-note:latest && docker compose up -d --force-recreate memento-note` ; (6) `scripts/deploy-prod.sh` charge `/opt/memento/.env.docker` via un **parseur robuste ligne-par-ligne** (jamais `source` — crash `unexpected EOF` sur guillemet non fermé) et teste Postgres avec `pg_isready` + credentials **du conteneur** (`$POSTGRES_USER` interne) ; (7) lint CI = `eslint --max-warnings 9999` : les **warnings ne bloquent pas**, seules les **erreurs** bloquent (ex. `no-html-link-for-pages` → `<Link>` obligatoire pour la navigation interne, pas `<a href="/">`) ; **TRAVAILLER SUR UNE BRANCHE** pendant le dev, ne push sur `main` que quand le code est testé et fonctionnel — **avant push `main`** : exécuter localement `npm run lint`, `npm run test:unit`, `npm run build` (même enchaînement que CI) ; chaque push sur `main` déclenche un déploiement automatique en production.
|
||||||
- Authentification : priorité à l'inscription/connexion via **Google OAuth** (plutôt qu'un compte email/mot de passe) ; 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.
|
- 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.
|
- 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).
|
- **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).
|
- **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`.
|
- 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.
|
- 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).
|
- 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).
|
- 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).
|
||||||
|
|||||||
88
PRODUCT.md
Normal file
88
PRODUCT.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Product
|
||||||
|
|
||||||
|
<!-- impeccable:product-schema 1 -->
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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.
|
- **`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.
|
- **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.
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ Dans **Stripe Dashboard** → **Développeurs** → **Webhooks** :
|
|||||||
- `customer.subscription.created`
|
- `customer.subscription.created`
|
||||||
- `customer.subscription.updated`
|
- `customer.subscription.updated`
|
||||||
- `customer.subscription.deleted`
|
- `customer.subscription.deleted`
|
||||||
|
- `customer.subscription.trial_will_end` (rappel email ~3 jours avant fin d’essai)
|
||||||
- `invoice.payment_failed`
|
- `invoice.payment_failed`
|
||||||
4. Copier la **signature secrète** → `STRIPE_WEBHOOK_SECRET` (commence par `whsec_...`)
|
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 |
|
| É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.created` | Upsert (nouvelle souscription) | Selon Stripe |
|
||||||
| `customer.subscription.updated` | Upsert (changement de plan, etc.) | Selon Stripe |
|
| `customer.subscription.updated` | Upsert (changement de plan, etc.) | Selon Stripe |
|
||||||
| `customer.subscription.deleted` | Marquer annulé | `CANCELED` |
|
| `customer.subscription.deleted` | Marquer annulé | `CANCELED` |
|
||||||
| `invoice.payment_failed` | Sync subscription (passage en `PAST_DUE`) | `PAST_DUE` |
|
| `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
|
### 3.3 Mapping statuts Stripe → Prisma
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Model Context Protocol (MCP) server for integrating Memento note-taking app with
|
|||||||
|
|
||||||
## Features
|
## 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
|
- 🔒 **API Key Authentication** with secure storage
|
||||||
- 🚀 **Performance Optimized** with connection pooling and caching
|
- 🚀 **Performance Optimized** with connection pooling and caching
|
||||||
- 📊 **Observability** with Prometheus metrics export
|
- 📊 **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
|
curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health
|
||||||
```
|
```
|
||||||
|
|
||||||
## Available Tools (22)
|
## Available Tools (29)
|
||||||
|
|
||||||
### Notes (13)
|
### 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_move_notes` | Move multiple notes at once |
|
||||||
| `batch_delete_notes` | Delete multiple notes at once |
|
| `batch_delete_notes` | Delete multiple notes at once |
|
||||||
|
|
||||||
### Notebooks (6)
|
### Notebooks (7)
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
@@ -102,11 +102,28 @@ curl -H "x-api-key: mcp_sk_xxx" http://localhost:3001/health
|
|||||||
| `update_label` | Update a label |
|
| `update_label` | Update a label |
|
||||||
| `delete_label` | Delete a label |
|
| `delete_label` | Delete a label |
|
||||||
|
|
||||||
### Reminders (1)
|
### Reminders (3)
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `get_due_reminders` | Get due reminders |
|
| `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)
|
### Utilities (2)
|
||||||
|
|
||||||
|
|||||||
@@ -197,8 +197,9 @@ export function validateConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Port validation
|
// Port validation (check raw value before clamping)
|
||||||
const portValidation = validatePort(config.port);
|
const rawPort = Number.parseInt(env('PORT', '3001'), 10);
|
||||||
|
const portValidation = validatePort(Number.isNaN(rawPort) ? config.port : rawPort);
|
||||||
if (!portValidation.valid) {
|
if (!portValidation.valid) {
|
||||||
errors.push({ key: 'PORT', message: portValidation.error, critical: true });
|
errors.push({ key: 'PORT', message: portValidation.error, critical: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -394,7 +394,7 @@ app.get('/', (req, res) => {
|
|||||||
sessions: '/sessions',
|
sessions: '/sessions',
|
||||||
},
|
},
|
||||||
auth: { enabled: config.requireAuth },
|
auth: { enabled: config.requireAuth },
|
||||||
tools: 22,
|
tools: 29,
|
||||||
uptime: process.uptime(),
|
uptime: process.uptime(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -445,25 +445,23 @@ app.all(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate tool input if present
|
// Validate tool input if present
|
||||||
if (req.body?.method) {
|
if (req.body?.method === 'tools/call' && req.body?.params?.name && req.body?.params?.arguments) {
|
||||||
const toolName = req.body.method;
|
const toolName = req.body.params.name;
|
||||||
if (req.body?.params) {
|
const validation = validateAndSanitize(toolName, req.body.params.arguments);
|
||||||
const validation = validateAndSanitize(toolName, req.body.params);
|
if (!validation.success) {
|
||||||
if (!validation.success) {
|
log('warn', `Validation failed for ${toolName}:`, validation.errors);
|
||||||
log('warn', `Validation failed for ${toolName}:`, validation.errors);
|
return res
|
||||||
return res
|
.status(400)
|
||||||
.status(400)
|
.json(
|
||||||
.json(
|
mcpError(McpErrors.INVALID_PARAMS.code, {
|
||||||
mcpError(McpErrors.INVALID_PARAMS.code, {
|
detail: 'Input validation failed',
|
||||||
detail: 'Input validation failed',
|
field: validation.errors[0]?.field,
|
||||||
field: validation.errors[0]?.field,
|
context: { toolName, errors: validation.errors },
|
||||||
context: { toolName, errors: validation.errors },
|
})
|
||||||
})
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
// Update request with sanitized data
|
|
||||||
req.body.params = validation.data;
|
|
||||||
}
|
}
|
||||||
|
// Update request with sanitized data
|
||||||
|
req.body.params.arguments = validation.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctx = { userId: req.userSession?.userId || null };
|
const ctx = { userId: req.userSession?.userId || null };
|
||||||
@@ -539,7 +537,7 @@ async function main() {
|
|||||||
Auth: ${config.requireAuth ? 'ENABLED' : 'DISABLED (dev)'}
|
Auth: ${config.requireAuth ? 'ENABLED' : 'DISABLED (dev)'}
|
||||||
Timeout: ${config.requestTimeout}ms
|
Timeout: ${config.requestTimeout}ms
|
||||||
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
|
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
|
||||||
Tools: 22
|
Tools: 29
|
||||||
Features: ${config.enableMetrics ? 'Metrics' : ''}${config.enableAuditLog ? ', Audit Log' : ''}
|
Features: ${config.enableMetrics ? 'Metrics' : ''}${config.enableAuditLog ? ', Audit Log' : ''}
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ async function main() {
|
|||||||
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
|
Database: ${isPostgres ? 'PostgreSQL' : 'SQLite'}
|
||||||
User: ${config.userId || 'all'}
|
User: ${config.userId || 'all'}
|
||||||
Log Level: ${config.logLevel}
|
Log Level: ${config.logLevel}
|
||||||
Tools: 22
|
Tools: 29
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "memento-mcp-server",
|
"name": "memento-mcp-server",
|
||||||
"version": "3.2.0",
|
"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",
|
"type": "module",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* Run with: npm test
|
* 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 { fileURLToPath } from 'url';
|
||||||
import { dirname, join } from 'path';
|
import { dirname, join } from 'path';
|
||||||
|
|
||||||
@@ -124,9 +124,8 @@ describe('MCP Server - Input Validation', () => {
|
|||||||
|
|
||||||
it('should allow safe HTML', () => {
|
it('should allow safe HTML', () => {
|
||||||
const xss = checkXSS({ content: 'Hello <em>world</em>' });
|
const xss = checkXSS({ content: 'Hello <em>world</em>' });
|
||||||
// This will be true because we check for any HTML tags
|
// Safe formatting tags are not flagged as XSS
|
||||||
// In production, you might want more sophisticated checking
|
expect(xss).toBe(false);
|
||||||
expect(xss).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should sanitize input', () => {
|
it('should sanitize input', () => {
|
||||||
@@ -206,11 +205,18 @@ describe('MCP Server - Tool Definitions', () => {
|
|||||||
'update_label',
|
'update_label',
|
||||||
'delete_label',
|
'delete_label',
|
||||||
'get_due_reminders',
|
'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',
|
'export_notes',
|
||||||
'import_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');
|
const { toolSchemas } = await import('../validation.js');
|
||||||
|
|
||||||
for (const toolName of toolNames) {
|
for (const toolName of toolNames) {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const DEFAULT_NOTES_LIMIT = 100;
|
|||||||
const MAX_NOTES_LIMIT = 500;
|
const MAX_NOTES_LIMIT = 500;
|
||||||
|
|
||||||
const NOTE_COLORS = 'default, red, orange, yellow, green, teal, blue, purple, pink, gray';
|
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) {
|
export function parseNote(dbNote) {
|
||||||
if (!dbNote) return null;
|
if (!dbNote) return null;
|
||||||
@@ -463,6 +463,100 @@ const toolDefinitions = [
|
|||||||
description: 'Get notes with due reminders. Designed for cron/automation.',
|
description: 'Get notes with due reminders. Designed for cron/automation.',
|
||||||
inputSchema: { type: 'object', properties: {} },
|
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 ──────────────────────────────────────────────────────────
|
// ─── Tool Handlers ──────────────────────────────────────────────────────────
|
||||||
@@ -502,7 +596,7 @@ export function registerTools(server, prisma) {
|
|||||||
title: args.title || null,
|
title: args.title || null,
|
||||||
content: args.content,
|
content: args.content,
|
||||||
color: args.color || 'default',
|
color: args.color || 'default',
|
||||||
type: args.type || 'text',
|
type: args.type || 'richtext',
|
||||||
checkItems: args.checkItems ?? null,
|
checkItems: args.checkItems ?? null,
|
||||||
labels: args.labels ?? null,
|
labels: args.labels ?? null,
|
||||||
isPinned: args.isPinned || false,
|
isPinned: args.isPinned || false,
|
||||||
@@ -1003,6 +1097,201 @@ export function registerTools(server, prisma) {
|
|||||||
return textResult({ count: reminders.length, reminders });
|
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 ═══
|
// ═══ TRASH ═══
|
||||||
case 'trash_note': {
|
case 'trash_note': {
|
||||||
const note = await prisma.note.update({
|
const note = await prisma.note.update({
|
||||||
|
|||||||
@@ -179,7 +179,6 @@ export const getNotesSchema = z.object({
|
|||||||
notebookId: idSchema.optional().nullable(),
|
notebookId: idSchema.optional().nullable(),
|
||||||
fullDetails: boolSchema(false),
|
fullDetails: boolSchema(false),
|
||||||
limit: z.number().int().min(1).max(500).default(100),
|
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({
|
export const searchNotesSchema = z.object({
|
||||||
query: z.string().min(1).max(500),
|
query: z.string().min(1).max(500),
|
||||||
limit: z.number().int().min(1).max(100).default(50),
|
|
||||||
notebookId: idSchema.optional().nullable(),
|
notebookId: idSchema.optional().nullable(),
|
||||||
includeArchived: boolSchema(false),
|
includeArchived: boolSchema(false),
|
||||||
});
|
});
|
||||||
@@ -243,7 +241,6 @@ export const moveNoteSchema = z.object({
|
|||||||
*/
|
*/
|
||||||
export const togglePinSchema = z.object({
|
export const togglePinSchema = z.object({
|
||||||
id: idSchema,
|
id: idSchema,
|
||||||
pinned: z.boolean().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -251,14 +248,13 @@ export const togglePinSchema = z.object({
|
|||||||
*/
|
*/
|
||||||
export const toggleArchiveSchema = z.object({
|
export const toggleArchiveSchema = z.object({
|
||||||
id: idSchema,
|
id: idSchema,
|
||||||
archived: z.boolean().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* batch_move_notes input schema
|
* batch_move_notes input schema
|
||||||
*/
|
*/
|
||||||
export const batchMoveNotesSchema = z.object({
|
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(),
|
notebookId: idSchema.optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -266,7 +262,7 @@ export const batchMoveNotesSchema = z.object({
|
|||||||
* batch_delete_notes input schema
|
* batch_delete_notes input schema
|
||||||
*/
|
*/
|
||||||
export const batchDeleteNotesSchema = z.object({
|
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({
|
export const createNotebookSchema = z.object({
|
||||||
name: z.string().min(1).max(200),
|
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(),
|
icon: z.string().max(50).optional().nullable(),
|
||||||
parentId: idSchema.optional().nullable(),
|
parentId: idSchema.optional().nullable(),
|
||||||
|
order: z.number().int().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* get_notebooks input schema
|
* get_notebooks input schema
|
||||||
*/
|
*/
|
||||||
export const getNotebooksSchema = z.object({
|
export const getNotebooksSchema = z.object({});
|
||||||
includeHierarchy: boolSchema(false),
|
|
||||||
includeTrashed: boolSchema(false),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* get_notebook input schema
|
* get_notebook input schema
|
||||||
@@ -300,8 +294,9 @@ export const getNotebookSchema = z.object({
|
|||||||
export const updateNotebookSchema = z.object({
|
export const updateNotebookSchema = z.object({
|
||||||
id: idSchema,
|
id: idSchema,
|
||||||
name: z.string().min(1).max(200).optional(),
|
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(),
|
icon: z.string().max(50).optional().nullable(),
|
||||||
|
order: z.number().int().optional(),
|
||||||
parentId: idSchema.optional().nullable(),
|
parentId: idSchema.optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -332,14 +327,15 @@ export const getNotebookHierarchySchema = z.object({
|
|||||||
*/
|
*/
|
||||||
export const createLabelSchema = z.object({
|
export const createLabelSchema = z.object({
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
color: colorSchema.default('default'),
|
color: z.string().max(50).optional().nullable(),
|
||||||
|
notebookId: idSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* get_labels input schema
|
* get_labels input schema
|
||||||
*/
|
*/
|
||||||
export const getLabelsSchema = z.object({
|
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
|
* get_due_reminders input schema
|
||||||
*/
|
*/
|
||||||
export const getDueRemindersSchema = z.object({
|
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_notes input schema
|
* export_notes input schema
|
||||||
*/
|
*/
|
||||||
export const exportNotesSchema = z.object({
|
export const exportNotesSchema = z.object({});
|
||||||
notebookId: idSchema.optional().nullable(),
|
|
||||||
includeArchived: boolSchema(false),
|
|
||||||
format: z.enum(['json', 'markdown']).default('json'),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* import_notes input schema
|
* import_notes input schema
|
||||||
*/
|
*/
|
||||||
export const importNotesSchema = z.object({
|
export const importNotesSchema = z.object({
|
||||||
notes: z.array(
|
data: z.object({
|
||||||
z.object({
|
version: z.string().optional(),
|
||||||
title: z.string().optional(),
|
data: z.object({
|
||||||
content: z.string(),
|
notes: z.array(z.any()).optional(),
|
||||||
color: colorSchema.optional(),
|
labels: z.array(z.any()).optional(),
|
||||||
labels: labelsSchema,
|
notebooks: z.array(z.any()).optional(),
|
||||||
notebookId: idSchema.optional().nullable(),
|
}).optional(),
|
||||||
})
|
}),
|
||||||
).min(1).max(100),
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(),
|
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,
|
get_due_reminders: getDueRemindersSchema,
|
||||||
export_notes: exportNotesSchema,
|
export_notes: exportNotesSchema,
|
||||||
import_notes: importNotesSchema,
|
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) {
|
if (error instanceof z.ZodError) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
errors: error.errors.map((e) => ({
|
errors: error.issues.map((e) => ({
|
||||||
field: e.path.join('.'),
|
field: e.path.join('.'),
|
||||||
message: e.message,
|
message: e.message,
|
||||||
code: e.code,
|
code: e.code,
|
||||||
|
|||||||
7
mcp-server/vitest.config.js
Normal file
7
mcp-server/vitest.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['test/test.js', 'test/**/*.test.js'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -8,7 +8,32 @@ import { Checkbox } from '@/components/ui/checkbox'
|
|||||||
import { updateBillingConfig } from '@/app/actions/admin-billing'
|
import { updateBillingConfig } from '@/app/actions/admin-billing'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { CreditCard, Gauge, 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 = {
|
type BillingAdminData = {
|
||||||
billingConfig: Record<string, string>
|
billingConfig: Record<string, string>
|
||||||
@@ -19,7 +44,6 @@ type BillingAdminData = {
|
|||||||
topUsers: Array<{ userId: string; email: string; name: string | null; requests: number }>
|
topUsers: Array<{ userId: string; email: string; name: string | null; requests: number }>
|
||||||
}
|
}
|
||||||
tiers: string[]
|
tiers: string[]
|
||||||
/** Allocations mensuelles du solde unique (source de vérité débit) */
|
|
||||||
creditAllocations?: Array<{
|
creditAllocations?: Array<{
|
||||||
tier: string
|
tier: string
|
||||||
monthlyCredits: number | null
|
monthlyCredits: number | null
|
||||||
@@ -31,6 +55,35 @@ type BillingAdminData = {
|
|||||||
credits: number
|
credits: number
|
||||||
defaultDisplay: string
|
defaultDisplay: string
|
||||||
}>
|
}>
|
||||||
|
stripeHealth?: {
|
||||||
|
secretConfigured: boolean
|
||||||
|
secretMode: 'test' | 'live' | 'missing' | 'placeholder'
|
||||||
|
publishableConfigured: boolean
|
||||||
|
webhookSecretConfigured: boolean
|
||||||
|
billingEnabled: boolean
|
||||||
|
trialDays: number
|
||||||
|
prices: PriceHealth[]
|
||||||
|
}
|
||||||
|
subscriptionStats?: {
|
||||||
|
byTier: Record<string, number>
|
||||||
|
byStatus: Record<string, number>
|
||||||
|
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 = [
|
const SUBSCRIPTION_PRICE_KEYS = [
|
||||||
@@ -46,7 +99,6 @@ const PACK_PRICE_KEYS = [
|
|||||||
'STRIPE_PRICE_CREDITS_L',
|
'STRIPE_PRICE_CREDITS_L',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
/** Date stable SSR/client (pas de toLocaleString — mismatch locale + fuseau). */
|
|
||||||
function formatStableUtc(iso: string): string {
|
function formatStableUtc(iso: string): string {
|
||||||
const d = new Date(iso)
|
const d = new Date(iso)
|
||||||
if (Number.isNaN(d.getTime())) return 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`
|
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 <CheckCircle2 className="h-4 w-4 text-emerald-600 shrink-0" />
|
||||||
|
if (warn) return <AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
|
||||||
|
return <XCircle className="h-4 w-4 text-rose-500 shrink-0" />
|
||||||
|
}
|
||||||
|
|
||||||
export function BillingAdminClient({ initialData }: { initialData: BillingAdminData }) {
|
export function BillingAdminClient({ initialData }: { initialData: BillingAdminData }) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const [billingEnabled, setBillingEnabled] = useState(initialData.billingConfig.BILLING_ENABLED === 'true')
|
const [billingEnabled, setBillingEnabled] = useState(initialData.billingConfig.BILLING_ENABLED === 'true')
|
||||||
const [isSavingBilling, setIsSavingBilling] = useState(false)
|
const [isSavingBilling, setIsSavingBilling] = useState(false)
|
||||||
|
const [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) => {
|
const handleSaveBilling = async (formData: FormData) => {
|
||||||
setIsSavingBilling(true)
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
@@ -84,6 +156,246 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
|||||||
<p className="text-sm text-muted-foreground mt-1">{t('admin.billing.description')}</p>
|
<p className="text-sm text-muted-foreground mt-1">{t('admin.billing.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Stripe health */}
|
||||||
|
{health && (
|
||||||
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||||
|
<Activity className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold">{t('admin.billing.healthTitle')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('admin.billing.healthDescription')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
|
||||||
|
<StatusDot ok={health.secretConfigured} warn={health.secretMode === 'placeholder'} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-semibold">{t('admin.billing.healthSecret')}</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{modeLabel}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
|
||||||
|
<StatusDot ok={health.publishableConfigured} />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold">{t('admin.billing.healthPublishable')}</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{health.publishableConfigured ? t('admin.billing.configured') : t('admin.billing.missing')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
|
||||||
|
<StatusDot ok={health.webhookSecretConfigured} />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold">{t('admin.billing.healthWebhook')}</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{health.webhookSecretConfigured ? t('admin.billing.configured') : t('admin.billing.missing')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
|
||||||
|
<StatusDot ok={health.billingEnabled} warn={!health.billingEnabled} />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold">{t('admin.billing.healthBillingFlag')}</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{health.billingEnabled ? t('admin.billing.enabled') : t('admin.billing.disabled')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
|
||||||
|
<StatusDot ok />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold">{t('admin.billing.healthTrial')}</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{t('admin.billing.trialDaysValue', { days: health.trialDays })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 pb-6">
|
||||||
|
<h3 className="text-sm font-medium mb-3">{t('admin.billing.priceStatusTitle')}</h3>
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-border/60">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-muted/40 text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="text-start p-2.5 font-medium">{t('admin.billing.colKey')}</th>
|
||||||
|
<th className="text-start p-2.5 font-medium">{t('admin.billing.colPriceId')}</th>
|
||||||
|
<th className="text-start p-2.5 font-medium">{t('admin.billing.colSource')}</th>
|
||||||
|
<th className="text-start p-2.5 font-medium">{t('admin.billing.colStripe')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(health.prices ?? []).map((row) => (
|
||||||
|
<tr key={row.key} className="border-t border-border/50">
|
||||||
|
<td className="p-2.5 font-mono">{t(`admin.billing.${row.key}`)}</td>
|
||||||
|
<td className="p-2.5 font-mono truncate max-w-[180px]">
|
||||||
|
{row.configured ? row.priceId : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="p-2.5">{row.source}</td>
|
||||||
|
<td className="p-2.5">
|
||||||
|
{!row.configured ? (
|
||||||
|
<span className="text-rose-600">{t('admin.billing.missing')}</span>
|
||||||
|
) : row.error ? (
|
||||||
|
<span className="text-rose-600" title={row.error}>{t('admin.billing.priceError')}</span>
|
||||||
|
) : row.stripeAmount != null ? (
|
||||||
|
<span className={cn(row.stripeActive === false && 'text-amber-600')}>
|
||||||
|
{row.stripeAmount.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} {row.stripeCurrency}
|
||||||
|
{row.stripeActive === false ? ` (${t('admin.billing.inactive')})` : ''}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">{t('admin.billing.notChecked')}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Subscription stats */}
|
||||||
|
{stats && (
|
||||||
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold">{t('admin.billing.subsTitle')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('admin.billing.subsDescription')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
|
||||||
|
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statPaid')}</p>
|
||||||
|
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.paidActive}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
|
||||||
|
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statTrialing')}</p>
|
||||||
|
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.trialing}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
|
||||||
|
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statPastDue')}</p>
|
||||||
|
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.pastDue}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
|
||||||
|
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statCanceling')}</p>
|
||||||
|
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.cancelAtPeriodEnd}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 pb-4 grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-2">{t('admin.billing.byTier')}</h3>
|
||||||
|
<ul className="space-y-1.5 text-sm">
|
||||||
|
{Object.entries(stats.byTier).map(([tier, count]) => (
|
||||||
|
<li key={tier} className="flex justify-between border-b border-border/40 pb-1">
|
||||||
|
<span>{tier}</span>
|
||||||
|
<span className="tabular-nums text-muted-foreground">{count}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li className="flex justify-between text-xs text-muted-foreground pt-1">
|
||||||
|
<span>{t('admin.billing.usersWithoutSub')}</span>
|
||||||
|
<span className="tabular-nums">{stats.usersWithoutSub}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-2">{t('admin.billing.byStatus')}</h3>
|
||||||
|
<ul className="space-y-1.5 text-sm">
|
||||||
|
{Object.keys(stats.byStatus).length === 0 ? (
|
||||||
|
<li className="text-muted-foreground text-xs">{t('admin.billing.noSubs')}</li>
|
||||||
|
) : (
|
||||||
|
Object.entries(stats.byStatus).map(([status, count]) => (
|
||||||
|
<li key={status} className="flex justify-between border-b border-border/40 pb-1">
|
||||||
|
<span>{status}</span>
|
||||||
|
<span className="tabular-nums text-muted-foreground">{count}</span>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 pb-6">
|
||||||
|
<h3 className="text-sm font-medium mb-2">{t('admin.billing.recentSubs')}</h3>
|
||||||
|
{stats.recent.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('admin.billing.noSubs')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-border/60">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-muted/40 text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="text-start p-2.5 font-medium">{t('admin.billing.colUser')}</th>
|
||||||
|
<th className="text-start p-2.5 font-medium">{t('admin.billing.colTier')}</th>
|
||||||
|
<th className="text-start p-2.5 font-medium">{t('admin.billing.colStatus')}</th>
|
||||||
|
<th className="text-start p-2.5 font-medium">{t('admin.billing.colPeriod')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{stats.recent.map((row) => (
|
||||||
|
<tr key={`${row.email}-${row.updatedAt}`} className="border-t border-border/50">
|
||||||
|
<td className="p-2.5 truncate max-w-[200px]">{row.email}</td>
|
||||||
|
<td className="p-2.5">{row.tier}</td>
|
||||||
|
<td className="p-2.5">
|
||||||
|
{row.status}
|
||||||
|
{row.cancelAtPeriodEnd ? ` · ${t('admin.billing.canceling')}` : ''}
|
||||||
|
{!row.hasStripeSub ? ` · ${t('admin.billing.manualTier')}` : ''}
|
||||||
|
</td>
|
||||||
|
<td className="p-2.5 text-muted-foreground">
|
||||||
|
{row.trialEndsAt
|
||||||
|
? t('admin.billing.trialUntil', { date: formatStableUtc(row.trialEndsAt) })
|
||||||
|
: row.currentPeriodEnd
|
||||||
|
? formatStableUtc(row.currentPeriodEnd)
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* How to test Stripe */}
|
||||||
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowTestGuide((v) => !v)}
|
||||||
|
className="w-full flex items-center gap-3 p-6 border-b border-border text-start hover:bg-muted/20 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||||
|
<BookOpen className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="font-semibold">{t('admin.billing.testGuideTitle')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('admin.billing.testGuideDescription')}</p>
|
||||||
|
</div>
|
||||||
|
{showTestGuide ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
</button>
|
||||||
|
{showTestGuide && (
|
||||||
|
<div className="p-6 space-y-3 text-sm text-muted-foreground leading-relaxed">
|
||||||
|
<ol className="list-decimal ps-5 space-y-2">
|
||||||
|
<li>{t('admin.billing.testStep1')}</li>
|
||||||
|
<li>{t('admin.billing.testStep2')}</li>
|
||||||
|
<li>
|
||||||
|
<code className="text-[11px] bg-muted px-1.5 py-0.5 rounded">stripe listen --forward-to localhost:3000/api/billing/webhook</code>
|
||||||
|
{' — '}{t('admin.billing.testStep3')}
|
||||||
|
</li>
|
||||||
|
<li>{t('admin.billing.testStep4')}</li>
|
||||||
|
<li>{t('admin.billing.testStep5')}</li>
|
||||||
|
<li>{t('admin.billing.testStep6')}</li>
|
||||||
|
</ol>
|
||||||
|
<p className="text-xs border-t border-border/50 pt-3">
|
||||||
|
{t('admin.billing.testCardHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||||
<div className="flex items-center gap-3 p-6 border-b border-border">
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||||
@@ -106,17 +418,28 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium mb-3">{t('admin.billing.subscriptionPricesTitle')}</h3>
|
<h3 className="text-sm font-medium mb-3">{t('admin.billing.subscriptionPricesTitle')}</h3>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
{SUBSCRIPTION_PRICE_KEYS.map((key) => (
|
{SUBSCRIPTION_PRICE_KEYS.map((key) => {
|
||||||
<div key={key} className="space-y-2">
|
const meta = priceMap[key]
|
||||||
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
|
return (
|
||||||
<Input
|
<div key={key} className="space-y-2">
|
||||||
id={key}
|
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
|
||||||
name={key}
|
<Input
|
||||||
defaultValue={initialData.billingConfig[key] ?? ''}
|
id={key}
|
||||||
placeholder="price_..."
|
name={key}
|
||||||
/>
|
defaultValue={initialData.billingConfig[key] ?? ''}
|
||||||
</div>
|
placeholder="price_..."
|
||||||
))}
|
/>
|
||||||
|
{meta?.stripeAmount != null && (
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
Stripe: {meta.stripeAmount.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} {meta.stripeCurrency}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{meta?.error && (
|
||||||
|
<p className="text-[11px] text-rose-600">{meta.error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-border/50 pt-4">
|
<div className="border-t border-border/50 pt-4">
|
||||||
@@ -144,7 +467,6 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Solde unique — source de vérité du débit */}
|
|
||||||
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||||
<div className="flex items-center gap-3 p-6 border-b border-border">
|
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||||
@@ -216,7 +538,6 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
|||||||
{t('admin.billing.usagePeriod', { period: initialData.usageOverview.period })}
|
{t('admin.billing.usagePeriod', { period: initialData.usageOverview.period })}
|
||||||
{initialData.usageOverview.lastSyncedAt
|
{initialData.usageOverview.lastSyncedAt
|
||||||
? ` · ${t('admin.billing.lastSync', {
|
? ` · ${t('admin.billing.lastSync', {
|
||||||
// Format fixe UTC (évite mismatch SSR locale/fuseau vs client)
|
|
||||||
date: formatStableUtc(initialData.usageOverview.lastSyncedAt),
|
date: formatStableUtc(initialData.usageOverview.lastSyncedAt),
|
||||||
})}`
|
})}`
|
||||||
: ` · ${t('admin.billing.notSynced')}`}
|
: ` · ${t('admin.billing.notSynced')}`}
|
||||||
|
|||||||
92
memento-note/app/(auth)/check-email/page.tsx
Normal file
92
memento-note/app/(auth)/check-email/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl">
|
||||||
|
<div className="space-y-8 text-center">
|
||||||
|
<div className="w-14 h-14 mx-auto rounded-2xl bg-[var(--color-brand-accent)]/10 flex items-center justify-center">
|
||||||
|
<Mail size={28} className="text-[var(--color-brand-accent)]" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-2xl md:text-3xl font-serif font-bold">
|
||||||
|
{t('auth.checkEmailTitle')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-[var(--muted-foreground)] text-sm font-light leading-relaxed">
|
||||||
|
{initialEmail
|
||||||
|
? t('auth.checkEmailDescription', { email: initialEmail })
|
||||||
|
: t('auth.checkEmailDescriptionGeneric')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 text-start">
|
||||||
|
<label htmlFor="email" className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||||
|
{t('auth.email')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResend}
|
||||||
|
disabled={sending}
|
||||||
|
className="w-full bg-[var(--foreground)] text-[var(--background)] py-4 rounded-2xl font-bold uppercase tracking-[0.2em] text-[10px] flex items-center justify-center gap-3 transition-all hover:shadow-xl hover:shadow-black/10 active:scale-[0.98] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{sending ? <Sparkles size={16} className="animate-spin" /> : t('auth.resendVerification')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="inline-flex items-center gap-2 text-xs text-[var(--muted-foreground)] hover:text-[var(--color-brand-accent)] transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} />
|
||||||
|
{t('auth.backToLogin')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CheckEmailPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="p-10 text-center text-sm text-muted-foreground">…</div>}>
|
||||||
|
<CheckEmailContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
97
memento-note/app/(auth)/verify-email/page.tsx
Normal file
97
memento-note/app/(auth)/verify-email/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-4">
|
||||||
|
<Sparkles size={28} className="mx-auto animate-spin text-[var(--color-brand-accent)]" />
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">{t('auth.verifyLoading')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'ok') {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-6">
|
||||||
|
<div className="w-14 h-14 mx-auto rounded-2xl bg-emerald-500/10 flex items-center justify-center">
|
||||||
|
<CheckCircle2 size={28} className="text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-2xl font-serif font-bold">{t('auth.verifySuccessTitle')}</h1>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">{t('auth.verifySuccessDescription')}</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/login?verified=1" className="block">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full bg-[var(--foreground)] text-[var(--background)] py-4 rounded-2xl font-bold uppercase tracking-[0.2em] text-[10px] transition-all hover:shadow-xl active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
{t('auth.signIn')}
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-6">
|
||||||
|
<div className="w-14 h-14 mx-auto rounded-2xl bg-red-500/10 flex items-center justify-center">
|
||||||
|
<AlertCircle size={28} className="text-red-500" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-2xl font-serif font-bold">
|
||||||
|
{status === 'expired' ? t('auth.verifyExpiredTitle') : t('auth.verifyInvalidTitle')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
{status === 'expired'
|
||||||
|
? t('auth.verifyExpiredDescription')
|
||||||
|
: t('auth.verifyInvalidDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/check-email" className="block">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full bg-[var(--foreground)] text-[var(--background)] py-4 rounded-2xl font-bold uppercase tracking-[0.2em] text-[10px] transition-all hover:shadow-xl active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
{t('auth.resendVerification')}
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VerifyEmailPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="p-10 text-center text-sm text-muted-foreground">…</div>}>
|
||||||
|
<VerifyEmailContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { motion, AnimatePresence, useReducedMotion } from 'motion/react'
|
import { motion, AnimatePresence, useReducedMotion } from 'motion/react'
|
||||||
import {
|
import {
|
||||||
@@ -84,6 +84,7 @@ const COLOR_PALETTE = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#
|
|||||||
|
|
||||||
export default function InsightsPage() {
|
export default function InsightsPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
const { t, language: locale } = useLanguage()
|
const { t, language: locale } = useLanguage()
|
||||||
|
|
||||||
const formatSyncTime = useCallback(
|
const formatSyncTime = useCallback(
|
||||||
@@ -129,6 +130,15 @@ export default function InsightsPage() {
|
|||||||
loadInitialData()
|
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 ───────────────────────────────────────────────────────
|
// ─── Données calculées ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const selectedCluster = useMemo(
|
const selectedCluster = useMemo(
|
||||||
|
|||||||
@@ -4,18 +4,38 @@ import { motion } from 'motion/react'
|
|||||||
import { Shield } from 'lucide-react'
|
import { Shield } from 'lucide-react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
export default function PricingPage() {
|
export default function PricingPage() {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly')
|
const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly')
|
||||||
|
const trialDays = SUBSCRIPTION_TRIAL_DAYS
|
||||||
|
|
||||||
const PLANS = [
|
const PLANS = [
|
||||||
{ key: 'basic', popular: false, price: t('landing.pricing.basicPrice'), period: '' },
|
{ key: 'basic', popular: false, hasTrial: 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: 'pro',
|
||||||
{ key: 'enterprise', popular: false, price: billingInterval === 'monthly' ? '49,90€' : '39,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perUser') : t('landing.pricing.perUserAnnual') },
|
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 (
|
return (
|
||||||
@@ -42,7 +62,9 @@ export default function PricingPage() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<div className="absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap">
|
<div className="absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap">
|
||||||
<span className="text-[9px] font-bold text-ochre uppercase tracking-widest italic animate-pulse">(-20%)</span>
|
<span className="text-[9px] font-bold text-ochre uppercase tracking-widest italic animate-pulse">
|
||||||
|
{t('landing.pricing.savePercent')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,9 +84,22 @@ export default function PricingPage() {
|
|||||||
<span className="text-4xl font-serif font-medium">{plan.price}</span>
|
<span className="text-4xl font-serif font-medium">{plan.price}</span>
|
||||||
{plan.period && <span className="text-xs opacity-60">{plan.period}</span>}
|
{plan.period && <span className="text-xs opacity-60">{plan.period}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
{plan.hasTrial && (
|
||||||
|
<p className={`text-[11px] font-semibold mb-3 ${plan.popular ? 'text-ochre' : 'text-brand-accent'}`}>
|
||||||
|
{t('landing.pricing.trialBadge', { days: trialDays })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="text-sm font-light leading-relaxed opacity-80">{t(`landing.pricing.${plan.key}.desc`)}</p>
|
<p className="text-sm font-light leading-relaxed opacity-80">{t(`landing.pricing.${plan.key}.desc`)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 space-y-4 mb-10">
|
<div className="flex-1 space-y-4 mb-10">
|
||||||
|
{plan.hasTrial && (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={`mt-1 rounded-full p-0.5 ${plan.popular ? 'bg-ochre text-ink' : 'bg-brand-accent/10 text-brand-accent'}`}>
|
||||||
|
<Shield size={10} fill="currentColor" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium">{t('landing.pricing.trialFeature', { days: trialDays })}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{[0, 1, 2, 3, 4, 5].map(j => {
|
{[0, 1, 2, 3, 4, 5].map(j => {
|
||||||
const feat = t(`landing.pricing.${plan.key}.feature${j}`)
|
const feat = t(`landing.pricing.${plan.key}.feature${j}`)
|
||||||
if (!feat || feat === `landing.pricing.${plan.key}.feature${j}`) return null
|
if (!feat || feat === `landing.pricing.${plan.key}.feature${j}`) return null
|
||||||
@@ -82,7 +117,9 @@ export default function PricingPage() {
|
|||||||
onClick={() => router.push('/register')}
|
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'}`}
|
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`)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -2,13 +2,24 @@ import { headers } from 'next/headers'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { parseAcceptLanguage } from '@/lib/i18n/detect-user-language'
|
import { parseAcceptLanguage } from '@/lib/i18n/detect-user-language'
|
||||||
|
|
||||||
export const metadata = {
|
export async function generateMetadata({
|
||||||
title: 'Privacy Policy — Memento',
|
searchParams,
|
||||||
description: 'How Memento handles your data, the Chrome and Firefox web clipper extension, and AI features.',
|
}: {
|
||||||
alternates: { canonical: 'https://memento-note.com/privacy' },
|
searchParams?: Promise<{ lang?: string | string[] }>
|
||||||
robots: { index: true, follow: true },
|
}) {
|
||||||
|
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'
|
type Locale = 'en' | 'fr'
|
||||||
const SUPPORTED: Locale[] = ['en', 'fr']
|
const SUPPORTED: Locale[] = ['en', 'fr']
|
||||||
const RTL: Locale[] = []
|
const RTL: Locale[] = []
|
||||||
@@ -353,7 +364,9 @@ export default async function PrivacyPage({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<article className="max-w-3xl mx-auto px-5 sm:px-8 py-16 sm:py-24">
|
<article className="max-w-3xl mx-auto px-5 sm:px-8 py-16 sm:py-24">
|
||||||
<p className="text-[11px] uppercase tracking-[0.3em] text-[#D4A373] mb-4">Privacy</p>
|
<p className="text-[11px] uppercase tracking-[0.3em] text-[#D4A373] mb-4">
|
||||||
|
{locale === 'fr' ? 'Confidentialité' : 'Privacy'}
|
||||||
|
</p>
|
||||||
<h1 className="font-serif text-4xl sm:text-5xl tracking-tight mb-4">{doc.title}</h1>
|
<h1 className="font-serif text-4xl sm:text-5xl tracking-tight mb-4">{doc.title}</h1>
|
||||||
<p className="text-sm text-white/40 mb-3">{doc.lastUpdated}</p>
|
<p className="text-sm text-white/40 mb-3">{doc.lastUpdated}</p>
|
||||||
<p className="text-white/65 leading-relaxed text-lg mb-12">{doc.intro}</p>
|
<p className="text-white/65 leading-relaxed text-lg mb-12">{doc.intro}</p>
|
||||||
|
|||||||
@@ -46,6 +46,152 @@ function assertValidTier(tier: string): asserts tier is TierType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getStripeHealth(billingConfig: Record<string, string>) {
|
||||||
|
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<ReturnType<typeof import('@/lib/stripe').getStripe>> | 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<string, number> = Object.fromEntries(TIERS.map((t) => [t, 0]))
|
||||||
|
for (const row of byTierRaw) {
|
||||||
|
byTier[row.tier] = row._count._all
|
||||||
|
}
|
||||||
|
|
||||||
|
const byStatus: Record<string, number> = {}
|
||||||
|
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() {
|
export async function getBillingAdminData() {
|
||||||
await checkAdmin()
|
await checkAdmin()
|
||||||
const { getSystemConfig } = await import('@/lib/config')
|
const { getSystemConfig } = await import('@/lib/config')
|
||||||
@@ -83,6 +229,11 @@ export async function getBillingAdminData() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [stripeHealth, subscriptionStats] = await Promise.all([
|
||||||
|
getStripeHealth(billingConfig),
|
||||||
|
getSubscriptionStats(),
|
||||||
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
entitlements,
|
entitlements,
|
||||||
billingConfig,
|
billingConfig,
|
||||||
@@ -92,6 +243,8 @@ export async function getBillingAdminData() {
|
|||||||
creditAllocations,
|
creditAllocations,
|
||||||
creditCosts,
|
creditCosts,
|
||||||
creditPacks,
|
creditPacks,
|
||||||
|
stripeHealth,
|
||||||
|
subscriptionStats,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
11
memento-note/app/actions/auth-verify.ts
Normal file
11
memento-note/app/actions/auth-verify.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -2,20 +2,49 @@
|
|||||||
|
|
||||||
import { signIn } from '@/auth';
|
import { signIn } from '@/auth';
|
||||||
import { AuthError } from 'next-auth';
|
import { AuthError } from 'next-auth';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
|
||||||
export async function authenticate(
|
export async function authenticate(
|
||||||
prevState: string | undefined,
|
prevState: string | undefined,
|
||||||
formData: FormData,
|
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 {
|
try {
|
||||||
await signIn('credentials', {
|
await signIn('credentials', {
|
||||||
email: formData.get('email'),
|
email,
|
||||||
password: formData.get('password'),
|
password,
|
||||||
redirectTo: '/home',
|
redirectTo: '/home',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
console.error('AuthError details:', error.type, error.message);
|
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) {
|
switch (error.type) {
|
||||||
case 'CredentialsSignin':
|
case 'CredentialsSignin':
|
||||||
return 'Invalid credentials.';
|
return 'Invalid credentials.';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import prisma from '@/lib/prisma';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { getSystemConfig } from '@/lib/config';
|
import { getSystemConfig } from '@/lib/config';
|
||||||
|
import { sendVerificationEmail } from '@/lib/auth/email-verification';
|
||||||
|
|
||||||
const RegisterSchema = z.object({
|
const RegisterSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
@@ -17,9 +18,8 @@ const RegisterSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function register(prevState: string | undefined, formData: FormData) {
|
export async function register(prevState: string | undefined, formData: FormData) {
|
||||||
// Check if registration is allowed
|
|
||||||
const config = await getSystemConfig();
|
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) {
|
if (!allowRegister) {
|
||||||
return 'Registration is currently disabled by the administrator.';
|
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 { email, password, name } = validatedFields.data;
|
||||||
|
const normalizedEmail = email.toLowerCase();
|
||||||
|
const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase();
|
||||||
|
const isAdmin = Boolean(adminEmail && normalizedEmail === adminEmail);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existingUser = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
|
const existingUser = await prisma.user.findUnique({ where: { email: normalizedEmail } });
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
return 'User already exists.';
|
return 'User already exists.';
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(password, 10);
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
const role = isAdmin ? 'ADMIN' : 'USER';
|
||||||
const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase();
|
|
||||||
const role = adminEmail && email.toLowerCase() === adminEmail ? 'ADMIN' : 'USER';
|
|
||||||
|
|
||||||
await prisma.user.create({
|
await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email: email.toLowerCase(),
|
email: normalizedEmail,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
name,
|
name,
|
||||||
role,
|
role,
|
||||||
|
// Admin bootstrap + Google OAuth are trusted; everyone else must verify.
|
||||||
|
emailVerified: isAdmin ? new Date() : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Attempt to sign in immediately after registration
|
if (!isAdmin) {
|
||||||
// We cannot import signIn here directly if it causes circular deps or issues,
|
const mailResult = await sendVerificationEmail({
|
||||||
// but usually it works. If not, redirecting to login is fine.
|
email: normalizedEmail,
|
||||||
// Let's stick to redirecting to login but with a clear success message?
|
name,
|
||||||
// Or better: lowercase the email to fix the potential bug.
|
});
|
||||||
|
|
||||||
|
if (!mailResult.success) {
|
||||||
|
console.error('[register] verification email failed:', mailResult.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Registration Error:', error);
|
console.error('Registration Error:', error);
|
||||||
return 'Database Error: Failed to create user.';
|
return 'Database Error: Failed to create user.';
|
||||||
}
|
}
|
||||||
|
|
||||||
redirect('/login');
|
if (isAdmin) {
|
||||||
|
redirect('/login?verified=1');
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(`/check-email?email=${encodeURIComponent(normalizedEmail)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,14 +17,14 @@ export const maxDuration = 60
|
|||||||
const sectionPlanSchema = z.object({
|
const sectionPlanSchema = z.object({
|
||||||
title: z.string().min(1),
|
title: z.string().min(1),
|
||||||
goal: z.string().min(1),
|
goal: z.string().min(1),
|
||||||
demoKind: z.enum(['svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none']),
|
demoKind: z.enum(['steps', 'svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none']),
|
||||||
demoGoal: z.string().optional(),
|
demoGoal: z.string().nullish(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const requestSchema = z.object({
|
const requestSchema = z.object({
|
||||||
/** undefined = legacy deterministic full-page (fallback path) */
|
/** undefined = legacy deterministic full-page (fallback path) */
|
||||||
action: z.enum(['plan', 'section']).optional(),
|
action: z.enum(['plan', 'section']).optional(),
|
||||||
content: z.string().min(40),
|
content: z.string().min(40).max(500_000),
|
||||||
lang: z.string().optional(),
|
lang: z.string().optional(),
|
||||||
noteId: z.string().optional(),
|
noteId: z.string().optional(),
|
||||||
notebookId: z.string().optional(),
|
notebookId: z.string().optional(),
|
||||||
@@ -44,7 +44,10 @@ export async function POST(req: NextRequest) {
|
|||||||
return aiConsentForbiddenResponse()
|
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 parsed = requestSchema.parse(body)
|
||||||
const wordCount = parsed.content
|
const wordCount = parsed.content
|
||||||
.replace(/<[^>]+>/g, ' ')
|
.replace(/<[^>]+>/g, ' ')
|
||||||
@@ -61,11 +64,12 @@ export async function POST(req: NextRequest) {
|
|||||||
const provider = getSlidesProvider(config)
|
const provider = getSlidesProvider(config)
|
||||||
const lang = parsed.lang || 'fr'
|
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') {
|
if (parsed.action === 'plan') {
|
||||||
try {
|
try {
|
||||||
await reserveAiUsageOrThrow(session.user.id, 'interactive_page', {
|
await reserveAiUsageOrThrow(session.user.id, 'interactive_page', {
|
||||||
lane: 'chat',
|
lane: 'chat',
|
||||||
|
amount: 5,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof QuotaExceededError) {
|
if (err instanceof QuotaExceededError) {
|
||||||
@@ -97,7 +101,7 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ plan: result.plan, attempts: result.attempts })
|
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.action === 'section') {
|
||||||
if (!parsed.section || !parsed.sectionId || !parsed.pageTitle) {
|
if (!parsed.section || !parsed.sectionId || !parsed.pageTitle) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -105,6 +109,26 @@ export async function POST(req: NextRequest) {
|
|||||||
{ status: 400 }
|
{ 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({
|
const result = await generatePageSection({
|
||||||
content: parsed.content,
|
content: parsed.content,
|
||||||
lang,
|
lang,
|
||||||
@@ -171,7 +195,16 @@ export async function POST(req: NextRequest) {
|
|||||||
})
|
})
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof z.ZodError) {
|
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 =
|
const message =
|
||||||
error instanceof Error ? error.message : 'Erreur génération interactive page'
|
error instanceof Error ? error.message : 'Erreur génération interactive page'
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { auth } from '@/auth';
|
import { auth } from '@/auth';
|
||||||
import { stripe } from '@/lib/stripe';
|
import { stripe } from '@/lib/stripe';
|
||||||
import { isBillingEnabled, resolvePriceId } from '@/lib/billing/stripe-prices';
|
import { isBillingEnabled, resolvePriceId } from '@/lib/billing/stripe-prices';
|
||||||
|
import {
|
||||||
|
shouldOfferSubscriptionTrial,
|
||||||
|
SUBSCRIPTION_TRIAL_DAYS,
|
||||||
|
} from '@/lib/billing/trial';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -91,6 +95,17 @@ export async function POST(req: NextRequest) {
|
|||||||
const proto = req.headers.get('x-forwarded-proto') ?? 'http';
|
const proto = req.headers.get('x-forwarded-proto') ?? 'http';
|
||||||
const origin = `${proto}://${host}`;
|
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.
|
// Hosted Checkout is the most reliable path (redirect). Embedded is optional.
|
||||||
if (preferredMode === 'embedded') {
|
if (preferredMode === 'embedded') {
|
||||||
try {
|
try {
|
||||||
@@ -100,8 +115,8 @@ export async function POST(req: NextRequest) {
|
|||||||
line_items: [{ price: priceId, quantity: 1 }],
|
line_items: [{ price: priceId, quantity: 1 }],
|
||||||
ui_mode: 'embedded' as any,
|
ui_mode: 'embedded' as any,
|
||||||
return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
|
return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
metadata: { userId, tier },
|
metadata: { userId, tier, trial: offerTrial ? '1' : '0' },
|
||||||
subscription_data: { metadata: { userId, tier } },
|
subscription_data: subscriptionData,
|
||||||
customer_update: { address: 'auto' },
|
customer_update: { address: 'auto' },
|
||||||
allow_promotion_codes: true,
|
allow_promotion_codes: true,
|
||||||
} as any);
|
} as any);
|
||||||
@@ -109,6 +124,7 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
clientSecret: embedded.client_secret,
|
clientSecret: embedded.client_secret,
|
||||||
sessionId: embedded.id,
|
sessionId: embedded.id,
|
||||||
|
trialDays: offerTrial ? SUBSCRIPTION_TRIAL_DAYS : 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (embeddedErr) {
|
} catch (embeddedErr) {
|
||||||
@@ -122,8 +138,8 @@ export async function POST(req: NextRequest) {
|
|||||||
line_items: [{ price: priceId, quantity: 1 }],
|
line_items: [{ price: priceId, quantity: 1 }],
|
||||||
success_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
|
success_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
cancel_url: `${origin}/settings/billing?canceled=1`,
|
cancel_url: `${origin}/settings/billing?canceled=1`,
|
||||||
metadata: { userId, tier },
|
metadata: { userId, tier, trial: offerTrial ? '1' : '0' },
|
||||||
subscription_data: { metadata: { userId, tier } },
|
subscription_data: subscriptionData,
|
||||||
customer_update: { address: 'auto' },
|
customer_update: { address: 'auto' },
|
||||||
allow_promotion_codes: true,
|
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({ 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) {
|
} catch (error) {
|
||||||
console.error('[billing/create-checkout]', error);
|
console.error('[billing/create-checkout]', error);
|
||||||
const msg = error instanceof Error ? error.message : 'Failed to create checkout session';
|
const msg = error instanceof Error ? error.message : 'Failed to create checkout session';
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { auth } from '@/auth';
|
import { auth } from '@/auth';
|
||||||
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
|
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
|
||||||
import { stripe } from '@/lib/stripe';
|
import { stripe } from '@/lib/stripe';
|
||||||
import { 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';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -23,47 +25,16 @@ export async function GET(req: NextRequest) {
|
|||||||
if (checkoutSession.subscription && checkoutSession.status === 'complete') {
|
if (checkoutSession.subscription && checkoutSession.status === 'complete') {
|
||||||
const subId = typeof checkoutSession.subscription === 'string'
|
const subId = typeof checkoutSession.subscription === 'string'
|
||||||
? checkoutSession.subscription
|
? checkoutSession.subscription
|
||||||
: (checkoutSession.subscription as any).id;
|
: (checkoutSession.subscription as { id: string }).id;
|
||||||
|
|
||||||
const sub = await stripe.subscriptions.retrieve(subId) as any;
|
const sub = await stripe.subscriptions.retrieve(subId);
|
||||||
const priceId = sub.items.data[0].price.id;
|
const syncUserId =
|
||||||
const tier = (await priceIdToTier(priceId)) || (checkoutSession.metadata?.tier as any) || 'PRO';
|
(checkoutSession.metadata?.userId as string | undefined) ??
|
||||||
|
(sub.metadata?.userId as string | undefined) ??
|
||||||
const currentPeriodStartTimestamp =
|
userId;
|
||||||
sub.current_period_start ??
|
if (syncUserId === userId) {
|
||||||
sub.items?.data?.[0]?.current_period_start ??
|
await syncSubscriptionFromStripe(sub, userId);
|
||||||
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) {
|
} catch (err) {
|
||||||
console.error('[billing/status] Failed to sync Stripe session:', 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({
|
return NextResponse.json({
|
||||||
tier,
|
tier,
|
||||||
effectiveTier,
|
effectiveTier,
|
||||||
@@ -120,6 +93,9 @@ export async function GET(req: NextRequest) {
|
|||||||
currentPeriodStart: subscription?.currentPeriodStart ?? null,
|
currentPeriodStart: subscription?.currentPeriodStart ?? null,
|
||||||
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
|
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
|
||||||
hasStripeSubscription: !!subscription?.stripeSubscriptionId,
|
hasStripeSubscription: !!subscription?.stripeSubscriptionId,
|
||||||
|
trialEndsAt: subscription?.trialEndsAt?.toISOString() ?? null,
|
||||||
|
trialEligible,
|
||||||
|
trialDays: trialEligible ? SUBSCRIPTION_TRIAL_DAYS : 0,
|
||||||
prices,
|
prices,
|
||||||
creditPacks,
|
creditPacks,
|
||||||
billingEnabled,
|
billingEnabled,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
isCreditPackId,
|
isCreditPackId,
|
||||||
resolvePackFromPriceId,
|
resolvePackFromPriceId,
|
||||||
} from '@/lib/billing/credit-packs';
|
} from '@/lib/billing/credit-packs';
|
||||||
|
import { sendTrialEndingReminder } from '@/lib/billing/trial-reminder-email';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
import type Stripe from 'stripe';
|
import type Stripe from 'stripe';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
@@ -159,6 +161,48 @@ export async function POST(req: NextRequest) {
|
|||||||
break;
|
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:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export async function GET() {
|
|||||||
const [
|
const [
|
||||||
recentNotes,
|
recentNotes,
|
||||||
inboxCount,
|
inboxCount,
|
||||||
|
inboxPreview,
|
||||||
dueFlashcards,
|
dueFlashcards,
|
||||||
upcomingReminders,
|
upcomingReminders,
|
||||||
unviewedInsights,
|
unviewedInsights,
|
||||||
@@ -83,6 +84,13 @@ export async function GET() {
|
|||||||
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
|
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({
|
prisma.flashcard.count({
|
||||||
where: { deck: { userId }, nextReviewAt: { lte: now } },
|
where: { deck: { userId }, nextReviewAt: { lte: now } },
|
||||||
}),
|
}),
|
||||||
@@ -177,6 +185,12 @@ export async function GET() {
|
|||||||
notebook: n.notebookId ? notebookMap.get(n.notebookId) || null : null,
|
notebook: n.notebookId ? notebookMap.get(n.notebookId) || null : null,
|
||||||
})),
|
})),
|
||||||
inboxCount,
|
inboxCount,
|
||||||
|
inboxPreview: inboxPreview.map(n => ({
|
||||||
|
id: n.id,
|
||||||
|
title: n.title,
|
||||||
|
notebookId: n.notebookId,
|
||||||
|
updatedAt: n.updatedAt.toISOString(),
|
||||||
|
})),
|
||||||
dueFlashcards,
|
dueFlashcards,
|
||||||
upcomingReminders: upcomingReminders.map(r => ({
|
upcomingReminders: upcomingReminders.map(r => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
|||||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||||
import { isPublishTemplateId, isInteractivePageTemplate } from '@/lib/publish/types'
|
import { isPublishTemplateId, isInteractivePageTemplate } from '@/lib/publish/types'
|
||||||
import { computePublishedSourceHash, renderPublishedTemplate, renderRewrittenTemplate } from '@/lib/publish/template-render'
|
import { computePublishedSourceHash, renderPublishedTemplate, renderRewrittenTemplate } from '@/lib/publish/template-render'
|
||||||
import { validateInteractivePage } from '@/lib/interactive-page'
|
import { validateInteractivePage, type PageSpecV1, type PageValidationResult } from '@/lib/interactive-page'
|
||||||
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
|
|
||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
import { getSlidesProvider } from '@/lib/ai/factory'
|
import { getSlidesProvider } from '@/lib/ai/factory'
|
||||||
import { generateInteractivePageFromContent } from '@/lib/ai/services/interactive-page-generate.service'
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
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 {
|
const { noteId, action, mode, template, language, rewrite, pageSpec } = body as {
|
||||||
noteId?: string
|
noteId?: string
|
||||||
action?: string
|
action?: string
|
||||||
@@ -123,9 +172,21 @@ export async function POST(request: NextRequest) {
|
|||||||
return aiConsentForbiddenResponse()
|
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)
|
// Deterministic generate (no LLM quota)
|
||||||
const config = await getSystemConfig()
|
const config = await getSystemConfig()
|
||||||
const provider = getSlidesProvider(config)
|
const provider = getSlidesProvider(config)
|
||||||
@@ -144,22 +205,12 @@ export async function POST(request: NextRequest) {
|
|||||||
{ status: 422 }
|
{ status: 422 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
validatedPage = { ok: true, page: generated.page }
|
validatedPage = { ok: true as const, page: generated.page }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guaranteed valid PageSpec after generate-or-validate above
|
// Moderate ALL human-facing text of the page (prose, callouts, demo
|
||||||
if (!validatedPage?.ok) {
|
// narration, tables, captions) — not just titles.
|
||||||
return NextResponse.json({ error: 'invalid_page_spec' }, { status: 422 })
|
const textForModeration = collectPageText(validatedPage.page).join('\n')
|
||||||
}
|
|
||||||
|
|
||||||
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')
|
|
||||||
|
|
||||||
const moderation = await moderateWithFallback(
|
const moderation = await moderateWithFallback(
|
||||||
note.title || '',
|
note.title || '',
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export const authConfig = {
|
|||||||
nextUrl.pathname === '/login' ||
|
nextUrl.pathname === '/login' ||
|
||||||
nextUrl.pathname === '/register' ||
|
nextUrl.pathname === '/register' ||
|
||||||
nextUrl.pathname === '/forgot-password' ||
|
nextUrl.pathname === '/forgot-password' ||
|
||||||
|
nextUrl.pathname === '/check-email' ||
|
||||||
|
nextUrl.pathname === '/verify-email' ||
|
||||||
nextUrl.pathname.startsWith('/reset-password');
|
nextUrl.pathname.startsWith('/reset-password');
|
||||||
|
|
||||||
if (isAdminPage) {
|
if (isAdminPage) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { motion, AnimatePresence } from 'motion/react'
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
import { Search, Bot, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
|
import { Search, Bot, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
@@ -35,12 +35,22 @@ export function DashboardAgentCarousel({
|
|||||||
}: DashboardAgentCarouselProps) {
|
}: DashboardAgentCarouselProps) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const [idx, setIdx] = useState(0)
|
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 ? (
|
const navActions = suggestions.length > 1 ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIdx(i => Math.max(0, i - 1))}
|
onClick={goPrev}
|
||||||
disabled={idx === 0}
|
disabled={idx === 0}
|
||||||
className="p-1 rounded border border-border/30 disabled:opacity-25"
|
className="p-1 rounded border border-border/30 disabled:opacity-25"
|
||||||
aria-label={t('homeDashboard.intelPrev')}
|
aria-label={t('homeDashboard.intelPrev')}
|
||||||
@@ -52,7 +62,7 @@ export function DashboardAgentCarousel({
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIdx(i => Math.min(suggestions.length - 1, i + 1))}
|
onClick={goNext}
|
||||||
disabled={idx >= suggestions.length - 1}
|
disabled={idx >= suggestions.length - 1}
|
||||||
className="p-1 rounded border border-border/30 disabled:opacity-25"
|
className="p-1 rounded border border-border/30 disabled:opacity-25"
|
||||||
aria-label={t('homeDashboard.intelNext')}
|
aria-label={t('homeDashboard.intelNext')}
|
||||||
@@ -84,6 +94,7 @@ export function DashboardAgentCarousel({
|
|||||||
) : (
|
) : (
|
||||||
<AgentSlide
|
<AgentSlide
|
||||||
current={suggestions[idx]}
|
current={suggestions[idx]}
|
||||||
|
direction={directionRef.current}
|
||||||
actingId={actingId}
|
actingId={actingId}
|
||||||
formatFrequency={formatFrequency}
|
formatFrequency={formatFrequency}
|
||||||
onAccept={onAccept}
|
onAccept={onAccept}
|
||||||
@@ -98,6 +109,7 @@ export function DashboardAgentCarousel({
|
|||||||
|
|
||||||
function AgentSlide({
|
function AgentSlide({
|
||||||
current,
|
current,
|
||||||
|
direction,
|
||||||
actingId,
|
actingId,
|
||||||
formatFrequency,
|
formatFrequency,
|
||||||
onAccept,
|
onAccept,
|
||||||
@@ -106,6 +118,7 @@ function AgentSlide({
|
|||||||
t,
|
t,
|
||||||
}: {
|
}: {
|
||||||
current: AgentSuggestion
|
current: AgentSuggestion
|
||||||
|
direction: number
|
||||||
actingId: string | null
|
actingId: string | null
|
||||||
formatFrequency: (f: string) => string
|
formatFrequency: (f: string) => string
|
||||||
onAccept: (id: string) => void
|
onAccept: (id: string) => void
|
||||||
@@ -115,7 +128,11 @@ function AgentSlide({
|
|||||||
}) {
|
}) {
|
||||||
const slide = prefersReducedMotion
|
const slide = prefersReducedMotion
|
||||||
? { initial: {}, animate: {}, exit: {} }
|
? { initial: {}, animate: {}, exit: {} }
|
||||||
: { initial: { opacity: 0, y: 8 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -8 } }
|
: {
|
||||||
|
initial: { opacity: 0, x: 14 * direction },
|
||||||
|
animate: { opacity: 1, x: 0 },
|
||||||
|
exit: { opacity: 0, x: -14 * direction },
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { motion, useReducedMotion } from 'motion/react'
|
||||||
import { Inbox, GraduationCap, Mail, Pin, Bot, BarChart3 } from 'lucide-react'
|
import { Inbox, GraduationCap, Mail, Pin, Bot, BarChart3 } from 'lucide-react'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { RevisionHeatmap } from '@/components/flashcards/revision-heatmap'
|
import { RevisionHeatmap } from '@/components/flashcards/revision-heatmap'
|
||||||
@@ -42,14 +43,19 @@ export function DashboardWidgetShell({
|
|||||||
|
|
||||||
export function DashboardInboxWidget({
|
export function DashboardInboxWidget({
|
||||||
count,
|
count,
|
||||||
|
notes,
|
||||||
loading,
|
loading,
|
||||||
onOpen,
|
onOpen,
|
||||||
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
count: number
|
count: number
|
||||||
|
notes: Array<{ id: string; title: string | null; notebookId: string | null }>
|
||||||
loading: boolean
|
loading: boolean
|
||||||
onOpen: () => void
|
onOpen: () => void
|
||||||
|
onSelect: (id: string, notebookId: string | null) => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
const reduced = !!useReducedMotion()
|
||||||
return (
|
return (
|
||||||
<DashboardWidgetShell
|
<DashboardWidgetShell
|
||||||
widgetId="inbox"
|
widgetId="inbox"
|
||||||
@@ -58,19 +64,42 @@ export function DashboardInboxWidget({
|
|||||||
compact
|
compact
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
<div className="space-y-2">
|
||||||
|
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||||
|
<div className="h-8 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
) : notes.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-concrete italic py-1">{t('homeDashboard.inboxEmpty')}</p>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<div className="space-y-1.5">
|
||||||
type="button"
|
{notes.map((note, idx) => (
|
||||||
onClick={onOpen}
|
<motion.button
|
||||||
className="w-full flex items-center justify-between gap-3 p-3 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start"
|
key={note.id}
|
||||||
>
|
type="button"
|
||||||
<div>
|
onClick={() => onSelect(note.id, note.notebookId)}
|
||||||
<p className="text-2xl font-serif font-bold text-ink dark:text-dark-ink leading-none">{count}</p>
|
initial={reduced ? false : { opacity: 0, x: 8 }}
|
||||||
<p className="text-[10px] text-concrete mt-1">{t('homeDashboard.toOrganize')}</p>
|
animate={{ opacity: 1, x: 0 }}
|
||||||
</div>
|
transition={{ duration: reduced ? 0 : 0.22, delay: reduced ? 0 : Math.min(idx * 0.05, 0.15), ease: [0.16, 1, 0.3, 1] }}
|
||||||
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">{t('homeDashboard.widgetOpen')} →</span>
|
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"
|
||||||
</button>
|
>
|
||||||
|
<p className="text-[11px] text-ink dark:text-dark-ink truncate">
|
||||||
|
{note.title || t('homeDashboard.untitled')}
|
||||||
|
</p>
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpen}
|
||||||
|
className="w-full flex items-center justify-between gap-2 px-1 pt-1 text-start"
|
||||||
|
>
|
||||||
|
<span className="text-[9px] font-mono uppercase font-bold text-concrete">
|
||||||
|
{t('homeDashboard.inboxSeeAll', { count })}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">
|
||||||
|
{t('homeDashboard.widgetOpen')} →
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</DashboardWidgetShell>
|
</DashboardWidgetShell>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,12 +19,22 @@ interface BridgeNote {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CLUSTER_COLORS = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF']
|
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 {
|
export interface DashboardMindOrbitProps {
|
||||||
clusters: Cluster[]
|
clusters: Cluster[]
|
||||||
bridgeNotes: BridgeNote[]
|
bridgeNotes: BridgeNote[]
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
onOpenInsights: () => void
|
onOpenInsights: () => void
|
||||||
|
onOpenCluster?: (clusterId: number) => void
|
||||||
onNoteSelect: (id: string) => void
|
onNoteSelect: (id: string) => void
|
||||||
prefersReducedMotion?: boolean
|
prefersReducedMotion?: boolean
|
||||||
}
|
}
|
||||||
@@ -34,6 +44,7 @@ export function DashboardMindOrbit({
|
|||||||
bridgeNotes,
|
bridgeNotes,
|
||||||
loading,
|
loading,
|
||||||
onOpenInsights,
|
onOpenInsights,
|
||||||
|
onOpenCluster,
|
||||||
onNoteSelect,
|
onNoteSelect,
|
||||||
prefersReducedMotion,
|
prefersReducedMotion,
|
||||||
}: DashboardMindOrbitProps) {
|
}: DashboardMindOrbitProps) {
|
||||||
@@ -43,6 +54,8 @@ export function DashboardMindOrbit({
|
|||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
const maxCount = topClusters[0]?.noteIds.length || 1
|
const maxCount = topClusters[0]?.noteIds.length || 1
|
||||||
const topBridge = bridgeNotes[0]
|
const topBridge = bridgeNotes[0]
|
||||||
|
const reduced = !!prefersReducedMotion
|
||||||
|
const points = topClusters.map((_, idx) => clusterPoint(idx, topClusters.length))
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="h-[180px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
|
return <div className="h-[180px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
|
||||||
@@ -80,21 +93,64 @@ export function DashboardMindOrbit({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-center gap-3 min-h-[100px] py-2">
|
<motion.div
|
||||||
|
className="relative h-[176px]"
|
||||||
|
initial={reduced ? false : { clipPath: 'circle(0% at 50% 44%)' }}
|
||||||
|
animate={{ clipPath: 'circle(120% at 50% 44%)' }}
|
||||||
|
transition={{ duration: reduced ? 0 : 0.62, ease: EASE }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{points.map((point, idx) => {
|
||||||
|
const color = CLUSTER_COLORS[topClusters[idx].clusterId % CLUSTER_COLORS.length]
|
||||||
|
return (
|
||||||
|
<motion.path
|
||||||
|
key={`spoke-${topClusters[idx].clusterId}`}
|
||||||
|
d={`M 50 44 L ${point.x} ${point.y}`}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={0.7}
|
||||||
|
strokeLinecap="round"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
initial={reduced ? false : { pathLength: 0, opacity: 0 }}
|
||||||
|
animate={{ pathLength: 1, opacity: 0.4 }}
|
||||||
|
transition={{ duration: reduced ? 0 : 0.45, delay: reduced ? 0 : 0.12 + idx * 0.05, ease: EASE }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="absolute left-1/2 top-[44%] w-2.5 h-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-brand-accent shadow-[0_2px_8px_rgba(164,113,72,0.45)] pointer-events-none"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
|
||||||
{topClusters.map((cluster, idx) => {
|
{topClusters.map((cluster, idx) => {
|
||||||
const color = CLUSTER_COLORS[cluster.clusterId % CLUSTER_COLORS.length]
|
const color = CLUSTER_COLORS[cluster.clusterId % CLUSTER_COLORS.length]
|
||||||
const scale = 0.65 + (cluster.noteIds.length / maxCount) * 0.55
|
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 label = cluster.name || `${t('homeDashboard.theme')} ${cluster.clusterId + 1}`
|
||||||
|
const point = points[idx]
|
||||||
return (
|
return (
|
||||||
<motion.button
|
<motion.button
|
||||||
key={cluster.clusterId}
|
key={cluster.clusterId}
|
||||||
type="button"
|
type="button"
|
||||||
whileHover={prefersReducedMotion ? undefined : { scale: 1.06 }}
|
whileHover={reduced ? undefined : { scale: 1.06 }}
|
||||||
whileTap={prefersReducedMotion ? undefined : { scale: 0.97 }}
|
whileTap={reduced ? undefined : { scale: 0.97 }}
|
||||||
onClick={onOpenInsights}
|
onClick={() => (onOpenCluster ? onOpenCluster(cluster.clusterId) : onOpenInsights())}
|
||||||
className="relative flex flex-col items-center gap-1.5 group"
|
className="absolute flex flex-col items-center gap-1 group"
|
||||||
style={{ width: size + 16 }}
|
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 }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="rounded-full border-2 flex items-center justify-center font-mono font-bold text-white shadow-sm group-hover:shadow-md transition-shadow"
|
className="rounded-full border-2 flex items-center justify-center font-mono font-bold text-white shadow-sm group-hover:shadow-md transition-shadow"
|
||||||
@@ -114,13 +170,16 @@ export function DashboardMindOrbit({
|
|||||||
</motion.button>
|
</motion.button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{topBridge?.note && (
|
{topBridge?.note && (
|
||||||
<button
|
<motion.button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onNoteSelect(topBridge.noteId)}
|
onClick={() => onNoteSelect(topBridge.noteId)}
|
||||||
className="w-full mt-2 p-2.5 rounded-xl border border-brand-accent/20 bg-brand-accent/[0.04] hover:bg-brand-accent/[0.08] transition-all text-start flex items-center gap-2 group"
|
className="w-full mt-2 p-2.5 rounded-xl border border-brand-accent/20 bg-brand-accent/[0.04] hover:bg-brand-accent/[0.08] transition-all text-start flex items-center gap-2 group"
|
||||||
|
initial={reduced ? false : { opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: reduced ? 0 : 0.32, delay: reduced ? 0 : 0.48, ease: EASE }}
|
||||||
>
|
>
|
||||||
<Zap size={11} className="text-brand-accent shrink-0" />
|
<Zap size={11} className="text-brand-accent shrink-0" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -132,7 +191,7 @@ export function DashboardMindOrbit({
|
|||||||
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded-full shrink-0">
|
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded-full shrink-0">
|
||||||
{Math.round(topBridge.bridgeScore * 100)}%
|
{Math.round(topBridge.bridgeScore * 100)}%
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</motion.button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ export function DashboardNextPaths({
|
|||||||
onAction,
|
onAction,
|
||||||
prefersReducedMotion,
|
prefersReducedMotion,
|
||||||
}: DashboardNextPathsProps) {
|
}: DashboardNextPathsProps) {
|
||||||
const { t } = useLanguage()
|
const { t, language } = useLanguage()
|
||||||
|
const rtl = language === 'ar' || language === 'fa'
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -84,6 +85,8 @@ export function DashboardNextPaths({
|
|||||||
const hero = paths[0]
|
const hero = paths[0]
|
||||||
const rest = paths.slice(1, 5)
|
const rest = paths.slice(1, 5)
|
||||||
const HeroIcon = TYPE_META[hero.type].Icon
|
const HeroIcon = TYPE_META[hero.type].Icon
|
||||||
|
const reduced = !!prefersReducedMotion
|
||||||
|
const ease = [0.16, 1, 0.3, 1] as const
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-brand-accent/20 bg-gradient-to-br from-white via-white to-brand-accent/[0.04] dark:from-zinc-900 dark:via-zinc-900 dark:to-brand-accent/[0.06] shadow-sm overflow-hidden">
|
<div className="rounded-2xl border border-brand-accent/20 bg-gradient-to-br from-white via-white to-brand-accent/[0.04] dark:from-zinc-900 dark:via-zinc-900 dark:to-brand-accent/[0.06] shadow-sm overflow-hidden">
|
||||||
@@ -109,7 +112,11 @@ export function DashboardNextPaths({
|
|||||||
<motion.button
|
<motion.button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onAction(hero)}
|
onClick={() => 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"
|
className="w-full text-start px-5 py-4 hover:bg-brand-accent/[0.03] transition-colors border-b border-border/10"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
@@ -137,14 +144,17 @@ export function DashboardNextPaths({
|
|||||||
|
|
||||||
{rest.length > 0 && (
|
{rest.length > 0 && (
|
||||||
<div className="divide-y divide-border/10">
|
<div className="divide-y divide-border/10">
|
||||||
{rest.map(path => {
|
{rest.map((path, idx) => {
|
||||||
const meta = TYPE_META[path.type]
|
const meta = TYPE_META[path.type]
|
||||||
const Icon = meta.Icon
|
const Icon = meta.Icon
|
||||||
return (
|
return (
|
||||||
<button
|
<motion.button
|
||||||
key={path.id}
|
key={path.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onAction(path)}
|
onClick={() => onAction(path)}
|
||||||
|
initial={reduced ? false : { opacity: 0, x: rtl ? -10 : 10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: reduced ? 0 : 0.28, delay: reduced ? 0 : Math.min(0.08 + idx * 0.04, 0.2), ease }}
|
||||||
className="w-full flex items-center gap-3 px-5 py-3 text-start hover:bg-stone-50/80 dark:hover:bg-zinc-950/40 transition-colors"
|
className="w-full flex items-center gap-3 px-5 py-3 text-start hover:bg-stone-50/80 dark:hover:bg-zinc-950/40 transition-colors"
|
||||||
>
|
>
|
||||||
<Icon size={13} className={`shrink-0 ${meta.accent}`} />
|
<Icon size={13} className={`shrink-0 ${meta.accent}`} />
|
||||||
@@ -155,7 +165,7 @@ export function DashboardNextPaths({
|
|||||||
<span className="text-[8px] font-mono uppercase text-brand-accent shrink-0">
|
<span className="text-[8px] font-mono uppercase text-brand-accent shrink-0">
|
||||||
{t(`homeDashboard.pathActions.${path.actionKey}`)}
|
{t(`homeDashboard.pathActions.${path.actionKey}`)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</motion.button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, type ReactNode } from 'react'
|
import { useState, useEffect, useCallback, useMemo, type ReactNode } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useReducedMotion } from 'motion/react'
|
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 { useLanguage } from '@/lib/i18n'
|
||||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||||
import { redirectToAiConsentSettings } from '@/lib/consent/ai-consent-redirect'
|
import { redirectToAiConsentSettings } from '@/lib/consent/ai-consent-redirect'
|
||||||
@@ -152,7 +152,7 @@ interface MindMapData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DashboardViewProps {
|
interface DashboardViewProps {
|
||||||
onNoteSelect: (noteId: string, notebookId: string | null) => void
|
onNoteSelect: (noteId: string, notebookId: string | null, peekNoteId?: string | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GmailStatus {
|
interface GmailStatus {
|
||||||
@@ -217,6 +217,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
|||||||
const [data, setData] = useState<{
|
const [data, setData] = useState<{
|
||||||
recentNotes: BriefingNote[]
|
recentNotes: BriefingNote[]
|
||||||
inboxCount: number
|
inboxCount: number
|
||||||
|
inboxPreview?: Array<{ id: string; title: string | null; notebookId: string | null }>
|
||||||
dueFlashcards: number
|
dueFlashcards: number
|
||||||
upcomingReminders: BriefingReminder[]
|
upcomingReminders: BriefingReminder[]
|
||||||
insights: BriefingInsight[]
|
insights: BriefingInsight[]
|
||||||
@@ -491,9 +492,14 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
|||||||
} : prev)
|
} : prev)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleOpenFromInsight = useCallback(async (insight: BriefingInsight, noteId: string) => {
|
const handleOpenFromInsight = useCallback(async (
|
||||||
|
insight: BriefingInsight,
|
||||||
|
noteId: string,
|
||||||
|
peekNoteId?: string | null,
|
||||||
|
) => {
|
||||||
await markInsightViewed(insight.id)
|
await markInsightViewed(insight.id)
|
||||||
onNoteSelect(noteId, null)
|
const peek = peekNoteId && peekNoteId !== noteId ? peekNoteId : undefined
|
||||||
|
onNoteSelect(noteId, null, peek)
|
||||||
}, [markInsightViewed, onNoteSelect])
|
}, [markInsightViewed, onNoteSelect])
|
||||||
|
|
||||||
const handleDismissInsight = useCallback(async (insight: BriefingInsight) => {
|
const handleDismissInsight = useCallback(async (insight: BriefingInsight) => {
|
||||||
@@ -636,18 +642,14 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
|||||||
if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null)
|
if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null)
|
||||||
break
|
break
|
||||||
case 'compare':
|
case 'compare':
|
||||||
if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null)
|
if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null, path.note2Id)
|
||||||
if (path.note2Id) toast.info(t('homeDashboard.pathCompareHint', { title: path.title }))
|
|
||||||
break
|
break
|
||||||
case 'addLink':
|
case 'addLink':
|
||||||
if (path.noteId) {
|
if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null, path.note2Id)
|
||||||
onNoteSelect(path.noteId, path.notebookId ?? null)
|
|
||||||
toast.info(t('homeDashboard.pathAddLinkHint', { link: path.title }))
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
case 'openInsight': {
|
case 'openInsight': {
|
||||||
const insight = insights.find(i => i.id === path.insightId)
|
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
|
break
|
||||||
}
|
}
|
||||||
case 'createBridge': {
|
case 'createBridge': {
|
||||||
@@ -772,8 +774,11 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
|||||||
onClick={handleCapture}
|
onClick={handleCapture}
|
||||||
disabled={!captureText.trim() || capturing}
|
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"
|
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}
|
||||||
>
|
>
|
||||||
<Send size={12} />
|
{capturing
|
||||||
|
? <Loader2 size={12} className="animate-spin" />
|
||||||
|
: <Send size={12} />}
|
||||||
</button>
|
</button>
|
||||||
</div>,
|
</div>,
|
||||||
)
|
)
|
||||||
@@ -952,6 +957,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
|||||||
bridgeNotes={mindMap?.bridgeNotes ?? []}
|
bridgeNotes={mindMap?.bridgeNotes ?? []}
|
||||||
loading={mindMapLoading}
|
loading={mindMapLoading}
|
||||||
onOpenInsights={() => router.push('/insights')}
|
onOpenInsights={() => router.push('/insights')}
|
||||||
|
onOpenCluster={(clusterId) => router.push(`/insights?cluster=${clusterId}`)}
|
||||||
onNoteSelect={(nid) => onNoteSelect(nid, null)}
|
onNoteSelect={(nid) => onNoteSelect(nid, null)}
|
||||||
prefersReducedMotion={!!prefersReducedMotion}
|
prefersReducedMotion={!!prefersReducedMotion}
|
||||||
/>,
|
/>,
|
||||||
@@ -983,8 +989,10 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
|||||||
return wrap(
|
return wrap(
|
||||||
<DashboardInboxWidget
|
<DashboardInboxWidget
|
||||||
count={inboxCount}
|
count={inboxCount}
|
||||||
|
notes={data?.inboxPreview ?? []}
|
||||||
loading={briefingLoading}
|
loading={briefingLoading}
|
||||||
onOpen={() => router.push('/home?forceList=1')}
|
onOpen={() => router.push('/home?forceList=1')}
|
||||||
|
onSelect={onNoteSelect}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
case 'revision':
|
case 'revision':
|
||||||
@@ -1114,7 +1122,17 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="pb-24">
|
<div className="pb-24">
|
||||||
<DashboardWidgetGrid renderWidget={renderWidget} />
|
<DashboardWidgetGrid
|
||||||
|
renderWidget={renderWidget}
|
||||||
|
isWidgetEmpty={(id) => {
|
||||||
|
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
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { toast } from 'sonner'
|
|||||||
|
|
||||||
interface DashboardWidgetGridProps {
|
interface DashboardWidgetGridProps {
|
||||||
renderWidget: (id: DashboardWidgetId) => React.ReactNode
|
renderWidget: (id: DashboardWidgetId) => React.ReactNode
|
||||||
|
isWidgetEmpty?: (id: DashboardWidgetId) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableWidget({
|
function SortableWidget({
|
||||||
@@ -139,7 +140,7 @@ function ZoneColumn({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps) {
|
export function DashboardWidgetGrid({ renderWidget, isWidgetEmpty }: DashboardWidgetGridProps) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const [layout, setLayout] = useState<DashboardLayout>(() => getDefaultDashboardLayout())
|
const [layout, setLayout] = useState<DashboardLayout>(() => getDefaultDashboardLayout())
|
||||||
const [editMode, setEditMode] = useState(false)
|
const [editMode, setEditMode] = useState(false)
|
||||||
@@ -228,8 +229,11 @@ export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps)
|
|||||||
}, [persistLayout, t])
|
}, [persistLayout, t])
|
||||||
|
|
||||||
const fullWidgets = visibleWidgetsInZone(layout, 'full')
|
const fullWidgets = visibleWidgetsInZone(layout, 'full')
|
||||||
|
.filter(w => editMode || !isWidgetEmpty?.(w.id))
|
||||||
const mainWidgets = visibleWidgetsInZone(layout, 'main')
|
const mainWidgets = visibleWidgetsInZone(layout, 'main')
|
||||||
|
.filter(w => editMode || !isWidgetEmpty?.(w.id))
|
||||||
const sideWidgets = visibleWidgetsInZone(layout, 'side')
|
const sideWidgets = visibleWidgetsInZone(layout, 'side')
|
||||||
|
.filter(w => editMode || !isWidgetEmpty?.(w.id))
|
||||||
const hidden = hiddenWidgetIds(layout)
|
const hidden = hiddenWidgetIds(layout)
|
||||||
const catalog = catalogByCategory(layout)
|
const catalog = catalogByCategory(layout)
|
||||||
const hasVisibleWidgets = fullWidgets.length + mainWidgets.length + sideWidgets.length > 0
|
const hasVisibleWidgets = fullWidgets.length + mainWidgets.length + sideWidgets.length > 0
|
||||||
|
|||||||
@@ -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.)
|
// Garder openNote dans l'URL tant que l'éditeur est ouvert → le sidebar peut surligner la note (comme activeNoteId dans la ref.)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const openNoteId = searchParams.get('openNote')
|
const openNoteId = searchParams.get('openNote')
|
||||||
if (!openNoteId) return
|
if (!openNoteId) {
|
||||||
|
setEditingNote(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
@@ -816,8 +819,15 @@ export function HomeClient({
|
|||||||
|
|
||||||
const handleEditorClose = useCallback(() => {
|
const handleEditorClose = useCallback(() => {
|
||||||
setEditingNote(null)
|
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())
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
params.delete('openNote')
|
params.delete('openNote')
|
||||||
|
params.delete('peekNote')
|
||||||
|
params.delete('from')
|
||||||
const qs = params.toString()
|
const qs = params.toString()
|
||||||
router.replace(qs ? `/home?${qs}` : '/home', { scroll: false })
|
router.replace(qs ? `/home?${qs}` : '/home', { scroll: false })
|
||||||
}, [router, searchParams])
|
}, [router, searchParams])
|
||||||
@@ -831,10 +841,17 @@ export function HomeClient({
|
|||||||
// Show dashboard when no active filter/view params
|
// Show dashboard when no active filter/view params
|
||||||
const showDashboard = !editingNote && isDashboardHomeRoute('/home', searchParams)
|
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()
|
const params = new URLSearchParams()
|
||||||
params.set('openNote', noteId)
|
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.push(`/home?${params.toString()}`)
|
||||||
}, [router])
|
}, [router])
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export interface IntelligenceHubProps {
|
|||||||
onDismissInsight: (insight: IntelBriefingInsight) => void
|
onDismissInsight: (insight: IntelBriefingInsight) => void
|
||||||
onDismissBridgeSuggestion: (s: IntelBridgeSuggestion) => void
|
onDismissBridgeSuggestion: (s: IntelBridgeSuggestion) => void
|
||||||
onCreateBridgeSuggestion: (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
|
dismissingInsightId: string | null
|
||||||
actingBridgeSuggestionKey: string | null
|
actingBridgeSuggestionKey: string | null
|
||||||
prefersReducedMotion: boolean
|
prefersReducedMotion: boolean
|
||||||
@@ -334,7 +334,7 @@ export function IntelligenceHub({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpenInsightNote(insight, insight.note2.id)}
|
onClick={() => onOpenInsightNote(insight, insight.note1.id, insight.note2.id)}
|
||||||
className="inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg border border-border/40 hover:border-indigo-400/40 transition-colors"
|
className="inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg border border-border/40 hover:border-indigo-400/40 transition-colors"
|
||||||
>
|
>
|
||||||
<GitCompare size={9} />
|
<GitCompare size={9} />
|
||||||
|
|||||||
@@ -50,8 +50,10 @@ function elementOpacity(
|
|||||||
state: StepResolvedState,
|
state: StepResolvedState,
|
||||||
dim: number
|
dim: number
|
||||||
): number {
|
): number {
|
||||||
|
// Overview = full scene at full brightness (matches edges behavior)
|
||||||
|
if (state.overview) return 1
|
||||||
if (!(id in state.revealed)) return 0
|
if (!(id in state.revealed)) return 0
|
||||||
if (state.overview || state.spotlight.length === 0) return 1
|
if (state.spotlight.length === 0) return 1
|
||||||
return state.spotlight.includes(id) ? 1 : dim
|
return state.spotlight.includes(id) ? 1 : dim
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,6 +752,11 @@ function HeatmapPanel({
|
|||||||
const dim = dimOpacity(dark)
|
const dim = dimOpacity(dark)
|
||||||
const fillIntent = spotlightColor(dark)
|
const fillIntent = spotlightColor(dark)
|
||||||
const ghostStroke = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.16)'
|
const ghostStroke = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.16)'
|
||||||
|
// Intensity ∝ value — normalized so real-world scales (not just [0,1]) work
|
||||||
|
const maxAbsV = Math.max(
|
||||||
|
1e-9,
|
||||||
|
...values.flat().map((v) => Math.abs(v))
|
||||||
|
)
|
||||||
|
|
||||||
const cellRect = (r: number, c: number) => ({
|
const cellRect = (r: number, c: number) => ({
|
||||||
x: labelW + (c - 1) * cell,
|
x: labelW + (c - 1) * cell,
|
||||||
@@ -778,8 +785,23 @@ function HeatmapPanel({
|
|||||||
return map
|
return map
|
||||||
}, [rows, cols, triangular])
|
}, [rows, cols, triangular])
|
||||||
|
|
||||||
|
const uid = useId().replace(/:/g, '')
|
||||||
|
const markerId = `demo-arrowhead-hm-${uid}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg viewBox={`0 0 ${w} ${h}`} className="w-full h-auto max-w-lg mx-auto">
|
<svg viewBox={`0 0 ${w} ${h}`} className="w-full h-auto max-w-lg mx-auto">
|
||||||
|
<defs>
|
||||||
|
<marker
|
||||||
|
id={markerId}
|
||||||
|
markerWidth="9"
|
||||||
|
markerHeight="9"
|
||||||
|
refX="7"
|
||||||
|
refY="3.5"
|
||||||
|
orient="auto"
|
||||||
|
>
|
||||||
|
<path d="M0,0 L7,3.5 L0,7 Z" fill={fillIntent} />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
{colLabels?.map((lab, i) => (
|
{colLabels?.map((lab, i) => (
|
||||||
<text
|
<text
|
||||||
key={`c-${i}`}
|
key={`c-${i}`}
|
||||||
@@ -839,9 +861,10 @@ function HeatmapPanel({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fillOpacity = 0.14 + v * 0.78
|
const vNorm = Math.min(1, Math.abs(v) / maxAbsV)
|
||||||
|
const fillOpacity = 0.14 + vNorm * 0.78
|
||||||
const textFill =
|
const textFill =
|
||||||
v > 0.45 ? (dark ? '#0a0a0a' : '#fff') : dark ? '#eee' : '#111'
|
vNorm > 0.45 ? (dark ? '#0a0a0a' : '#fff') : dark ? '#eee' : '#111'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g key={id} opacity={op} style={{ transition }}>
|
<g key={id} opacity={op} style={{ transition }}>
|
||||||
@@ -874,7 +897,7 @@ function HeatmapPanel({
|
|||||||
<AnnotationsOverlay
|
<AnnotationsOverlay
|
||||||
annotations={state.annotations}
|
annotations={state.annotations}
|
||||||
dark={dark}
|
dark={dark}
|
||||||
markerId="demo-arrowhead-hm"
|
markerId={markerId}
|
||||||
getAnchor={(id, kind) => {
|
getAnchor={(id, kind) => {
|
||||||
const p = positions.get(id)
|
const p = positions.get(id)
|
||||||
if (!p) return null
|
if (!p) return null
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function DemoSpeak({ speak, className }: { speak: string; className?: str
|
|||||||
|
|
||||||
let md = marked.parse(withSlots, { gfm: true, breaks: true }) as string
|
let md = marked.parse(withSlots, { gfm: true, breaks: true }) as string
|
||||||
placeholders.forEach((frag, i) => {
|
placeholders.forEach((frag, i) => {
|
||||||
md = md.replace(`%%KATEX${i}%%`, frag)
|
md = md.replace(`%%KATEX${i}%%`, () => frag)
|
||||||
})
|
})
|
||||||
return sanitizeRichHtml(md)
|
return sanitizeRichHtml(md)
|
||||||
}, [speak])
|
}, [speak])
|
||||||
|
|||||||
@@ -141,12 +141,12 @@ export function InteractivePagePublishDialog({
|
|||||||
setProgress(null)
|
setProgress(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Guard: empty content from editor race
|
// Guard: empty content from editor race (aligned with API min 40 words)
|
||||||
const wordCount = content
|
const wordCount = content
|
||||||
.replace(/<[^>]+>/g, ' ')
|
.replace(/<[^>]+>/g, ' ')
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
.filter(Boolean).length
|
.filter(Boolean).length
|
||||||
if (wordCount < 30) {
|
if (wordCount < 40) {
|
||||||
setPhase('error')
|
setPhase('error')
|
||||||
setError(
|
setError(
|
||||||
t('richTextEditor.publishInteractivePageTooShort') ||
|
t('richTextEditor.publishInteractivePageTooShort') ||
|
||||||
|
|||||||
@@ -4,6 +4,17 @@ import { PageView } from '@/components/interactive-page/page-view'
|
|||||||
import { validateInteractivePage, type PageSpecV1 } from '@/lib/interactive-page'
|
import { validateInteractivePage, type PageSpecV1 } from '@/lib/interactive-page'
|
||||||
import { AlertCircle } from 'lucide-react'
|
import { AlertCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
const STRINGS = {
|
||||||
|
fr: {
|
||||||
|
stale: 'Le contenu source a évolué — cette page interactive est à régénérer.',
|
||||||
|
invalid: 'Page interactive indisponible',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
stale: 'The source content has changed — this interactive page needs regeneration.',
|
||||||
|
invalid: 'Interactive page unavailable',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public / preview shell for published interactive pages.
|
* Public / preview shell for published interactive pages.
|
||||||
* Parses stored PageSpecV1 JSON from `publishedContent`.
|
* Parses stored PageSpecV1 JSON from `publishedContent`.
|
||||||
@@ -17,20 +28,26 @@ export function InteractivePublishedPage({
|
|||||||
}) {
|
}) {
|
||||||
let page: PageSpecV1 | null = null
|
let page: PageSpecV1 | null = null
|
||||||
let error: string | null = null
|
let error: string | null = null
|
||||||
|
let lang = 'fr'
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(publishedContent)
|
const raw = JSON.parse(publishedContent)
|
||||||
const result = validateInteractivePage(raw)
|
const result = validateInteractivePage(raw)
|
||||||
if (result.ok) page = result.page
|
if (result.ok) {
|
||||||
else error = result.issues[0]?.message || 'PageSpec invalide'
|
page = result.page
|
||||||
|
lang = result.page.lang || 'fr'
|
||||||
|
} else {
|
||||||
|
error = result.issues[0]?.message || 'PageSpec invalide'
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
error = 'JSON de page interactive illisible'
|
error = 'JSON de page interactive illisible'
|
||||||
}
|
}
|
||||||
|
const t = lang.startsWith('fr') ? STRINGS.fr : STRINGS.en
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-lg gap-3 p-10 text-sm text-destructive">
|
<div className="mx-auto flex max-w-lg gap-3 p-10 text-sm text-destructive">
|
||||||
<AlertCircle className="h-5 w-5 shrink-0" />
|
<AlertCircle className="h-5 w-5 shrink-0" />
|
||||||
<p>{error || 'Page interactive indisponible'}</p>
|
<p>{error || t.invalid}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -39,7 +56,7 @@ export function InteractivePublishedPage({
|
|||||||
<div>
|
<div>
|
||||||
{isStale ? (
|
{isStale ? (
|
||||||
<div className="border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-center text-xs text-amber-800 dark:text-amber-200">
|
<div className="border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-center text-xs text-amber-800 dark:text-amber-200">
|
||||||
Le contenu source a évolué — cette page interactive est à régénérer.
|
{t.stale}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<PageView page={page} demoMode="interactive" />
|
<PageView page={page} demoMode="interactive" />
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { InteractiveDemoPlayer } from '@/components/interactive-demo/interactive
|
|||||||
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
|
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
|
||||||
import { PageFormula, PageMd } from '@/components/interactive-page/page-md'
|
import { PageFormula, PageMd } from '@/components/interactive-page/page-md'
|
||||||
import { SimBlockView } from '@/components/interactive-page/sim-block'
|
import { SimBlockView } from '@/components/interactive-page/sim-block'
|
||||||
|
import { StepsBlockView } from '@/components/interactive-page/steps-block'
|
||||||
import { intentColor } from '@/lib/interactive-demo/intent-colors'
|
import { intentColor } from '@/lib/interactive-demo/intent-colors'
|
||||||
import type { IntentId, InteractiveDemoV1 } from '@/lib/interactive-demo/types'
|
import type { IntentId, InteractiveDemoV1 } from '@/lib/interactive-demo/types'
|
||||||
import type { PageBlock } from '@/lib/interactive-page'
|
import type { PageBlock } from '@/lib/interactive-page'
|
||||||
@@ -25,16 +26,20 @@ import { cn } from '@/lib/utils'
|
|||||||
/** Intents actually used inside a demo (legend per demo, brainstorm P11). */
|
/** Intents actually used inside a demo (legend per demo, brainstorm P11). */
|
||||||
function collectDemoIntents(demo: InteractiveDemoV1): IntentId[] {
|
function collectDemoIntents(demo: InteractiveDemoV1): IntentId[] {
|
||||||
const set = new Set<IntentId>()
|
const set = new Set<IntentId>()
|
||||||
for (const panel of demo.scene.panels) {
|
const collectFromPanels = (panels: InteractiveDemoV1['scene']['panels']) => {
|
||||||
if (panel.type === 'svg-scene') {
|
for (const panel of panels) {
|
||||||
for (const n of panel.payload.nodes) if (n.intent) set.add(n.intent)
|
if (panel.type === 'svg-scene') {
|
||||||
for (const e of panel.payload.edges ?? []) if (e.intent) set.add(e.intent)
|
for (const n of panel.payload.nodes) if (n.intent) set.add(n.intent)
|
||||||
}
|
for (const e of panel.payload.edges ?? []) if (e.intent) set.add(e.intent)
|
||||||
if (panel.type === 'chart') {
|
}
|
||||||
for (const s of panel.payload.series) if (s.intent) set.add(s.intent)
|
if (panel.type === 'chart') {
|
||||||
|
for (const s of panel.payload.series) if (s.intent) set.add(s.intent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
collectFromPanels(demo.scene.panels)
|
||||||
for (const act of demo.acts) {
|
for (const act of demo.acts) {
|
||||||
|
if (act.scene) collectFromPanels(act.scene.panels)
|
||||||
for (const step of act.steps) {
|
for (const step of act.steps) {
|
||||||
for (const a of step.annotate ?? []) if (a.intent) set.add(a.intent)
|
for (const a of step.annotate ?? []) if (a.intent) set.add(a.intent)
|
||||||
}
|
}
|
||||||
@@ -280,6 +285,10 @@ export function PageBlockView({
|
|||||||
return <SimBlockView block={block} lang={lang} />
|
return <SimBlockView block={block} lang={lang} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (block.type === 'steps') {
|
||||||
|
return <StepsBlockView block={block} lang={lang} />
|
||||||
|
}
|
||||||
|
|
||||||
if (block.type === 'chart') {
|
if (block.type === 'chart') {
|
||||||
return <ChartBlockView block={block} />
|
return <ChartBlockView block={block} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ export function PageMd({
|
|||||||
|
|
||||||
let out = marked.parse(withSlots, { gfm: true, breaks: true }) as string
|
let out = marked.parse(withSlots, { gfm: true, breaks: true }) as string
|
||||||
placeholders.forEach((frag, i) => {
|
placeholders.forEach((frag, i) => {
|
||||||
out = out.replace(`%%KATEX${i}%%`, frag)
|
// function replacement — $', $`, $& in KaTeX HTML must not be interpreted
|
||||||
|
out = out.replace(`%%KATEX${i}%%`, () => frag)
|
||||||
})
|
})
|
||||||
return sanitizeRichHtml(out)
|
return sanitizeRichHtml(out)
|
||||||
}, [md])
|
}, [md])
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ function collectIntents(page: PageSpecV1): IntentId[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (block.type === 'demo') {
|
if (block.type === 'demo') {
|
||||||
for (const panel of block.demo.scene.panels) {
|
const panels = [
|
||||||
|
...block.demo.scene.panels,
|
||||||
|
...block.demo.acts.flatMap((a) => a.scene?.panels ?? []),
|
||||||
|
]
|
||||||
|
for (const panel of panels) {
|
||||||
if (panel.type === 'svg-scene') {
|
if (panel.type === 'svg-scene') {
|
||||||
for (const n of panel.payload.nodes) if (n.intent) set.add(n.intent)
|
for (const n of panel.payload.nodes) if (n.intent) set.add(n.intent)
|
||||||
for (const e of panel.payload.edges ?? []) if (e.intent) set.add(e.intent)
|
for (const e of panel.payload.edges ?? []) if (e.intent) set.add(e.intent)
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ export function PageView({
|
|||||||
style={paperStyle}
|
style={paperStyle}
|
||||||
>
|
>
|
||||||
<style>{`
|
<style>{`
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
}
|
||||||
.interactive-page {
|
.interactive-page {
|
||||||
--pp-paper: #F4F0E8;
|
--pp-paper: #F4F0E8;
|
||||||
--pp-paper-deep: #EAE4D9;
|
--pp-paper-deep: #EAE4D9;
|
||||||
@@ -139,6 +142,15 @@ export function PageView({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
|
{/* Without JS the scroll-reveal never fires — show everything */}
|
||||||
|
<noscript>
|
||||||
|
<style>{`
|
||||||
|
.interactive-page [data-scroll-init] {
|
||||||
|
opacity: 1 !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</noscript>
|
||||||
|
|
||||||
{/* ── Hero (kicker / 800 title / ink subtitle / mono meta) ── */}
|
{/* ── Hero (kicker / 800 title / ink subtitle / mono meta) ── */}
|
||||||
<header className="mx-auto max-w-[1100px] px-5 pb-8 pt-12 md:pt-16">
|
<header className="mx-auto max-w-[1100px] px-5 pb-8 pt-12 md:pt-16">
|
||||||
|
|||||||
134
memento-note/components/interactive-page/steps-block.tsx
Normal file
134
memento-note/components/interactive-page/steps-block.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import katex from 'katex'
|
||||||
|
import { AnimPlayerShell } from '@/components/simulators/anim-player-shell'
|
||||||
|
import type { StepsBlock } from '@/lib/interactive-page'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function renderTex(tex: string, displayMode: boolean): string {
|
||||||
|
try {
|
||||||
|
return katex.renderToString(tex, { displayMode, throwOnError: false })
|
||||||
|
} catch {
|
||||||
|
return tex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step-by-step derivation (Symbolab/Khan style): equation states revealed
|
||||||
|
* line by line, current line highlighted, transformation rule in the margin.
|
||||||
|
* No boxes, no LLM drawing — pure KaTeX + deterministic chrome.
|
||||||
|
*/
|
||||||
|
export function StepsBlockView({
|
||||||
|
block,
|
||||||
|
lang,
|
||||||
|
}: {
|
||||||
|
block: StepsBlock
|
||||||
|
lang: string
|
||||||
|
}) {
|
||||||
|
const fr = lang.startsWith('fr')
|
||||||
|
const steps = block.steps
|
||||||
|
|
||||||
|
const beats = useMemo(
|
||||||
|
() =>
|
||||||
|
steps.map((s, i) => ({
|
||||||
|
id: `st${i + 1}`,
|
||||||
|
speak: {
|
||||||
|
fr: s.speak || s.rule || (fr ? `Étape ${i + 1}` : `Step ${i + 1}`),
|
||||||
|
en: s.speak || s.rule || `Step ${i + 1}`,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
[steps, fr]
|
||||||
|
)
|
||||||
|
|
||||||
|
const rendered = useMemo(() => steps.map((s) => renderTex(s.tex, true)), [steps])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<figure
|
||||||
|
className="my-8 rounded-2xl border p-4 md:p-5"
|
||||||
|
style={{ background: 'var(--pp-card)', borderColor: 'var(--pp-line)' }}
|
||||||
|
>
|
||||||
|
{block.title ? (
|
||||||
|
<div className="mb-4 flex items-baseline justify-between gap-3">
|
||||||
|
<h3 className="text-sm font-semibold tracking-tight">{block.title}</h3>
|
||||||
|
<span
|
||||||
|
className="text-[10px] font-semibold uppercase tracking-[0.16em]"
|
||||||
|
style={{ color: 'var(--pp-muted)' }}
|
||||||
|
>
|
||||||
|
{fr ? 'Dérivation pas à pas' : 'Step-by-step derivation'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<AnimPlayerShell beats={beats} lang={lang}>
|
||||||
|
{(stepIndex) => (
|
||||||
|
<div className="space-y-1.5 p-3 md:p-4" style={{ background: 'var(--pp-paper)' }}>
|
||||||
|
{steps.slice(0, stepIndex + 1).map((s, i) => {
|
||||||
|
const current = i === stepIndex
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-3 rounded-lg border-l-4 px-3 py-2',
|
||||||
|
current ? 'shadow-sm' : 'border-transparent'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
borderLeftColor: current ? 'var(--pp-plum)' : 'transparent',
|
||||||
|
background: current ? 'var(--pp-card)' : 'transparent',
|
||||||
|
opacity: current ? 1 : 0.72,
|
||||||
|
transition: 'opacity 400ms ease, background 400ms ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="mt-1 shrink-0 text-[10px] font-semibold tabular-nums"
|
||||||
|
style={{ color: 'var(--pp-muted)', fontFamily: 'var(--pp-mono)' }}
|
||||||
|
>
|
||||||
|
{String(i + 1).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="min-w-0 flex-1 overflow-x-auto text-[1.02em] [&_.katex-display]:my-1"
|
||||||
|
dangerouslySetInnerHTML={{ __html: rendered[i] }}
|
||||||
|
/>
|
||||||
|
{s.rule ? (
|
||||||
|
<span
|
||||||
|
className="mt-0.5 shrink-0 rounded-md px-2 py-1 text-[11px] leading-snug"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--pp-mono)',
|
||||||
|
color: 'var(--pp-plum)',
|
||||||
|
background: 'color-mix(in oklab, var(--pp-plum) 10%, transparent)',
|
||||||
|
maxWidth: '38%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.rule}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimPlayerShell>
|
||||||
|
|
||||||
|
{block.caption ? (
|
||||||
|
<figcaption
|
||||||
|
className="mt-3 text-center text-sm"
|
||||||
|
style={{ color: 'var(--pp-muted)' }}
|
||||||
|
>
|
||||||
|
{block.caption}
|
||||||
|
</figcaption>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Without JS: full derivation visible + rules listed */}
|
||||||
|
<noscript>
|
||||||
|
<ol className="mt-3 list-inside list-decimal space-y-2 text-sm">
|
||||||
|
{steps.map((s, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<span dangerouslySetInnerHTML={{ __html: rendered[i] }} />
|
||||||
|
{s.rule ? <em className="ml-2 text-muted-foreground">({s.rule})</em> : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</noscript>
|
||||||
|
</figure>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import Link from 'next/link'
|
|||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import type { SupportedLanguage } from '@/lib/i18n/load-translations'
|
import type { SupportedLanguage } from '@/lib/i18n/load-translations'
|
||||||
|
import { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants'
|
||||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
|
|
||||||
const ECHO_LINES = ['echo0', 'echo1', 'echo2'] as const
|
const ECHO_LINES = ['echo0', 'echo1', 'echo2'] as const
|
||||||
@@ -68,11 +69,30 @@ export function LandingPage() {
|
|||||||
return () => { root.style.overflow = prev }
|
return () => { root.style.overflow = prev }
|
||||||
}, [menuOpen])
|
}, [menuOpen])
|
||||||
|
|
||||||
|
const trialDays = SUBSCRIPTION_TRIAL_DAYS
|
||||||
const PLANS = [
|
const PLANS = [
|
||||||
{ key: 'basic', popular: false, price: t('landing.pricing.basicPrice'), period: '' },
|
{ key: 'basic', popular: false, hasTrial: 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: 'pro',
|
||||||
{ key: 'enterprise', popular: false, price: billingInterval === 'monthly' ? '49,90€' : '39,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perUser') : t('landing.pricing.perUserAnnual') },
|
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: '',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
@@ -467,7 +487,9 @@ export function LandingPage() {
|
|||||||
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all relative ${billingInterval === 'annual' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/45'}`}
|
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all relative ${billingInterval === 'annual' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/45'}`}
|
||||||
>
|
>
|
||||||
{t('landing.pricing.annual')}
|
{t('landing.pricing.annual')}
|
||||||
<span className="absolute -top-3 -right-1 text-[10px] text-[#D4A373]">-20%</span>
|
<span className="absolute -top-3 -right-1 text-[10px] text-[#D4A373] whitespace-nowrap">
|
||||||
|
{t('landing.pricing.savePercent')}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -493,8 +515,19 @@ export function LandingPage() {
|
|||||||
<span className="text-3xl font-serif">{plan.price}</span>
|
<span className="text-3xl font-serif">{plan.price}</span>
|
||||||
{plan.period && <span className="text-xs text-white/35">{plan.period}</span>}
|
{plan.period && <span className="text-xs text-white/35">{plan.period}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
{plan.hasTrial && (
|
||||||
|
<p className="text-[11px] font-semibold text-[#D4A373] mb-3">
|
||||||
|
{t('landing.pricing.trialBadge', { days: trialDays })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="text-sm text-white/45 mb-6">{t(`landing.pricing.${plan.key}.desc`)}</p>
|
<p className="text-sm text-white/45 mb-6">{t(`landing.pricing.${plan.key}.desc`)}</p>
|
||||||
<ul className="space-y-2.5 mb-8 flex-1">
|
<ul className="space-y-2.5 mb-8 flex-1">
|
||||||
|
{plan.hasTrial && (
|
||||||
|
<li className="flex gap-2 text-xs text-[#D4A373]/90">
|
||||||
|
<Check size={12} className="text-[#D4A373] mt-0.5 shrink-0" />
|
||||||
|
{t('landing.pricing.trialFeature', { days: trialDays })}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
{[0, 1, 2, 3, 4, 5].map((j) => {
|
{[0, 1, 2, 3, 4, 5].map((j) => {
|
||||||
const feat = t(`landing.pricing.${plan.key}.feature${j}`)
|
const feat = t(`landing.pricing.${plan.key}.feature${j}`)
|
||||||
if (!feat || feat.startsWith('landing.')) return null
|
if (!feat || feat.startsWith('landing.')) return null
|
||||||
@@ -514,7 +547,9 @@ export function LandingPage() {
|
|||||||
: 'bg-white/10 text-white hover:bg-white/15'
|
: 'bg-white/10 text-white hover:bg-white/15'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t(`landing.pricing.${plan.key}.cta`)}
|
{plan.hasTrial
|
||||||
|
? t('landing.pricing.trialCta', { days: trialDays })
|
||||||
|
: t(`landing.pricing.${plan.key}.cta`)}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useActionState } from 'react';
|
import { useActionState, useRef, useState, Suspense } from 'react';
|
||||||
import { useFormStatus } from 'react-dom';
|
import { useFormStatus } from 'react-dom';
|
||||||
import { authenticate } from '@/app/actions/auth';
|
import { authenticate } from '@/app/actions/auth';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { Mail, Lock, ArrowRight, Sparkles } from 'lucide-react';
|
import { Mail, Lock, ArrowRight, Sparkles } from 'lucide-react';
|
||||||
import { useLanguage } from '@/lib/i18n';
|
import { useLanguage } from '@/lib/i18n';
|
||||||
import { GoogleSignInButton } from '@/components/google-sign-in-button';
|
import { GoogleSignInButton } from '@/components/google-sign-in-button';
|
||||||
|
import { resendSignupVerification } from '@/app/actions/auth-verify';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
function AuthDivider({ label }: { label: string }) {
|
function AuthDivider({ label }: { label: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -41,7 +44,7 @@ function LoginButton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoginForm({
|
function LoginFormInner({
|
||||||
allowRegister = true,
|
allowRegister = true,
|
||||||
googleAuthEnabled = false,
|
googleAuthEnabled = false,
|
||||||
authError,
|
authError,
|
||||||
@@ -51,7 +54,11 @@ export function LoginForm({
|
|||||||
authError?: string;
|
authError?: string;
|
||||||
}) {
|
}) {
|
||||||
const [errorMessage, dispatch] = useActionState(authenticate, undefined);
|
const [errorMessage, dispatch] = useActionState(authenticate, undefined);
|
||||||
const { t } = useLanguage();
|
const { t, language } = useLanguage();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const emailRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [resending, setResending] = useState(false);
|
||||||
|
const verified = searchParams.get('verified') === '1';
|
||||||
const oauthError =
|
const oauthError =
|
||||||
authError === 'SessionRequired'
|
authError === 'SessionRequired'
|
||||||
? t('auth.sessionExpired')
|
? t('auth.sessionExpired')
|
||||||
@@ -59,6 +66,21 @@ export function LoginForm({
|
|||||||
? t('auth.oauthAccountNotLinked')
|
? t('auth.oauthAccountNotLinked')
|
||||||
: authError ?? null;
|
: authError ?? null;
|
||||||
|
|
||||||
|
const showUnverified = errorMessage === 'EMAIL_NOT_VERIFIED';
|
||||||
|
|
||||||
|
const handleResend = async () => {
|
||||||
|
const email = emailRef.current?.value?.trim() ?? '';
|
||||||
|
if (!email) {
|
||||||
|
toast.error(t('auth.verifyMissingEmail'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResending(true);
|
||||||
|
const result = await resendSignupVerification(email, language);
|
||||||
|
setResending(false);
|
||||||
|
if (result.success) toast.success(t('auth.verifyResent'));
|
||||||
|
else toast.error(t('auth.verifyResendFailed'));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl">
|
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl">
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@@ -71,6 +93,12 @@ export function LoginForm({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{verified && (
|
||||||
|
<p className="text-sm text-emerald-600 dark:text-emerald-400 text-center px-2" role="status">
|
||||||
|
{t('auth.emailVerifiedBanner')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{oauthError && (
|
{oauthError && (
|
||||||
<p className="text-sm text-red-500 text-center px-2" role="alert">
|
<p className="text-sm text-red-500 text-center px-2" role="alert">
|
||||||
{oauthError}
|
{oauthError}
|
||||||
@@ -94,6 +122,7 @@ export function LoginForm({
|
|||||||
<Mail size={16} />
|
<Mail size={16} />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
ref={emailRef}
|
||||||
className="w-full bg-slate-50 dark:bg-white/5 border border-[var(--border)] rounded-2xl py-4 pl-12 pr-4 text-sm outline-none focus:border-[var(--color-brand-accent)] focus:ring-4 ring-[var(--color-brand-accent)]/5 transition-all"
|
className="w-full bg-slate-50 dark:bg-white/5 border border-[var(--border)] rounded-2xl py-4 pl-12 pr-4 text-sm outline-none focus:border-[var(--color-brand-accent)] focus:ring-4 ring-[var(--color-brand-accent)]/5 transition-all"
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -136,13 +165,26 @@ export function LoginForm({
|
|||||||
|
|
||||||
<LoginButton />
|
<LoginButton />
|
||||||
|
|
||||||
<div
|
<div className="space-y-2" aria-live="polite" aria-atomic="true">
|
||||||
className="flex h-8 items-end space-x-1"
|
{showUnverified && (
|
||||||
aria-live="polite"
|
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 space-y-2">
|
||||||
aria-atomic="true"
|
<p className="text-sm text-amber-800 dark:text-amber-200">{t('auth.emailNotVerified')}</p>
|
||||||
>
|
<button
|
||||||
{errorMessage && (
|
type="button"
|
||||||
<p className="text-sm text-red-500">{errorMessage}</p>
|
onClick={handleResend}
|
||||||
|
disabled={resending}
|
||||||
|
className="text-[11px] font-bold uppercase tracking-widest text-[var(--color-brand-accent)] hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{resending ? t('auth.sending') : t('auth.resendVerification')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{errorMessage && !showUnverified && (
|
||||||
|
<p className="text-sm text-red-500">
|
||||||
|
{errorMessage === 'Invalid credentials.'
|
||||||
|
? t('auth.invalidCredentials')
|
||||||
|
: errorMessage}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -164,3 +206,15 @@ export function LoginForm({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function LoginForm(props: {
|
||||||
|
allowRegister?: boolean;
|
||||||
|
googleAuthEnabled?: boolean;
|
||||||
|
authError?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="p-10 text-center text-sm text-muted-foreground">…</div>}>
|
||||||
|
<LoginFormInner {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,10 +24,19 @@ interface NoteEditorPeekHostProps {
|
|||||||
export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPeekHostProps) {
|
export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPeekHostProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
const peekNoteId = searchParams.get('peekNote')
|
||||||
const { t, language } = useLanguage()
|
const { t, language } = useLanguage()
|
||||||
const isRtl = language === 'fa' || language === 'ar'
|
const isRtl = language === 'fa' || language === 'ar'
|
||||||
const [peekState, setPeekState] = useState<{ note: Note; blockId?: string } | null>(null)
|
const [peekState, setPeekState] = useState<{ note: Note; blockId?: string } | null>(null)
|
||||||
|
|
||||||
|
const stripPeekFromUrl = useCallback(() => {
|
||||||
|
if (!searchParams.get('peekNote')) return
|
||||||
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
|
params.delete('peekNote')
|
||||||
|
const qs = params.toString()
|
||||||
|
router.replace(qs ? `/home?${qs}` : '/home', { scroll: false })
|
||||||
|
}, [router, searchParams])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onOpenPeek = (event: Event) => {
|
const onOpenPeek = (event: Event) => {
|
||||||
const detail = (event as CustomEvent<NotePeekOpenDetail>).detail
|
const detail = (event as CustomEvent<NotePeekOpenDetail>).detail
|
||||||
@@ -52,9 +61,25 @@ export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPee
|
|||||||
}
|
}
|
||||||
}, [noteId, t])
|
}, [noteId, t])
|
||||||
|
|
||||||
|
// Dashboard « Comparer / Lier » : ouvrir la note liée à droite dès que l’éditeur est monté.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!peekNoteId || peekNoteId === noteId) return
|
||||||
|
let cancelled = false
|
||||||
|
void getNoteById(peekNoteId).then((fetched) => {
|
||||||
|
if (cancelled) return
|
||||||
|
if (fetched) {
|
||||||
|
setPeekState(prev => (prev?.note.id === fetched.id ? prev : { note: fetched }))
|
||||||
|
} else {
|
||||||
|
toast.error(t('notePeek.loadFailed'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [peekNoteId, noteId, t])
|
||||||
|
|
||||||
const handleClosePeek = useCallback(() => {
|
const handleClosePeek = useCallback(() => {
|
||||||
setPeekState(null)
|
setPeekState(null)
|
||||||
}, [])
|
stripPeekFromUrl()
|
||||||
|
}, [stripPeekFromUrl])
|
||||||
|
|
||||||
const handleOpenPeekFully = useCallback(() => {
|
const handleOpenPeekFully = useCallback(() => {
|
||||||
if (!peekState) return
|
if (!peekState) return
|
||||||
@@ -63,6 +88,7 @@ export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPee
|
|||||||
}))
|
}))
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
params.set('openNote', peekState.note.id)
|
params.set('openNote', peekState.note.id)
|
||||||
|
params.delete('peekNote')
|
||||||
router.replace(params.toString() ? `/home?${params.toString()}` : '/home', { scroll: false })
|
router.replace(params.toString() ? `/home?${params.toString()}` : '/home', { scroll: false })
|
||||||
setPeekState(null)
|
setPeekState(null)
|
||||||
}, [noteId, peekState, router, searchParams])
|
}, [noteId, peekState, router, searchParams])
|
||||||
@@ -82,6 +108,11 @@ export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPee
|
|||||||
blockId={peekState.blockId}
|
blockId={peekState.blockId}
|
||||||
onClose={handleClosePeek}
|
onClose={handleClosePeek}
|
||||||
onOpenFully={handleOpenPeekFully}
|
onOpenFully={handleOpenPeekFully}
|
||||||
|
onBackToDashboard={
|
||||||
|
searchParams.get('from') === 'dashboard'
|
||||||
|
? () => router.replace('/home')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { X, Maximize2 } from 'lucide-react'
|
import { X, Maximize2, LayoutGrid } from 'lucide-react'
|
||||||
import type { Note } from '@/lib/types'
|
import type { Note } from '@/lib/types'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { NoteEditorProvider, useNoteEditorContext } from './note-editor-context'
|
import { NoteEditorProvider, useNoteEditorContext } from './note-editor-context'
|
||||||
@@ -17,6 +17,7 @@ interface NoteEditorSplitPeekProps {
|
|||||||
blockId?: string
|
blockId?: string
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onOpenFully: () => void
|
onOpenFully: () => void
|
||||||
|
onBackToDashboard?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function PeekEditorBody({ blockId }: { blockId?: string }) {
|
function PeekEditorBody({ blockId }: { blockId?: string }) {
|
||||||
@@ -54,7 +55,7 @@ function PeekEditorBody({ blockId }: { blockId?: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NoteEditorSplitPeek({ note, blockId, onClose, onOpenFully }: NoteEditorSplitPeekProps) {
|
export function NoteEditorSplitPeek({ note, blockId, onClose, onOpenFully, onBackToDashboard }: NoteEditorSplitPeekProps) {
|
||||||
const { t, language } = useLanguage()
|
const { t, language } = useLanguage()
|
||||||
const isRtl = language === 'fa' || language === 'ar'
|
const isRtl = language === 'fa' || language === 'ar'
|
||||||
|
|
||||||
@@ -77,6 +78,16 @@ export function NoteEditorSplitPeek({ note, blockId, onClose, onOpenFully }: Not
|
|||||||
{t('notePeek.label')}
|
{t('notePeek.label')}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{onBackToDashboard && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBackToDashboard}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wide text-ink dark:text-dark-ink hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
<LayoutGrid size={12} />
|
||||||
|
{t('notes.backToDashboard')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpenFully}
|
onClick={onOpenFully}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||||
|
import { useSearchParams } from 'next/navigation'
|
||||||
import { useNoteEditorContext } from './note-editor-context'
|
import { useNoteEditorContext } from './note-editor-context'
|
||||||
import { LabelManager } from '@/components/label-manager'
|
import { LabelManager } from '@/components/label-manager'
|
||||||
import { LabelBadge } from '@/components/label-badge'
|
import { LabelBadge } from '@/components/label-badge'
|
||||||
@@ -48,6 +49,8 @@ interface NoteEditorToolbarProps {
|
|||||||
export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachmentsCount }: NoteEditorToolbarProps) {
|
export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachmentsCount }: NoteEditorToolbarProps) {
|
||||||
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef, richTextEditorRef } = useNoteEditorContext()
|
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef, richTextEditorRef } = useNoteEditorContext()
|
||||||
const { t, language } = useLanguage()
|
const { t, language } = useLanguage()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const fromDashboard = searchParams.get('from') === 'dashboard' || Boolean(searchParams.get('peekNote'))
|
||||||
const { requestAiConsent } = useAiConsent()
|
const { requestAiConsent } = useAiConsent()
|
||||||
const [isConverting, setIsConverting] = useState(false)
|
const [isConverting, setIsConverting] = useState(false)
|
||||||
const [shareOpen, setShareOpen] = useState(false)
|
const [shareOpen, setShareOpen] = useState(false)
|
||||||
@@ -355,19 +358,24 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
|||||||
|
|
||||||
const handlePublishInteractivePage = async () => {
|
const handlePublishInteractivePage = async () => {
|
||||||
if (publishLoading) return
|
if (publishLoading) return
|
||||||
const consented = await requestAiConsent()
|
setPublishLoading(true)
|
||||||
if (!consented) return
|
try {
|
||||||
if (state.isDirty && !state.isSaving) {
|
const consented = await requestAiConsent()
|
||||||
await actions.handleSaveInPlace()
|
if (!consented) return
|
||||||
|
if (state.isDirty && !state.isSaving) {
|
||||||
|
await actions.handleSaveInPlace()
|
||||||
|
}
|
||||||
|
const html =
|
||||||
|
richTextEditorRef?.current?.getEditor()?.getHTML?.() ||
|
||||||
|
state.content ||
|
||||||
|
note.content ||
|
||||||
|
''
|
||||||
|
setInteractivePageContent(html)
|
||||||
|
setPublishOpen(false)
|
||||||
|
setInteractivePageOpen(true)
|
||||||
|
} finally {
|
||||||
|
setPublishLoading(false)
|
||||||
}
|
}
|
||||||
const html =
|
|
||||||
richTextEditorRef?.current?.getEditor()?.getHTML?.() ||
|
|
||||||
state.content ||
|
|
||||||
note.content ||
|
|
||||||
''
|
|
||||||
setInteractivePageContent(html)
|
|
||||||
setPublishOpen(false)
|
|
||||||
setInteractivePageOpen(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePublishWithAi = async () => {
|
const handlePublishWithAi = async () => {
|
||||||
@@ -564,7 +572,9 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
|||||||
className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity"
|
className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={18} />
|
<ArrowLeft size={18} />
|
||||||
<span className="text-sm font-medium">{t('notes.backToCollection')}</span>
|
<span className="text-sm font-medium">
|
||||||
|
{fromDashboard ? t('notes.backToDashboard') : t('notes.backToCollection')}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { toast } from 'sonner';
|
|||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { BillingHistory } from './billing-history';
|
import { BillingHistory } from './billing-history';
|
||||||
|
import { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants';
|
||||||
|
|
||||||
type Tier = 'PRO' | 'BUSINESS';
|
type Tier = 'PRO' | 'BUSINESS';
|
||||||
type Interval = 'month' | 'year';
|
type Interval = 'month' | 'year';
|
||||||
@@ -22,6 +23,9 @@ interface BillingStatus {
|
|||||||
currentPeriodEnd: string | null;
|
currentPeriodEnd: string | null;
|
||||||
cancelAtPeriodEnd: boolean;
|
cancelAtPeriodEnd: boolean;
|
||||||
hasStripeSubscription: boolean;
|
hasStripeSubscription: boolean;
|
||||||
|
trialEndsAt?: string | null;
|
||||||
|
trialEligible?: boolean;
|
||||||
|
trialDays?: number;
|
||||||
billingEnabled?: boolean;
|
billingEnabled?: boolean;
|
||||||
prices?: {
|
prices?: {
|
||||||
PRO: {
|
PRO: {
|
||||||
@@ -250,6 +254,12 @@ export function BillingPlans() {
|
|||||||
|
|
||||||
const effectiveTier = status?.effectiveTier ?? 'BASIC';
|
const effectiveTier = status?.effectiveTier ?? 'BASIC';
|
||||||
const isPaid = effectiveTier !== 'BASIC';
|
const isPaid = effectiveTier !== 'BASIC';
|
||||||
|
const isTrialing = (status?.status ?? '').toUpperCase() === 'TRIALING';
|
||||||
|
const trialEligible = !!status?.trialEligible;
|
||||||
|
const trialDays = status?.trialDays ?? SUBSCRIPTION_TRIAL_DAYS;
|
||||||
|
|
||||||
|
const trialCta = (fallback: string) =>
|
||||||
|
trialEligible ? t('billing.startTrialCta', { days: trialDays }) : fallback;
|
||||||
|
|
||||||
const plans = [
|
const plans = [
|
||||||
{
|
{
|
||||||
@@ -280,6 +290,7 @@ export function BillingPlans() {
|
|||||||
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
|
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
|
||||||
description: t('billing.proDescription') || 'Pour les consultants et créateurs exigeants.',
|
description: t('billing.proDescription') || 'Pour les consultants et créateurs exigeants.',
|
||||||
features: [
|
features: [
|
||||||
|
...(trialEligible ? [t('billing.trialFeature', { days: trialDays })] : []),
|
||||||
t('billing.proFeature1'),
|
t('billing.proFeature1'),
|
||||||
t('billing.proFeature2'),
|
t('billing.proFeature2'),
|
||||||
t('billing.proFeature3'),
|
t('billing.proFeature3'),
|
||||||
@@ -289,7 +300,7 @@ export function BillingPlans() {
|
|||||||
],
|
],
|
||||||
current: effectiveTier === 'PRO',
|
current: effectiveTier === 'PRO',
|
||||||
popular: true,
|
popular: true,
|
||||||
buttonText: effectiveTier === 'PRO' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.proCta') || 'Passer au Plan Pro'),
|
buttonText: effectiveTier === 'PRO' ? (t('billing.currentPlan') || 'Plan Actuel') : trialCta(t('billing.proCta') || 'Passer au Plan Pro'),
|
||||||
buttonClass: effectiveTier === 'PRO'
|
buttonClass: effectiveTier === 'PRO'
|
||||||
? 'bg-paper text-concrete cursor-default'
|
? 'bg-paper text-concrete cursor-default'
|
||||||
: 'bg-brand-accent text-white shadow-xl shadow-brand-accent/20 hover:scale-[1.02] active:scale-95',
|
: 'bg-brand-accent text-white shadow-xl shadow-brand-accent/20 hover:scale-[1.02] active:scale-95',
|
||||||
@@ -302,6 +313,7 @@ export function BillingPlans() {
|
|||||||
(interval === 'month' ? (t('billing.businessPrice') || '29,90€') : (t('billing.businessAnnualPrice') || '299€')),
|
(interval === 'month' ? (t('billing.businessPrice') || '29,90€') : (t('billing.businessAnnualPrice') || '299€')),
|
||||||
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
|
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
|
||||||
features: [
|
features: [
|
||||||
|
...(trialEligible ? [t('billing.trialFeature', { days: trialDays })] : []),
|
||||||
t('billing.businessFeature1'),
|
t('billing.businessFeature1'),
|
||||||
t('billing.businessFeature2'),
|
t('billing.businessFeature2'),
|
||||||
t('billing.businessFeature3'),
|
t('billing.businessFeature3'),
|
||||||
@@ -310,7 +322,7 @@ export function BillingPlans() {
|
|||||||
t('billing.businessFeature6'),
|
t('billing.businessFeature6'),
|
||||||
],
|
],
|
||||||
current: effectiveTier === 'BUSINESS',
|
current: effectiveTier === 'BUSINESS',
|
||||||
buttonText: effectiveTier === 'BUSINESS' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.businessCta') || 'Choisir Plan Business'),
|
buttonText: effectiveTier === 'BUSINESS' ? (t('billing.currentPlan') || 'Plan Actuel') : trialCta(t('billing.businessCta') || 'Choisir Plan Business'),
|
||||||
buttonClass: effectiveTier === 'BUSINESS'
|
buttonClass: effectiveTier === 'BUSINESS'
|
||||||
? 'bg-paper text-concrete cursor-default'
|
? 'bg-paper text-concrete cursor-default'
|
||||||
: 'bg-ink text-white shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
|
: 'bg-ink text-white shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
|
||||||
@@ -415,9 +427,11 @@ export function BillingPlans() {
|
|||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest',
|
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest',
|
||||||
status?.status === 'active' || status?.status === 'ACTIVE'
|
isTrialing
|
||||||
? 'bg-primary/10 text-primary/80 dark:text-primary border border-primary/20'
|
? 'bg-sky-500/10 text-sky-700 dark:text-sky-300 border border-sky-500/20'
|
||||||
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20'
|
: status?.status === 'active' || status?.status === 'ACTIVE'
|
||||||
|
? 'bg-primary/10 text-primary/80 dark:text-primary border border-primary/20'
|
||||||
|
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20'
|
||||||
)}>
|
)}>
|
||||||
{status?.status ? t(`billing.${status.status.toLowerCase()}`) || status.status : t('billing.active')}
|
{status?.status ? t(`billing.${status.status.toLowerCase()}`) || status.status : t('billing.active')}
|
||||||
</span>
|
</span>
|
||||||
@@ -425,6 +439,12 @@ export function BillingPlans() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isTrialing && status?.trialEndsAt && (
|
||||||
|
<p className="text-xs text-sky-700 dark:text-sky-300 bg-sky-500/10 border border-sky-500/20 rounded-xl px-3 py-2">
|
||||||
|
{t('billing.trialEndsOn', { date: formatDate(status.trialEndsAt) })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{isPaid && (
|
{isPaid && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-4 border-t border-border/40">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-4 border-t border-border/40">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -439,10 +459,18 @@ export function BillingPlans() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-[10px] text-concrete uppercase tracking-wider">
|
<span className="text-[10px] text-concrete uppercase tracking-wider">
|
||||||
{status?.cancelAtPeriodEnd ? t('billing.expiresOn') : t('billing.nextBillingDate')}
|
{isTrialing
|
||||||
|
? t('billing.trialEndsLabel')
|
||||||
|
: status?.cancelAtPeriodEnd
|
||||||
|
? t('billing.expiresOn')
|
||||||
|
: t('billing.nextBillingDate')}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-xs font-semibold text-ink">
|
<p className="text-xs font-semibold text-ink">
|
||||||
{status?.currentPeriodEnd ? formatDate(status.currentPeriodEnd) : '—'}
|
{isTrialing && status?.trialEndsAt
|
||||||
|
? formatDate(status.trialEndsAt)
|
||||||
|
: status?.currentPeriodEnd
|
||||||
|
? formatDate(status.currentPeriodEnd)
|
||||||
|
: '—'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -619,6 +647,7 @@ export function BillingPlans() {
|
|||||||
brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
|
brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
|
||||||
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
|
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
|
||||||
suggest_charts: t('usageMeter.featureCharts'),
|
suggest_charts: t('usageMeter.featureCharts'),
|
||||||
|
interactive_demo: t('usageMeter.featureInteractiveDemo'),
|
||||||
publish_enhance: t('usageMeter.featurePublishEnhance'),
|
publish_enhance: t('usageMeter.featurePublishEnhance'),
|
||||||
ai_flashcard: t('usageMeter.featureFlashcards'),
|
ai_flashcard: t('usageMeter.featureFlashcards'),
|
||||||
voice_transcribe: t('usageMeter.featureVoice'),
|
voice_transcribe: t('usageMeter.featureVoice'),
|
||||||
|
|||||||
@@ -1433,7 +1433,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
label: t('nav.dashboard') || 'Dashboard',
|
label: t('nav.dashboard') || 'Dashboard',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setActiveView('dashboard')
|
setActiveView('dashboard')
|
||||||
router.push('/home')
|
router.replace('/home')
|
||||||
},
|
},
|
||||||
isActive: isDashboardRoute,
|
isActive: isDashboardRoute,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -63,14 +63,16 @@ export function GenericFormulaView({
|
|||||||
const xParam = sim.params.find((p) => p.id === visual.xParamId)
|
const xParam = sim.params.find((p) => p.id === visual.xParamId)
|
||||||
const parsed = parseSimExpr(visual.expr)
|
const parsed = parseSimExpr(visual.expr)
|
||||||
if (!xParam || 'message' in parsed) return null
|
if (!xParam || 'message' in parsed) return null
|
||||||
|
// Full env: params + chained computed (visual.expr may reference computed ids)
|
||||||
|
const fullEnv = { ...values, ...results }
|
||||||
const pts: { x: number; y: number }[] = []
|
const pts: { x: number; y: number }[] = []
|
||||||
for (let i = 0; i <= CURVE_SAMPLES; i++) {
|
for (let i = 0; i <= CURVE_SAMPLES; i++) {
|
||||||
const x = xParam.min + ((xParam.max - xParam.min) * i) / CURVE_SAMPLES
|
const x = xParam.min + ((xParam.max - xParam.min) * i) / CURVE_SAMPLES
|
||||||
const y = parsed.evaluate({ ...values, [xParam.id]: x })
|
const y = parsed.evaluate({ ...fullEnv, [xParam.id]: x })
|
||||||
pts.push({ x: Number(x.toFixed(4)), y: Number.isFinite(y) ? Number(y.toFixed(6)) : 0 })
|
pts.push({ x: Number(x.toFixed(4)), y: Number.isFinite(y) ? Number(y.toFixed(6)) : 0 })
|
||||||
}
|
}
|
||||||
return { pts, xParam, currentX: values[xParam.id], currentY: parsed.evaluate(values) }
|
return { pts, xParam, currentX: values[xParam.id], currentY: parsed.evaluate(fullEnv) }
|
||||||
}, [visual, sim.params, values])
|
}, [visual, sim.params, values, results])
|
||||||
|
|
||||||
const accent = intentColor('highlight', dark)
|
const accent = intentColor('highlight', dark)
|
||||||
const gridStroke = dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'
|
const gridStroke = dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type {
|
|||||||
PageValidationIssue,
|
PageValidationIssue,
|
||||||
} from '@/lib/interactive-page'
|
} from '@/lib/interactive-page'
|
||||||
|
|
||||||
export type PagePlanDemoKind = 'svg-scene' | 'chart' | 'heatmap-matrix' | 'simulation' | 'none'
|
export type PagePlanDemoKind = 'steps' | 'svg-scene' | 'chart' | 'heatmap-matrix' | 'simulation' | 'none'
|
||||||
|
|
||||||
export type PagePlanSection = {
|
export type PagePlanSection = {
|
||||||
title: string
|
title: string
|
||||||
|
|||||||
@@ -47,131 +47,7 @@ function chunkSentences(text: string): string[] {
|
|||||||
.filter((s) => s.length > 20)
|
.filter((s) => s.length > 20)
|
||||||
}
|
}
|
||||||
|
|
||||||
const INTENT_CYCLE = ['compute', 'flow', 'output', 'cache'] as const
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Instant Play/Step demo from note vocabulary — no LLM.
|
|
||||||
* 3–4 nodes + spotlight steps; formulas in speak when available.
|
|
||||||
*/
|
|
||||||
export function buildDeterministicDemo(
|
|
||||||
content: string,
|
|
||||||
lang: string,
|
|
||||||
assets: ReturnType<typeof extractSourceAssets>
|
|
||||||
): InteractiveDemoV1 | null {
|
|
||||||
const fr = lang.startsWith('fr')
|
|
||||||
const plain = stripToPlain(content)
|
|
||||||
const sentences = [
|
|
||||||
...assets.keySentences,
|
|
||||||
...chunkSentences(plain),
|
|
||||||
].filter((s, i, a) => a.indexOf(s) === i)
|
|
||||||
|
|
||||||
// Prefer short phrase labels from key sentences — never raw formula fragments
|
|
||||||
const labels: string[] = []
|
|
||||||
for (const s of sentences.slice(0, 8)) {
|
|
||||||
const words = s
|
|
||||||
.replace(/\$[^$]*\$/g, ' ')
|
|
||||||
.replace(/[\\{}]/g, ' ')
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 4)
|
|
||||||
.join(' ')
|
|
||||||
.trim()
|
|
||||||
if (words.length >= 6 && words.length <= 40 && !labels.includes(words)) {
|
|
||||||
labels.push(words)
|
|
||||||
}
|
|
||||||
if (labels.length >= 4) break
|
|
||||||
}
|
|
||||||
// Clean formulas usable inside $…$ (KaTeX eats spaces → no prose, bounded)
|
|
||||||
const cleanFormulas = assets.formulas.filter((f) => f.length <= 80)
|
|
||||||
if (labels.length < 3) {
|
|
||||||
const fallbacks = fr
|
|
||||||
? ['Entrée', 'Transformation', 'Résultat', 'Retour']
|
|
||||||
: ['Input', 'Transform', 'Output', 'Loop']
|
|
||||||
for (const fb of fallbacks) {
|
|
||||||
if (labels.length >= 4) break
|
|
||||||
if (!labels.includes(fb)) labels.push(fb)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
while (labels.length < 3) labels.push(`Étape ${labels.length + 1}`)
|
|
||||||
const nodeCount = Math.min(4, Math.max(3, labels.length))
|
|
||||||
|
|
||||||
const nodes = labels.slice(0, nodeCount).map((label, i) => ({
|
|
||||||
id: `n${i + 1}`,
|
|
||||||
label:
|
|
||||||
cleanFormulas[i] && i < 2
|
|
||||||
? `${i + 1} · ${label.split('\n')[0]}\n$${cleanFormulas[i]}$`
|
|
||||||
: `${i + 1} · ${label}`,
|
|
||||||
intent: INTENT_CYCLE[i % INTENT_CYCLE.length],
|
|
||||||
}))
|
|
||||||
|
|
||||||
const edges = nodes.map((n, i) => {
|
|
||||||
const next = nodes[(i + 1) % nodes.length]
|
|
||||||
return {
|
|
||||||
id: `e${i + 1}`,
|
|
||||||
from: n.id,
|
|
||||||
to: next.id,
|
|
||||||
style: 'solid' as const,
|
|
||||||
intent: 'flow' as const,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const steps = nodes.map((n, i) => {
|
|
||||||
const formula = cleanFormulas[i]
|
|
||||||
const speakBase =
|
|
||||||
sentences[i]?.slice(0, 100) ||
|
|
||||||
(fr ? `Étape **${i + 1}** du mécanisme.` : `Step **${i + 1}** of the mechanism.`)
|
|
||||||
const speak = formula
|
|
||||||
? `${speakBase.split('.')[0]}. $${formula}$`
|
|
||||||
: speakBase
|
|
||||||
const revealed = nodes.slice(0, i + 1).map((x) => x.id)
|
|
||||||
if (i > 0) revealed.push(edges[i - 1].id)
|
|
||||||
const isLast = i === nodes.length - 1
|
|
||||||
return {
|
|
||||||
id: `a1.s${i + 1}`,
|
|
||||||
speak: speak.slice(0, 160),
|
|
||||||
pattern: isLast ? ('overview' as const) : ('spotlightTour' as const),
|
|
||||||
pointTo: [n.id],
|
|
||||||
reveal: [{ ids: isLast ? nodes.map((x) => x.id).concat(edges.map((e) => e.id)) : revealed, scope: 'act' as const }],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const raw = {
|
|
||||||
schemaVersion: 1 as const,
|
|
||||||
id: 'demo.page-auto',
|
|
||||||
lang,
|
|
||||||
disclaimer: fr
|
|
||||||
? 'Schéma pédagogique généré depuis la note — valeurs illustratives.'
|
|
||||||
: 'Pedagogical diagram from your note — illustrative values.',
|
|
||||||
scene: {
|
|
||||||
id: 'scene.main',
|
|
||||||
panels: [
|
|
||||||
{
|
|
||||||
id: 'panel.main',
|
|
||||||
type: 'svg-scene' as const,
|
|
||||||
payload: { nodes, edges },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
acts: [
|
|
||||||
{
|
|
||||||
id: 'a1',
|
|
||||||
title: fr ? 'Parcours' : 'Walkthrough',
|
|
||||||
pattern: 'flowTrace' as const,
|
|
||||||
steps,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const validated = validateInteractiveDemo(raw)
|
|
||||||
if (!validated.ok) {
|
|
||||||
console.warn(
|
|
||||||
'[interactive-page] deterministic demo invalid',
|
|
||||||
validated.issues.slice(0, 5)
|
|
||||||
)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return validated.demo
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildPageFromNote(
|
export function buildPageFromNote(
|
||||||
content: string,
|
content: string,
|
||||||
@@ -296,7 +172,7 @@ export function buildPageFromNote(
|
|||||||
|
|
||||||
function injectDemo(
|
function injectDemo(
|
||||||
page: Record<string, unknown>,
|
page: Record<string, unknown>,
|
||||||
demo: unknown
|
block: Record<string, unknown>
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const sections = Array.isArray(page.sections)
|
const sections = Array.isArray(page.sections)
|
||||||
? ([...page.sections] as Record<string, unknown>[])
|
? ([...page.sections] as Record<string, unknown>[])
|
||||||
@@ -307,7 +183,7 @@ function injectDemo(
|
|||||||
const blocks = Array.isArray(target.blocks)
|
const blocks = Array.isArray(target.blocks)
|
||||||
? [...(target.blocks as Record<string, unknown>[])]
|
? [...(target.blocks as Record<string, unknown>[])]
|
||||||
: []
|
: []
|
||||||
blocks.push({ type: 'demo', demo, caption: 'Démo interactive' })
|
blocks.push(block)
|
||||||
target.blocks = blocks
|
target.blocks = blocks
|
||||||
sections[targetIdx] = target
|
sections[targetIdx] = target
|
||||||
return { ...page, sections }
|
return { ...page, sections }
|
||||||
@@ -332,8 +208,43 @@ export type GenerateInteractivePageResult =
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instant reliable page: deterministic skeleton + deterministic Play/Step demo.
|
* Deterministic step-by-step block from extracted formulas (math notes).
|
||||||
* No LLM round-trip for the page itself (LLM demos were timing out past client abort).
|
* Replaces the old generic box-diagram fallback — boxes are banned.
|
||||||
|
*/
|
||||||
|
function buildDeterministicSteps(
|
||||||
|
content: string,
|
||||||
|
lang: string,
|
||||||
|
assets: ReturnType<typeof extractSourceAssets>
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
const fr = lang.startsWith('fr')
|
||||||
|
const formulas = assets.formulas.filter((f) => f.length <= 120).slice(0, 6)
|
||||||
|
if (formulas.length < 3) return null
|
||||||
|
const plain = stripToPlain(content)
|
||||||
|
const sentences = [
|
||||||
|
...assets.keySentences,
|
||||||
|
...chunkSentences(plain),
|
||||||
|
].filter((s, i, a) => a.indexOf(s) === i)
|
||||||
|
return {
|
||||||
|
type: 'steps',
|
||||||
|
title: fr ? 'Dérivation pas à pas' : 'Step-by-step derivation',
|
||||||
|
steps: formulas.map((tex, i) => ({
|
||||||
|
tex,
|
||||||
|
...(i === 0
|
||||||
|
? { rule: fr ? 'Point de départ' : 'Starting point' }
|
||||||
|
: {}),
|
||||||
|
speak:
|
||||||
|
sentences[i]?.slice(0, 120) ||
|
||||||
|
(fr ? `Étape **${i + 1}** de la dérivation.` : `Step **${i + 1}** of the derivation.`),
|
||||||
|
})),
|
||||||
|
caption: fr
|
||||||
|
? 'Formules extraites de la note, déroulées pas à pas.'
|
||||||
|
: 'Formulas extracted from the note, walked through step by step.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instant reliable page: deterministic skeleton + deterministic steps (math)
|
||||||
|
* or Play/Step demo (other content). No LLM round-trip for the page itself.
|
||||||
*/
|
*/
|
||||||
export async function generateInteractivePageFromContent(
|
export async function generateInteractivePageFromContent(
|
||||||
input: GenerateInteractivePageInput
|
input: GenerateInteractivePageInput
|
||||||
@@ -351,9 +262,9 @@ export async function generateInteractivePageFromContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let pageObj = buildPageFromNote(input.content, lang, assets)
|
let pageObj = buildPageFromNote(input.content, lang, assets)
|
||||||
const demo = buildDeterministicDemo(input.content, lang, assets)
|
const stepsBlock = buildDeterministicSteps(input.content, lang, assets)
|
||||||
if (demo) {
|
if (stepsBlock) {
|
||||||
pageObj = injectDemo(pageObj, demo)
|
pageObj = injectDemo(pageObj, stepsBlock)
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = normalizeInteractivePageCandidate(pageObj, lang)
|
const normalized = normalizeInteractivePageCandidate(pageObj, lang)
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
import { catalogForPrompt } from '@/lib/simulators'
|
import { catalogForPrompt } from '@/lib/simulators'
|
||||||
import thermoFixture from '@/lib/interactive-page/fixtures/thermo-page.json'
|
import thermoFixture from '@/lib/interactive-page/fixtures/thermo-page.json'
|
||||||
|
|
||||||
const MAX_ATTEMPTS = 2
|
const MAX_ATTEMPTS = 3
|
||||||
|
|
||||||
// ── Shared helpers ───────────────────────────────────────────────────────────
|
// ── Shared helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -124,19 +124,19 @@ function fixtureDemo(panelType: string): string | null {
|
|||||||
|
|
||||||
// ── 1. Page plan ─────────────────────────────────────────────────────────────
|
// ── 1. Page plan ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const DEMO_KINDS = ['svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none'] as const
|
const DEMO_KINDS = ['steps', 'svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none'] as const
|
||||||
export type PagePlanDemoKind = (typeof DEMO_KINDS)[number]
|
export type PagePlanDemoKind = (typeof DEMO_KINDS)[number]
|
||||||
|
|
||||||
const planSectionSchema = z.object({
|
const planSectionSchema = z.object({
|
||||||
title: z.string().min(1),
|
title: z.string().min(1),
|
||||||
goal: z.string().min(1),
|
goal: z.string().min(1),
|
||||||
demoKind: z.enum(DEMO_KINDS),
|
demoKind: z.enum(DEMO_KINDS),
|
||||||
demoGoal: z.string().optional(),
|
demoGoal: z.string().nullish(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const pagePlanSchema = z.object({
|
const pagePlanSchema = z.object({
|
||||||
heroTitle: z.string().min(1),
|
heroTitle: z.string().min(1),
|
||||||
heroSubtitle: z.string().optional(),
|
heroSubtitle: z.string().nullish(),
|
||||||
overviewLead: z.string().min(1),
|
overviewLead: z.string().min(1),
|
||||||
overviewCards: z
|
overviewCards: z
|
||||||
.array(
|
.array(
|
||||||
@@ -144,7 +144,7 @@ const pagePlanSchema = z.object({
|
|||||||
badge: z.string().min(1),
|
badge: z.string().min(1),
|
||||||
title: z.string().min(1),
|
title: z.string().min(1),
|
||||||
body: z.string().min(1),
|
body: z.string().min(1),
|
||||||
intent: z.enum(INTENT_IDS).optional(),
|
intent: z.enum(INTENT_IDS).nullish(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.min(INTERACTIVE_PAGE_CAPS.minOverviewCards)
|
.min(INTERACTIVE_PAGE_CAPS.minOverviewCards)
|
||||||
@@ -170,7 +170,7 @@ SCHEMA:
|
|||||||
"heroTitle": string, // the SUBJECT of the note, never a generic title
|
"heroTitle": string, // the SUBJECT of the note, never a generic title
|
||||||
"heroSubtitle": string, // one sentence, autoportant
|
"heroSubtitle": string, // one sentence, autoportant
|
||||||
"overviewLead": string, // the central idea in ONE self-contained paragraph (may use $KaTeX$ inline)
|
"overviewLead": string, // the central idea in ONE self-contained paragraph (may use $KaTeX$ inline)
|
||||||
"overviewCards": [ { "badge": string, "title": string, "body": string, "intent?": ${JSON.stringify(INTENT_IDS)} } ], // 2–4 key concepts, small-caps badges (ex. PROBLEM / APPROACH / RESULT)
|
"overviewCards": [ { "badge": string, "title": string, "body": string, "intent?": ${JSON.stringify(INTENT_IDS)} } ], // 3–4 key concepts, small-caps badges (ex. PROBLEM / APPROACH / RESULT)
|
||||||
"sections": [ { "title": string, "goal": string, "demoKind": ${JSON.stringify(DEMO_KINDS)}, "demoGoal?": string } ] // 2–5
|
"sections": [ { "title": string, "goal": string, "demoKind": ${JSON.stringify(DEMO_KINDS)}, "demoGoal?": string } ] // 2–5
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +181,7 @@ COUVERTURE (règle n°1):
|
|||||||
- If the content does not benefit from an interactive page, REFUSE: return { "error": "unsuitable_content", "reason": "…" } instead.
|
- If the content does not benefit from an interactive page, REFUSE: return { "error": "unsuitable_content", "reason": "…" } instead.
|
||||||
|
|
||||||
MATCHING CONTENU → DÉMO (choose demoKind per section, "none" when no visual helps):
|
MATCHING CONTENU → DÉMO (choose demoKind per section, "none" when no visual helps):
|
||||||
|
- Dérivation, démonstration, résolution d'équation, calcul pas à pas (maths, physique) → "steps" — JAMAIS de svg-scene pour du contenu mathématique
|
||||||
- Catégories × propriétés (comparatif, matrice, échanges) → "heatmap-matrix"
|
- Catégories × propriétés (comparatif, matrice, échanges) → "heatmap-matrix"
|
||||||
- Loi / relation / courbe / évolution chiffrée → "chart"
|
- Loi / relation / courbe / évolution chiffrée → "chart"
|
||||||
- Processus / flux / cycle / architecture → "svg-scene"
|
- Processus / flux / cycle / architecture → "svg-scene"
|
||||||
@@ -298,8 +299,14 @@ Block types (discriminated by "type"):
|
|||||||
- { "type": "table", "columns": string[], "rows": string[][], "caption?": string } // every row.length === columns.length
|
- { "type": "table", "columns": string[], "rows": string[][], "caption?": string } // every row.length === columns.length
|
||||||
- { "type": "demo", "demo": InteractiveDemoV1, "caption?": string }
|
- { "type": "demo", "demo": InteractiveDemoV1, "caption?": string }
|
||||||
- { "type": "sim", "sim": SimRef, "caption?": string } // interactive simulation with sliders
|
- { "type": "sim", "sim": SimRef, "caption?": string } // interactive simulation with sliders
|
||||||
|
- { "type": "steps", "title?": string, "steps": [{ "tex": string, "rule?": string, "speak?": string }] (2–12), "caption?": string } // step-by-step derivation: tex = KaTeX of the equation state, rule = transformation applied (short, e.g. "on sépare les variables")
|
||||||
IntentId: ${JSON.stringify(INTENT_IDS)}
|
IntentId: ${JSON.stringify(INTENT_IDS)}
|
||||||
|
|
||||||
|
BLOCK "steps" (Symbolab-style derivation) — MANDATORY for math/derivation content:
|
||||||
|
- Each step = the equation state AFTER applying the rule; steps must chain logically (each follows from the previous).
|
||||||
|
- rule = the transformation applied to reach THIS state (short verb phrase). speak = 1 sentence teacher narration.
|
||||||
|
- FORBIDDEN: using "demo" svg-scene (boxes with arrows) for equations, derivations, proofs, or calculus content — always "steps" instead.
|
||||||
|
|
||||||
SimRef — TWO forms:
|
SimRef — TWO forms:
|
||||||
(A) CATALOG simulator (PREFERRED when the section matches one): { "simId": "<id from catalog>", "title?": string, "preset?": { "<paramId>": number }, "disclaimer?": string }
|
(A) CATALOG simulator (PREFERRED when the section matches one): { "simId": "<id from catalog>", "title?": string, "preset?": { "<paramId>": number }, "disclaimer?": string }
|
||||||
→ You ONLY pick the simId and preset values (from the note's real numbers within the allowed ranges). The app runs the simulation.
|
→ You ONLY pick the simId and preset values (from the note's real numbers within the allowed ranges). The app runs the simulation.
|
||||||
@@ -347,13 +354,16 @@ function buildSectionUserPrompt(
|
|||||||
|
|
||||||
SECTION_GOAL: ${section.goal}
|
SECTION_GOAL: ${section.goal}
|
||||||
${
|
${
|
||||||
section.demoKind === 'simulation'
|
section.demoKind === 'steps'
|
||||||
? `SIMULATION_REQUIRED: include ONE "sim" block. Prefer a catalog simulator if the section matches one (bind the note's real values into "preset"); otherwise "generic-formula" with the section's key relation.
|
? `STEPS_REQUIRED: include ONE "steps" block — the step-by-step derivation of this section's key result. Real equations from the note, logically chained, a short "rule" per step.
|
||||||
|
STEPS_GOAL: ${section.demoGoal || section.goal}`
|
||||||
|
: section.demoKind === 'simulation'
|
||||||
|
? `SIMULATION_REQUIRED: include ONE "sim" block. Prefer a catalog simulator if the section matches one (bind the note's real values into "preset"); otherwise "generic-formula" with the section's key relation.
|
||||||
SIM_GOAL: ${section.demoGoal || section.goal}`
|
SIM_GOAL: ${section.demoGoal || section.goal}`
|
||||||
: section.demoKind !== 'none'
|
: section.demoKind !== 'none'
|
||||||
? `DEMO_REQUIRED: include ONE "demo" block of kind "${section.demoKind}".
|
? `DEMO_REQUIRED: include ONE "demo" block of kind "${section.demoKind}".
|
||||||
DEMO_GOAL: ${section.demoGoal || section.goal}`
|
DEMO_GOAL: ${section.demoGoal || section.goal}`
|
||||||
: `NO demo/sim block for this section — rich prose/formula/callout/stats/table only.`
|
: `NO demo/sim/steps block for this section — rich prose/formula/callout/stats/table only.`
|
||||||
}
|
}
|
||||||
|
|
||||||
EXCERPT_START
|
EXCERPT_START
|
||||||
@@ -396,8 +406,10 @@ function validateSectionCandidate(
|
|||||||
hero: { kicker: 'CHECK', title: 'Section check' },
|
hero: { kicker: 'CHECK', title: 'Section check' },
|
||||||
sections: [
|
sections: [
|
||||||
typeof candidate === 'object' && candidate !== null
|
typeof candidate === 'object' && candidate !== null
|
||||||
? { id: sectionId, ...(candidate as Record<string, unknown>) }
|
? { ...(candidate as Record<string, unknown>), id: sectionId }
|
||||||
: candidate,
|
: candidate,
|
||||||
|
// schema requires ≥2 sections — inert filler for the wrap check
|
||||||
|
{ id: 's99', title: '—', blocks: [{ type: 'prose', md: '—' }] },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
const normalized = normalizeInteractivePageCandidate(wrapped, lang)
|
const normalized = normalizeInteractivePageCandidate(wrapped, lang)
|
||||||
|
|||||||
@@ -214,6 +214,32 @@ export function normalizeSlideDeck(input: {
|
|||||||
}
|
}
|
||||||
return { ...s, stats: valid.slice(0, 4) }
|
return { ...s, stats: valid.slice(0, 4) }
|
||||||
}
|
}
|
||||||
|
// Equation without real formulas → degrade to bullets (avoid shipping f(x)=?)
|
||||||
|
if (s.type === 'equation') {
|
||||||
|
const eqs = Array.isArray(s.equations) ? s.equations : []
|
||||||
|
const real = eqs.filter((eq: any) => String(eq?.latex || '').trim() && !/f\s*\(\s*x\s*\)\s*=\s*\?/i.test(String(eq.latex)))
|
||||||
|
if (real.length === 0) {
|
||||||
|
return {
|
||||||
|
type: 'bullets',
|
||||||
|
title: trimStr(s.title || 'Points clés', 90),
|
||||||
|
items: cleanList(
|
||||||
|
[...(s.explanation ? [String(s.explanation)] : []), ...eqs.map((eq: any) => eq?.label).filter(Boolean)],
|
||||||
|
4,
|
||||||
|
140,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...s, equations: real.slice(0, 4) }
|
||||||
|
}
|
||||||
|
// Image without URL → degrade to bullets (avoid placeholder in final deck)
|
||||||
|
if (s.type === 'image' && !String(s.url || '').trim()) {
|
||||||
|
const caption = String(s.caption || '').trim()
|
||||||
|
return {
|
||||||
|
type: 'bullets',
|
||||||
|
title: trimStr(s.title || 'Illustration', 90),
|
||||||
|
items: caption ? [caption] : cleanList(s.items, 3, 140),
|
||||||
|
}
|
||||||
|
}
|
||||||
return s
|
return s
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const SlideTypeEnum = z.enum([
|
|||||||
'chart',
|
'chart',
|
||||||
'table',
|
'table',
|
||||||
'quote',
|
'quote',
|
||||||
|
'image',
|
||||||
'summary',
|
'summary',
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -61,6 +62,8 @@ const OutlineSlideSchema = z.object({
|
|||||||
keyPoints: z.array(z.string()).min(1).max(6),
|
keyPoints: z.array(z.string()).min(1).max(6),
|
||||||
/** For equation slides: latex strings to include */
|
/** For equation slides: latex strings to include */
|
||||||
formulas: z.array(z.string()).optional(),
|
formulas: z.array(z.string()).optional(),
|
||||||
|
/** For image slides: 0-based index into assets.images */
|
||||||
|
imageIndex: z.number().optional(),
|
||||||
narrativeRole: z.enum(['opening', 'evidence', 'transition', 'conclusion', 'data']).optional(),
|
narrativeRole: z.enum(['opening', 'evidence', 'transition', 'conclusion', 'data']).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -96,6 +99,8 @@ const ExpandedSlideSchema = z.object({
|
|||||||
author: z.string().optional(),
|
author: z.string().optional(),
|
||||||
context: z.string().optional(),
|
context: z.string().optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
|
url: z.string().optional(),
|
||||||
|
caption: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export interface GenerateSlideDeckParams {
|
export interface GenerateSlideDeckParams {
|
||||||
@@ -138,6 +143,51 @@ const LANG_NAMES: Record<string, string> = {
|
|||||||
hi: 'Hindi',
|
hi: 'Hindi',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Detect content language from text via Unicode script ranges + Latin word frequency. */
|
||||||
|
function detectContentLanguage(text: string): string {
|
||||||
|
if (!text) return 'English'
|
||||||
|
// Non-Latin scripts (deterministic)
|
||||||
|
if (/[\u0600-\u06FF\uFB50-\uFDFF\uFE70-\uFEFF]/.test(text)) {
|
||||||
|
if (/[\u06CC\u0698\u06AF\u06A9\u067E\u0686]/.test(text)) return 'Persian (Farsi)'
|
||||||
|
return 'Arabic'
|
||||||
|
}
|
||||||
|
if (/[\uAC00-\uD7AF]/.test(text)) return 'Korean'
|
||||||
|
if (/[\u3040-\u309F\u30A0-\u30FF]/.test(text)) return 'Japanese'
|
||||||
|
if (/[\u4E00-\u9FFF]/.test(text)) return 'Chinese'
|
||||||
|
if (/[\u0400-\u04FF]/.test(text)) return 'Russian'
|
||||||
|
if (/[\u0900-\u097F]/.test(text)) return 'Hindi'
|
||||||
|
|
||||||
|
// Latin script — distinguish via accented chars + function word frequency
|
||||||
|
const lower = text.toLowerCase()
|
||||||
|
const wc = (re: RegExp) => (lower.match(re) || []).length
|
||||||
|
|
||||||
|
// French: distinctive accents (é è ê à ô etc) + function words
|
||||||
|
const frAccents = (lower.match(/[éèêëàâäïîôöùûüç]/g) || []).length
|
||||||
|
const frWords = wc(/\b(dans|avec|pour|cette|être|avoir|fait|plus|sans|sous|entre|après|très|bien|aussi|même|encore|toujours|jamais|pendant|depuis|chez|leurs|mes|tes|ses|notre|votre|celui|ceux|celle|aucun|autre|chaque)\b/g)
|
||||||
|
const frScore = frAccents + frWords
|
||||||
|
|
||||||
|
// Spanish
|
||||||
|
const esAccents = (lower.match(/[áíóúñ¿¡]/g) || []).length
|
||||||
|
const esWords = wc(/\b(pero|como|más|para|con|por|una|los|las|del|al|también|puede|antes|después|siempre|nunca|también|aquí|allí|muy|bien|también|nosotros|vosotros|ellos|suyo|nuestro)\b/g)
|
||||||
|
const esScore = esAccents + esWords
|
||||||
|
|
||||||
|
// German
|
||||||
|
const deUmlauts = (lower.match(/[äöüß]/g) || []).length
|
||||||
|
const deWords = wc(/\b(und|ist|ein|eine|von|mit|für|auf|nicht|auch|sich|bei|zum|zur|den|des|dem|wir|sie|hat|war|wird|sind|kann|muss|noch|schon|immer|wieder)\b/g)
|
||||||
|
const deScore = deUmlauts * 2 + deWords
|
||||||
|
|
||||||
|
// Pick highest scorer (must beat English baseline)
|
||||||
|
const scores: [string, number][] = [
|
||||||
|
['French', frScore],
|
||||||
|
['Spanish', esScore],
|
||||||
|
['German', deScore],
|
||||||
|
]
|
||||||
|
scores.sort((a, b) => b[1] - a[1])
|
||||||
|
// Require a minimum signal (accents/words) to avoid false positives on short English text
|
||||||
|
if (scores[0][1] >= 6) return scores[0][0]
|
||||||
|
return 'English'
|
||||||
|
}
|
||||||
|
|
||||||
// ── LLM helpers ──────────────────────────────────────────────────────────────
|
// ── LLM helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function extractJsonPayload(text: string): unknown | null {
|
export function extractJsonPayload(text: string): unknown | null {
|
||||||
@@ -223,6 +273,18 @@ function outlineSystem(lang: 'fr' | 'en', maxSlides: number, assets: SourceAsset
|
|||||||
: `MATH/STEM DOMAIN DETECTED: you MUST include at least ${Math.min(2, Math.max(1, assets.formulas.length))} slides of type "equation" carrying the extracted formulas. Do NOT replace formulas with vague bullets.`
|
: `MATH/STEM DOMAIN DETECTED: you MUST include at least ${Math.min(2, Math.max(1, assets.formulas.length))} slides of type "equation" carrying the extracted formulas. Do NOT replace formulas with vague bullets.`
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
|
const imageRule = assets.hasImages
|
||||||
|
? lang === 'fr'
|
||||||
|
? `MATÉRIEL VISUEL: ${assets.images.length} image(s) disponible(s). Inclus 1 à ${Math.min(3, assets.images.length)} slide(s) de type "image" pour illustrer le propos. Pour chaque slide image, donne l'index (imageIndex, 0-based) de l'image à utiliser.`
|
||||||
|
: `VISUAL MATERIAL: ${assets.images.length} image(s) available. Include 1 to ${Math.min(3, assets.images.length)} slide(s) of type "image" to illustrate key points. For each image slide, provide imageIndex (0-based) of the image to use.`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const chartRule = assets.hasNumbers
|
||||||
|
? lang === 'fr'
|
||||||
|
? `DONNÉES NUMÉRIQUES: ${assets.numbers.length} valeurs détectées. Tu DOIS inclure au moins 1 slide de type "chart". chartType: "bar" (comparer des quantités), "line" (tendance temporelle), "donut" (proportions d'un tout).`
|
||||||
|
: `NUMERIC DATA: ${assets.numbers.length} values detected. You MUST include at least 1 slide of type "chart". chartType: "bar" (compare quantities), "line" (time trend), "donut" (proportions of a whole).`
|
||||||
|
: ''
|
||||||
|
|
||||||
if (lang === 'fr') {
|
if (lang === 'fr') {
|
||||||
return `Tu es l'architecte narratif DeckForge/PPTAgent pour Memento.
|
return `Tu es l'architecte narratif DeckForge/PPTAgent pour Memento.
|
||||||
Tu produis UNIQUEMENT un outline JSON (pas le corps final des slides).
|
Tu produis UNIQUEMENT un outline JSON (pas le corps final des slides).
|
||||||
@@ -233,10 +295,13 @@ Règles (DeckForge + think-cell):
|
|||||||
- keyPoints: 2–5 FAITS CONCRETS tirés de la note (chiffres, noms, formules) — jamais de filler
|
- keyPoints: 2–5 FAITS CONCRETS tirés de la note (chiffres, noms, formules) — jamais de filler
|
||||||
- Chaque slide = UNE idée actionnable (titre = insight, pas libellé de section)
|
- Chaque slide = UNE idée actionnable (titre = insight, pas libellé de section)
|
||||||
- Arc pédagogique pour cours: title → définitions/équations → propriétés → exemples → summary
|
- Arc pédagogique pour cours: title → définitions/équations → propriétés → exemples → summary
|
||||||
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | summary
|
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | image | summary
|
||||||
|
- ATTENTION: le type "equation" est RÉSERVÉ aux formules mathématiques réelles (avec LaTeX). N'utilise JAMAIS "equation" pour un concept métaphorique (équilibre, rapport de forces, etc.) → utilise "bullets" ou "comparison" à la place.
|
||||||
- Slide 1 = title, dernière = summary
|
- Slide 1 = title, dernière = summary
|
||||||
- INTERDIT slides vides, listes de 1 mot, ou répéter le titre de la note
|
- INTERDIT slides vides, listes de 1 mot, ou répéter le titre de la note
|
||||||
${mathRule}
|
${mathRule}
|
||||||
|
${imageRule}
|
||||||
|
${chartRule}
|
||||||
Réponds en JSON OutlineSchema.`
|
Réponds en JSON OutlineSchema.`
|
||||||
}
|
}
|
||||||
return `You are DeckForge/PPTAgent narrative architect for Memento.
|
return `You are DeckForge/PPTAgent narrative architect for Memento.
|
||||||
@@ -248,10 +313,13 @@ Rules:
|
|||||||
- keyPoints: 2–5 CONCRETE facts from the note (numbers, names, formulas) — no filler
|
- keyPoints: 2–5 CONCRETE facts from the note (numbers, names, formulas) — no filler
|
||||||
- One actionable idea per slide (title = insight, not section label)
|
- One actionable idea per slide (title = insight, not section label)
|
||||||
- Pedagogical arc for lessons: title → definitions/equations → properties → examples → summary
|
- Pedagogical arc for lessons: title → definitions/equations → properties → examples → summary
|
||||||
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | summary
|
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | image | summary
|
||||||
|
- WARNING: type "equation" is RESERVED for real mathematical formulas (with LaTeX). NEVER use "equation" for metaphorical concepts (balance of power, concessions, etc.) → use "bullets" or "comparison" instead.
|
||||||
- First=title, last=summary
|
- First=title, last=summary
|
||||||
- FORBIDDEN empty slides, one-word lists, or repeating the note title alone
|
- FORBIDDEN empty slides, one-word lists, or repeating the note title alone
|
||||||
${mathRule}
|
${mathRule}
|
||||||
|
${imageRule}
|
||||||
|
${chartRule}
|
||||||
JSON OutlineSchema only.`
|
JSON OutlineSchema only.`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +336,10 @@ OBLIGATOIRE selon type:
|
|||||||
- comparison: title + left/right avec points[2-4] chacun
|
- comparison: title + left/right avec points[2-4] chacun
|
||||||
- timeline: title + events[2-5]
|
- timeline: title + events[2-5]
|
||||||
- stats: title + stats[2-4] SEULEMENT si chiffres réels fournis
|
- stats: title + stats[2-4] SEULEMENT si chiffres réels fournis
|
||||||
- chart: title + data[2+] SEULEMENT si chiffres réels
|
- chart: title + chartType + data[{label, value: NUMBER}] (min 2 entrées)
|
||||||
|
chartType: "bar" (comparer quantités) | "line" (tendance temporelle) | "donut" (proportions) | "horizontal-bar" (libellés longs) | "radar" (comparer dimensions)
|
||||||
|
IMPORTANT: value DOIT être un number (pas string). Utilise les chiffres fournis dans le prompt.
|
||||||
|
- image: title + url (fourni dans le prompt) + caption (1 phrase décrivant ce que l'image illustre)
|
||||||
- summary: title + items[3-5] actionnables
|
- summary: title + items[3-5] actionnables
|
||||||
- quote: quote non vide
|
- quote: quote non vide
|
||||||
|
|
||||||
@@ -284,7 +355,11 @@ REQUIRED by type:
|
|||||||
- cards: title + cards[2-4]
|
- cards: title + cards[2-4]
|
||||||
- comparison: left/right points[2-4] each
|
- comparison: left/right points[2-4] each
|
||||||
- timeline: events[2-5]
|
- timeline: events[2-5]
|
||||||
- stats/chart: only with real numbers provided
|
- stats: only with real numbers provided
|
||||||
|
- chart: title + chartType + data[{label, value: NUMBER}] (min 2 entries)
|
||||||
|
chartType: "bar" (compare quantities) | "line" (time trend) | "donut" (proportions) | "horizontal-bar" (long labels) | "radar" (compare dimensions)
|
||||||
|
IMPORTANT: value MUST be a number (not string). Use the numbers provided in the prompt.
|
||||||
|
- image: title + url (provided in prompt) + caption (1 sentence describing what the image illustrates)
|
||||||
- summary: items[3-5]
|
- summary: items[3-5]
|
||||||
- quote: non-empty quote
|
- quote: non-empty quote
|
||||||
|
|
||||||
@@ -356,6 +431,72 @@ function enforceMathOutline(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Force image slides into outline when server extracted images (like enforceMathOutline for formulas). */
|
||||||
|
function enforceImageOutline(
|
||||||
|
outline: z.infer<typeof OutlineSchema>,
|
||||||
|
assets: SourceAssets,
|
||||||
|
maxSlides: number,
|
||||||
|
): z.infer<typeof OutlineSchema> {
|
||||||
|
if (!assets.hasImages || assets.images.length === 0) return outline
|
||||||
|
const hasImg = outline.slides.some((s) => s.type === 'image')
|
||||||
|
if (hasImg) return outline
|
||||||
|
|
||||||
|
// Inject 1 image slide after title (or after first equation slide)
|
||||||
|
const title = outline.slides.find((s) => s.type === 'title') || outline.slides[0]!
|
||||||
|
const insertAfter = outline.slides.findIndex((s) => s.type === 'title')
|
||||||
|
const insertIdx = insertAfter >= 0 ? insertAfter + 1 : 1
|
||||||
|
|
||||||
|
const imgSlide: z.infer<typeof OutlineSlideSchema> = {
|
||||||
|
position: insertIdx + 1,
|
||||||
|
type: 'image',
|
||||||
|
headline: assets.keySentences[0]?.slice(0, 60) || 'Illustration',
|
||||||
|
keyPoints: [assets.images[0]!],
|
||||||
|
imageIndex: 0,
|
||||||
|
narrativeRole: 'evidence',
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = outline.slides.slice(0, insertIdx)
|
||||||
|
const after = outline.slides.slice(insertIdx, maxSlides - 1)
|
||||||
|
return {
|
||||||
|
...outline,
|
||||||
|
slides: [...before, imgSlide, ...after].map((s, i) => ({ ...s, position: i + 1 })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickChartType(count: number): 'bar' | 'horizontal-bar' | 'donut' {
|
||||||
|
if (count <= 4) return 'donut'
|
||||||
|
if (count <= 6) return 'bar'
|
||||||
|
return 'horizontal-bar'
|
||||||
|
}
|
||||||
|
|
||||||
|
function enforceChartOutline(
|
||||||
|
outline: z.infer<typeof OutlineSchema>,
|
||||||
|
assets: SourceAssets,
|
||||||
|
maxSlides: number,
|
||||||
|
): z.infer<typeof OutlineSchema> {
|
||||||
|
if (!assets.hasNumbers || assets.numbers.length < 2) return outline
|
||||||
|
const hasChart = outline.slides.some((s) => s.type === 'chart')
|
||||||
|
if (hasChart) return outline
|
||||||
|
|
||||||
|
const titleIdx = outline.slides.findIndex((s) => s.type === 'title')
|
||||||
|
const insertIdx = titleIdx >= 0 ? titleIdx + 1 : 1
|
||||||
|
|
||||||
|
const chartSlide: z.infer<typeof OutlineSlideSchema> = {
|
||||||
|
position: insertIdx + 1,
|
||||||
|
type: 'chart',
|
||||||
|
headline: assets.keySentences[0]?.slice(0, 60) || 'Données clés',
|
||||||
|
keyPoints: assets.numbers.slice(0, 4).map((n) => `${n.label}: ${n.raw}`),
|
||||||
|
narrativeRole: 'data',
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = outline.slides.slice(0, insertIdx)
|
||||||
|
const after = outline.slides.slice(insertIdx, maxSlides - 1)
|
||||||
|
return {
|
||||||
|
...outline,
|
||||||
|
slides: [...before, chartSlide, ...after].map((s, i) => ({ ...s, position: i + 1 })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function fallbackExpandFromOutline(
|
function fallbackExpandFromOutline(
|
||||||
o: z.infer<typeof OutlineSlideSchema>,
|
o: z.infer<typeof OutlineSlideSchema>,
|
||||||
assets: SourceAssets,
|
assets: SourceAssets,
|
||||||
@@ -371,10 +512,17 @@ function fallbackExpandFromOutline(
|
|||||||
}
|
}
|
||||||
if (type === 'equation') {
|
if (type === 'equation') {
|
||||||
const forms = o.formulas?.length ? o.formulas : assets.formulas.slice(0, 3)
|
const forms = o.formulas?.length ? o.formulas : assets.formulas.slice(0, 3)
|
||||||
|
if (!forms.length) {
|
||||||
|
return {
|
||||||
|
type: 'bullets',
|
||||||
|
title,
|
||||||
|
items: (o.keyPoints.length >= 2 ? o.keyPoints : assets.keySentences.slice(0, 3)).slice(0, 5),
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
type: 'equation',
|
type: 'equation',
|
||||||
title,
|
title,
|
||||||
equations: (forms.length ? forms : ['f(x) = ?']).map((latex, i) => ({
|
equations: forms.map((latex, i) => ({
|
||||||
latex,
|
latex,
|
||||||
label: o.keyPoints[i] || `Formule ${i + 1}`,
|
label: o.keyPoints[i] || `Formule ${i + 1}`,
|
||||||
})),
|
})),
|
||||||
@@ -420,6 +568,16 @@ function fallbackExpandFromOutline(
|
|||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (type === 'image') {
|
||||||
|
const imgIdx = o.imageIndex ?? 0
|
||||||
|
const url = assets.images[imgIdx] || assets.images[0] || ''
|
||||||
|
return {
|
||||||
|
type: 'image',
|
||||||
|
title,
|
||||||
|
url,
|
||||||
|
caption: o.keyPoints[0] || assets.keySentences[0] || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
// default bullets — use keyPoints + sentences to fill density
|
// default bullets — use keyPoints + sentences to fill density
|
||||||
const items = [
|
const items = [
|
||||||
...o.keyPoints,
|
...o.keyPoints,
|
||||||
@@ -490,15 +648,15 @@ export async function generateSlideDeck(params: GenerateSlideDeckParams): Promis
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const noteLang = notes[0]?.language
|
const noteLang = notes.find((n) => n.language)?.language
|
||||||
const contentLang =
|
|
||||||
params.contentLanguage ||
|
|
||||||
(noteLang && LANG_NAMES[noteLang] ? LANG_NAMES[noteLang] : lang === 'fr' ? 'French' : 'English')
|
|
||||||
|
|
||||||
const combined = notes.map((n) => n.content || '').join('\n\n')
|
const combined = notes.map((n) => n.content || '').join('\n\n')
|
||||||
const assets = extractSourceAssets(combined)
|
const assets = extractSourceAssets(combined)
|
||||||
|
const contentLang =
|
||||||
|
params.contentLanguage ||
|
||||||
|
(noteLang && LANG_NAMES[noteLang] ? LANG_NAMES[noteLang] : detectContentLanguage(combined))
|
||||||
|
const perNoteLimit = notes.length > 5 ? 1200 : 8000
|
||||||
const notesText = notes
|
const notesText = notes
|
||||||
.map((n) => `### ${n.title || 'Note'}\n${prepareNoteTextForSlides(n.content || '', 10_000)}`)
|
.map((n) => `### ${n.title || 'Note'}\n${prepareNoteTextForSlides(n.content || '', perNoteLimit)}`)
|
||||||
.join('\n\n')
|
.join('\n\n')
|
||||||
const wordCount = notes.reduce((s, n) => s + countNoteWords(n.content || ''), 0)
|
const wordCount = notes.reduce((s, n) => s + countNoteWords(n.content || ''), 0)
|
||||||
const limit = slideLimitFromWordCount(wordCount)
|
const limit = slideLimitFromWordCount(wordCount)
|
||||||
@@ -526,10 +684,7 @@ export async function generateSlideDeck(params: GenerateSlideDeckParams): Promis
|
|||||||
? `FORMULES EXTRAITES (à placer dans des slides equation):\n${assets.formulas.map((f, i) => `${i + 1}. ${f}`).join('\n')}`
|
? `FORMULES EXTRAITES (à placer dans des slides equation):\n${assets.formulas.map((f, i) => `${i + 1}. ${f}`).join('\n')}`
|
||||||
: '',
|
: '',
|
||||||
assets.numbers.length
|
assets.numbers.length
|
||||||
? `CHIFFRES:\n${assets.numbers
|
? `DONNÉES NUMÉRIQUES (JSON pour slides chart/stats — utilise ces valeurs exactes):\n${JSON.stringify(assets.numbers.slice(0, 10).map((n) => ({ label: n.label.slice(0, 24), value: n.value })))}`
|
||||||
.slice(0, 10)
|
|
||||||
.map((n) => `- ${n.label}: ${n.raw}`)
|
|
||||||
.join('\n')}`
|
|
||||||
: '',
|
: '',
|
||||||
assets.keySentences.length
|
assets.keySentences.length
|
||||||
? `PHRASES CLÉS:\n${assets.keySentences
|
? `PHRASES CLÉS:\n${assets.keySentences
|
||||||
@@ -537,6 +692,9 @@ export async function generateSlideDeck(params: GenerateSlideDeckParams): Promis
|
|||||||
.map((s) => `- ${s}`)
|
.map((s) => `- ${s}`)
|
||||||
.join('\n')}`
|
.join('\n')}`
|
||||||
: '',
|
: '',
|
||||||
|
assets.images.length
|
||||||
|
? `IMAGES DISPONIBLES (utilise imageIndex 0-based pour les slides image):\n${assets.images.map((url, i) => `${i}. ${url}`).join('\n')}`
|
||||||
|
: '',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n\n')
|
.join('\n\n')
|
||||||
@@ -556,68 +714,49 @@ export async function generateSlideDeck(params: GenerateSlideDeckParams): Promis
|
|||||||
outline = enforceMathOutline(outline, assets, targetMax)
|
outline = enforceMathOutline(outline, assets, targetMax)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stage 2: Expand PER SLIDE (DeckForge SlideWriter loop) ──
|
// Images → force image slides when images exist but LLM didn't create any
|
||||||
const expanded: z.infer<typeof ExpandedSlideSchema>[] = []
|
if (assets.hasImages) {
|
||||||
for (const slideOutline of outline.slides) {
|
outline = enforceImageOutline(outline, assets, targetMax)
|
||||||
try {
|
|
||||||
const formulaHint =
|
|
||||||
slideOutline.type === 'equation'
|
|
||||||
? `\nFORMULES OBLIGATOIRES pour cette slide:\n${(slideOutline.formulas || assets.formulas).slice(0, 4).join('\n')}`
|
|
||||||
: assets.formulas.length && slideOutline.type === 'bullets'
|
|
||||||
? `\n(Formules dispo si besoin: ${assets.formulas.slice(0, 2).join(' ; ')})`
|
|
||||||
: ''
|
|
||||||
|
|
||||||
const one = await llmObject({
|
|
||||||
model,
|
|
||||||
schema: ExpandedSlideSchema,
|
|
||||||
system: expandOneSystem(lang),
|
|
||||||
prompt:
|
|
||||||
lang === 'fr'
|
|
||||||
? `Langue: ${contentLang}.
|
|
||||||
${intentHints ? `Intent: ${intentHints}\n` : ''}Slide ${slideOutline.position}/${outline.slides.length}
|
|
||||||
type: ${slideOutline.type}
|
|
||||||
headline: ${slideOutline.headline}
|
|
||||||
keyPoints: ${JSON.stringify(slideOutline.keyPoints)}
|
|
||||||
role: ${slideOutline.narrativeRole || 'evidence'}
|
|
||||||
${formulaHint}
|
|
||||||
|
|
||||||
Contexte note (extrait):\n${notesText.slice(0, 6000)}
|
|
||||||
|
|
||||||
Expand cette slide en JSON ExpandedSlide COMPLET (corps non vide).`
|
|
||||||
: `Language: ${contentLang}.
|
|
||||||
${intentHints ? `Intent: ${intentHints}\n` : ''}Slide ${slideOutline.position}/${outline.slides.length}
|
|
||||||
type: ${slideOutline.type}
|
|
||||||
headline: ${slideOutline.headline}
|
|
||||||
keyPoints: ${JSON.stringify(slideOutline.keyPoints)}
|
|
||||||
${formulaHint}
|
|
||||||
|
|
||||||
Note excerpt:\n${notesText.slice(0, 6000)}
|
|
||||||
|
|
||||||
Expand into full ExpandedSlide JSON (non-empty body).`,
|
|
||||||
})
|
|
||||||
// Force type/title from outline if model drifts
|
|
||||||
one.type = slideOutline.type
|
|
||||||
if (!one.title) one.title = slideOutline.headline
|
|
||||||
// Inject formulas if equation empty
|
|
||||||
if (one.type === 'equation' && (!one.equations || one.equations.length === 0)) {
|
|
||||||
const forms = slideOutline.formulas?.length ? slideOutline.formulas : assets.formulas
|
|
||||||
one.equations = forms.slice(0, 4).map((latex, i) => ({
|
|
||||||
latex,
|
|
||||||
label: slideOutline.keyPoints[i] || `Eq. ${i + 1}`,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
expanded.push(one)
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[SlideDeck] expand failed for slide, using deterministic fallback', e)
|
|
||||||
expanded.push(fallbackExpandFromOutline(slideOutline, assets))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Charts → force chart slides when numbers exist but LLM didn't create any
|
||||||
|
if (assets.hasNumbers) {
|
||||||
|
outline = enforceChartOutline(outline, assets, targetMax)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage 2: Expand slides ──
|
||||||
|
// Single-pass: outline LLM call + deterministic fill (1 LLM call total)
|
||||||
|
let mode = 'outline+deterministic-fill'
|
||||||
|
|
||||||
|
const expanded: z.infer<typeof ExpandedSlideSchema>[] = outline.slides.map((slideOutline) => {
|
||||||
|
const one = fallbackExpandFromOutline(slideOutline, assets)
|
||||||
|
one.type = slideOutline.type
|
||||||
|
if (!one.title) one.title = slideOutline.headline
|
||||||
|
return one
|
||||||
|
})
|
||||||
|
|
||||||
// ── Stage 3: Normalize + STEM inject + substance gate ──
|
// ── Stage 3: Normalize + STEM inject + substance gate ──
|
||||||
|
|
||||||
|
// Rescue chart slides with missing/partial data before normalization drops them
|
||||||
|
const rescued = expanded.map((slide) => {
|
||||||
|
if (slide.type !== 'chart') return slide
|
||||||
|
const validData = (slide.data || []).filter(
|
||||||
|
(d: any) => d && typeof d.value === 'number' && Number.isFinite(d.value) && String(d.label || '').trim(),
|
||||||
|
)
|
||||||
|
if (validData.length < 2 && assets.numbers.length >= 2) {
|
||||||
|
return {
|
||||||
|
...slide,
|
||||||
|
data: assets.numbers.slice(0, 6).map((n) => ({ label: n.label.slice(0, 20), value: n.value })),
|
||||||
|
chartType: slide.chartType || pickChartType(assets.numbers.length),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slide
|
||||||
|
})
|
||||||
|
|
||||||
let normalized = normalizeSlideDeck({
|
let normalized = normalizeSlideDeck({
|
||||||
title: outline.title,
|
title: outline.title,
|
||||||
theme,
|
theme,
|
||||||
slides: expanded as unknown[],
|
slides: rescued as unknown[],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Always inject formulas if missing (deterministic — never ship STEM without equations)
|
// Always inject formulas if missing (deterministic — never ship STEM without equations)
|
||||||
@@ -630,7 +769,6 @@ Expand into full ExpandedSlide JSON (non-empty body).`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
let gate = assertDeckHasSubstance(normalized, stemOpts)
|
let gate = assertDeckHasSubstance(normalized, stemOpts)
|
||||||
let mode = 'outline+per-slide-expand'
|
|
||||||
|
|
||||||
if (!gate.ok) {
|
if (!gate.ok) {
|
||||||
// Deterministic fill for empty body slides using outline + assets
|
// Deterministic fill for empty body slides using outline + assets
|
||||||
@@ -669,6 +807,30 @@ Expand into full ExpandedSlide JSON (non-empty body).`,
|
|||||||
|
|
||||||
if (!normalized.theme) normalized.theme = theme
|
if (!normalized.theme) normalized.theme = theme
|
||||||
|
|
||||||
|
// GUARANTEE: if numbers were extracted but no chart survived the pipeline, force-inject one
|
||||||
|
if (assets.hasNumbers && assets.numbers.length >= 2) {
|
||||||
|
const hasChart = normalized.slides.some((s) => s.type === 'chart')
|
||||||
|
if (!hasChart) {
|
||||||
|
const chartSlide: Record<string, unknown> = {
|
||||||
|
type: 'chart',
|
||||||
|
title: lang === 'fr' ? 'Données clés' : 'Key data',
|
||||||
|
chartType: pickChartType(assets.numbers.length),
|
||||||
|
data: assets.numbers.slice(0, 6).map((n) => ({
|
||||||
|
label: (n.label.slice(0, 20) || n.raw),
|
||||||
|
value: n.value,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
const summaryIdx = normalized.slides.findIndex((s) => s.type === 'summary')
|
||||||
|
if (summaryIdx >= 0) {
|
||||||
|
normalized.slides.splice(summaryIdx, 0, chartSlide)
|
||||||
|
} else if (normalized.slides.length < 8) {
|
||||||
|
normalized.slides.push(chartSlide)
|
||||||
|
} else {
|
||||||
|
normalized.slides[normalized.slides.length - 2] = chartSlide
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const canvas = await persist(params.userId, normalized, params.actionId, mode)
|
const canvas = await persist(params.userId, normalized, params.actionId, mode)
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -4,24 +4,55 @@
|
|||||||
* Critical for STEM notes: formulas must be harvested, not hoped for from the model.
|
* Critical for STEM notes: formulas must be harvested, not hoped for from the model.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { extractPublishImageUrls } from '@/lib/publish/process-note-html'
|
||||||
|
|
||||||
export interface SourceAssets {
|
export interface SourceAssets {
|
||||||
formulas: string[]
|
formulas: string[]
|
||||||
numbers: Array<{ label: string; value: number; raw: string }>
|
numbers: Array<{ label: string; value: number; raw: string }>
|
||||||
keySentences: string[]
|
keySentences: string[]
|
||||||
hasMath: boolean
|
hasMath: boolean
|
||||||
hasNumbers: boolean
|
hasNumbers: boolean
|
||||||
|
hasImages: boolean
|
||||||
wordCount: number
|
wordCount: number
|
||||||
|
images: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract LaTeX / equation-like fragments from note plain text or HTML. */
|
/** Extract LaTeX / equation-like fragments from note plain text or HTML. */
|
||||||
export function extractFormulas(raw: string): string[] {
|
export function extractFormulas(raw: string): string[] {
|
||||||
if (!raw) return []
|
if (!raw) return []
|
||||||
const found: string[] = []
|
const found: string[] = []
|
||||||
|
// 3+ consecutive plain words at brace depth 0 = prose tail captured after a
|
||||||
|
// formula (KaTeX eats spaces in math mode → must never reach $…$).
|
||||||
|
const PROSE_TAIL_RE =
|
||||||
|
/(?:[.!?]?\s+)[A-ZÀ-ÖØ-Þ]?[a-zA-ZÀ-ÿ'’]{2,}\s+[a-zA-ZÀ-ÿ'’]{2,}\s+[a-zA-ZÀ-ÿ'’]{2,}[\s\S]*$/g
|
||||||
|
const braceDepth = (s: string): number => {
|
||||||
|
let d = 0
|
||||||
|
for (const ch of s) {
|
||||||
|
if (ch === '{') d++
|
||||||
|
else if (ch === '}') d = Math.max(0, d - 1)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
const push = (s: string) => {
|
const push = (s: string) => {
|
||||||
const t = s.replace(/\s+/g, ' ').trim()
|
let t = s.replace(/\s+/g, ' ').trim()
|
||||||
|
t = t.replace(/^\$+|\$+$/g, '').trim()
|
||||||
|
PROSE_TAIL_RE.lastIndex = 0
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
while ((m = PROSE_TAIL_RE.exec(t)) !== null) {
|
||||||
|
if (braceDepth(t.slice(0, m.index)) === 0) {
|
||||||
|
t = t.slice(0, m.index).trim()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t = t.replace(/^\$+|\$+$/g, '').trim()
|
||||||
if (t.length >= 2 && t.length <= 280 && !found.includes(t)) found.push(t)
|
if (t.length >= 2 && t.length <= 280 && !found.includes(t)) found.push(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TipTap / published HTML: data-latex="..."
|
||||||
|
for (const m of raw.matchAll(/data-latex=["']([^"']+)["']/gi)) {
|
||||||
|
push(decodeHtmlEntities(m[1] || ''))
|
||||||
|
}
|
||||||
|
|
||||||
// $$ ... $$
|
// $$ ... $$
|
||||||
for (const m of raw.matchAll(/\$\$([\s\S]+?)\$\$/g)) push(m[1] || '')
|
for (const m of raw.matchAll(/\$\$([\s\S]+?)\$\$/g)) push(m[1] || '')
|
||||||
// \[ ... \]
|
// \[ ... \]
|
||||||
@@ -47,20 +78,34 @@ export function extractFormulas(raw: string): string[] {
|
|||||||
return found.slice(0, 24)
|
return found.slice(0, 24)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decodeHtmlEntities(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
}
|
||||||
|
|
||||||
export function extractNumbers(raw: string): Array<{ label: string; value: number; raw: string }> {
|
export function extractNumbers(raw: string): Array<{ label: string; value: number; raw: string }> {
|
||||||
if (!raw) return []
|
if (!raw) return []
|
||||||
|
// Normalize Persian (۰-۹) and Arabic (٠-٩) numerals to Western (0-9)
|
||||||
|
const text = raw
|
||||||
|
.replace(/[\u06F0-\u06F9]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0x06f0 + 0x30))
|
||||||
|
.replace(/[\u0660-\u0669]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0x0660 + 0x30))
|
||||||
|
|
||||||
const out: Array<{ label: string; value: number; raw: string }> = []
|
const out: Array<{ label: string; value: number; raw: string }> = []
|
||||||
const re =
|
const re =
|
||||||
/(?:^|[^\d])((?:≈|~)?\s*-?\d+(?:[.,]\d+)?)\s*(%|€|\$|k|m|bn|ms|s|°C)?\b/gi
|
/(?:^|[^\d])((?:≈|~)?\s*-?\d+(?:[.,]\d+)?)\s*(%|€|\$|k|m|bn|ms|s|°C)?\b/gi
|
||||||
let m: RegExpExecArray | null
|
let m: RegExpExecArray | null
|
||||||
while ((m = re.exec(raw)) !== null && out.length < 20) {
|
while ((m = re.exec(text)) !== null && out.length < 20) {
|
||||||
const numStr = (m[1] || '').replace(/[≈~\s]/g, '').replace(',', '.')
|
const numStr = (m[1] || '').replace(/[≈~\s]/g, '').replace(',', '.')
|
||||||
const value = parseFloat(numStr)
|
const value = parseFloat(numStr)
|
||||||
if (!Number.isFinite(value)) continue
|
if (!Number.isFinite(value)) continue
|
||||||
const unit = m[2] || ''
|
const unit = m[2] || ''
|
||||||
// crude label: 40 chars before
|
// crude label: 40 chars before
|
||||||
const start = Math.max(0, m.index - 40)
|
const start = Math.max(0, m.index - 40)
|
||||||
const ctx = raw.slice(start, m.index).replace(/\s+/g, ' ').trim()
|
const ctx = text.slice(start, m.index).replace(/\s+/g, ' ').trim()
|
||||||
const label = ctx.split(/[.;:!?\n]/).pop()?.trim().slice(-32) || `n${out.length + 1}`
|
const label = ctx.split(/[.;:!?\n]/).pop()?.trim().slice(-32) || `n${out.length + 1}`
|
||||||
out.push({ label, value: unit === '%' ? value : value, raw: `${numStr}${unit}` })
|
out.push({ label, value: unit === '%' ? value : value, raw: `${numStr}${unit}` })
|
||||||
}
|
}
|
||||||
@@ -88,16 +133,20 @@ export function extractKeySentences(raw: string, max = 16): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function extractSourceAssets(raw: string): SourceAssets {
|
export function extractSourceAssets(raw: string): SourceAssets {
|
||||||
const formulas = extractFormulas(raw)
|
const plain = raw.replace(/<[^>]+>/g, ' ')
|
||||||
const numbers = extractNumbers(raw)
|
const formulas = extractFormulas(plain)
|
||||||
|
const numbers = extractNumbers(plain)
|
||||||
const keySentences = extractKeySentences(raw)
|
const keySentences = extractKeySentences(raw)
|
||||||
const wordCount = raw.replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length
|
const images = extractPublishImageUrls(raw).slice(0, 8)
|
||||||
|
const wordCount = plain.split(/\s+/).filter(Boolean).length
|
||||||
return {
|
return {
|
||||||
formulas,
|
formulas,
|
||||||
numbers,
|
numbers,
|
||||||
keySentences,
|
keySentences,
|
||||||
hasMath: formulas.length > 0 || /équat|equat|différen|differen|dériv|deriv|intégr|integr|EDO|ODE|PDE|latex/i.test(raw),
|
hasMath: formulas.length > 0 || /équat|equat|différen|differen|dériv|deriv|intégr|integr|EDO|ODE|PDE|latex/i.test(plain),
|
||||||
hasNumbers: numbers.length >= 2,
|
hasNumbers: numbers.length >= 2,
|
||||||
|
hasImages: images.length > 0,
|
||||||
wordCount,
|
wordCount,
|
||||||
|
images,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,13 +277,13 @@ function renderBarChart(data: { label: string; value: number }[], r: Recipe): st
|
|||||||
const max = Math.max(...data.map(d => d.value), 1)
|
const max = Math.max(...data.map(d => d.value), 1)
|
||||||
const bars = data.map(d => {
|
const bars = data.map(d => {
|
||||||
const pct = Math.round((d.value / max) * 100)
|
const pct = Math.round((d.value / max) * 100)
|
||||||
return `<div style="display:flex;flex-direction:column;align-items:center;gap:6px;flex:1;min-width:0;">
|
return `<div style="display:flex;flex-direction:column;justify-content:flex-end;align-items:center;gap:6px;flex:1;min-width:0;height:100%;">
|
||||||
<span style="font-size:0.75rem;font-weight:700;color:${r.textSecondary};">${d.value}</span>
|
<span style="font-size:0.75rem;font-weight:700;color:${r.textSecondary};">${d.value}</span>
|
||||||
<div class="bar" data-height="${pct}" style="background:linear-gradient(to top,${r.accent1},${r.accent2});height:0%;width:100%;border-radius:6px 6px 0 0;"></div>
|
<div class="bar" data-height="${pct}" style="background:linear-gradient(to top,${r.accent1},${r.accent2});height:0%;width:80%;border-radius:6px 6px 0 0;"></div>
|
||||||
<span style="font-size:0.7rem;color:${r.textMuted};text-align:center;max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(d.label)}</span>
|
<span style="font-size:0.7rem;color:${r.textMuted};text-align:center;max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(d.label)}</span>
|
||||||
</div>`
|
</div>`
|
||||||
}).join('')
|
}).join('')
|
||||||
return `<div style="display:flex;align-items:flex-end;gap:12px;height:200px;">${bars}</div>`
|
return `<div style="display:flex;align-items:flex-end;gap:12px;height:220px;">${bars}</div>`
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderHBarChart(data: { label: string; value: number }[], r: Recipe): string {
|
function renderHBarChart(data: { label: string; value: number }[], r: Recipe): string {
|
||||||
@@ -325,7 +325,7 @@ function renderLineChart(data: { label: string; value: number }[], r: Recipe): s
|
|||||||
return `<text x="${x}" y="${h - 15}" text-anchor="middle" font-size="10" fill="${r.textMuted}">${esc(d.label)}</text>`
|
return `<text x="${x}" y="${h - 15}" text-anchor="middle" font-size="10" fill="${r.textMuted}">${esc(d.label)}</text>`
|
||||||
}).join('')
|
}).join('')
|
||||||
return `<svg viewBox="0 0 ${w} ${h}" style="width:100%;height:auto;">
|
return `<svg viewBox="0 0 ${w} ${h}" style="width:100%;height:auto;">
|
||||||
<defs><linearGradient id="lg-${Math.random().toString(36).slice(2, 6)}" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="${r.accent1}" stop-opacity="0.25"/><stop offset="100%" stop-color="${r.accent1}" stop-opacity="0"/></linearGradient></defs>
|
<defs><linearGradient id="lg-area" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="${r.accent1}" stop-opacity="0.25"/><stop offset="100%" stop-color="${r.accent1}" stop-opacity="0"/></linearGradient></defs>
|
||||||
${gridLines}
|
${gridLines}
|
||||||
<path fill="url(#lg-area)" d="${areaD}" opacity="0.4"/>
|
<path fill="url(#lg-area)" d="${areaD}" opacity="0.4"/>
|
||||||
<path class="line-path" d="${pathD}" stroke="${r.accent1}" fill="none" stroke-width="2.5" stroke-linecap="round"/>
|
<path class="line-path" d="${pathD}" stroke="${r.accent1}" fill="none" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
@@ -486,7 +486,9 @@ function updateNav(){document.querySelectorAll('.nav-dot').forEach(function(d,i)
|
|||||||
document.addEventListener('keydown',function(e){if(e.key==='ArrowRight'||e.key===' ')changeSlide(1);if(e.key==='ArrowLeft')changeSlide(-1);});
|
document.addEventListener('keydown',function(e){if(e.key==='ArrowRight'||e.key===' ')changeSlide(1);if(e.key==='ArrowLeft')changeSlide(-1);});
|
||||||
var tx=0;document.addEventListener('touchstart',function(e){tx=e.touches[0].clientX;},{passive:true});document.addEventListener('touchend',function(e){var dx=tx-e.changedTouches[0].clientX;if(Math.abs(dx)>50)changeSlide(dx>0?1:-1);},{passive:true});
|
var tx=0;document.addEventListener('touchstart',function(e){tx=e.touches[0].clientX;},{passive:true});document.addEventListener('touchend',function(e){var dx=tx-e.changedTouches[0].clientX;if(Math.abs(dx)>50)changeSlide(dx>0?1:-1);},{passive:true});
|
||||||
function animateSlide(s){s.querySelectorAll('.reveal').forEach(function(el,i){el.style.transition='none';el.style.opacity='0';el.style.transform='translateY(18px)';el.offsetHeight;el.style.transition='opacity 0.35s ease '+(i*0.07)+'s, transform 0.35s ease '+(i*0.07)+'s';el.style.opacity='1';el.style.transform='translateY(0)';});s.querySelectorAll('.bar[data-height]').forEach(function(b){b.style.height='0%';setTimeout(function(){b.style.height=b.dataset.height+'%';},100);});s.querySelectorAll('.bar-fill[data-width]').forEach(function(b){b.style.width='0%';setTimeout(function(){b.style.width=b.dataset.width+'%';},100);});s.querySelectorAll('.line-path').forEach(function(p){var l=p.getTotalLength?p.getTotalLength():2000;p.style.strokeDasharray=l;p.style.strokeDashoffset=l;setTimeout(function(){p.style.strokeDashoffset='0';},100);});s.querySelectorAll('[data-count]').forEach(function(el){var t=parseFloat(el.dataset.count),sf=el.dataset.suffix||'',st=30,inc=t/st,i=0,v=0;var iv=setInterval(function(){v+=inc;i++;el.textContent=(i>=st?t:Math.round(v))+sf;if(i>=st)clearInterval(iv);},30);});}
|
function animateSlide(s){s.querySelectorAll('.reveal').forEach(function(el,i){el.style.transition='none';el.style.opacity='0';el.style.transform='translateY(18px)';el.offsetHeight;el.style.transition='opacity 0.35s ease '+(i*0.07)+'s, transform 0.35s ease '+(i*0.07)+'s';el.style.opacity='1';el.style.transform='translateY(0)';});s.querySelectorAll('.bar[data-height]').forEach(function(b){b.style.height='0%';setTimeout(function(){b.style.height=b.dataset.height+'%';},100);});s.querySelectorAll('.bar-fill[data-width]').forEach(function(b){b.style.width='0%';setTimeout(function(){b.style.width=b.dataset.width+'%';},100);});s.querySelectorAll('.line-path').forEach(function(p){var l=p.getTotalLength?p.getTotalLength():2000;p.style.strokeDasharray=l;p.style.strokeDashoffset=l;setTimeout(function(){p.style.strokeDashoffset='0';},100);});s.querySelectorAll('[data-count]').forEach(function(el){var t=parseFloat(el.dataset.count),sf=el.dataset.suffix||'',st=30,inc=t/st,i=0,v=0;var iv=setInterval(function(){v+=inc;i++;el.textContent=(i>=st?t:Math.round(v))+sf;if(i>=st)clearInterval(iv);},30);});}
|
||||||
var first=document.querySelector('.slide[data-slide="1"]');if(first){first.classList.add('active');setTimeout(function(){animateSlide(first);renderKatex();},300);}else{setTimeout(renderKatex,400);}
|
var first=document.querySelector('.slide[data-slide="1"]');if(first){first.classList.add('active');setTimeout(function(){animateSlide(first);renderKatex();},300);}
|
||||||
|
var katexRetries=0;function ensureKatex(){if(typeof katex!=='undefined'){renderKatex();}else if(katexRetries<8){katexRetries++;setTimeout(ensureKatex,500);}}
|
||||||
|
setTimeout(ensureKatex,400);
|
||||||
// Particles
|
// Particles
|
||||||
document.querySelectorAll('canvas[id^="particles-"]').forEach(function(c){c.width=window.innerWidth;c.height=window.innerHeight;var ctx=c.getContext('2d'),pts=[];for(var i=0;i<50;i++)pts.push({x:Math.random()*c.width,y:Math.random()*c.height,vx:(Math.random()-0.5)*0.3,vy:(Math.random()-0.5)*0.3,r:Math.random()*2+0.5});function draw(){ctx.clearRect(0,0,c.width,c.height);pts.forEach(function(p){p.x+=p.vx;p.y+=p.vy;if(p.x<0)p.x=c.width;if(p.x>c.width)p.x=0;if(p.y<0)p.y=c.height;if(p.y>c.height)p.y=0;ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,Math.PI*2);ctx.fillStyle='${r.accent1}80';ctx.fill();});requestAnimationFrame(draw);}draw();});
|
document.querySelectorAll('canvas[id^="particles-"]').forEach(function(c){c.width=window.innerWidth;c.height=window.innerHeight;var ctx=c.getContext('2d'),pts=[];for(var i=0;i<50;i++)pts.push({x:Math.random()*c.width,y:Math.random()*c.height,vx:(Math.random()-0.5)*0.3,vy:(Math.random()-0.5)*0.3,r:Math.random()*2+0.5});function draw(){ctx.clearRect(0,0,c.width,c.height);pts.forEach(function(p){p.x+=p.vx;p.y+=p.vy;if(p.x<0)p.x=c.width;if(p.x>c.width)p.x=0;if(p.y<0)p.y=c.height;if(p.y>c.height)p.y=0;ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,Math.PI*2);ctx.fillStyle='${r.accent1}80';ctx.fill();});requestAnimationFrame(draw);}draw();});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -55,17 +55,25 @@ export function buildAuthProviders() {
|
|||||||
|
|
||||||
const passwordsMatch = await bcrypt.compare(password, user.password);
|
const passwordsMatch = await bcrypt.compare(password, user.password);
|
||||||
|
|
||||||
if (passwordsMatch) {
|
if (!passwordsMatch) {
|
||||||
return {
|
return null;
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
name: user.name,
|
|
||||||
role: user.role,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
// Password accounts must confirm email (Google OAuth sets emailVerified).
|
||||||
} catch {
|
if (!user.emailVerified) {
|
||||||
|
throw new Error('EMAIL_NOT_VERIFIED');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.message === 'EMAIL_NOT_VERIFIED') {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
133
memento-note/lib/auth/email-verification.ts
Normal file
133
memento-note/lib/auth/email-verification.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
import { sendEmail } from '@/lib/mail'
|
||||||
|
import { getSystemConfig } from '@/lib/config'
|
||||||
|
import { getEmailTemplate } from '@/lib/email-template'
|
||||||
|
|
||||||
|
const VERIFY_PREFIX = 'email-verify:'
|
||||||
|
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000 // 24h
|
||||||
|
|
||||||
|
export function generateVerificationToken(): string {
|
||||||
|
const array = new Uint8Array(32)
|
||||||
|
globalThis.crypto.getRandomValues(array)
|
||||||
|
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function identifierForEmail(email: string): string {
|
||||||
|
return `${VERIFY_PREFIX}${email.toLowerCase()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEmailVerificationToken(email: string): Promise<string> {
|
||||||
|
const normalized = email.toLowerCase()
|
||||||
|
const identifier = identifierForEmail(normalized)
|
||||||
|
const token = generateVerificationToken()
|
||||||
|
const expires = new Date(Date.now() + TOKEN_TTL_MS)
|
||||||
|
|
||||||
|
// Replace any pending tokens for this email
|
||||||
|
await prisma.verificationToken.deleteMany({ where: { identifier } })
|
||||||
|
await prisma.verificationToken.create({
|
||||||
|
data: { identifier, token, expires },
|
||||||
|
})
|
||||||
|
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendVerificationEmail(opts: {
|
||||||
|
email: string
|
||||||
|
name?: string | null
|
||||||
|
locale?: string
|
||||||
|
}): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const token = await createEmailVerificationToken(opts.email)
|
||||||
|
const baseUrl = (process.env.NEXTAUTH_URL || '').replace(/\/$/, '')
|
||||||
|
const verifyLink = `${baseUrl}/verify-email?token=${token}`
|
||||||
|
|
||||||
|
const isFr = (opts.locale ?? '').toLowerCase().startsWith('fr')
|
||||||
|
const greet = opts.name?.trim()
|
||||||
|
? opts.name.trim()
|
||||||
|
: isFr
|
||||||
|
? 'Bonjour'
|
||||||
|
: 'Hi'
|
||||||
|
|
||||||
|
const title = isFr ? 'Confirmez votre adresse e-mail' : 'Confirm your email address'
|
||||||
|
const body = isFr
|
||||||
|
? `<p>${greet},</p><p>Merci de vous être inscrit sur Memento. Cliquez sur le bouton ci-dessous pour activer votre compte. Ce lien est valable 24 heures.</p>`
|
||||||
|
: `<p>${greet},</p><p>Thanks for signing up for Memento. Click the button below to activate your account. This link is valid for 24 hours.</p>`
|
||||||
|
const cta = isFr ? 'Confirmer mon e-mail' : 'Confirm my email'
|
||||||
|
const subject = isFr
|
||||||
|
? 'Confirmez votre compte Memento'
|
||||||
|
: 'Confirm your Memento account'
|
||||||
|
|
||||||
|
const html = getEmailTemplate(title, body, verifyLink, cta)
|
||||||
|
const sysConfig = await getSystemConfig()
|
||||||
|
const emailProvider = (sysConfig.EMAIL_PROVIDER || 'auto') as 'resend' | 'smtp' | 'auto'
|
||||||
|
|
||||||
|
return sendEmail({ to: opts.email.toLowerCase(), subject, html }, emailProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyEmailToken(
|
||||||
|
token: string,
|
||||||
|
): Promise<{ success: true } | { success: false; error: 'invalid' | 'expired' }> {
|
||||||
|
if (!token) return { success: false, error: 'invalid' }
|
||||||
|
|
||||||
|
const record = await prisma.verificationToken.findFirst({
|
||||||
|
where: { token },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!record || !record.identifier.startsWith(VERIFY_PREFIX)) {
|
||||||
|
return { success: false, error: 'invalid' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.expires < new Date()) {
|
||||||
|
await prisma.verificationToken.deleteMany({
|
||||||
|
where: { identifier: record.identifier },
|
||||||
|
})
|
||||||
|
return { success: false, error: 'expired' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = record.identifier.slice(VERIFY_PREFIX.length)
|
||||||
|
const user = await prisma.user.findUnique({ where: { email } })
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: 'invalid' }
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { emailVerified: new Date() },
|
||||||
|
}),
|
||||||
|
prisma.verificationToken.deleteMany({
|
||||||
|
where: { identifier: record.identifier },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resend verification for an unverified password account.
|
||||||
|
* Always returns success to avoid email enumeration when the address is unknown.
|
||||||
|
*/
|
||||||
|
export async function resendVerificationEmail(
|
||||||
|
email: string,
|
||||||
|
locale?: string,
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const normalized = email.toLowerCase().trim()
|
||||||
|
if (!normalized) return { error: 'missing_email', success: false }
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { email: normalized } })
|
||||||
|
if (!user || !user.password) {
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
if (user.emailVerified) {
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sendVerificationEmail({
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
locale,
|
||||||
|
})
|
||||||
|
if (!result.success) {
|
||||||
|
return { success: false, error: 'send_failed' }
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
2
memento-note/lib/billing/trial-constants.ts
Normal file
2
memento-note/lib/billing/trial-constants.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/** Free trial length on first Pro / Business checkout. Safe for client imports. */
|
||||||
|
export const SUBSCRIPTION_TRIAL_DAYS = 7
|
||||||
55
memento-note/lib/billing/trial-reminder-email.ts
Normal file
55
memento-note/lib/billing/trial-reminder-email.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { sendEmail } from '@/lib/mail'
|
||||||
|
|
||||||
|
function formatTrialEnd(date: Date, locale: string): string {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat(locale, {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
}).format(date)
|
||||||
|
} catch {
|
||||||
|
return date.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort reminder ~3 days before Stripe ends a trial.
|
||||||
|
* Failures are logged by the caller; never throw to the webhook.
|
||||||
|
*/
|
||||||
|
export async function sendTrialEndingReminder(opts: {
|
||||||
|
to: string
|
||||||
|
name?: string | null
|
||||||
|
trialEndsAt: Date
|
||||||
|
billingUrl: string
|
||||||
|
locale?: string
|
||||||
|
}): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const locale = opts.locale?.startsWith('fr') ? 'fr' : 'en'
|
||||||
|
const endLabel = formatTrialEnd(opts.trialEndsAt, locale === 'fr' ? 'fr-FR' : 'en-US')
|
||||||
|
const greet = opts.name?.trim() ? opts.name.trim() : locale === 'fr' ? 'Bonjour' : 'Hi'
|
||||||
|
|
||||||
|
const subject =
|
||||||
|
locale === 'fr'
|
||||||
|
? 'Votre essai Memento se termine bientôt'
|
||||||
|
: 'Your Memento trial is ending soon'
|
||||||
|
|
||||||
|
const html =
|
||||||
|
locale === 'fr'
|
||||||
|
? `
|
||||||
|
<p>${greet},</p>
|
||||||
|
<p>Votre période d'essai Memento se termine le <strong>${endLabel}</strong>.</p>
|
||||||
|
<p>Après cette date, votre abonnement démarrera automatiquement avec le moyen de paiement enregistré.</p>
|
||||||
|
<p>Pour gérer votre abonnement ou votre carte :</p>
|
||||||
|
<p><a href="${opts.billingUrl}">Ouvrir la facturation</a></p>
|
||||||
|
<p>— L'équipe Memento</p>
|
||||||
|
`
|
||||||
|
: `
|
||||||
|
<p>${greet},</p>
|
||||||
|
<p>Your Memento trial ends on <strong>${endLabel}</strong>.</p>
|
||||||
|
<p>After that date, your subscription will start automatically using the payment method on file.</p>
|
||||||
|
<p>To manage your subscription or card:</p>
|
||||||
|
<p><a href="${opts.billingUrl}">Open billing settings</a></p>
|
||||||
|
<p>— The Memento team</p>
|
||||||
|
`
|
||||||
|
|
||||||
|
return sendEmail({ to: opts.to, subject, html })
|
||||||
|
}
|
||||||
34
memento-note/lib/billing/trial.ts
Normal file
34
memento-note/lib/billing/trial.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offer a trial only to users who never held a real Stripe subscription.
|
||||||
|
* Users with cus_mock / price_mock leftovers from local tests are still eligible
|
||||||
|
* if they never had a non-mock stripeSubscriptionId.
|
||||||
|
*/
|
||||||
|
export async function shouldOfferSubscriptionTrial(userId: string): Promise<boolean> {
|
||||||
|
const sub = await prisma.subscription.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
select: {
|
||||||
|
stripeSubscriptionId: true,
|
||||||
|
tier: true,
|
||||||
|
status: true,
|
||||||
|
trialEndsAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!sub) return true
|
||||||
|
|
||||||
|
const stripeSubId = sub.stripeSubscriptionId
|
||||||
|
if (stripeSubId && !stripeSubId.startsWith('sub_mock')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already on a paid / trial tier locally without a Stripe id (admin override)
|
||||||
|
if (sub.tier !== 'BASIC' && (sub.status === 'ACTIVE' || sub.status === 'TRIALING')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export const DASHBOARD_LAYOUT_VERSION = 6 as const
|
export const DASHBOARD_LAYOUT_VERSION = 7 as const
|
||||||
|
|
||||||
/** Widgets visibles dans la mise en page par défaut (réf. prototype / capture utilisateur). */
|
/** Widgets visibles dans la mise en page par défaut (réf. prototype / capture utilisateur). */
|
||||||
export const CANONICAL_VISIBLE_WIDGET_IDS: readonly DashboardWidgetId[] = [
|
export const CANONICAL_VISIBLE_WIDGET_IDS: readonly DashboardWidgetId[] = [
|
||||||
@@ -10,8 +10,6 @@ export const CANONICAL_VISIBLE_WIDGET_IDS: readonly DashboardWidgetId[] = [
|
|||||||
'mind-map',
|
'mind-map',
|
||||||
'sentiment',
|
'sentiment',
|
||||||
'inbox',
|
'inbox',
|
||||||
'revision',
|
|
||||||
'stats',
|
|
||||||
'reminders',
|
'reminders',
|
||||||
'flashcards-progress',
|
'flashcards-progress',
|
||||||
'agents',
|
'agents',
|
||||||
@@ -115,13 +113,13 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
|
|||||||
// Colonne latérale (droite) — cartes compactes + widgets IA
|
// Colonne latérale (droite) — cartes compactes + widgets IA
|
||||||
{ id: 'sentiment', visible: true, order: 6, zone: 'side' },
|
{ id: 'sentiment', visible: true, order: 6, zone: 'side' },
|
||||||
{ id: 'inbox', visible: true, order: 7, zone: 'side' },
|
{ id: 'inbox', visible: true, order: 7, zone: 'side' },
|
||||||
{ id: 'revision', visible: true, order: 8, zone: 'side' },
|
{ id: 'reminders', visible: true, order: 8, zone: 'side' },
|
||||||
{ id: 'stats', visible: true, order: 9, zone: 'side' },
|
{ id: 'flashcards-progress', visible: true, order: 9, zone: 'side' },
|
||||||
{ id: 'reminders', visible: true, order: 10, zone: 'side' },
|
{ id: 'agents', visible: true, order: 10, zone: 'side' },
|
||||||
{ id: 'flashcards-progress', visible: true, order: 11, zone: 'side' },
|
{ id: 'pinned', visible: true, order: 11, zone: 'side' },
|
||||||
{ id: 'agents', visible: true, order: 12, zone: 'side' },
|
// Catalogue — masqués par défaut (chiffres déjà dans le bandeau du haut)
|
||||||
{ id: 'pinned', visible: true, order: 13, zone: 'side' },
|
{ id: 'revision', visible: false, order: 12, zone: 'side' },
|
||||||
// Catalogue — masqués par défaut, ajoutables via « Personnaliser »
|
{ id: 'stats', visible: false, order: 13, zone: 'side' },
|
||||||
{ id: 'daily-review', visible: false, order: 14, zone: 'side' },
|
{ id: 'daily-review', visible: false, order: 14, zone: 'side' },
|
||||||
{ id: 'agent-activity', visible: false, order: 15, zone: 'side' },
|
{ id: 'agent-activity', visible: false, order: 15, zone: 'side' },
|
||||||
{ id: 'gmail', visible: false, order: 16, zone: 'side' },
|
{ id: 'gmail', visible: false, order: 16, zone: 'side' },
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ export function getEmailTemplate(title: string, content: string, actionLink?: st
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div className="container">
|
<div class="container">
|
||||||
<div className="header">
|
<div class="header">
|
||||||
<a href="${process.env.NEXTAUTH_URL}" className="logo">
|
<a href="${process.env.NEXTAUTH_URL || 'https://memento-note.com'}" class="logo">
|
||||||
📒 Memento
|
📒 Memento
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,8 +24,8 @@ export function getEmailTemplate(title: string, content: string, actionLink?: st
|
|||||||
<div>
|
<div>
|
||||||
${content}
|
${content}
|
||||||
</div>
|
</div>
|
||||||
${actionLink ? `<div style="text-align: center;"><a href="${actionLink}" className="button">${actionText || 'Click here'}</a></div>` : ''}
|
${actionLink ? `<div style="text-align: center;"><a href="${actionLink}" class="button">${actionText || 'Click here'}</a></div>` : ''}
|
||||||
<div className="footer">
|
<div class="footer">
|
||||||
<p>This email was sent from your Memento instance.</p>
|
<p>This email was sent from your Memento instance.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,11 +50,16 @@ export function LanguageProvider({ children, initialLanguage = 'en', initialTran
|
|||||||
|
|
||||||
const isFirstRender = useRef(true)
|
const isFirstRender = useRef(true)
|
||||||
|
|
||||||
// Load saved preference from localStorage AFTER hydration
|
// Load saved preference from cookie only (explicit picker). localStorage
|
||||||
|
// without cookie used to override note-based detection with a stale 'en'.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = localStorage.getItem('user-language') as SupportedLanguage
|
const cookie = document.cookie
|
||||||
if (saved && SUPPORTED_LANGS.includes(saved) && saved !== initialLanguage) {
|
.split(';')
|
||||||
setLanguageState(saved)
|
.map(s => s.trim())
|
||||||
|
.find(s => s.startsWith('user-language='))
|
||||||
|
?.split('=')[1] as SupportedLanguage | undefined
|
||||||
|
if (cookie && SUPPORTED_LANGS.includes(cookie) && cookie !== initialLanguage) {
|
||||||
|
setLanguageState(cookie)
|
||||||
}
|
}
|
||||||
}, [initialLanguage])
|
}, [initialLanguage])
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const svgEdgeSchema = z.object({
|
|||||||
from: z.string().min(1),
|
from: z.string().min(1),
|
||||||
to: z.string().min(1),
|
to: z.string().min(1),
|
||||||
style: z.enum(['solid', 'dashed']).optional(),
|
style: z.enum(['solid', 'dashed']).optional(),
|
||||||
weight: z.number().optional(),
|
weight: z.number().min(0).max(5).optional(),
|
||||||
intent: intentSchema,
|
intent: intentSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -73,8 +73,8 @@ const chartPayloadSchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const heatmapPayloadSchema = z.object({
|
const heatmapPayloadSchema = z.object({
|
||||||
rows: z.number().int().positive(),
|
rows: z.number().int().positive().max(50),
|
||||||
cols: z.number().int().positive(),
|
cols: z.number().int().positive().max(50),
|
||||||
values: z.array(z.array(z.number())),
|
values: z.array(z.array(z.number())),
|
||||||
triangular: z.enum(['lower', 'upper', 'none']).optional(),
|
triangular: z.enum(['lower', 'upper', 'none']).optional(),
|
||||||
rowLabels: z.array(z.string()).optional(),
|
rowLabels: z.array(z.string()).optional(),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const INTERACTIVE_PAGE_CAPS = {
|
|||||||
maxDemosPerPage: 5,
|
maxDemosPerPage: 5,
|
||||||
maxSimsPerPage: 3,
|
maxSimsPerPage: 3,
|
||||||
maxOverviewCards: 4,
|
maxOverviewCards: 4,
|
||||||
minOverviewCards: 2,
|
minOverviewCards: 3,
|
||||||
maxStatsItems: 5,
|
maxStatsItems: 5,
|
||||||
minStatsItems: 2,
|
minStatsItems: 2,
|
||||||
maxJsonBytes: 128 * 1024,
|
maxJsonBytes: 128 * 1024,
|
||||||
@@ -30,8 +30,14 @@ export const PAGE_BLOCK_TYPES = [
|
|||||||
'table',
|
'table',
|
||||||
'image',
|
'image',
|
||||||
'sim',
|
'sim',
|
||||||
|
'steps',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
export const STEPS_CAPS = {
|
||||||
|
minSteps: 2,
|
||||||
|
maxSteps: 12,
|
||||||
|
} as const
|
||||||
|
|
||||||
export const CALLOUT_KINDS = [
|
export const CALLOUT_KINDS = [
|
||||||
'definition',
|
'definition',
|
||||||
'warning',
|
'warning',
|
||||||
@@ -69,6 +75,8 @@ export const PAGE_HUMAN_STRING_KEYS = [
|
|||||||
'intro',
|
'intro',
|
||||||
'xLabel',
|
'xLabel',
|
||||||
'yLabel',
|
'yLabel',
|
||||||
|
// steps blocks
|
||||||
|
'rule',
|
||||||
// inherited from demos (speak etc. scanned via demo validator)
|
// inherited from demos (speak etc. scanned via demo validator)
|
||||||
'speak',
|
'speak',
|
||||||
'text',
|
'text',
|
||||||
|
|||||||
@@ -63,6 +63,33 @@
|
|||||||
"simId": "ts-diagram"
|
"simId": "ts-diagram"
|
||||||
},
|
},
|
||||||
"caption": "Le même cycle sur le diagramme T–s : les aires sont les chaleurs échangées."
|
"caption": "Le même cycle sur le diagramme T–s : les aires sont les chaleurs échangées."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "steps",
|
||||||
|
"title": "Le COP frigorifique, dérivé pas à pas",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"tex": "\\eta_{\\text{Carnot}} = 1 - \\frac{T_c}{T_h}",
|
||||||
|
"rule": "Point de départ — rendement de Carnot",
|
||||||
|
"speak": "Le rendement maximal d'un moteur entre $T_h$ et $T_c$ ne dépend que des températures."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tex": "\\mathrm{COP}_{\\text{PAC}} = \\frac{1}{\\eta_{\\text{Carnot}}} = \\frac{T_h}{T_h - T_c}",
|
||||||
|
"rule": "Inversion — pompe à chaleur",
|
||||||
|
"speak": "La pompe à chaleur est l'inverse du moteur : son COP est l'inverse du rendement."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tex": "\\mathrm{COP}_{\\text{frigo}} = \\mathrm{COP}_{\\text{PAC}} - 1 = \\frac{T_c}{T_h - T_c}",
|
||||||
|
"rule": "Soustraction de 1 — réfrigérateur",
|
||||||
|
"speak": "Le frigo ne compte que la chaleur utile $Q_c$ : on retire 1 au COP de la PAC."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tex": "\\mathrm{COP}_{\\text{frigo}} = \\frac{260}{300 - 260} = 6{,}5",
|
||||||
|
"rule": "Application numérique",
|
||||||
|
"speak": "Avec $T_c = 260$ K et $T_h = 300$ K : le COP maximal vaut 6,5."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"caption": "Chaque ligne découle de la précédente — la règle appliquée est en marge."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export {
|
|||||||
PAGE_BLOCK_TYPES,
|
PAGE_BLOCK_TYPES,
|
||||||
CALLOUT_KINDS,
|
CALLOUT_KINDS,
|
||||||
PAGE_HUMAN_STRING_KEYS,
|
PAGE_HUMAN_STRING_KEYS,
|
||||||
|
STEPS_CAPS,
|
||||||
isPageHumanStringKey,
|
isPageHumanStringKey,
|
||||||
} from './constants'
|
} from './constants'
|
||||||
export { pageSpecV1Schema, pageBlockSchema } from './schema'
|
export { pageSpecV1Schema, pageBlockSchema } from './schema'
|
||||||
@@ -22,4 +23,6 @@ export type {
|
|||||||
CatalogSimRef,
|
CatalogSimRef,
|
||||||
GenericFormulaSim,
|
GenericFormulaSim,
|
||||||
SimBlock,
|
SimBlock,
|
||||||
|
StepsBlock,
|
||||||
|
DerivationStep,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|||||||
@@ -189,6 +189,31 @@ function normalizeBlock(
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === 'steps' || type === 'derivation' || type === 'walkthrough' || type === 'solution' || type === 'proof') {
|
||||||
|
const rawSteps = Array.isArray(obj.steps) ? obj.steps : []
|
||||||
|
const steps = rawSteps
|
||||||
|
.map((st) => {
|
||||||
|
const r = asRecord(st)
|
||||||
|
if (!r) return null
|
||||||
|
const tex = asString(r.tex) || asString(r.latex) || asString(r.equation) || asString(r.math)
|
||||||
|
if (!tex) return null
|
||||||
|
const out: Record<string, unknown> = { tex }
|
||||||
|
const rule = asString(r.rule) || asString(r.transform) || asString(r.action) || asString(r.operation)
|
||||||
|
const speak = asString(r.speak) || asString(r.note) || asString(r.comment)
|
||||||
|
if (rule) out.rule = rule
|
||||||
|
if (speak) out.speak = speak
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
if (steps.length < 2) return null
|
||||||
|
const out: Record<string, unknown> = { type: 'steps', steps }
|
||||||
|
const title = asString(obj.title)
|
||||||
|
if (title) out.title = title
|
||||||
|
const caption = asString(obj.caption)
|
||||||
|
if (caption) out.caption = caption
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import {
|
|||||||
INTERACTIVE_PAGE_SCHEMA_VERSION,
|
INTERACTIVE_PAGE_SCHEMA_VERSION,
|
||||||
PAGE_BLOCK_TYPES,
|
PAGE_BLOCK_TYPES,
|
||||||
SIM_CAPS,
|
SIM_CAPS,
|
||||||
|
STEPS_CAPS,
|
||||||
} from './constants'
|
} from './constants'
|
||||||
|
import { isValidSimParamId } from './sim-eval'
|
||||||
|
|
||||||
const intentSchema = z.enum(INTENT_IDS).optional()
|
const intentSchema = z.enum(INTENT_IDS).optional()
|
||||||
|
|
||||||
@@ -90,6 +92,9 @@ const imageBlock = z.object({
|
|||||||
const simParamIdSchema = z
|
const simParamIdSchema = z
|
||||||
.string()
|
.string()
|
||||||
.regex(/^[A-Za-z_][A-Za-z0-9_]*$/, 'Invalid sim identifier')
|
.regex(/^[A-Za-z_][A-Za-z0-9_]*$/, 'Invalid sim identifier')
|
||||||
|
.refine(isValidSimParamId, {
|
||||||
|
message: 'Identifier collides with a reserved constant/function',
|
||||||
|
})
|
||||||
|
|
||||||
const genericSimParamSchema = z.object({
|
const genericSimParamSchema = z.object({
|
||||||
id: simParamIdSchema,
|
id: simParamIdSchema,
|
||||||
@@ -150,6 +155,22 @@ const simBlock = z.object({
|
|||||||
caption: z.string().optional(),
|
caption: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const stepsBlock = z.object({
|
||||||
|
type: z.literal('steps'),
|
||||||
|
title: z.string().optional(),
|
||||||
|
steps: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
tex: z.string().min(1),
|
||||||
|
rule: z.string().optional(),
|
||||||
|
speak: z.string().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.min(STEPS_CAPS.minSteps)
|
||||||
|
.max(STEPS_CAPS.maxSteps),
|
||||||
|
caption: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
export const pageBlockSchema = z.discriminatedUnion('type', [
|
export const pageBlockSchema = z.discriminatedUnion('type', [
|
||||||
proseBlock,
|
proseBlock,
|
||||||
formulaBlock,
|
formulaBlock,
|
||||||
@@ -160,6 +181,7 @@ export const pageBlockSchema = z.discriminatedUnion('type', [
|
|||||||
tableBlock,
|
tableBlock,
|
||||||
imageBlock,
|
imageBlock,
|
||||||
simBlock,
|
simBlock,
|
||||||
|
stepsBlock,
|
||||||
])
|
])
|
||||||
|
|
||||||
const sectionSchema = z.object({
|
const sectionSchema = z.object({
|
||||||
@@ -199,7 +221,7 @@ export const pageSpecV1Schema = z.object({
|
|||||||
overview: overviewSchema.optional(),
|
overview: overviewSchema.optional(),
|
||||||
sections: z
|
sections: z
|
||||||
.array(sectionSchema)
|
.array(sectionSchema)
|
||||||
.min(1)
|
.min(2)
|
||||||
.max(INTERACTIVE_PAGE_CAPS.maxSections),
|
.max(INTERACTIVE_PAGE_CAPS.maxSections),
|
||||||
footer: z.string().optional(),
|
footer: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -115,6 +115,27 @@ export type SimBlock = {
|
|||||||
caption?: string
|
caption?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step-by-step derivation (Symbolab/Khan style): equation states revealed
|
||||||
|
* line by line with the transformation rule used at each step. Fully
|
||||||
|
* generic — the LLM writes KaTeX + rules, the app renders; no drawing.
|
||||||
|
*/
|
||||||
|
export type DerivationStep = {
|
||||||
|
/** KaTeX of the equation state at this step. */
|
||||||
|
tex: string
|
||||||
|
/** Transformation rule applied to reach this state (e.g. "on sépare les variables"). */
|
||||||
|
rule?: string
|
||||||
|
/** Narration for the Play/Step player (falls back to rule). */
|
||||||
|
speak?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StepsBlock = {
|
||||||
|
type: 'steps'
|
||||||
|
title?: string
|
||||||
|
steps: DerivationStep[]
|
||||||
|
caption?: string
|
||||||
|
}
|
||||||
|
|
||||||
export type PageBlock =
|
export type PageBlock =
|
||||||
| ProseBlock
|
| ProseBlock
|
||||||
| FormulaBlock
|
| FormulaBlock
|
||||||
@@ -125,6 +146,7 @@ export type PageBlock =
|
|||||||
| TableBlock
|
| TableBlock
|
||||||
| ImageBlock
|
| ImageBlock
|
||||||
| SimBlock
|
| SimBlock
|
||||||
|
| StepsBlock
|
||||||
|
|
||||||
export type PageSection = {
|
export type PageSection = {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
isPageHumanStringKey,
|
isPageHumanStringKey,
|
||||||
} from './constants'
|
} from './constants'
|
||||||
import { pageSpecV1Schema } from './schema'
|
import { pageSpecV1Schema } from './schema'
|
||||||
import { validateSimExprRefs } from './sim-eval'
|
import { validateSimExprRefs, isValidSimParamId } from './sim-eval'
|
||||||
import type {
|
import type {
|
||||||
PageBlock,
|
PageBlock,
|
||||||
PageSpecV1,
|
PageSpecV1,
|
||||||
@@ -79,6 +79,13 @@ function validateSim(
|
|||||||
if (sim.simId === 'generic-formula') {
|
if (sim.simId === 'generic-formula') {
|
||||||
const generic = sim as Extract<typeof sim, { simId: 'generic-formula' }>
|
const generic = sim as Extract<typeof sim, { simId: 'generic-formula' }>
|
||||||
const paramIds = new Set(generic.params.map((p) => p.id))
|
const paramIds = new Set(generic.params.map((p) => p.id))
|
||||||
|
if (paramIds.size !== generic.params.length) {
|
||||||
|
out.push(issue('sim_duplicate_id', `${path}.params`, 'Duplicate param id'))
|
||||||
|
}
|
||||||
|
const computedIds = new Set(generic.computed.map((c) => c.id))
|
||||||
|
if (computedIds.size !== generic.computed.length) {
|
||||||
|
out.push(issue('sim_duplicate_id', `${path}.computed`, 'Duplicate computed id'))
|
||||||
|
}
|
||||||
for (const p of generic.params) {
|
for (const p of generic.params) {
|
||||||
if (p.min >= p.max) {
|
if (p.min >= p.max) {
|
||||||
out.push(issue('sim_param_range', `${path}.params`, `Param "${p.id}": min >= max`))
|
out.push(issue('sim_param_range', `${path}.params`, `Param "${p.id}": min >= max`))
|
||||||
|
|||||||
@@ -38,7 +38,24 @@
|
|||||||
"privacyTerms": "© 2025 Memento Labs — الخصوصية · الشروط",
|
"privacyTerms": "© 2025 Memento Labs — الخصوصية · الشروط",
|
||||||
"sessionExpired": "يتم إنشاء موقعك مع التنقل وجدول المحتويات",
|
"sessionExpired": "يتم إنشاء موقعك مع التنقل وجدول المحتويات",
|
||||||
"welcomeBack": "مرحبًا بعودتك",
|
"welcomeBack": "مرحبًا بعودتك",
|
||||||
"welcomeBackSubtitle": "أدخل بيانات الاعتماد للوصول إلى ملاحظاتك."
|
"welcomeBackSubtitle": "أدخل بيانات الاعتماد للوصول إلى ملاحظاتك.",
|
||||||
|
"checkEmailTitle": "تحقق من بريدك الإلكتروني",
|
||||||
|
"checkEmailDescription": "أرسلنا رابط تأكيد إلى {email}. افتحه لتفعيل حسابك قبل تسجيل الدخول.",
|
||||||
|
"checkEmailDescriptionGeneric": "أرسلنا رابط تأكيد إلى بريدك. افتحه لتفعيل حسابك قبل تسجيل الدخول.",
|
||||||
|
"resendVerification": "إعادة إرسال رسالة التأكيد",
|
||||||
|
"verifyResent": "تم إرسال رسالة التأكيد. تحقق من صندوق الوارد.",
|
||||||
|
"verifyResendFailed": "تعذّر إرسال رسالة التأكيد. حاول لاحقًا.",
|
||||||
|
"verifyMissingEmail": "أدخل عنوان بريدك الإلكتروني.",
|
||||||
|
"verifyLoading": "جارٍ تأكيد بريدك…",
|
||||||
|
"verifySuccessTitle": "تم تأكيد البريد",
|
||||||
|
"verifySuccessDescription": "حسابك جاهز. يمكنك تسجيل الدخول الآن.",
|
||||||
|
"verifyExpiredTitle": "انتهت صلاحية الرابط",
|
||||||
|
"verifyExpiredDescription": "انتهت صلاحية رابط التأكيد. اطلب رابطًا جديدًا.",
|
||||||
|
"verifyInvalidTitle": "رابط غير صالح",
|
||||||
|
"verifyInvalidDescription": "رابط التأكيد غير صالح أو سبق استخدامه.",
|
||||||
|
"emailNotVerified": "يرجى تأكيد بريدك قبل تسجيل الدخول.",
|
||||||
|
"emailVerifiedBanner": "تم تأكيد البريد. يمكنك تسجيل الدخول الآن.",
|
||||||
|
"invalidCredentials": "البريد أو كلمة المرور غير صحيحة."
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"notes": "الملاحظات",
|
"notes": "الملاحظات",
|
||||||
@@ -1580,7 +1597,58 @@
|
|||||||
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
||||||
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
||||||
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
||||||
"packsCatalogTitle": "Pack catalogue (code)"
|
"packsCatalogTitle": "Pack catalogue (code)",
|
||||||
|
"healthTitle": "Stripe health check",
|
||||||
|
"healthDescription": "Runtime status of keys, webhooks, price IDs and billing flag (secrets are never shown).",
|
||||||
|
"healthSecret": "Secret key (server)",
|
||||||
|
"healthPublishable": "Publishable key",
|
||||||
|
"healthWebhook": "Webhook secret",
|
||||||
|
"healthBillingFlag": "Billing enabled",
|
||||||
|
"healthTrial": "Free trial",
|
||||||
|
"trialDaysValue": "{days} days on first checkout",
|
||||||
|
"modeTest": "Test mode (sk_test_…)",
|
||||||
|
"modeLive": "Live mode (sk_live_…)",
|
||||||
|
"modePlaceholder": "Placeholder / invalid key",
|
||||||
|
"modeMissing": "Not configured",
|
||||||
|
"configured": "Configured",
|
||||||
|
"missing": "Missing",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"priceStatusTitle": "Price IDs vs Stripe",
|
||||||
|
"colKey": "Plan",
|
||||||
|
"colPriceId": "Price ID",
|
||||||
|
"colSource": "Source",
|
||||||
|
"colStripe": "Stripe amount",
|
||||||
|
"priceError": "Lookup failed",
|
||||||
|
"inactive": "inactive",
|
||||||
|
"notChecked": "Not checked (no Stripe key)",
|
||||||
|
"subsTitle": "Subscriptions overview",
|
||||||
|
"subsDescription": "Counts from the local database (synced via Stripe webhooks).",
|
||||||
|
"statPaid": "Active + trial",
|
||||||
|
"statTrialing": "On trial",
|
||||||
|
"statPastDue": "Past due",
|
||||||
|
"statCanceling": "Cancel at period end",
|
||||||
|
"byTier": "By tier",
|
||||||
|
"byStatus": "By status",
|
||||||
|
"usersWithoutSub": "Users with no Subscription row",
|
||||||
|
"noSubs": "No subscriptions yet",
|
||||||
|
"recentSubs": "Recent paid / trial accounts",
|
||||||
|
"colUser": "User",
|
||||||
|
"colTier": "Tier",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"colPeriod": "Period / trial end",
|
||||||
|
"canceling": "canceling",
|
||||||
|
"manualTier": "manual (no Stripe sub)",
|
||||||
|
"trialUntil": "Trial until {date}",
|
||||||
|
"testGuideTitle": "How to test Stripe locally",
|
||||||
|
"testGuideDescription": "Checklist to validate checkout, webhooks and trial.",
|
||||||
|
"testStep1": "Stripe Dashboard → Test mode ON. Create Pro/Business products + monthly/annual prices + credit packs.",
|
||||||
|
"testStep2": "Put sk_test_…, pk_test_… in .env. Put price_… IDs in Admin → Billing (or env) and enable billing.",
|
||||||
|
"testStep3": "Copy the whsec_… into STRIPE_WEBHOOK_SECRET and restart the app.",
|
||||||
|
"testStep4": "npm run dev → open /settings/billing as a BASIC user.",
|
||||||
|
"testStep5": "Start Pro checkout. Card: 4242 4242 4242 4242, any future expiry, any CVC. Expect a 7-day trial.",
|
||||||
|
"testStep6": "Confirm Admin → Billing shows TRIALING, and /settings/billing shows the trial end date.",
|
||||||
|
"testCardHint": "Other cards: 4000000000009995 = payment fails · 4000002500003155 = 3D Secure. Never use real cards in test mode."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
@@ -3113,7 +3181,11 @@
|
|||||||
"packLName": "حزمة مكثفة",
|
"packLName": "حزمة مكثفة",
|
||||||
"buyPack": "شراء",
|
"buyPack": "شراء",
|
||||||
"packCheckoutSuccess": "تمت إضافة حزمة الأرصدة إلى رصيدك!",
|
"packCheckoutSuccess": "تمت إضافة حزمة الأرصدة إلى رصيدك!",
|
||||||
"packCheckoutFailed": "تعذّر بدء الشراء. تحقق من إعدادات Stripe أو أعد المحاولة."
|
"packCheckoutFailed": "تعذّر بدء الشراء. تحقق من إعدادات Stripe أو أعد المحاولة.",
|
||||||
|
"startTrialCta": "جرّب مجانًا لمدة {days} أيام",
|
||||||
|
"trialFeature": "تجربة مجانية لمدة {days} أيام (بطاقة مطلوبة)",
|
||||||
|
"trialEndsOn": "تنتهي فترتك التجريبية المجانية في {date}. سيتم تحصيل الرسوم تلقائيًا بعد ذلك.",
|
||||||
|
"trialEndsLabel": "نهاية التجربة"
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"nav": {
|
"nav": {
|
||||||
@@ -3295,7 +3367,16 @@
|
|||||||
"feature4": "دعم مخصص",
|
"feature4": "دعم مخصص",
|
||||||
"feature5": "إعداد مباشر"
|
"feature5": "إعداد مباشر"
|
||||||
},
|
},
|
||||||
"basicPrice": "مجاني"
|
"basicPrice": "مجاني",
|
||||||
|
"savePercent": "وفّر حوالي 17%",
|
||||||
|
"proMonthly": "9,90€",
|
||||||
|
"proAnnualMonthly": "8,25€",
|
||||||
|
"businessMonthly": "29,90€",
|
||||||
|
"businessAnnualMonthly": "24,92€",
|
||||||
|
"enterprisePrice": "حسب الطلب",
|
||||||
|
"trialBadge": "تجربة مجانية لمدة {days} أيام",
|
||||||
|
"trialFeature": "تجربة مجانية لمدة {days} أيام (بطاقة مطلوبة)",
|
||||||
|
"trialCta": "جرّب مجانًا لمدة {days} أيام"
|
||||||
},
|
},
|
||||||
"cta": {
|
"cta": {
|
||||||
"title": "توقف عن فقدان أفضل أفكارك.",
|
"title": "توقف عن فقدان أفضل أفكارك.",
|
||||||
|
|||||||
@@ -38,7 +38,24 @@
|
|||||||
"privacyTerms": "© 2025 Memento Labs — Datenschutz · AGB",
|
"privacyTerms": "© 2025 Memento Labs — Datenschutz · AGB",
|
||||||
"sessionExpired": "Ihre Seite wird mit Navigation und Inhaltsverzeichnis generiert",
|
"sessionExpired": "Ihre Seite wird mit Navigation und Inhaltsverzeichnis generiert",
|
||||||
"welcomeBack": "Willkommen zurück",
|
"welcomeBack": "Willkommen zurück",
|
||||||
"welcomeBackSubtitle": "Geben Sie Ihre Anmeldedaten ein, um auf Ihre Notizen zuzugreifen."
|
"welcomeBackSubtitle": "Geben Sie Ihre Anmeldedaten ein, um auf Ihre Notizen zuzugreifen.",
|
||||||
|
"checkEmailTitle": "E-Mail prüfen",
|
||||||
|
"checkEmailDescription": "Wir haben einen Bestätigungslink an {email} gesendet. Öffnen Sie ihn, um Ihr Konto zu aktivieren.",
|
||||||
|
"checkEmailDescriptionGeneric": "Wir haben einen Bestätigungslink an Ihre E-Mail gesendet. Öffnen Sie ihn, um Ihr Konto zu aktivieren.",
|
||||||
|
"resendVerification": "Bestätigungs-E-Mail erneut senden",
|
||||||
|
"verifyResent": "Bestätigungs-E-Mail gesendet. Prüfen Sie Ihren Posteingang.",
|
||||||
|
"verifyResendFailed": "Bestätigungs-E-Mail konnte nicht gesendet werden. Später erneut versuchen.",
|
||||||
|
"verifyMissingEmail": "Geben Sie Ihre E-Mail-Adresse ein.",
|
||||||
|
"verifyLoading": "E-Mail wird bestätigt…",
|
||||||
|
"verifySuccessTitle": "E-Mail bestätigt",
|
||||||
|
"verifySuccessDescription": "Ihr Konto ist bereit. Sie können sich jetzt anmelden.",
|
||||||
|
"verifyExpiredTitle": "Link abgelaufen",
|
||||||
|
"verifyExpiredDescription": "Dieser Bestätigungslink ist abgelaufen. Fordern Sie einen neuen an.",
|
||||||
|
"verifyInvalidTitle": "Ungültiger Link",
|
||||||
|
"verifyInvalidDescription": "Dieser Bestätigungslink ist ungültig oder wurde bereits verwendet.",
|
||||||
|
"emailNotVerified": "Bitte bestätigen Sie Ihre E-Mail, bevor Sie sich anmelden.",
|
||||||
|
"emailVerifiedBanner": "E-Mail bestätigt. Sie können sich jetzt anmelden.",
|
||||||
|
"invalidCredentials": "Ungültige E-Mail oder Passwort."
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"notes": "Notizen",
|
"notes": "Notizen",
|
||||||
@@ -1580,7 +1597,58 @@
|
|||||||
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
||||||
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
||||||
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
||||||
"packsCatalogTitle": "Pack catalogue (code)"
|
"packsCatalogTitle": "Pack catalogue (code)",
|
||||||
|
"healthTitle": "Stripe health check",
|
||||||
|
"healthDescription": "Runtime status of keys, webhooks, price IDs and billing flag (secrets are never shown).",
|
||||||
|
"healthSecret": "Secret key (server)",
|
||||||
|
"healthPublishable": "Publishable key",
|
||||||
|
"healthWebhook": "Webhook secret",
|
||||||
|
"healthBillingFlag": "Billing enabled",
|
||||||
|
"healthTrial": "Free trial",
|
||||||
|
"trialDaysValue": "{days} days on first checkout",
|
||||||
|
"modeTest": "Test mode (sk_test_…)",
|
||||||
|
"modeLive": "Live mode (sk_live_…)",
|
||||||
|
"modePlaceholder": "Placeholder / invalid key",
|
||||||
|
"modeMissing": "Not configured",
|
||||||
|
"configured": "Configured",
|
||||||
|
"missing": "Missing",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"priceStatusTitle": "Price IDs vs Stripe",
|
||||||
|
"colKey": "Plan",
|
||||||
|
"colPriceId": "Price ID",
|
||||||
|
"colSource": "Source",
|
||||||
|
"colStripe": "Stripe amount",
|
||||||
|
"priceError": "Lookup failed",
|
||||||
|
"inactive": "inactive",
|
||||||
|
"notChecked": "Not checked (no Stripe key)",
|
||||||
|
"subsTitle": "Subscriptions overview",
|
||||||
|
"subsDescription": "Counts from the local database (synced via Stripe webhooks).",
|
||||||
|
"statPaid": "Active + trial",
|
||||||
|
"statTrialing": "On trial",
|
||||||
|
"statPastDue": "Past due",
|
||||||
|
"statCanceling": "Cancel at period end",
|
||||||
|
"byTier": "By tier",
|
||||||
|
"byStatus": "By status",
|
||||||
|
"usersWithoutSub": "Users with no Subscription row",
|
||||||
|
"noSubs": "No subscriptions yet",
|
||||||
|
"recentSubs": "Recent paid / trial accounts",
|
||||||
|
"colUser": "User",
|
||||||
|
"colTier": "Tier",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"colPeriod": "Period / trial end",
|
||||||
|
"canceling": "canceling",
|
||||||
|
"manualTier": "manual (no Stripe sub)",
|
||||||
|
"trialUntil": "Trial until {date}",
|
||||||
|
"testGuideTitle": "How to test Stripe locally",
|
||||||
|
"testGuideDescription": "Checklist to validate checkout, webhooks and trial.",
|
||||||
|
"testStep1": "Stripe Dashboard → Test mode ON. Create Pro/Business products + monthly/annual prices + credit packs.",
|
||||||
|
"testStep2": "Put sk_test_…, pk_test_… in .env. Put price_… IDs in Admin → Billing (or env) and enable billing.",
|
||||||
|
"testStep3": "Copy the whsec_… into STRIPE_WEBHOOK_SECRET and restart the app.",
|
||||||
|
"testStep4": "npm run dev → open /settings/billing as a BASIC user.",
|
||||||
|
"testStep5": "Start Pro checkout. Card: 4242 4242 4242 4242, any future expiry, any CVC. Expect a 7-day trial.",
|
||||||
|
"testStep6": "Confirm Admin → Billing shows TRIALING, and /settings/billing shows the trial end date.",
|
||||||
|
"testCardHint": "Other cards: 4000000000009995 = payment fails · 4000002500003155 = 3D Secure. Never use real cards in test mode."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
@@ -3113,7 +3181,11 @@
|
|||||||
"packLName": "Power-Paket",
|
"packLName": "Power-Paket",
|
||||||
"buyPack": "Kaufen",
|
"buyPack": "Kaufen",
|
||||||
"packCheckoutSuccess": "Credit-Paket Ihrem Guthaben hinzugefügt!",
|
"packCheckoutSuccess": "Credit-Paket Ihrem Guthaben hinzugefügt!",
|
||||||
"packCheckoutFailed": "Paketkauf fehlgeschlagen. Stripe-Konfiguration prüfen oder erneut versuchen."
|
"packCheckoutFailed": "Paketkauf fehlgeschlagen. Stripe-Konfiguration prüfen oder erneut versuchen.",
|
||||||
|
"startTrialCta": "{days} Tage kostenlos starten",
|
||||||
|
"trialFeature": "{days} Tage gratis testen (Karte erforderlich)",
|
||||||
|
"trialEndsOn": "Ihre kostenlose Testphase endet am {date}. Danach werden Sie automatisch belastet.",
|
||||||
|
"trialEndsLabel": "Testende"
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"nav": {
|
"nav": {
|
||||||
@@ -3295,7 +3367,16 @@
|
|||||||
"feature4": "Dedizierter Support",
|
"feature4": "Dedizierter Support",
|
||||||
"feature5": "Live-Onboarding"
|
"feature5": "Live-Onboarding"
|
||||||
},
|
},
|
||||||
"basicPrice": "Kostenlos"
|
"basicPrice": "Kostenlos",
|
||||||
|
"savePercent": "~17% sparen",
|
||||||
|
"proMonthly": "9,90€",
|
||||||
|
"proAnnualMonthly": "8,25€",
|
||||||
|
"businessMonthly": "29,90€",
|
||||||
|
"businessAnnualMonthly": "24,92€",
|
||||||
|
"enterprisePrice": "Individuell",
|
||||||
|
"trialBadge": "{days} Tage gratis testen",
|
||||||
|
"trialFeature": "{days} Tage gratis testen (Karte erforderlich)",
|
||||||
|
"trialCta": "{days} Tage kostenlos starten"
|
||||||
},
|
},
|
||||||
"cta": {
|
"cta": {
|
||||||
"title": "Hören Sie auf, Ihre besten Ideen zu verlieren.",
|
"title": "Hören Sie auf, Ihre besten Ideen zu verlieren.",
|
||||||
|
|||||||
@@ -321,6 +321,7 @@
|
|||||||
"switchType": "Switch to {type}",
|
"switchType": "Switch to {type}",
|
||||||
"saveNow": "Save now",
|
"saveNow": "Save now",
|
||||||
"backToCollection": "Back to collection",
|
"backToCollection": "Back to collection",
|
||||||
|
"backToDashboard": "Back to dashboard",
|
||||||
"markdownEditingTitle": "Return to editing",
|
"markdownEditingTitle": "Return to editing",
|
||||||
"markdownPreviewTitle": "Preview",
|
"markdownPreviewTitle": "Preview",
|
||||||
"brainstormThisIdea": "Brainstorm this idea",
|
"brainstormThisIdea": "Brainstorm this idea",
|
||||||
@@ -4288,6 +4289,8 @@
|
|||||||
"toReview": "To review",
|
"toReview": "To review",
|
||||||
"allCaughtUp": "All caught up.",
|
"allCaughtUp": "All caught up.",
|
||||||
"toOrganize": "to organize",
|
"toOrganize": "to organize",
|
||||||
|
"inboxSeeAll": "See all {count}",
|
||||||
|
"inboxEmpty": "Inbox is empty.",
|
||||||
"review": "Review",
|
"review": "Review",
|
||||||
"cardsDue": "cards due",
|
"cardsDue": "cards due",
|
||||||
"reminders": "Reminders",
|
"reminders": "Reminders",
|
||||||
|
|||||||
@@ -38,7 +38,24 @@
|
|||||||
"privacyTerms": "© 2025 Memento Labs — Privacidad · Términos",
|
"privacyTerms": "© 2025 Memento Labs — Privacidad · Términos",
|
||||||
"sessionExpired": "Tu sitio se genera con navegación y tabla de contenidos",
|
"sessionExpired": "Tu sitio se genera con navegación y tabla de contenidos",
|
||||||
"welcomeBack": "Bienvenido de nuevo",
|
"welcomeBack": "Bienvenido de nuevo",
|
||||||
"welcomeBackSubtitle": "Introduce tus credenciales para acceder a tus notas."
|
"welcomeBackSubtitle": "Introduce tus credenciales para acceder a tus notas.",
|
||||||
|
"checkEmailTitle": "Revisa tu correo",
|
||||||
|
"checkEmailDescription": "Enviamos un enlace de confirmación a {email}. Ábrelo para activar tu cuenta antes de iniciar sesión.",
|
||||||
|
"checkEmailDescriptionGeneric": "Enviamos un enlace de confirmación a tu correo. Ábrelo para activar tu cuenta antes de iniciar sesión.",
|
||||||
|
"resendVerification": "Reenviar correo de confirmación",
|
||||||
|
"verifyResent": "Correo de confirmación enviado. Revisa tu bandeja de entrada.",
|
||||||
|
"verifyResendFailed": "No se pudo enviar el correo de confirmación. Inténtalo más tarde.",
|
||||||
|
"verifyMissingEmail": "Introduce tu dirección de correo.",
|
||||||
|
"verifyLoading": "Confirmando tu correo…",
|
||||||
|
"verifySuccessTitle": "Correo confirmado",
|
||||||
|
"verifySuccessDescription": "Tu cuenta está lista. Ya puedes iniciar sesión.",
|
||||||
|
"verifyExpiredTitle": "Enlace caducado",
|
||||||
|
"verifyExpiredDescription": "Este enlace de confirmación ha caducado. Solicita uno nuevo.",
|
||||||
|
"verifyInvalidTitle": "Enlace no válido",
|
||||||
|
"verifyInvalidDescription": "Este enlace de confirmación no es válido o ya se usó.",
|
||||||
|
"emailNotVerified": "Confirma tu correo antes de iniciar sesión.",
|
||||||
|
"emailVerifiedBanner": "Correo confirmado. Ya puedes iniciar sesión.",
|
||||||
|
"invalidCredentials": "Correo o contraseña incorrectos."
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"notes": "Notas",
|
"notes": "Notas",
|
||||||
@@ -1580,7 +1597,58 @@
|
|||||||
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
||||||
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
||||||
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
||||||
"packsCatalogTitle": "Pack catalogue (code)"
|
"packsCatalogTitle": "Pack catalogue (code)",
|
||||||
|
"healthTitle": "Stripe health check",
|
||||||
|
"healthDescription": "Runtime status of keys, webhooks, price IDs and billing flag (secrets are never shown).",
|
||||||
|
"healthSecret": "Secret key (server)",
|
||||||
|
"healthPublishable": "Publishable key",
|
||||||
|
"healthWebhook": "Webhook secret",
|
||||||
|
"healthBillingFlag": "Billing enabled",
|
||||||
|
"healthTrial": "Free trial",
|
||||||
|
"trialDaysValue": "{days} days on first checkout",
|
||||||
|
"modeTest": "Test mode (sk_test_…)",
|
||||||
|
"modeLive": "Live mode (sk_live_…)",
|
||||||
|
"modePlaceholder": "Placeholder / invalid key",
|
||||||
|
"modeMissing": "Not configured",
|
||||||
|
"configured": "Configured",
|
||||||
|
"missing": "Missing",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"priceStatusTitle": "Price IDs vs Stripe",
|
||||||
|
"colKey": "Plan",
|
||||||
|
"colPriceId": "Price ID",
|
||||||
|
"colSource": "Source",
|
||||||
|
"colStripe": "Stripe amount",
|
||||||
|
"priceError": "Lookup failed",
|
||||||
|
"inactive": "inactive",
|
||||||
|
"notChecked": "Not checked (no Stripe key)",
|
||||||
|
"subsTitle": "Subscriptions overview",
|
||||||
|
"subsDescription": "Counts from the local database (synced via Stripe webhooks).",
|
||||||
|
"statPaid": "Active + trial",
|
||||||
|
"statTrialing": "On trial",
|
||||||
|
"statPastDue": "Past due",
|
||||||
|
"statCanceling": "Cancel at period end",
|
||||||
|
"byTier": "By tier",
|
||||||
|
"byStatus": "By status",
|
||||||
|
"usersWithoutSub": "Users with no Subscription row",
|
||||||
|
"noSubs": "No subscriptions yet",
|
||||||
|
"recentSubs": "Recent paid / trial accounts",
|
||||||
|
"colUser": "User",
|
||||||
|
"colTier": "Tier",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"colPeriod": "Period / trial end",
|
||||||
|
"canceling": "canceling",
|
||||||
|
"manualTier": "manual (no Stripe sub)",
|
||||||
|
"trialUntil": "Trial until {date}",
|
||||||
|
"testGuideTitle": "How to test Stripe locally",
|
||||||
|
"testGuideDescription": "Checklist to validate checkout, webhooks and trial.",
|
||||||
|
"testStep1": "Stripe Dashboard → Test mode ON. Create Pro/Business products + monthly/annual prices + credit packs.",
|
||||||
|
"testStep2": "Put sk_test_…, pk_test_… in .env. Put price_… IDs in Admin → Billing (or env) and enable billing.",
|
||||||
|
"testStep3": "Copy the whsec_… into STRIPE_WEBHOOK_SECRET and restart the app.",
|
||||||
|
"testStep4": "npm run dev → open /settings/billing as a BASIC user.",
|
||||||
|
"testStep5": "Start Pro checkout. Card: 4242 4242 4242 4242, any future expiry, any CVC. Expect a 7-day trial.",
|
||||||
|
"testStep6": "Confirm Admin → Billing shows TRIALING, and /settings/billing shows the trial end date.",
|
||||||
|
"testCardHint": "Other cards: 4000000000009995 = payment fails · 4000002500003155 = 3D Secure. Never use real cards in test mode."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
@@ -3113,7 +3181,11 @@
|
|||||||
"packLName": "Paquete intensivo",
|
"packLName": "Paquete intensivo",
|
||||||
"buyPack": "Comprar",
|
"buyPack": "Comprar",
|
||||||
"packCheckoutSuccess": "¡Paquete de créditos añadido a su saldo!",
|
"packCheckoutSuccess": "¡Paquete de créditos añadido a su saldo!",
|
||||||
"packCheckoutFailed": "No se pudo iniciar la compra. Compruebe la config de Stripe o inténtelo de nuevo."
|
"packCheckoutFailed": "No se pudo iniciar la compra. Compruebe la config de Stripe o inténtelo de nuevo.",
|
||||||
|
"startTrialCta": "Probar {days} días gratis",
|
||||||
|
"trialFeature": "Prueba gratis de {days} días (tarjeta requerida)",
|
||||||
|
"trialEndsOn": "Tu prueba gratuita termina el {date}. Después se te cobrará automáticamente.",
|
||||||
|
"trialEndsLabel": "Fin de la prueba"
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"nav": {
|
"nav": {
|
||||||
@@ -3295,7 +3367,16 @@
|
|||||||
"feature4": "Soporte dedicado",
|
"feature4": "Soporte dedicado",
|
||||||
"feature5": "Onboarding en vivo"
|
"feature5": "Onboarding en vivo"
|
||||||
},
|
},
|
||||||
"basicPrice": "Gratis"
|
"basicPrice": "Gratis",
|
||||||
|
"savePercent": "Ahorra ~17%",
|
||||||
|
"proMonthly": "9,90€",
|
||||||
|
"proAnnualMonthly": "8,25€",
|
||||||
|
"businessMonthly": "29,90€",
|
||||||
|
"businessAnnualMonthly": "24,92€",
|
||||||
|
"enterprisePrice": "A medida",
|
||||||
|
"trialBadge": "Prueba gratis {days} días",
|
||||||
|
"trialFeature": "Prueba gratis de {days} días (tarjeta requerida)",
|
||||||
|
"trialCta": "Probar {days} días gratis"
|
||||||
},
|
},
|
||||||
"cta": {
|
"cta": {
|
||||||
"title": "Deja de perder tus mejores ideas.",
|
"title": "Deja de perder tus mejores ideas.",
|
||||||
|
|||||||
@@ -38,7 +38,24 @@
|
|||||||
"privacyTerms": "© ۲۰۲۵ Memento Labs — حریم خصوصی · شرایط",
|
"privacyTerms": "© ۲۰۲۵ Memento Labs — حریم خصوصی · شرایط",
|
||||||
"sessionExpired": "سایت شما با ناوبری و فهرست مطالب تولید میشود",
|
"sessionExpired": "سایت شما با ناوبری و فهرست مطالب تولید میشود",
|
||||||
"welcomeBack": "خوش آمدید",
|
"welcomeBack": "خوش آمدید",
|
||||||
"welcomeBackSubtitle": "اعتبارنامههای خود را برای دسترسی به یادداشتهایتان وارد کنید."
|
"welcomeBackSubtitle": "اعتبارنامههای خود را برای دسترسی به یادداشتهایتان وارد کنید.",
|
||||||
|
"checkEmailTitle": "ایمیل خود را بررسی کنید",
|
||||||
|
"checkEmailDescription": "لینک تأیید را به {email} فرستادیم. قبل از ورود آن را باز کنید تا حساب فعال شود.",
|
||||||
|
"checkEmailDescriptionGeneric": "لینک تأیید را به ایمیل شما فرستادیم. قبل از ورود آن را باز کنید تا حساب فعال شود.",
|
||||||
|
"resendVerification": "ارسال دوباره ایمیل تأیید",
|
||||||
|
"verifyResent": "ایمیل تأیید ارسال شد. صندوق ورودی را بررسی کنید.",
|
||||||
|
"verifyResendFailed": "ارسال ایمیل تأیید ممکن نشد. بعداً دوباره تلاش کنید.",
|
||||||
|
"verifyMissingEmail": "آدرس ایمیل خود را وارد کنید.",
|
||||||
|
"verifyLoading": "در حال تأیید ایمیل…",
|
||||||
|
"verifySuccessTitle": "ایمیل تأیید شد",
|
||||||
|
"verifySuccessDescription": "حساب شما آماده است. اکنون میتوانید وارد شوید.",
|
||||||
|
"verifyExpiredTitle": "لینک منقضی شده",
|
||||||
|
"verifyExpiredDescription": "این لینک تأیید منقضی شده است. یک لینک جدید درخواست کنید.",
|
||||||
|
"verifyInvalidTitle": "لینک نامعتبر",
|
||||||
|
"verifyInvalidDescription": "این لینک تأیید نامعتبر است یا قبلاً استفاده شده.",
|
||||||
|
"emailNotVerified": "قبل از ورود ایمیل خود را تأیید کنید.",
|
||||||
|
"emailVerifiedBanner": "ایمیل تأیید شد. اکنون میتوانید وارد شوید.",
|
||||||
|
"invalidCredentials": "ایمیل یا رمز عبور نادرست است."
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"notes": "یادداشتها",
|
"notes": "یادداشتها",
|
||||||
@@ -1580,7 +1597,58 @@
|
|||||||
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
||||||
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
||||||
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
||||||
"packsCatalogTitle": "Pack catalogue (code)"
|
"packsCatalogTitle": "Pack catalogue (code)",
|
||||||
|
"healthTitle": "Stripe health check",
|
||||||
|
"healthDescription": "Runtime status of keys, webhooks, price IDs and billing flag (secrets are never shown).",
|
||||||
|
"healthSecret": "Secret key (server)",
|
||||||
|
"healthPublishable": "Publishable key",
|
||||||
|
"healthWebhook": "Webhook secret",
|
||||||
|
"healthBillingFlag": "Billing enabled",
|
||||||
|
"healthTrial": "Free trial",
|
||||||
|
"trialDaysValue": "{days} days on first checkout",
|
||||||
|
"modeTest": "Test mode (sk_test_…)",
|
||||||
|
"modeLive": "Live mode (sk_live_…)",
|
||||||
|
"modePlaceholder": "Placeholder / invalid key",
|
||||||
|
"modeMissing": "Not configured",
|
||||||
|
"configured": "Configured",
|
||||||
|
"missing": "Missing",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"priceStatusTitle": "Price IDs vs Stripe",
|
||||||
|
"colKey": "Plan",
|
||||||
|
"colPriceId": "Price ID",
|
||||||
|
"colSource": "Source",
|
||||||
|
"colStripe": "Stripe amount",
|
||||||
|
"priceError": "Lookup failed",
|
||||||
|
"inactive": "inactive",
|
||||||
|
"notChecked": "Not checked (no Stripe key)",
|
||||||
|
"subsTitle": "Subscriptions overview",
|
||||||
|
"subsDescription": "Counts from the local database (synced via Stripe webhooks).",
|
||||||
|
"statPaid": "Active + trial",
|
||||||
|
"statTrialing": "On trial",
|
||||||
|
"statPastDue": "Past due",
|
||||||
|
"statCanceling": "Cancel at period end",
|
||||||
|
"byTier": "By tier",
|
||||||
|
"byStatus": "By status",
|
||||||
|
"usersWithoutSub": "Users with no Subscription row",
|
||||||
|
"noSubs": "No subscriptions yet",
|
||||||
|
"recentSubs": "Recent paid / trial accounts",
|
||||||
|
"colUser": "User",
|
||||||
|
"colTier": "Tier",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"colPeriod": "Period / trial end",
|
||||||
|
"canceling": "canceling",
|
||||||
|
"manualTier": "manual (no Stripe sub)",
|
||||||
|
"trialUntil": "Trial until {date}",
|
||||||
|
"testGuideTitle": "How to test Stripe locally",
|
||||||
|
"testGuideDescription": "Checklist to validate checkout, webhooks and trial.",
|
||||||
|
"testStep1": "Stripe Dashboard → Test mode ON. Create Pro/Business products + monthly/annual prices + credit packs.",
|
||||||
|
"testStep2": "Put sk_test_…, pk_test_… in .env. Put price_… IDs in Admin → Billing (or env) and enable billing.",
|
||||||
|
"testStep3": "Copy the whsec_… into STRIPE_WEBHOOK_SECRET and restart the app.",
|
||||||
|
"testStep4": "npm run dev → open /settings/billing as a BASIC user.",
|
||||||
|
"testStep5": "Start Pro checkout. Card: 4242 4242 4242 4242, any future expiry, any CVC. Expect a 7-day trial.",
|
||||||
|
"testStep6": "Confirm Admin → Billing shows TRIALING, and /settings/billing shows the trial end date.",
|
||||||
|
"testCardHint": "Other cards: 4000000000009995 = payment fails · 4000002500003155 = 3D Secure. Never use real cards in test mode."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
@@ -3113,7 +3181,11 @@
|
|||||||
"packLName": "بسته قدرتمند",
|
"packLName": "بسته قدرتمند",
|
||||||
"buyPack": "خرید",
|
"buyPack": "خرید",
|
||||||
"packCheckoutSuccess": "بسته اعتبار به موجودی شما اضافه شد!",
|
"packCheckoutSuccess": "بسته اعتبار به موجودی شما اضافه شد!",
|
||||||
"packCheckoutFailed": "شروع خرید ممکن نشد. پیکربندی Stripe را بررسی کنید یا دوباره تلاش کنید."
|
"packCheckoutFailed": "شروع خرید ممکن نشد. پیکربندی Stripe را بررسی کنید یا دوباره تلاش کنید.",
|
||||||
|
"startTrialCta": "شروع آزمایش رایگان {days} روزه",
|
||||||
|
"trialFeature": "آزمایش رایگان {days} روزه (نیاز به کارت)",
|
||||||
|
"trialEndsOn": "آزمایش رایگان شما در {date} تمام میشود. سپس بهطور خودکار صورتحساب صادر میشود.",
|
||||||
|
"trialEndsLabel": "پایان آزمایش"
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"nav": {
|
"nav": {
|
||||||
@@ -3295,7 +3367,16 @@
|
|||||||
"feature4": "پشتیبانی اختصاصی",
|
"feature4": "پشتیبانی اختصاصی",
|
||||||
"feature5": "آنبوردینگ زنده"
|
"feature5": "آنبوردینگ زنده"
|
||||||
},
|
},
|
||||||
"basicPrice": "رایگان"
|
"basicPrice": "رایگان",
|
||||||
|
"savePercent": "حدود ۱۷٪ صرفهجویی",
|
||||||
|
"proMonthly": "۹٫۹۰€",
|
||||||
|
"proAnnualMonthly": "۸٫۲۵€",
|
||||||
|
"businessMonthly": "۲۹٫۹۰€",
|
||||||
|
"businessAnnualMonthly": "۲۴٫۹۲€",
|
||||||
|
"enterprisePrice": "قیمت سفارشی",
|
||||||
|
"trialBadge": "آزمایش رایگان {days} روزه",
|
||||||
|
"trialFeature": "آزمایش رایگان {days} روزه (نیاز به کارت)",
|
||||||
|
"trialCta": "شروع آزمایش رایگان {days} روزه"
|
||||||
},
|
},
|
||||||
"cta": {
|
"cta": {
|
||||||
"title": "از دست دادن بهترین ایدهها را متوقف کنید.",
|
"title": "از دست دادن بهترین ایدهها را متوقف کنید.",
|
||||||
|
|||||||
@@ -323,6 +323,7 @@
|
|||||||
"switchType": "Passer en {type}",
|
"switchType": "Passer en {type}",
|
||||||
"saveNow": "Enregistrer maintenant",
|
"saveNow": "Enregistrer maintenant",
|
||||||
"backToCollection": "Retour à la collection",
|
"backToCollection": "Retour à la collection",
|
||||||
|
"backToDashboard": "Retour au dashboard",
|
||||||
"markdownEditingTitle": "Revenir à l'édition",
|
"markdownEditingTitle": "Revenir à l'édition",
|
||||||
"markdownPreviewTitle": "Aperçu",
|
"markdownPreviewTitle": "Aperçu",
|
||||||
"brainstormThisIdea": "Brainstormer cette idée",
|
"brainstormThisIdea": "Brainstormer cette idée",
|
||||||
@@ -4294,6 +4295,8 @@
|
|||||||
"toReview": "À traiter",
|
"toReview": "À traiter",
|
||||||
"allCaughtUp": "Tout est à jour.",
|
"allCaughtUp": "Tout est à jour.",
|
||||||
"toOrganize": "à organiser",
|
"toOrganize": "à organiser",
|
||||||
|
"inboxSeeAll": "Voir les {count}",
|
||||||
|
"inboxEmpty": "Rien à classer.",
|
||||||
"review": "Révisions",
|
"review": "Révisions",
|
||||||
"cardsDue": "cartes dues",
|
"cardsDue": "cartes dues",
|
||||||
"reminders": "Rappels",
|
"reminders": "Rappels",
|
||||||
|
|||||||
@@ -38,7 +38,24 @@
|
|||||||
"privacyTerms": "© 2025 Memento Labs — गोपनीयता · शर्तें",
|
"privacyTerms": "© 2025 Memento Labs — गोपनीयता · शर्तें",
|
||||||
"sessionExpired": "आपकी साइट नेविगेशन और विषय-सूची के साथ बनाई जाती है",
|
"sessionExpired": "आपकी साइट नेविगेशन और विषय-सूची के साथ बनाई जाती है",
|
||||||
"welcomeBack": "वापसी पर स्वागत है",
|
"welcomeBack": "वापसी पर स्वागत है",
|
||||||
"welcomeBackSubtitle": "अपने नोट्स तक पहुँचने के लिए अपनी प्रमाणीकरण जानकारी दर्ज करें।"
|
"welcomeBackSubtitle": "अपने नोट्स तक पहुँचने के लिए अपनी प्रमाणीकरण जानकारी दर्ज करें।",
|
||||||
|
"checkEmailTitle": "अपना ईमेल देखें",
|
||||||
|
"checkEmailDescription": "हमने {email} पर पुष्टि लिंक भेजा है। साइन इन से पहले खाता सक्रिय करने के लिए इसे खोलें।",
|
||||||
|
"checkEmailDescriptionGeneric": "हमने आपके ईमेल पर पुष्टि लिंक भेजा है। साइन इन से पहले खाता सक्रिय करने के लिए इसे खोलें।",
|
||||||
|
"resendVerification": "पुष्टि ईमेल फिर से भेजें",
|
||||||
|
"verifyResent": "पुष्टि ईमेल भेजा गया। इनबॉक्स देखें।",
|
||||||
|
"verifyResendFailed": "पुष्टि ईमेल नहीं भेजा जा सका। बाद में फिर कोशिश करें।",
|
||||||
|
"verifyMissingEmail": "अपना ईमेल पता दर्ज करें।",
|
||||||
|
"verifyLoading": "ईमेल की पुष्टि हो रही है…",
|
||||||
|
"verifySuccessTitle": "ईमेल पुष्टि हो गई",
|
||||||
|
"verifySuccessDescription": "आपका खाता तैयार है। अब साइन इन कर सकते हैं।",
|
||||||
|
"verifyExpiredTitle": "लिंक समाप्त",
|
||||||
|
"verifyExpiredDescription": "यह पुष्टि लिंक समाप्त हो गया है। नया लिंक माँगें।",
|
||||||
|
"verifyInvalidTitle": "अमान्य लिंक",
|
||||||
|
"verifyInvalidDescription": "यह पुष्टि लिंक अमान्य है या पहले ही उपयोग हो चुका है।",
|
||||||
|
"emailNotVerified": "साइन इन से पहले अपना ईमेल पुष्टि करें।",
|
||||||
|
"emailVerifiedBanner": "ईमेल पुष्टि हो गई। अब साइन इन करें।",
|
||||||
|
"invalidCredentials": "ईमेल या पासवर्ड गलत है।"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"notes": "नोट्स",
|
"notes": "नोट्स",
|
||||||
@@ -1580,7 +1597,58 @@
|
|||||||
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
||||||
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
||||||
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
||||||
"packsCatalogTitle": "Pack catalogue (code)"
|
"packsCatalogTitle": "Pack catalogue (code)",
|
||||||
|
"healthTitle": "Stripe health check",
|
||||||
|
"healthDescription": "Runtime status of keys, webhooks, price IDs and billing flag (secrets are never shown).",
|
||||||
|
"healthSecret": "Secret key (server)",
|
||||||
|
"healthPublishable": "Publishable key",
|
||||||
|
"healthWebhook": "Webhook secret",
|
||||||
|
"healthBillingFlag": "Billing enabled",
|
||||||
|
"healthTrial": "Free trial",
|
||||||
|
"trialDaysValue": "{days} days on first checkout",
|
||||||
|
"modeTest": "Test mode (sk_test_…)",
|
||||||
|
"modeLive": "Live mode (sk_live_…)",
|
||||||
|
"modePlaceholder": "Placeholder / invalid key",
|
||||||
|
"modeMissing": "Not configured",
|
||||||
|
"configured": "Configured",
|
||||||
|
"missing": "Missing",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"priceStatusTitle": "Price IDs vs Stripe",
|
||||||
|
"colKey": "Plan",
|
||||||
|
"colPriceId": "Price ID",
|
||||||
|
"colSource": "Source",
|
||||||
|
"colStripe": "Stripe amount",
|
||||||
|
"priceError": "Lookup failed",
|
||||||
|
"inactive": "inactive",
|
||||||
|
"notChecked": "Not checked (no Stripe key)",
|
||||||
|
"subsTitle": "Subscriptions overview",
|
||||||
|
"subsDescription": "Counts from the local database (synced via Stripe webhooks).",
|
||||||
|
"statPaid": "Active + trial",
|
||||||
|
"statTrialing": "On trial",
|
||||||
|
"statPastDue": "Past due",
|
||||||
|
"statCanceling": "Cancel at period end",
|
||||||
|
"byTier": "By tier",
|
||||||
|
"byStatus": "By status",
|
||||||
|
"usersWithoutSub": "Users with no Subscription row",
|
||||||
|
"noSubs": "No subscriptions yet",
|
||||||
|
"recentSubs": "Recent paid / trial accounts",
|
||||||
|
"colUser": "User",
|
||||||
|
"colTier": "Tier",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"colPeriod": "Period / trial end",
|
||||||
|
"canceling": "canceling",
|
||||||
|
"manualTier": "manual (no Stripe sub)",
|
||||||
|
"trialUntil": "Trial until {date}",
|
||||||
|
"testGuideTitle": "How to test Stripe locally",
|
||||||
|
"testGuideDescription": "Checklist to validate checkout, webhooks and trial.",
|
||||||
|
"testStep1": "Stripe Dashboard → Test mode ON. Create Pro/Business products + monthly/annual prices + credit packs.",
|
||||||
|
"testStep2": "Put sk_test_…, pk_test_… in .env. Put price_… IDs in Admin → Billing (or env) and enable billing.",
|
||||||
|
"testStep3": "Copy the whsec_… into STRIPE_WEBHOOK_SECRET and restart the app.",
|
||||||
|
"testStep4": "npm run dev → open /settings/billing as a BASIC user.",
|
||||||
|
"testStep5": "Start Pro checkout. Card: 4242 4242 4242 4242, any future expiry, any CVC. Expect a 7-day trial.",
|
||||||
|
"testStep6": "Confirm Admin → Billing shows TRIALING, and /settings/billing shows the trial end date.",
|
||||||
|
"testCardHint": "Other cards: 4000000000009995 = payment fails · 4000002500003155 = 3D Secure. Never use real cards in test mode."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
@@ -3113,7 +3181,11 @@
|
|||||||
"packLName": "पावर पैक",
|
"packLName": "पावर पैक",
|
||||||
"buyPack": "खरीदें",
|
"buyPack": "खरीदें",
|
||||||
"packCheckoutSuccess": "क्रेडिट पैक आपके शेष में जोड़ा गया!",
|
"packCheckoutSuccess": "क्रेडिट पैक आपके शेष में जोड़ा गया!",
|
||||||
"packCheckoutFailed": "खरीद शुरू नहीं हो सकी। Stripe कॉन्फ़िग जांचें या पुनः प्रयास करें।"
|
"packCheckoutFailed": "खरीद शुरू नहीं हो सकी। Stripe कॉन्फ़िग जांचें या पुनः प्रयास करें।",
|
||||||
|
"startTrialCta": "{days} दिन मुफ़्त आज़माएँ",
|
||||||
|
"trialFeature": "{days} दिन का मुफ़्त ट्रायल (कार्ड आवश्यक)",
|
||||||
|
"trialEndsOn": "आपका मुफ़्त ट्रायल {date} को समाप्त होता है। उसके बाद स्वचालित रूप से शुल्क लगेगा।",
|
||||||
|
"trialEndsLabel": "ट्रायल समाप्त"
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"nav": {
|
"nav": {
|
||||||
@@ -3295,7 +3367,16 @@
|
|||||||
"feature4": "समर्पित सपोर्ट",
|
"feature4": "समर्पित सपोर्ट",
|
||||||
"feature5": "लाइव ऑनबोर्डिंग"
|
"feature5": "लाइव ऑनबोर्डिंग"
|
||||||
},
|
},
|
||||||
"basicPrice": "मुफ़्त"
|
"basicPrice": "मुफ़्त",
|
||||||
|
"savePercent": "~17% बचाएँ",
|
||||||
|
"proMonthly": "€9.90",
|
||||||
|
"proAnnualMonthly": "€8.25",
|
||||||
|
"businessMonthly": "€29.90",
|
||||||
|
"businessAnnualMonthly": "€24.92",
|
||||||
|
"enterprisePrice": "कस्टम",
|
||||||
|
"trialBadge": "{days} दिन का मुफ़्त ट्रायल",
|
||||||
|
"trialFeature": "{days} दिन का मुफ़्त ट्रायल (कार्ड आवश्यक)",
|
||||||
|
"trialCta": "{days} दिन मुफ़्त आज़माएँ"
|
||||||
},
|
},
|
||||||
"cta": {
|
"cta": {
|
||||||
"title": "अपने सबसे अच्छे विचारों को खोना बंद करें।",
|
"title": "अपने सबसे अच्छे विचारों को खोना बंद करें।",
|
||||||
|
|||||||
@@ -38,7 +38,24 @@
|
|||||||
"privacyTerms": "© 2025 Memento Labs — Privacy · Termini",
|
"privacyTerms": "© 2025 Memento Labs — Privacy · Termini",
|
||||||
"sessionExpired": "Il tuo sito viene generato con navigazione e sommario",
|
"sessionExpired": "Il tuo sito viene generato con navigazione e sommario",
|
||||||
"welcomeBack": "Bentornato",
|
"welcomeBack": "Bentornato",
|
||||||
"welcomeBackSubtitle": "Inserisci le tue credenziali per accedere alle tue note."
|
"welcomeBackSubtitle": "Inserisci le tue credenziali per accedere alle tue note.",
|
||||||
|
"checkEmailTitle": "Controlla la tua e-mail",
|
||||||
|
"checkEmailDescription": "Abbiamo inviato un link di conferma a {email}. Aprilo per attivare l’account prima di accedere.",
|
||||||
|
"checkEmailDescriptionGeneric": "Abbiamo inviato un link di conferma alla tua e-mail. Aprilo per attivare l’account prima di accedere.",
|
||||||
|
"resendVerification": "Reinvia e-mail di conferma",
|
||||||
|
"verifyResent": "E-mail di conferma inviata. Controlla la posta in arrivo.",
|
||||||
|
"verifyResendFailed": "Impossibile inviare l’e-mail di conferma. Riprova più tardi.",
|
||||||
|
"verifyMissingEmail": "Inserisci il tuo indirizzo e-mail.",
|
||||||
|
"verifyLoading": "Conferma dell’e-mail in corso…",
|
||||||
|
"verifySuccessTitle": "E-mail confermata",
|
||||||
|
"verifySuccessDescription": "Il tuo account è pronto. Ora puoi accedere.",
|
||||||
|
"verifyExpiredTitle": "Link scaduto",
|
||||||
|
"verifyExpiredDescription": "Questo link di conferma è scaduto. Richiedine uno nuovo.",
|
||||||
|
"verifyInvalidTitle": "Link non valido",
|
||||||
|
"verifyInvalidDescription": "Questo link di conferma non è valido o è già stato usato.",
|
||||||
|
"emailNotVerified": "Conferma la tua e-mail prima di accedere.",
|
||||||
|
"emailVerifiedBanner": "E-mail confermata. Ora puoi accedere.",
|
||||||
|
"invalidCredentials": "E-mail o password non validi."
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"notes": "Note",
|
"notes": "Note",
|
||||||
@@ -1580,7 +1597,58 @@
|
|||||||
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
||||||
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
||||||
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
||||||
"packsCatalogTitle": "Pack catalogue (code)"
|
"packsCatalogTitle": "Pack catalogue (code)",
|
||||||
|
"healthTitle": "Stripe health check",
|
||||||
|
"healthDescription": "Runtime status of keys, webhooks, price IDs and billing flag (secrets are never shown).",
|
||||||
|
"healthSecret": "Secret key (server)",
|
||||||
|
"healthPublishable": "Publishable key",
|
||||||
|
"healthWebhook": "Webhook secret",
|
||||||
|
"healthBillingFlag": "Billing enabled",
|
||||||
|
"healthTrial": "Free trial",
|
||||||
|
"trialDaysValue": "{days} days on first checkout",
|
||||||
|
"modeTest": "Test mode (sk_test_…)",
|
||||||
|
"modeLive": "Live mode (sk_live_…)",
|
||||||
|
"modePlaceholder": "Placeholder / invalid key",
|
||||||
|
"modeMissing": "Not configured",
|
||||||
|
"configured": "Configured",
|
||||||
|
"missing": "Missing",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"priceStatusTitle": "Price IDs vs Stripe",
|
||||||
|
"colKey": "Plan",
|
||||||
|
"colPriceId": "Price ID",
|
||||||
|
"colSource": "Source",
|
||||||
|
"colStripe": "Stripe amount",
|
||||||
|
"priceError": "Lookup failed",
|
||||||
|
"inactive": "inactive",
|
||||||
|
"notChecked": "Not checked (no Stripe key)",
|
||||||
|
"subsTitle": "Subscriptions overview",
|
||||||
|
"subsDescription": "Counts from the local database (synced via Stripe webhooks).",
|
||||||
|
"statPaid": "Active + trial",
|
||||||
|
"statTrialing": "On trial",
|
||||||
|
"statPastDue": "Past due",
|
||||||
|
"statCanceling": "Cancel at period end",
|
||||||
|
"byTier": "By tier",
|
||||||
|
"byStatus": "By status",
|
||||||
|
"usersWithoutSub": "Users with no Subscription row",
|
||||||
|
"noSubs": "No subscriptions yet",
|
||||||
|
"recentSubs": "Recent paid / trial accounts",
|
||||||
|
"colUser": "User",
|
||||||
|
"colTier": "Tier",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"colPeriod": "Period / trial end",
|
||||||
|
"canceling": "canceling",
|
||||||
|
"manualTier": "manual (no Stripe sub)",
|
||||||
|
"trialUntil": "Trial until {date}",
|
||||||
|
"testGuideTitle": "How to test Stripe locally",
|
||||||
|
"testGuideDescription": "Checklist to validate checkout, webhooks and trial.",
|
||||||
|
"testStep1": "Stripe Dashboard → Test mode ON. Create Pro/Business products + monthly/annual prices + credit packs.",
|
||||||
|
"testStep2": "Put sk_test_…, pk_test_… in .env. Put price_… IDs in Admin → Billing (or env) and enable billing.",
|
||||||
|
"testStep3": "Copy the whsec_… into STRIPE_WEBHOOK_SECRET and restart the app.",
|
||||||
|
"testStep4": "npm run dev → open /settings/billing as a BASIC user.",
|
||||||
|
"testStep5": "Start Pro checkout. Card: 4242 4242 4242 4242, any future expiry, any CVC. Expect a 7-day trial.",
|
||||||
|
"testStep6": "Confirm Admin → Billing shows TRIALING, and /settings/billing shows the trial end date.",
|
||||||
|
"testCardHint": "Other cards: 4000000000009995 = payment fails · 4000002500003155 = 3D Secure. Never use real cards in test mode."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
@@ -3113,7 +3181,11 @@
|
|||||||
"packLName": "Pacchetto power",
|
"packLName": "Pacchetto power",
|
||||||
"buyPack": "Acquista",
|
"buyPack": "Acquista",
|
||||||
"packCheckoutSuccess": "Pacchetto crediti aggiunto al saldo!",
|
"packCheckoutSuccess": "Pacchetto crediti aggiunto al saldo!",
|
||||||
"packCheckoutFailed": "Acquisto del pacchetto non riuscito. Verifica la config Stripe o riprova."
|
"packCheckoutFailed": "Acquisto del pacchetto non riuscito. Verifica la config Stripe o riprova.",
|
||||||
|
"startTrialCta": "Prova {days} giorni gratis",
|
||||||
|
"trialFeature": "Prova gratuita di {days} giorni (carta richiesta)",
|
||||||
|
"trialEndsOn": "La prova gratuita termina il {date}. Poi verrai addebitato automaticamente.",
|
||||||
|
"trialEndsLabel": "Fine prova"
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"nav": {
|
"nav": {
|
||||||
@@ -3295,7 +3367,16 @@
|
|||||||
"feature4": "Supporto dedicato",
|
"feature4": "Supporto dedicato",
|
||||||
"feature5": "Onboarding live"
|
"feature5": "Onboarding live"
|
||||||
},
|
},
|
||||||
"basicPrice": "Gratis"
|
"basicPrice": "Gratis",
|
||||||
|
"savePercent": "Risparmia ~17%",
|
||||||
|
"proMonthly": "9,90€",
|
||||||
|
"proAnnualMonthly": "8,25€",
|
||||||
|
"businessMonthly": "29,90€",
|
||||||
|
"businessAnnualMonthly": "24,92€",
|
||||||
|
"enterprisePrice": "Su preventivo",
|
||||||
|
"trialBadge": "Prova gratuita {days} giorni",
|
||||||
|
"trialFeature": "Prova gratuita di {days} giorni (carta richiesta)",
|
||||||
|
"trialCta": "Prova {days} giorni gratis"
|
||||||
},
|
},
|
||||||
"cta": {
|
"cta": {
|
||||||
"title": "Smetti di perdere le tue idee migliori.",
|
"title": "Smetti di perdere le tue idee migliori.",
|
||||||
|
|||||||
@@ -38,7 +38,24 @@
|
|||||||
"privacyTerms": "© 2025 Memento Labs — プライバシー · 利用規約",
|
"privacyTerms": "© 2025 Memento Labs — プライバシー · 利用規約",
|
||||||
"sessionExpired": "サイトはナビゲーションと目次付きで生成されます",
|
"sessionExpired": "サイトはナビゲーションと目次付きで生成されます",
|
||||||
"welcomeBack": "おかえりなさい",
|
"welcomeBack": "おかえりなさい",
|
||||||
"welcomeBackSubtitle": "ノートにアクセスするには認証情報を入力してください。"
|
"welcomeBackSubtitle": "ノートにアクセスするには認証情報を入力してください。",
|
||||||
|
"checkEmailTitle": "メールを確認してください",
|
||||||
|
"checkEmailDescription": "{email} に確認リンクを送信しました。ログイン前に開いてアカウントを有効化してください。",
|
||||||
|
"checkEmailDescriptionGeneric": "確認リンクをメールで送信しました。ログイン前に開いてアカウントを有効化してください。",
|
||||||
|
"resendVerification": "確認メールを再送信",
|
||||||
|
"verifyResent": "確認メールを送信しました。受信箱を確認してください。",
|
||||||
|
"verifyResendFailed": "確認メールを送信できませんでした。後でもう一度お試しください。",
|
||||||
|
"verifyMissingEmail": "メールアドレスを入力してください。",
|
||||||
|
"verifyLoading": "メールを確認しています…",
|
||||||
|
"verifySuccessTitle": "メール確認完了",
|
||||||
|
"verifySuccessDescription": "アカウントの準備ができました。ログインできます。",
|
||||||
|
"verifyExpiredTitle": "リンクの期限切れ",
|
||||||
|
"verifyExpiredDescription": "この確認リンクは期限切れです。新しいリンクをリクエストしてください。",
|
||||||
|
"verifyInvalidTitle": "無効なリンク",
|
||||||
|
"verifyInvalidDescription": "この確認リンクは無効か、すでに使用されています。",
|
||||||
|
"emailNotVerified": "ログイン前にメールを確認してください。",
|
||||||
|
"emailVerifiedBanner": "メール確認済みです。ログインできます。",
|
||||||
|
"invalidCredentials": "メールまたはパスワードが正しくありません。"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"notes": "ノート",
|
"notes": "ノート",
|
||||||
@@ -1580,7 +1597,58 @@
|
|||||||
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
||||||
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
||||||
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
||||||
"packsCatalogTitle": "Pack catalogue (code)"
|
"packsCatalogTitle": "Pack catalogue (code)",
|
||||||
|
"healthTitle": "Stripe health check",
|
||||||
|
"healthDescription": "Runtime status of keys, webhooks, price IDs and billing flag (secrets are never shown).",
|
||||||
|
"healthSecret": "Secret key (server)",
|
||||||
|
"healthPublishable": "Publishable key",
|
||||||
|
"healthWebhook": "Webhook secret",
|
||||||
|
"healthBillingFlag": "Billing enabled",
|
||||||
|
"healthTrial": "Free trial",
|
||||||
|
"trialDaysValue": "{days} days on first checkout",
|
||||||
|
"modeTest": "Test mode (sk_test_…)",
|
||||||
|
"modeLive": "Live mode (sk_live_…)",
|
||||||
|
"modePlaceholder": "Placeholder / invalid key",
|
||||||
|
"modeMissing": "Not configured",
|
||||||
|
"configured": "Configured",
|
||||||
|
"missing": "Missing",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"priceStatusTitle": "Price IDs vs Stripe",
|
||||||
|
"colKey": "Plan",
|
||||||
|
"colPriceId": "Price ID",
|
||||||
|
"colSource": "Source",
|
||||||
|
"colStripe": "Stripe amount",
|
||||||
|
"priceError": "Lookup failed",
|
||||||
|
"inactive": "inactive",
|
||||||
|
"notChecked": "Not checked (no Stripe key)",
|
||||||
|
"subsTitle": "Subscriptions overview",
|
||||||
|
"subsDescription": "Counts from the local database (synced via Stripe webhooks).",
|
||||||
|
"statPaid": "Active + trial",
|
||||||
|
"statTrialing": "On trial",
|
||||||
|
"statPastDue": "Past due",
|
||||||
|
"statCanceling": "Cancel at period end",
|
||||||
|
"byTier": "By tier",
|
||||||
|
"byStatus": "By status",
|
||||||
|
"usersWithoutSub": "Users with no Subscription row",
|
||||||
|
"noSubs": "No subscriptions yet",
|
||||||
|
"recentSubs": "Recent paid / trial accounts",
|
||||||
|
"colUser": "User",
|
||||||
|
"colTier": "Tier",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"colPeriod": "Period / trial end",
|
||||||
|
"canceling": "canceling",
|
||||||
|
"manualTier": "manual (no Stripe sub)",
|
||||||
|
"trialUntil": "Trial until {date}",
|
||||||
|
"testGuideTitle": "How to test Stripe locally",
|
||||||
|
"testGuideDescription": "Checklist to validate checkout, webhooks and trial.",
|
||||||
|
"testStep1": "Stripe Dashboard → Test mode ON. Create Pro/Business products + monthly/annual prices + credit packs.",
|
||||||
|
"testStep2": "Put sk_test_…, pk_test_… in .env. Put price_… IDs in Admin → Billing (or env) and enable billing.",
|
||||||
|
"testStep3": "Copy the whsec_… into STRIPE_WEBHOOK_SECRET and restart the app.",
|
||||||
|
"testStep4": "npm run dev → open /settings/billing as a BASIC user.",
|
||||||
|
"testStep5": "Start Pro checkout. Card: 4242 4242 4242 4242, any future expiry, any CVC. Expect a 7-day trial.",
|
||||||
|
"testStep6": "Confirm Admin → Billing shows TRIALING, and /settings/billing shows the trial end date.",
|
||||||
|
"testCardHint": "Other cards: 4000000000009995 = payment fails · 4000002500003155 = 3D Secure. Never use real cards in test mode."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
@@ -3113,7 +3181,11 @@
|
|||||||
"packLName": "パワーパック",
|
"packLName": "パワーパック",
|
||||||
"buyPack": "購入",
|
"buyPack": "購入",
|
||||||
"packCheckoutSuccess": "クレジットパックが残高に追加されました!",
|
"packCheckoutSuccess": "クレジットパックが残高に追加されました!",
|
||||||
"packCheckoutFailed": "購入を開始できませんでした。Stripe設定を確認するか再試行してください。"
|
"packCheckoutFailed": "購入を開始できませんでした。Stripe設定を確認するか再試行してください。",
|
||||||
|
"startTrialCta": "{days}日間無料で試す",
|
||||||
|
"trialFeature": "{days}日間無料トライアル(カード登録が必要)",
|
||||||
|
"trialEndsOn": "無料トライアルは {date} に終了します。その後、自動的に請求されます。",
|
||||||
|
"trialEndsLabel": "トライアル終了"
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"nav": {
|
"nav": {
|
||||||
@@ -3295,7 +3367,16 @@
|
|||||||
"feature4": "専任サポート",
|
"feature4": "専任サポート",
|
||||||
"feature5": "ライブオンボーディング"
|
"feature5": "ライブオンボーディング"
|
||||||
},
|
},
|
||||||
"basicPrice": "無料"
|
"basicPrice": "無料",
|
||||||
|
"savePercent": "約17%お得",
|
||||||
|
"proMonthly": "€9.90",
|
||||||
|
"proAnnualMonthly": "€8.25",
|
||||||
|
"businessMonthly": "€29.90",
|
||||||
|
"businessAnnualMonthly": "€24.92",
|
||||||
|
"enterprisePrice": "お問い合わせ",
|
||||||
|
"trialBadge": "{days}日間無料トライアル",
|
||||||
|
"trialFeature": "{days}日間無料トライアル(カード登録が必要)",
|
||||||
|
"trialCta": "{days}日間無料で試す"
|
||||||
},
|
},
|
||||||
"cta": {
|
"cta": {
|
||||||
"title": "最高のアイデアを失うのをやめる。",
|
"title": "最高のアイデアを失うのをやめる。",
|
||||||
|
|||||||
@@ -38,7 +38,24 @@
|
|||||||
"privacyTerms": "© 2025 Memento Labs — 개인정보 · 약관",
|
"privacyTerms": "© 2025 Memento Labs — 개인정보 · 약관",
|
||||||
"sessionExpired": "사이트가 탐색 및 목차와 함께 생성됩니다",
|
"sessionExpired": "사이트가 탐색 및 목차와 함께 생성됩니다",
|
||||||
"welcomeBack": "다시 오신 것을 환영합니다",
|
"welcomeBack": "다시 오신 것을 환영합니다",
|
||||||
"welcomeBackSubtitle": "노트에 액세스하려면 자격 증명을 입력하세요."
|
"welcomeBackSubtitle": "노트에 액세스하려면 자격 증명을 입력하세요.",
|
||||||
|
"checkEmailTitle": "이메일을 확인하세요",
|
||||||
|
"checkEmailDescription": "{email}(으)로 확인 링크를 보냈습니다. 로그인하기 전에 열어 계정을 활성화하세요.",
|
||||||
|
"checkEmailDescriptionGeneric": "확인 링크를 이메일로 보냈습니다. 로그인하기 전에 열어 계정을 활성화하세요.",
|
||||||
|
"resendVerification": "확인 이메일 다시 보내기",
|
||||||
|
"verifyResent": "확인 이메일을 보냈습니다. 받은편지함을 확인하세요.",
|
||||||
|
"verifyResendFailed": "확인 이메일을 보낼 수 없습니다. 나중에 다시 시도하세요.",
|
||||||
|
"verifyMissingEmail": "이메일 주소를 입력하세요.",
|
||||||
|
"verifyLoading": "이메일을 확인하는 중…",
|
||||||
|
"verifySuccessTitle": "이메일 확인 완료",
|
||||||
|
"verifySuccessDescription": "계정이 준비되었습니다. 이제 로그인할 수 있습니다.",
|
||||||
|
"verifyExpiredTitle": "링크 만료",
|
||||||
|
"verifyExpiredDescription": "이 확인 링크는 만료되었습니다. 새 링크를 요청하세요.",
|
||||||
|
"verifyInvalidTitle": "유효하지 않은 링크",
|
||||||
|
"verifyInvalidDescription": "이 확인 링크는 유효하지 않거나 이미 사용되었습니다.",
|
||||||
|
"emailNotVerified": "로그인하기 전에 이메일을 확인하세요.",
|
||||||
|
"emailVerifiedBanner": "이메일이 확인되었습니다. 이제 로그인할 수 있습니다.",
|
||||||
|
"invalidCredentials": "이메일 또는 비밀번호가 올바르지 않습니다."
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"notes": "노트",
|
"notes": "노트",
|
||||||
@@ -1580,7 +1597,58 @@
|
|||||||
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
||||||
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
||||||
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
||||||
"packsCatalogTitle": "Pack catalogue (code)"
|
"packsCatalogTitle": "Pack catalogue (code)",
|
||||||
|
"healthTitle": "Stripe health check",
|
||||||
|
"healthDescription": "Runtime status of keys, webhooks, price IDs and billing flag (secrets are never shown).",
|
||||||
|
"healthSecret": "Secret key (server)",
|
||||||
|
"healthPublishable": "Publishable key",
|
||||||
|
"healthWebhook": "Webhook secret",
|
||||||
|
"healthBillingFlag": "Billing enabled",
|
||||||
|
"healthTrial": "Free trial",
|
||||||
|
"trialDaysValue": "{days} days on first checkout",
|
||||||
|
"modeTest": "Test mode (sk_test_…)",
|
||||||
|
"modeLive": "Live mode (sk_live_…)",
|
||||||
|
"modePlaceholder": "Placeholder / invalid key",
|
||||||
|
"modeMissing": "Not configured",
|
||||||
|
"configured": "Configured",
|
||||||
|
"missing": "Missing",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"priceStatusTitle": "Price IDs vs Stripe",
|
||||||
|
"colKey": "Plan",
|
||||||
|
"colPriceId": "Price ID",
|
||||||
|
"colSource": "Source",
|
||||||
|
"colStripe": "Stripe amount",
|
||||||
|
"priceError": "Lookup failed",
|
||||||
|
"inactive": "inactive",
|
||||||
|
"notChecked": "Not checked (no Stripe key)",
|
||||||
|
"subsTitle": "Subscriptions overview",
|
||||||
|
"subsDescription": "Counts from the local database (synced via Stripe webhooks).",
|
||||||
|
"statPaid": "Active + trial",
|
||||||
|
"statTrialing": "On trial",
|
||||||
|
"statPastDue": "Past due",
|
||||||
|
"statCanceling": "Cancel at period end",
|
||||||
|
"byTier": "By tier",
|
||||||
|
"byStatus": "By status",
|
||||||
|
"usersWithoutSub": "Users with no Subscription row",
|
||||||
|
"noSubs": "No subscriptions yet",
|
||||||
|
"recentSubs": "Recent paid / trial accounts",
|
||||||
|
"colUser": "User",
|
||||||
|
"colTier": "Tier",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"colPeriod": "Period / trial end",
|
||||||
|
"canceling": "canceling",
|
||||||
|
"manualTier": "manual (no Stripe sub)",
|
||||||
|
"trialUntil": "Trial until {date}",
|
||||||
|
"testGuideTitle": "How to test Stripe locally",
|
||||||
|
"testGuideDescription": "Checklist to validate checkout, webhooks and trial.",
|
||||||
|
"testStep1": "Stripe Dashboard → Test mode ON. Create Pro/Business products + monthly/annual prices + credit packs.",
|
||||||
|
"testStep2": "Put sk_test_…, pk_test_… in .env. Put price_… IDs in Admin → Billing (or env) and enable billing.",
|
||||||
|
"testStep3": "Copy the whsec_… into STRIPE_WEBHOOK_SECRET and restart the app.",
|
||||||
|
"testStep4": "npm run dev → open /settings/billing as a BASIC user.",
|
||||||
|
"testStep5": "Start Pro checkout. Card: 4242 4242 4242 4242, any future expiry, any CVC. Expect a 7-day trial.",
|
||||||
|
"testStep6": "Confirm Admin → Billing shows TRIALING, and /settings/billing shows the trial end date.",
|
||||||
|
"testCardHint": "Other cards: 4000000000009995 = payment fails · 4000002500003155 = 3D Secure. Never use real cards in test mode."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
@@ -3113,7 +3181,11 @@
|
|||||||
"packLName": "파워 팩",
|
"packLName": "파워 팩",
|
||||||
"buyPack": "구매",
|
"buyPack": "구매",
|
||||||
"packCheckoutSuccess": "크레딧 팩이 잔액에 추가되었습니다!",
|
"packCheckoutSuccess": "크레딧 팩이 잔액에 추가되었습니다!",
|
||||||
"packCheckoutFailed": "구매를 시작할 수 없습니다. Stripe 설정을 확인하거나 다시 시도하세요."
|
"packCheckoutFailed": "구매를 시작할 수 없습니다. Stripe 설정을 확인하거나 다시 시도하세요.",
|
||||||
|
"startTrialCta": "{days}일 무료로 시작",
|
||||||
|
"trialFeature": "{days}일 무료 체험 (카드 등록 필요)",
|
||||||
|
"trialEndsOn": "무료 체험이 {date}에 종료됩니다. 이후 자동으로 결제됩니다.",
|
||||||
|
"trialEndsLabel": "체험 종료"
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"nav": {
|
"nav": {
|
||||||
@@ -3295,7 +3367,16 @@
|
|||||||
"feature4": "전담 지원",
|
"feature4": "전담 지원",
|
||||||
"feature5": "라이브 온보딩"
|
"feature5": "라이브 온보딩"
|
||||||
},
|
},
|
||||||
"basicPrice": "무료"
|
"basicPrice": "무료",
|
||||||
|
"savePercent": "약 17% 절약",
|
||||||
|
"proMonthly": "€9.90",
|
||||||
|
"proAnnualMonthly": "€8.25",
|
||||||
|
"businessMonthly": "€29.90",
|
||||||
|
"businessAnnualMonthly": "€24.92",
|
||||||
|
"enterprisePrice": "맞춤 견적",
|
||||||
|
"trialBadge": "{days}일 무료 체험",
|
||||||
|
"trialFeature": "{days}일 무료 체험 (카드 등록 필요)",
|
||||||
|
"trialCta": "{days}일 무료로 시작"
|
||||||
},
|
},
|
||||||
"cta": {
|
"cta": {
|
||||||
"title": "최고의 아이디어를 잃는 일을 멈추세요.",
|
"title": "최고의 아이디어를 잃는 일을 멈추세요.",
|
||||||
|
|||||||
@@ -38,7 +38,24 @@
|
|||||||
"privacyTerms": "© 2025 Memento Labs — Privacy · Voorwaarden",
|
"privacyTerms": "© 2025 Memento Labs — Privacy · Voorwaarden",
|
||||||
"sessionExpired": "Uw site wordt gegenereerd met navigatie en inhoudsopgave",
|
"sessionExpired": "Uw site wordt gegenereerd met navigatie en inhoudsopgave",
|
||||||
"welcomeBack": "Welkom terug",
|
"welcomeBack": "Welkom terug",
|
||||||
"welcomeBackSubtitle": "Voer uw inloggegevens in om toegang te krijgen tot uw notities."
|
"welcomeBackSubtitle": "Voer uw inloggegevens in om toegang te krijgen tot uw notities.",
|
||||||
|
"checkEmailTitle": "Controleer je e-mail",
|
||||||
|
"checkEmailDescription": "We hebben een bevestigingslink naar {email} gestuurd. Open die om je account te activeren voordat je inlogt.",
|
||||||
|
"checkEmailDescriptionGeneric": "We hebben een bevestigingslink naar je e-mail gestuurd. Open die om je account te activeren voordat je inlogt.",
|
||||||
|
"resendVerification": "Bevestigingsmail opnieuw versturen",
|
||||||
|
"verifyResent": "Bevestigingsmail verzonden. Controleer je inbox.",
|
||||||
|
"verifyResendFailed": "Bevestigingsmail kon niet worden verzonden. Probeer later opnieuw.",
|
||||||
|
"verifyMissingEmail": "Voer je e-mailadres in.",
|
||||||
|
"verifyLoading": "E-mail wordt bevestigd…",
|
||||||
|
"verifySuccessTitle": "E-mail bevestigd",
|
||||||
|
"verifySuccessDescription": "Je account is klaar. Je kunt nu inloggen.",
|
||||||
|
"verifyExpiredTitle": "Link verlopen",
|
||||||
|
"verifyExpiredDescription": "Deze bevestigingslink is verlopen. Vraag een nieuwe aan.",
|
||||||
|
"verifyInvalidTitle": "Ongeldige link",
|
||||||
|
"verifyInvalidDescription": "Deze bevestigingslink is ongeldig of al gebruikt.",
|
||||||
|
"emailNotVerified": "Bevestig je e-mail voordat je inlogt.",
|
||||||
|
"emailVerifiedBanner": "E-mail bevestigd. Je kunt nu inloggen.",
|
||||||
|
"invalidCredentials": "Ongeldig e-mailadres of wachtwoord."
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"notes": "Notities",
|
"notes": "Notities",
|
||||||
@@ -1580,7 +1597,58 @@
|
|||||||
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
||||||
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
||||||
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
||||||
"packsCatalogTitle": "Pack catalogue (code)"
|
"packsCatalogTitle": "Pack catalogue (code)",
|
||||||
|
"healthTitle": "Stripe health check",
|
||||||
|
"healthDescription": "Runtime status of keys, webhooks, price IDs and billing flag (secrets are never shown).",
|
||||||
|
"healthSecret": "Secret key (server)",
|
||||||
|
"healthPublishable": "Publishable key",
|
||||||
|
"healthWebhook": "Webhook secret",
|
||||||
|
"healthBillingFlag": "Billing enabled",
|
||||||
|
"healthTrial": "Free trial",
|
||||||
|
"trialDaysValue": "{days} days on first checkout",
|
||||||
|
"modeTest": "Test mode (sk_test_…)",
|
||||||
|
"modeLive": "Live mode (sk_live_…)",
|
||||||
|
"modePlaceholder": "Placeholder / invalid key",
|
||||||
|
"modeMissing": "Not configured",
|
||||||
|
"configured": "Configured",
|
||||||
|
"missing": "Missing",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"priceStatusTitle": "Price IDs vs Stripe",
|
||||||
|
"colKey": "Plan",
|
||||||
|
"colPriceId": "Price ID",
|
||||||
|
"colSource": "Source",
|
||||||
|
"colStripe": "Stripe amount",
|
||||||
|
"priceError": "Lookup failed",
|
||||||
|
"inactive": "inactive",
|
||||||
|
"notChecked": "Not checked (no Stripe key)",
|
||||||
|
"subsTitle": "Subscriptions overview",
|
||||||
|
"subsDescription": "Counts from the local database (synced via Stripe webhooks).",
|
||||||
|
"statPaid": "Active + trial",
|
||||||
|
"statTrialing": "On trial",
|
||||||
|
"statPastDue": "Past due",
|
||||||
|
"statCanceling": "Cancel at period end",
|
||||||
|
"byTier": "By tier",
|
||||||
|
"byStatus": "By status",
|
||||||
|
"usersWithoutSub": "Users with no Subscription row",
|
||||||
|
"noSubs": "No subscriptions yet",
|
||||||
|
"recentSubs": "Recent paid / trial accounts",
|
||||||
|
"colUser": "User",
|
||||||
|
"colTier": "Tier",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"colPeriod": "Period / trial end",
|
||||||
|
"canceling": "canceling",
|
||||||
|
"manualTier": "manual (no Stripe sub)",
|
||||||
|
"trialUntil": "Trial until {date}",
|
||||||
|
"testGuideTitle": "How to test Stripe locally",
|
||||||
|
"testGuideDescription": "Checklist to validate checkout, webhooks and trial.",
|
||||||
|
"testStep1": "Stripe Dashboard → Test mode ON. Create Pro/Business products + monthly/annual prices + credit packs.",
|
||||||
|
"testStep2": "Put sk_test_…, pk_test_… in .env. Put price_… IDs in Admin → Billing (or env) and enable billing.",
|
||||||
|
"testStep3": "Copy the whsec_… into STRIPE_WEBHOOK_SECRET and restart the app.",
|
||||||
|
"testStep4": "npm run dev → open /settings/billing as a BASIC user.",
|
||||||
|
"testStep5": "Start Pro checkout. Card: 4242 4242 4242 4242, any future expiry, any CVC. Expect a 7-day trial.",
|
||||||
|
"testStep6": "Confirm Admin → Billing shows TRIALING, and /settings/billing shows the trial end date.",
|
||||||
|
"testCardHint": "Other cards: 4000000000009995 = payment fails · 4000002500003155 = 3D Secure. Never use real cards in test mode."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
@@ -3113,7 +3181,11 @@
|
|||||||
"packLName": "Powerpakket",
|
"packLName": "Powerpakket",
|
||||||
"buyPack": "Kopen",
|
"buyPack": "Kopen",
|
||||||
"packCheckoutSuccess": "Creditpakket toegevoegd aan je saldo!",
|
"packCheckoutSuccess": "Creditpakket toegevoegd aan je saldo!",
|
||||||
"packCheckoutFailed": "Aankoop mislukt. Controleer de Stripe-config of probeer opnieuw."
|
"packCheckoutFailed": "Aankoop mislukt. Controleer de Stripe-config of probeer opnieuw.",
|
||||||
|
"startTrialCta": "{days} dagen gratis starten",
|
||||||
|
"trialFeature": "{days} dagen gratis proberen (kaart vereist)",
|
||||||
|
"trialEndsOn": "Je gratis proefperiode eindigt op {date}. Daarna word je automatisch gefactureerd.",
|
||||||
|
"trialEndsLabel": "Einde proefperiode"
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"nav": {
|
"nav": {
|
||||||
@@ -3295,7 +3367,16 @@
|
|||||||
"feature4": "Dedicated support",
|
"feature4": "Dedicated support",
|
||||||
"feature5": "Live onboarding"
|
"feature5": "Live onboarding"
|
||||||
},
|
},
|
||||||
"basicPrice": "Gratis"
|
"basicPrice": "Gratis",
|
||||||
|
"savePercent": "Bespaar ~17%",
|
||||||
|
"proMonthly": "€9,90",
|
||||||
|
"proAnnualMonthly": "€8,25",
|
||||||
|
"businessMonthly": "€29,90",
|
||||||
|
"businessAnnualMonthly": "€24,92",
|
||||||
|
"enterprisePrice": "Op maat",
|
||||||
|
"trialBadge": "{days} dagen gratis proberen",
|
||||||
|
"trialFeature": "{days} dagen gratis proberen (kaart vereist)",
|
||||||
|
"trialCta": "{days} dagen gratis starten"
|
||||||
},
|
},
|
||||||
"cta": {
|
"cta": {
|
||||||
"title": "Stop met het verliezen van je beste ideeën.",
|
"title": "Stop met het verliezen van je beste ideeën.",
|
||||||
|
|||||||
@@ -38,7 +38,24 @@
|
|||||||
"privacyTerms": "© 2025 Memento Labs — Prywatność · Warunki",
|
"privacyTerms": "© 2025 Memento Labs — Prywatność · Warunki",
|
||||||
"sessionExpired": "Twoja strona jest generowana z nawigacją i spisem treści",
|
"sessionExpired": "Twoja strona jest generowana z nawigacją i spisem treści",
|
||||||
"welcomeBack": "Witamy ponownie",
|
"welcomeBack": "Witamy ponownie",
|
||||||
"welcomeBackSubtitle": "Wprowadź swoje dane logowania, aby uzyskać dostęp do notatek."
|
"welcomeBackSubtitle": "Wprowadź swoje dane logowania, aby uzyskać dostęp do notatek.",
|
||||||
|
"checkEmailTitle": "Sprawdź e-mail",
|
||||||
|
"checkEmailDescription": "Wysłaliśmy link potwierdzający na {email}. Otwórz go, aby aktywować konto przed logowaniem.",
|
||||||
|
"checkEmailDescriptionGeneric": "Wysłaliśmy link potwierdzający na Twój e-mail. Otwórz go, aby aktywować konto przed logowaniem.",
|
||||||
|
"resendVerification": "Wyślij ponownie e-mail potwierdzający",
|
||||||
|
"verifyResent": "Wysłano e-mail potwierdzający. Sprawdź skrzynkę.",
|
||||||
|
"verifyResendFailed": "Nie udało się wysłać e-maila potwierdzającego. Spróbuj później.",
|
||||||
|
"verifyMissingEmail": "Podaj adres e-mail.",
|
||||||
|
"verifyLoading": "Potwierdzanie e-maila…",
|
||||||
|
"verifySuccessTitle": "E-mail potwierdzony",
|
||||||
|
"verifySuccessDescription": "Konto jest gotowe. Możesz się zalogować.",
|
||||||
|
"verifyExpiredTitle": "Link wygasł",
|
||||||
|
"verifyExpiredDescription": "Ten link potwierdzający wygasł. Poproś o nowy.",
|
||||||
|
"verifyInvalidTitle": "Nieprawidłowy link",
|
||||||
|
"verifyInvalidDescription": "Ten link potwierdzający jest nieprawidłowy lub został już użyty.",
|
||||||
|
"emailNotVerified": "Potwierdź e-mail przed zalogowaniem.",
|
||||||
|
"emailVerifiedBanner": "E-mail potwierdzony. Możesz się zalogować.",
|
||||||
|
"invalidCredentials": "Nieprawidłowy e-mail lub hasło."
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"notes": "Notatki",
|
"notes": "Notatki",
|
||||||
@@ -1580,7 +1597,58 @@
|
|||||||
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
||||||
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
||||||
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
||||||
"packsCatalogTitle": "Pack catalogue (code)"
|
"packsCatalogTitle": "Pack catalogue (code)",
|
||||||
|
"healthTitle": "Stripe health check",
|
||||||
|
"healthDescription": "Runtime status of keys, webhooks, price IDs and billing flag (secrets are never shown).",
|
||||||
|
"healthSecret": "Secret key (server)",
|
||||||
|
"healthPublishable": "Publishable key",
|
||||||
|
"healthWebhook": "Webhook secret",
|
||||||
|
"healthBillingFlag": "Billing enabled",
|
||||||
|
"healthTrial": "Free trial",
|
||||||
|
"trialDaysValue": "{days} days on first checkout",
|
||||||
|
"modeTest": "Test mode (sk_test_…)",
|
||||||
|
"modeLive": "Live mode (sk_live_…)",
|
||||||
|
"modePlaceholder": "Placeholder / invalid key",
|
||||||
|
"modeMissing": "Not configured",
|
||||||
|
"configured": "Configured",
|
||||||
|
"missing": "Missing",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"priceStatusTitle": "Price IDs vs Stripe",
|
||||||
|
"colKey": "Plan",
|
||||||
|
"colPriceId": "Price ID",
|
||||||
|
"colSource": "Source",
|
||||||
|
"colStripe": "Stripe amount",
|
||||||
|
"priceError": "Lookup failed",
|
||||||
|
"inactive": "inactive",
|
||||||
|
"notChecked": "Not checked (no Stripe key)",
|
||||||
|
"subsTitle": "Subscriptions overview",
|
||||||
|
"subsDescription": "Counts from the local database (synced via Stripe webhooks).",
|
||||||
|
"statPaid": "Active + trial",
|
||||||
|
"statTrialing": "On trial",
|
||||||
|
"statPastDue": "Past due",
|
||||||
|
"statCanceling": "Cancel at period end",
|
||||||
|
"byTier": "By tier",
|
||||||
|
"byStatus": "By status",
|
||||||
|
"usersWithoutSub": "Users with no Subscription row",
|
||||||
|
"noSubs": "No subscriptions yet",
|
||||||
|
"recentSubs": "Recent paid / trial accounts",
|
||||||
|
"colUser": "User",
|
||||||
|
"colTier": "Tier",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"colPeriod": "Period / trial end",
|
||||||
|
"canceling": "canceling",
|
||||||
|
"manualTier": "manual (no Stripe sub)",
|
||||||
|
"trialUntil": "Trial until {date}",
|
||||||
|
"testGuideTitle": "How to test Stripe locally",
|
||||||
|
"testGuideDescription": "Checklist to validate checkout, webhooks and trial.",
|
||||||
|
"testStep1": "Stripe Dashboard → Test mode ON. Create Pro/Business products + monthly/annual prices + credit packs.",
|
||||||
|
"testStep2": "Put sk_test_…, pk_test_… in .env. Put price_… IDs in Admin → Billing (or env) and enable billing.",
|
||||||
|
"testStep3": "Copy the whsec_… into STRIPE_WEBHOOK_SECRET and restart the app.",
|
||||||
|
"testStep4": "npm run dev → open /settings/billing as a BASIC user.",
|
||||||
|
"testStep5": "Start Pro checkout. Card: 4242 4242 4242 4242, any future expiry, any CVC. Expect a 7-day trial.",
|
||||||
|
"testStep6": "Confirm Admin → Billing shows TRIALING, and /settings/billing shows the trial end date.",
|
||||||
|
"testCardHint": "Other cards: 4000000000009995 = payment fails · 4000002500003155 = 3D Secure. Never use real cards in test mode."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
@@ -3113,7 +3181,11 @@
|
|||||||
"packLName": "Pakiet power",
|
"packLName": "Pakiet power",
|
||||||
"buyPack": "Kup",
|
"buyPack": "Kup",
|
||||||
"packCheckoutSuccess": "Pakiet kredytów dodany do salda!",
|
"packCheckoutSuccess": "Pakiet kredytów dodany do salda!",
|
||||||
"packCheckoutFailed": "Nie udało się rozpocząć zakupu. Sprawdź konfigurację Stripe lub spróbuj ponownie."
|
"packCheckoutFailed": "Nie udało się rozpocząć zakupu. Sprawdź konfigurację Stripe lub spróbuj ponownie.",
|
||||||
|
"startTrialCta": "Wypróbuj {days} dni za darmo",
|
||||||
|
"trialFeature": "{days}-dniowy okres próbny (wymagana karta)",
|
||||||
|
"trialEndsOn": "Twój okres próbny kończy się {date}. Potem nastąpi automatyczne obciążenie.",
|
||||||
|
"trialEndsLabel": "Koniec okresu próbnego"
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"nav": {
|
"nav": {
|
||||||
@@ -3295,7 +3367,16 @@
|
|||||||
"feature4": "Dedykowane wsparcie",
|
"feature4": "Dedykowane wsparcie",
|
||||||
"feature5": "Onboarding na żywo"
|
"feature5": "Onboarding na żywo"
|
||||||
},
|
},
|
||||||
"basicPrice": "Za darmo"
|
"basicPrice": "Za darmo",
|
||||||
|
"savePercent": "Oszczędź ~17%",
|
||||||
|
"proMonthly": "9,90€",
|
||||||
|
"proAnnualMonthly": "8,25€",
|
||||||
|
"businessMonthly": "29,90€",
|
||||||
|
"businessAnnualMonthly": "24,92€",
|
||||||
|
"enterprisePrice": "Indywidualnie",
|
||||||
|
"trialBadge": "{days} dni za darmo",
|
||||||
|
"trialFeature": "{days}-dniowy okres próbny (wymagana karta)",
|
||||||
|
"trialCta": "Wypróbuj {days} dni za darmo"
|
||||||
},
|
},
|
||||||
"cta": {
|
"cta": {
|
||||||
"title": "Przestań tracić najlepsze pomysły.",
|
"title": "Przestań tracić najlepsze pomysły.",
|
||||||
|
|||||||
@@ -38,7 +38,24 @@
|
|||||||
"privacyTerms": "© 2025 Memento Labs — Privacidade · Termos",
|
"privacyTerms": "© 2025 Memento Labs — Privacidade · Termos",
|
||||||
"sessionExpired": "Seu site é gerado com navegação e sumário",
|
"sessionExpired": "Seu site é gerado com navegação e sumário",
|
||||||
"welcomeBack": "Bem-vindo de volta",
|
"welcomeBack": "Bem-vindo de volta",
|
||||||
"welcomeBackSubtitle": "Digite suas credenciais para acessar suas notas."
|
"welcomeBackSubtitle": "Digite suas credenciais para acessar suas notas.",
|
||||||
|
"checkEmailTitle": "Verifique o seu e-mail",
|
||||||
|
"checkEmailDescription": "Enviámos um link de confirmação para {email}. Abra-o para ativar a conta antes de entrar.",
|
||||||
|
"checkEmailDescriptionGeneric": "Enviámos um link de confirmação para o seu e-mail. Abra-o para ativar a conta antes de entrar.",
|
||||||
|
"resendVerification": "Reenviar e-mail de confirmação",
|
||||||
|
"verifyResent": "E-mail de confirmação enviado. Verifique a caixa de entrada.",
|
||||||
|
"verifyResendFailed": "Não foi possível enviar o e-mail de confirmação. Tente mais tarde.",
|
||||||
|
"verifyMissingEmail": "Introduza o seu endereço de e-mail.",
|
||||||
|
"verifyLoading": "A confirmar o seu e-mail…",
|
||||||
|
"verifySuccessTitle": "E-mail confirmado",
|
||||||
|
"verifySuccessDescription": "A sua conta está pronta. Já pode iniciar sessão.",
|
||||||
|
"verifyExpiredTitle": "Link expirado",
|
||||||
|
"verifyExpiredDescription": "Este link de confirmação expirou. Peça um novo.",
|
||||||
|
"verifyInvalidTitle": "Link inválido",
|
||||||
|
"verifyInvalidDescription": "Este link de confirmação é inválido ou já foi usado.",
|
||||||
|
"emailNotVerified": "Confirme o seu e-mail antes de iniciar sessão.",
|
||||||
|
"emailVerifiedBanner": "E-mail confirmado. Já pode iniciar sessão.",
|
||||||
|
"invalidCredentials": "E-mail ou palavra-passe incorretos."
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"notes": "Notas",
|
"notes": "Notas",
|
||||||
@@ -1580,7 +1597,58 @@
|
|||||||
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
||||||
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
||||||
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
||||||
"packsCatalogTitle": "Pack catalogue (code)"
|
"packsCatalogTitle": "Pack catalogue (code)",
|
||||||
|
"healthTitle": "Stripe health check",
|
||||||
|
"healthDescription": "Runtime status of keys, webhooks, price IDs and billing flag (secrets are never shown).",
|
||||||
|
"healthSecret": "Secret key (server)",
|
||||||
|
"healthPublishable": "Publishable key",
|
||||||
|
"healthWebhook": "Webhook secret",
|
||||||
|
"healthBillingFlag": "Billing enabled",
|
||||||
|
"healthTrial": "Free trial",
|
||||||
|
"trialDaysValue": "{days} days on first checkout",
|
||||||
|
"modeTest": "Test mode (sk_test_…)",
|
||||||
|
"modeLive": "Live mode (sk_live_…)",
|
||||||
|
"modePlaceholder": "Placeholder / invalid key",
|
||||||
|
"modeMissing": "Not configured",
|
||||||
|
"configured": "Configured",
|
||||||
|
"missing": "Missing",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"priceStatusTitle": "Price IDs vs Stripe",
|
||||||
|
"colKey": "Plan",
|
||||||
|
"colPriceId": "Price ID",
|
||||||
|
"colSource": "Source",
|
||||||
|
"colStripe": "Stripe amount",
|
||||||
|
"priceError": "Lookup failed",
|
||||||
|
"inactive": "inactive",
|
||||||
|
"notChecked": "Not checked (no Stripe key)",
|
||||||
|
"subsTitle": "Subscriptions overview",
|
||||||
|
"subsDescription": "Counts from the local database (synced via Stripe webhooks).",
|
||||||
|
"statPaid": "Active + trial",
|
||||||
|
"statTrialing": "On trial",
|
||||||
|
"statPastDue": "Past due",
|
||||||
|
"statCanceling": "Cancel at period end",
|
||||||
|
"byTier": "By tier",
|
||||||
|
"byStatus": "By status",
|
||||||
|
"usersWithoutSub": "Users with no Subscription row",
|
||||||
|
"noSubs": "No subscriptions yet",
|
||||||
|
"recentSubs": "Recent paid / trial accounts",
|
||||||
|
"colUser": "User",
|
||||||
|
"colTier": "Tier",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"colPeriod": "Period / trial end",
|
||||||
|
"canceling": "canceling",
|
||||||
|
"manualTier": "manual (no Stripe sub)",
|
||||||
|
"trialUntil": "Trial until {date}",
|
||||||
|
"testGuideTitle": "How to test Stripe locally",
|
||||||
|
"testGuideDescription": "Checklist to validate checkout, webhooks and trial.",
|
||||||
|
"testStep1": "Stripe Dashboard → Test mode ON. Create Pro/Business products + monthly/annual prices + credit packs.",
|
||||||
|
"testStep2": "Put sk_test_…, pk_test_… in .env. Put price_… IDs in Admin → Billing (or env) and enable billing.",
|
||||||
|
"testStep3": "Copy the whsec_… into STRIPE_WEBHOOK_SECRET and restart the app.",
|
||||||
|
"testStep4": "npm run dev → open /settings/billing as a BASIC user.",
|
||||||
|
"testStep5": "Start Pro checkout. Card: 4242 4242 4242 4242, any future expiry, any CVC. Expect a 7-day trial.",
|
||||||
|
"testStep6": "Confirm Admin → Billing shows TRIALING, and /settings/billing shows the trial end date.",
|
||||||
|
"testCardHint": "Other cards: 4000000000009995 = payment fails · 4000002500003155 = 3D Secure. Never use real cards in test mode."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
@@ -3113,7 +3181,11 @@
|
|||||||
"packLName": "Pacote intensivo",
|
"packLName": "Pacote intensivo",
|
||||||
"buyPack": "Comprar",
|
"buyPack": "Comprar",
|
||||||
"packCheckoutSuccess": "Pacote de créditos adicionado ao saldo!",
|
"packCheckoutSuccess": "Pacote de créditos adicionado ao saldo!",
|
||||||
"packCheckoutFailed": "Falha ao iniciar a compra. Verifique a config Stripe ou tente de novo."
|
"packCheckoutFailed": "Falha ao iniciar a compra. Verifique a config Stripe ou tente de novo.",
|
||||||
|
"startTrialCta": "Experimentar {days} dias grátis",
|
||||||
|
"trialFeature": "Teste grátis de {days} dias (cartão necessário)",
|
||||||
|
"trialEndsOn": "O seu teste gratuito termina em {date}. Depois será cobrado automaticamente.",
|
||||||
|
"trialEndsLabel": "Fim do teste"
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"nav": {
|
"nav": {
|
||||||
@@ -3295,7 +3367,16 @@
|
|||||||
"feature4": "Suporte dedicado",
|
"feature4": "Suporte dedicado",
|
||||||
"feature5": "Onboarding ao vivo"
|
"feature5": "Onboarding ao vivo"
|
||||||
},
|
},
|
||||||
"basicPrice": "Grátis"
|
"basicPrice": "Grátis",
|
||||||
|
"savePercent": "Economize ~17%",
|
||||||
|
"proMonthly": "9,90€",
|
||||||
|
"proAnnualMonthly": "8,25€",
|
||||||
|
"businessMonthly": "29,90€",
|
||||||
|
"businessAnnualMonthly": "24,92€",
|
||||||
|
"enterprisePrice": "Sob consulta",
|
||||||
|
"trialBadge": "Teste grátis {days} dias",
|
||||||
|
"trialFeature": "Teste grátis de {days} dias (cartão necessário)",
|
||||||
|
"trialCta": "Experimentar {days} dias grátis"
|
||||||
},
|
},
|
||||||
"cta": {
|
"cta": {
|
||||||
"title": "Pare de perder suas melhores ideias.",
|
"title": "Pare de perder suas melhores ideias.",
|
||||||
|
|||||||
@@ -38,7 +38,24 @@
|
|||||||
"privacyTerms": "© 2025 Memento Labs — Конфиденциальность · Условия",
|
"privacyTerms": "© 2025 Memento Labs — Конфиденциальность · Условия",
|
||||||
"sessionExpired": "Ваш сайт создаётся с навигацией и оглавлением",
|
"sessionExpired": "Ваш сайт создаётся с навигацией и оглавлением",
|
||||||
"welcomeBack": "С возвращением",
|
"welcomeBack": "С возвращением",
|
||||||
"welcomeBackSubtitle": "Введите свои учётные данные для доступа к заметкам."
|
"welcomeBackSubtitle": "Введите свои учётные данные для доступа к заметкам.",
|
||||||
|
"checkEmailTitle": "Проверьте почту",
|
||||||
|
"checkEmailDescription": "Мы отправили ссылку подтверждения на {email}. Откройте её, чтобы активировать аккаунт перед входом.",
|
||||||
|
"checkEmailDescriptionGeneric": "Мы отправили ссылку подтверждения на вашу почту. Откройте её, чтобы активировать аккаунт перед входом.",
|
||||||
|
"resendVerification": "Отправить письмо ещё раз",
|
||||||
|
"verifyResent": "Письмо подтверждения отправлено. Проверьте входящие.",
|
||||||
|
"verifyResendFailed": "Не удалось отправить письмо подтверждения. Попробуйте позже.",
|
||||||
|
"verifyMissingEmail": "Введите адрес электронной почты.",
|
||||||
|
"verifyLoading": "Подтверждение почты…",
|
||||||
|
"verifySuccessTitle": "Почта подтверждена",
|
||||||
|
"verifySuccessDescription": "Аккаунт готов. Теперь можно войти.",
|
||||||
|
"verifyExpiredTitle": "Ссылка устарела",
|
||||||
|
"verifyExpiredDescription": "Срок действия ссылки истёк. Запросите новую.",
|
||||||
|
"verifyInvalidTitle": "Недействительная ссылка",
|
||||||
|
"verifyInvalidDescription": "Эта ссылка недействительна или уже использована.",
|
||||||
|
"emailNotVerified": "Подтвердите почту перед входом.",
|
||||||
|
"emailVerifiedBanner": "Почта подтверждена. Можно войти.",
|
||||||
|
"invalidCredentials": "Неверный e-mail или пароль."
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"notes": "Заметки",
|
"notes": "Заметки",
|
||||||
@@ -1580,7 +1597,58 @@
|
|||||||
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
||||||
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
||||||
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
||||||
"packsCatalogTitle": "Pack catalogue (code)"
|
"packsCatalogTitle": "Pack catalogue (code)",
|
||||||
|
"healthTitle": "Stripe health check",
|
||||||
|
"healthDescription": "Runtime status of keys, webhooks, price IDs and billing flag (secrets are never shown).",
|
||||||
|
"healthSecret": "Secret key (server)",
|
||||||
|
"healthPublishable": "Publishable key",
|
||||||
|
"healthWebhook": "Webhook secret",
|
||||||
|
"healthBillingFlag": "Billing enabled",
|
||||||
|
"healthTrial": "Free trial",
|
||||||
|
"trialDaysValue": "{days} days on first checkout",
|
||||||
|
"modeTest": "Test mode (sk_test_…)",
|
||||||
|
"modeLive": "Live mode (sk_live_…)",
|
||||||
|
"modePlaceholder": "Placeholder / invalid key",
|
||||||
|
"modeMissing": "Not configured",
|
||||||
|
"configured": "Configured",
|
||||||
|
"missing": "Missing",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"priceStatusTitle": "Price IDs vs Stripe",
|
||||||
|
"colKey": "Plan",
|
||||||
|
"colPriceId": "Price ID",
|
||||||
|
"colSource": "Source",
|
||||||
|
"colStripe": "Stripe amount",
|
||||||
|
"priceError": "Lookup failed",
|
||||||
|
"inactive": "inactive",
|
||||||
|
"notChecked": "Not checked (no Stripe key)",
|
||||||
|
"subsTitle": "Subscriptions overview",
|
||||||
|
"subsDescription": "Counts from the local database (synced via Stripe webhooks).",
|
||||||
|
"statPaid": "Active + trial",
|
||||||
|
"statTrialing": "On trial",
|
||||||
|
"statPastDue": "Past due",
|
||||||
|
"statCanceling": "Cancel at period end",
|
||||||
|
"byTier": "By tier",
|
||||||
|
"byStatus": "By status",
|
||||||
|
"usersWithoutSub": "Users with no Subscription row",
|
||||||
|
"noSubs": "No subscriptions yet",
|
||||||
|
"recentSubs": "Recent paid / trial accounts",
|
||||||
|
"colUser": "User",
|
||||||
|
"colTier": "Tier",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"colPeriod": "Period / trial end",
|
||||||
|
"canceling": "canceling",
|
||||||
|
"manualTier": "manual (no Stripe sub)",
|
||||||
|
"trialUntil": "Trial until {date}",
|
||||||
|
"testGuideTitle": "How to test Stripe locally",
|
||||||
|
"testGuideDescription": "Checklist to validate checkout, webhooks and trial.",
|
||||||
|
"testStep1": "Stripe Dashboard → Test mode ON. Create Pro/Business products + monthly/annual prices + credit packs.",
|
||||||
|
"testStep2": "Put sk_test_…, pk_test_… in .env. Put price_… IDs in Admin → Billing (or env) and enable billing.",
|
||||||
|
"testStep3": "Copy the whsec_… into STRIPE_WEBHOOK_SECRET and restart the app.",
|
||||||
|
"testStep4": "npm run dev → open /settings/billing as a BASIC user.",
|
||||||
|
"testStep5": "Start Pro checkout. Card: 4242 4242 4242 4242, any future expiry, any CVC. Expect a 7-day trial.",
|
||||||
|
"testStep6": "Confirm Admin → Billing shows TRIALING, and /settings/billing shows the trial end date.",
|
||||||
|
"testCardHint": "Other cards: 4000000000009995 = payment fails · 4000002500003155 = 3D Secure. Never use real cards in test mode."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
@@ -3113,7 +3181,11 @@
|
|||||||
"packLName": "Мощный пакет",
|
"packLName": "Мощный пакет",
|
||||||
"buyPack": "Купить",
|
"buyPack": "Купить",
|
||||||
"packCheckoutSuccess": "Пакет кредитов добавлен на баланс!",
|
"packCheckoutSuccess": "Пакет кредитов добавлен на баланс!",
|
||||||
"packCheckoutFailed": "Не удалось начать покупку. Проверьте настройки Stripe или повторите попытку."
|
"packCheckoutFailed": "Не удалось начать покупку. Проверьте настройки Stripe или повторите попытку.",
|
||||||
|
"startTrialCta": "Попробовать {days} дней бесплатно",
|
||||||
|
"trialFeature": "Бесплатный период {days} дней (нужна карта)",
|
||||||
|
"trialEndsOn": "Ваш пробный период заканчивается {date}. Затем списание произойдёт автоматически.",
|
||||||
|
"trialEndsLabel": "Конец пробного периода"
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"nav": {
|
"nav": {
|
||||||
@@ -3295,7 +3367,16 @@
|
|||||||
"feature4": "Выделенная поддержка",
|
"feature4": "Выделенная поддержка",
|
||||||
"feature5": "Live-онбординг"
|
"feature5": "Live-онбординг"
|
||||||
},
|
},
|
||||||
"basicPrice": "Бесплатно"
|
"basicPrice": "Бесплатно",
|
||||||
|
"savePercent": "Экономия ~17%",
|
||||||
|
"proMonthly": "9,90€",
|
||||||
|
"proAnnualMonthly": "8,25€",
|
||||||
|
"businessMonthly": "29,90€",
|
||||||
|
"businessAnnualMonthly": "24,92€",
|
||||||
|
"enterprisePrice": "По запросу",
|
||||||
|
"trialBadge": "{days} дней бесплатно",
|
||||||
|
"trialFeature": "Бесплатный период {days} дней (нужна карта)",
|
||||||
|
"trialCta": "Попробовать {days} дней бесплатно"
|
||||||
},
|
},
|
||||||
"cta": {
|
"cta": {
|
||||||
"title": "Хватит терять лучшие идеи.",
|
"title": "Хватит терять лучшие идеи.",
|
||||||
|
|||||||
@@ -38,7 +38,24 @@
|
|||||||
"privacyTerms": "© 2025 Memento Labs — 隐私 · 条款",
|
"privacyTerms": "© 2025 Memento Labs — 隐私 · 条款",
|
||||||
"sessionExpired": "您的网站将包含导航和目录地生成",
|
"sessionExpired": "您的网站将包含导航和目录地生成",
|
||||||
"welcomeBack": "欢迎回来",
|
"welcomeBack": "欢迎回来",
|
||||||
"welcomeBackSubtitle": "输入你的凭据以访问笔记。"
|
"welcomeBackSubtitle": "输入你的凭据以访问笔记。",
|
||||||
|
"checkEmailTitle": "请查收邮件",
|
||||||
|
"checkEmailDescription": "我们已向 {email} 发送确认链接。请打开链接以激活账户后再登录。",
|
||||||
|
"checkEmailDescriptionGeneric": "我们已向您的邮箱发送确认链接。请打开链接以激活账户后再登录。",
|
||||||
|
"resendVerification": "重新发送确认邮件",
|
||||||
|
"verifyResent": "确认邮件已发送,请检查收件箱。",
|
||||||
|
"verifyResendFailed": "无法发送确认邮件,请稍后再试。",
|
||||||
|
"verifyMissingEmail": "请输入电子邮箱地址。",
|
||||||
|
"verifyLoading": "正在确认邮箱…",
|
||||||
|
"verifySuccessTitle": "邮箱已确认",
|
||||||
|
"verifySuccessDescription": "账户已就绪,现在可以登录。",
|
||||||
|
"verifyExpiredTitle": "链接已过期",
|
||||||
|
"verifyExpiredDescription": "此确认链接已过期,请重新申请。",
|
||||||
|
"verifyInvalidTitle": "无效链接",
|
||||||
|
"verifyInvalidDescription": "此确认链接无效或已被使用。",
|
||||||
|
"emailNotVerified": "登录前请先确认邮箱。",
|
||||||
|
"emailVerifiedBanner": "邮箱已确认,现在可以登录。",
|
||||||
|
"invalidCredentials": "邮箱或密码不正确。"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"notes": "笔记",
|
"notes": "笔记",
|
||||||
@@ -1580,7 +1597,58 @@
|
|||||||
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_S": "Pack S — 100 credits (price ID)",
|
||||||
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_M": "Pack M — 500 credits (price ID)",
|
||||||
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
"STRIPE_PRICE_CREDITS_L": "Pack L — 2,000 credits (price ID)",
|
||||||
"packsCatalogTitle": "Pack catalogue (code)"
|
"packsCatalogTitle": "Pack catalogue (code)",
|
||||||
|
"healthTitle": "Stripe health check",
|
||||||
|
"healthDescription": "Runtime status of keys, webhooks, price IDs and billing flag (secrets are never shown).",
|
||||||
|
"healthSecret": "Secret key (server)",
|
||||||
|
"healthPublishable": "Publishable key",
|
||||||
|
"healthWebhook": "Webhook secret",
|
||||||
|
"healthBillingFlag": "Billing enabled",
|
||||||
|
"healthTrial": "Free trial",
|
||||||
|
"trialDaysValue": "{days} days on first checkout",
|
||||||
|
"modeTest": "Test mode (sk_test_…)",
|
||||||
|
"modeLive": "Live mode (sk_live_…)",
|
||||||
|
"modePlaceholder": "Placeholder / invalid key",
|
||||||
|
"modeMissing": "Not configured",
|
||||||
|
"configured": "Configured",
|
||||||
|
"missing": "Missing",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"priceStatusTitle": "Price IDs vs Stripe",
|
||||||
|
"colKey": "Plan",
|
||||||
|
"colPriceId": "Price ID",
|
||||||
|
"colSource": "Source",
|
||||||
|
"colStripe": "Stripe amount",
|
||||||
|
"priceError": "Lookup failed",
|
||||||
|
"inactive": "inactive",
|
||||||
|
"notChecked": "Not checked (no Stripe key)",
|
||||||
|
"subsTitle": "Subscriptions overview",
|
||||||
|
"subsDescription": "Counts from the local database (synced via Stripe webhooks).",
|
||||||
|
"statPaid": "Active + trial",
|
||||||
|
"statTrialing": "On trial",
|
||||||
|
"statPastDue": "Past due",
|
||||||
|
"statCanceling": "Cancel at period end",
|
||||||
|
"byTier": "By tier",
|
||||||
|
"byStatus": "By status",
|
||||||
|
"usersWithoutSub": "Users with no Subscription row",
|
||||||
|
"noSubs": "No subscriptions yet",
|
||||||
|
"recentSubs": "Recent paid / trial accounts",
|
||||||
|
"colUser": "User",
|
||||||
|
"colTier": "Tier",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"colPeriod": "Period / trial end",
|
||||||
|
"canceling": "canceling",
|
||||||
|
"manualTier": "manual (no Stripe sub)",
|
||||||
|
"trialUntil": "Trial until {date}",
|
||||||
|
"testGuideTitle": "How to test Stripe locally",
|
||||||
|
"testGuideDescription": "Checklist to validate checkout, webhooks and trial.",
|
||||||
|
"testStep1": "Stripe Dashboard → Test mode ON. Create Pro/Business products + monthly/annual prices + credit packs.",
|
||||||
|
"testStep2": "Put sk_test_…, pk_test_… in .env. Put price_… IDs in Admin → Billing (or env) and enable billing.",
|
||||||
|
"testStep3": "Copy the whsec_… into STRIPE_WEBHOOK_SECRET and restart the app.",
|
||||||
|
"testStep4": "npm run dev → open /settings/billing as a BASIC user.",
|
||||||
|
"testStep5": "Start Pro checkout. Card: 4242 4242 4242 4242, any future expiry, any CVC. Expect a 7-day trial.",
|
||||||
|
"testStep6": "Confirm Admin → Billing shows TRIALING, and /settings/billing shows the trial end date.",
|
||||||
|
"testCardHint": "Other cards: 4000000000009995 = payment fails · 4000002500003155 = 3D Secure. Never use real cards in test mode."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
@@ -3113,7 +3181,11 @@
|
|||||||
"packLName": "加强包",
|
"packLName": "加强包",
|
||||||
"buyPack": "购买",
|
"buyPack": "购买",
|
||||||
"packCheckoutSuccess": "积分包已加入余额!",
|
"packCheckoutSuccess": "积分包已加入余额!",
|
||||||
"packCheckoutFailed": "无法开始购买。请检查 Stripe 配置或重试。"
|
"packCheckoutFailed": "无法开始购买。请检查 Stripe 配置或重试。",
|
||||||
|
"startTrialCta": "免费试用 {days} 天",
|
||||||
|
"trialFeature": "{days} 天免费试用(需绑定支付方式)",
|
||||||
|
"trialEndsOn": "您的免费试用将于 {date} 结束,之后将自动扣费。",
|
||||||
|
"trialEndsLabel": "试用结束"
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"nav": {
|
"nav": {
|
||||||
@@ -3295,7 +3367,16 @@
|
|||||||
"feature4": "专属支持",
|
"feature4": "专属支持",
|
||||||
"feature5": "现场入职"
|
"feature5": "现场入职"
|
||||||
},
|
},
|
||||||
"basicPrice": "免费"
|
"basicPrice": "免费",
|
||||||
|
"savePercent": "节省约 17%",
|
||||||
|
"proMonthly": "€9.90",
|
||||||
|
"proAnnualMonthly": "€8.25",
|
||||||
|
"businessMonthly": "€29.90",
|
||||||
|
"businessAnnualMonthly": "€24.92",
|
||||||
|
"enterprisePrice": "定制方案",
|
||||||
|
"trialBadge": "{days} 天免费试用",
|
||||||
|
"trialFeature": "{days} 天免费试用(需绑定支付方式)",
|
||||||
|
"trialCta": "免费试用 {days} 天"
|
||||||
},
|
},
|
||||||
"cta": {
|
"cta": {
|
||||||
"title": "别再丢掉最好的想法。",
|
"title": "别再丢掉最好的想法。",
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Grandfather existing password accounts so email-verification only applies to new signups.
|
||||||
|
-- Non-destructive: only fills NULL emailVerified for users that already have a password.
|
||||||
|
UPDATE "User"
|
||||||
|
SET "emailVerified" = COALESCE("emailVerified", "createdAt")
|
||||||
|
WHERE "password" IS NOT NULL
|
||||||
|
AND "emailVerified" IS NULL;
|
||||||
Reference in New Issue
Block a user