Task 6 : sélection de formule par cartes cliquables (au lieu de RadioItems)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -14,9 +14,10 @@
|
|||||||
- Imports du code applicatif toujours préfixés `src.` (ex. `src.pages.compte.abonnement`).
|
- Imports du code applicatif toujours préfixés `src.` (ex. `src.pages.compte.abonnement`).
|
||||||
- Lancer les tests avec `uv run pytest` (l'activation de venv via Bash n'est pas fiable ici).
|
- Lancer les tests avec `uv run pytest` (l'activation de venv via Bash n'est pas fiable ici).
|
||||||
- **Soumission de formulaire native** : seul un `dcc.Input`/`dbc.Input` portant un
|
- **Soumission de formulaire native** : seul un `dcc.Input`/`dbc.Input` portant un
|
||||||
attribut `name` est soumis dans le POST d'un `html.Form`. Un `dcc.RadioItems` n'est
|
attribut `name` est soumis dans le POST d'un `html.Form`. Une interaction Dash
|
||||||
**pas** soumis nativement — le relier à un `dcc.Input(type="hidden", name=...)` via
|
contrôlée (clic sur carte, `RadioItems`…) n'est **pas** soumise nativement — la
|
||||||
callback. `html.Input` n'existe pas en Dash 3.4.
|
relier à un `dcc.Input(type="hidden", name=...)` via callback. `html.Input` n'existe
|
||||||
|
pas en Dash 3.4.
|
||||||
- Boutons : classes Bootstrap existantes (`btn btn-primary`, `btn btn-outline-danger`…), cohérentes avec le reste du site.
|
- Boutons : classes Bootstrap existantes (`btn btn-primary`, `btn btn-outline-danger`…), cohérentes avec le reste du site.
|
||||||
- Chaque commit doit passer `uv run pytest` (au minimum les tests touchés).
|
- Chaque commit doit passer `uv run pytest` (au minimum les tests touchés).
|
||||||
|
|
||||||
@@ -585,22 +586,29 @@ git commit -m "feat(abonnement): CTA connexion vers /a-propos/abonnement"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Task 6 : Sélecteur de formule dans `mes-infos`
|
### Task 6 : Sélection de la formule par cartes cliquables dans `mes-infos`
|
||||||
|
|
||||||
Le formulaire `mes-infos` n'exige plus de paramètre `?plan=` : la formule se choisit
|
Le formulaire `mes-infos` n'exige plus de paramètre `?plan=`. La formule se choisit en
|
||||||
via un `dcc.RadioItems` relié à un champ caché natif soumis dans le POST.
|
cliquant l'une des deux cartes (réutilisées de la page publique). Aucune sélection par
|
||||||
|
défaut : un texte invite l'utilisateur ; la carte choisie prend un fond teinté. Un
|
||||||
|
callback recopie la valeur dans un champ caché natif soumis dans le POST.
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `src/pages/compte/abonnement_mes_infos.py`
|
- Modify: `src/pages/compte/abonnement_mes_infos.py`
|
||||||
|
- Modify: `src/assets/css/style.css`
|
||||||
- Test: `tests/subscriptions/test_mes_infos_plan.py` (créer)
|
- Test: `tests/subscriptions/test_mes_infos_plan.py` (créer)
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `_plan_card` (Task 3, depuis `src.pages.a_propos.abonnement`),
|
||||||
|
`plans.plan_meta`, `plans.trial_days`, `sub_db.has_used_trial`.
|
||||||
- Produces:
|
- Produces:
|
||||||
|
|
||||||
- `_plan_choices() -> list[dict]` (options `{"label", "value"}` pour « simple » et « soutien »)
|
- `_selectable_cards(trial_for) -> dbc.Row`
|
||||||
- `_sync_plan(value: str | None) -> str` (callback : recopie la formule choisie, défaut « simple »)
|
- `_selection_state(selected: str) -> tuple[str, str, str, str]` (valeur cachée, classe carte simple, classe carte soutien, classe invite) — pur, testable
|
||||||
|
- callback `_select_plan` (déclenché par `n_clicks` des cartes via `ctx.triggered_id`)
|
||||||
|
- `_toggle_submit(retractation, cgu, plan) -> bool` (désactive tant qu'une case ou la formule manque)
|
||||||
|
|
||||||
- [ ] **Step 1 : Écrire les tests qui échouent**
|
- [ ] **Step 1 : Écrire les tests qui échouent**
|
||||||
|
|
||||||
@@ -615,86 +623,125 @@ def _plan_env(monkeypatch):
|
|||||||
monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien")
|
monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien")
|
||||||
|
|
||||||
|
|
||||||
def test_plan_choices_lists_both_with_prices():
|
def test_selectable_cards_render_both_plans():
|
||||||
from src.pages.compte import abonnement_mes_infos as m
|
from src.pages.compte import abonnement_mes_infos as m
|
||||||
|
|
||||||
choices = m._plan_choices()
|
text = str(m._selectable_cards(trial_for=lambda key: 2))
|
||||||
assert [c["value"] for c in choices] == ["simple", "soutien"]
|
assert "plan-card-simple" in text
|
||||||
assert any("20" in c["label"] for c in choices)
|
assert "plan-card-soutien" in text
|
||||||
assert any("50" in c["label"] for c in choices)
|
assert "plan-selectable" in text
|
||||||
|
assert "Abonnement de soutien" in text
|
||||||
|
|
||||||
|
|
||||||
def test_sync_plan_returns_selection():
|
def test_selection_state_simple():
|
||||||
from src.pages.compte import abonnement_mes_infos as m
|
from src.pages.compte import abonnement_mes_infos as m
|
||||||
|
|
||||||
assert m._sync_plan("soutien") == "soutien"
|
value, cls_simple, cls_soutien, cls_invite = m._selection_state("simple")
|
||||||
|
assert value == "simple"
|
||||||
|
assert "selected" in cls_simple
|
||||||
|
assert "selected" not in cls_soutien
|
||||||
|
assert cls_invite == "d-none"
|
||||||
|
|
||||||
|
|
||||||
def test_sync_plan_defaults_to_simple_when_empty():
|
def test_selection_state_soutien():
|
||||||
from src.pages.compte import abonnement_mes_infos as m
|
from src.pages.compte import abonnement_mes_infos as m
|
||||||
|
|
||||||
assert m._sync_plan(None) == "simple"
|
value, cls_simple, cls_soutien, _ = m._selection_state("soutien")
|
||||||
|
assert value == "soutien"
|
||||||
|
assert "selected" in cls_soutien
|
||||||
|
assert "selected" not in cls_simple
|
||||||
|
|
||||||
|
|
||||||
|
def test_submit_disabled_without_plan():
|
||||||
|
from src.pages.compte import abonnement_mes_infos as m
|
||||||
|
|
||||||
|
assert m._toggle_submit(["ok"], ["ok"], "") is True
|
||||||
|
assert m._toggle_submit(["ok"], ["ok"], "simple") is False
|
||||||
|
assert m._toggle_submit([], ["ok"], "simple") is True
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2 : Lancer les tests pour vérifier l'échec**
|
- [ ] **Step 2 : Lancer les tests pour vérifier l'échec**
|
||||||
|
|
||||||
Run: `uv run pytest tests/subscriptions/test_mes_infos_plan.py -v`
|
Run: `uv run pytest tests/subscriptions/test_mes_infos_plan.py -v`
|
||||||
Expected: FAIL (`_plan_choices` / `_sync_plan` inexistants)
|
Expected: FAIL (`_selectable_cards` / `_selection_state` inexistants)
|
||||||
|
|
||||||
- [ ] **Step 3 : Implémenter**
|
- [ ] **Step 3 : Implémenter**
|
||||||
|
|
||||||
Dans `src/pages/compte/abonnement_mes_infos.py` :
|
3a. Dans `src/pages/compte/abonnement_mes_infos.py`, compléter les imports :
|
||||||
|
ajouter `ctx` à l'import `from dash import ...`, ajouter `_plan_card` à l'import
|
||||||
3a. Ajouter l'import `plans` et le callback en tête (à côté des imports existants) :
|
existant depuis `src.pages.a_propos.abonnement`, et ajouter :
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
from src.subscriptions import db as sub_db
|
||||||
from src.subscriptions import plans
|
from src.subscriptions import plans
|
||||||
```
|
```
|
||||||
|
|
||||||
3b. Ajouter les helpers + callback (par ex. juste après `_csrf_input`) :
|
3b. Ajouter les helpers + callback de sélection (par ex. juste après `_csrf_input`) :
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def _plan_choices():
|
def _trial_for(user_id):
|
||||||
choices = []
|
used = sub_db.has_used_trial(user_id)
|
||||||
|
return lambda key: None if used else plans.trial_days(key)
|
||||||
|
|
||||||
|
|
||||||
|
def _selectable_cards(trial_for):
|
||||||
|
cols = []
|
||||||
for key in ("simple", "soutien"):
|
for key in ("simple", "soutien"):
|
||||||
meta = plans.plan_meta(key)
|
meta = plans.plan_meta(key)
|
||||||
if meta:
|
if not meta:
|
||||||
choices.append(
|
continue
|
||||||
{
|
cols.append(
|
||||||
"label": f" {meta['label']} — {meta['prix_ht']} € HT / mois",
|
dbc.Col(
|
||||||
"value": key,
|
html.Div(
|
||||||
}
|
_plan_card(meta, trial_for(key)),
|
||||||
|
id=f"plan-card-{key}",
|
||||||
|
n_clicks=0,
|
||||||
|
className="plan-selectable",
|
||||||
|
),
|
||||||
|
md=6,
|
||||||
)
|
)
|
||||||
return choices
|
)
|
||||||
|
return dbc.Row(cols, className="g-4 mb-2")
|
||||||
|
|
||||||
|
|
||||||
def _plan_selector(default="simple"):
|
def _selection_state(selected):
|
||||||
return html.Div(
|
base = "plan-selectable"
|
||||||
[
|
return (
|
||||||
dbc.Label("Formule *"),
|
selected,
|
||||||
dcc.RadioItems(
|
f"{base} selected" if selected == "simple" else base,
|
||||||
id="inf-plan",
|
f"{base} selected" if selected == "soutien" else base,
|
||||||
options=_plan_choices(),
|
"d-none",
|
||||||
value=default,
|
|
||||||
className="mb-3",
|
|
||||||
labelStyle={"display": "block"},
|
|
||||||
),
|
|
||||||
dcc.Input(
|
|
||||||
type="hidden", id="inf-plan-hidden", name="plan", value=default
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@callback(
|
@callback(
|
||||||
Output("inf-plan-hidden", "value"),
|
Output("inf-plan-hidden", "value"),
|
||||||
Input("inf-plan", "value"),
|
Output("plan-card-simple", "className"),
|
||||||
|
Output("plan-card-soutien", "className"),
|
||||||
|
Output("inf-plan-invite", "className"),
|
||||||
|
Input("plan-card-simple", "n_clicks"),
|
||||||
|
Input("plan-card-soutien", "n_clicks"),
|
||||||
|
prevent_initial_call=True,
|
||||||
)
|
)
|
||||||
def _sync_plan(value):
|
def _select_plan(_n_simple, _n_soutien):
|
||||||
return value or "simple"
|
selected = "simple" if ctx.triggered_id == "plan-card-simple" else "soutien"
|
||||||
|
return _selection_state(selected)
|
||||||
```
|
```
|
||||||
|
|
||||||
3c. Dans `layout`, **supprimer** la garde `?plan=` (lignes 53-55) :
|
3c. Modifier le callback `_toggle_submit` existant pour exiger aussi une formule :
|
||||||
|
|
||||||
|
```python
|
||||||
|
@callback(
|
||||||
|
Output("inf-submit", "disabled"),
|
||||||
|
Input("inf-cb-retractation", "value"),
|
||||||
|
Input("inf-cb-cgu", "value"),
|
||||||
|
Input("inf-plan-hidden", "value"),
|
||||||
|
)
|
||||||
|
def _toggle_submit(retractation, cgu, plan):
|
||||||
|
return not (retractation and cgu and plan)
|
||||||
|
```
|
||||||
|
|
||||||
|
3d. Dans `layout`, **supprimer** la garde `?plan=` (lignes 53-55) :
|
||||||
|
|
||||||
```python
|
```python
|
||||||
plan = query.get("plan", "")
|
plan = query.get("plan", "")
|
||||||
@@ -702,14 +749,20 @@ def _sync_plan(value):
|
|||||||
return dcc.Location(href="/compte/abonnement", id="inf-no-plan-redirect")
|
return dcc.Location(href="/compte/abonnement", id="inf-no-plan-redirect")
|
||||||
```
|
```
|
||||||
|
|
||||||
3d. Dans le `html.Form` (`children=`), **remplacer** la ligne
|
3e. Dans le `html.Form` (`children=`), **remplacer** la ligne
|
||||||
`dcc.Input(type="hidden", name="plan", value=plan),` par un appel au sélecteur en tête
|
`dcc.Input(type="hidden", name="plan", value=plan),` par l'invite, les cartes
|
||||||
de formulaire :
|
sélectionnables et le champ caché vide :
|
||||||
|
|
||||||
```python
|
```python
|
||||||
children=[
|
children=[
|
||||||
_csrf_input(),
|
_csrf_input(),
|
||||||
_plan_selector(),
|
html.Div(
|
||||||
|
"Choisissez votre formule :",
|
||||||
|
id="inf-plan-invite",
|
||||||
|
className="fw-bold mb-2",
|
||||||
|
),
|
||||||
|
_selectable_cards(_trial_for(current_user.id)),
|
||||||
|
dcc.Input(type="hidden", id="inf-plan-hidden", name="plan", value=""),
|
||||||
dbc.Row([col1, col2], className="g-4 mb-4"),
|
dbc.Row([col1, col2], className="g-4 mb-4"),
|
||||||
checkboxes,
|
checkboxes,
|
||||||
html.Button(
|
html.Button(
|
||||||
@@ -722,10 +775,25 @@ de formulaire :
|
|||||||
],
|
],
|
||||||
```
|
```
|
||||||
|
|
||||||
|
3f. Ajouter à la fin de `src/assets/css/style.css` :
|
||||||
|
|
||||||
|
```css
|
||||||
|
.plan-selectable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.plan-selectable .card {
|
||||||
|
transition: background-color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.plan-selectable.selected .card {
|
||||||
|
background-color: var(--bs-primary-bg-subtle);
|
||||||
|
border-color: var(--bs-primary);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
- [ ] **Step 4 : Lancer les tests pour vérifier le succès**
|
- [ ] **Step 4 : Lancer les tests pour vérifier le succès**
|
||||||
|
|
||||||
Run: `uv run pytest tests/subscriptions/test_mes_infos_plan.py -v`
|
Run: `uv run pytest tests/subscriptions/test_mes_infos_plan.py -v`
|
||||||
Expected: PASS (3 tests)
|
Expected: PASS (4 tests)
|
||||||
|
|
||||||
- [ ] **Step 5 : Vérifier qu'aucun test existant ne dépendait de la garde `?plan=`**
|
- [ ] **Step 5 : Vérifier qu'aucun test existant ne dépendait de la garde `?plan=`**
|
||||||
|
|
||||||
@@ -735,8 +803,8 @@ Expected: PASS
|
|||||||
- [ ] **Step 6 : Commit**
|
- [ ] **Step 6 : Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add src/pages/compte/abonnement_mes_infos.py tests/subscriptions/test_mes_infos_plan.py
|
git add src/pages/compte/abonnement_mes_infos.py src/assets/css/style.css tests/subscriptions/test_mes_infos_plan.py
|
||||||
git commit -m "feat(abonnement): sélecteur de formule dans mes-infos, plus de param plan requis"
|
git commit -m "feat(abonnement): sélection de formule par cartes cliquables dans mes-infos"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -767,7 +835,7 @@ montre « M'abonner ».
|
|||||||
- §1 page publique cards/explainer/bouton/trim CGU → Task 3 ✓
|
- §1 page publique cards/explainer/bouton/trim CGU → Task 3 ✓
|
||||||
- §2 bouton conditionnel (4 états) → Task 3 (`_subscribe_button`) ✓
|
- §2 bouton conditionnel (4 états) → Task 3 (`_subscribe_button`) ✓
|
||||||
- §3 `/compte/abonnement` M'abonner/Me réabonner via `has_used_trial` → Task 4 ✓
|
- §3 `/compte/abonnement` M'abonner/Me réabonner via `has_used_trial` → Task 4 ✓
|
||||||
- §4 mes-infos sélecteur de formule + suppression garde `?plan=` → Task 6 ✓
|
- §4 mes-infos cartes cliquables + gating submit + suppression garde `?plan=` → Task 6 ✓
|
||||||
- §5 `verify_email` auto-login → Task 2 ✓
|
- §5 `verify_email` auto-login → Task 2 ✓
|
||||||
- §6 CTA connexion → Task 5 ✓
|
- §6 CTA connexion → Task 5 ✓
|
||||||
- §7 `linkedin_button(next)` + inscription → Task 1 ✓
|
- §7 `linkedin_button(next)` + inscription → Task 1 ✓
|
||||||
@@ -775,5 +843,7 @@ montre « M'abonner ».
|
|||||||
**Placeholders :** aucun ; tout le code est fourni.
|
**Placeholders :** aucun ; tout le code est fourni.
|
||||||
|
|
||||||
**Cohérence des types :** `_subscribe_button(bool, bool, bool)`, `_reabo_button(bool)`,
|
**Cohérence des types :** `_subscribe_button(bool, bool, bool)`, `_reabo_button(bool)`,
|
||||||
`_plan_choices() -> list[dict]`, `_sync_plan(str|None) -> str`, `_plan_cards(trial_for)`
|
`_plan_cards(trial_for)` / `_plan_card(meta, trial)` (Task 3, réutilisé par Task 6),
|
||||||
— noms et signatures cohérents entre tâches et tests.
|
`_selectable_cards(trial_for) -> dbc.Row`, `_selection_state(str) -> tuple[str,str,str,str]`,
|
||||||
|
`_toggle_submit(retractation, cgu, plan) -> bool` — noms et signatures cohérents entre
|
||||||
|
tâches et tests.
|
||||||
|
|||||||
@@ -116,19 +116,29 @@ Bouton centré, `btn btn-primary`, largeur ajustée.
|
|||||||
|
|
||||||
- **Retirer** la redirection « pas de `?plan=` » (lignes 53-55) : la page est
|
- **Retirer** la redirection « pas de `?plan=` » (lignes 53-55) : la page est
|
||||||
accessible directement.
|
accessible directement.
|
||||||
- **Ajouter en tête de formulaire** un sélecteur de formule avec rappel des tarifs
|
- **Ajouter en tête de formulaire** une sélection de formule sous forme de **cartes
|
||||||
(20 € HT / 50 € HT). Contrainte technique Dash 3.4 : `html.Input` n'existe pas et
|
cliquables** (réutilisation de `_plan_card` de la page publique), avec rappel des
|
||||||
ni `dbc.Input` ni `dcc.Input` n'exposent `checked` ; un « radio natif »
|
tarifs (20 € HT / 50 € HT). UX voulue : aucune formule sélectionnée par défaut, un
|
||||||
présélectionnable n'est donc pas réalisable directement. Mécanisme retenu :
|
texte « Choisissez votre formule » invite l'utilisateur ; la carte sélectionnée
|
||||||
- un `dcc.RadioItems(id="inf-plan", value="simple")` pour l'UX (styled, défaut) ;
|
prend un fond légèrement teinté. Mécanisme (le POST du `html.Form` ne soumet
|
||||||
- un `dcc.Input(type="hidden", id="inf-plan-hidden", name="plan", value="simple")`
|
nativement qu'un champ portant `name`) :
|
||||||
— c'est CE champ, natif, qui est soumis dans le POST du `html.Form` (même
|
- deux cartes, chacune enveloppée dans un `html.Div` cliquable
|
||||||
mécanisme que le champ caché `plan` actuel, déjà lu par `subscribe()`) ;
|
(`id="plan-card-simple"|"plan-card-soutien"`, `n_clicks`, classe `plan-selectable`) ;
|
||||||
- un callback `Output("inf-plan-hidden","value"), Input("inf-plan","value")` qui
|
- un `dcc.Input(type="hidden", id="inf-plan-hidden", name="plan", value="")` — vide
|
||||||
recopie la formule choisie dans le champ caché.
|
au départ ; c'est CE champ, natif, qui est soumis (même mécanisme que le champ
|
||||||
- **Remplacer** le `dcc.Input(type="hidden", name="plan", value=plan)` fixe (ligne 232) par le champ caché synchronisé ci-dessus.
|
caché `plan` actuel, déjà lu par `subscribe()`) ;
|
||||||
- Le reste (prefill Frisbii, SIRET, cases rétractation/CGU, `_toggle_submit`) est
|
- un callback sur le `n_clicks` des deux cartes qui écrit la formule dans le champ
|
||||||
inchangé. `subscribe()` lit toujours `request.form.get("plan")` → compatible.
|
caché, applique la classe `selected` à la carte choisie (retirée de l'autre) et
|
||||||
|
masque l'invite ;
|
||||||
|
- un texte d'invite `id="inf-plan-invite"` visible tant qu'aucune formule n'est choisie.
|
||||||
|
- **Style** : ajouter dans `src/assets/css/style.css`
|
||||||
|
`.plan-selectable { cursor: pointer }` et
|
||||||
|
`.plan-selectable.selected .card { background-color: var(--bs-primary-bg-subtle); border-color: var(--bs-primary) }`.
|
||||||
|
- **Gating du bouton d'envoi** : `_toggle_submit` exige désormais aussi qu'une formule
|
||||||
|
soit sélectionnée (en plus des deux cases rétractation/CGU). Sans plan → désactivé.
|
||||||
|
- **Remplacer** le `dcc.Input(type="hidden", name="plan", value=plan)` fixe (ligne 232) par le champ caché vide synchronisé ci-dessus.
|
||||||
|
- Le reste (prefill Frisbii, SIRET, cases rétractation/CGU) est inchangé.
|
||||||
|
`subscribe()` lit toujours `request.form.get("plan")` → compatible.
|
||||||
|
|
||||||
### 5. `src/auth/routes.py` — `verify_email()`
|
### 5. `src/auth/routes.py` — `verify_email()`
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user