Files
colibre/src/pages/compte_abonnement.py
T
2026-06-29 17:15:36 +02:00

318 lines
9.9 KiB
Python

import dash_bootstrap_components as dbc
from dash import Input, Output, State, callback, dcc, html, register_page
from flask_login import current_user
from src.pages._compte_shell import account_guard, account_shell
from src.subscriptions import db, plans
register_page(
__name__,
path="/compte/abonnement",
title="Abonnement | decp.info",
name="Abonnement",
description="Gestion de votre abonnement decp.info.",
)
_PLAN_KEYS = ("simple", "soutien")
def _csrf_input():
from flask_wtf.csrf import generate_csrf
return dcc.Input(type="hidden", name="csrf_token", value=generate_csrf())
def _plan_card(meta: dict, trial: int | None, trial_used: bool):
from src.utils import TOUS_ABONNES
if trial_used:
badge = html.Div(
"Sans période d'essai (déjà utilisée) — débit immédiat",
className="text-muted mb-2",
)
elif trial:
badge = html.Div(f"{trial} jours d'essai gratuit", className="mb-3")
else:
badge = None
return dbc.Card(
dbc.CardBody(
[
html.H4(meta["label"], className="mb-1"),
html.P(f"{meta['prix_ht']} € HT / mois", className="text-muted mb-3"),
html.P(meta["description"], className="mb-3"),
badge,
html.A(
"S'abonner",
href=(
"#"
if TOUS_ABONNES
else f"/compte/abonnement/mes-infos?plan={meta['key']}"
),
className=(
"btn btn-secondary disabled"
if TOUS_ABONNES
else "btn btn-primary"
),
),
],
className="p-4",
),
className="h-100",
)
def _plan_cards(trial_used=False, trial_for=plans.trial_days):
cards = []
for key in _PLAN_KEYS:
meta = plans.plan_meta(key)
if meta:
cards.append(_plan_card(meta, trial_for(key), trial_used))
return dbc.Row([dbc.Col(c, md=6) for c in cards], className="g-4 mb-5")
def _explainer():
col_left = dbc.Col(
[
html.H4("Ce que les abonnements financent"),
html.Ul(
[
html.Li("Abonnement Frisbii (solution de paiement) : 50 €"),
html.Li("Serveur Scaleway : 40 €"),
html.Li("Espace de coworking : 250 €"),
html.Li(
[
"Salaire médian (",
html.Span(
"coût employeur",
id="salaire-modal-trigger",
style={
"cursor": "pointer",
"textDecoration": "underline",
"color": "var(--bs-link-color)",
},
),
") : 3 840 €",
]
),
]
),
],
md=6,
style={
"borderRight": "1px solid var(--bs-border-color)",
"paddingRight": "2rem",
},
)
col_right = dbc.Col(
[
html.H4("Ce que les abonnements de soutien permettent"),
html.Ul(
[
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")
def _active_view(row):
meta = plans.plan_meta(row["plan"]) or {"label": row["plan"]}
end = row["current_period_end"]
blocks = [html.H3(meta["label"], className="mb-3")]
if row["status"] == "pending":
blocks.extend(
[
dbc.Alert(
"Sans méthode de paiement enregistrée, votre abonnement sera "
"résilié à la fin de la période d'essai.",
color="warning",
className="mb-3",
),
html.Form(
method="POST",
action="/subscriptions/add-payment",
children=[
_csrf_input(),
html.Button(
"Ajouter une méthode de paiement",
type="submit",
className="btn btn-primary mb-3",
),
],
),
]
)
elif row["status"] == "trial":
blocks.append(
dbc.Alert(
f"Essai gratuit jusqu'au {end}, puis facturation et débit automatique à chaque date anniversaire.",
color="info",
)
)
elif row["status"] == "cancelled":
blocks.append(
dbc.Alert(f"Abonnement résilié, actif jusqu'au {end}.", color="warning")
)
else:
blocks.append(html.P(f"Prochain renouvellement : {end}"))
if row["status"] in ("pending", "trial", "active"):
blocks.append(
html.Button(
"Me désabonner",
id="resiliation-trigger",
n_clicks=0,
className="btn btn-outline-primary mt-3",
)
)
return html.Div(blocks)
def _resiliation_modal(end):
if end:
body_text = (
f"Êtes-vous sûr de vouloir mettre fin à votre abonnement ? "
f"Vous resterez abonné jusqu'à la date anniversaire, le {end}."
)
else:
body_text = "Êtes-vous sûr de vouloir mettre fin à votre abonnement ?"
return dbc.Modal(
[
dbc.ModalHeader(dbc.ModalTitle("Résilier l'abonnement")),
dbc.ModalBody(body_text),
dbc.ModalFooter(
[
dbc.Button(
"Annuler",
id="resiliation-annuler",
color="secondary",
className="me-2",
n_clicks=0,
),
html.Form(
method="POST",
action="/subscriptions/cancel",
children=[
_csrf_input(),
html.Button(
"Je suis sûr",
type="submit",
className="btn btn-outline-primary",
),
],
style={"display": "inline"},
),
]
),
],
id="resiliation-modal",
is_open=False,
)
def _feedback(query):
msgs = {
"succes": (
"Merci, votre abonnement est activé. Bonne exploration !",
"success",
),
"annule": ("Paiement annulé.", "secondary"),
}
out = []
if query.get("paiement") in msgs:
text, color = msgs[query["paiement"]]
out.append(dbc.Alert(text, color=color))
if query.get("resiliation") == "ok":
out.append(dbc.Alert("Votre abonnement a été résilié.", color="info"))
if query.get("error") == "frisbii":
out.append(
dbc.Alert(
"Une erreur est survenue avec le service de paiement.", color="primary"
)
)
return out
_salaire_modal = dbc.Modal(
[
dbc.ModalHeader(dbc.ModalTitle("Salaire médian — coût employeur")),
dbc.ModalBody(html.Img(src="/assets/salaire.png", style={"width": "100%"})),
],
id="salaire-modal",
size="lg",
is_open=False,
)
@callback(
Output("salaire-modal", "is_open"),
Input("salaire-modal-trigger", "n_clicks"),
prevent_initial_call=True,
)
def _open_salaire_modal(_):
return True
@callback(
Output("resiliation-modal", "is_open"),
Input("resiliation-trigger", "n_clicks"),
Input("resiliation-annuler", "n_clicks"),
State("resiliation-modal", "is_open"),
prevent_initial_call=True,
)
def _toggle_resiliation(_, __, is_open):
return not is_open
def _tous_abonnes_banner():
from src.utils import TOUS_ABONNES
if not TOUS_ABONNES:
return None
return 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",
)
def layout(**query):
guard = account_guard("/compte/abonnement", require_subscription=False)
if guard is not None:
return guard
row = db.get_by_user(current_user.id) if current_user.is_authenticated else None
trial_used = (
db.has_used_trial(current_user.id) if current_user.is_authenticated else False
)
body = [html.H2("Abonnement", className="mb-4")]
banner = _tous_abonnes_banner()
if banner is not None:
body.append(banner)
body.extend(_feedback(query))
if row is not None:
body.append(_active_view(row))
body.append(_resiliation_modal(row["current_period_end"]))
else:
body.extend([_plan_cards(trial_used=trial_used), _explainer()])
body.append(_salaire_modal)
return account_shell("abonnement", html.Div(body))