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:
@@ -1010,3 +1010,14 @@ input[type="number"] {
|
|||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
color: #ccc;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import dash_bootstrap_components as dbc
|
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 flask_login import current_user
|
||||||
|
|
||||||
from src.auth import db as auth_db
|
from src.auth import db as auth_db
|
||||||
from src.pages._compte_shell import account_guard, account_shell
|
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 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
|
from src.utils.data import get_annuaire_data
|
||||||
|
|
||||||
register_page(
|
register_page(
|
||||||
@@ -25,6 +27,55 @@ def _csrf_input():
|
|||||||
return dcc.Input(type="hidden", name="csrf_token", value=generate_csrf())
|
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():
|
def _cgu_modal():
|
||||||
return dbc.Modal(
|
return dbc.Modal(
|
||||||
[
|
[
|
||||||
@@ -50,10 +101,6 @@ def layout(**query):
|
|||||||
if guard is not None:
|
if guard is not None:
|
||||||
return guard
|
return guard
|
||||||
|
|
||||||
plan = query.get("plan", "")
|
|
||||||
if not plan:
|
|
||||||
return dcc.Location(href="/compte/abonnement", id="inf-no-plan-redirect")
|
|
||||||
|
|
||||||
prefill: dict = {}
|
prefill: dict = {}
|
||||||
try:
|
try:
|
||||||
prefill = frisbii_client.get_customer(f"colibre-{current_user.id}")
|
prefill = frisbii_client.get_customer(f"colibre-{current_user.id}")
|
||||||
@@ -229,7 +276,13 @@ def layout(**query):
|
|||||||
action="/subscriptions/subscribe",
|
action="/subscriptions/subscribe",
|
||||||
children=[
|
children=[
|
||||||
_csrf_input(),
|
_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"),
|
dbc.Row([col1, col2], className="g-4 mb-4"),
|
||||||
checkboxes,
|
checkboxes,
|
||||||
html.Button(
|
html.Button(
|
||||||
@@ -305,9 +358,10 @@ def _lookup_siret(_, siret):
|
|||||||
Output("inf-submit", "disabled"),
|
Output("inf-submit", "disabled"),
|
||||||
Input("inf-cb-retractation", "value"),
|
Input("inf-cb-retractation", "value"),
|
||||||
Input("inf-cb-cgu", "value"),
|
Input("inf-cb-cgu", "value"),
|
||||||
|
Input("inf-plan-hidden", "value"),
|
||||||
)
|
)
|
||||||
def _toggle_submit(retractation, cgu):
|
def _toggle_submit(retractation, cgu, plan):
|
||||||
return not (retractation and cgu)
|
return not (retractation and cgu and plan)
|
||||||
|
|
||||||
|
|
||||||
@callback(
|
@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
|
||||||
Reference in New Issue
Block a user