feat(abonnement): sélection de formule par cartes cliquables dans mes-infos

Remplace la garde ?plan= (redirection si absent) par une sélection
interactive : deux cartes de formule (réutilisées de la page publique via
_plan_card) cliquables, un callback recopie le choix dans un champ caché
natif soumis avec le POST du formulaire. Le bouton de soumission reste
désactivé tant qu'aucune formule n'est choisie (en plus des cases à
cocher existantes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Colin Maudry
2026-07-04 23:43:39 +02:00
parent 315c500005
commit 349fb17c90
3 changed files with 118 additions and 9 deletions
+11
View File
@@ -1010,3 +1010,14 @@ input[type="number"] {
font-size: 1.1rem;
color: #ccc;
}
.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);
}
+63 -9
View File
@@ -1,11 +1,13 @@
import dash_bootstrap_components as dbc
from dash import Input, Output, State, callback, dcc, html, register_page
from dash import Input, Output, State, callback, ctx, dcc, html, register_page
from flask_login import current_user
from src.auth import db as auth_db
from src.pages._compte_shell import account_guard, account_shell
from src.pages.a_propos.abonnement import subscription_terms
from src.pages.a_propos.abonnement import _plan_card, subscription_terms
from src.subscriptions import client as frisbii_client
from src.subscriptions import db as sub_db
from src.subscriptions import plans
from src.utils.data import get_annuaire_data
register_page(
@@ -25,6 +27,55 @@ def _csrf_input():
return dcc.Input(type="hidden", name="csrf_token", value=generate_csrf())
def _trial_for(user_id):
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"):
meta = plans.plan_meta(key)
if not meta:
continue
cols.append(
dbc.Col(
html.Div(
_plan_card(meta, trial_for(key)),
id=f"plan-card-{key}",
n_clicks=0,
className="plan-selectable",
),
md=6,
)
)
return dbc.Row(cols, className="g-4 mb-2")
def _selection_state(selected):
base = "plan-selectable"
return (
selected,
f"{base} selected" if selected == "simple" else base,
f"{base} selected" if selected == "soutien" else base,
"d-none",
)
@callback(
Output("inf-plan-hidden", "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 _select_plan(_n_simple, _n_soutien):
selected = "simple" if ctx.triggered_id == "plan-card-simple" else "soutien"
return _selection_state(selected)
def _cgu_modal():
return dbc.Modal(
[
@@ -50,10 +101,6 @@ def layout(**query):
if guard is not None:
return guard
plan = query.get("plan", "")
if not plan:
return dcc.Location(href="/compte/abonnement", id="inf-no-plan-redirect")
prefill: dict = {}
try:
prefill = frisbii_client.get_customer(f"colibre-{current_user.id}")
@@ -229,7 +276,13 @@ def layout(**query):
action="/subscriptions/subscribe",
children=[
_csrf_input(),
dcc.Input(type="hidden", name="plan", value=plan),
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"),
checkboxes,
html.Button(
@@ -305,9 +358,10 @@ def _lookup_siret(_, siret):
Output("inf-submit", "disabled"),
Input("inf-cb-retractation", "value"),
Input("inf-cb-cgu", "value"),
Input("inf-plan-hidden", "value"),
)
def _toggle_submit(retractation, cgu):
return not (retractation and cgu)
def _toggle_submit(retractation, cgu, plan):
return not (retractation and cgu and plan)
@callback(
@@ -0,0 +1,44 @@
import pytest
@pytest.fixture(autouse=True)
def _plan_env(monkeypatch):
monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple")
monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien")
def test_selectable_cards_render_both_plans():
from src.pages.compte import abonnement_mes_infos as m
text = str(m._selectable_cards(trial_for=lambda key: 2))
assert "plan-card-simple" in text
assert "plan-card-soutien" in text
assert "plan-selectable" in text
assert "Abonnement de soutien" in text
def test_selection_state_simple():
from src.pages.compte import abonnement_mes_infos as m
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_selection_state_soutien():
from src.pages.compte import abonnement_mes_infos as m
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