From b89da05c84dde2bbcf8f7ab120f219f0e4bf90b5 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Sat, 4 Jul 2026 22:46:43 +0200 Subject: [PATCH] Spec : refonte du tunnel d'abonnement (offre publique) Co-Authored-By: Claude Opus 4.8 --- ...26-07-04-refonte-flow-abonnement-design.md | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-04-refonte-flow-abonnement-design.md diff --git a/docs/superpowers/specs/2026-07-04-refonte-flow-abonnement-design.md b/docs/superpowers/specs/2026-07-04-refonte-flow-abonnement-design.md new file mode 100644 index 0000000..23b90c7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-04-refonte-flow-abonnement-design.md @@ -0,0 +1,208 @@ +# Refonte du tunnel d'abonnement — abonnement public + +## Contexte et objectif + +Aujourd'hui, pour s'abonner, un visiteur doit : (1) créer un compte, (2) valider +son email, (3) se connecter, (4) seulement là découvrir et entamer le tunnel +d'abonnement. Les cards d'abonnement vivent derrière l'authentification, dans +`/compte/abonnement`. Ce parcours est trop long et masque l'offre aux visiteurs. + +Objectif : **rendre l'offre d'abonnement visible aux visiteurs non connectés** et +raccourcir le tunnel, tout en préservant l'accès des anciens abonnés à leur compte +(factures) même s'ils ne sont plus abonnés. + +## Principes + +- **Se connecter ≠ être abonné.** La connexion ne requiert pas d'abonnement (déjà le + cas). Un ancien abonné garde l'accès à `/compte/admin` et `/compte/abonnement` + (guards en `require_subscription=False`). Il perd seulement l'accès aux sections + réservées (`/compte/vues`, `/compte/roadmap`). +- **L'offre est publique.** La page `/a-propos/abonnement` (URL inchangée, au + singulier) présente les cards et un unique bouton d'entrée dans le tunnel. +- **Le choix du plan (simple/soutien) se fait dans `mes-infos`**, pas sur la card. + Cela évite de transporter le plan à travers inscription → email → mes-infos. + +## Flow cible + +``` +Visiteur + └─ navbar "Connexion" ─────────────► /connexion (formulaire + lien vers offre) + └─ /a-propos/abonnement (public) ──► cards + explainer + bouton "Je m'abonne" + │ + ├─ non connecté ────────────► /inscription + │ ├─ email : signup → email de validation + │ │ └─ clic lien ► /auth/verify-email : auto-login + │ │ └─► /compte/abonnement/mes-infos + │ └─ LinkedIn (next=mes-infos) ─► /compte/abonnement/mes-infos + │ + └─ déjà connecté, non abonné ► /compte/abonnement/mes-infos + +/compte/abonnement/mes-infos + └─ radios plan (simple/soutien, défaut simple) + rappel tarifs + + infos facturation + cases CGU + └─ POST /subscriptions/subscribe ► checkout Frisbii + +/compte/abonnement (connecté) + ├─ abonné actif ──────► vue de gestion (inchangée) + └─ non abonné ────────► texte + bouton : + ├─ has_used_trial → "Me réabonner" ─► /a-propos/abonnement + └─ sinon → "M'abonner" ─► /a-propos/abonnement +``` + +## Décisions actées + +1. **Bouton unique « Je m'abonne »** centré sous les 2 cards de la page publique. + Les cards deviennent purement informatives (plus de bouton « S'abonner » par card). +2. **Choix du plan par boutons radio dans `mes-infos`** (défaut « simple »), avec + rappel des tarifs. Aucun plan transporté depuis la card. +3. **URL de la page publique inchangée** : `/a-propos/abonnement` (singulier). Pas de + renommage ni de redirect. +4. **Cible du bouton conditionnelle à l'état de connexion** (voir détail §2). +5. **`/compte/abonnement` non-abonné** : plus de cards, un bouton « M'abonner » / + « Me réabonner » selon `has_used_trial(user_id)`. +6. **Signal « déjà abonné » = `has_used_trial`** (True quand un abonnement a atteint + `trial`/`active`). Un checkout Frisbii abandonné (`pending`/`failed`, aucun accès) + → considéré « jamais abonné » → « M'abonner ». +7. **Validation d'email = auto-login** puis redirection vers `mes-infos`. +8. **`linkedin_button` paramétrable par `next`** : la connexion garde son + comportement, l'inscription route vers `mes-infos`. + +## Changements par fichier + +### 1. `src/pages/a_propos/abonnement.py` — page publique + +Devient le foyer unique des composants cards (refactor depuis `compte/abonnement.py`). + +- **Remonter** ici (auth-agnostiques) : `_plan_card`, `_plan_cards`, `_explainer`. + - `_plan_card` : **retirer le bouton « S'abonner » par card**. La carte n'affiche + plus que label, tarif, description et le badge d'essai générique + (`trial_days(key)` jours, sans personnalisation `trial_used`). +- `layout()` devient dynamique (appelé par requête, peut lire `current_user`) et + assemble de haut en bas : + 1. `_plan_cards()` + `_explainer()` + 2. le bouton **« Je m'abonne »** centré (voir §2) + 3. `subscription_terms` (CGU existantes) +- **Contenu** : la sous-section « Fonctionnalités incluses » + `abonnement_features` + de `subscription_terms` fait désormais doublon avec `_explainer` juste au-dessus. + → **la retirer de `subscription_terms`** (garder le reste des CGU tel quel). + +### 2. Bouton « Je m'abonne » (dans `a_propos/abonnement.py`) + +Une fonction dédiée qui décide libellé + cible selon l'état : + +| État | Libellé | Cible | +| ---------------------------------- | --------------------------- | -------------------------------------- | +| `TOUS_ABONNES` | « Je m'abonne » (désactivé) | `#` + bannière (comme cards actuelles) | +| non authentifié | « Je m'abonne » | `/inscription` | +| authentifié, sans abonnement actif | « Je m'abonne » | `/compte/abonnement/mes-infos` | +| authentifié, abonnement actif | « Gérer mon abonnement » | `/compte/abonnement` | + +Bouton centré, `btn btn-primary`, largeur ajustée. + +### 3. `src/pages/compte/abonnement.py` + +- **Supprimer** `_plan_card`, `_plan_cards`, `_explainer` (déplacés en §1) et l'import + `from src.pages.a_propos.abonnement import abonnement_features`. +- La branche « non-abonné » (`else` de `layout`, aujourd'hui cards + explainer) + devient un court texte d'invitation + un bouton : + - `db.has_used_trial(current_user.id)` → « Me réabonner » + - sinon → « M'abonner » + - href → `/a-propos/abonnement` dans les deux cas. + - Le message « Votre abonnement a expiré » (cas `status == "expired"`) est conservé. +- La vue « abonné actif » (`_active_view`, résiliation, feedback paiement, + `_salaire_modal`, `_tous_abonnes_banner`) est **inchangée**. + +### 4. `src/pages/compte/abonnement_mes_infos.py` + +- **Retirer** la redirection « pas de `?plan=` » (lignes 53-55) : la page est + accessible directement. +- **Ajouter en tête de formulaire** un groupe de boutons radio **HTML natifs** + (`html.Input(type="radio", name="plan", value="simple"|"soutien")`, « simple » + `checked` par défaut), avec un rappel des tarifs (20 € HT / 50 € HT) à côté de + chaque option. → radio natif, **pas** `dcc.RadioItems`, pour que le champ `plan` + soit bien soumis dans le POST natif du `html.Form` (cohérent avec les `dbc.Input` + déjà utilisés). +- **Supprimer** le `dcc.Input(type="hidden", name="plan", value=plan)` (ligne 232). +- Le reste (prefill Frisbii, SIRET, cases rétractation/CGU, `_toggle_submit`) est + inchangé. `subscribe()` lit toujours `request.form.get("plan")` → compatible. + +### 5. `src/auth/routes.py` — `verify_email()` + +Après `db.set_email_verified(user_id)` : + +```python +user = User(db.get_user_by_id(user_id)) +login_user(user, remember=True) +return redirect("/compte/abonnement/mes-infos") +``` + +(`login_user` et `User` sont déjà importés.) La page `/verification-email` reste +utilisée pour le seul cas `error=invalid_token`. + +### 6. `src/pages/connexion.py` + +- `linkedin_button` accepte un paramètre `next_url` optionnel : + + ```python + def linkedin_button(next_url: str | None = None): + href = "/auth/linkedin" + if next_url: + href += f"?next={next_url}" + # ... inchangé + ``` + +- Le CTA du bas de `/connexion` (« Créer un compte avec mon adresse email » → + `/inscription`) devient un lien vers `/a-propos/abonnement` (« Pas encore de + compte ? Voir les abonnements »). + +### 7. `src/pages/inscription.py` + +- Appelle `linkedin_button("/compte/abonnement/mes-infos")` pour que l'inscription + via LinkedIn finisse dans le tunnel (au lieu de `/compte/admin`). +- Le reste (formulaire email, lien « Déjà un compte ? ») inchangé. + +## Sécurité + +- **Redirection de retour LinkedIn** : `safe_next` (`auth/setup.py:16-19`) n'autorise + qu'un chemin interne commençant par un seul `/` (rejette `//` et les URLs + absolues). Le `next=/compte/abonnement/mes-infos` passé par l'inscription est un + chemin interne valide, filtré à l'entrée (`linkedin_login`) et à la sortie (callback). + Pas d'open redirect introduit. +- **Auto-login via lien email** : le token de vérification est à usage unique et + consommé (`consume_verification_token`). L'auto-login qui en découle est un + magic-link classique, acceptable. + +## Cas limites + +- **Abandon sur mes-infos** : l'utilisateur a un compte fonctionnel sans abonnement. + `get_current` renvoie `None`, `has_used_trial` False → `/compte/abonnement` affiche + « M'abonner ». Cohérent. +- **Ancien abonné (expiré/résilié)** : `has_used_trial` True → « Me réabonner ». +- **Abonné actif visitant `/a-propos/abonnement`** : bouton « Gérer mon abonnement » + → `/compte/abonnement` (pas de « Je m'abonne » trompeur). +- **`TOUS_ABONNES`** : bouton public désactivé + bannière, comme les cards + actuelles ; `mes-infos`/`subscribe` déjà gérés en amont. +- **Email déjà pris à l'inscription** : comportement existant (`email_taken`), + l'utilisateur est invité à se connecter. + +## Tests + +Étendre `tests/` (Selenium `DashComposite`) : + +- Visiteur non connecté : `/a-propos/abonnement` affiche les cards + « Je m'abonne » + pointant vers `/inscription`. +- Utilisateur connecté sans abonnement : « Je m'abonne » pointe vers `mes-infos` ; + `/compte/abonnement` affiche « M'abonner ». +- Utilisateur ayant déjà été abonné (`trial_used=1`) : `/compte/abonnement` affiche + « Me réabonner ». +- `mes-infos` accessible sans `?plan=` ; radios présents, « simple » par défaut ; + soumission POST envoie bien `plan`. +- `verify_email` : après consommation du token, session authentifiée et redirection + vers `mes-infos`. +- `safe_next` : `next` externe (`//evil.com`, `https://…`) ignoré au profit du fallback. + +## Hors périmètre + +- Refonte visuelle des cards / de la page publique (on réutilise l'existant). +- Modification du parcours de paiement Frisbii lui-même. +- Gestion des factures (déjà côté Frisbii, inchangée).