feat(abonnement): page publique avec cards et bouton Je m'abonne conditionnel

La page publique /a-propos/abonnement affiche désormais les cards de
formule (informatives, sans bouton par card), un explainer des
fonctionnalités, et un unique CTA "Je m'abonne" dont la cible dépend
de l'état d'authentification/abonnement. Retrait de la sous-section
"Fonctionnalités incluses" des CGU (doublon avec l'explainer).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Colin Maudry
2026-07-04 23:21:19 +02:00
parent 6464baebd2
commit e904ec4a7d
2 changed files with 178 additions and 12 deletions
+116 -12
View File
@@ -1,6 +1,11 @@
import dash_bootstrap_components as dbc
from dash import dcc, html, register_page
from flask_login import current_user
from src.pages._apropos_shell import apropos_shell
from src.subscriptions import db as sub_db
from src.subscriptions import plans
from src.utils import TOUS_ABONNES
from src.utils.seo import META_CONTENT
register_page(
@@ -17,6 +22,106 @@ abonnement_features = dcc.Markdown("""
""")
def _plan_card(meta: dict, trial: int | None):
badge = (
html.Div(f"{trial} jours d'essai gratuit", className="mb-3") if trial else None
)
return dbc.Card(
dbc.CardBody(
[
html.H4(meta["label"], className="mb-1"),
html.P(
f"{meta['prix_ht']} € HT / mois "
f"({str(int(meta['prix_ht']) * 1.2).replace('.0', '')} € TTC)",
className="text-muted mb-3",
),
html.P(meta["description"], className="mb-3"),
badge,
],
className="p-4",
),
className="h-100",
)
def _plan_cards(trial_for=plans.trial_days):
cards = []
for key in ("simple", "soutien"):
meta = plans.plan_meta(key)
if meta:
cards.append(_plan_card(meta, trial_for(key)))
return dbc.Row([dbc.Col(c, md=6) for c in cards], className="g-4 mb-4")
def _explainer():
col_left = dbc.Col(
[html.H4("Fonctionnalités réservées aux abonné·es :"), abonnement_features],
md=6,
style={
"borderRight": "1px solid var(--bs-border-color)",
"paddingRight": "2rem",
},
)
col_right = dbc.Col(
[
html.H4("Ce que les abonnements permettent"),
html.Ul(
[
html.Li(
"passer plus de temps à développer colibre et moins de temps "
"à chercher des missions"
),
html.Li(
"rédaction d'études à partir des données, par exemple sur les "
"acheteurs dont les données sont introuvables et les raisons "
"de cette non-publication."
),
html.Li(
"fédération des bonnes volontés souhaitant militer pour une "
"législation plus ambitieuse sur la transparence de la "
"commande publique."
),
]
),
],
md=6,
style={"paddingLeft": "2rem"},
)
return dbc.Row([col_left, col_right], className="align-items-start pt-2 mb-4")
def _subscribe_button(
authenticated: bool, has_active_subscription: bool, tous_abonnes: bool
):
if tous_abonnes:
return html.Div(
[
dbc.Alert(
"Les fonctionnalités normalement accessibles contre un abonnement "
"de 20 € HT par mois sont accessibles à tous et toutes en attendant "
"la validation de mon dossier pour recevoir des paiements.",
color="info",
),
html.A(
"Je m'abonne",
href="#",
className="btn btn-secondary disabled",
),
],
className="text-center my-4",
)
if authenticated and has_active_subscription:
label, href = "Gérer mon abonnement", "/compte/abonnement"
elif authenticated:
label, href = "Je m'abonne", "/compte/abonnement/mes-infos"
else:
label, href = "Je m'abonne", "/inscription"
return html.Div(
html.A(label, href=href, className="btn btn-primary btn-lg"),
className="text-center my-4",
)
subscription_terms = html.Div(
[
html.H2("Abonnement"),
@@ -26,17 +131,6 @@ L'accès aux fonctionnalités de base de colibre est gratuit et sans inscription
il est possible de souscrire à un abonnement mensuel qui donne accès à des fonctionnalités supplémentaires. Cet abonnement s'adresse tant aux professionnel·les qu'aux particuliers.
"""
),
html.H4("Fonctionnalités incluses"),
dcc.Markdown(
"""
Les fonctionnalités accessibles aux abonné·es évoluent au fil du développement du service.
Fonctionnalités réservées aux abonné·es :
"""
),
abonnement_features,
dcc.Markdown("""
Les fonctionnalités en cours de développement et soumises au vote sont visibles dans la section [Roadmap](/a-propos/roadmap)."""),
html.H4("Tarifs"),
dcc.Markdown(
"""
@@ -112,4 +206,14 @@ Conformément au RGPD, vous pouvez demander l'accès, la rectification ou la sup
def layout(**_):
return apropos_shell("abonnement", subscription_terms)
authenticated = current_user.is_authenticated
has_active = authenticated and sub_db.has_active_subscription(current_user.id)
body = html.Div(
[
_plan_cards(),
_explainer(),
_subscribe_button(authenticated, has_active, TOUS_ABONNES),
subscription_terms,
]
)
return apropos_shell("abonnement", body)
+62
View File
@@ -0,0 +1,62 @@
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_plan_cards_informative_no_button():
from src.app import app # noqa: F401
from src.pages.a_propos import abonnement as page
text = str(page._plan_cards(trial_for=lambda key: 2))
assert "Abonnement" in text
assert "Abonnement de soutien" in text
assert "2 jours d'essai gratuit" in text
assert "S'abonner" not in text
def test_subscribe_button_visitor_goes_to_inscription():
from src.app import app # noqa: F401
from src.pages.a_propos import abonnement as page
text = str(page._subscribe_button(False, False, False))
assert "Je m'abonne" in text
assert "href='/inscription'" in text
def test_subscribe_button_authenticated_no_sub_goes_to_mes_infos():
from src.app import app # noqa: F401
from src.pages.a_propos import abonnement as page
text = str(page._subscribe_button(True, False, False))
assert "href='/compte/abonnement/mes-infos'" in text
def test_subscribe_button_active_sub_manages():
from src.app import app # noqa: F401
from src.pages.a_propos import abonnement as page
text = str(page._subscribe_button(True, True, False))
assert "Gérer mon abonnement" in text
assert "href='/compte/abonnement'" in text
def test_subscribe_button_disabled_when_tous_abonnes():
from src.app import app # noqa: F401
from src.pages.a_propos import abonnement as page
text = str(page._subscribe_button(False, False, True))
assert "disabled" in text
def test_cgu_terms_trimmed_of_features_section():
from src.app import app # noqa: F401
from src.pages.a_propos import abonnement as page
text = str(page.subscription_terms)
assert "Fonctionnalités incluses" not in text
assert "Résiliation" in text
assert "Tarifs" in text