From 349fb17c9006eb26f140e0e2bac76cc205de2bf1 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Sat, 4 Jul 2026 23:43:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(abonnement):=20s=C3=A9lection=20de=20formu?= =?UTF-8?q?le=20par=20cartes=20cliquables=20dans=20mes-infos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/assets/css/style.css | 11 ++++ src/pages/compte/abonnement_mes_infos.py | 72 +++++++++++++++++++--- tests/subscriptions/test_mes_infos_plan.py | 44 +++++++++++++ 3 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 tests/subscriptions/test_mes_infos_plan.py diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 9f8e92b..1549cfe 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -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); +} diff --git a/src/pages/compte/abonnement_mes_infos.py b/src/pages/compte/abonnement_mes_infos.py index b4606d2..85f0c22 100644 --- a/src/pages/compte/abonnement_mes_infos.py +++ b/src/pages/compte/abonnement_mes_infos.py @@ -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( diff --git a/tests/subscriptions/test_mes_infos_plan.py b/tests/subscriptions/test_mes_infos_plan.py new file mode 100644 index 0000000..542ccbc --- /dev/null +++ b/tests/subscriptions/test_mes_infos_plan.py @@ -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