Le modèle économique était gravé dans quinze écrans et trois pages légales
Passer fokan en gratuit demandait de toucher le prix, le mur, les CGV, la politique de confidentialité, les messages d'erreur d'API et jusqu'au texte affiché dans l'agenda du foyer. `FOKAN_MODE` remplace tout ça par un mot, avec « payant » pour défaut — une variable oubliée ne doit jamais ouvrir l'accès. Ce qui rend la bascule bon marché était déjà là : `getSubscription().isActive` est la porte unique du produit, et un accès gratuit est un statut « active » sans date de fin, que `!periodEnd` rendait déjà permanent. Elle n'a pas changé. Le mode décide de ce qu'on raconte à qui n'est pas entré, jamais de ce qu'on lui ouvre. La liste d'attente n'existe pas : pas de table, pas de file, pas de rang stocké. Un foyer sans place porte le statut « attente », et le passage de 4 h admet les plus anciens sur `attente_depuis`. Relever FOKAN_PLACES_GRATUITES suffit — la variable est relue à chaque exécution du cron. Trois pièges fermés au passage : - le mode se lit côté serveur et descend en props. `process.env` n'existe pas dans le bundle navigateur : un import depuis un composant client aurait rendu « payant » côté client et « gratuit » côté serveur, en silence, à l'hydratation ; - /api/checkout est fermé au serveur, pas seulement à l'écran. Une clé Stripe encore présente — le cas normal d'une bascule qu'on veut pouvoir défaire — l'aurait fait aboutir, et on aurait encaissé pour un accès offert ; - /api/stripe/portal reste ouvert. Un abonnement souscrit en ligne doit se résilier en ligne (art. L215-1-1) : fermer la sortie parce qu'on ferme l'entrée enfermerait ceux qui ont payé. Cinq mentions légales tombent avec la vente — SIREN, téléphone, médiateur, régime de TVA, garantie de conformité — parce qu'un article décrivant une obligation qui n'existe pas est pire qu'un article absent. Deux apparaissent : la gratuité (un service gratuit qui tait son financement se fait lire « c'est toi le produit ») et le plafond d'accès, qui est une limitation subie. La politique de confidentialité perd Stripe quatre fois : déclarer un sous-traitant qui ne traite rien n'est pas prudent, c'est inexact. Un test rend réellement les trois pages dans les deux modes plutôt que de relire des ternaires — c'est la seule façon de vérifier qu'aucune n'affirme une chose fausse, et il tient aussi la numérotation des articles. Corrigé en passant : les mentions légales renvoyaient encore les sources vers la page d'accueil, où D-064 les a retirées la veille. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
40894eef5f
commit
62a7b0216d
51 changed files with 6270 additions and 436 deletions
32
.env.example
32
.env.example
|
|
@ -67,6 +67,32 @@ FROM_NAME=fokan
|
|||
# qui est par construction une vraie boîte — contrairement à l'alias `noreply@` de FROM_EMAIL.
|
||||
# REPLY_TO_EMAIL=contact@fokan.fr
|
||||
|
||||
# ── Mode d'accès (D-065) ───────────────────────────────────────────────────────
|
||||
# « payant » (défaut) : mur Stripe, 19,99 €/an, tout le vocabulaire d'abonnement.
|
||||
# « gratuit » : mur d'inscription (une adresse, pas de carte), pages CGU au lieu de
|
||||
# CGV, et /api/checkout fermé côté serveur — pas seulement à l'écran.
|
||||
#
|
||||
# Le défaut est « payant » : une variable absente ou mal orthographiée doit laisser le produit
|
||||
# dans l'état qu'on lui connaît, jamais ouvrir l'accès complet à tout le monde sur une faute de
|
||||
# frappe. Seul le mot exact « gratuit » bascule.
|
||||
#
|
||||
# La bascule est réversible et n'efface rien : les accès déjà ouverts portent `acces_mode`, qui
|
||||
# distingue ce qui a été acheté de ce qui a été offert. `/api/stripe/portal` reste ouvert en
|
||||
# mode gratuit, pour que les abonnements en cours restent résiliables en ligne (art. L215-1-1).
|
||||
FOKAN_MODE=payant
|
||||
|
||||
# Plafond de comptes gratuits. IGNORÉ en mode payant (sans quoi un plafond oublié limiterait un
|
||||
# jour le nombre de clients). Vide = pas de plafond.
|
||||
#
|
||||
# Au-delà, l'inscription crée le compte et le range en liste d'attente : le foyer est calculé,
|
||||
# son détail reste masqué, et le passage quotidien de 4 h admet les plus anciens dès qu'il reste
|
||||
# de la place. RELEVER CE NOMBRE SUFFIT — rien à déclencher, rien à écrire : la variable est
|
||||
# relue à chaque exécution du cron, et les admissions partent le lendemain matin avec leur mail.
|
||||
#
|
||||
# `0` est un plafond légitime, pas un synonyme de « vide » : il ferme les inscriptions et met
|
||||
# tout le monde en attente, ce qui est le geste d'un lancement qu'on prépare.
|
||||
# FOKAN_PLACES_GRATUITES=100
|
||||
|
||||
# Paiement (Stripe, § 10.1) — 19,99 €/an. Sans STRIPE_SECRET_KEY, /api/checkout répond 501.
|
||||
# Utiliser les clés de TEST (sk_test_/whsec_test) tant que la case de rétractation (§ 10.7)
|
||||
# n'a pas été validée par un professionnel du droit — ne jamais passer en clés live avant.
|
||||
|
|
@ -88,6 +114,12 @@ STRIPE_PRICE_ANNUAL=
|
|||
# ⚠️ Sans LEGAL_SIREN, le service ne peut pas être vendu légalement : encaisser sans
|
||||
# immatriculation rend les CGV inopposables et relève de l'art. L8221-3 du Code du travail.
|
||||
# L'immatriculation micro-entreprise est gratuite (guichet unique INPI, 1 à 2 semaines).
|
||||
#
|
||||
# En FOKAN_MODE=gratuit, quatre champs cessent d'être réclamés parce qu'ils ne tiennent qu'au
|
||||
# commerce : LEGAL_SIREN (art. 6 III-1 LCEN, éditeur professionnel), LEGAL_TELEPHONE (art.
|
||||
# L221-5, vente à distance), LEGAL_MEDIATEUR_* (art. L616-1, litiges de consommation) et
|
||||
# LEGAL_REGIME_TVA (il n'y a pas de prix à qualifier). Les cinq autres restent dus dans les deux
|
||||
# modes — identité, siège, responsable de publication, contact, hébergeur.
|
||||
LEGAL_IDENTITE=
|
||||
LEGAL_SIEGE=
|
||||
LEGAL_SIREN=
|
||||
|
|
|
|||
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -29,6 +29,16 @@ Documents de référence par pilier : [logement](docs/pilier-logement.md) · [v
|
|||
- **Filtrer n'autorise pas à compter moralement** (D-041). Le calendrier du foyer se filtre (domaine, enjeu, échéance, récurrence) et se trie, mais son défaut est neutre : aucun filtre actif, tri par domaine, c'est-à-dire le rendu d'avant. Les chiffres des puces sont des volumes, jamais un « à traiter » ; l'état vide dit que la combinaison ne ramène rien, jamais « bravo, tout est à jour ». **Un horizon n'avale jamais une échéance non datée** — « je ne sais pas » est une réponse de première classe, et la ranger sous « dans les 2 mois » lui ferait répondre une question qu'elle ne tranche pas. Les comptes des puces sont des **facettes** : chaque puce compte ce que donnerait son activation, les autres axes restant tels quels, faute de quoi elle annoncerait un total que le lecteur ne peut pas vérifier à l'écran.
|
||||
- **Deux guides entiers par visiteur, puis l'aperçu** (D-042). Le catalogue était réservé aux abonnés, mais le rail « à lire aussi » de chaque fiche EST la table des matières qu'il prétendait protéger. Trois choses à savoir avant d'y toucher : **aucune IP n'est stockée** (HMAC salé, purge à 90 jours), le **crawler reçoit le texte entier** avec le balisage `isAccessibleForFree:false` + `hasPart` qui le déclare — sans quoi le paywall détruirait le SEO qu'il monétise —, et **on coupe le markdown au jeton, jamais le HTML au caractère** : une balise laissée ouverte n'abîme pas l'article, elle avale le reste de la page. Le compteur est une **friction commerciale, pas une frontière de sécurité** : il est partagé derrière un CGNAT et remis à zéro par un changement d'IP, c'est assumé.
|
||||
|
||||
## Ce que le mode d'accès change, et ce qu'il ne touche jamais
|
||||
|
||||
**Le produit a deux modes, et une seule porte** (D-065). `FOKAN_MODE` vaut `gratuit` ou `payant`, **défaut `payant`** — une variable absente ou mal orthographiée doit laisser le produit dans l'état qu'on lui connaît, jamais ouvrir l'accès à tout le monde sur une faute de frappe. Cinq choses à savoir avant d'y toucher :
|
||||
|
||||
- **`getSubscription().isActive` ne connaît pas le modèle économique.** C'est la porte unique de tout le produit (hub, .ics, gestes, rappels, catalogue, rejeu) et elle n'a pas bougé : un accès gratuit est un `subscription_status = "active"` **sans date de fin**, et `!periodEnd` le rendait déjà actif indéfiniment. Ce que le mode change est ce qu'on **raconte** à qui n'a pas franchi la porte, jamais ce qu'on lui ouvre. `acces_mode` dit comment elle a été franchie — sans cette colonne, le plafond de places gratuites compterait les foyers payants et fermerait les inscriptions au profit de gens qui ont payé.
|
||||
- **Le mode se lit côté serveur, et descend en `props`.** `process.env.FOKAN_MODE` n'existe pas dans le bundle navigateur : un import de `lib/mode-serveur.ts` depuis un composant `"use client"` répondrait « payant » quoi qu'il arrive, et la page se contredirait à l'hydratation, en silence. Le vocabulaire partagé est dans `lib/mode.ts` (pur, isomorphe) ; **les phrases entières dont la structure change restent dans leur composant** — on mutualise le vocabulaire, jamais le discours. Quand une phrase peut être dite *vraie dans les deux modes*, on la neutralise plutôt que de la dupliquer (c'est ce qui a été fait pour le quota de rejeux : « par an », pas « par année d'abonnement »).
|
||||
- **La liste d'attente n'existe pas.** Pas de table, pas de file, pas de rang stocké : un foyer sans place porte `subscription_status = "attente"`, et le passage de 4 h admet les plus anciens (`attente_depuis`) quand il reste de la place. **Relever `FOKAN_PLACES_GRATUITES` suffit** — la variable est relue à chaque exécution du cron. La décision se prend à l'écriture et non à l'affichage, sans quoi la place irait à qui recharge sa page le plus souvent. `0` est un plafond légitime, pas un synonyme de « vide ».
|
||||
- **Le full reveal est le même, la contrepartie change.** `POST /api/activer` est le pendant de `/api/checkout` : même écran, même position, une adresse au lieu d'une carte. **Aucune session n'est ouverte sur une adresse simplement tapée** — ce serait donner le foyer à qui saisirait l'adresse d'un autre ; le lien magique est ce qui prouve la boîte. Le mode gratuit ferme `/api/checkout` **au serveur** (une clé Stripe encore présente la ferait aboutir), mais **jamais `/api/stripe/portal`** : un abonnement souscrit en ligne doit se résilier en ligne (art. L215-1-1), et fermer la sortie enfermerait ceux qui ont payé.
|
||||
- **Cinq mentions légales ne tiennent qu'au commerce**, et les afficher comme manquantes sur un service gratuit serait faux, pas seulement inutile : SIREN (art. 6 III-1 LCEN, éditeur professionnel), téléphone (art. L221-5, vente à distance), médiateur (art. L616-1, litiges de consommation — et adhésion payante), régime de TVA, garantie légale de conformité (art. L224-25-1). Restent dues dans les deux modes : identité, siège, responsable de publication, contact, hébergeur. `/cgv` **garde son URL** et change de titre — c'est le titre qui nomme un document, pas son chemin. La politique de confidentialité perd Stripe quatre fois : déclarer un sous-traitant qui ne traite rien n'est pas prudent, c'est inexact (art. 13 RGPD).
|
||||
|
||||
## Ce qu'une tranche de revenus autorise, et ce qu'elle n'autorise pas
|
||||
|
||||
**On ne simule pas des droits, on filtre des échéances** (D-051). Le quiz demande une tranche de revenus en **net mensuel**, facultative, dont les bornes sont calculées sur les plafonds réels de la composition du foyer — 3 à 4 pastilles selon la famille. Quatre points à connaître avant d'y toucher :
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -11,7 +11,8 @@ Marque sœur de [Kankwa](../kankwa), éditée par MIAW. Nom de code : **fokan**
|
|||
- **Un quiz de 2 minutes** (9 écrans) plus des détections gratuites — adresse (BAN, DPE ADEME, Géorisques, ZFE, arrêtés, restrictions d'eau, calendrier scolaire de l'académie) et carte grise (champ K → référentiel RDW pour le modèle, RappelConso pour les rappels de ta version) — produisent le calendrier complet du foyer, 30 à 50 échéances, sans saisie obligatoire.
|
||||
- **Le mail est le moteur** : rappels rares, au bon moment, avec l'action incluse. Réaction en 10 s, ou ignorance sans conséquence — le glissement est dans le design.
|
||||
- **Une carte des périmètres réglementaires** : cinq couches (nucléaire, sécheresse, ZFE, PPRT, inondation), où la frontière EST la règle. Une couche est de la configuration, pas du code (D-022 → D-031).
|
||||
- **Révélation graduée** : gratuit = l'inventaire (total, rythme de l'année, 2-3 échéances en clair, des halos sans frontière). Payant = **19,99 €/an par foyer** — full reveal, veille active, rappels, flux ics vivant, catalogue de guides, enrichissement continu. Le prix ne s'affiche **qu'après le quiz** (D-028).
|
||||
- **Révélation graduée** : avant la porte = l'inventaire (total, rythme de l'année, 2-3 échéances en clair, des halos sans frontière). Après = full reveal, veille active, rappels, flux ics vivant, catalogue de guides, enrichissement continu.
|
||||
- **Deux modes d'accès, une variable** (`FOKAN_MODE`, [D-065](docs/decisions.md)) : **`gratuit`** — une adresse suffit, avec un plafond de comptes (`FOKAN_PLACES_GRATUITES`) et une liste d'attente au-delà — ou **`payant`** — 19,99 €/an par foyer via Stripe. Le défaut est `payant`, pour qu'une variable oubliée n'ouvre jamais l'accès par accident. Dans les deux cas la contrepartie ne s'affiche **qu'après le quiz** (D-028), et la porte du produit (`getSubscription().isActive`) ignore lequel des deux est actif.
|
||||
- **La barrière** : la base de connaissance des échéances françaises et le pipeline de compréhension de documents. Le produit, c'est le savoir ; le code n'est que son véhicule.
|
||||
|
||||
## Où en est le produit
|
||||
|
|
@ -34,9 +35,10 @@ Marque sœur de [Kankwa](../kankwa), éditée par MIAW. Nom de code : **fokan**
|
|||
| Pilier enfance & scolarité | **5e pilier, ouvert le 28/07/2026 (D-019)** — bourses collège et lycée, Parcoursup, inscriptions maternelle et lycée, assurance scolaire, DSE, et la déclaration de scolarité des 16-18 ans sans laquelle la Caf ne verse pas l'ARS (D-051) |
|
||||
| Pilier animaux | Fiches livrées ; `garde-vacances` calée sur le calendrier scolaire (D-019) |
|
||||
| Calendrier scolaire | Livré (D-019) — API Éducation nationale, grain académie, rafraîchi au passage hebdomadaire |
|
||||
| Comptes, paiement, notifications | better-auth, Stripe (clés de test), scheduler quotidien — livrés |
|
||||
| Comptes, accès, notifications | better-auth, scheduler quotidien — livrés. Deux portes d'accès : inscription gratuite (`/api/activer`) ou Stripe (clés de test), au choix de `FOKAN_MODE` (D-065) |
|
||||
| Liste d'attente | Livrée (08/08/2026, D-065) — pas de table : un `subscription_status = "attente"` et le passage de 4 h qui admet dans l'ordre d'arrivée. Relever `FOKAN_PLACES_GRATUITES` suffit à ouvrir des places |
|
||||
| Contrats | 8 types déclarables au quiz, 8 fiches |
|
||||
| Rejeu du quiz | Livré (28/07/2026) — un abonné met à jour ses déclarations, garde-fou de **cinq rejeux par période d'abonnement** (D-060) : les dates sont conservées, le quota se recompte contre la période payée, donc la reconduction annuelle le relève sans qu'aucun code ne tourne |
|
||||
| Rejeu du quiz | Livré (28/07/2026) — un foyer dont l'accès est ouvert met à jour ses déclarations, garde-fou de **cinq rejeux par an** (D-060) : les dates sont conservées, le quota se recompte contre la période en cours (ou une année glissante quand il n'y en a pas, cas de l'accès gratuit), donc rien ne tourne à la date de remise à zéro |
|
||||
| CI, lint, migrations versionnées | Livrés (28/07/2026) — cf. `.github/workflows/ci.yml`, `drizzle/` |
|
||||
| Sauvegardes | **Absentes** — seul risque que le cadrage qualifie d'inacceptable (§ 24.2), différé à la demande du porteur de projet |
|
||||
|
||||
|
|
@ -91,11 +93,11 @@ Le projet tourne en conteneur Docker (`docker-compose.yml`) : `fokan-db` (Postgr
|
|||
|
||||
## Roadmap
|
||||
|
||||
1. ✅ **Phase 0** — landing, quiz réel, hub à révélation graduée, carte, pages légales. *(Le prix a quitté la landing : il ne s'affiche qu'après le quiz, D-028.)*
|
||||
1. ✅ **Phase 0** — landing, quiz réel, hub à révélation graduée, carte, pages légales. *(La contrepartie a quitté la landing : elle ne s'affiche qu'après le quiz, D-028.)*
|
||||
2. **MVP — la veille seule** *(en cours)* : les rappels mail, la CI, les migrations, la carte et **les gestes du foyer** sont livrés ; restent le sourcing des intervalles de distribution et **les sauvegardes** (seul risque que le cadrage qualifie d'inacceptable, § 24.2 — traité à l'échelle du serveur, hors de ce dépôt depuis [D-054](docs/decisions.md)).
|
||||
3. **Phase 2** — pipeline de compréhension de documents (mail entrant) + bannette ; les contrats se renseignent par transfert de PDF. C'est aussi ce qui débloquerait les rappels de produits non-véhicules (bébés-enfants, maison, électroménager).
|
||||
4. **Phase 3** — scan photo, multiplicateurs (foyer des parents, bien locatif — ⚠️ rouvre D-021 : « un compte ne porte qu'un foyer »), affiliation activée.
|
||||
|
||||
**Prochains chantiers, dans l'ordre** ([D-054](docs/decisions.md), qui reclasse l'audit du 30/07 § 8) : ① le **sourcing des intervalles de courroie**, seul travail qui fasse parler la distribution · ② le **récap annuel** et le mail de fin d'abonnement, deux messages encore absents · ③ la **purge RGPD**, due à la fin de la première année d'abonnement non renouvelé, pas avant · ④ nom et identité.
|
||||
**Prochains chantiers, dans l'ordre** ([D-054](docs/decisions.md), qui reclasse l'audit du 30/07 § 8) : ① le **sourcing des intervalles de courroie**, seul travail qui fasse parler la distribution · ② le **récap annuel**, message encore absent (le mail de fin d'abonnement, lui, n'a plus d'objet tant que `FOKAN_MODE=gratuit`) · ③ la **purge RGPD** — son échéance change avec D-065 : elle n'était due qu'à la fin d'une première année d'abonnement non renouvelé, or un accès gratuit ne se termine jamais. Ce qui la déclenche désormais est le premier foyer dormant depuis douze mois, ce que la politique de confidentialité annonce · ④ nom et identité.
|
||||
|
||||
Les **gestes du foyer** — « fait » / « déjà fait », sourdine, reprise, responsable — sont livrés le 06/08/2026 ([D-055](docs/decisions.md)) : c'était le premier de cette liste depuis l'audit du 30/07, et le seul chantier qui augmentait la valeur de tout l'existant.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
# Audit du service mail — 30/07/2026
|
||||
|
||||
> ⚠️ **Amendé le 08/08/2026 par [D-065](decisions.md).** Cet audit décrit un produit à porte
|
||||
> payante ; le passage en mode gratuit ne change ni les cinq messages recensés, ni leurs
|
||||
> déclencheurs, ni les défauts du § 4 — mais **le lien magique porte désormais six contextes au
|
||||
> lieu de trois** : `inscription` (l'accès s'ouvre), `attente` (la place est prise, l'accès non)
|
||||
> et `place-liberee` (le passage de 4 h a admis le foyer depuis la liste d'attente) s'ajoutent à
|
||||
> `connexion`, `invitation` et `activation`. Ce sont des variantes du **même** message, soumises
|
||||
> au même sceau interne (§ 4.1) — pas trois envois de plus. Le § 6 (décisions produit ouvertes)
|
||||
> et le § 7 (chantiers hors canal) restent valables tels quels, à une exception : le mail de fin
|
||||
> d'abonnement qu'ils appellent n'a plus d'objet tant que `FOKAN_MODE=gratuit`.
|
||||
|
||||
Périmètre : tout ce qui peut faire partir un message depuis `noreply@fokan.fr`, du prospect
|
||||
anonyme à l'abonné payant. Trois questions, dans cet ordre : **quels mails**, **sur quel
|
||||
déclencheur et sous quelles conditions**, **avec quel contenu**. Les défauts sont en § 4.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,87 @@
|
|||
# Journal des décisions post-cadrage
|
||||
|
||||
## D-065 — 8 août 2026 · Le mode d'accès devient une variable : gratuit ou payant, sans casser l'existant
|
||||
|
||||
**Contexte** : décision du porteur de projet de passer fokan en accès gratuit, avec un plafond de comptes et une liste d'attente au-delà. La consigne est explicite — **on ne case pas l'existant, on rend modulaire** : le payant doit rester atteignable d'un mot dans un fichier d'environnement, au cas où le modèle y revienne.
|
||||
|
||||
### 1. Une variable, deux modes, et une porte qui ne connaît ni l'un ni l'autre
|
||||
|
||||
`FOKAN_MODE` vaut `gratuit` ou `payant`, **défaut `payant`**. Le défaut n'est pas une commodité : une variable absente ou mal orthographiée en production doit laisser le produit dans l'état qu'on lui connaît, jamais ouvrir l'accès complet à tout le monde sur une faute de frappe. Seul le mot exact bascule ; `GRATUIT`, `free`, `1` et `gratui` restent payants, et un test le grave.
|
||||
|
||||
La pièce qui rend la bascule bon marché était déjà là : **`getSubscription().isActive` est la seule porte de tout le produit** — hub, flux .ics, gestes, rappels, catalogue, rejeu du quiz, quinze appelants. Elle n'a pas changé. Un accès gratuit est un `subscription_status = "active"` **sans date de fin**, et `!periodEnd` le rendait déjà actif indéfiniment. Ce que le mode change est ce qu'on **raconte** à qui n'a pas franchi la porte — jamais ce qu'on lui ouvre.
|
||||
|
||||
Trois colonnes s'ajoutent à `households`, et pas une table : `acces_mode` (`gratuit` | `payant`), `acces_le`, `attente_depuis`. La première est celle sans laquelle rien ne marche — sans elle, le plafond de places gratuites compterait les foyers payants historiques et fermerait les inscriptions au profit de gens qui ont payé. La migration les rétro-remplit sur les foyers qui portent un identifiant d'abonnement Stripe : on écrit la vérité qu'on connaît pendant qu'on la connaît encore.
|
||||
|
||||
**Le mode se lit côté serveur uniquement** (`lib/mode-serveur.ts`) et descend en `props` jusqu'aux composants client. Ce n'est pas de la discipline gratuite : `process.env.FOKAN_MODE` n'existe pas dans le bundle navigateur (Next n'injecte que `NEXT_PUBLIC_*`), un import depuis un composant `"use client"` répondrait donc « payant » quoi qu'il arrive, et la page se contredirait entre son rendu serveur et son hydratation — en silence. Le vocabulaire partagé vit dans `lib/mode.ts`, pur et isomorphe ; les décomptes de base dans `lib/places.ts`, pour que les trois pages légales n'aient pas à traîner un pool Postgres pour afficher un titre.
|
||||
|
||||
### 2. La liste d'attente n'existe pas
|
||||
|
||||
C'est la conception, pas une pirouette : il n'y a **ni table, ni file, ni rang stocké**. Un foyer sans place porte `subscription_status = "attente"` au lieu de `"active"`, et le passage quotidien de 4 h bascule les plus anciens quand il reste de la place. La seule chose ajoutée au produit est une valeur de statut et une colonne de date — et c'est ce qui rend la bascule inverse gratuite : repasser en payant ne demande de supprimer aucun objet, seulement de cesser d'admettre.
|
||||
|
||||
**Ce que « compte latent » veut dire, précisément** : le compte existe pour de bon (il est dans `user`, il est membre du foyer, il peut se connecter, son quiz a tourné, ses échéances sont calculées et continuent de se recalculer chaque nuit) ; ce qui n'est pas ouvert, c'est le déverrouillage. Un foyer en attente est exactement dans l'état d'un foyer gratuit d'avant — l'aperçu, rien de plus — à ceci près qu'il n'a plus rien à faire pour en sortir.
|
||||
|
||||
**Le rang ne se stocke pas, il se recalcule.** C'est la règle déjà appliquée au quota de rejeux (D-060) et aux conditions de ressources (D-051) : on stocke ce que le foyer a fait, jamais ce qu'on en conclut. Un rang en base serait un verdict à réécrire à chaque admission et à chaque suppression de compte, et qui mentirait entre-temps.
|
||||
|
||||
**Relever la limite ne demande rien d'autre que de relever la limite.** `FOKAN_PLACES_GRATUITES` est relue à chaque exécution du cron ; passer de 100 à 150 se traduit par cinquante admissions le lendemain matin, chacune avec son mail. Pas de commande à taper, pas d'écran d'administration à construire. Trois choix expliqués :
|
||||
|
||||
- **La décision se prend à l'écriture, pas à l'affichage.** Décider à la lecture donnerait la place à celui qui recharge sa page le plus souvent.
|
||||
- **L'admission est quotidienne et non immédiate**, parce que la seule chose qui bouge est le plafond, et qu'il bouge quand un humain édite un fichier. Un passage par jour est à l'échelle de cet événement-là.
|
||||
- **`0` est un plafond légitime, pas un synonyme de « vide »** : il ferme les inscriptions et met tout le monde en attente — le geste d'un lancement qu'on prépare. Un plafond *abaissé* sous le nombre d'accès déjà ouverts ne ferme personne (invariant n° 3) et ne rend jamais un nombre de places négatif ; un test tient ce cas, parce qu'un `restantes: -12` passé en `limit()` ferait planter le passage quotidien.
|
||||
|
||||
### 3. Le full reveal quitte le paiement pour l'inscription — mêmes chemins, sans Stripe
|
||||
|
||||
`POST /api/activer` est le pendant exact de `/api/checkout` : même écran, même position dans le parcours, même geste. Ce qui change est la contrepartie — **une adresse au lieu d'une carte** — et ce qui disparaît avec elle : le prix, la case de renonciation au droit de rétractation (pas de contrat à distance à titre onéreux, donc rien à quoi renoncer), et le passage par un tiers.
|
||||
|
||||
**L'adresse redevient obligatoire, et ce n'est pas un retour au mur e-mail de D-005.** Ce mur-là se dressait *avant* toute contrepartie ; celui-ci vient après que le foyer a vu son calendrier. Et l'adresse **est** le compte : sans elle, ni destinataire de rappel, ni moyen de retrouver son foyer, ni place à tenir dans une file. En payant, c'est Stripe qui la collecte au même endroit du parcours — on ne demande rien de plus, on le demande soi-même.
|
||||
|
||||
**Aucune session n'est ouverte sur une adresse simplement tapée.** Le déverrouillage suppose une session (D-017 : le lien seul ne suffit plus une fois l'accès ouvert), et connecter quelqu'un sur une adresse non prouvée donnerait le foyer à quiconque saisirait celle d'un autre. Le lien magique est ce qui prouve la boîte ; c'est un aller-retour, pas une friction gratuite. La route est limitée à cinq appels par quart d'heure et par IP — elle crée un compte et fait partir un mail vers une adresse fournie par l'appelant, c'est-à-dire qu'elle serait sans cela un relais d'envoi depuis un domaine aligné SPF/DKIM/DMARC.
|
||||
|
||||
Le rattachement du compte au foyer, vingt lignes qui vivaient dans le webhook Stripe (D-049), est extrait dans `lib/compte-foyer.ts` et partagé par les deux portes : deux copies auraient divergé au premier correctif, sur le chemin dont l'enjeu est qu'un foyer ait un destinataire de rappel. Une seule différence subsiste, et elle est passée en argument : `emailVerified` vaut `true` côté Stripe (qui vient d'envoyer un reçu à cette adresse) et `false` côté formulaire — marquer vérifiée une adresse tapée ferait dire au produit qu'il a constaté quelque chose qu'il n'a pas constaté.
|
||||
|
||||
**Le mode gratuit ferme `/api/checkout` au serveur, pas seulement à l'écran.** Un mur qui ne propose plus de payer ne suffit pas : la route reste appelable, et une clé Stripe encore présente dans l'environnement — le cas normal d'une bascule qu'on veut pouvoir défaire — la ferait aboutir. On encaisserait alors 19,99 € pour un accès offert.
|
||||
|
||||
**`/api/stripe/portal` n'est PAS fermé, et c'est une obligation.** Un abonnement souscrit en ligne doit se résilier en ligne, en trois étapes au plus, depuis une fonctionnalité accessible en permanence (art. L215-1-1 du Code de la consommation ; 15 000 € d'amende administrative au manquement). Fermer la porte de sortie parce qu'on a fermé la porte d'entrée enfermerait précisément ceux qui ont payé.
|
||||
|
||||
### 4. Trois contextes de mail, pas trois mails
|
||||
|
||||
Le lien magique en portait déjà trois (`connexion`, `invitation`, `activation`) ; il en porte six. `inscription` (l'accès s'ouvre), `attente` (la place est prise, l'accès non), `place-liberee` (le passage quotidien a admis le foyer). Ce n'est donc pas un sixième message au sens de l'audit mail : c'est la même mécanique, avec sa table de messages, et les nouveaux contextes sont soumis **au même sceau interne** que les autres — sans quoi n'importe qui pourrait faire partir depuis `noreply@fokan.fr` un « ta place s'est libérée » avec un lien de connexion qui fonctionne.
|
||||
|
||||
Deux tests gardent le piège le plus probable : **`attente` est le seul de la famille à ne pas annoncer un accès ouvert**, et un copier-coller depuis `inscription` produirait un message intitulé « Ton foyer est ouvert » suivi d'un lien menant à un aperçu verrouillé — ce qui se lit comme une panne, pas comme une file d'attente. Le second vérifie qu'aucun contexte hors `activation` ne parle de paiement.
|
||||
|
||||
`place-liberee` est le seul point où la liste d'attente parle à l'extérieur, et il est indispensable : sans lui, un compte admis six semaines plus tard ne l'apprendrait qu'en revenant de lui-même sur un site qu'il a peut-être oublié — soit exactement le contraire de la promesse du produit.
|
||||
|
||||
### 5. Les pages légales : ce qui tombe avec la vente, et ce qui n'en dépendait pas
|
||||
|
||||
**`/cgv` garde son URL et change de titre.** Elle est liée depuis le pied de page, depuis le mur, depuis d'anciens mails et probablement depuis des signets ; déplacer un document contractuel à chaque bascule casserait ces liens dans les deux sens. C'est le titre qui nomme un document, pas son chemin.
|
||||
|
||||
**Cinq mentions ne tiennent qu'au commerce, et les afficher comme manquantes sur un service gratuit serait faux au sens strict** — pas seulement inutile. `mentionsManquantes()` prend donc le mode :
|
||||
|
||||
| Mention | Pourquoi elle tombe en gratuit |
|
||||
| --- | --- |
|
||||
| SIREN | art. 6 III-1 LCEN — exigé de l'éditeur **professionnel** |
|
||||
| Téléphone | art. L221-5 et R111-1, 4° — régissent la **vente** à distance |
|
||||
| Médiateur de la consommation | art. L616-1 — litiges nés d'un contrat à titre onéreux ; l'adhésion est de surcroît payante, la réclamer reviendrait à faire payer la gratuité |
|
||||
| Régime de TVA | il n'y a pas de prix à qualifier |
|
||||
| Garantie légale de conformité | art. L224-25-1 — contrats conclus « contre un prix » ou contre des données fournies en contrepartie ; le second cas ne joue pas, l'article 2 des CGU l'engageant en toutes lettres |
|
||||
|
||||
**Ce qui reste dû dans les deux modes** : identité, siège, responsable de la publication, contact, hébergeur. Le régime allégé du III-2 LCEN ne joue pas — il suppose qu'un hébergeur **tiers** détienne l'identité, alors que le service est auto-hébergé (§ 22).
|
||||
|
||||
**Deux articles apparaissent en gratuit**, et aucun n'est décoratif. *Gratuité* : un service gratuit qui ne dit pas comment il se finance laisse le lecteur conclure « c'est moi le produit » — on répond par les engagements vérifiables que la politique de confidentialité tient déjà (aucune publicité, aucune revente, aucun traceur), et par la clause qu'un passage au payant ne serait jamais rétroactif. *Nombre d'accès et liste d'attente* : elle décrit une limitation que l'utilisateur subit, et la taire dans un document contractuel serait le silence qu'on reproche aux autres.
|
||||
|
||||
**La politique de confidentialité perd Stripe quatre fois** — sous-traitant, destinataire, transfert hors UE, durée de conservation comptable. Déclarer un sous-traitant qui ne traite rien n'est pas prudent, c'est inexact, et l'art. 13 impose l'exactitude. Les foyers ayant payé avant la bascule existent : le texte les nomme **au passé** plutôt que de faire comme s'ils n'avaient jamais existé.
|
||||
|
||||
Un défaut sans rapport corrigé en passant : les mentions légales renvoyaient encore les sources vers « la page d'accueil », où D-064 les a retirées la veille.
|
||||
|
||||
### 6. Le vocabulaire, et ce qu'on a refusé d'y mettre
|
||||
|
||||
`lib/mode.ts` porte le vocabulaire qui se répète mot pour mot d'un écran à l'autre — contrepartie, bénéficiaire, CTA, prix, nom des conditions — plus deux formules composées (`reserveAux`, `ceQuiLOuvre`). **Les phrases entières dont la structure change restent dans leur composant**, en ternaire : une table de chaînes qui grossit finit par rendre illisible ce que la page raconte. On mutualise le vocabulaire, jamais le discours.
|
||||
|
||||
Deux messages ont été **neutralisés** plutôt que dupliqués : le quota de rejeux disait « cinq mises à jour par année d'abonnement » et « le compteur repart à la prochaine période d'abonnement ». Il dit « par an » et « à la prochaine période », vrai dans les deux modes — six points d'appel évités, dont un composant client, pour deux phrases.
|
||||
|
||||
Une subtilité qui a failli coûter cher : **`debutHistorique` a été séparée de `debutPeriodeCouverte`**. La borne basse de l'historique d'un accès gratuit est sa date d'ouverture, mais le quota de rejeux se compte contre la *même* fonction — lui donner une borne fixe aurait condamné tout inscrit gratuit à cinq mises à jour à vie, exactement le défaut que le repli en année glissante de D-060 avait été écrit pour éviter.
|
||||
|
||||
**Le prix a une seule source.** `EDITEUR.prixAnnuelTtc` (`lib/legal.ts`) le tient pour que CGV et encart ne divergent pas ; `MOTS.payant.prix` le recopiait, et un test tient désormais l'accord des deux.
|
||||
|
||||
## D-064 — 8 août 2026 · La page d'accueil cesse de publier ses sources, et deux sections cessent de se répéter
|
||||
|
||||
**Contexte** : six retouches demandées par le porteur de projet sur la page d'accueil — vocabulaire, masquage des sources, une phrase à retirer, une nuance à ajouter, et « il y a des redondances entre *Ce qu'on va chercher pour toi* et *Ce que personne ne retient* ». Révise le § 5 de D-033, qui avait au contraire réaligné et enrichi la liste des sources.
|
||||
|
|
|
|||
20
drizzle/0025_daffy_xorn.sql
Normal file
20
drizzle/0025_daffy_xorn.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
ALTER TABLE "households" ADD COLUMN "acces_mode" text;--> statement-breakpoint
|
||||
ALTER TABLE "households" ADD COLUMN "acces_le" timestamp;--> statement-breakpoint
|
||||
ALTER TABLE "households" ADD COLUMN "attente_depuis" timestamp;--> statement-breakpoint
|
||||
-- D-065 — le mode d'accès devient explicite, et les accès déjà ouverts doivent le déclarer.
|
||||
--
|
||||
-- Sans cette reprise, tout foyer dont l'accès a été ouvert par Stripe porterait `acces_mode` nul,
|
||||
-- donc ne serait compté ni comme payant ni comme gratuit. Le plafond de places gratuites le
|
||||
-- laisserait tranquille (il ne compte que 'gratuit') mais la bascule inverse — repasser en payant —
|
||||
-- ne saurait plus distinguer un accès acheté d'un accès offert. On écrit la vérité qu'on connaît
|
||||
-- pendant qu'on la connaît encore : ces foyers-là ont un identifiant d'abonnement Stripe.
|
||||
--
|
||||
-- `acces_le` reprend le début de la période payée, à défaut la date de création du foyer. Ce n'est
|
||||
-- pas la date exacte du paiement pour les foyers antérieurs au 05/08/2026 (qui n'ont pas de
|
||||
-- `subscription_period_start`, cf. `debutPeriodeCouverte`), et c'est assumé : cette colonne sert à
|
||||
-- ordonner des admissions gratuites, pas à facturer.
|
||||
UPDATE "households"
|
||||
SET "acces_mode" = 'payant',
|
||||
"acces_le" = coalesce("subscription_period_start", "created_at")
|
||||
WHERE "stripe_subscription_id" IS NOT NULL
|
||||
OR "subscription_status" IN ('active', 'expired', 'canceled');
|
||||
3643
drizzle/meta/0025_snapshot.json
Normal file
3643
drizzle/meta/0025_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -176,6 +176,13 @@
|
|||
"when": 1786108683994,
|
||||
"tag": "0024_lucky_the_hood",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "7",
|
||||
"when": 1786211578381,
|
||||
"tag": "0025_daffy_xorn",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
71
src/app/api/activer/route.ts
Normal file
71
src/app/api/activer/route.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
import { z } from "zod";
|
||||
import { db, households as householdsTable } from "@/db";
|
||||
import { autoriser } from "@/lib/rate-limit";
|
||||
import { inscrireFoyer } from "@/lib/inscription";
|
||||
import { estGratuit } from "@/lib/mode-serveur";
|
||||
|
||||
/**
|
||||
* L'ouverture d'accès en mode gratuit (D-065) — le pendant exact de `/api/checkout`, sans Stripe.
|
||||
*
|
||||
* **Même chemin, même écran, même geste** : le visiteur voit son aperçu, descend jusqu'au mur,
|
||||
* et le franchit. Ce qui change est la contrepartie demandée — une adresse au lieu d'une carte
|
||||
* bancaire — et ce qui disparaît avec elle : le prix, la case de renonciation au droit de
|
||||
* rétractation (il n'y a pas de contrat à distance à titre onéreux, donc pas de rétractation à
|
||||
* laquelle renoncer), et le passage par un tiers.
|
||||
*
|
||||
* **L'adresse redevient obligatoire ici, et c'est assumé.** D-005 avait retiré le mur e-mail
|
||||
* parce qu'il se dressait AVANT toute contrepartie ; ce n'est pas le cas ici, où l'adresse est
|
||||
* demandée après que le foyer a vu son calendrier, et où elle EST le compte — sans elle il n'y a
|
||||
* ni destinataire de rappel, ni moyen de retrouver son foyer, ni place à tenir dans une file.
|
||||
* En mode payant, c'est Stripe qui la collecte au même endroit du parcours ; on ne demande donc
|
||||
* rien de plus, on le demande nous-mêmes.
|
||||
*/
|
||||
|
||||
const Body = z.object({
|
||||
householdId: z.string().uuid(),
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!estGratuit()) {
|
||||
// Jamais 404 : la route existe, c'est le mode qui ne l'ouvre pas. Le client sait alors qu'il
|
||||
// doit passer par le paiement plutôt que réessayer.
|
||||
return NextResponse.json({ error: "l'inscription gratuite est fermée sur ce site" }, { status: 409 });
|
||||
}
|
||||
|
||||
const entetes = await headers();
|
||||
/*
|
||||
Le limiteur (audit S1) : cette route crée un compte et fait partir un mail à une adresse
|
||||
fournie par l'appelant. Sans plafond, elle serait un relais d'envoi vers n'importe quelle
|
||||
boîte, depuis un domaine aligné SPF/DKIM/DMARC — le pire usage qu'on puisse faire de notre
|
||||
réputation d'expéditeur. Cinq par quart d'heure et par adresse IP : au-delà, ce n'est plus
|
||||
quelqu'un qui s'inscrit.
|
||||
*/
|
||||
const ip = entetes.get("x-forwarded-for")?.split(",")[0].trim() ?? entetes.get("x-real-ip") ?? "inconnue";
|
||||
if (!autoriser(`activer:${ip}`, 5, 15 * 60 * 1000)) {
|
||||
return NextResponse.json({ error: "trop de tentatives — réessaie dans un quart d'heure" }, { status: 429 });
|
||||
}
|
||||
|
||||
const parsed = Body.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "adresse électronique invalide" }, { status: 400 });
|
||||
}
|
||||
const { householdId, email } = parsed.data;
|
||||
|
||||
const [foyer] = await db()
|
||||
.select({ id: householdsTable.id })
|
||||
.from(householdsTable)
|
||||
.where(eq(householdsTable.id, householdId));
|
||||
if (!foyer) return NextResponse.json({ error: "foyer introuvable" }, { status: 404 });
|
||||
|
||||
try {
|
||||
const resultat = await inscrireFoyer({ householdId, email, entetes });
|
||||
return NextResponse.json(resultat);
|
||||
} catch (erreur) {
|
||||
console.error("[activer] inscription impossible", erreur);
|
||||
return NextResponse.json({ error: "un souci est survenu de notre côté" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { headers } from "next/headers";
|
|||
import { db, households as householdsTable } from "@/db";
|
||||
import { stripe } from "@/lib/stripe";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { estGratuit } from "@/lib/mode-serveur";
|
||||
|
||||
const Body = z.object({
|
||||
householdId: z.string().uuid(),
|
||||
|
|
@ -24,6 +25,17 @@ const Body = z.object({
|
|||
* quiz → paiement → full reveal instantané → rétractation (§ 10.7).
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
/*
|
||||
Le mode gratuit ferme le paiement au serveur, et pas seulement à l'écran (D-065).
|
||||
|
||||
Un mur qui ne propose plus de payer ne suffit pas : la route reste appelable, et une clé
|
||||
Stripe encore présente dans l'environnement — le cas normal d'une bascule qu'on veut pouvoir
|
||||
défaire — la ferait aboutir. On encaisserait alors 19,99 € pour un accès qui est offert.
|
||||
*/
|
||||
if (estGratuit()) {
|
||||
return NextResponse.json({ error: "le paiement est désactivé sur ce site" }, { status: 409 });
|
||||
}
|
||||
|
||||
const client = stripe();
|
||||
if (!client || !process.env.STRIPE_PRICE_ANNUAL) {
|
||||
return NextResponse.json({ error: "paiement non configuré" }, { status: 501 });
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { z } from "zod";
|
|||
import { db, memberships as membershipsTable } from "@/db";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getSubscription } from "@/lib/subscription";
|
||||
import { reserveAux } from "@/lib/mode";
|
||||
import { modeAcces } from "@/lib/mode-serveur";
|
||||
import { autoriser, ipDe } from "@/lib/rate-limit";
|
||||
import { dateApprisePossible, type Geste } from "@/engine/gestes";
|
||||
import { appliquerGeste, echeancesDuFoyer } from "@/engine/gestes-db";
|
||||
|
|
@ -59,7 +61,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
|
|||
*/
|
||||
const subscription = await getSubscription(id);
|
||||
if (!subscription.isActive) {
|
||||
return NextResponse.json({ error: "réservé aux foyers abonnés" }, { status: 403 });
|
||||
return NextResponse.json({ error: reserveAux(modeAcces()) }, { status: 403 });
|
||||
}
|
||||
|
||||
const parse = Corps.safeParse(await req.json().catch(() => null));
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { randomBytes } from "node:crypto";
|
|||
import { db, households as householdsTable, memberships as membershipsTable } from "@/db";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getSubscription } from "@/lib/subscription";
|
||||
import { reserveAux } from "@/lib/mode";
|
||||
import { modeAcces } from "@/lib/mode-serveur";
|
||||
|
||||
/**
|
||||
* Génère (à la demande, une seule fois) ou renvoie le jeton du flux .ics d'un foyer (audit S4).
|
||||
|
|
@ -25,7 +27,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
|
|||
|
||||
const subscription = await getSubscription(id);
|
||||
if (!subscription.isActive) {
|
||||
return NextResponse.json({ error: "réservé aux foyers abonnés" }, { status: 403 });
|
||||
return NextResponse.json({ error: reserveAux(modeAcces()) }, { status: 403 });
|
||||
}
|
||||
|
||||
const [household] = await database
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { headers } from "next/headers";
|
|||
import { db, households as householdsTable, memberships as membershipsTable } from "@/db";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getSubscription } from "@/lib/subscription";
|
||||
import { reserveAux } from "@/lib/mode";
|
||||
import { modeAcces } from "@/lib/mode-serveur";
|
||||
import { quotaRejeuFoyer } from "@/engine/quiz-rejeu";
|
||||
|
||||
/**
|
||||
|
|
@ -25,7 +27,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
|
|||
|
||||
const subscription = await getSubscription(id);
|
||||
if (!subscription.isActive) {
|
||||
return NextResponse.json({ error: "réservé aux foyers abonnés" }, { status: 403 });
|
||||
return NextResponse.json({ error: reserveAux(modeAcces()) }, { status: 403 });
|
||||
}
|
||||
|
||||
const [household] = await database
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import { loadTemplates } from "@/knowledge/loader";
|
|||
import type { Asset } from "@/knowledge/schema";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getSubscription } from "@/lib/subscription";
|
||||
import { reserveAux } from "@/lib/mode";
|
||||
import { modeAcces } from "@/lib/mode-serveur";
|
||||
import { autoriser, ipDe, PLAFONDS } from "@/lib/rate-limit";
|
||||
|
||||
/**
|
||||
|
|
@ -76,7 +78,7 @@ async function autoriserRejeu(householdId: string, req: NextRequest): Promise<{
|
|||
if (!membership) return { ok: false, status: 403, error: "pas membre de ce foyer" };
|
||||
|
||||
const subscription = await getSubscription(householdId);
|
||||
if (!subscription.isActive) return { ok: false, status: 403, error: "réservé aux foyers abonnés" };
|
||||
if (!subscription.isActive) return { ok: false, status: 403, error: reserveAux(modeAcces()) };
|
||||
|
||||
const quota = await quotaRejeuFoyer(householdId, subscription);
|
||||
if (!quota) return { ok: false, status: 404, error: "foyer introuvable" };
|
||||
|
|
|
|||
|
|
@ -18,6 +18,14 @@ import { auth } from "@/lib/auth";
|
|||
* **Membre, pas payeur.** Le § 6.3 pose des membres égaux, sans hiérarchie ; réserver la
|
||||
* facturation au `subscriptionPayerId` créerait exactement la relation admin/invité que le
|
||||
* cadrage refuse — et laisserait un foyer sans recours le jour où le payeur s'en va.
|
||||
*
|
||||
* ⚠️ **Cette route n'est PAS fermée par le mode gratuit** (D-065), contrairement à
|
||||
* `/api/checkout`. Un abonnement souscrit en ligne doit pouvoir se résilier en ligne, en trois
|
||||
* étapes au plus, depuis une fonctionnalité accessible en permanence (art. L215-1-1 du Code de
|
||||
* la consommation, 15 000 € d'amende administrative au manquement). Fermer la porte de sortie
|
||||
* parce qu'on a fermé la porte d'entrée enfermerait précisément les gens qui ont payé. Elle
|
||||
* répond 501 d'elle-même si la clé Stripe a été retirée de l'environnement — auquel cas il faut
|
||||
* avoir résilié les abonnements en cours AVANT, pas après.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const client = stripe();
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||
import { db, households as householdsTable, memberships as membershipsTable } from "@/db";
|
||||
import { stripe } from "@/lib/stripe";
|
||||
import { auth, metadataInterne } from "@/lib/auth";
|
||||
import { rattacherCompteAuFoyer } from "@/lib/compte-foyer";
|
||||
|
||||
/** Active/désactive l'abonnement du foyer — jamais de statut mis à jour ailleurs (§ 21). */
|
||||
export async function POST(req: NextRequest) {
|
||||
|
|
@ -41,6 +42,14 @@ export async function POST(req: NextRequest) {
|
|||
.update(householdsTable)
|
||||
.set({
|
||||
subscriptionStatus: "active",
|
||||
/*
|
||||
Comment cet accès a été ouvert (D-065). Écrit ici et nulle part ailleurs pour ce
|
||||
chemin : sans lui, le décompte des places gratuites ne saurait pas distinguer un
|
||||
accès acheté d'un accès offert, et un retour au payant ne saurait plus lesquels
|
||||
reconduire.
|
||||
*/
|
||||
accesMode: "payant",
|
||||
accesLe: new Date(),
|
||||
subscriptionPeriodEnd: periodEnd,
|
||||
subscriptionPeriodStart: periodStart,
|
||||
subscriptionCancelAtPeriodEnd: subscription.cancel_at_period_end === true,
|
||||
|
|
@ -97,29 +106,18 @@ export async function POST(req: NextRequest) {
|
|||
const base = process.env.NEXT_PUBLIC_SITE_URL ?? "https://fokan.fr";
|
||||
|
||||
/**
|
||||
* On passe par l'adaptateur interne de better-auth plutôt que par un `insert` maison :
|
||||
* c'est lui qui fabrique l'identifiant, joue les hooks et respecte le schéma. Le compte
|
||||
* est marqué vérifié parce que l'adresse vient de Stripe, qui vient d'y envoyer un reçu.
|
||||
* Le rattachement lui-même vit dans `lib/compte-foyer.ts` depuis D-065 : la porte
|
||||
* gratuite fait exactement la même chose, et deux copies auraient divergé au premier
|
||||
* correctif — sur un chemin dont l'enjeu est qu'un foyer payé ait un destinataire de
|
||||
* rappel (le défaut que D-049 avait corrigé).
|
||||
*
|
||||
* Contrepartie assumée : Stripe ne prouve pas la possession de la boîte. Quelqu'un qui
|
||||
* paierait 19,99 € en donnant l'adresse d'un tiers rattacherait un foyer au compte de ce
|
||||
* tiers — un ajout indésirable, jamais une prise de contrôle, et facturé à celui qui le
|
||||
* tente. En face, l'ancien comportement perdait un client payant sur un simple délai.
|
||||
* `emailVerified: true` est propre à CE chemin : l'adresse vient de Stripe, qui vient
|
||||
* d'y envoyer un reçu. Contrepartie assumée — Stripe ne prouve pas la possession de la
|
||||
* boîte, et quelqu'un qui paierait 19,99 € en donnant l'adresse d'un tiers rattacherait
|
||||
* un foyer au compte de ce tiers : un ajout indésirable, jamais une prise de contrôle,
|
||||
* et facturé à celui qui le tente.
|
||||
*/
|
||||
const ctx = await auth.$context;
|
||||
const compte =
|
||||
(await ctx.internalAdapter.findUserByEmail(email))?.user ??
|
||||
(await ctx.internalAdapter.createUser({ email, emailVerified: true, name: "" }));
|
||||
|
||||
if (compte) {
|
||||
const [dejaMembre] = await database
|
||||
.select({ id: membershipsTable.id })
|
||||
.from(membershipsTable)
|
||||
.where(and(eq(membershipsTable.householdId, householdId), eq(membershipsTable.userId, compte.id)));
|
||||
if (!dejaMembre) {
|
||||
await database.insert(membershipsTable).values({ householdId, userId: compte.id });
|
||||
}
|
||||
}
|
||||
await rattacherCompteAuFoyer(householdId, email, { emailVerified: true });
|
||||
|
||||
/**
|
||||
* Le lien magique part quand même : un tap reste un tap. L'URL durable du foyer voyage
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { db, households as householdsTable } from "@/db";
|
|||
import { buildHub } from "@/engine/hub";
|
||||
import { hubToIcs } from "@/engine/ics";
|
||||
import { getSubscription } from "@/lib/subscription";
|
||||
import { reserveAux } from "@/lib/mode";
|
||||
import { modeAcces } from "@/lib/mode-serveur";
|
||||
|
||||
/**
|
||||
* Le flux .ics vivant (§ 10.2, audit S4) — servi par JETON, jamais par l'UUID du foyer.
|
||||
|
|
@ -23,7 +25,16 @@ export async function GET(_req: Request, { params }: { params: Promise<{ token:
|
|||
|
||||
const subscription = await getSubscription(household.id);
|
||||
if (!subscription.isActive) {
|
||||
return new Response("Le flux .ics est réservé aux abonnés — active la veille pour le débloquer.", { status: 403 });
|
||||
/*
|
||||
Ce texte-là n'est pas un message d'API : il s'affiche dans l'agenda du foyer, à la place
|
||||
des échéances, potentiellement pendant des mois. Il doit donc dire juste dans les deux
|
||||
modes — « réservé aux abonnés » sur un produit gratuit ferait chercher un paiement qui
|
||||
n'existe pas (D-065).
|
||||
*/
|
||||
return new Response(
|
||||
`Le flux .ics est ${reserveAux(modeAcces())} — active la veille pour le débloquer.`,
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const ics = hubToIcs(await buildHub(household.id), household.id);
|
||||
|
|
|
|||
|
|
@ -3,19 +3,51 @@ import Link from "next/link";
|
|||
import { Container } from "@/components/ui/primitives";
|
||||
import { MentionsIncompletes, Valeur } from "@/components/legal/valeur";
|
||||
import { EDITEUR, MAJ } from "@/lib/legal";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Conditions générales de vente",
|
||||
description:
|
||||
"Objet du service, prix, durée de l'abonnement, droit de rétractation et résiliation de l'abonnement fokan.",
|
||||
alternates: { canonical: "/cgv" },
|
||||
};
|
||||
import { mots } from "@/lib/mode";
|
||||
import { modeAcces } from "@/lib/mode-serveur";
|
||||
|
||||
/**
|
||||
* Les CGV.
|
||||
* Les métadonnées suivent le mode (D-065).
|
||||
*
|
||||
* Deux points portent une conséquence directe sur le code, et sont donc écrits en collant à ce que
|
||||
* le produit fait réellement plutôt qu'à ce qu'on aimerait promettre :
|
||||
* `generateMetadata` et non un objet constant : le titre et la description sont ceux d'un contrat
|
||||
* de vente en payant, d'un contrat d'utilisation en gratuit. Un objet figé au build aurait
|
||||
* annoncé « prix, durée de l'abonnement, droit de rétractation » dans les résultats de recherche
|
||||
* d'un site où l'on ne vend rien.
|
||||
*/
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const m = mots(modeAcces());
|
||||
return {
|
||||
title: m.conditionsTitre,
|
||||
description:
|
||||
modeAcces() === "gratuit"
|
||||
? "Objet du service, conditions d'accès, responsabilité et fermeture de compte sur fokan."
|
||||
: "Objet du service, prix, durée de l'abonnement, droit de rétractation et résiliation de l'abonnement fokan.",
|
||||
alternates: { canonical: "/cgv" },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Les conditions générales — de vente (payant) ou d'utilisation (gratuit).
|
||||
*
|
||||
* **L'URL reste `/cgv` dans les deux modes**, et c'est un choix. Elle est liée depuis le pied de
|
||||
* page, depuis le mur, depuis d'anciens mails et probablement depuis des signets ; une bascule de
|
||||
* mode qui déplacerait un document contractuel casserait ces liens dans les deux sens, à chaque
|
||||
* aller-retour. Le titre, lui, dit la vérité — c'est le titre qui nomme un document, pas son
|
||||
* chemin.
|
||||
*
|
||||
* **Ce que le mode gratuit retire, et pourquoi chaque retrait est nécessaire plutôt que
|
||||
* cosmétique** : un article qui décrit une obligation qui n'existe pas est pire qu'un article
|
||||
* absent. Annoncer un prix qu'on ne facture pas, une reconduction qui n'a pas lieu, une
|
||||
* renonciation à un droit de rétractation qui n'est pas ouvert, ou un médiateur de la
|
||||
* consommation auquel on n'a pas adhéré — ce sont quatre mentions fausses, pas quatre survivances.
|
||||
*
|
||||
* **Ce que les deux modes gardent, et qui est l'essentiel du texte** : l'objet du service, le
|
||||
* refus de garantie de conformité réglementaire (« en cas d'écart, le texte officiel fait foi »),
|
||||
* la disponibilité, les données personnelles et le droit applicable. Ce sont les articles qui
|
||||
* décrivent ce que le produit fait ; ils ne dépendent pas de ce qu'il coûte.
|
||||
*
|
||||
* Deux points portent une conséquence directe sur le code EN MODE PAYANT, et sont donc écrits en
|
||||
* collant à ce que le produit fait réellement plutôt qu'à ce qu'on aimerait promettre :
|
||||
*
|
||||
* - **L'abonnement est un `mode: "subscription"` Stripe** (`src/app/api/checkout/route.ts`), donc
|
||||
* à reconduction automatique. Écrire « sans reconduction » aurait été faux.
|
||||
|
|
@ -33,40 +65,60 @@ export const metadata: Metadata = {
|
|||
* ⚠️ Le bouton ne suffit pas : **la résiliation doit être activée dans la configuration du
|
||||
* portail Stripe**. Un portail livré sans « Cancel subscription » rendrait cet article faux.
|
||||
*
|
||||
* ⚠️ Et il reste vrai en mode gratuit pour les abonnements déjà en cours : `/api/stripe/portal`
|
||||
* n'est volontairement pas fermé par la bascule (voir son commentaire de tête).
|
||||
*
|
||||
* Le texte reste à faire valider par un professionnel du droit avant tout encaissement réel
|
||||
* (§ 10.7, § 28) — ce fichier ne remplace pas cette validation.
|
||||
*/
|
||||
export const dynamic = "force-dynamic"; // cf. mentions-legales : l'identité vient du `.env`, absent au build
|
||||
|
||||
export default function Cgv() {
|
||||
export default function Conditions() {
|
||||
const mode = modeAcces();
|
||||
const m = mots(mode);
|
||||
const gratuit = mode === "gratuit";
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Container width="narrow" className="py-12 sm:py-16">
|
||||
<MentionsIncompletes />
|
||||
<article className="prose-fokan">
|
||||
<h1>Conditions générales de vente</h1>
|
||||
<h1>{m.conditionsTitre}</h1>
|
||||
<p>
|
||||
Les présentes conditions régissent la vente de l'abonnement « La Veille » proposé
|
||||
sur {EDITEUR.siteNom} par {EDITEUR.raisonSociale} (l'« éditeur »), dont
|
||||
l'identité complète figure dans les{" "}
|
||||
<Link href="/mentions-legales">mentions légales</Link>. Elles sont acceptées au moment du
|
||||
paiement, dans leur version en vigueur ce jour-là ; l'abonné en reçoit copie avec
|
||||
sa confirmation de commande.
|
||||
{gratuit ? (
|
||||
<>
|
||||
Les présentes conditions régissent l'utilisation du service « La Veille »
|
||||
proposé sur {EDITEUR.siteNom} par {EDITEUR.raisonSociale} (l'« éditeur »),
|
||||
dont l'identité complète figure dans les{" "}
|
||||
<Link href="/mentions-legales">mentions légales</Link>. Elles sont acceptées au
|
||||
moment de la création d'un accès, dans leur version en vigueur ce jour-là.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Les présentes conditions régissent la vente de l'abonnement « La Veille »
|
||||
proposé sur {EDITEUR.siteNom} par {EDITEUR.raisonSociale} (l'« éditeur »),
|
||||
dont l'identité complète figure dans les{" "}
|
||||
<Link href="/mentions-legales">mentions légales</Link>. Elles sont acceptées au
|
||||
moment du paiement, dans leur version en vigueur ce jour-là ; l'abonné en
|
||||
reçoit copie avec sa confirmation de commande.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
L'abonnement s'adresse à des <strong>consommateurs</strong>, au sens du Code
|
||||
de la consommation : il est souscrit pour les besoins d'un foyer, en dehors de
|
||||
toute activité professionnelle.
|
||||
Le service s'adresse à des <strong>particuliers</strong> : il est utilisé pour les
|
||||
besoins d'un foyer, en dehors de toute activité professionnelle.
|
||||
</p>
|
||||
|
||||
<h2>1. Objet du service</h2>
|
||||
<p>
|
||||
L'abonnement donne accès, pour un foyer, au calendrier complet des échéances
|
||||
calculées à partir des déclarations de l'utilisateur et de données publiques : dates
|
||||
et fenêtres, raison de chaque échéance et premier pas de l'action, carte des
|
||||
périmètres réglementaires entourant l'adresse, détail des rappels constructeur des
|
||||
véhicules, catalogue complet des guides, agenda synchronisable, foyer partagé, et
|
||||
rappels par courrier électronique si l'utilisateur les active.
|
||||
{gratuit ? "La création d'un accès ouvre" : "L'abonnement donne accès"}, pour un
|
||||
foyer, au calendrier complet des échéances calculées à partir des déclarations de
|
||||
l'utilisateur et de données publiques : dates et fenêtres, raison de chaque
|
||||
échéance et premier pas de
|
||||
l'action, carte des périmètres réglementaires entourant l'adresse, détail des
|
||||
rappels constructeur des véhicules, catalogue complet des guides, agenda
|
||||
synchronisable, foyer partagé, et rappels par courrier électronique si
|
||||
l'utilisateur les active.
|
||||
</p>
|
||||
<p>
|
||||
Le service est une aide à l'anticipation, <strong>pas une garantie de
|
||||
|
|
@ -78,127 +130,223 @@ export default function Cgv() {
|
|||
préjudice résultant d'une décision prise sur la seule base du service.
|
||||
</p>
|
||||
|
||||
<h2>2. Prix</h2>
|
||||
<p>
|
||||
L'abonnement est de{" "}
|
||||
<strong>
|
||||
{EDITEUR.prixAnnuelTtc} par an
|
||||
{EDITEUR.regimeTva === "assujetti" ? ", toutes taxes comprises," : ""} pour
|
||||
l'ensemble du foyer
|
||||
</strong>
|
||||
. Aucun frais supplémentaire n'est facturé ; aucune option payante n'est
|
||||
proposée à ce jour. Un changement de prix ne s'applique jamais à une période déjà
|
||||
payée et est annoncé avant la reconduction.
|
||||
</p>
|
||||
{EDITEUR.regimeTva === "franchise" && (
|
||||
<p>TVA non applicable, article 293 B du Code général des impôts.</p>
|
||||
{gratuit ? (
|
||||
<>
|
||||
{/*
|
||||
L'article qui remplace « Prix » et « Paiement ». Il n'est pas décoratif : un
|
||||
service gratuit qui ne dit pas comment il se finance laisse le lecteur conclure
|
||||
tout seul, et il conclut « c'est moi le produit ». On répond par des engagements
|
||||
vérifiables — les mêmes que ceux que la politique de confidentialité tient.
|
||||
*/}
|
||||
<h2>2. Gratuité</h2>
|
||||
<p>
|
||||
L'accès au service est <strong>gratuit</strong>. Aucun paiement n'est
|
||||
demandé, à aucun moment, et aucun moyen de paiement n'est collecté. Il
|
||||
n'existe ni option payante, ni palier supérieur, ni période d'essai à
|
||||
l'issue de laquelle quelque chose serait facturé.
|
||||
</p>
|
||||
<p>
|
||||
Cette gratuité ne s'achète pas autrement : le service ne diffuse{" "}
|
||||
<strong>aucune publicité</strong>, ne vend, ne loue et ne cède{" "}
|
||||
<strong>aucune donnée</strong> à des tiers, et ne dépose aucun traceur publicitaire
|
||||
ni de mesure d'audience. Ces engagements sont détaillés dans la{" "}
|
||||
<Link href="/confidentialite">politique de confidentialité</Link>.
|
||||
</p>
|
||||
<p>
|
||||
L'éditeur se réserve la possibilité de rendre tout ou partie du service payant
|
||||
à l'avenir. Un tel changement ne s'appliquerait{" "}
|
||||
<strong>jamais rétroactivement</strong> et serait annoncé avant sa prise
|
||||
d'effet ; il n'autorise en aucun cas un prélèvement sans un nouvel accord
|
||||
exprès de l'utilisateur.
|
||||
</p>
|
||||
|
||||
{/*
|
||||
Le pendant du plafond de places (D-065). Il doit figurer ici parce qu'il décrit une
|
||||
limitation d'accès que l'utilisateur subit : la taire dans un document contractuel
|
||||
serait exactement le genre de silence qu'on reproche aux autres.
|
||||
*/}
|
||||
<h2>3. Nombre d'accès et liste d'attente</h2>
|
||||
<p>
|
||||
Le nombre d'accès ouverts simultanément peut être plafonné. Lorsque le plafond
|
||||
est atteint, la création d'un accès place le foyer sur une{" "}
|
||||
<strong>liste d'attente</strong> : le compte est créé et le calendrier du
|
||||
foyer est calculé, mais son détail reste masqué jusqu'à l'ouverture de
|
||||
l'accès. Les accès sont ouverts <strong>dans l'ordre
|
||||
d'arrivée</strong>, et l'utilisateur est prévenu par courrier
|
||||
électronique le jour où le sien s'ouvre.
|
||||
</p>
|
||||
<p>
|
||||
Aucun délai n'est garanti, et aucune date n'est promise. L'attente
|
||||
n'ouvre droit à aucune contrepartie, le service étant gratuit.
|
||||
</p>
|
||||
|
||||
<h2>4. Compte et sécurité</h2>
|
||||
<p>
|
||||
L'accès est rattaché à une adresse électronique. La connexion se fait par un
|
||||
lien à usage unique envoyé à cette adresse, ou par un mot de passe si
|
||||
l'utilisateur en définit un. Il lui appartient de veiller à la confidentialité
|
||||
de sa boîte de réception : quiconque y a accès peut ouvrir son foyer.
|
||||
</p>
|
||||
<p>
|
||||
Un foyer peut compter plusieurs membres, invités par l'un d'eux. Les
|
||||
membres d'un foyer y ont des droits identiques.
|
||||
</p>
|
||||
|
||||
<h2>5. Fermeture</h2>
|
||||
<p>
|
||||
L'utilisateur peut demander à tout moment la suppression de son compte et de
|
||||
son foyer en écrivant à <Valeur v={EDITEUR.email} />. La suppression est réelle et
|
||||
définitive : il n'existe pas de corbeille.
|
||||
</p>
|
||||
<p>
|
||||
L'éditeur peut fermer un accès en cas d'usage manifestement abusif du
|
||||
service (extraction automatisée, revente d'accès, création massive de comptes),
|
||||
après information de l'utilisateur. Le service étant gratuit, cette fermeture
|
||||
n'ouvre droit à aucune indemnité.
|
||||
</p>
|
||||
<p>
|
||||
L'éditeur peut également interrompre le service, en tout ou partie. Une
|
||||
interruption définitive serait annoncée avec un préavis raisonnable, permettant à
|
||||
chacun de récupérer son calendrier.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2>2. Prix</h2>
|
||||
<p>
|
||||
L'abonnement est de{" "}
|
||||
<strong>
|
||||
{EDITEUR.prixAnnuelTtc} par an
|
||||
{EDITEUR.regimeTva === "assujetti" ? ", toutes taxes comprises," : ""} pour
|
||||
l'ensemble du foyer
|
||||
</strong>
|
||||
. Aucun frais supplémentaire n'est facturé ; aucune option payante n'est
|
||||
proposée à ce jour. Un changement de prix ne s'applique jamais à une période déjà
|
||||
payée et est annoncé avant la reconduction.
|
||||
</p>
|
||||
{EDITEUR.regimeTva === "franchise" && (
|
||||
<p>TVA non applicable, article 293 B du Code général des impôts.</p>
|
||||
)}
|
||||
|
||||
<h2>3. Paiement</h2>
|
||||
<p>
|
||||
Le paiement est traité par Stripe Payments Europe. L'éditeur ne reçoit, ne voit et
|
||||
ne conserve aucune donnée de carte bancaire. Stripe demande l'adresse électronique
|
||||
de l'acheteur, qui sert également à créer son accès.
|
||||
</p>
|
||||
|
||||
<h2>4. Durée et reconduction</h2>
|
||||
<p>
|
||||
L'abonnement est souscrit pour <strong>douze mois</strong> et se reconduit ensuite
|
||||
automatiquement par périodes de douze mois. Conformément à l'article L215-1 du Code
|
||||
de la consommation, l'éditeur informe l'abonné, au plus tôt trois mois et au
|
||||
plus tard un mois avant l'échéance, de sa faculté de ne pas reconduire. Ce message
|
||||
sert aussi de récapitulatif de l'année écoulée.
|
||||
</p>
|
||||
|
||||
<h2>5. Droit de rétractation, et sa renonciation</h2>
|
||||
<p>
|
||||
L'abonné dispose en principe d'un délai de quatorze jours pour se rétracter
|
||||
(articles L221-18 et suivants du Code de la consommation).
|
||||
</p>
|
||||
<p>
|
||||
Le service étant délivré intégralement et immédiatement après le paiement — le calendrier
|
||||
complet s'affiche à la seconde même — sa souscription est subordonnée à la
|
||||
renonciation expresse de l'abonné à ce droit, recueillie par une case à cocher
|
||||
obligatoire au moment du paiement, dans les termes suivants :{" "}
|
||||
<em>« Je demande l'exécution immédiate du service et renonce à mon droit de
|
||||
rétractation de 14 jours. »</em> Cette renonciation est horodatée et conservée
|
||||
(article L221-28, 1° du même code).
|
||||
</p>
|
||||
|
||||
<h2>6. Résiliation</h2>
|
||||
<p>
|
||||
L'abonné peut mettre fin à la reconduction <strong>à tout moment, sans
|
||||
justification et sans frais</strong>, depuis le bouton « Gérer l'abonnement » de{" "}
|
||||
<Link href="/mon-espace">son espace</Link>, accessible en permanence dès qu'il est
|
||||
connecté (article L215-1-1 du Code de la consommation). Trois étapes suffisent :
|
||||
information, identification, confirmation. La résiliation peut également être demandée
|
||||
par écrit à <Valeur v={EDITEUR.email} />.
|
||||
</p>
|
||||
<p>
|
||||
L'éditeur en accuse réception et indique la date à laquelle le contrat prend fin.
|
||||
La résiliation prend effet à la fin de la période en cours : l'accès reste ouvert
|
||||
jusque-là, et aucune somme n'est due ensuite. Les sommes correspondant à une
|
||||
période entamée ne sont pas remboursées, le service ayant été délivré.
|
||||
</p>
|
||||
<p>
|
||||
L'éditeur peut résilier un abonnement en cas d'usage manifestement abusif du
|
||||
service (extraction automatisée, revente d'accès), après information de
|
||||
l'abonné et remboursement au prorata de la période restante.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h2>3. Paiement</h2>
|
||||
<p>
|
||||
Le paiement est traité par Stripe Payments Europe. L'éditeur ne reçoit, ne voit et
|
||||
ne conserve aucune donnée de carte bancaire. Stripe demande l'adresse électronique
|
||||
de l'acheteur, qui sert également à créer son accès.
|
||||
</p>
|
||||
|
||||
<h2>4. Durée et reconduction</h2>
|
||||
<p>
|
||||
L'abonnement est souscrit pour <strong>douze mois</strong> et se reconduit ensuite
|
||||
automatiquement par périodes de douze mois. Conformément à l'article L215-1 du Code
|
||||
de la consommation, l'éditeur informe l'abonné, au plus tôt trois mois et au
|
||||
plus tard un mois avant l'échéance, de sa faculté de ne pas reconduire. Ce message
|
||||
sert aussi de récapitulatif de l'année écoulée.
|
||||
</p>
|
||||
|
||||
<h2>5. Droit de rétractation, et sa renonciation</h2>
|
||||
<p>
|
||||
L'abonné dispose en principe d'un délai de quatorze jours pour se rétracter
|
||||
(articles L221-18 et suivants du Code de la consommation).
|
||||
</p>
|
||||
<p>
|
||||
Le service étant délivré intégralement et immédiatement après le paiement — le calendrier
|
||||
complet s'affiche à la seconde même — sa souscription est subordonnée à la
|
||||
renonciation expresse de l'abonné à ce droit, recueillie par une case à cocher
|
||||
obligatoire au moment du paiement, dans les termes suivants :{" "}
|
||||
<em>« Je demande l'exécution immédiate du service et renonce à mon droit de
|
||||
rétractation de 14 jours. »</em> Cette renonciation est horodatée et conservée
|
||||
(article L221-28, 1° du même code).
|
||||
</p>
|
||||
|
||||
<h2>6. Résiliation</h2>
|
||||
<p>
|
||||
L'abonné peut mettre fin à la reconduction <strong>à tout moment, sans
|
||||
justification et sans frais</strong>, depuis le bouton « Gérer l'abonnement » de{" "}
|
||||
<Link href="/mon-espace">son espace</Link>, accessible en permanence dès qu'il est
|
||||
connecté (article L215-1-1 du Code de la consommation). Trois étapes suffisent :
|
||||
information, identification, confirmation. La résiliation peut également être demandée
|
||||
par écrit à <Valeur v={EDITEUR.email} />.
|
||||
</p>
|
||||
<p>
|
||||
L'éditeur en accuse réception et indique la date à laquelle le contrat prend fin.
|
||||
La résiliation prend effet à la fin de la période en cours : l'accès reste ouvert
|
||||
jusque-là, et aucune somme n'est due ensuite. Les sommes correspondant à une
|
||||
période entamée ne sont pas remboursées, le service ayant été délivré.
|
||||
</p>
|
||||
<p>
|
||||
L'éditeur peut résilier un abonnement en cas d'usage manifestement abusif du
|
||||
service (extraction automatisée, revente d'accès), après information de
|
||||
l'abonné et remboursement au prorata de la période restante.
|
||||
</p>
|
||||
|
||||
<h2>7. Disponibilité</h2>
|
||||
<h2>{gratuit ? "6" : "7"}. Disponibilité</h2>
|
||||
<p>
|
||||
Le service est fourni sans garantie de disponibilité continue. Certaines données
|
||||
dépendent de sources publiques tierces : l'indisponibilité de l'une
|
||||
d'elles peut retarder l'apparition d'une information, sans affecter le
|
||||
reste du calendrier. Une interruption durable et non résolue ouvre droit à un
|
||||
remboursement au prorata sur demande.
|
||||
reste du calendrier.
|
||||
{gratuit
|
||||
? " Le service étant gratuit, une interruption n'ouvre droit à aucune indemnité."
|
||||
: " Une interruption durable et non résolue ouvre droit à un remboursement au prorata sur demande."}
|
||||
</p>
|
||||
|
||||
{/*
|
||||
L'article manquait entièrement, alors que le service est un « service numérique » au
|
||||
sens des art. L224-25-1 et suivants du Code de la consommation, applicables depuis le
|
||||
1er janvier 2022. Ces garanties sont d'ordre public : ne pas les mentionner ne les
|
||||
écarte pas, cela expose seulement à une amende — et prive l'abonné de l'information à
|
||||
laquelle il a droit.
|
||||
L'article de garantie légale ne vise QUE le service fourni contre paiement. Les art.
|
||||
L224-25-1 et suivants du Code de la consommation s'appliquent aux contrats de
|
||||
fourniture de contenus et services numériques conclus « contre un prix » ou contre des
|
||||
données personnelles fournies en contrepartie. Le second cas ne joue pas ici : les
|
||||
données du foyer servent à rendre le service demandé, elles ne sont ni monétisées ni
|
||||
cédées — ce que l'article 2 vient d'engager en toutes lettres.
|
||||
*/}
|
||||
<h2>8. Garantie légale de conformité</h2>
|
||||
<p>
|
||||
L'abonnement est un service numérique fourni de manière continue. À ce titre,
|
||||
l'éditeur en garantit la conformité pendant toute la durée du contrat (articles
|
||||
L224-25-12 et suivants du Code de la consommation), et répond des défauts de conformité
|
||||
existants au moment de la fourniture. L'abonné qui constate un défaut peut en
|
||||
demander la mise en conformité sans frais ; à défaut, il peut obtenir une réduction du
|
||||
prix ou la résolution du contrat.
|
||||
</p>
|
||||
<p>
|
||||
Cette garantie légale est distincte de la présente rédaction et s'applique quoi
|
||||
qu'il y soit écrit. Elle ne couvre pas l'exactitude des données publiques
|
||||
reprises par le service, dont l'article 1 rappelle qu'elles ne valent pas
|
||||
garantie de conformité réglementaire.
|
||||
</p>
|
||||
{!gratuit && (
|
||||
<>
|
||||
<h2>8. Garantie légale de conformité</h2>
|
||||
<p>
|
||||
L'abonnement est un service numérique fourni de manière continue. À ce titre,
|
||||
l'éditeur en garantit la conformité pendant toute la durée du contrat (articles
|
||||
L224-25-12 et suivants du Code de la consommation), et répond des défauts de conformité
|
||||
existants au moment de la fourniture. L'abonné qui constate un défaut peut en
|
||||
demander la mise en conformité sans frais ; à défaut, il peut obtenir une réduction du
|
||||
prix ou la résolution du contrat.
|
||||
</p>
|
||||
<p>
|
||||
Cette garantie légale est distincte de la présente rédaction et s'applique quoi
|
||||
qu'il y soit écrit. Elle ne couvre pas l'exactitude des données publiques
|
||||
reprises par le service, dont l'article 1 rappelle qu'elles ne valent pas
|
||||
garantie de conformité réglementaire.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h2>9. Données personnelles</h2>
|
||||
<h2>{gratuit ? "7" : "9"}. Données personnelles</h2>
|
||||
<p>
|
||||
Le traitement des données est décrit dans la{" "}
|
||||
<Link href="/confidentialite">politique de confidentialité</Link>. Aucun document n'
|
||||
est stocké, aucune donnée n'est revendue, aucune publicité n'est diffusée.
|
||||
</p>
|
||||
|
||||
<h2>10. Réclamations et médiation</h2>
|
||||
<h2>{gratuit ? "8" : "10"}. Réclamations{gratuit ? "" : " et médiation"}</h2>
|
||||
<p>
|
||||
Toute réclamation peut être adressée à <Valeur v={EDITEUR.email} />. Si elle n'a
|
||||
pas abouti dans un délai de deux mois, le consommateur peut recourir{" "}
|
||||
<strong>gratuitement</strong> au médiateur de la consommation dont relève
|
||||
l'éditeur :{" "}
|
||||
{EDITEUR.mediateur ? (
|
||||
<a href={EDITEUR.mediateur.url} target="_blank" rel="noopener noreferrer">
|
||||
{EDITEUR.mediateur.nom}
|
||||
</a>
|
||||
) : (
|
||||
<Valeur v={null} />
|
||||
Toute réclamation peut être adressée à <Valeur v={EDITEUR.email} />.
|
||||
{!gratuit && (
|
||||
<>
|
||||
{" "}
|
||||
Si elle n'a pas abouti dans un délai de deux mois, le consommateur peut recourir{" "}
|
||||
<strong>gratuitement</strong> au médiateur de la consommation dont relève
|
||||
l'éditeur :{" "}
|
||||
{EDITEUR.mediateur ? (
|
||||
<a href={EDITEUR.mediateur.url} target="_blank" rel="noopener noreferrer">
|
||||
{EDITEUR.mediateur.nom}
|
||||
</a>
|
||||
) : (
|
||||
<Valeur v={null} />
|
||||
)}
|
||||
.
|
||||
</>
|
||||
)}
|
||||
.
|
||||
</p>
|
||||
{/*
|
||||
La plateforme européenne de règlement en ligne des litiges (RLL/ODR) était citée ici.
|
||||
|
|
@ -206,14 +354,33 @@ export default function Cgv() {
|
|||
règlement 524/2013, les dépôts de plainte ont cessé le 20 mars 2025 et la plateforme a
|
||||
fermé le 20 juillet 2025. Renvoyer un consommateur en litige vers un service fermé,
|
||||
c'est lui faire perdre le délai pendant lequel il cherche la porte.
|
||||
|
||||
Le médiateur, lui, n'est retiré en mode gratuit que parce que l'art. L616-1 vise les
|
||||
litiges de consommation, c'est-à-dire nés d'un contrat à titre onéreux — et qu'une
|
||||
adhésion à un médiateur est payante. Nommer un médiateur auquel on n'a pas adhéré
|
||||
serait une mention fausse ; en réclamer une pour un service gratuit ferait payer la
|
||||
gratuité.
|
||||
*/}
|
||||
|
||||
<h2>11. Droit applicable</h2>
|
||||
<h2>{gratuit ? "9" : "11"}. Droit applicable</h2>
|
||||
<p>
|
||||
Les présentes conditions sont soumises au droit français. Les dispositions protectrices
|
||||
du Code de la consommation s'appliquent de plein droit et prévalent sur toute clause
|
||||
contraire. Aucune clause ne prive le consommateur du droit de saisir la juridiction de
|
||||
son lieu de résidence.
|
||||
Les présentes conditions sont soumises au droit français.
|
||||
{gratuit ? (
|
||||
<>
|
||||
{" "}
|
||||
Les dispositions protectrices du Code de la consommation s'appliquent de plein
|
||||
droit lorsqu'elles sont applicables, et prévalent sur toute clause contraire.
|
||||
Aucune clause ne prive l'utilisateur du droit de saisir la juridiction de son
|
||||
lieu de résidence.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{" "}
|
||||
Les dispositions protectrices du Code de la consommation s'appliquent de plein
|
||||
droit et prévalent sur toute clause contraire. Aucune clause ne prive le
|
||||
consommateur du droit de saisir la juridiction de son lieu de résidence.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-muted">Dernière mise à jour : {MAJ.cgv}.</p>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import Link from "next/link";
|
|||
import { Container } from "@/components/ui/primitives";
|
||||
import { MentionsIncompletes, Valeur } from "@/components/legal/valeur";
|
||||
import { EDITEUR, MAJ } from "@/lib/legal";
|
||||
import { modeAcces } from "@/lib/mode-serveur";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Politique de confidentialité",
|
||||
|
|
@ -40,6 +41,19 @@ export const metadata: Metadata = {
|
|||
export const dynamic = "force-dynamic"; // cf. mentions-legales : l'identité vient du `.env`, absent au build
|
||||
|
||||
export default function Confidentialite() {
|
||||
/*
|
||||
Le mode d'accès (D-065). Stripe est un sous-traitant, un destinataire, un transfert hors UE et
|
||||
une durée de conservation comptable : quatre affirmations qui deviennent fausses le jour où
|
||||
plus personne ne paie. Une politique de confidentialité qui déclare un sous-traitant qui ne
|
||||
traite rien n'est pas prudente, elle est inexacte — et l'art. 13 impose l'exactitude, pas
|
||||
l'exhaustivité prudente.
|
||||
|
||||
Le paiement n'est PAS effacé purement et simplement : les foyers qui ont payé avant la
|
||||
bascule existent, et leurs données de facturation aussi. Le texte les nomme au passé plutôt
|
||||
que de faire comme s'ils n'avaient jamais existé.
|
||||
*/
|
||||
const gratuit = modeAcces() === "gratuit";
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Container width="narrow" className="py-12 sm:py-16">
|
||||
|
|
@ -80,8 +94,8 @@ export default function Confidentialite() {
|
|||
n'est transmise à personne.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Ton adresse électronique</strong>, si tu crées un accès, si tu paies, ou si tu
|
||||
demandes à recevoir le lien de ton foyer.
|
||||
<strong>Ton adresse électronique</strong>, si tu crées un accès
|
||||
{gratuit ? "" : ", si tu paies"}, ou si tu demandes à recevoir le lien de ton foyer.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Les échéances calculées</strong> et leur suivi (fait, ignoré, reporté), ainsi
|
||||
|
|
@ -100,7 +114,7 @@ export default function Confidentialite() {
|
|||
lettre, trompeur sur le fond.
|
||||
*/}
|
||||
<li>
|
||||
<strong>Un compteur de lectures pour les guides gratuits</strong> : deux guides
|
||||
<strong>Un compteur de lectures pour les guides en accès libre</strong> : deux guides
|
||||
entiers sont lisibles sans compte. Pour les compter, le serveur calcule une empreinte
|
||||
irréversible de ton adresse IP — salée, tronquée, jamais l'adresse elle-même — et
|
||||
retient les pages déjà lues. Elle ne permet ni de te reconnaître ailleurs sur le site,
|
||||
|
|
@ -136,8 +150,10 @@ export default function Confidentialite() {
|
|||
évidemment rien.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Aucune donnée bancaire.</strong> Le paiement est traité par Stripe ; l'
|
||||
éditeur ne voit aucun numéro de carte.
|
||||
<strong>Aucune donnée bancaire.</strong>{" "}
|
||||
{gratuit
|
||||
? "Le service est gratuit : aucun moyen de paiement n'est demandé, à aucun moment. Les foyers qui ont payé avant le passage en gratuit l'ont fait chez Stripe, qui reste seul détenteur de ces données ; l'éditeur n'a jamais vu de numéro de carte."
|
||||
: "Le paiement est traité par Stripe ; l'éditeur ne voit aucun numéro de carte."}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Aucune donnée de santé</strong>, aucune donnée de géolocalisation en temps réel,
|
||||
|
|
@ -154,9 +170,10 @@ export default function Confidentialite() {
|
|||
<h2>Pourquoi, et sur quelle base</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Exécution du contrat</strong> : calculer le calendrier, tenir l'accès du
|
||||
foyer, facturer l'abonnement, envoyer les rappels — qui sont le service lui-même,
|
||||
réservés aux abonnés, et que tu peux couper échéance par échéance depuis ton foyer.
|
||||
<strong>Exécution du {gratuit ? "service" : "contrat"}</strong> : calculer le
|
||||
calendrier, tenir l'accès du foyer,{gratuit ? "" : " facturer l'abonnement,"}{" "}
|
||||
envoyer les rappels — qui sont le service lui-même, et que tu peux couper échéance
|
||||
par échéance depuis ton foyer.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Consentement</strong> : l'envoi du lien de ton foyer, quand tu le
|
||||
|
|
@ -175,12 +192,24 @@ export default function Confidentialite() {
|
|||
</ul>
|
||||
|
||||
<h2>Qui d'autre y a accès</h2>
|
||||
<p>
|
||||
Trois sous-traitants, et aucun autre : <strong>Stripe</strong> (paiement),{" "}
|
||||
<strong>le fournisseur d'envoi de courrier électronique</strong> utilisé pour les
|
||||
liens de connexion et les rappels, et l'hébergement décrit dans les mentions légales.
|
||||
Aucune donnée n'est vendue, louée, ni transmise à des fins publicitaires.
|
||||
</p>
|
||||
{gratuit ? (
|
||||
<p>
|
||||
Deux sous-traitants, et aucun autre :{" "}
|
||||
<strong>le fournisseur d'envoi de courrier électronique</strong> utilisé pour
|
||||
les liens de connexion et les rappels, et l'hébergement décrit dans les mentions
|
||||
légales. Aucune donnée n'est vendue, louée, ni transmise à des fins
|
||||
publicitaires. Le service étant gratuit, aucun prestataire de paiement
|
||||
n'intervient — <strong>Stripe</strong> ne conserve que la facturation des
|
||||
abonnements souscrits avant le passage en accès gratuit.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
Trois sous-traitants, et aucun autre : <strong>Stripe</strong> (paiement),{" "}
|
||||
<strong>le fournisseur d'envoi de courrier électronique</strong> utilisé pour les
|
||||
liens de connexion et les rappels, et l'hébergement décrit dans les mentions légales.
|
||||
Aucune donnée n'est vendue, louée, ni transmise à des fins publicitaires.
|
||||
</p>
|
||||
)}
|
||||
{/*
|
||||
Le service de lecture n'est pas un quatrième sous-traitant : il tourne sur la même
|
||||
machine que le site, sans accès réseau sortant et sans contrat. Le dire explicitement
|
||||
|
|
@ -209,28 +238,39 @@ export default function Confidentialite() {
|
|||
reste en Europe » aurait été faux ; taire le point l'aurait été aussi (art. 13.1.f).
|
||||
*/}
|
||||
<h2>Hors de l'Union européenne</h2>
|
||||
<p>
|
||||
Le foyer, ses échéances et son calendrier ne quittent jamais la France. Deux
|
||||
sous-traitants peuvent en revanche traiter certaines données hors de
|
||||
l'Union — <strong>Stripe</strong>, dont la maison mère est américaine, et le
|
||||
fournisseur d'envoi de courrier électronique. Ces transferts sont encadrés par les
|
||||
clauses contractuelles types de la Commission européenne, et ne portent que sur
|
||||
l'adresse électronique et les données de facturation, jamais sur le contenu de ton
|
||||
foyer.
|
||||
</p>
|
||||
{gratuit ? (
|
||||
<p>
|
||||
Le foyer, ses échéances et son calendrier ne quittent jamais la France. Un seul
|
||||
sous-traitant peut traiter certaines données hors de l'Union : le fournisseur
|
||||
d'envoi de courrier électronique. Ce transfert est encadré par les clauses
|
||||
contractuelles types de la Commission européenne, et ne porte que sur
|
||||
l'adresse électronique, jamais sur le contenu de ton foyer.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
Le foyer, ses échéances et son calendrier ne quittent jamais la France. Deux
|
||||
sous-traitants peuvent en revanche traiter certaines données hors de
|
||||
l'Union — <strong>Stripe</strong>, dont la maison mère est américaine, et le
|
||||
fournisseur d'envoi de courrier électronique. Ces transferts sont encadrés par les
|
||||
clauses contractuelles types de la Commission européenne, et ne portent que sur
|
||||
l'adresse électronique et les données de facturation, jamais sur le contenu de ton
|
||||
foyer.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<h2>Combien de temps</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Foyer et échéances</strong> : tant que le foyer existe. Un foyer sans abonnement
|
||||
ni accès créé est supprimé après douze mois d'inactivité.
|
||||
<strong>Foyer et échéances</strong> : tant que le foyer existe. Un foyer dont
|
||||
l'accès n'a jamais été ouvert est supprimé après douze mois
|
||||
d'inactivité.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Compte</strong> : jusqu'à sa suppression, demandée à tout moment.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Facturation</strong> : durée légale de conservation comptable, chez Stripe et
|
||||
chez l'éditeur.
|
||||
chez l'éditeur.{gratuit ? " Ne concerne que les abonnements antérieurs au passage en accès gratuit." : ""}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Journal des rappels envoyés</strong> : deux ans, pour ne pas répéter un envoi.
|
||||
|
|
|
|||
|
|
@ -1,29 +1,42 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { BellOff, Check, FileX2, Landmark, Lock, ShieldCheck } from "lucide-react";
|
||||
import { BellOff, Check, FileX2, Landmark, Lock, MailCheck, ShieldCheck, Users } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ErrorNote } from "@/components/ui/form";
|
||||
import { mots, type ModeAcces, type PlacesGratuites } from "@/lib/mode";
|
||||
|
||||
/**
|
||||
* Le mur de paiement (§ 10.1, § 10.7).
|
||||
* Le mur — payant (§ 10.1, § 10.7) ou gratuit (D-065).
|
||||
*
|
||||
* Deux partis pris de conversion :
|
||||
* 1. **Aucun champ e-mail.** Stripe Checkout le collecte lui-même — un champ de moins
|
||||
* entre l'intention et le paiement, et plus aucune saisie demandée avant contrepartie.
|
||||
* 2. **Le prix est confronté à ce qu'il achète**, pas posé nu : le visiteur vient de voir
|
||||
* son calendrier, l'encart dit ce qui se débloque dessus.
|
||||
* **Un seul composant, deux colonnes de droite.** La colonne de gauche — ce qui se débloque,
|
||||
* chiffré sur CE foyer — est rigoureusement la même dans les deux modes, et c'est le point : ce
|
||||
* qu'on donne ne dépend pas de ce qu'on demande. Seule la contrepartie change, et elle vit dans
|
||||
* la colonne de droite, isolée du discours.
|
||||
*
|
||||
* La case de renonciation reste un blocage légal non négociable : sans elle, le parcours
|
||||
* quiz → paiement → full reveal instantané → rétractation resterait une faille ouverte.
|
||||
* Texte repris verbatim du cadrage — à faire valider par un professionnel du droit avant
|
||||
* tout encaissement réel (§ 10.7, § 28).
|
||||
* Deux partis pris de conversion, conservés d'un mode à l'autre :
|
||||
* 1. **Un seul champ, au plus.** En payant, aucun : Stripe Checkout collecte l'adresse. En
|
||||
* gratuit, l'adresse — parce qu'elle EST le compte, et qu'il n'y a personne d'autre pour la
|
||||
* demander.
|
||||
* 2. **La contrepartie est confrontée à ce qu'elle achète**, pas posée nue : le visiteur vient de
|
||||
* voir son calendrier, l'encart dit ce qui se débloque dessus.
|
||||
*
|
||||
* **Ce que le mode gratuit fait disparaître, et pourquoi** : le prix (il n'y en a pas), la case
|
||||
* de renonciation au droit de rétractation (il n'y a pas de contrat à distance à titre onéreux,
|
||||
* donc rien à quoi renoncer — la maintenir serait une clause sans objet dans un contrat qui n'en
|
||||
* est pas un), et le renvoi aux CGV, qui devient un renvoi aux CGU.
|
||||
*
|
||||
* **Ce que le mode gratuit ajoute** : le plafond de places, quand il y en a un, et la liste
|
||||
* d'attente qu'il ouvre. On l'annonce AVANT le geste, jamais après — découvrir qu'on est 47e
|
||||
* après avoir donné son adresse serait un piège, et c'est exactement ce que la règle d'or refuse.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ce que l'abonnement débloque. La liste suit le § 10.2 ligne par ligne — et trois postes qui y
|
||||
* Ce que la contrepartie débloque. La liste suit le § 10.2 ligne par ligne — et trois postes qui y
|
||||
* figuraient manquaient ici : l'agenda vivant, la délégation au conjoint, et le nombre de guides.
|
||||
* Un poste vendu par le cadrage mais absent de cet encart est un poste qu'on ne vend pas.
|
||||
*
|
||||
* Elle ne dépend pas du mode : ce qui s'ouvre est identique, seule la porte change.
|
||||
*/
|
||||
function inclus(nbGuides: number): string[] {
|
||||
return [
|
||||
|
|
@ -46,10 +59,10 @@ function inclus(nbGuides: number): string[] {
|
|||
}
|
||||
|
||||
/**
|
||||
* Les garanties, au moment du paiement plutôt que sur la landing.
|
||||
* Les garanties, au moment du geste plutôt que sur la landing.
|
||||
*
|
||||
* Elles y sont aussi, mais c'est ici qu'elles servent : l'écran où l'on sort sa carte bancaire est
|
||||
* celui où « où sont mes données » se pose vraiment.
|
||||
* Elles y sont aussi, mais c'est ici qu'elles servent : l'écran où l'on sort sa carte bancaire —
|
||||
* ou son adresse — est celui où « où sont mes données » se pose vraiment.
|
||||
*/
|
||||
const GARANTIES = [
|
||||
{ Icon: Landmark, texte: "Hébergé en France" },
|
||||
|
|
@ -57,50 +70,25 @@ const GARANTIES = [
|
|||
{ Icon: BellOff, texte: "Aucune publicité" },
|
||||
];
|
||||
|
||||
export function Activer({
|
||||
householdId,
|
||||
total,
|
||||
masquees,
|
||||
legalesMasquees,
|
||||
nbGuides,
|
||||
}: {
|
||||
type Props = {
|
||||
householdId: string;
|
||||
total: number;
|
||||
masquees: number;
|
||||
legalesMasquees: number;
|
||||
nbGuides: number;
|
||||
}) {
|
||||
const [waived, setWaived] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/**
|
||||
* Le mode arrive en `prop`, jamais par import : `lib/mode-serveur.ts` lit `process.env`, que
|
||||
* Next n'injecte pas dans le bundle navigateur. Un import ici rendrait « payant » côté client
|
||||
* et « gratuit » côté serveur, en silence, à l'hydratation.
|
||||
*/
|
||||
mode: ModeAcces;
|
||||
/** L'état des places. `null` en mode payant, où la question ne se pose pas. */
|
||||
places: PlacesGratuites | null;
|
||||
};
|
||||
|
||||
function track(name: string) {
|
||||
fetch("/api/event", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, householdId }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function payer() {
|
||||
setSending(true);
|
||||
setError(null);
|
||||
track("cta_activate");
|
||||
const res = await fetch("/api/checkout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ householdId, retractationWaived: waived }),
|
||||
}).catch(() => null);
|
||||
|
||||
if (!res?.ok) {
|
||||
setSending(false);
|
||||
if (res?.status === 501) setError("Le paiement n'est pas encore activé sur ce site.");
|
||||
else setError("Un souci est survenu de notre côté — réessaie dans un instant.");
|
||||
return;
|
||||
}
|
||||
const { url } = await res.json();
|
||||
window.location.href = url;
|
||||
}
|
||||
export function Activer(props: Props) {
|
||||
const { total, masquees, legalesMasquees, nbGuides, mode } = props;
|
||||
const m = mots(mode);
|
||||
|
||||
return (
|
||||
<section
|
||||
|
|
@ -108,7 +96,7 @@ export function Activer({
|
|||
className="surface-card scroll-mt-24 overflow-hidden border-accent-line p-0 shadow-lift"
|
||||
>
|
||||
<div className="grid md:grid-cols-[1.1fr_0.9fr]">
|
||||
{/* Ce qui se débloque, sur le calendrier qu'il vient de voir. */}
|
||||
{/* Ce qui se débloque, sur le calendrier qu'il vient de voir. Identique dans les deux modes. */}
|
||||
<div className="p-6 sm:p-8">
|
||||
<h2 className="text-section">
|
||||
Les {total} échéances de ton foyer, surveillées toute l'année
|
||||
|
|
@ -138,7 +126,12 @@ export function Activer({
|
|||
. Tu en connais le nombre — pas lesquelles, ni quand elles tombent.
|
||||
</>
|
||||
) : (
|
||||
<>Tout est compté ; le détail — quoi, quand, pourquoi — fait partie de l'abonnement.</>
|
||||
<>
|
||||
Tout est compté ; le détail — quoi, quand, pourquoi — {mode === "gratuit"
|
||||
? "s'ouvre avec ton compte"
|
||||
: "fait partie de l'abonnement"}
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<ul className="mt-6 space-y-2.5">
|
||||
|
|
@ -151,91 +144,329 @@ export function Activer({
|
|||
</ul>
|
||||
|
||||
{/*
|
||||
L'argumentaire du prix vit ici, et nulle part ailleurs : il a quitté la landing, où il
|
||||
demandait à un visiteur de justifier un tarif avant d'avoir rien reçu. À cet endroit-ci,
|
||||
il répond à une question qu'il vient de se poser lui-même.
|
||||
L'argumentaire vit ici, et nulle part ailleurs : il a quitté la landing, où il
|
||||
demandait à un visiteur de justifier quelque chose avant d'avoir rien reçu. À cet
|
||||
endroit-ci, il répond à une question qu'il vient de se poser lui-même — « pourquoi
|
||||
est-ce que ça coûte ça ? » en payant, « où est le piège ? » en gratuit.
|
||||
*/}
|
||||
<div className="mt-7 border-t border-line pt-5">
|
||||
<p className="text-[0.925rem] leading-relaxed text-muted">
|
||||
Un service gratuit se paie toujours — en publicité, ou en revente de données.{" "}
|
||||
<strong className="font-semibold text-ink">Ici, tu es le client, pas le produit.</strong>
|
||||
</p>
|
||||
<p className="text-[0.925rem] leading-relaxed text-muted">{m.reassurance}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Le geste de paiement, isolé du discours. */}
|
||||
<div className="flex flex-col justify-center border-t border-line bg-surface-alt p-6 sm:p-8 md:border-t-0 md:border-l">
|
||||
<p className="flex items-baseline gap-1.5">
|
||||
<span className="tnum font-display text-5xl font-semibold tracking-tight">19,99 €</span>
|
||||
<span className="text-muted">/ an</span>
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
1,66 € par mois, pour tout le foyer.
|
||||
</p>
|
||||
|
||||
<label className="mt-6 flex cursor-pointer items-start gap-3 rounded-xl border border-line bg-surface p-3.5 text-[0.825rem] leading-relaxed text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={waived}
|
||||
onChange={(e) => setWaived(e.target.checked)}
|
||||
className="mt-0.5 size-4 shrink-0 accent-[var(--accent)]"
|
||||
/>
|
||||
<span>
|
||||
Je demande l'exécution immédiate du service et renonce à mon droit de
|
||||
rétractation de 14 jours.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Button size="lg" className="mt-4 w-full" disabled={sending || !waived} onClick={payer}>
|
||||
{sending ? "Redirection…" : "Je débloque"}
|
||||
</Button>
|
||||
|
||||
{error && <ErrorNote>{error}</ErrorNote>}
|
||||
|
||||
<p className="mt-4 flex items-start gap-2 text-xs text-muted">
|
||||
<ShieldCheck size={14} className="mt-0.5 shrink-0" aria-hidden />
|
||||
Paiement sécurisé par Stripe, qui te demandera ton adresse mail. Sans engagement
|
||||
au-delà de l'année en cours, sans reconduction surprise : le récap annuel te
|
||||
prévient avant.
|
||||
</p>
|
||||
{/* Le renvoi aux CGV appartient à l'écran de paiement : c'est là que le contrat se
|
||||
conclut, et un acheteur ne doit pas avoir à descendre au pied de page pour le lire. */}
|
||||
<p className="mt-2 text-xs text-faint">
|
||||
En payant, tu acceptes les{" "}
|
||||
<a href="/cgv" target="_blank" rel="noopener noreferrer" className="underline underline-offset-2">
|
||||
conditions générales de vente
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
{/* Les garanties closent la colonne du paiement, pas celle du discours : c'est ici qu'on
|
||||
se demande où vont ses données, une seconde avant de sortir sa carte. */}
|
||||
<ul className="mt-5 flex flex-wrap gap-x-4 gap-y-2 border-t border-line pt-4">
|
||||
{GARANTIES.map(({ Icon, texte }) => (
|
||||
<li key={texte} className="flex items-center gap-1.5 text-[0.8rem] text-muted">
|
||||
<Icon size={13} className="shrink-0 text-accent" aria-hidden />
|
||||
{texte}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{/* Le geste, isolé du discours. */}
|
||||
{mode === "gratuit" ? <ColonneGratuite {...props} /> : <ColonnePayante {...props} />}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Le geste payant : Stripe, le prix, la renonciation ───────────────────── */
|
||||
|
||||
function ColonnePayante({ householdId, mode }: Props) {
|
||||
const m = mots(mode);
|
||||
const [waived, setWaived] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function payer() {
|
||||
setSending(true);
|
||||
setError(null);
|
||||
piste("cta_activate", householdId);
|
||||
const res = await fetch("/api/checkout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ householdId, retractationWaived: waived }),
|
||||
}).catch(() => null);
|
||||
|
||||
if (!res?.ok) {
|
||||
setSending(false);
|
||||
if (res?.status === 501) setError("Le paiement n'est pas encore activé sur ce site.");
|
||||
else if (res?.status === 409) setError("Le paiement est désactivé — recharge la page, l'accès est gratuit.");
|
||||
else setError("Un souci est survenu de notre côté — réessaie dans un instant.");
|
||||
return;
|
||||
}
|
||||
const { url } = await res.json();
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
return (
|
||||
<Colonne>
|
||||
<p className="flex items-baseline gap-1.5">
|
||||
<span className="tnum font-display text-5xl font-semibold tracking-tight">{m.prix}</span>
|
||||
<span className="text-muted">/ an</span>
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted">1,66 € par mois, pour tout le foyer.</p>
|
||||
|
||||
{/*
|
||||
La case de renonciation reste un blocage légal non négociable EN MODE PAYANT : sans elle,
|
||||
le parcours quiz → paiement → full reveal instantané → rétractation resterait une faille
|
||||
ouverte. Texte repris verbatim du cadrage — à faire valider par un professionnel du droit
|
||||
avant tout encaissement réel (§ 10.7, § 28).
|
||||
*/}
|
||||
<label className="mt-6 flex cursor-pointer items-start gap-3 rounded-xl border border-line bg-surface p-3.5 text-[0.825rem] leading-relaxed text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={waived}
|
||||
onChange={(e) => setWaived(e.target.checked)}
|
||||
className="mt-0.5 size-4 shrink-0 accent-[var(--accent)]"
|
||||
/>
|
||||
<span>
|
||||
Je demande l'exécution immédiate du service et renonce à mon droit de
|
||||
rétractation de 14 jours.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Button size="lg" className="mt-4 w-full" disabled={sending || !waived} onClick={payer}>
|
||||
{sending ? "Redirection…" : m.cta}
|
||||
</Button>
|
||||
|
||||
{error && <ErrorNote>{error}</ErrorNote>}
|
||||
|
||||
<p className="mt-4 flex items-start gap-2 text-xs text-muted">
|
||||
<ShieldCheck size={14} className="mt-0.5 shrink-0" aria-hidden />
|
||||
Paiement sécurisé par Stripe, qui te demandera ton adresse mail. Sans engagement
|
||||
au-delà de l'année en cours, sans reconduction surprise : le récap annuel te
|
||||
prévient avant.
|
||||
</p>
|
||||
{/* Le renvoi aux conditions appartient à l'écran du geste : c'est là que le contrat se
|
||||
conclut, et un acheteur ne doit pas avoir à descendre au pied de page pour le lire. */}
|
||||
<p className="mt-2 text-xs text-faint">
|
||||
En payant, tu acceptes les <LienConditions mode={mode} />.
|
||||
</p>
|
||||
|
||||
<Garanties />
|
||||
</Colonne>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Le geste gratuit : une adresse, et la place qu'elle prend ─────────────── */
|
||||
|
||||
type ResultatInscription = { statut: "ouvert" | "attente" | "deja-ouvert"; rang?: number };
|
||||
|
||||
function ColonneGratuite({ householdId, mode, places }: Props) {
|
||||
const m = mots(mode);
|
||||
const [email, setEmail] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [resultat, setResultat] = useState<ResultatInscription | null>(null);
|
||||
|
||||
/*
|
||||
Complet ou non — décidé au rendu serveur, donc juste au chargement de la page. Il peut
|
||||
devenir faux entre l'affichage et le clic si la dernière place part entre-temps : la route
|
||||
tranche pour de bon, et son verdict remplace celui-ci à l'écran. On annonce ce qu'on sait,
|
||||
on ne promet pas ce qu'on ne tient pas.
|
||||
*/
|
||||
const complet = places !== null && !places.ouvert;
|
||||
|
||||
async function inscrire() {
|
||||
setSending(true);
|
||||
setError(null);
|
||||
piste("cta_activate", householdId);
|
||||
const res = await fetch("/api/activer", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ householdId, email }),
|
||||
}).catch(() => null);
|
||||
|
||||
setSending(false);
|
||||
if (!res?.ok) {
|
||||
if (res?.status === 429) setError("Trop de tentatives — réessaie dans un quart d'heure.");
|
||||
else if (res?.status === 400) setError("Cette adresse ne ressemble pas à une adresse électronique.");
|
||||
else if (res?.status === 409) setError("L'inscription gratuite est fermée — recharge la page.");
|
||||
else setError("Un souci est survenu de notre côté — réessaie dans un instant.");
|
||||
return;
|
||||
}
|
||||
setResultat(await res.json());
|
||||
}
|
||||
|
||||
// Une fois l'adresse déposée, la colonne ne redemande plus rien : elle dit ce qui s'est passé
|
||||
// et ce qu'il reste à faire. Redonner un champ vide se lirait comme un échec.
|
||||
if (resultat) return <ColonneConfirmee resultat={resultat} email={email} />;
|
||||
|
||||
return (
|
||||
<Colonne>
|
||||
{complet ? (
|
||||
<>
|
||||
<p className="flex items-center gap-2 font-display text-2xl font-semibold tracking-tight">
|
||||
<Users size={20} className="shrink-0 text-accent" aria-hidden />
|
||||
Les accès sont complets
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
On ouvre les accès par vagues, pour tenir la qualité de la veille. Laisse ton adresse :
|
||||
ton foyer est déjà calculé, il t'attend, et on t'écrit dès qu'une place
|
||||
se libère.
|
||||
{places?.places !== null && places?.places !== undefined && (
|
||||
<> {places.places} accès ouverts à ce jour.</>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-display text-4xl font-semibold tracking-tight">C'est gratuit.</p>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
Pour tout le foyer, sans carte bancaire et sans durée.
|
||||
{places?.restantes !== null && places?.restantes !== undefined && (
|
||||
<>
|
||||
{" "}
|
||||
{places.restantes > 1
|
||||
? `Il reste ${places.restantes} accès à ouvrir.`
|
||||
: "Il reste un accès à ouvrir."}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className="mt-6 block text-sm font-medium" htmlFor="email-activation">
|
||||
Ton adresse électronique
|
||||
</label>
|
||||
<input
|
||||
id="email-activation"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && email && !sending) inscrire();
|
||||
}}
|
||||
placeholder="prenom@exemple.fr"
|
||||
className="mt-2 w-full rounded-xl border border-line bg-surface px-3.5 py-2.5 text-[0.95rem] outline-none focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent-soft"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted">
|
||||
Elle sert à trois choses et à rien d'autre : te connecter, retrouver ton foyer, et
|
||||
t'envoyer les rappels que tu peux couper à tout moment.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="mt-4 w-full"
|
||||
disabled={sending || !email}
|
||||
onClick={inscrire}
|
||||
>
|
||||
{sending ? "Un instant…" : complet ? "Me prévenir quand une place s'ouvre" : m.cta}
|
||||
</Button>
|
||||
|
||||
{error && <ErrorNote>{error}</ErrorNote>}
|
||||
|
||||
<p className="mt-4 flex items-start gap-2 text-xs text-muted">
|
||||
<ShieldCheck size={14} className="mt-0.5 shrink-0" aria-hidden />
|
||||
Aucun mot de passe à choisir : on t'envoie un lien qui te connecte. Aucune carte
|
||||
bancaire n'est demandée, à aucun moment.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-faint">
|
||||
En créant ton accès, tu acceptes les <LienConditions mode={mode} />.
|
||||
</p>
|
||||
|
||||
<Garanties />
|
||||
</Colonne>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce que l'écran dit une fois l'adresse déposée.
|
||||
*
|
||||
* **Aucun des trois cas ne déverrouille la page sur-le-champ**, et c'est délibéré : le
|
||||
* déverrouillage suppose une session (D-017 — le lien seul ne suffit plus une fois l'accès
|
||||
* ouvert), et ouvrir une session sur une adresse simplement tapée dans un formulaire donnerait le
|
||||
* foyer à quiconque saisirait l'adresse de quelqu'un d'autre. Le lien magique est ce qui prouve
|
||||
* la boîte ; il est un aller-retour, pas une friction gratuite.
|
||||
*/
|
||||
function ColonneConfirmee({ resultat, email }: { resultat: ResultatInscription; email: string }) {
|
||||
const enAttente = resultat.statut === "attente";
|
||||
return (
|
||||
<Colonne>
|
||||
<p className="flex items-center gap-2 font-display text-2xl font-semibold tracking-tight">
|
||||
<MailCheck size={20} className="shrink-0 text-accent" aria-hidden />
|
||||
{enAttente ? "Tu es sur la liste" : "C'est ouvert"}
|
||||
</p>
|
||||
<p className="mt-3 text-[0.95rem] leading-relaxed text-muted">
|
||||
{enAttente ? (
|
||||
<>
|
||||
Ton compte est créé et ton foyer t'attend, calculé et prêt.
|
||||
{typeof resultat.rang === "number" && (
|
||||
<>
|
||||
{" "}
|
||||
Tu es <strong className="font-semibold text-ink">{resultat.rang}e</strong> sur la
|
||||
liste.
|
||||
</>
|
||||
)}{" "}
|
||||
On t'écrit à <strong className="font-semibold text-ink">{email}</strong> le jour
|
||||
où ta place s'ouvre — tu n'as rien à surveiller, et rien à refaire.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Ton accès est ouvert. On vient d'envoyer un lien de connexion à{" "}
|
||||
<strong className="font-semibold text-ink">{email}</strong> : clique dessus et ton
|
||||
calendrier complet s'affiche, ici même.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-4 text-xs text-muted">
|
||||
Rien dans les prochaines minutes ? Regarde les indésirables — c'est le seul endroit
|
||||
où ce mail se perd.
|
||||
</p>
|
||||
<Garanties />
|
||||
</Colonne>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Pièces communes ──────────────────────────────────────────────────────── */
|
||||
|
||||
function Colonne({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col justify-center border-t border-line bg-surface-alt p-6 sm:p-8 md:border-t-0 md:border-l">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Les garanties closent la colonne du geste, pas celle du discours : c'est ici qu'on se demande
|
||||
où vont ses données, une seconde avant de les donner. */
|
||||
function Garanties() {
|
||||
return (
|
||||
<ul className="mt-5 flex flex-wrap gap-x-4 gap-y-2 border-t border-line pt-4">
|
||||
{GARANTIES.map(({ Icon, texte }) => (
|
||||
<li key={texte} className="flex items-center gap-1.5 text-[0.8rem] text-muted">
|
||||
<Icon size={13} className="shrink-0 text-accent" aria-hidden />
|
||||
{texte}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** Le lien contractuel. Même URL dans les deux modes (cf. `app/cgv/page.tsx`), libellé différent. */
|
||||
function LienConditions({ mode }: { mode: ModeAcces }) {
|
||||
return (
|
||||
<a href="/cgv" target="_blank" rel="noopener noreferrer" className="underline underline-offset-2">
|
||||
{mots(mode).conditions}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** La mesure d'usage interne : un nom d'événement et un identifiant de foyer, rien d'autre. */
|
||||
function piste(name: string, householdId: string) {
|
||||
fetch("/api/event", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, householdId }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rappel discret du verrou, posé sous les listes masquées plutôt qu'en travers.
|
||||
*
|
||||
* La destination est paramétrable parce que le verrou a deux causes distinctes, et qu'elles
|
||||
* n'appellent pas le même geste : un foyer qui n'a pas encore payé descend vers l'offre, un
|
||||
* foyer déjà abonné mais non reconnu n'a rien à acheter — il a juste à se connecter. Les
|
||||
* valeurs par défaut gardent le premier cas, de loin le plus fréquent.
|
||||
* n'appellent pas le même geste : un foyer qui n'a pas encore franchi la porte descend vers
|
||||
* l'offre, un foyer déjà ouvert mais non reconnu n'a rien à obtenir — il a juste à se connecter.
|
||||
* Les valeurs par défaut gardent le premier cas, de loin le plus fréquent.
|
||||
*/
|
||||
export function LienVersActiver({ label, href = "#activer", cta = "Je débloque" }: {
|
||||
export function LienVersActiver({ label, href = "#activer", cta }: {
|
||||
label: string;
|
||||
href?: string;
|
||||
cta?: string;
|
||||
cta: string;
|
||||
}) {
|
||||
return (
|
||||
<p className="mt-3 flex items-center gap-2 text-sm text-muted">
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import { buttonClass } from "@/components/ui/button-styles";
|
|||
*
|
||||
* Elle est masquée au-delà de `sm` : sur desktop, elle volerait la place sans résoudre de problème.
|
||||
*/
|
||||
export function BarreActiver() {
|
||||
export function BarreActiver({ cta }: { cta: string }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -59,8 +59,11 @@ export function BarreActiver() {
|
|||
<Lock size={14} className="shrink-0" aria-hidden />
|
||||
Titres, dates et tracés masqués
|
||||
</p>
|
||||
{/* Le libellé vient de l'appelant (D-065) : il n'est pas le même selon le mode, et une
|
||||
barre qui dirait « Je débloque » vers un encart qui dit « c'est gratuit » perdrait la
|
||||
seule information qui décide du clic. */}
|
||||
<a href="#activer" className={buttonClass("primary", "sm", "shrink-0")} tabIndex={visible ? 0 : -1}>
|
||||
Je débloque
|
||||
{cta}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,16 +3,19 @@ import { eq } from "drizzle-orm";
|
|||
import { headers } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Lock, MapPin, PartyPopper, type LucideIcon } from "lucide-react";
|
||||
import { Hourglass, Lock, MapPin, PartyPopper, type LucideIcon } from "lucide-react";
|
||||
import { db, assets as assetsTable } from "@/db";
|
||||
import { buildHub, identiteIcs, type HubDeadline } from "@/engine/hub";
|
||||
import { buildHistorique } from "@/engine/historique";
|
||||
import { echeancesRangees } from "@/engine/gestes-db";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { resolveMembership } from "@/lib/membership";
|
||||
import { debutPeriodeCouverte, getSubscription } from "@/lib/subscription";
|
||||
import { debutHistorique, getSubscription } from "@/lib/subscription";
|
||||
import { hasLead } from "@/lib/leads";
|
||||
import { loadSeoPages } from "@/knowledge/loader";
|
||||
import { ceQuiLOuvre, mots, reserveAux } from "@/lib/mode";
|
||||
import { modeAcces } from "@/lib/mode-serveur";
|
||||
import { etatPlaces, rangEnAttente } from "@/lib/places";
|
||||
import { PILIER_META, PILIER_ORDER } from "@/lib/pilier-meta";
|
||||
import { Container, IconTile, SectionTitle } from "@/components/ui/primitives";
|
||||
import { buttonClass } from "@/components/ui/button-styles";
|
||||
|
|
@ -87,6 +90,7 @@ function PilierVerrouille({
|
|||
color,
|
||||
count,
|
||||
agregats,
|
||||
reserve,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
|
|
@ -94,6 +98,8 @@ function PilierVerrouille({
|
|||
color: string;
|
||||
count: number;
|
||||
agregats?: string | null;
|
||||
/** « réservé aux abonnés » / « réservé aux inscrits » — cf. `reserveAux` (D-065). */
|
||||
reserve: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
|
|
@ -112,7 +118,7 @@ function PilierVerrouille({
|
|||
{agregats && <p className="tnum text-sm text-faint">{agregats}</p>}
|
||||
</div>
|
||||
<Lock size={15} className="shrink-0 text-faint" aria-hidden />
|
||||
<span className="sr-only">Titres, dates et enjeux réservés aux abonnés</span>
|
||||
<span className="sr-only">Titres, dates et enjeux {reserve}</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
|
@ -138,6 +144,24 @@ export default async function FoyerPage({
|
|||
const subscription = await getSubscription(id);
|
||||
const isPaid = subscription.isActive;
|
||||
|
||||
/**
|
||||
* Le mode d'accès, lu une fois et descendu en `props` jusqu'aux composants client (D-065).
|
||||
*
|
||||
* `isPaid` garde son nom bien qu'il signifie désormais « l'accès est ouvert » : c'est la même
|
||||
* question, posée quinze fois dans ce fichier, et la renommer n'aurait rien clarifié qu'un
|
||||
* commentaire ne dise mieux. Ce qui change avec le mode est ce qu'on RACONTE au visiteur qui
|
||||
* n'a pas encore franchi la porte, jamais ce qu'on lui ouvre.
|
||||
*/
|
||||
const mode = modeAcces();
|
||||
const vocabulaire = mots(mode);
|
||||
const reserve = reserveAux(mode);
|
||||
// Les places ne se comptent qu'en mode gratuit plafonné, et seulement pour un foyer qui n'a
|
||||
// pas encore d'accès : afficher « il reste 12 places » à quelqu'un qui est déjà entré serait
|
||||
// du bruit, et une requête pour rien sur chaque visite d'abonné.
|
||||
const places = mode === "gratuit" && !isPaid ? await etatPlaces() : null;
|
||||
const enAttente = subscription.status === "attente";
|
||||
const rangAttente = enAttente ? await rangEnAttente(id) : null;
|
||||
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
const members = await resolveMembership(id, session?.user ?? null, isPaid, invite);
|
||||
const isMember = Boolean(session?.user.id) && members.some((m) => m.userId === session!.user.id);
|
||||
|
|
@ -238,7 +262,7 @@ export default async function FoyerPage({
|
|||
* sous les yeux (D-020), et le passé n'est pas à l'écran par défaut.
|
||||
*/
|
||||
const passees = unlocked
|
||||
? await buildHistorique(id, debutPeriodeCouverte(subscription), maintenant)
|
||||
? await buildHistorique(id, debutHistorique(subscription), maintenant)
|
||||
: [];
|
||||
|
||||
/**
|
||||
|
|
@ -275,6 +299,32 @@ export default async function FoyerPage({
|
|||
</Container>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Le foyer en liste d'attente (D-065). Il est dit EN HAUT, avant le calendrier verrouillé,
|
||||
parce que sans lui la page se lit comme un mur ordinaire : le visiteur a déjà donné son
|
||||
adresse, et rien à l'écran ne dirait qu'il n'a plus rien à faire. Aucun compte à rebours,
|
||||
aucune date promise — on ne sait pas quand une place se libère, et l'inventer serait pire
|
||||
que le silence.
|
||||
*/}
|
||||
{enAttente && (
|
||||
<Container width="wide" className="pt-8">
|
||||
<div className="animate-rise rounded-xl border border-line bg-surface-alt px-5 py-4">
|
||||
<p className="flex items-center gap-2.5 font-medium">
|
||||
<Hourglass size={18} className="shrink-0 text-accent" aria-hidden />
|
||||
Ton accès attend une place
|
||||
{typeof rangAttente === "number" && (
|
||||
<span className="tnum text-muted">— tu es {rangAttente}e sur la liste</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-1.5 max-w-2xl text-[0.925rem] text-muted">
|
||||
Ton compte existe et ton foyer est calculé : tout ce que tu vois ci-dessous continue
|
||||
de se tenir à jour. On t'écrit le jour où ta place s'ouvre — tu n'as
|
||||
rien à surveiller, et rien à refaire.
|
||||
</p>
|
||||
</div>
|
||||
</Container>
|
||||
)}
|
||||
|
||||
{/* ── La révélation ─────────────────────────────────────────────── */}
|
||||
<Container width="wide" className="pt-12 pb-10 sm:pt-16">
|
||||
<div className="animate-rise text-center">
|
||||
|
|
@ -369,7 +419,7 @@ export default async function FoyerPage({
|
|||
{!unlocked && (
|
||||
<p className="mt-2 text-muted">
|
||||
On t'a compté tout ce qu'on a repéré, réparti par domaine. Le détail —
|
||||
quoi, quand, pourquoi — fait partie de l'abonnement.
|
||||
quoi, quand, pourquoi — {ceQuiLOuvre(mode)}.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
@ -416,6 +466,7 @@ export default async function FoyerPage({
|
|||
color={color}
|
||||
count={deadlines.length}
|
||||
agregats={agregatsVerrouilles(deadlines, maintenant)}
|
||||
reserve={reserve}
|
||||
>
|
||||
{estVehicules && <Rappels assets={assets} unlocked={unlocked} />}
|
||||
</PilierVerrouille>
|
||||
|
|
@ -427,7 +478,7 @@ export default async function FoyerPage({
|
|||
{!unlocked && (
|
||||
isPaid
|
||||
? <LienVersActiver label="Connecte-toi pour voir le détail." href="/connexion" cta="Se connecter" />
|
||||
: <LienVersActiver label="Titres, dates et enjeux masqués." />
|
||||
: <LienVersActiver label="Titres, dates et enjeux masqués." cta={vocabulaire.cta} />
|
||||
)}
|
||||
|
||||
{/* L'étagère des échéances rangées — repliée, sans compteur en évidence, et
|
||||
|
|
@ -469,7 +520,12 @@ export default async function FoyerPage({
|
|||
{/* ── Le mur de paiement, une fois le manque installé (jamais à un foyer déjà abonné) ── */}
|
||||
{/* Pleine largeur, hors de la grille : l'encart est lui-même en deux colonnes (discours
|
||||
d'un côté, geste de paiement de l'autre) et se serait replié dans une colonne étroite. */}
|
||||
{!isPaid && (
|
||||
{/*
|
||||
Le mur ne s'affiche pas à un foyer déjà en attente : il a franchi la porte, il n'a plus
|
||||
rien à y faire. Lui reproposer le formulaire lui ferait croire que son geste n'a pas
|
||||
compté — et un second dépôt de la même adresse ne changerait rien à son rang.
|
||||
*/}
|
||||
{!isPaid && !enAttente && (
|
||||
<>
|
||||
<Container width="wide" className="py-8">
|
||||
<Activer
|
||||
|
|
@ -478,9 +534,11 @@ export default async function FoyerPage({
|
|||
masquees={masquees.length}
|
||||
legalesMasquees={legalesMasquees}
|
||||
nbGuides={nbGuides}
|
||||
mode={mode}
|
||||
places={places}
|
||||
/>
|
||||
</Container>
|
||||
<BarreActiver />
|
||||
<BarreActiver cta={vocabulaire.cta} />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
@ -489,10 +547,12 @@ export default async function FoyerPage({
|
|||
<Container width="wide" className="py-8">
|
||||
<div className="surface-card bg-surface-alt p-6 text-center sm:p-8">
|
||||
<p className="font-display text-lg font-semibold text-balance">
|
||||
Ce foyer est déjà abonné.
|
||||
{mode === "gratuit" ? "Ce foyer a déjà son accès." : "Ce foyer est déjà abonné."}
|
||||
</p>
|
||||
<p className="mx-auto mt-2 max-w-md text-muted">
|
||||
Connecte-toi avec le compte utilisé au moment du paiement pour voir le calendrier complet.
|
||||
Connecte-toi avec le compte{" "}
|
||||
{mode === "gratuit" ? "qui a ouvert cet accès" : "utilisé au moment du paiement"} pour
|
||||
voir le calendrier complet.
|
||||
</p>
|
||||
<Link href="/connexion" className={buttonClass("primary", "lg", "mt-5")}>
|
||||
Me connecter
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { ArrowRight, ChevronRight, Lock } from "lucide-react";
|
|||
import { loadSeoPages } from "@/knowledge/loader";
|
||||
import { PILIER_META, ENJEU_META } from "@/lib/pilier-meta";
|
||||
import { hasActiveSubscription } from "@/lib/subscription";
|
||||
import { mots, reserveAux } from "@/lib/mode";
|
||||
import { modeAcces } from "@/lib/mode-serveur";
|
||||
import { accesGuide, LECTURES_LIBRES } from "@/lib/guide-acces";
|
||||
import { rendreApercu } from "@/lib/guide-apercu";
|
||||
import { Container } from "@/components/ui/primitives";
|
||||
|
|
@ -50,6 +52,11 @@ export default async function GuidePage({ params }: { params: Promise<{ slug: st
|
|||
* et non dans un composant qui pourrait être rejoué.
|
||||
*/
|
||||
const unlocked = await hasActiveSubscription();
|
||||
// Le vocabulaire du mode d'accès (D-065) — « réservé aux abonnés » n'a plus de sens quand
|
||||
// l'accès est gratuit, et laisser la formule en dur ferait mentir la bascule sur la porte
|
||||
// d'entrée SEO du site.
|
||||
const mode = modeAcces();
|
||||
const vocabulaire = mots(mode);
|
||||
const acces = await accesGuide({ slug, entetes: await headers(), abonne: unlocked });
|
||||
|
||||
// Contenu maison (knowledge/pages), validé en CI — rendu HTML de confiance, jamais modifié
|
||||
|
|
@ -206,7 +213,7 @@ export default async function GuidePage({ params }: { params: Promise<{ slug: st
|
|||
<aside className="surface-card mt-2 bg-surface-alt p-6 text-center sm:p-8">
|
||||
<p className="inline-flex items-center gap-2 font-display text-xl font-semibold text-balance">
|
||||
<Lock size={18} className="shrink-0 text-muted" aria-hidden />
|
||||
La suite de ce guide est réservée aux abonnés
|
||||
La suite de ce guide est {reserveAux(mode)}
|
||||
</p>
|
||||
{/*
|
||||
On dit le mécanisme en clair — combien de guides sont offerts, et que le quota
|
||||
|
|
@ -215,9 +222,9 @@ export default async function GuidePage({ params }: { params: Promise<{ slug: st
|
|||
conclut que le site est cassé.
|
||||
*/}
|
||||
<p className="mx-auto mt-3 max-w-xl text-muted">
|
||||
Tu as lu tes {LECTURES_LIBRES} guides en accès libre. L'abonnement ouvre les{" "}
|
||||
{pages.length} guides — et surtout, il te dit lesquels concernent TON foyer, sans
|
||||
que tu aies à les parcourir.
|
||||
Tu as lu tes {LECTURES_LIBRES} guides en accès libre. {vocabulaire.laContrepartie}{" "}
|
||||
ouvre les {pages.length} guides — et surtout, elle te dit lesquels concernent TON
|
||||
foyer, sans que tu aies à les parcourir.
|
||||
</p>
|
||||
<Link href="/quiz" className={buttonClass("primary", "lg", "mt-5")}>
|
||||
Voir les échéances de mon foyer
|
||||
|
|
@ -225,7 +232,7 @@ export default async function GuidePage({ params }: { params: Promise<{ slug: st
|
|||
</Link>
|
||||
<p className="mt-3 text-xs text-muted">2 minutes, sans compte, sans carte bancaire.</p>
|
||||
<p className="mt-6 text-sm text-muted">
|
||||
Déjà abonné ?{" "}
|
||||
Déjà {vocabulaire.beneficiaire} ?{" "}
|
||||
<Link href="/connexion" className="font-medium text-accent underline underline-offset-2">
|
||||
Connecte-toi
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { ArrowRight, Lock } from "lucide-react";
|
|||
import { loadSeoPages } from "@/knowledge/loader";
|
||||
import { ENJEU_META, ENJEU_ORDER, PILIER_META, PILIER_ORDER } from "@/lib/pilier-meta";
|
||||
import { hasActiveSubscription } from "@/lib/subscription";
|
||||
import { mots, reserveAux } from "@/lib/mode";
|
||||
import { modeAcces } from "@/lib/mode-serveur";
|
||||
import { LECTURES_LIBRES } from "@/lib/guide-acces";
|
||||
import { Container, Eyebrow, IconTile, Lede } from "@/components/ui/primitives";
|
||||
import { buttonClass } from "@/components/ui/button-styles";
|
||||
|
|
@ -23,6 +25,11 @@ export default async function GuidesIndex() {
|
|||
pages: pages.filter((p) => p.template.pilier === pilier),
|
||||
})).filter((g) => g.pages.length > 0);
|
||||
const unlocked = await hasActiveSubscription();
|
||||
// Le vocabulaire du mode d'accès (D-065) — « réservé aux abonnés » n'a plus de sens quand
|
||||
// l'accès est gratuit, et laisser la formule en dur ferait mentir la bascule sur la porte
|
||||
// d'entrée SEO du site.
|
||||
const mode = modeAcces();
|
||||
const vocabulaire = mots(mode);
|
||||
|
||||
return (
|
||||
<main className="pb-8">
|
||||
|
|
@ -107,13 +114,13 @@ export default async function GuidesIndex() {
|
|||
<div className="surface-card mt-6 bg-surface-alt p-6 text-center sm:p-8">
|
||||
<p className="inline-flex items-center gap-2 font-display text-lg font-semibold text-balance">
|
||||
<Lock size={17} className="shrink-0 text-muted" aria-hidden />
|
||||
Le catalogue complet ({pages.length} guides) est réservé aux abonnés
|
||||
Le catalogue complet ({pages.length} guides) est {reserveAux(mode)}
|
||||
</p>
|
||||
{/*
|
||||
Le CTA est ici parce que `/guide` est, après la landing, la porte d'entrée SEO du
|
||||
site : sans lui, un visiteur venu de Google trouvait un catalogue verrouillé et,
|
||||
pour seule issue, un lien de connexion réservé à ceux qui ont déjà payé. Le prix
|
||||
n'y est pas — il n'apparaît qu'après le quiz, une fois l'aperçu rendu.
|
||||
pour seule issue, un lien de connexion réservé à ceux qui sont déjà entrés. La
|
||||
contrepartie n'y est pas — elle n'apparaît qu'après le quiz, une fois l'aperçu rendu.
|
||||
*/}
|
||||
{/*
|
||||
Ce paragraphe promettait « chaque guide reste trouvable individuellement — par une
|
||||
|
|
@ -134,7 +141,7 @@ export default async function GuidesIndex() {
|
|||
<p className="mt-3 text-xs text-muted">2 minutes, sans compte, sans carte bancaire.</p>
|
||||
|
||||
<p className="mt-6 text-sm text-muted">
|
||||
Déjà abonné ?{" "}
|
||||
Déjà {vocabulaire.beneficiaire} ?{" "}
|
||||
<Link href="/connexion" className="font-medium text-accent underline underline-offset-2">
|
||||
Connecte-toi
|
||||
</Link>
|
||||
|
|
|
|||
208
src/app/legal.test.tsx
Normal file
208
src/app/legal.test.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import Conditions from "./cgv/page";
|
||||
import MentionsLegales from "./mentions-legales/page";
|
||||
import Confidentialite from "./confidentialite/page";
|
||||
|
||||
/**
|
||||
* Les trois pages légales, rendues pour de bon dans les deux modes (D-065).
|
||||
*
|
||||
* **Pourquoi un rendu et non une lecture de code** : ce qui est en jeu ici n'est pas qu'un
|
||||
* ternaire soit bien écrit, c'est qu'aucune des trois pages n'affirme une chose fausse. Une
|
||||
* politique de confidentialité qui déclare Stripe sous-traitant quand plus personne ne paie est
|
||||
* inexacte au sens de l'art. 13 du RGPD ; des CGU qui annoncent un prix, une reconduction ou une
|
||||
* renonciation au droit de rétractation décrivent un contrat qui n'existe pas. Ce sont les seules
|
||||
* pages du site où une phrase de trop a une conséquence juridique, et la seule façon de vérifier
|
||||
* qu'elle n'y est pas est de regarder ce qui sort.
|
||||
*
|
||||
* Ces trois composants sont **synchrones et sans base** : ils ne lisent que `process.env` (via
|
||||
* `lib/legal.ts` et `lib/mode-serveur.ts`), ce qui les rend rendables ici sans monter Next.
|
||||
*/
|
||||
|
||||
const initial = { ...process.env };
|
||||
afterEach(() => {
|
||||
process.env = { ...initial };
|
||||
});
|
||||
|
||||
/**
|
||||
* Le HTML de la page, dans un mode donné.
|
||||
*
|
||||
* ⚠️ **Seul `FOKAN_MODE` est paramétrable ici.** `EDITEUR` (`lib/legal.ts`) lit l'environnement
|
||||
* **au chargement du module** : les valeurs légales sont donc figées à l'import et posées à
|
||||
* blanc sous vitest, d'où les « à compléter » dans le rendu. Ce n'est pas un défaut du produit —
|
||||
* en production le processus démarre avec son `.env` — mais c'est ce qui interdit d'éprouver ici
|
||||
* autre chose que la structure du texte. `modeAcces()` est une fonction, elle, et se relit à
|
||||
* chaque rendu : c'est exactement ce qu'il faut pour ces tests.
|
||||
*/
|
||||
function rendre(Page: () => React.ReactElement, mode: "gratuit" | "payant"): string {
|
||||
process.env.FOKAN_MODE = mode;
|
||||
return renderToStaticMarkup(<Page />);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le corps du document, ramené à du texte comparable.
|
||||
*
|
||||
* **L'encart d'avertissement est retiré**, et pas par commodité : en mode gratuit il nomme
|
||||
* précisément les quatre mentions qu'il dit NE PAS réclamer (« SIREN, téléphone, médiateur,
|
||||
* régime de TVA »). Le chercher dans la page entière ferait échouer les assertions sur la
|
||||
* présence même de l'explication qui justifie leur absence. Ce qui doit être exempt de ces
|
||||
* mentions est le document contractuel — c'est-à-dire l'`<article>`.
|
||||
*/
|
||||
function texte(html: string): string {
|
||||
const article = html.match(/<article[^>]*>([\s\S]*)<\/article>/);
|
||||
return (article ? article[1] : html)
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/'|'/g, "'")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
describe("les conditions générales", () => {
|
||||
it("s'intitulent « de vente » en payant et « d'utilisation » en gratuit", () => {
|
||||
expect(texte(rendre(Conditions, "payant"))).toContain("Conditions générales de vente");
|
||||
expect(texte(rendre(Conditions, "gratuit"))).toContain("Conditions générales d'utilisation");
|
||||
});
|
||||
|
||||
/**
|
||||
* Les quatre clauses qui décrivent une obligation qui n'existe plus. Un article absent est
|
||||
* neutre ; un article qui annonce un prix qu'on ne facture pas, une reconduction qui n'a pas
|
||||
* lieu ou un médiateur auquel on n'a pas adhéré est une mention fausse.
|
||||
*/
|
||||
it("ne parlent ni de prix, ni de reconduction, ni de rétractation, ni de médiateur en gratuit", () => {
|
||||
const t = texte(rendre(Conditions, "gratuit"));
|
||||
expect(t).not.toMatch(/19,99|€/);
|
||||
expect(t).not.toMatch(/rétractation/i);
|
||||
expect(t).not.toMatch(/reconduit|reconduction/i);
|
||||
expect(t).not.toMatch(/médiateur/i);
|
||||
expect(t).not.toMatch(/Stripe/);
|
||||
});
|
||||
|
||||
it("gardent ces quatre articles en payant", () => {
|
||||
const t = texte(rendre(Conditions, "payant"));
|
||||
expect(t).toContain("19,99 €");
|
||||
expect(t).toMatch(/rétractation/i);
|
||||
expect(t).toMatch(/reconduit/i);
|
||||
expect(t).toMatch(/médiateur/i);
|
||||
});
|
||||
|
||||
/**
|
||||
* Ce que le mode gratuit AJOUTE. La gratuité n'est pas l'absence d'un article sur le prix :
|
||||
* un service gratuit qui ne dit pas comment il se finance laisse conclure « c'est moi le
|
||||
* produit », et le plafond d'accès est une limitation que l'utilisateur subit — la taire dans
|
||||
* un document contractuel serait le silence qu'on reproche aux autres.
|
||||
*/
|
||||
it("expliquent la gratuité et la liste d'attente, en gratuit seulement", () => {
|
||||
const gratuit = texte(rendre(Conditions, "gratuit"));
|
||||
expect(gratuit).toMatch(/aucune publicité/i);
|
||||
expect(gratuit).toMatch(/liste d'attente/i);
|
||||
expect(gratuit).toMatch(/ordre d'arrivée/i);
|
||||
// Et l'engagement qui rend la bascule inverse acceptable : jamais de rétroactivité.
|
||||
expect(gratuit).toMatch(/rétroactivement/i);
|
||||
|
||||
expect(texte(rendre(Conditions, "payant"))).not.toMatch(/liste d'attente/i);
|
||||
});
|
||||
|
||||
/** Les articles qui décrivent ce que le produit FAIT ne dépendent pas de ce qu'il coûte. */
|
||||
it("gardent l'objet du service et le refus de garantie de conformité dans les deux modes", () => {
|
||||
for (const mode of ["gratuit", "payant"] as const) {
|
||||
const t = texte(rendre(Conditions, mode));
|
||||
expect(t, mode).toContain("pas une garantie de conformité");
|
||||
expect(t, mode).toContain("le texte officiel fait foi");
|
||||
expect(t, mode).toMatch(/droit français/i);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* La numérotation est écrite à la main de part et d'autre du branchement : deux articles
|
||||
* portant le même numéro dans un document contractuel, c'est une référence croisée qui ne
|
||||
* désigne plus rien.
|
||||
*/
|
||||
it("numérotent leurs articles sans trou ni doublon", () => {
|
||||
for (const mode of ["gratuit", "payant"] as const) {
|
||||
const html = rendre(Conditions, mode);
|
||||
const numeros = [...html.matchAll(/<h2>(\d+)\./g)].map((m) => Number(m[1]));
|
||||
expect(numeros.length, mode).toBeGreaterThan(5);
|
||||
expect(numeros, mode).toEqual(numeros.map((_, i) => i + 1));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("les mentions légales", () => {
|
||||
it("ne réclament ni SIREN, ni TVA, ni téléphone en gratuit", () => {
|
||||
const t = texte(rendre(MentionsLegales, "gratuit"));
|
||||
expect(t).not.toMatch(/SIREN/);
|
||||
expect(t).not.toMatch(/TVA/);
|
||||
expect(t).not.toMatch(/Téléphone/);
|
||||
// L'identité, elle, reste due : le régime allégé du III-2 LCEN ne joue pas sur un service
|
||||
// auto-hébergé, où aucun hébergeur tiers ne détient l'identité de l'éditeur. La ligne existe
|
||||
// donc, même vide — c'est le « à compléter » qui la rend impossible à oublier.
|
||||
expect(t).toMatch(/Éditeur du site/);
|
||||
expect(t).toMatch(/Responsable de la publication/);
|
||||
expect(t).toMatch(/Hébergement/);
|
||||
});
|
||||
|
||||
it("les réclament toutes en payant", () => {
|
||||
const t = texte(rendre(MentionsLegales, "payant"));
|
||||
expect(t).toMatch(/SIREN/);
|
||||
expect(t).toMatch(/TVA/);
|
||||
expect(t).toMatch(/Téléphone/);
|
||||
});
|
||||
|
||||
it("renvoient vers le bon document contractuel", () => {
|
||||
expect(texte(rendre(MentionsLegales, "payant"))).toContain("Conditions générales de vente");
|
||||
expect(texte(rendre(MentionsLegales, "gratuit"))).toContain("Conditions générales d'utilisation");
|
||||
});
|
||||
|
||||
/**
|
||||
* D-064 a retiré l'inventaire des sources de la page d'accueil ; cette page continuait de l'y
|
||||
* envoyer. Une mention légale qui renvoie vers une information absente est fausse, et le test
|
||||
* est là pour que la prochaine réorganisation de la landing ne la rétablisse pas par accident.
|
||||
*/
|
||||
it("ne renvoient plus les sources vers la page d'accueil", () => {
|
||||
for (const mode of ["gratuit", "payant"] as const) {
|
||||
expect(texte(rendre(MentionsLegales, mode)), mode).not.toMatch(
|
||||
/sources utilisées sont nommées sur la page/i,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("la politique de confidentialité", () => {
|
||||
/**
|
||||
* Le point le plus coûteux des trois pages : déclarer un sous-traitant qui ne traite rien
|
||||
* n'est pas de la prudence, c'est une inexactitude — et l'art. 13 impose l'exactitude.
|
||||
*/
|
||||
it("ne déclare plus Stripe ni comme sous-traitant, ni comme transfert hors UE, en gratuit", () => {
|
||||
const t = texte(rendre(Confidentialite, "gratuit"));
|
||||
expect(t).toContain("Deux sous-traitants");
|
||||
expect(t).toMatch(/Un seul sous-traitant peut traiter certaines données hors de l'Union/);
|
||||
expect(t).toMatch(/aucun moyen de paiement n'est demandé/i);
|
||||
});
|
||||
|
||||
it("les déclare en payant", () => {
|
||||
const t = texte(rendre(Confidentialite, "payant"));
|
||||
expect(t).toContain("Trois sous-traitants");
|
||||
expect(t).toMatch(/Deux sous-traitants peuvent en revanche traiter/);
|
||||
});
|
||||
|
||||
/**
|
||||
* Les foyers qui ont payé avant la bascule existent, et leurs données de facturation aussi.
|
||||
* Le texte doit les nommer au passé plutôt que de faire comme s'ils n'avaient jamais existé —
|
||||
* les effacer du document serait, là encore, une inexactitude.
|
||||
*/
|
||||
it("nomme au passé la facturation des abonnements antérieurs", () => {
|
||||
const t = texte(rendre(Confidentialite, "gratuit"));
|
||||
expect(t).toMatch(/avant le passage en (accès )?gratuit/i);
|
||||
});
|
||||
|
||||
/** Ce qui ne dépend pas du modèle économique ne bouge dans aucun des deux modes. */
|
||||
it("garde ses engagements de fond dans les deux modes", () => {
|
||||
for (const mode of ["gratuit", "payant"] as const) {
|
||||
const t = texte(rendre(Confidentialite, mode));
|
||||
expect(t, mode).toContain("Aucun document conservé");
|
||||
expect(t, mode).toMatch(/Aucune adresse IP en clair/);
|
||||
expect(t, mode).toMatch(/CNIL/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -3,6 +3,8 @@ import Link from "next/link";
|
|||
import { Container } from "@/components/ui/primitives";
|
||||
import { MentionsIncompletes, Valeur } from "@/components/legal/valeur";
|
||||
import { EDITEUR, MAJ } from "@/lib/legal";
|
||||
import { mots } from "@/lib/mode";
|
||||
import { modeAcces } from "@/lib/mode-serveur";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Mentions légales",
|
||||
|
|
@ -22,6 +24,17 @@ export const metadata: Metadata = {
|
|||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function MentionsLegales() {
|
||||
/*
|
||||
Le mode d'accès (D-065). Quatre lignes de la fiche d'identité ne tiennent qu'au commerce et
|
||||
sont retirées en gratuit — SIREN, TVA, téléphone, et le renvoi aux conditions de vente. Le
|
||||
raisonnement article par article est dans `mentionsManquantes` (`lib/legal.ts`), qui décide
|
||||
aussi de l'encart d'avertissement : les deux doivent rester d'accord, sans quoi la page
|
||||
afficherait « à compléter » sur un champ que l'encart ne réclame pas.
|
||||
*/
|
||||
const mode = modeAcces();
|
||||
const vocabulaire = mots(mode);
|
||||
const gratuit = mode === "gratuit";
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Container width="narrow" className="py-12 sm:py-16">
|
||||
|
|
@ -37,9 +50,14 @@ export default function MentionsLegales() {
|
|||
est situé <Valeur v={EDITEUR.siege} />.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
SIREN / SIRET : <Valeur v={EDITEUR.siren} />
|
||||
</li>
|
||||
{/* Le SIREN n'est exigé par l'art. 6 III-1 LCEN que de l'éditeur professionnel : on
|
||||
ne le réclame pas sur un service gratuit — mais on l'affiche s'il existe, car le
|
||||
taire quand on l'a serait retirer une information au lecteur. */}
|
||||
{(!gratuit || EDITEUR.siren) && (
|
||||
<li>
|
||||
SIREN / SIRET : <Valeur v={EDITEUR.siren} />
|
||||
</li>
|
||||
)}
|
||||
{EDITEUR.formeJuridique && (
|
||||
<>
|
||||
<li>
|
||||
|
|
@ -50,29 +68,36 @@ export default function MentionsLegales() {
|
|||
</li>
|
||||
</>
|
||||
)}
|
||||
<li>
|
||||
{/*
|
||||
Deux régimes, deux mentions, et jamais les deux : afficher « TVA intracommunautaire :
|
||||
à compléter » sous franchise en base ferait chercher au lecteur un numéro qui
|
||||
n'existe pas, et annoncer un numéro sous franchise serait une fausse mention.
|
||||
*/}
|
||||
{EDITEUR.regimeTva === "franchise" ? (
|
||||
<>TVA : non applicable, article 293 B du Code général des impôts</>
|
||||
) : (
|
||||
<>
|
||||
TVA intracommunautaire : <Valeur v={EDITEUR.tva} />
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
{/* Rien à qualifier sans prix : la mention de TVA disparaît entièrement en gratuit. */}
|
||||
{!gratuit && (
|
||||
<li>
|
||||
{/*
|
||||
Deux régimes, deux mentions, et jamais les deux : afficher « TVA intracommunautaire :
|
||||
à compléter » sous franchise en base ferait chercher au lecteur un numéro qui
|
||||
n'existe pas, et annoncer un numéro sous franchise serait une fausse mention.
|
||||
*/}
|
||||
{EDITEUR.regimeTva === "franchise" ? (
|
||||
<>TVA : non applicable, article 293 B du Code général des impôts</>
|
||||
) : (
|
||||
<>
|
||||
TVA intracommunautaire : <Valeur v={EDITEUR.tva} />
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
Responsable de la publication : <Valeur v={EDITEUR.responsablePublication} />
|
||||
</li>
|
||||
<li>
|
||||
Contact : <Valeur v={EDITEUR.email} />
|
||||
</li>
|
||||
<li>
|
||||
Téléphone : <Valeur v={EDITEUR.telephone} />
|
||||
</li>
|
||||
{/* Obligatoire en vente à distance (art. L221-5 et R111-1, 4°), sans objet sinon.
|
||||
Affiché malgré tout s'il est renseigné : un moyen de contact de plus ne nuit pas. */}
|
||||
{(!gratuit || EDITEUR.telephone) && (
|
||||
<li>
|
||||
Téléphone : <Valeur v={EDITEUR.telephone} />
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<h2>Hébergement</h2>
|
||||
|
|
@ -95,9 +120,9 @@ export default function MentionsLegales() {
|
|||
Ce n'est ni un conseil juridique, ni un conseil financier, ni un service de
|
||||
conservation de documents. Les règles applicables sont celles publiées par les autorités
|
||||
compétentes : en cas d'écart, <strong>l'arrêté, le texte ou la décision
|
||||
administrative fait foi</strong>, jamais l'affichage du site. Les sources utilisées
|
||||
sont nommées sur la <Link href="/">page d'accueil</Link> et, fiche par fiche, au bas
|
||||
de chaque guide.
|
||||
administrative fait foi</strong>, jamais l'affichage du site. La source de chaque
|
||||
règle est nommée fiche par fiche, au bas de chaque guide, et couche par couche sur la
|
||||
carte des périmètres.
|
||||
</p>
|
||||
|
||||
<h2>Propriété intellectuelle</h2>
|
||||
|
|
@ -127,7 +152,7 @@ export default function MentionsLegales() {
|
|||
<h2>Autres documents</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<Link href="/cgv">Conditions générales de vente</Link>
|
||||
<Link href="/cgv">{vocabulaire.conditionsTitre}</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/confidentialite">Politique de confidentialité</Link>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import Link from "next/link";
|
|||
import { CreditCard, Mail } from "lucide-react";
|
||||
import { buttonClass } from "@/components/ui/button-styles";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { mots, type ModeAcces } from "@/lib/mode";
|
||||
|
||||
/**
|
||||
* L'abonnement et les rappels — deux lignes qui ne doivent jamais se confondre.
|
||||
|
|
@ -22,6 +23,11 @@ import { cn } from "@/lib/cn";
|
|||
* **La phrase qui compte le plus est « ton calendrier reste complet ».** Sans elle, un
|
||||
* interrupteur posé sous une ligne d'abonnement se lit comme une résiliation, et quelqu'un qui
|
||||
* voulait seulement le silence annulerait ce pour quoi il paie.
|
||||
*
|
||||
* **En mode gratuit (D-065), la première ligne perd sa raison d'être et change de sujet.** Il n'y
|
||||
* a ni prélèvement à annoncer, ni reconduction, ni portail de facturation — donc rien à dire
|
||||
* qu'un état : la veille tourne. La seconde ligne, elle, est rigoureusement identique : couper
|
||||
* ses mails est un réglage qui ne doit rien au modèle économique.
|
||||
*/
|
||||
|
||||
function Interrupteur({
|
||||
|
|
@ -80,6 +86,8 @@ export function Abonnement({
|
|||
cancelAtPeriodEnd,
|
||||
rappelsCoupes,
|
||||
gerable,
|
||||
mode,
|
||||
enAttente,
|
||||
}: {
|
||||
householdId: string;
|
||||
isActive: boolean;
|
||||
|
|
@ -87,8 +95,18 @@ export function Abonnement({
|
|||
periodEnd: string | null;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
rappelsCoupes: boolean;
|
||||
/** Un portail de facturation n'existe que si Stripe connaît déjà ce foyer comme client. */
|
||||
/**
|
||||
* Un portail de facturation n'existe que si Stripe connaît déjà ce foyer comme client.
|
||||
*
|
||||
* Il reste proposé en mode gratuit à un foyer qui a un jour payé : l'art. L215-1-1 du Code de
|
||||
* la consommation impose que ce qui a été souscrit en ligne se résilie en ligne, et fermer la
|
||||
* sortie parce qu'on a fermé l'entrée enfermerait précisément ceux qui ont payé.
|
||||
*/
|
||||
gerable: boolean;
|
||||
/** Le mode arrive en `prop` : ce composant est client, il ne peut pas lire `process.env`. */
|
||||
mode: ModeAcces;
|
||||
/** Le foyer attend une place (mode gratuit plafonné). Ni ouvert, ni simplement fermé. */
|
||||
enAttente: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [rappels, setRappels] = useState(!rappelsCoupes);
|
||||
|
|
@ -97,6 +115,7 @@ export function Abonnement({
|
|||
const [portailEnCours, setPortailEnCours] = useState(false);
|
||||
|
||||
const dateFin = periodEnd ? new Date(periodEnd).toLocaleDateString("fr-FR") : null;
|
||||
const m = mots(mode);
|
||||
|
||||
function basculerRappels() {
|
||||
const vise = !rappels;
|
||||
|
|
@ -133,12 +152,32 @@ export function Abonnement({
|
|||
{/* ── L'abonnement : un état, et où on l'arrête ──────────────────── */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">{isActive ? "La veille est active" : "La veille n'est pas activée"}</p>
|
||||
<p className="font-medium">
|
||||
{isActive
|
||||
? "La veille est active"
|
||||
: enAttente
|
||||
? "Ta place est réservée"
|
||||
: "La veille n'est pas activée"}
|
||||
</p>
|
||||
<p className="mt-1 text-[0.925rem] text-muted">
|
||||
{!isActive ? (
|
||||
{enAttente ? (
|
||||
/* Aucune date promise : on ne sait pas quand une place se libère, et l'inventer
|
||||
serait pire que le silence (§ 4.5 — pas de compte à rebours). */
|
||||
<>
|
||||
Le calendrier de ton foyer est compté mais masqué — titres, dates et enjeux font
|
||||
partie de l'abonnement.
|
||||
Ton compte existe et ton foyer est calculé. Les accès sont ouverts par vagues ; on
|
||||
t'écrit le jour où le tien s'ouvre — rien à surveiller d'ici là.
|
||||
</>
|
||||
) : !isActive ? (
|
||||
<>
|
||||
Le calendrier de ton foyer est compté mais masqué — titres, dates et enjeux{" "}
|
||||
{mode === "gratuit" ? "s'ouvrent avec ton accès" : "font partie de l'abonnement"}.
|
||||
</>
|
||||
) : mode === "gratuit" ? (
|
||||
/* Rien à reconduire, rien à prélever : la ligne dit l'état, et s'arrête là. */
|
||||
<>
|
||||
Tu n'as rien à faire : on te prévient par mail au bon moment, et seulement au
|
||||
bon moment. Ton accès est gratuit et sans durée — aucune carte bancaire ne
|
||||
t'a été demandée, et il n'y en aura jamais.
|
||||
</>
|
||||
) : cancelAtPeriodEnd ? (
|
||||
/* Résilié : on ne réclame pas, on ne dramatise pas, on dit ce qui va se passer. */
|
||||
|
|
@ -149,13 +188,13 @@ export function Abonnement({
|
|||
) : (
|
||||
<>
|
||||
Tu n'as rien à faire : on te prévient par mail au bon moment, et seulement au
|
||||
bon moment. 19,99 €/an
|
||||
bon moment. {m.prix}/an
|
||||
{dateFin && `, reconduit le ${dateFin}`} — jamais de débit surprise.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{!isActive ? (
|
||||
{enAttente ? null : !isActive ? (
|
||||
<Link href={`/foyer/${householdId}#activer`} className={buttonClass("primary", "md")}>
|
||||
Activer la veille
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import {
|
|||
} from "@/db";
|
||||
import { buildHub } from "@/engine/hub";
|
||||
import { getSubscription, type SubscriptionState } from "@/lib/subscription";
|
||||
import { ceQuiLOuvre } from "@/lib/mode";
|
||||
import { modeAcces } from "@/lib/mode-serveur";
|
||||
import { quotaRejeuFoyer } from "@/engine/quiz-rejeu";
|
||||
import { messageQuotaEpuise, messageQuotaRestant, type QuotaRejeu } from "@/engine/quiz-quota";
|
||||
import { loadSeoPages } from "@/knowledge/loader";
|
||||
|
|
@ -141,6 +143,8 @@ export default async function MonEspace() {
|
|||
*/
|
||||
const principal = foyers.find((f) => f.subscription.isActive) ?? foyers[0] ?? null;
|
||||
const nbGuides = loadSeoPages().length;
|
||||
// Le mode d'accès, lu une fois pour toute la page et descendu en `props` (D-065).
|
||||
const mode = modeAcces();
|
||||
|
||||
/**
|
||||
* A-t-on déjà un mot de passe ? La question se pose à la table `account` et non à `user` :
|
||||
|
|
@ -229,7 +233,9 @@ export default async function MonEspace() {
|
|||
) : (
|
||||
<span className="mt-3 flex items-center gap-1.5 border-t border-line pt-3 text-[0.925rem] text-muted">
|
||||
<Lock size={13} className="shrink-0" aria-hidden />
|
||||
Aperçu — la veille n'est pas encore activée
|
||||
{f.subscription.status === "attente"
|
||||
? "Aperçu — ta place sur la liste d'attente est prise"
|
||||
: "Aperçu — la veille n'est pas encore activée"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
|
@ -268,7 +274,7 @@ export default async function MonEspace() {
|
|||
>
|
||||
{principal.subscription.isActive
|
||||
? `Les ${nbGuides} règles vérifiées aux sources officielles — fréquences, sanctions, marche à suivre.`
|
||||
: `Le catalogue complet (${nbGuides} guides) s'ouvre avec l'abonnement.`}
|
||||
: `Le catalogue complet (${nbGuides} guides) ${ceQuiLOuvre(mode)}.`}
|
||||
</Tuile>
|
||||
|
||||
{/* Le flux .ics est réservé aux foyers abonnés côté serveur : ne le proposer
|
||||
|
|
@ -285,8 +291,8 @@ export default async function MonEspace() {
|
|||
</Link>
|
||||
}
|
||||
>
|
||||
Les échéances du foyer dans Google Agenda, Apple Calendrier ou Outlook — avec
|
||||
l'abonnement.
|
||||
Les échéances du foyer dans Google Agenda, Apple Calendrier ou Outlook — ça{" "}
|
||||
{ceQuiLOuvre(mode)}.
|
||||
</Tuile>
|
||||
)}
|
||||
|
||||
|
|
@ -304,6 +310,8 @@ export default async function MonEspace() {
|
|||
cancelAtPeriodEnd={principal.subscription.cancelAtPeriodEnd}
|
||||
rappelsCoupes={principal.subscription.rappelsCoupesLe !== null}
|
||||
gerable={principal.gerable}
|
||||
mode={mode}
|
||||
enAttente={principal.subscription.status === "attente"}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { Mecanismes } from "@/components/landing/mecanismes";
|
|||
import { CarteIllustration, CoucheResumes } from "@/components/landing/carte-illustration";
|
||||
import { COUCHES } from "@/carte/registre";
|
||||
import { PILIER_META, PILIER_ORDER } from "@/lib/pilier-meta";
|
||||
import { modeAcces } from "@/lib/mode-serveur";
|
||||
import type { Pilier } from "@/knowledge/schema";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -83,6 +84,12 @@ const COMPTE_APERCU = APERCU.reduce<Partial<Record<Pilier, number>>>((acc, p) =>
|
|||
|
||||
export default function Landing() {
|
||||
const { fiches, parPilier } = compteEcheances();
|
||||
/*
|
||||
Le mode d'accès (D-065). La landing en dit très peu — c'est délibéré depuis D-005 :
|
||||
l'argumentaire du prix vit sur le mur, après le quiz, là où il répond à une question que le
|
||||
visiteur vient de se poser. Deux phrases seulement le mentionnent, et ce sont celles-là.
|
||||
*/
|
||||
const mode = modeAcces();
|
||||
/**
|
||||
* Les couches de la carte, réduites à ce qui se montre sur une page publique : un libellé, une
|
||||
* teinte, un résumé. Elles viennent du registre — une couche ajoutée apparaît ici, halo compris
|
||||
|
|
@ -231,7 +238,8 @@ export default function Landing() {
|
|||
<CarteIllustration couches={couches} />
|
||||
<p className="mt-3 text-center text-sm text-muted">
|
||||
Après le quiz, tu vois lesquels de ces {couches.length} périmètres tombent près de
|
||||
chez toi. L'inscription montre le tracé exact, et de quel côté tu te trouves.
|
||||
chez toi. {mode === "gratuit" ? "Ton compte gratuit" : "L'inscription"} montre le
|
||||
tracé exact, et de quel côté tu te trouves.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -336,7 +344,8 @@ export default function Landing() {
|
|||
<h2 className="max-w-xl text-title">Combien d'échéances ton foyer porte-t-il ?</h2>
|
||||
<p className="max-w-md text-muted">
|
||||
Deux minutes pour le savoir. Le compte, le rythme de ton année et les premières
|
||||
échéances en clair s'affichent tout de suite, sans payer.
|
||||
échéances en clair s'affichent tout de suite,{" "}
|
||||
{mode === "gratuit" ? "sans même créer de compte" : "sans payer"}.
|
||||
</p>
|
||||
<Link href="/quiz" className={buttonClass("primary", "lg")}>
|
||||
Faire le quiz
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ async function FoyerDejaLa({ householdId }: { householdId: string }) {
|
|||
</p>
|
||||
)}
|
||||
<p className="mx-auto mt-4 max-w-md text-muted">
|
||||
Un compte suit un seul foyer — c'est lui qui porte tes échéances, ton abonnement
|
||||
Un compte suit un seul foyer — c'est lui qui porte tes échéances, ton accès
|
||||
et ce que tu as déjà répondu. Refaire le quiz en créerait un second, vide, et
|
||||
laisserait le premier derrière.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -2031,11 +2031,13 @@ export function QuizClient() {
|
|||
|
||||
Copie corrigée par l'audit F8 (28/07/2026) : elle promettait un rattrapage post-quiz
|
||||
retiré depuis D-007 — rien n'était plus jamais reproposé. Le seul rattrapage réel
|
||||
est désormais le rejeu du quiz, réservé aux abonnés (cinq par année d'abonnement, D-060). */}
|
||||
est désormais le rejeu du quiz, réservé aux foyers dont l'accès est ouvert (cinq par an,
|
||||
D-060). La copie ne nomme aucun mode : « une fois ton accès ouvert » est vrai qu'on ait
|
||||
payé ou qu'on se soit inscrit, et évite de faire descendre le mode jusqu'ici (D-065). */}
|
||||
{screen === 8 && (
|
||||
<Ecran
|
||||
titre="Des contrats à associer, tant qu'on y est ?"
|
||||
aide="Seulement ceux que tu as déjà — rien n'est obligatoire ici. Une fois abonné, tu pourras revenir compléter cet écran en rejouant le quiz."
|
||||
aide="Seulement ceux que tu as déjà — rien n'est obligatoire ici. Une fois ton accès ouvert, tu pourras revenir compléter cet écran en rejouant le quiz."
|
||||
>
|
||||
{relevantContractTypesForProfil({
|
||||
hasVehicule: answers.vehicules.length > 0,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
import Link from "next/link";
|
||||
import { Logo } from "@/components/logo";
|
||||
import { mots } from "@/lib/mode";
|
||||
import { modeAcces } from "@/lib/mode-serveur";
|
||||
|
||||
/** Signature de marque (§ 2.5, § 11.3) : sœur de Kankwa, transparence sur la posture. */
|
||||
/**
|
||||
* Signature de marque (§ 2.5, § 11.3) : sœur de Kankwa, transparence sur la posture.
|
||||
*
|
||||
* Composant SERVEUR (aucune directive `"use client"`) : il peut donc lire le mode lui-même, ce
|
||||
* qui évite de le faire descendre depuis le layout racine jusqu'ici pour un seul libellé.
|
||||
*/
|
||||
export function SiteFooter() {
|
||||
// Le document contractuel n'a pas le même nom selon le mode (D-065) : on ne vend rien en
|
||||
// gratuit, donc pas de conditions de VENTE. L'URL, elle, ne bouge pas — cf. `app/cgv/page.tsx`.
|
||||
const vocabulaire = mots(modeAcces());
|
||||
return (
|
||||
<footer className="mt-24 border-t border-line bg-canvas-alt">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-5 py-10 sm:flex-row sm:items-start sm:justify-between sm:px-6">
|
||||
|
|
@ -29,7 +39,7 @@ export function SiteFooter() {
|
|||
Deux colonnes de liens depuis que les pages légales existent : les mêler au quiz et à la
|
||||
connexion aurait fait une liste de six liens sans hiérarchie, où l'on cherche les mentions
|
||||
légales aussi longtemps que si elles n'y étaient pas. Elles sont obligatoires pour un
|
||||
service payant, et c'est ici qu'on les cherche.
|
||||
service en ligne, et c'est ici qu'on les cherche.
|
||||
*/}
|
||||
<div className="flex gap-12 text-sm sm:gap-16">
|
||||
<nav className="flex flex-col gap-2">
|
||||
|
|
@ -45,7 +55,7 @@ export function SiteFooter() {
|
|||
Mentions légales
|
||||
</Link>
|
||||
<Link href="/cgv" className="text-muted no-underline hover:text-accent">
|
||||
CGV
|
||||
{vocabulaire.conditionsSigle}
|
||||
</Link>
|
||||
<Link href="/confidentialite" className="text-muted no-underline hover:text-accent">
|
||||
Confidentialité
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { A_COMPLETER, mentionsManquantes } from "@/lib/legal";
|
||||
import { modeAcces } from "@/lib/mode-serveur";
|
||||
|
||||
/**
|
||||
* Une valeur légale, ou la trace visible de son absence.
|
||||
*
|
||||
* Un champ manquant ne se tait pas : il s'affiche en jaune, sur la page publique, et devient
|
||||
* impossible à oublier avant l'ouverture du paiement. C'est délibérément inconfortable — ces pages
|
||||
* impossible à oublier avant la mise en ligne. C'est délibérément inconfortable — ces pages
|
||||
* sont les seules du site où un blanc a une conséquence juridique.
|
||||
*/
|
||||
export function Valeur({ v }: { v: string | null }) {
|
||||
|
|
@ -25,7 +26,13 @@ export function Valeur({ v }: { v: string | null }) {
|
|||
* pour le masquer, parce qu'un interrupteur aurait été actionné le jour de la mise en ligne.
|
||||
*/
|
||||
export function MentionsIncompletes() {
|
||||
const manques = mentionsManquantes();
|
||||
/*
|
||||
La liste dépend du mode (D-065) : cinq des neuf mentions ne tiennent qu'au commerce, et les
|
||||
réclamer sur un service gratuit ferait crier au défaut là où il n'y en a pas — c'est-à-dire
|
||||
apprendrait à l'éditeur à ignorer cet encart, ce qui le rendrait inutile le jour où il compte.
|
||||
*/
|
||||
const mode = modeAcces();
|
||||
const manques = mentionsManquantes(mode);
|
||||
if (manques.length === 0) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -34,10 +41,22 @@ export function MentionsIncompletes() {
|
|||
className="mb-10 rounded-lg border border-warn-line bg-warn-soft px-4 py-3 text-[0.925rem] text-warn"
|
||||
>
|
||||
<p>
|
||||
<strong>Ces mentions sont incomplètes.</strong> Il manque {manques.join(", ")}. Tant que
|
||||
c'est le cas, le service ne peut pas être vendu : les conditions ci-dessous ne
|
||||
sont pas opposables et l'éditeur n'est pas identifiable au sens de l'article
|
||||
6 III de la loi du 21 juin 2004.
|
||||
<strong>Ces mentions sont incomplètes.</strong> Il manque {manques.join(", ")}.{" "}
|
||||
{mode === "payant" ? (
|
||||
<>
|
||||
Tant que c'est le cas, le service ne peut pas être vendu : les conditions
|
||||
ci-dessous ne sont pas opposables et l'éditeur n'est pas identifiable au sens
|
||||
de l'article 6 III de la loi du 21 juin 2004.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Tant que c'est le cas, l'éditeur n'est pas identifiable au sens de
|
||||
l'article 6 III de la loi du 21 juin 2004. Les mentions propres à la vente
|
||||
(SIREN, téléphone, médiateur, régime de TVA) ne sont pas réclamées ici : le
|
||||
service est gratuit. Elles redeviennent obligatoires le jour où il cesse de
|
||||
l'être.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</aside>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ function useFoyerLocal(): string | null {
|
|||
|
||||
/**
|
||||
* Navigation globale. Volontairement minimale pour un visiteur anonyme (logo + CTA quiz) :
|
||||
* pas de "Guides" (§ 10.2 : catalogue jamais navigable pour qui n'est pas abonné) ni
|
||||
* pas de "Guides" (§ 10.2 : catalogue jamais navigable pour qui n'a pas d'accès ouvert — payé
|
||||
* ou gratuit, la porte est la même depuis D-065) ni
|
||||
* "Mon espace" (§ 6.1 : on y atterrit depuis un mail — un visiteur sans compte n'a de toute
|
||||
* façon aucun foyer à retrouver). Une fois connecté, les deux réapparaissent.
|
||||
* Sur le quiz, le CTA disparaît — jamais distraire de l'onboarding ≤ 2 min (§ 4.1).
|
||||
|
|
|
|||
|
|
@ -58,10 +58,41 @@ export const households = pgTable("households", {
|
|||
/** Réponses brutes du quiz (audit + recalcul). */
|
||||
quizAnswers: jsonb("quiz_answers").notNull(),
|
||||
/**
|
||||
* Abonnement (§ 21, § 10) — 19,99 €/an, rattaché au compte payeur (§ 6.3).
|
||||
* "free" = preview verrouillée ; "active" = full reveal + veille + rappels constructeur.
|
||||
* L'accès du foyer (§ 21, § 10) — la seule colonne qui décide de ce qui est déverrouillé.
|
||||
*
|
||||
* "free" = aperçu verrouillé ; "active" = full reveal + veille + rappels constructeur.
|
||||
*
|
||||
* **"active" ne veut pas dire « a payé » depuis D-065** : il veut dire « l'accès est ouvert ».
|
||||
* En mode payant il s'obtient par le webhook Stripe, en mode gratuit par l'inscription — et
|
||||
* c'est ce qui permet aux quinze portes du produit (`getSubscription().isActive`, le flux .ics,
|
||||
* les gestes, les rappels, le catalogue) de ne rien connaître du modèle économique. Comment
|
||||
* l'accès a été obtenu se lit sur `accesMode`, jamais ici.
|
||||
*
|
||||
* "attente" n'existe qu'en mode gratuit plafonné : le compte existe, son foyer est calculé,
|
||||
* mais rien n'est déverrouillé tant qu'une place ne s'est pas libérée.
|
||||
*/
|
||||
subscriptionStatus: text("subscription_status").notNull().default("free"), // free | active | expired | canceled
|
||||
subscriptionStatus: text("subscription_status").notNull().default("free"), // free | active | attente | expired | canceled
|
||||
/**
|
||||
* Par quelle porte l'accès a été ouvert : "payant" (Stripe) ou "gratuit" (inscription).
|
||||
* `null` pour un foyer qui n'a jamais franchi la porte — l'immense majorité.
|
||||
*
|
||||
* Sans cette colonne, le plafond de places gratuites compterait les foyers payants
|
||||
* historiques et fermerait les inscriptions au profit de gens qui ont payé. C'est aussi ce qui
|
||||
* rend la bascule réversible : repasser en payant ne touche pas les accès déjà ouverts, on sait
|
||||
* lesquels étaient offerts.
|
||||
*/
|
||||
accesMode: text("acces_mode"), // gratuit | payant
|
||||
/** Quand l'accès a été ouvert — l'horodatage du geste, quel que soit le mode. */
|
||||
accesLe: timestamp("acces_le"),
|
||||
/**
|
||||
* Quand le foyer est entré en liste d'attente. `null` = il n'y est pas (ou plus).
|
||||
*
|
||||
* C'est l'ordre d'arrivée, et donc l'ordre d'admission. Aucun rang n'est stocké : il se
|
||||
* recalcule à la lecture contre cette colonne, comme le quota de rejeux se recompte contre la
|
||||
* période payée (D-060) — on stocke ce que le foyer a fait, jamais ce qu'on en conclut.
|
||||
* Conservé après l'admission : il dit combien de temps la place a été attendue.
|
||||
*/
|
||||
attenteDepuis: timestamp("attente_depuis"),
|
||||
subscriptionPeriodEnd: timestamp("subscription_period_end"),
|
||||
/**
|
||||
* Début de la période en cours — recopié de Stripe (`item.current_period_start`).
|
||||
|
|
@ -81,6 +112,16 @@ export const households = pgTable("households", {
|
|||
* pas, mais il n'a pas le droit d'annoncer un prélèvement qui n'aura pas lieu.
|
||||
*/
|
||||
subscriptionCancelAtPeriodEnd: boolean("subscription_cancel_at_period_end").notNull().default(false),
|
||||
/**
|
||||
* Le compte qui a ouvert l'accès : celui qui a payé en mode payant, celui qui s'est inscrit en
|
||||
* mode gratuit (D-065). Le nom garde « payer » parce que la colonne existe et qu'une migration
|
||||
* de renommage coûterait plus que ce commentaire n'éclaire.
|
||||
*
|
||||
* C'est aussi le destinataire du mail « ta place s'est libérée » : sans lui, un foyer admis
|
||||
* depuis la liste d'attente n'aurait personne à prévenir. Ce n'est pas une hiérarchie pour
|
||||
* autant — les membres d'un foyer restent égaux (§ 6.3), et le portail de facturation s'ouvre
|
||||
* à n'importe lequel d'entre eux.
|
||||
*/
|
||||
subscriptionPayerId: text("subscription_payer_id").references(() => user.id),
|
||||
stripeCustomerId: text("stripe_customer_id"),
|
||||
stripeSubscriptionId: text("stripe_subscription_id"),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,17 @@ import { renderRappelEmail } from "./rappel";
|
|||
import { renderMagicLinkEmail } from "./magic-link";
|
||||
import { renderGarderLienEmail } from "./garder-lien";
|
||||
import type { LigneRappel } from "@/engine/notifications";
|
||||
import type { ContexteLienMagique } from "@/lib/lien-magique";
|
||||
|
||||
/** Tous les contextes du lien magique — la liste vit ici pour qu'un ajout oublié fasse échouer. */
|
||||
const CONTEXTES = [
|
||||
"connexion",
|
||||
"invitation",
|
||||
"activation",
|
||||
"inscription",
|
||||
"attente",
|
||||
"place-liberee",
|
||||
] as const satisfies readonly ContexteLienMagique[];
|
||||
|
||||
/**
|
||||
* Les trois gabarits, rendus pour de vrai (audit mail § 4.14 : ils n'avaient aucun test).
|
||||
|
|
@ -241,14 +252,42 @@ describe("renderMagicLinkEmail", () => {
|
|||
expect(text.toLowerCase()).toContain("membre@foyer.fr");
|
||||
});
|
||||
|
||||
it("rend les deux parties dans les trois contextes", async () => {
|
||||
for (const context of ["connexion", "invitation", "activation"] as const) {
|
||||
it("rend les deux parties dans tous les contextes", async () => {
|
||||
for (const context of CONTEXTES) {
|
||||
const { html, text } = await renderMagicLinkEmail({ url: "https://fokan.fr/x", context });
|
||||
expect(html).toContain("<html");
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
expect(text).not.toContain("<html");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Le piège que ce test ferme (D-065) : les trois contextes du mode gratuit passent par la même
|
||||
* table de messages, et « attente » est le seul de la famille à NE PAS annoncer un accès ouvert.
|
||||
* Un copier-coller depuis « inscription » produirait un message intitulé « Ton foyer est ouvert »
|
||||
* suivi d'un lien qui mène à un aperçu verrouillé — ce qui se lit comme une panne, pas comme une
|
||||
* file d'attente, et fait écrire au support quelqu'un qui n'avait aucun problème.
|
||||
*/
|
||||
it("ne promet un accès ouvert dans aucun des messages d'attente", async () => {
|
||||
const { text } = await renderMagicLinkEmail({ url: "https://fokan.fr/x", context: "attente" });
|
||||
expect(text.toLowerCase()).toContain("liste d'attente");
|
||||
expect(text.toLowerCase()).not.toMatch(/déverrouillé|est ouvert\b/);
|
||||
});
|
||||
|
||||
it("dit à l'admis pourquoi il reçoit un message qu'il n'a pas demandé", async () => {
|
||||
const { text } = await renderMagicLinkEmail({ url: "https://fokan.fr/x", context: "place-liberee" });
|
||||
expect(text.toLowerCase()).toContain("liste d'attente");
|
||||
expect(text.toLowerCase()).toContain("ouvert");
|
||||
});
|
||||
|
||||
/** Aucun contexte ne doit parler de paiement en dehors de celui qui en vient. */
|
||||
it("ne parle de paiement que dans le contexte du paiement", async () => {
|
||||
for (const context of CONTEXTES) {
|
||||
const { text } = await renderMagicLinkEmail({ url: "https://fokan.fr/x", context });
|
||||
if (context === "activation") continue;
|
||||
expect(text.toLowerCase(), context).not.toMatch(/paiement|payé|abonnement/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderGarderLienEmail", () => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { ACCENT, Bouton, MailLayout, SOURDINE, baseUrl, styleTexte, styleTitre }
|
|||
type Props = {
|
||||
url: string;
|
||||
/** Ton différent selon le contexte — jamais dans le quiz, toujours au bon moment (§ 5.6, § 6.3). */
|
||||
context: "connexion" | "invitation" | "activation";
|
||||
context: "connexion" | "invitation" | "activation" | "inscription" | "attente" | "place-liberee";
|
||||
invitedByEmail?: string;
|
||||
/**
|
||||
* L'URL durable du foyer, affichée sous le bouton quand elle est connue (audit mail § 4.4).
|
||||
|
|
@ -18,19 +18,63 @@ type Props = {
|
|||
urlFoyer?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Le titre et le corps, par contexte.
|
||||
*
|
||||
* Trois contextes sont nés avec le mode gratuit (D-065), et l'un d'eux — « attente » — est le
|
||||
* seul de la famille à ne PAS annoncer un accès ouvert. Il faut donc qu'il le dise franchement
|
||||
* dans son titre : un message intitulé « Ton foyer est ouvert » suivi d'un lien qui mène à un
|
||||
* aperçu verrouillé se lit comme une panne, pas comme une file d'attente.
|
||||
*
|
||||
* « place-liberee » est l'inverse : il arrive sans que personne ne l'ait demandé ce jour-là, et
|
||||
* doit donc rappeler en un mot pourquoi il arrive.
|
||||
*/
|
||||
const MESSAGES: Record<Props["context"], { titre: string; corps: string; bouton: string }> = {
|
||||
invitation: {
|
||||
titre: "t'invite à rejoindre son foyer sur fokan", // préfixé par l'adresse de l'invitant
|
||||
corps:
|
||||
"Un tap et tu deviens membre à part entière du foyer : tu recevras directement tes propres rappels, sans jamais passer par quelqu'un d'autre.",
|
||||
bouton: "Rejoindre le foyer",
|
||||
},
|
||||
activation: {
|
||||
titre: "Ton foyer est activé sur fokan",
|
||||
corps:
|
||||
"Le paiement est passé, ton compte existe et ton calendrier est complet. Un tap et tu y es, sans mot de passe.",
|
||||
bouton: "Voir mon foyer",
|
||||
},
|
||||
inscription: {
|
||||
titre: "Ton foyer est ouvert sur fokan",
|
||||
corps:
|
||||
"Ton compte existe et ton calendrier est complet — dates, raisons, périmètres, tout est déverrouillé. Un tap et tu y es, sans mot de passe.",
|
||||
bouton: "Voir mon foyer",
|
||||
},
|
||||
attente: {
|
||||
titre: "Tu es sur la liste d'attente de fokan",
|
||||
corps:
|
||||
"Ton compte est créé et le calendrier de ton foyer est calculé : il t'attend. Le nombre d'accès ouverts est plafonné pour l'instant, et le tien s'ouvrira dès qu'une place se libère — on t'écrira ce jour-là, tu n'as rien à surveiller.",
|
||||
bouton: "Voir mon foyer",
|
||||
},
|
||||
"place-liberee": {
|
||||
titre: "Ta place s'est libérée sur fokan",
|
||||
corps:
|
||||
"Tu étais sur la liste d'attente : c'est fait, ton accès est ouvert. Le calendrier de ton foyer est déverrouillé — dates, raisons et périmètres compris. Un tap et tu y es.",
|
||||
bouton: "Voir mon foyer",
|
||||
},
|
||||
connexion: {
|
||||
titre: "Ton lien de connexion fokan",
|
||||
corps:
|
||||
"Ce lien te connecte directement, sans mot de passe. Il expire dans quelques minutes et ne sert qu'une fois.",
|
||||
bouton: "Me connecter",
|
||||
},
|
||||
};
|
||||
|
||||
function MagicLinkEmail({ url, context, invitedByEmail, urlFoyer }: Props) {
|
||||
const message = MESSAGES[context] ?? MESSAGES.connexion;
|
||||
const titre =
|
||||
context === "invitation"
|
||||
? `${invitedByEmail ?? "Quelqu'un"} t'invite à rejoindre son foyer sur fokan`
|
||||
: context === "activation"
|
||||
? "Ton foyer est activé sur fokan"
|
||||
: "Ton lien de connexion fokan";
|
||||
const corps =
|
||||
context === "invitation"
|
||||
? "Un tap et tu deviens membre à part entière du foyer : tu recevras directement tes propres rappels, sans jamais passer par quelqu'un d'autre."
|
||||
: context === "activation"
|
||||
? "Le paiement est passé, ton compte existe et ton calendrier est complet. Un tap et tu y es, sans mot de passe."
|
||||
: "Ce lien te connecte directement, sans mot de passe. Il expire dans quelques minutes et ne sert qu'une fois.";
|
||||
? `${invitedByEmail ?? "Quelqu'un"} ${message.titre}`
|
||||
: message.titre;
|
||||
const corps = message.corps;
|
||||
|
||||
return (
|
||||
<MailLayout apercu={titre}>
|
||||
|
|
@ -43,9 +87,7 @@ function MagicLinkEmail({ url, context, invitedByEmail, urlFoyer }: Props) {
|
|||
même écran. Le bouton ouvre la session, la ligne dessous dit ce qui reste après.
|
||||
*/}
|
||||
<Section style={{ margin: "22px 0 0" }}>
|
||||
<Bouton href={url}>
|
||||
{context === "invitation" ? "Rejoindre le foyer" : context === "activation" ? "Voir mon foyer" : "Me connecter"}
|
||||
</Bouton>
|
||||
<Bouton href={url}>{message.bouton}</Bouton>
|
||||
</Section>
|
||||
{urlFoyer ? (
|
||||
<Text className="sourdine" style={{ color: SOURDINE, fontSize: 13, lineHeight: 1.6, margin: "12px 0 0" }}>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,12 @@
|
|||
* fait, jamais ce qu'on en conclut.
|
||||
*/
|
||||
|
||||
/** Cinq mises à jour par période payée (D-060). La seule formule qui existe dure un an (§ 10.1). */
|
||||
/**
|
||||
* Cinq mises à jour par période (D-060). La seule formule payante qui existe dure un an (§ 10.1),
|
||||
* et le repli en année glissante ci-dessous donne la même durée à un accès gratuit, qui n'a pas
|
||||
* de période — c'est ce qui permet aux messages de cette page de ne parler ni d'abonnement ni de
|
||||
* gratuité (D-065). Ils disent « par an » et « la prochaine période », ce qui est vrai des deux.
|
||||
*/
|
||||
export const REJEUX_PAR_PERIODE = 5;
|
||||
|
||||
/** Repli quand aucune borne de période n'est connue : une année glissante (cf. `quotaRejeu`). */
|
||||
|
|
@ -55,9 +60,11 @@ export type QuotaRejeu = {
|
|||
* donc cinq passages sans qu'aucun code ne s'exécute au moment de la reconduction.
|
||||
*
|
||||
* **Repli sur une année glissante quand la période est inconnue** (`debut` nul) : les foyers
|
||||
* abonnés avant le 05/08/2026 n'ont ni `period_start` ni forcément `period_end`, et compter alors
|
||||
* *tous* les rejeux depuis toujours condamnerait le plus ancien des foyers à cinq mises à jour à
|
||||
* vie. On borne à un an — la durée réelle de la seule formule (§ 10.1) —, et le quota repart un an
|
||||
* abonnés avant le 05/08/2026 n'ont ni `period_start` ni forcément `period_end` — et c'est aussi
|
||||
* le cas de tout accès gratuit, qui n'a par construction aucune période (D-065). Compter alors
|
||||
* *tous* les rejeux depuis toujours condamnerait ces foyers-là à cinq mises à jour à vie ; c'est
|
||||
* précisément pourquoi `debutHistorique` existe à part de `debutPeriodeCouverte` et pourquoi
|
||||
* cette fonction-ci reçoit la seconde. On borne à un an — la durée réelle de la seule formule (§ 10.1) —, et le quota repart un an
|
||||
* après le plus vieux rejeu retenu, ce qui est la lecture la plus favorable qu'on puisse tenir sans
|
||||
* inventer une date de facturation.
|
||||
*/
|
||||
|
|
@ -88,7 +95,7 @@ export function quotaRejeu(
|
|||
function reprise(repartLe: Date | null): string {
|
||||
return repartLe
|
||||
? `Le compteur repart le ${repartLe.toLocaleDateString("fr-FR")}.`
|
||||
: "Le compteur repart à la prochaine période d'abonnement.";
|
||||
: "Le compteur repart à la prochaine période.";
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -96,7 +103,7 @@ function reprise(repartLe: Date | null): string {
|
|||
* mal en mettant ses déclarations à jour cinq fois.
|
||||
*/
|
||||
export function messageQuotaEpuise(repartLe: Date | null): string {
|
||||
return `${REJEUX_PAR_PERIODE} mises à jour par année d'abonnement, et elles sont faites. ${reprise(repartLe)}`;
|
||||
return `${REJEUX_PAR_PERIODE} mises à jour par an, et elles sont faites. ${reprise(repartLe)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -107,5 +114,5 @@ export function messageQuotaRestant(quota: QuotaRejeu): string {
|
|||
const s = quota.restants > 1 ? "s" : "";
|
||||
return quota.repartLe
|
||||
? `Il te reste ${quota.restants} mise${s} à jour d'ici le ${quota.repartLe.toLocaleDateString("fr-FR")}.`
|
||||
: `Il te reste ${quota.restants} mise${s} à jour sur cette période d'abonnement.`;
|
||||
: `Il te reste ${quota.restants} mise${s} à jour sur cette période.`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export async function register() {
|
|||
|
||||
const { reconcileAllHouseholds } = await import("@/engine/reconcile-db");
|
||||
const { purgerLecturesGuides } = await import("@/lib/guide-acces");
|
||||
const { promouvoirListeAttente } = await import("@/lib/inscription");
|
||||
const { db, events } = await import("@/db");
|
||||
const { getBoss } = await import("@/jobs/boss");
|
||||
|
||||
|
|
@ -36,10 +37,27 @@ export async function register() {
|
|||
console.error("[cron] purge des lectures de guides", e);
|
||||
return { supprimees: -1 };
|
||||
});
|
||||
/*
|
||||
L'admission de la liste d'attente (D-065), pour la même raison que la purge ci-dessus : elle
|
||||
dure quelques millisecondes et un quatrième passage planifié pour un `UPDATE` sur quelques
|
||||
dizaines de lignes serait un rouage de plus à surveiller pour rien.
|
||||
|
||||
C'est ICI que se traduit un relèvement de `FOKAN_PLACES_GRATUITES` : la variable est relue à
|
||||
chaque exécution, et la différence entre plafond et occupants est admise dans l'ordre
|
||||
d'arrivée. Rien à déclencher à la main, rien à écrire pour rehausser la limite.
|
||||
|
||||
En mode payant, ou sans plafond, la fonction rend `{ admis: 0 }` sans rien écrire — elle
|
||||
reste appelée pour que le heartbeat montre l'état de la file, y compris quand il est vide.
|
||||
Son échec ne doit pas emporter la réconciliation, qui est le passage de sécurité du § 20.4.
|
||||
*/
|
||||
const attente = await promouvoirListeAttente().catch((e) => {
|
||||
console.error("[cron] admission de la liste d'attente", e);
|
||||
return { admis: -1, restantes: null, enAttente: -1 };
|
||||
});
|
||||
// Heartbeat léger (§ 24.2 point 3) : pas encore d'UptimeRobot, mais la trace existe et
|
||||
// est requêtable — "le job n'a pas pingé depuis 3 jours" devient une simple requête SQL.
|
||||
await db().insert(events).values({ name: "cron_reconcile_daily", props: { ...summary, lectures } });
|
||||
console.log("[cron] réconciliation quotidienne", summary, "purge lectures", lectures);
|
||||
await db().insert(events).values({ name: "cron_reconcile_daily", props: { ...summary, lectures, attente } });
|
||||
console.log("[cron] réconciliation quotidienne", summary, "purge lectures", lectures, "liste d'attente", attente);
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -33,9 +33,11 @@ import {
|
|||
* la ligne d'outbox s'écrit APRÈS l'envoi, sinon une panne SMTP rendrait le foyer muet pour de bon.
|
||||
*
|
||||
* Deux portes fermées avant toute chose :
|
||||
* - l'abonnement. Les rappels au bon moment SONT le produit payant (§ 10.2) ; un foyer gratuit a
|
||||
* son aperçu de hub et rien d'autre. C'est aussi ce qui garde l'infrastructure d'envoi petite
|
||||
* et sa réputation propre (§ 26) ;
|
||||
* - **l'accès ouvert.** Les rappels au bon moment sont le cœur de ce que la porte ouvre (§ 10.2) ;
|
||||
* un foyer resté à l'aperçu — ou en liste d'attente — a son hub compté et rien d'autre. C'est
|
||||
* aussi ce qui garde l'infrastructure d'envoi petite et sa réputation propre (§ 26). Depuis
|
||||
* D-065, cette porte ne connaît plus le modèle économique : elle teste `subscription_status`,
|
||||
* lequel vaut "active" qu'on ait payé ou qu'on se soit inscrit gratuitement ;
|
||||
* - un destinataire. Un foyer sans membre n'a personne à qui écrire — cas majoritaire aujourd'hui,
|
||||
* l'immense majorité des foyers venant du quiz sans jamais avoir créé de compte.
|
||||
*/
|
||||
|
|
@ -48,7 +50,12 @@ const ENTRE_FOYERS_MS = 500;
|
|||
type Destinataire = { userId: string; email: string };
|
||||
|
||||
/**
|
||||
* L'abonnement est-il ouvert aujourd'hui ?
|
||||
* L'accès est-il ouvert aujourd'hui ?
|
||||
*
|
||||
* Le nom dit encore « abonnement » et c'est assumé : c'est la même question depuis D-065, et
|
||||
* renommer une fonction exportée qu'un test et deux scripts appellent n'aurait rien clarifié
|
||||
* qu'un commentaire ne dise mieux. Un accès gratuit n'a pas de `subscription_period_end`, donc
|
||||
* le filet de date le laisse passer indéfiniment — ce qui est exactement sa sémantique.
|
||||
*
|
||||
* Le même filet de date que `getSubscription` : un `active` dont la période est passée est traité
|
||||
* comme inactif partout dans le produit. Extrait en fonction pure pour deux raisons — il devient
|
||||
|
|
@ -370,10 +377,16 @@ export async function rappelsQuotidiens(aujourdhui = new Date()): Promise<{
|
|||
/**
|
||||
* Le même filet de date que `getSubscription` — qui manquait ici.
|
||||
*
|
||||
* Le statut vient de Stripe par webhook, et le reste du produit ne s'y fie jamais seul : un
|
||||
* `active` dont la période est passée est traité comme inactif partout ailleurs (accès au
|
||||
* calendrier, flux .ics). Le seul endroit qui l'ignorait était celui qui parle à l'extérieur —
|
||||
* un webhook manqué à l'échéance et le foyer continuait de recevoir des mails qu'il ne paie plus.
|
||||
* Le statut vient du webhook Stripe en mode payant, de l'inscription en mode gratuit (D-065),
|
||||
* et le reste du produit ne s'y fie jamais seul : un `active` dont la période est passée est
|
||||
* traité comme inactif partout ailleurs (accès au calendrier, flux .ics). Le seul endroit qui
|
||||
* l'ignorait était celui qui parle à l'extérieur — un webhook manqué à l'échéance et le foyer
|
||||
* continuait de recevoir des mails qu'il ne paie plus. Un accès gratuit n'a pas de date de fin :
|
||||
* la branche `isNull` ci-dessous est la sienne, et elle existait déjà.
|
||||
*
|
||||
* Les foyers en liste d'attente ne sont pas parcourus non plus, sans clause dédiée : leur statut
|
||||
* vaut "attente", donc l'égalité à "active" les écarte. C'est le bénéfice d'avoir fait passer
|
||||
* l'attente par le statut plutôt que par une table à part.
|
||||
*/
|
||||
const foyers = await db()
|
||||
.select({ id: householdsTable.id })
|
||||
|
|
|
|||
|
|
@ -46,9 +46,17 @@ export const auth = betterAuth({
|
|||
sendMagicLink: async ({ email, url, metadata }) => {
|
||||
const { context, invitedByEmail, urlFoyer } = lireMetadata(metadata);
|
||||
const { html, text } = await renderMagicLinkEmail({ url, context, invitedByEmail, urlFoyer });
|
||||
/*
|
||||
Le `Record` est exhaustif par construction : TypeScript refuse la compilation si un
|
||||
contexte est ajouté au type sans son objet. C'est voulu — un contexte oublié ici
|
||||
produirait un sujet `undefined`, donc un mail sans objet, donc un mail en spam.
|
||||
*/
|
||||
const subjects: Record<ContexteLienMagique, string> = {
|
||||
invitation: "Tu es invité·e à rejoindre un foyer sur fokan",
|
||||
activation: "Ton foyer est activé — accède à ton calendrier",
|
||||
inscription: "Ton foyer est ouvert — accède à ton calendrier",
|
||||
attente: "Tu es sur la liste d'attente de fokan",
|
||||
"place-liberee": "Ta place s'est libérée — ton calendrier est ouvert",
|
||||
connexion: "Ton lien de connexion fokan",
|
||||
};
|
||||
const { sent } = await sendMail({ to: email, subject: subjects[context], html, text });
|
||||
|
|
|
|||
50
src/lib/compte-foyer.ts
Normal file
50
src/lib/compte-foyer.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { and, eq } from "drizzle-orm";
|
||||
import { db, memberships as membershipsTable } from "@/db";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
/**
|
||||
* Rattacher une adresse à un foyer : trouver ou créer le compte, l'inscrire membre.
|
||||
*
|
||||
* Ces vingt lignes vivaient dans le webhook Stripe (D-049) et ont été extraites ici quand la
|
||||
* porte gratuite est apparue (D-065) : deux copies auraient divergé au premier correctif, et
|
||||
* c'est le genre de divergence qui se solde par un foyer payé sans destinataire de rappel — le
|
||||
* défaut exact que D-049 avait corrigé.
|
||||
*
|
||||
* **On passe par l'adaptateur interne de better-auth plutôt que par un `insert` maison** : c'est
|
||||
* lui qui fabrique l'identifiant, joue les hooks et respecte le schéma.
|
||||
*
|
||||
* `emailVerified` est passé par l'appelant parce que la preuve n'est pas la même des deux côtés.
|
||||
* Stripe vient d'envoyer un reçu à cette adresse, ce qui vaut vérification ; une adresse tapée
|
||||
* dans un formulaire ne prouve rien tant que le lien magique n'a pas été suivi. Marquer la
|
||||
* seconde comme vérifiée ferait dire au produit qu'il a constaté quelque chose qu'il n'a pas
|
||||
* constaté.
|
||||
*
|
||||
* **Contrepartie assumée, identique dans les deux modes** : quelqu'un qui donne l'adresse d'un
|
||||
* tiers rattache ce foyer au compte de ce tiers. C'est un ajout indésirable, jamais une prise de
|
||||
* contrôle — le tiers reçoit le lien, pas l'inscrivant —, et il ne donne accès qu'à un foyer que
|
||||
* l'inscrivant a lui-même rempli.
|
||||
*/
|
||||
export async function rattacherCompteAuFoyer(
|
||||
householdId: string,
|
||||
email: string,
|
||||
{ emailVerified }: { emailVerified: boolean },
|
||||
): Promise<{ userId: string } | null> {
|
||||
const ctx = await auth.$context;
|
||||
const compte =
|
||||
(await ctx.internalAdapter.findUserByEmail(email))?.user ??
|
||||
(await ctx.internalAdapter.createUser({ email, emailVerified, name: "" }));
|
||||
if (!compte) return null;
|
||||
|
||||
const database = db();
|
||||
const [dejaMembre] = await database
|
||||
.select({ id: membershipsTable.id })
|
||||
.from(membershipsTable)
|
||||
.where(and(eq(membershipsTable.householdId, householdId), eq(membershipsTable.userId, compte.id)));
|
||||
|
||||
// Idempotent : la route d'inscription se rejoue (double clic, retour arrière, reprise de file),
|
||||
// et une seconde appartenance sur la même paire ferait apparaître le même membre deux fois
|
||||
// dans la colonne « Membres du foyer ».
|
||||
if (!dejaMembre) await database.insert(membershipsTable).values({ householdId, userId: compte.id });
|
||||
|
||||
return { userId: compte.id };
|
||||
}
|
||||
261
src/lib/inscription.ts
Normal file
261
src/lib/inscription.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import { and, asc, eq, isNotNull } from "drizzle-orm";
|
||||
import { db, households as householdsTable, user as userTable } from "@/db";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { metadataInterne } from "@/lib/lien-magique";
|
||||
import { rattacherCompteAuFoyer } from "@/lib/compte-foyer";
|
||||
import { estGratuit, placesGratuites } from "@/lib/mode-serveur";
|
||||
import { etatPlaces, longueurListeAttente, rangEnAttente } from "@/lib/places";
|
||||
|
||||
/**
|
||||
* L'inscription gratuite et sa liste d'attente (D-065).
|
||||
*
|
||||
* **La conception tient en une phrase : il n'y a pas de liste d'attente.** Il y a un foyer dont
|
||||
* `subscription_status` vaut "attente" au lieu de "active", et un passage quotidien qui bascule
|
||||
* les plus anciens quand il reste de la place. Aucune table, aucune file, aucun rang stocké —
|
||||
* la seule chose qu'on ajoute au produit est une valeur de statut et une colonne de date.
|
||||
*
|
||||
* C'est ce qui rend la bascule inverse gratuite : repasser en payant ne demande de supprimer
|
||||
* aucun objet, seulement de cesser d'admettre.
|
||||
*
|
||||
* **Ce que « compte latent » veut dire ici, précisément** : le compte existe pour de bon (il est
|
||||
* dans `user`, il est membre du foyer, il peut se connecter, son quiz a tourné et ses échéances
|
||||
* sont calculées) ; ce qui n'est pas ouvert, c'est le déverrouillage. Un foyer en attente est
|
||||
* exactement dans l'état d'un foyer gratuit d'avant — l'aperçu, rien de plus — à ceci près qu'il
|
||||
* n'a plus rien à faire pour en sortir.
|
||||
*/
|
||||
|
||||
export type ResultatInscription =
|
||||
/** L'accès est ouvert, tout de suite. */
|
||||
| { statut: "ouvert" }
|
||||
/** Plus de place : le compte existe, le foyer attend son tour. */
|
||||
| { statut: "attente"; rang: number }
|
||||
/** Le foyer était déjà ouvert — un second clic, un retour arrière. Rien à faire, pas une erreur. */
|
||||
| { statut: "deja-ouvert" };
|
||||
|
||||
/**
|
||||
* Ouvre l'accès gratuit d'un foyer, ou le met en attente si le plafond est atteint.
|
||||
*
|
||||
* **La décision se prend à l'écriture, une seule fois**, et non à chaque affichage : le foyer
|
||||
* porte ensuite son statut, et une place qui se libérerait plus tard ne remet pas en cause
|
||||
* l'ordre d'arrivée de ceux qui attendent déjà. Le contraire — décider à la lecture — ferait
|
||||
* gagner la place à celui qui recharge sa page le plus souvent.
|
||||
*
|
||||
* Le lien magique part dans les deux cas. Pour un foyer en attente, c'est même l'essentiel : sans
|
||||
* lui, un compte latent serait un compte que personne ne peut retrouver.
|
||||
*/
|
||||
export async function inscrireFoyer({
|
||||
householdId,
|
||||
email,
|
||||
entetes,
|
||||
}: {
|
||||
householdId: string;
|
||||
email: string;
|
||||
entetes: Headers;
|
||||
}): Promise<ResultatInscription> {
|
||||
const database = db();
|
||||
|
||||
const [foyer] = await database
|
||||
.select({ statut: householdsTable.subscriptionStatus })
|
||||
.from(householdsTable)
|
||||
.where(eq(householdsTable.id, householdId));
|
||||
if (!foyer) throw new Error("foyer introuvable");
|
||||
|
||||
const compte = await rattacherCompteAuFoyer(householdId, email, {
|
||||
/*
|
||||
Jamais vérifié ici, contrairement au chemin Stripe : une adresse tapée dans un formulaire
|
||||
n'a été prouvée par personne. Elle le devient au premier lien magique suivi, ce qui est le
|
||||
geste que ce mail demande de toute façon.
|
||||
*/
|
||||
emailVerified: false,
|
||||
});
|
||||
|
||||
const base = process.env.NEXT_PUBLIC_SITE_URL ?? "https://fokan.fr";
|
||||
const urlFoyer = `${base}/foyer/${householdId}`;
|
||||
|
||||
// Déjà ouvert : on ne réécrit ni le statut ni la date d'ouverture — l'état utilisateur est
|
||||
// inviolable (invariant n° 3), et « acces_le » est une date d'histoire, pas un compteur.
|
||||
if (foyer.statut === "active") {
|
||||
await envoyerLien(email, householdId, urlFoyer, "connexion", entetes);
|
||||
return { statut: "deja-ouvert" };
|
||||
}
|
||||
|
||||
// Déjà en attente : même chose, on renvoie seulement le lien et son rang. Réécrire
|
||||
// `attente_depuis` ferait reculer dans la file quelqu'un qui redemande son lien.
|
||||
if (foyer.statut === "attente") {
|
||||
await envoyerLien(email, householdId, urlFoyer, "attente", entetes);
|
||||
return { statut: "attente", rang: (await rangEnAttente(householdId)) ?? 1 };
|
||||
}
|
||||
|
||||
const places = await etatPlaces();
|
||||
const maintenant = new Date();
|
||||
|
||||
if (places.ouvert) {
|
||||
await database
|
||||
.update(householdsTable)
|
||||
.set({
|
||||
subscriptionStatus: "active",
|
||||
accesMode: "gratuit",
|
||||
accesLe: maintenant,
|
||||
subscriptionPayerId: compte?.userId ?? null,
|
||||
// Aucune date de fin : c'est ce qui rend l'accès permanent au sens de `getSubscription`.
|
||||
// Ce n'est pas un oubli, c'est la sémantique du mode gratuit.
|
||||
subscriptionPeriodEnd: null,
|
||||
subscriptionPeriodStart: null,
|
||||
subscriptionCancelAtPeriodEnd: false,
|
||||
})
|
||||
.where(eq(householdsTable.id, householdId));
|
||||
|
||||
await envoyerLien(email, householdId, urlFoyer, "inscription", entetes);
|
||||
return { statut: "ouvert" };
|
||||
}
|
||||
|
||||
await database
|
||||
.update(householdsTable)
|
||||
.set({
|
||||
subscriptionStatus: "attente",
|
||||
accesMode: "gratuit",
|
||||
attenteDepuis: maintenant,
|
||||
subscriptionPayerId: compte?.userId ?? null,
|
||||
})
|
||||
.where(eq(householdsTable.id, householdId));
|
||||
|
||||
await envoyerLien(email, householdId, urlFoyer, "attente", entetes);
|
||||
return { statut: "attente", rang: (await rangEnAttente(householdId)) ?? 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Le lien magique, dont l'échec n'emporte jamais l'inscription.
|
||||
*
|
||||
* Même règle que le webhook Stripe : un mail de bienvenue manqué ne doit pas faire échouer
|
||||
* l'ouverture d'accès elle-même. L'adresse durable du foyer voyage avec lui (audit mail § 4.4).
|
||||
*/
|
||||
async function envoyerLien(
|
||||
email: string,
|
||||
householdId: string,
|
||||
urlFoyer: string,
|
||||
context: "inscription" | "attente" | "connexion",
|
||||
entetes: Headers,
|
||||
): Promise<void> {
|
||||
await auth.api
|
||||
.signInMagicLink({
|
||||
body: {
|
||||
email,
|
||||
callbackURL: `/foyer/${householdId}`,
|
||||
metadata: metadataInterne(context, { urlFoyer }),
|
||||
},
|
||||
headers: entetes,
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("[inscription] envoi du lien impossible :", String(e));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Le passage quotidien qui admet la liste d'attente — la « mécanique quand la limite est
|
||||
* rehaussée ».
|
||||
*
|
||||
* Il n'y a rien à déclencher quand on relève `FOKAN_PLACES_GRATUITES` : la variable est relue à
|
||||
* chaque exécution, le passage de 4 h compare places et occupants, et admet la différence dans
|
||||
* l'ordre d'arrivée. Un plafond relevé de 100 à 150 se traduit par cinquante admissions le
|
||||
* lendemain matin, sans commande à taper ni bouton d'administration à construire.
|
||||
*
|
||||
* **Idempotent et rejouable** (invariant n° 2) : il ne lit que l'état courant et n'écrit que sur
|
||||
* les foyers qu'il admet. Rejoué dans la minute, il ne trouve plus de place et ne fait rien.
|
||||
*
|
||||
* **Pourquoi le quotidien plutôt que l'immédiat** : admettre à la lecture donnerait la place à
|
||||
* qui recharge sa page le plus souvent, et admettre au moment où une place se libère supposerait
|
||||
* qu'on sache quand elle se libère — or rien ne ferme un accès gratuit aujourd'hui. La seule
|
||||
* chose qui bouge est le plafond, et il bouge quand un humain édite un fichier d'environnement.
|
||||
* Un passage par jour est exactement à l'échelle de cet événement-là.
|
||||
*/
|
||||
export async function promouvoirListeAttente(
|
||||
{ maintenant = new Date() }: { maintenant?: Date } = {},
|
||||
): Promise<{ admis: number; restantes: number | null; enAttente: number }> {
|
||||
if (!estGratuit() || placesGratuites() === null) {
|
||||
// Pas de plafond, ou mode payant : il ne peut y avoir personne en attente, et s'il en reste
|
||||
// d'un mode précédent on ne les admet pas en douce — la bascule est une décision, pas un
|
||||
// effet de bord. Le compte est rendu pour que le heartbeat le montre.
|
||||
return { admis: 0, restantes: null, enAttente: await longueurListeAttente() };
|
||||
}
|
||||
|
||||
const places = await etatPlaces();
|
||||
if (places.restantes === null || places.restantes === 0) {
|
||||
return { admis: 0, restantes: places.restantes, enAttente: await longueurListeAttente() };
|
||||
}
|
||||
|
||||
const database = db();
|
||||
const candidats = await database
|
||||
.select({ id: householdsTable.id, payeur: householdsTable.subscriptionPayerId })
|
||||
.from(householdsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(householdsTable.subscriptionStatus, "attente"),
|
||||
isNotNull(householdsTable.attenteDepuis),
|
||||
),
|
||||
)
|
||||
// L'ordre d'arrivée, la même colonne que celle qui sert à afficher le rang : les deux ne
|
||||
// peuvent donc pas se contredire à l'écran.
|
||||
.orderBy(asc(householdsTable.attenteDepuis))
|
||||
.limit(places.restantes);
|
||||
|
||||
for (const candidat of candidats) {
|
||||
await database
|
||||
.update(householdsTable)
|
||||
.set({ subscriptionStatus: "active", accesMode: "gratuit", accesLe: maintenant })
|
||||
/*
|
||||
`attente_depuis` est conservé volontairement : il dit combien de temps la place a été
|
||||
attendue, ce qui est la seule mesure qu'on ait de l'effet du plafond. Le foyer n'est plus
|
||||
« en attente » parce que son statut ne le dit plus, pas parce qu'on a effacé sa date.
|
||||
*/
|
||||
.where(and(eq(householdsTable.id, candidat.id), eq(householdsTable.subscriptionStatus, "attente")))
|
||||
.catch((e) => {
|
||||
console.error("[liste-attente] admission impossible pour", candidat.id, String(e));
|
||||
});
|
||||
|
||||
await previenirAdmission(candidat.id, candidat.payeur);
|
||||
}
|
||||
|
||||
return {
|
||||
admis: candidats.length,
|
||||
restantes: Math.max(0, places.restantes - candidats.length),
|
||||
enAttente: await longueurListeAttente(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Le mail « ta place s'est libérée ».
|
||||
*
|
||||
* C'est le seul point où la liste d'attente parle à l'extérieur, et il est indispensable : sans
|
||||
* lui, un compte latent admis six semaines plus tard ne l'apprendrait qu'en revenant de lui-même
|
||||
* sur un site qu'il a peut-être oublié — soit exactement le contraire de la promesse du produit
|
||||
* (« ça marche pour quelqu'un qui a oublié que le service existe »).
|
||||
*
|
||||
* Ce n'est pas un sixième message au sens de l'audit mail : c'est un contexte de plus du lien
|
||||
* magique, qui en portait déjà trois.
|
||||
*/
|
||||
async function previenirAdmission(householdId: string, userId: string | null): Promise<void> {
|
||||
if (!userId) return; // Sans compte rattaché, il n'y a personne à prévenir — et rien à réparer ici.
|
||||
|
||||
const [compte] = await db()
|
||||
.select({ email: userTable.email })
|
||||
.from(userTable)
|
||||
.where(eq(userTable.id, userId));
|
||||
if (!compte) return;
|
||||
|
||||
const base = process.env.NEXT_PUBLIC_SITE_URL ?? "https://fokan.fr";
|
||||
await auth.api
|
||||
.signInMagicLink({
|
||||
body: {
|
||||
email: compte.email,
|
||||
callbackURL: `/foyer/${householdId}`,
|
||||
metadata: metadataInterne("place-liberee", { urlFoyer: `${base}/foyer/${householdId}` }),
|
||||
},
|
||||
// Le passage tourne dans le cron : aucune requête entrante, donc aucun en-tête à relayer.
|
||||
headers: new Headers(),
|
||||
})
|
||||
.catch((e) => {
|
||||
// L'admission est déjà écrite : un mail manqué ne doit pas la défaire, et le foyer verra
|
||||
// son accès ouvert à sa prochaine visite de toute façon.
|
||||
console.error("[liste-attente] envoi du lien d'admission impossible :", String(e));
|
||||
});
|
||||
}
|
||||
|
|
@ -118,7 +118,13 @@ export const EDITEUR = {
|
|||
*/
|
||||
mediateur: mediateurNom && mediateurUrl ? { nom: mediateurNom, url: mediateurUrl } : null,
|
||||
|
||||
/** Le prix, tenu ici pour que CGV et encart de paiement ne puissent pas diverger. */
|
||||
/**
|
||||
* Le prix, tenu ici pour que CGV et encart de paiement ne puissent pas diverger.
|
||||
*
|
||||
* Il ne s'affiche qu'en mode payant (D-065) — le mot lui-même vient de `lib/mode.ts`, qui le
|
||||
* recopie. Deux sources pour un prix serait exactement le défaut que ce fichier existe pour
|
||||
* empêcher ; le test `mode.test.ts` tient l'accord des deux.
|
||||
*/
|
||||
prixAnnuelTtc: "19,99 €",
|
||||
} as const;
|
||||
|
||||
|
|
@ -138,22 +144,46 @@ export const MAJ = {
|
|||
export const A_COMPLETER = "à compléter";
|
||||
|
||||
/**
|
||||
* Ce qui manque encore pour encaisser légalement.
|
||||
* Ce qui manque encore pour être en règle — et ce n'est pas la même liste selon qu'on vend.
|
||||
*
|
||||
* Sert l'encart d'avertissement en haut des trois pages : `Valeur` signale un trou là où il
|
||||
* tombe, mais quelqu'un qui ouvre les CGV ne voit pas ceux des mentions légales. La liste est
|
||||
* donc calculée une fois et affichée partout, en toutes lettres.
|
||||
* tombe, mais quelqu'un qui ouvre les conditions ne voit pas ceux des mentions légales. La liste
|
||||
* est donc calculée une fois et affichée partout, en toutes lettres.
|
||||
*
|
||||
* **Cinq des neuf mentions ne tiennent qu'au commerce** (D-065), et les afficher comme
|
||||
* manquantes sur un service gratuit serait faux au sens strict — pas seulement inutile :
|
||||
*
|
||||
* - `siren` : l'art. 6 III-1 LCEN l'exige de l'éditeur **professionnel**. Un service qu'on
|
||||
* n'exploite pas à titre lucratif n'y est pas tenu par ce texte-là. (Le jour où le gratuit
|
||||
* devient une activité professionnelle par d'autres voies — publicité, données —, la question
|
||||
* se repose ; le produit s'interdit précisément ces deux voies.)
|
||||
* - `telephone` : l'art. L221-5 du Code de la consommation, qui l'impose, régit la **vente** à
|
||||
* distance. Sans vente, pas d'obligation.
|
||||
* - `mediateur` : l'art. L616-1 vise « tout professionnel » dans ses litiges avec des
|
||||
* consommateurs, c'est-à-dire des relations contractuelles à titre onéreux. Une adhésion à un
|
||||
* médiateur est de surcroît payante — la réclamer pour un service gratuit reviendrait à faire
|
||||
* payer la gratuité.
|
||||
* - `regimeTva` : il n'y a pas de prix à qualifier.
|
||||
* - `rcs` / `capital` : déjà conditionnés à la forme juridique, inchangés.
|
||||
*
|
||||
* **Ce qui reste dû dans les deux modes** : identité, siège, responsable de la publication,
|
||||
* contact et hébergeur. L'art. 6 III-1 LCEN les impose à tout éditeur professionnel, et le III-2
|
||||
* — le régime allégé des éditeurs non professionnels — ne joue pas ici : il suppose qu'un
|
||||
* hébergeur TIERS détienne l'identité, alors que le service est auto-hébergé (§ 22).
|
||||
*/
|
||||
export function mentionsManquantes(): string[] {
|
||||
export function mentionsManquantes(mode: "gratuit" | "payant"): string[] {
|
||||
const manques: string[] = [];
|
||||
if (!EDITEUR.identite) manques.push("l'identité de l'éditeur");
|
||||
if (!EDITEUR.siege) manques.push("l'adresse du siège");
|
||||
if (!EDITEUR.siren) manques.push("le numéro SIREN");
|
||||
if (!EDITEUR.telephone) manques.push("le téléphone");
|
||||
if (!EDITEUR.email) manques.push("l'adresse de contact");
|
||||
if (!EDITEUR.responsablePublication) manques.push("le responsable de la publication");
|
||||
if (!EDITEUR.hebergeur.nom || !EDITEUR.hebergeur.adresse) manques.push("l'hébergeur");
|
||||
if (!EDITEUR.mediateur) manques.push("le médiateur de la consommation");
|
||||
if (!EDITEUR.regimeTva) manques.push("le régime de TVA");
|
||||
|
||||
if (mode === "payant") {
|
||||
if (!EDITEUR.siren) manques.push("le numéro SIREN");
|
||||
if (!EDITEUR.telephone) manques.push("le téléphone");
|
||||
if (!EDITEUR.mediateur) manques.push("le médiateur de la consommation");
|
||||
if (!EDITEUR.regimeTva) manques.push("le régime de TVA");
|
||||
}
|
||||
return manques;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,29 @@
|
|||
* son import.
|
||||
*/
|
||||
|
||||
export type ContexteLienMagique = "connexion" | "invitation" | "activation";
|
||||
/**
|
||||
* Les contextes légitimes. Les trois derniers sont ceux du mode gratuit (D-065) — et ils sont
|
||||
* soumis exactement au même sceau que les autres, pour la même raison : sans lui, n'importe qui
|
||||
* pourrait faire partir depuis `noreply@fokan.fr` un « ta place s'est libérée » avec un lien de
|
||||
* connexion qui fonctionne.
|
||||
*/
|
||||
export type ContexteLienMagique =
|
||||
| "connexion"
|
||||
| "invitation"
|
||||
| "activation"
|
||||
| "inscription"
|
||||
| "attente"
|
||||
| "place-liberee";
|
||||
|
||||
/** L'ensemble des contextes qu'on accepte de lire, dérivé du type pour qu'aucun n'y manque. */
|
||||
const CONTEXTES: ReadonlySet<string> = new Set<ContexteLienMagique>([
|
||||
"connexion",
|
||||
"invitation",
|
||||
"activation",
|
||||
"inscription",
|
||||
"attente",
|
||||
"place-liberee",
|
||||
]);
|
||||
|
||||
export type MetadataLienMagique = {
|
||||
context: ContexteLienMagique;
|
||||
|
|
@ -56,7 +78,9 @@ export function lireMetadata(metadata: Record<string, unknown> | undefined): Met
|
|||
if (!metadata || metadata.sceau !== sceauInterne()) return NEUTRE;
|
||||
|
||||
const context: ContexteLienMagique =
|
||||
metadata.context === "invitation" || metadata.context === "activation" ? metadata.context : "connexion";
|
||||
typeof metadata.context === "string" && CONTEXTES.has(metadata.context)
|
||||
? (metadata.context as ContexteLienMagique)
|
||||
: "connexion";
|
||||
return {
|
||||
context,
|
||||
invitedByEmail: typeof metadata.invitedByEmail === "string" ? metadata.invitedByEmail : undefined,
|
||||
|
|
|
|||
97
src/lib/mode-serveur.test.ts
Normal file
97
src/lib/mode-serveur.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { estGratuit, modeAcces, placesGratuites } from "./mode-serveur";
|
||||
|
||||
/**
|
||||
* La lecture de l'environnement (D-065).
|
||||
*
|
||||
* Deux comportements valent d'être gravés, et ce sont les deux qui coûtent cher s'ils se
|
||||
* renversent un jour :
|
||||
*
|
||||
* 1. **Le défaut est « payant ».** Une variable absente, vide ou mal orthographiée en production
|
||||
* doit laisser le produit dans l'état qu'on lui connaît. L'erreur inverse — ouvrir l'accès
|
||||
* complet à tout le monde sur une faute de frappe — ne se rattrape pas : on ne referme pas un
|
||||
* accès qu'on a donné.
|
||||
* 2. **Le plafond est ignoré en mode payant.** Un `FOKAN_PLACES_GRATUITES` oublié dans un `.env`
|
||||
* après un retour au payant limiterait le nombre de CLIENTS, et le défaut ne se verrait qu'au
|
||||
* moment où il coûte de l'argent.
|
||||
*/
|
||||
|
||||
const initial = { ...process.env };
|
||||
afterEach(() => {
|
||||
process.env = { ...initial };
|
||||
});
|
||||
|
||||
function poser(mode?: string, places?: string) {
|
||||
if (mode === undefined) delete process.env.FOKAN_MODE;
|
||||
else process.env.FOKAN_MODE = mode;
|
||||
if (places === undefined) delete process.env.FOKAN_PLACES_GRATUITES;
|
||||
else process.env.FOKAN_PLACES_GRATUITES = places;
|
||||
}
|
||||
|
||||
describe("modeAcces", () => {
|
||||
it("vaut « payant » quand rien n'est posé", () => {
|
||||
poser(undefined);
|
||||
expect(modeAcces()).toBe("payant");
|
||||
expect(estGratuit()).toBe(false);
|
||||
});
|
||||
|
||||
it("ne bascule que sur le mot exact", () => {
|
||||
for (const valeur of ["", "gratui", "GRATUIT", "free", "1", "true", "payant"]) {
|
||||
poser(valeur);
|
||||
expect(modeAcces(), `FOKAN_MODE=${JSON.stringify(valeur)}`).toBe("payant");
|
||||
}
|
||||
});
|
||||
|
||||
it("bascule sur « gratuit », espaces compris", () => {
|
||||
for (const valeur of ["gratuit", " gratuit ", "gratuit\n"]) {
|
||||
poser(valeur);
|
||||
expect(modeAcces(), JSON.stringify(valeur)).toBe("gratuit");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("placesGratuites", () => {
|
||||
it("ignore le plafond en mode payant, même s'il est posé", () => {
|
||||
poser("payant", "50");
|
||||
expect(placesGratuites()).toBeNull();
|
||||
});
|
||||
|
||||
it("lit un entier en mode gratuit", () => {
|
||||
poser("gratuit", "150");
|
||||
expect(placesGratuites()).toBe(150);
|
||||
});
|
||||
|
||||
/**
|
||||
* `0` n'est PAS « pas de plafond » : c'est un plafond qui ferme les inscriptions et bascule
|
||||
* tout le monde en liste d'attente — le geste d'un lancement qu'on prépare. Le confondre avec
|
||||
* une variable vide ouvrirait grand ce qu'on venait de fermer.
|
||||
*/
|
||||
it("distingue le plafond zéro d'une variable absente", () => {
|
||||
poser("gratuit", "0");
|
||||
expect(placesGratuites()).toBe(0);
|
||||
poser("gratuit", undefined);
|
||||
expect(placesGratuites()).toBeNull();
|
||||
poser("gratuit", "");
|
||||
expect(placesGratuites()).toBeNull();
|
||||
});
|
||||
|
||||
it("retombe sur « pas de plafond » devant une valeur illisible", () => {
|
||||
// Un plafond qu'on ne sait pas lire ne doit pas se comporter comme un plafond de zéro : ce
|
||||
// serait fermer le produit sur une faute de frappe dans un fichier de configuration.
|
||||
for (const valeur of ["cent", "12.5", "-3", " ", "50 places"]) {
|
||||
poser("gratuit", valeur);
|
||||
expect(placesGratuites(), JSON.stringify(valeur)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* La notation scientifique passe, et c'est laissé tel quel : `Number("1e3")` vaut exactement
|
||||
* 1000, qui est l'entier que l'auteur a écrit. Le rejeter demanderait une expression régulière
|
||||
* sur les chiffres décimaux pour se protéger d'une écriture que personne n'emploie dans un
|
||||
* fichier `.env` — et le seul cas où elle apparaîtrait est celui où elle est voulue.
|
||||
*/
|
||||
it("accepte une notation scientifique qui désigne un entier", () => {
|
||||
poser("gratuit", "1e3");
|
||||
expect(placesGratuites()).toBe(1000);
|
||||
});
|
||||
});
|
||||
55
src/lib/mode-serveur.ts
Normal file
55
src/lib/mode-serveur.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import type { ModeAcces } from "./mode";
|
||||
|
||||
/**
|
||||
* La lecture du mode d'accès — côté serveur uniquement, et sans toucher à quoi que ce soit
|
||||
* d'autre.
|
||||
*
|
||||
* ⚠️ **Ne jamais importer ce module depuis un composant `"use client"`.** Il lit
|
||||
* `process.env.FOKAN_MODE`, que Next n'injecte pas dans le bundle navigateur : l'appel y
|
||||
* répondrait « payant » quoi qu'il arrive, et la page se contredirait entre son rendu serveur et
|
||||
* son hydratation. Les composants client reçoivent le mode en `props`, jamais par import (le
|
||||
* commentaire de tête de `lib/mode.ts` détaille pourquoi).
|
||||
*
|
||||
* Le paquet `server-only` aurait rendu la faute impossible au lieu de la commenter. Il n'est pas
|
||||
* dans les dépendances et l'ajouter pour une garde de compilation sur trois fonctions ne le vaut
|
||||
* pas ; ce module n'est importé que par des composants serveur et des routes, ce qui se vérifie
|
||||
* d'un `grep`.
|
||||
*
|
||||
* **Aucune dépendance, pas même la base** : les décomptes de places vivent dans `lib/places.ts`.
|
||||
* C'est ce qui permet aux trois pages légales — statiques, sans requête — de lire le mode sans
|
||||
* traîner un pool Postgres derrière elles.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Le mode courant. Défaut « payant » : une variable absente ou mal orthographiée en production
|
||||
* doit laisser le produit dans l'état qu'on lui connaît, pas ouvrir l'accès complet à tout le
|
||||
* monde. Une bascule commerciale se décide, elle ne se subit pas sur une faute de frappe.
|
||||
*/
|
||||
export function modeAcces(): ModeAcces {
|
||||
return process.env.FOKAN_MODE?.trim() === "gratuit" ? "gratuit" : "payant";
|
||||
}
|
||||
|
||||
/** Raccourci de lisibilité, parce que la moitié des appels ne veut qu'un booléen. */
|
||||
export function estGratuit(): boolean {
|
||||
return modeAcces() === "gratuit";
|
||||
}
|
||||
|
||||
/**
|
||||
* Le plafond de comptes gratuits, ou `null` s'il n'y en a pas.
|
||||
*
|
||||
* **Conditionné au mode gratuit** : en mode payant la variable est ignorée, sans quoi un
|
||||
* plafond oublié dans un `.env` limiterait un jour le nombre de clients payants — un défaut
|
||||
* qu'on ne verrait qu'au moment où il coûte de l'argent.
|
||||
*
|
||||
* Trois valeurs se ramènent à « pas de plafond » : variable absente, vide, ou non entière. `0`
|
||||
* en revanche est un plafond légitime — il ferme les inscriptions et bascule tout le monde en
|
||||
* liste d'attente, ce qui est exactement le geste d'un lancement qu'on prépare. C'est la seule
|
||||
* raison de ne pas traiter `0` comme « vide ».
|
||||
*/
|
||||
export function placesGratuites(): number | null {
|
||||
if (!estGratuit()) return null;
|
||||
const brut = process.env.FOKAN_PLACES_GRATUITES?.trim();
|
||||
if (!brut) return null;
|
||||
const n = Number(brut);
|
||||
return Number.isInteger(n) && n >= 0 ? n : null;
|
||||
}
|
||||
102
src/lib/mode.test.ts
Normal file
102
src/lib/mode.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { EDITEUR } from "./legal";
|
||||
import {
|
||||
calculerPlaces,
|
||||
ceQuiLOuvre,
|
||||
MOTS,
|
||||
mots,
|
||||
reserveAux,
|
||||
type ModeAcces,
|
||||
} from "./mode";
|
||||
|
||||
/**
|
||||
* Le socle du mode d'accès (D-065).
|
||||
*
|
||||
* Ce qui est éprouvé ici n'est pas « le mot est bien orthographié » — un test de chaîne littérale
|
||||
* ne vaut rien. Ce sont les trois propriétés dont la bascule dépend :
|
||||
*
|
||||
* 1. **Le vocabulaire est complet dans les deux modes.** Un champ vide se traduirait par un
|
||||
* « réservé aux » à l'écran, sur la page qui monétise le produit.
|
||||
* 2. **Les deux modes disent des choses différentes.** Un copier-coller qui laisserait
|
||||
* « abonnement » dans la colonne gratuite ne casserait rien à la compilation, et se verrait
|
||||
* seulement en production.
|
||||
* 3. **Le prix n'existe que d'un côté**, et il est le même que celui des CGV. Deux sources pour
|
||||
* un prix est le défaut que `lib/legal.ts` existe pour empêcher ; ce test tient l'accord.
|
||||
*
|
||||
* `calculerPlaces` est testé à part : c'est de l'arithmétique, et c'est elle qui décide qui entre.
|
||||
*/
|
||||
|
||||
const MODES: ModeAcces[] = ["gratuit", "payant"];
|
||||
|
||||
describe("le vocabulaire du mode", () => {
|
||||
it("est complet dans les deux modes", () => {
|
||||
for (const mode of MODES) {
|
||||
for (const [champ, valeur] of Object.entries(mots(mode))) {
|
||||
// `prix` est le seul champ dont `null` est une valeur légitime — cf. le test suivant.
|
||||
if (champ === "prix") continue;
|
||||
expect(valeur, `${mode}.${champ}`).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("ne laisse aucun terme identique entre les deux modes", () => {
|
||||
// Sauf le CTA court, qui n'a aucune raison de différer autrement que par son suffixe.
|
||||
for (const champ of ["contrepartie", "laContrepartie", "beneficiaire", "beneficiaires", "conditions", "conditionsSigle", "conditionsTitre"] as const) {
|
||||
expect(MOTS.gratuit[champ], champ).not.toBe(MOTS.payant[champ]);
|
||||
}
|
||||
});
|
||||
|
||||
it("n'affiche un prix qu'en mode payant, et c'est celui des conditions de vente", () => {
|
||||
expect(MOTS.gratuit.prix).toBeNull();
|
||||
expect(MOTS.payant.prix).toBe(EDITEUR.prixAnnuelTtc);
|
||||
});
|
||||
|
||||
it("ne parle jamais d'abonnement ni de paiement en mode gratuit", () => {
|
||||
const tout = Object.values(MOTS.gratuit).join(" ").toLowerCase();
|
||||
// « payé » est admis : la phrase de réassurance dit précisément qu'on ne paie pas.
|
||||
expect(tout).not.toMatch(/abonn|€|tarif|facture/);
|
||||
});
|
||||
|
||||
it("compose des formules grammaticalement correctes", () => {
|
||||
expect(reserveAux("payant")).toBe("réservé aux abonnés");
|
||||
expect(reserveAux("gratuit")).toBe("réservé aux inscrits");
|
||||
// Les deux se placent après un sujet : « le détail — quoi, quand, pourquoi — … ».
|
||||
expect(ceQuiLOuvre("payant")).toMatch(/^fait partie/);
|
||||
expect(ceQuiLOuvre("gratuit")).toMatch(/gratuitement$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculerPlaces", () => {
|
||||
it("laisse tout ouvert quand il n'y a pas de plafond", () => {
|
||||
expect(calculerPlaces(null, 9999)).toEqual({
|
||||
places: null,
|
||||
occupees: 9999,
|
||||
restantes: null,
|
||||
ouvert: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("ouvre tant qu'il reste au moins une place", () => {
|
||||
expect(calculerPlaces(100, 99)).toMatchObject({ restantes: 1, ouvert: true });
|
||||
});
|
||||
|
||||
it("ferme exactement au plafond, pas un de plus", () => {
|
||||
expect(calculerPlaces(100, 100)).toMatchObject({ restantes: 0, ouvert: false });
|
||||
});
|
||||
|
||||
/**
|
||||
* Le cas qui compte le plus : un plafond qu'on ABAISSE en dessous du nombre d'accès déjà
|
||||
* ouverts. Il ne doit jamais produire un nombre de places négatif — un `restantes: -12` posé
|
||||
* en `limit()` d'une requête d'admission ferait planter le passage quotidien, et un
|
||||
* `restantes < 0` testé par « > 0 » aurait par chance le bon comportement pour la mauvaise
|
||||
* raison. On ne ferme personne pour autant : ce qui est ouvert reste ouvert (invariant n° 3),
|
||||
* le plafond ne fait que cesser d'admettre.
|
||||
*/
|
||||
it("ne rend jamais un nombre de places négatif quand le plafond est abaissé", () => {
|
||||
expect(calculerPlaces(50, 120)).toMatchObject({ places: 50, occupees: 120, restantes: 0, ouvert: false });
|
||||
});
|
||||
|
||||
it("ferme tout à plafond zéro — le geste d'un lancement qu'on prépare", () => {
|
||||
expect(calculerPlaces(0, 0)).toMatchObject({ restantes: 0, ouvert: false });
|
||||
});
|
||||
});
|
||||
149
src/lib/mode.ts
Normal file
149
src/lib/mode.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/**
|
||||
* Le mode d'accès du produit — gratuit ou payant — et les mots qui en découlent.
|
||||
*
|
||||
* **Ce module est pur et isomorphe** : ni `process.env`, ni base, ni `server-only`. C'est
|
||||
* délibéré, et c'est la seule façon d'éviter le pire défaut possible ici. Lire
|
||||
* `process.env.FOKAN_MODE` depuis un module importé par un composant client donnerait
|
||||
* `undefined` dans le bundle navigateur (Next n'injecte que `NEXT_PUBLIC_*`) : le serveur
|
||||
* rendrait « c'est gratuit » et le navigateur « 19,99 € », en silence, à l'hydratation. Le mode
|
||||
* se lit donc **une seule fois, côté serveur** (`lib/mode-serveur.ts`) et descend en `props`
|
||||
* jusqu'aux composants client.
|
||||
*
|
||||
* **Le défaut est « payant »**, en trois endroits qui doivent rester d'accord : ici, dans
|
||||
* `mode-serveur.ts` et dans `.env.example`. Une variable oubliée en production ne doit pas
|
||||
* ouvrir le produit — le sens d'erreur d'une bascule commerciale est de garder l'état connu,
|
||||
* pas d'en inventer un.
|
||||
*
|
||||
* **Ce qui n'est PAS ici** : les phrases entières dont la structure change d'un mode à l'autre.
|
||||
* Elles restent dans leur composant, en `mode === "gratuit" ? … : …`, parce qu'une table de
|
||||
* chaînes qui grossit finit par rendre illisible ce que la page raconte vraiment. On mutualise
|
||||
* le vocabulaire (ce qui se répète mot pour mot dans quinze fichiers), jamais le discours.
|
||||
*/
|
||||
|
||||
export type ModeAcces = "gratuit" | "payant";
|
||||
|
||||
/**
|
||||
* Le vocabulaire commun, celui qui revient à l'identique d'un écran à l'autre.
|
||||
*
|
||||
* Chaque champ porte son article ou sa forme grammaticale dans son nom : `laContrepartie`
|
||||
* s'insère tel quel après un verbe, `contrepartie` se compose. Sans cette discipline, on obtient
|
||||
* des « fait partie de le abonnement » à la première élision oubliée.
|
||||
*/
|
||||
export type Mots = {
|
||||
/** Sans article : « abonnement » / « inscription ». */
|
||||
contrepartie: string;
|
||||
/** Avec article défini élidé : « l'abonnement » / « l'inscription ». */
|
||||
laContrepartie: string;
|
||||
/** Celui qui l'a franchie, singulier : « abonné » / « inscrit ». */
|
||||
beneficiaire: string;
|
||||
/** Pluriel, pour les « réservé aux … ». */
|
||||
beneficiaires: string;
|
||||
/** Le CTA principal du mur, court parce qu'il vit dans un bouton et dans une barre mobile. */
|
||||
cta: string;
|
||||
/** Le prix affiché, ou son absence assumée. `null` = rien à afficher (mode gratuit). */
|
||||
prix: string | null;
|
||||
/**
|
||||
* Ce que le mur promet en une ligne, sous le CTA. Deux registres opposés : le payant justifie
|
||||
* un tarif, le gratuit désamorce le soupçon qu'un gratuit fait naître (« où est le piège ? »).
|
||||
*/
|
||||
reassurance: string;
|
||||
/** Le nom du document contractuel : « conditions générales de vente » / « d'utilisation ». */
|
||||
conditions: string;
|
||||
/** Son sigle, pour le pied de page et les liens courts. */
|
||||
conditionsSigle: string;
|
||||
/** Le titre de la page `/cgv`, qui garde son URL dans les deux modes (cf. `app/cgv/page.tsx`). */
|
||||
conditionsTitre: string;
|
||||
};
|
||||
|
||||
const PAYANT: Mots = {
|
||||
contrepartie: "abonnement",
|
||||
laContrepartie: "l'abonnement",
|
||||
beneficiaire: "abonné",
|
||||
beneficiaires: "abonnés",
|
||||
cta: "Je débloque",
|
||||
prix: "19,99 €",
|
||||
reassurance:
|
||||
"Un service gratuit se paie toujours — en publicité, ou en revente de données. Ici, tu es le client, pas le produit.",
|
||||
conditions: "conditions générales de vente",
|
||||
conditionsSigle: "CGV",
|
||||
conditionsTitre: "Conditions générales de vente",
|
||||
};
|
||||
|
||||
const GRATUIT: Mots = {
|
||||
contrepartie: "inscription",
|
||||
laContrepartie: "l'inscription",
|
||||
beneficiaire: "inscrit",
|
||||
beneficiaires: "inscrits",
|
||||
cta: "Je débloque — c'est gratuit",
|
||||
prix: null,
|
||||
// Le soupçon à désamorcer n'est pas le prix mais son absence : un service gratuit qui ne dit
|
||||
// pas comment il vit se fait lire comme « c'est toi le produit ». On répond par ce qu'on ne
|
||||
// fait pas, et qui est vérifiable dans la politique de confidentialité.
|
||||
reassurance:
|
||||
"Gratuit ne veut pas dire financé par toi : aucune publicité, aucune revente de données, aucun traceur. C'est un service qu'on ouvre, pas une audience qu'on constitue.",
|
||||
conditions: "conditions générales d'utilisation",
|
||||
conditionsSigle: "CGU",
|
||||
conditionsTitre: "Conditions générales d'utilisation",
|
||||
};
|
||||
|
||||
export const MOTS: Record<ModeAcces, Mots> = { payant: PAYANT, gratuit: GRATUIT };
|
||||
|
||||
/** Le vocabulaire du mode courant. Un seul point d'entrée, pour ne jamais indexer `MOTS` à la main. */
|
||||
export function mots(mode: ModeAcces): Mots {
|
||||
return MOTS[mode];
|
||||
}
|
||||
|
||||
/**
|
||||
* L'état d'accès d'un foyer, tel que les écrans doivent le raconter.
|
||||
*
|
||||
* Trois valeurs et pas quatre : « ouvert » recouvre l'abonné payant et l'inscrit gratuit, parce
|
||||
* que ce qu'ils voient est rigoureusement identique — c'est tout l'intérêt d'avoir fait passer
|
||||
* les deux par le même `subscriptionStatus = "active"`. « attente » n'existe qu'en mode gratuit.
|
||||
*/
|
||||
export type EtatAcces = "ouvert" | "ferme" | "attente";
|
||||
|
||||
/**
|
||||
* La formule qui remplace « réservé aux abonnés ».
|
||||
*
|
||||
* Elle est ici parce qu'elle apparaît telle quelle sur huit écrans et dans quatre routes d'API,
|
||||
* et qu'un seul de ces endroits laissé en dur suffirait à faire mentir la bascule.
|
||||
*/
|
||||
export function reserveAux(mode: ModeAcces): string {
|
||||
return `réservé aux ${mots(mode).beneficiaires}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce qui manque à un visiteur, dit en une formule réutilisable : « fait partie de l'abonnement »
|
||||
* / « s'ouvre avec ton compte, gratuitement ».
|
||||
*/
|
||||
export function ceQuiLOuvre(mode: ModeAcces): string {
|
||||
return mode === "gratuit"
|
||||
? "s'ouvre avec ton compte, gratuitement"
|
||||
: "fait partie de l'abonnement";
|
||||
}
|
||||
|
||||
/**
|
||||
* L'état d'une liste d'attente, calculé à la lecture et jamais stocké.
|
||||
*
|
||||
* `places` nul = pas de plafond (variable d'environnement absente). C'est la valeur de
|
||||
* `FOKAN_PLACES_GRATUITES` non renseignée, et donc l'état d'un mode gratuit sans limite —
|
||||
* l'absence de plafond ne se déduit pas d'un `0`, qui voudrait dire « personne » et serait le
|
||||
* genre de faute qu'une variable vide provoque toute seule.
|
||||
*/
|
||||
export type PlacesGratuites = {
|
||||
/** Plafond configuré, ou `null` s'il n'y en a pas. */
|
||||
places: number | null;
|
||||
/** Combien d'accès gratuits sont déjà ouverts. */
|
||||
occupees: number;
|
||||
/** Ce qu'il reste. `null` quand il n'y a pas de plafond. */
|
||||
restantes: number | null;
|
||||
/** Y a-t-il de la place pour un accès de plus ? Vrai sans plafond. */
|
||||
ouvert: boolean;
|
||||
};
|
||||
|
||||
/** Le calcul, isolé de la base pour être testable : c'est de l'arithmétique, pas une requête. */
|
||||
export function calculerPlaces(places: number | null, occupees: number): PlacesGratuites {
|
||||
if (places === null) return { places: null, occupees, restantes: null, ouvert: true };
|
||||
const restantes = Math.max(0, places - occupees);
|
||||
return { places, occupees, restantes, ouvert: restantes > 0 };
|
||||
}
|
||||
97
src/lib/places.ts
Normal file
97
src/lib/places.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { and, count, eq, isNotNull } from "drizzle-orm";
|
||||
import { db, households as householdsTable } from "@/db";
|
||||
import { calculerPlaces, type PlacesGratuites } from "./mode";
|
||||
import { placesGratuites } from "./mode-serveur";
|
||||
|
||||
/**
|
||||
* Le décompte des places gratuites et de la liste d'attente (D-065).
|
||||
*
|
||||
* Séparé de `lib/mode-serveur.ts`, qui ne lit que l'environnement : ces fonctions-ci parlent à
|
||||
* la base, et les trois pages légales n'ont aucune raison de traîner un pool Postgres pour
|
||||
* afficher un titre.
|
||||
*
|
||||
* **Rien n'est stocké de ce qui se calcule ici** — ni le nombre de places restantes, ni le rang
|
||||
* dans la file. C'est la règle appliquée ailleurs au quota de rejeux (D-060) et aux conditions de
|
||||
* ressources (D-051) : on stocke ce que le foyer a fait, jamais ce qu'on en conclut. Un rang en
|
||||
* base serait un verdict à réécrire à chaque admission et à chaque suppression de compte, et qui
|
||||
* mentirait entre-temps.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Combien d'accès gratuits sont ouverts aujourd'hui.
|
||||
*
|
||||
* On compte `acces_mode = 'gratuit'` ET `subscription_status = 'active'`, jamais le seul statut :
|
||||
* un foyer payant historique occupe une ligne « active » sans consommer de place gratuite, et
|
||||
* le confondre ferait fermer les inscriptions au profit de gens qui ont payé. Les foyers en
|
||||
* attente ne comptent pas non plus — ils n'occupent rien, c'est tout leur sens.
|
||||
*/
|
||||
export async function placesOccupees(): Promise<number> {
|
||||
const [ligne] = await db()
|
||||
.select({ total: count() })
|
||||
.from(householdsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(householdsTable.accesMode, "gratuit"),
|
||||
eq(householdsTable.subscriptionStatus, "active"),
|
||||
),
|
||||
);
|
||||
return ligne?.total ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* L'état des places, tel qu'un écran ou une route doit le lire.
|
||||
*
|
||||
* En mode payant, il n'y a pas de places à compter : on répond « pas de plafond » sans toucher
|
||||
* la base. C'est la même réponse qu'un mode gratuit sans limite, et c'est correct — dans les
|
||||
* deux cas, rien ne s'oppose à un accès de plus.
|
||||
*/
|
||||
export async function etatPlaces(): Promise<PlacesGratuites> {
|
||||
const places = placesGratuites();
|
||||
if (places === null) return calculerPlaces(null, 0);
|
||||
return calculerPlaces(places, await placesOccupees());
|
||||
}
|
||||
|
||||
/**
|
||||
* Le rang d'un foyer dans la liste d'attente, 1 pour le premier servi.
|
||||
*
|
||||
* La règle est l'ordre d'arrivée (`attente_depuis`), la même que celle qui sert à admettre
|
||||
* (`promouvoirListeAttente`) : les deux lisent la même colonne, elles ne peuvent donc pas se
|
||||
* contredire à l'écran.
|
||||
*
|
||||
* Répond `null` si le foyer n'est pas en attente : c'est une question qui n'a alors pas de sens,
|
||||
* et rendre `0` inviterait à l'afficher.
|
||||
*/
|
||||
export async function rangEnAttente(householdId: string): Promise<number | null> {
|
||||
const [foyer] = await db()
|
||||
.select({ depuis: householdsTable.attenteDepuis, statut: householdsTable.subscriptionStatus })
|
||||
.from(householdsTable)
|
||||
.where(eq(householdsTable.id, householdId));
|
||||
|
||||
if (!foyer || foyer.statut !== "attente" || !foyer.depuis) return null;
|
||||
const seuil = new Date(foyer.depuis).getTime();
|
||||
|
||||
const enAttente = await db()
|
||||
.select({ depuis: householdsTable.attenteDepuis })
|
||||
.from(householdsTable)
|
||||
.where(
|
||||
and(eq(householdsTable.subscriptionStatus, "attente"), isNotNull(householdsTable.attenteDepuis)),
|
||||
);
|
||||
|
||||
// Comparaison en mémoire plutôt qu'un `lt` en SQL : la liste se compte en dizaines, et l'égalité
|
||||
// stricte est ici ce qui compte — deux foyers inscrits dans la même milliseconde afficheraient
|
||||
// sinon le même rang sans que rien ne le signale.
|
||||
const avant = enAttente.filter((f) => f.depuis !== null && f.depuis.getTime() < seuil).length;
|
||||
return avant + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combien de foyers attendent aujourd'hui. Sert au journal du passage quotidien, où il est la
|
||||
* seule mesure de l'effet du plafond.
|
||||
*/
|
||||
export async function longueurListeAttente(): Promise<number> {
|
||||
const [ligne] = await db()
|
||||
.select({ total: count() })
|
||||
.from(householdsTable)
|
||||
.where(eq(householdsTable.subscriptionStatus, "attente"));
|
||||
return ligne?.total ?? 0;
|
||||
}
|
||||
|
|
@ -4,8 +4,25 @@ import { db, households as householdsTable, memberships as membershipsTable } fr
|
|||
import { auth } from "@/lib/auth";
|
||||
|
||||
export type SubscriptionState = {
|
||||
status: "free" | "active" | "expired" | "canceled";
|
||||
/**
|
||||
* "attente" n'existe qu'en mode gratuit plafonné (D-065) : le compte et le foyer existent,
|
||||
* l'accès n'est pas ouvert. Il se comporte comme "free" partout où l'on teste `isActive` —
|
||||
* ce qui est voulu : une liste d'attente ne déverrouille rien.
|
||||
*/
|
||||
status: "free" | "active" | "attente" | "expired" | "canceled";
|
||||
isActive: boolean;
|
||||
/**
|
||||
* Comment l'accès a été ouvert, quand il l'est. `null` pour un foyer qui n'a jamais franchi la
|
||||
* porte — et pour les foyers payants antérieurs à D-065 que la migration n'aurait pas touchés.
|
||||
*
|
||||
* Sert à l'affichage (on ne parle pas de reconduction à un inscrit gratuit) et au décompte des
|
||||
* places. **Jamais à décider d'un accès** : cette décision-là est `isActive`, et elle seule.
|
||||
*/
|
||||
accesMode: "gratuit" | "payant" | null;
|
||||
/** Quand l'accès a été ouvert. Sert de borne basse à l'historique quand aucune période n'existe. */
|
||||
accesLe: Date | null;
|
||||
/** Depuis quand le foyer attend une place. `null` s'il n'attend pas. */
|
||||
attenteDepuis: Date | null;
|
||||
periodEnd: Date | null;
|
||||
/**
|
||||
* Début de la période payée en cours. Sert de borne basse à l'historique du foyer : on montre
|
||||
|
|
@ -19,14 +36,22 @@ export type SubscriptionState = {
|
|||
};
|
||||
|
||||
/**
|
||||
* La seule porte de vérité sur le statut payant d'un foyer (§ 10.2) — jamais dupliquée
|
||||
* en logique ad hoc. "active" ET la période n'est pas expirée : Stripe tient le statut à
|
||||
* jour via webhook, cette vérification de date est un filet de sécurité, pas la source.
|
||||
* La seule porte de vérité sur l'accès d'un foyer (§ 10.2) — jamais dupliquée en logique ad hoc.
|
||||
* "active" ET la période n'est pas expirée : Stripe tient le statut à jour via webhook, cette
|
||||
* vérification de date est un filet de sécurité, pas la source.
|
||||
*
|
||||
* **Depuis D-065, elle ne connaît plus le modèle économique.** Un accès gratuit est un "active"
|
||||
* sans date de fin — `!periodEnd` le rend donc actif indéfiniment, ce qui est exactement la
|
||||
* sémantique voulue, et aucun des quinze appelants n'a eu à changer. Ce que le mode d'accès
|
||||
* change est ce qu'on RACONTE (reconduction, prix, résiliation), jamais ce qu'on ouvre.
|
||||
*/
|
||||
export async function getSubscription(householdId: string): Promise<SubscriptionState> {
|
||||
const [row] = await db()
|
||||
.select({
|
||||
status: householdsTable.subscriptionStatus,
|
||||
accesMode: householdsTable.accesMode,
|
||||
accesLe: householdsTable.accesLe,
|
||||
attenteDepuis: householdsTable.attenteDepuis,
|
||||
periodEnd: householdsTable.subscriptionPeriodEnd,
|
||||
periodStart: householdsTable.subscriptionPeriodStart,
|
||||
cancelAtPeriodEnd: householdsTable.subscriptionCancelAtPeriodEnd,
|
||||
|
|
@ -38,6 +63,9 @@ export async function getSubscription(householdId: string): Promise<Subscription
|
|||
const vide: SubscriptionState = {
|
||||
status: "free",
|
||||
isActive: false,
|
||||
accesMode: null,
|
||||
accesLe: null,
|
||||
attenteDepuis: null,
|
||||
periodEnd: null,
|
||||
periodStart: null,
|
||||
cancelAtPeriodEnd: false,
|
||||
|
|
@ -52,6 +80,10 @@ export async function getSubscription(householdId: string): Promise<Subscription
|
|||
return {
|
||||
status,
|
||||
isActive,
|
||||
accesMode:
|
||||
row.accesMode === "gratuit" || row.accesMode === "payant" ? row.accesMode : null,
|
||||
accesLe: row.accesLe ? new Date(row.accesLe) : null,
|
||||
attenteDepuis: row.attenteDepuis ? new Date(row.attenteDepuis) : null,
|
||||
periodEnd,
|
||||
periodStart: row.periodStart ? new Date(row.periodStart) : null,
|
||||
cancelAtPeriodEnd: row.cancelAtPeriodEnd,
|
||||
|
|
@ -76,6 +108,25 @@ export function debutPeriodeCouverte(s: Pick<SubscriptionState, "periodStart" |
|
|||
return repli;
|
||||
}
|
||||
|
||||
/**
|
||||
* La borne basse de l'historique — la même que ci-dessus, plus le cas d'un accès gratuit.
|
||||
*
|
||||
* Elle est distincte de `debutPeriodeCouverte` **exprès**, et la tentation de les fusionner est
|
||||
* un piège coûteux : le quota de rejeux se compte lui aussi contre `debutPeriodeCouverte`, et lui
|
||||
* donner `accesLe` comme borne fixerait le début à jamais. Un inscrit gratuit se retrouverait
|
||||
* avec cinq mises à jour à vie — exactement le défaut que le repli en année glissante de
|
||||
* `quotaRejeu` a été écrit pour éviter (D-060).
|
||||
*
|
||||
* Ici, au contraire, une borne fixe est la bonne réponse : « ce qui est échu depuis que ton accès
|
||||
* est ouvert » est ce qu'un inscrit veut voir, et rien avant ne le concerne — son foyer n'était
|
||||
* pas surveillé.
|
||||
*/
|
||||
export function debutHistorique(
|
||||
s: Pick<SubscriptionState, "periodStart" | "periodEnd" | "accesLe">,
|
||||
): Date | null {
|
||||
return debutPeriodeCouverte(s) ?? s.accesLe;
|
||||
}
|
||||
|
||||
/** Un compte est "abonné" si au moins un de ses foyers a un abonnement actif (§ 10.2). */
|
||||
export async function hasActiveSubscription(): Promise<boolean> {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue