From 127d0e338e1b7f755ca31e457b0a1b652613af7a Mon Sep 17 00:00:00 2001 From: Bernard Siebens Date: Thu, 16 Jul 2026 16:44:37 +0200 Subject: [PATCH] Modularize and streamline billing templates; replace `_billing_form.html` with reusable modals and shared partials, and update styles and interactions for consistency and clarity. billing, feature management, and forms Refactored club detail templates to modularize common UI components. Standardized layout, interactions, and styles across admin, billing, and feature cards for consistency and reusability. --- billing/services/dues.py | 2 +- controlpanel/mixins.py | 20 + controlpanel/services/statistics.py | 14 + .../templates/controlpanel/_billing_form.html | 40 -- .../controlpanel/_club_admins_card.html | 43 ++ .../controlpanel/_club_billing_card.html | 124 ++++++ .../controlpanel/_club_features_card.html | 45 +++ .../controlpanel/_club_health_table.html | 2 +- .../templates/controlpanel/_form_fields.html | 29 ++ .../templates/controlpanel/_modal_form.html | 28 ++ .../templates/controlpanel/admin_form.html | 21 +- .../templates/controlpanel/billing.html | 52 ++- .../controlpanel/club_admin_form.html | 18 +- .../templates/controlpanel/club_detail.html | 180 +-------- .../templates/controlpanel/features.html | 23 +- .../templates/controlpanel/flag_form.html | 18 +- .../templates/controlpanel/payment_form.html | 14 - .../templates/controlpanel/period_form.html | 12 - .../controlpanel/subscription_form.html | 8 - .../templates/controlpanel/tier_form.html | 8 - .../controlpanel/tier_price_form.html | 8 - controlpanel/templatetags/ui.py | 11 + controlpanel/tests.py | 28 +- controlpanel/views.py | 171 ++++---- static/css/app.css | 371 ++++++++++++++++++ 25 files changed, 873 insertions(+), 417 deletions(-) delete mode 100644 controlpanel/templates/controlpanel/_billing_form.html create mode 100644 controlpanel/templates/controlpanel/_club_admins_card.html create mode 100644 controlpanel/templates/controlpanel/_club_billing_card.html create mode 100644 controlpanel/templates/controlpanel/_club_features_card.html create mode 100644 controlpanel/templates/controlpanel/_form_fields.html create mode 100644 controlpanel/templates/controlpanel/_modal_form.html delete mode 100644 controlpanel/templates/controlpanel/payment_form.html delete mode 100644 controlpanel/templates/controlpanel/period_form.html delete mode 100644 controlpanel/templates/controlpanel/subscription_form.html delete mode 100644 controlpanel/templates/controlpanel/tier_form.html delete mode 100644 controlpanel/templates/controlpanel/tier_price_form.html diff --git a/billing/services/dues.py b/billing/services/dues.py index a39837f..17ebe4a 100644 --- a/billing/services/dues.py +++ b/billing/services/dues.py @@ -114,7 +114,7 @@ def waive(due: Due, *, note: str = "") -> Due: return due -def owing_dues(today: date | None = None): +def owing_dues(): return Due.objects.filter(status__in=Due.OWING) diff --git a/controlpanel/mixins.py b/controlpanel/mixins.py index d573a09..31b438b 100644 --- a/controlpanel/mixins.py +++ b/controlpanel/mixins.py @@ -1,5 +1,7 @@ +from django.contrib import messages from django.contrib.auth.mixins import UserPassesTestMixin from django.http import Http404 +from django.shortcuts import redirect class PlatformStaffRequiredMixin(UserPassesTestMixin): @@ -38,3 +40,21 @@ class PlatformSuperuserRequiredMixin(PlatformStaffRequiredMixin): def test_func(self): return self.request.user.is_superuser + + +class RedirectOnInvalidMixin: + """A form submitted from a modal has nowhere sensible to re-render on error: the page + that opened it has already moved on, and the view has no standalone template of its + own. Redirect back to ``invalid_redirect_url_name`` instead, with the errors flattened + into messages, rather than Django's default of re-rendering ``template_name``. + """ + + invalid_redirect_url_name = None + + def get_invalid_redirect_kwargs(self): + return {} + + def form_invalid(self, form): + for error in form.errors.values(): + messages.error(self.request, " ".join(error)) + return redirect(self.invalid_redirect_url_name, **self.get_invalid_redirect_kwargs()) diff --git a/controlpanel/services/statistics.py b/controlpanel/services/statistics.py index 724eb3f..dada718 100644 --- a/controlpanel/services/statistics.py +++ b/controlpanel/services/statistics.py @@ -155,6 +155,20 @@ def onboarding_funnel(): ] +def flags_for_club(club): + """Every flag, annotated with whether it is on for this club and why.""" + enabled_ids = set(club.flags.values_list("pk", flat=True)) + return [ + { + "flag": flag, + "enabled": flag.pk in enabled_ids, + # `everyone` overrides club targeting, so the per-club toggle is moot. + "overridden": flag.everyone is not None, + } + for flag in get_waffle_flag_model().objects.order_by("name") + ] + + def flag_adoption(): """Clubs per feature flag. `everyone` overrides club targeting, so a flag set that way is on (or off) everywhere and its club count says nothing — hence `overridden`.""" diff --git a/controlpanel/templates/controlpanel/_billing_form.html b/controlpanel/templates/controlpanel/_billing_form.html deleted file mode 100644 index 1ec68c7..0000000 --- a/controlpanel/templates/controlpanel/_billing_form.html +++ /dev/null @@ -1,40 +0,0 @@ -{% load lucide ui %} - -{% comment %} - The shell every billing form uses: card, fields, cancel + submit. Included with - `heading`, `blurb`, `submit_label`, `submit_icon` and `cancel_url`. -{% endcomment %} -
-
- {% if blurb %}

{{ blurb }}

{% endif %} -
- {% csrf_token %} - {% for error in form.non_field_errors %} -
- {{ error }} -
- {% endfor %} - {% for field in form %} -
- {% if field.field.widget.input_type == "checkbox" %} - - {% else %} - - {{ field|daisy }} - {% endif %} - {% if field.help_text %}{{ field.help_text }}{% endif %} - {% for error in field.errors %}{{ error }}{% endfor %} -
- {% endfor %} -
- {% lucide "arrow-left" size=16 %} Cancel - -
-
-
-
diff --git a/controlpanel/templates/controlpanel/_club_admins_card.html b/controlpanel/templates/controlpanel/_club_admins_card.html new file mode 100644 index 0000000..56c59ad --- /dev/null +++ b/controlpanel/templates/controlpanel/_club_admins_card.html @@ -0,0 +1,43 @@ +{% load lucide %} + +{% comment %} + Club-scoped admins, and the form to add one. Included with `club`, `admins` already + in context. +{% endcomment %} +
+
+
+

{% lucide "shield-user" size=18 %} Club admins

+ {% lucide "user-plus" size=16 %} Add admin +
+
+ + + + + + + + + + {% for role in admins %} + + + + + + {% empty %} + + + + {% endfor %} + +
NameEmail
{{ role.member }}{{ role.member.user.email|default:"—" }} +
+ {% csrf_token %} + +
+
No admins yet.
+
+
+
diff --git a/controlpanel/templates/controlpanel/_club_billing_card.html b/controlpanel/templates/controlpanel/_club_billing_card.html new file mode 100644 index 0000000..beaabfb --- /dev/null +++ b/controlpanel/templates/controlpanel/_club_billing_card.html @@ -0,0 +1,124 @@ +{% load lucide ui %} + +{% comment %} + What the platform bills this club: plan, periods, and the modals for changing plan, + opening a period, and recording a payment. Included with `club`, `subscription`, + `dues`, `today`, `subscription_form`, `open_period_form`, `open_period_blurb` already + in context. +{% endcomment %} +
+
+
+

{% lucide "receipt-euro" size=18 %} Billing

+
+ + {% if subscription %} + + {% endif %} +
+
+ + {% if not subscription %} +

This club is not billed for anything. Put it on a tier to start.

+ {% else %} +

+ On plan {{ subscription.tier.name }}. + {% if subscription.auto_renew %} + Renews automatically 30 days before the period ends. + {% else %} + Auto-renew off — you must open each period by hand, or this club uses the platform for free. + {% endif %} + {% if subscription.auto_archive %} + Archived automatically when a period goes unpaid past its grace period. + {% else %} + Auto-archive off — it will never be archived for non-payment. + {% endif %} +

+ +
+ + + + + + + + + + + + {% for due in dues %} + + + + + + + + {% for payment in due.payments.all %} + + + + + + {% endfor %} + {% empty %} + + + + {% endfor %} + +
PeriodBilledPaidStatus
+ {{ due.period_start|date:"j M Y" }} — {{ due.period_end|date:"j M Y" }} +
{{ due.tier.name }} · {{ due.invoice.number }} · grace to {{ due.grace_until|date:"j M Y" }}
+
€{{ due.amount|floatformat:2 }}€{{ due.amount_paid|floatformat:2 }} + {% if due.status == "paid" %} + {% lucide "check" size=12 %} Paid + {% elif due.status == "waived" %} + {% lucide "check" size=12 %} Waived + {% elif due.grace_until < today %} + {% lucide "triangle-alert" size=12 %} Overdue + {% elif due.period_end < today %} + {% lucide "hourglass" size=12 %} In grace + {% else %} + {{ due.get_status_display }} + {% endif %} + + {% if due.is_owing %} + + {% if not due.payments.all %} +
+ {% csrf_token %} + +
+ {% endif %} + {% endif %} + {% lucide "file-down" size=14 %} Download invoice +
+ {% lucide "corner-down-right" size=12 %} + {{ payment.paid_at|date:"j M Y" }} · {{ payment.get_method_display }}{% if payment.reference %} · {{ payment.reference }}{% endif %} + €{{ payment.amount|floatformat:2 }}
No periods billed yet.
+
+ + {% comment %} Dialogs live outside the table: may only contain elements. {% endcomment %} + {% for due in dues %} + {% if due.is_owing %} + {% url 'controlpanel:due_pay' due.pk as due_pay_url %} + {% include "controlpanel/_modal_form.html" with modal_id=due.pk|dom_id:"due_pay_modal" title="Record payment" form=due.payment_form action_url=due_pay_url submit_label="Record payment" submit_icon="banknote" %} + {% endif %} + {% endfor %} + {% endif %} +
+
+ +{% url 'controlpanel:club_subscribe' club.pk as subscribe_url %} +{% include "controlpanel/_modal_form.html" with modal_id="subscription_modal" title=subscription|yesno:"Change plan,Start billing" form=subscription_form action_url=subscribe_url submit_label="Save plan" submit_icon="layers" blurb="Changing tier does not re-bill: the current period keeps the amount it was issued at, and the new rate applies from the next one." %} + +{% if subscription %} + {% url 'controlpanel:club_open_period' club.pk as open_period_url %} + {% include "controlpanel/_modal_form.html" with modal_id="open_period_modal" title=club.is_archived|yesno:"Reactivate,Open period" form=open_period_form action_url=open_period_url submit_label="Open period" submit_icon="calendar-plus" blurb=open_period_blurb %} +{% endif %} diff --git a/controlpanel/templates/controlpanel/_club_features_card.html b/controlpanel/templates/controlpanel/_club_features_card.html new file mode 100644 index 0000000..e335918 --- /dev/null +++ b/controlpanel/templates/controlpanel/_club_features_card.html @@ -0,0 +1,45 @@ +{% load lucide %} + +{% comment %} + Which feature flags apply to this club. Included with `club`, `flags` (from + `flags_for_club`) already in context. +{% endcomment %} +
+
+
+

{% lucide "toggle-right" size=18 %} Features

+ {% lucide "wrench" size=14 %} Manage features +
+
+ + + {% for entry in flags %} + + + + + + {% empty %} + + + + {% endfor %} + +
{{ entry.flag.name }}{{ entry.flag.note|default:"—" }} + {% if entry.overridden %} + {# `everyone` overrides club targeting, so a per-club toggle would be a lie. #} + + {% if entry.flag.everyone %}On for all clubs{% else %}Off everywhere{% endif %} + + {% else %} +
+ {% csrf_token %} + +
+ {% endif %} +
No features defined yet.
+
+
+
diff --git a/controlpanel/templates/controlpanel/_club_health_table.html b/controlpanel/templates/controlpanel/_club_health_table.html index 6f9bf2d..f85df8a 100644 --- a/controlpanel/templates/controlpanel/_club_health_table.html +++ b/controlpanel/templates/controlpanel/_club_health_table.html @@ -100,7 +100,7 @@ {% elif club.covered_until %} paid {% else %} - + - {% endif %} {% else %} diff --git a/controlpanel/templates/controlpanel/_form_fields.html b/controlpanel/templates/controlpanel/_form_fields.html new file mode 100644 index 0000000..7b31099 --- /dev/null +++ b/controlpanel/templates/controlpanel/_form_fields.html @@ -0,0 +1,29 @@ +{% load ui %} + +{% comment %} + The field loop every card-form and modal-form wrapper shares: label, daisyUI-styled + widget, help text, errors — with checkboxes laid out label-beside-input instead of + label-above. Included with `form`. +{% endcomment %} +{% for error in form.non_field_errors %} +
+ {{ error }} +
+{% endfor %} +{% for field in form %} +
+ {% if field.field.widget.input_type == "checkbox" %} + + {% else %} + + {{ field|daisy }} + {% endif %} + {% if field.help_text %}{{ field.help_text }}{% endif %} + {% for error in field.errors %}{{ error }}{% endfor %} +
+{% endfor %} diff --git a/controlpanel/templates/controlpanel/_modal_form.html b/controlpanel/templates/controlpanel/_modal_form.html new file mode 100644 index 0000000..1955fb3 --- /dev/null +++ b/controlpanel/templates/controlpanel/_modal_form.html @@ -0,0 +1,28 @@ +{% load lucide %} + +{% comment %} + A daisyUI native modal wrapping a Django form that posts straight to + `action_url`. Included with `modal_id`, `title`, `form`, `action_url`, `submit_label` + and `submit_icon`, plus an optional `blurb`. The submit button sits outside the form + tag (linked via the `form` attribute) so it can share the `modal-action` row with the + dialog-closing Cancel button without nesting one
inside another. +{% endcomment %} + + + +
diff --git a/controlpanel/templates/controlpanel/admin_form.html b/controlpanel/templates/controlpanel/admin_form.html index 9ee2d28..88112ad 100644 --- a/controlpanel/templates/controlpanel/admin_form.html +++ b/controlpanel/templates/controlpanel/admin_form.html @@ -1,5 +1,5 @@ {% extends "controlpanel/base.html" %} -{% load lucide ui %} +{% load lucide %} {% block heading %}Grant platform access{% endblock heading %} @@ -11,24 +11,7 @@
{% csrf_token %} - {% for error in form.non_field_errors %} -
{{ error }}
- {% endfor %} - {% for field in form %} -
- {% if field.field.widget.input_type == "checkbox" %} - - {% else %} - - {{ field|daisy }} - {% endif %} - {% if field.help_text %}{{ field.help_text }}{% endif %} - {% for error in field.errors %}{{ error }}{% endfor %} -
- {% endfor %} + {% include "controlpanel/_form_fields.html" %}
{% lucide "arrow-left" size=16 %} Cancel diff --git a/controlpanel/templates/controlpanel/billing.html b/controlpanel/templates/controlpanel/billing.html index e8f57a3..a826711 100644 --- a/controlpanel/templates/controlpanel/billing.html +++ b/controlpanel/templates/controlpanel/billing.html @@ -1,28 +1,30 @@ {% extends "controlpanel/base.html" %} -{% load lucide %} +{% load lucide ui %} {% block heading %}Billing{% endblock heading %} -{% block subheading %}

What the platform charges its clubs.

{% endblock subheading %} {% block actions %} - {% lucide "plus" size=16 %} New tier + {% endblock actions %} {% block panel %} + {% url 'controlpanel:tier_create' as tier_create_url %} + {% include "controlpanel/_modal_form.html" with modal_id="tier_create_modal" title="New plan" form=tier_form action_url=tier_create_url submit_label="Create plan" submit_icon="plus" %} +
-

{% lucide "layers" size=18 %} Tiers

+

{% lucide "layers" size=18 %} Plans

{% comment %} Prices are dated, not edited. A rate change is a new row with a future active_from; every period already opened keeps the amount it was billed at, so raising the price cannot rewrite an invoice you have already sent. {% endcomment %} -

A rate change is a new dated price. Periods already billed keep the amount they were issued at.

+

A rate change only takes effect as of a certain date. Periods already billed keep the amount they were issued at.

- + @@ -34,7 +36,8 @@ - {% empty %} - + {% endfor %} @@ -64,6 +67,15 @@ + {% comment %} Dialogs live outside the table: may only contain elements. {% endcomment %} + {% for tier in tiers %} + {% url 'controlpanel:tier_price_create' tier.pk as tier_price_url %} + {% include "controlpanel/_modal_form.html" with modal_id=tier.pk|dom_id:"tier_price_modal" title="New price — "|add:tier.name form=tier.price_form action_url=tier_price_url submit_label="Add price" submit_icon="euro" %} + + {% url 'controlpanel:tier_update' tier.pk as tier_update_url %} + {% include "controlpanel/_modal_form.html" with modal_id=tier.pk|dom_id:"tier_edit_modal" title="Edit "|add:tier.name form=tier.edit_form action_url=tier_update_url submit_label="Save" submit_icon="check" %} + {% endfor %} +

{% lucide "receipt-euro" size=18 %} Owed

@@ -74,7 +86,7 @@
- + @@ -90,18 +102,18 @@
Grace to {{ due.grace_until|date:"j M Y" }}
- - {% empty %} @@ -114,4 +126,10 @@ + + {% comment %} Dialogs live outside the table: may only contain elements. {% endcomment %} + {% for due in owing %} + {% url 'controlpanel:due_pay' due.pk as due_pay_url %} + {% include "controlpanel/_modal_form.html" with modal_id=due.pk|dom_id:"due_pay_modal" title="Record payment — "|add:due.club.name form=due.payment_form action_url=due_pay_url submit_label="Record payment" submit_icon="banknote" %} + {% endfor %} {% endblock panel %} diff --git a/controlpanel/templates/controlpanel/club_admin_form.html b/controlpanel/templates/controlpanel/club_admin_form.html index ee6cbc6..8921561 100644 --- a/controlpanel/templates/controlpanel/club_admin_form.html +++ b/controlpanel/templates/controlpanel/club_admin_form.html @@ -1,5 +1,5 @@ {% extends "controlpanel/base.html" %} -{% load lucide ui %} +{% load lucide %} {% block heading %}Add an admin to {{ club }}{% endblock heading %} @@ -11,21 +11,7 @@ {% csrf_token %} - {% for error in form.non_field_errors %} -
- {{ error }} -
- {% endfor %} - {% for field in form %} -
- - {{ field|daisy }} - {% if field.help_text %}{{ field.help_text }}{% endif %} - {% for error in field.errors %}{{ error }}{% endfor %} -
- {% endfor %} + {% include "controlpanel/_form_fields.html" %}
{% lucide "arrow-left" size=16 %} Cancel diff --git a/controlpanel/templates/controlpanel/club_detail.html b/controlpanel/templates/controlpanel/club_detail.html index 8e9fa6f..6790aea 100644 --- a/controlpanel/templates/controlpanel/club_detail.html +++ b/controlpanel/templates/controlpanel/club_detail.html @@ -181,183 +181,9 @@
{% endfor %} -
-
-
-

{% lucide "toggle-right" size=18 %} Features

- {% lucide "wrench" size=14 %} Manage features -
-
-
TierPlan Clubs Prices
{{ tier.name }}
{% if not tier.is_active %}Retired{% endif %} - {% if tier.description %}
{{ tier.description }}
{% endif %} + {% if tier.description %} +
{{ tier.description }}
{% endif %}
{{ tier.club_count }} @@ -48,14 +51,14 @@ No price — cannot be billed {% endfor %} - {% lucide "euro" size=14 %} New price - {% lucide "pencil" size=14 %} Edit + + +
No tiers yet.No plans yet.
Club Period OwedStatusStatus
€{{ due.balance|floatformat:2 }} + {% if due.grace_until < today %} {% lucide "triangle-alert" size=12 %} Overdue {% elif due.period_end < today %} {% lucide "hourglass" size=12 %} In grace {% else %} - {{ due.get_status_display }} + {{ due.get_status_display }} {% endif %} - {% lucide "banknote" size=14 %} Record payment - {% lucide "file-text" size=14 %} Invoice + + + {% lucide "file-down" size=14 %} Download invoice
- - {% for entry in flags %} - - - - - - {% empty %} - - - - {% endfor %} - -
{{ entry.flag.name }}{{ entry.flag.note|default:"—" }} - {% if entry.overridden %} - {# `everyone` overrides club targeting, so a per-club toggle would be a lie. #} - - {% if entry.flag.everyone %}On for all clubs{% else %}Off everywhere{% endif %} - - {% else %} - - {% csrf_token %} - - - {% endif %} -
No features defined yet.
-
-
-
-
-
- - - {% if not subscription %} -

This club is not billed for anything. Put it on a tier to start.

- {% else %} -

- On plan {{ subscription.tier.name }}. - {% if subscription.auto_renew %} - Renews automatically 30 days before the period ends. - {% else %} - Auto-renew off — you must open each period by hand, or this club uses the platform for free. - {% endif %} - {% if subscription.auto_archive %} - Archived automatically when a period goes unpaid past its grace period. - {% else %} - Auto-archive off — it will never be archived for non-payment. - {% endif %} -

- -
- - - - - - - - - - - - {% for due in dues %} - - - - - - - - {% for payment in due.payments.all %} - - - - - - {% endfor %} - {% empty %} - - - - {% endfor %} - -
PeriodBilledPaidStatus
- {{ due.period_start|date:"j M Y" }} — {{ due.period_end|date:"j M Y" }} -
{{ due.tier.name }} · {{ due.invoice.number }} · grace to {{ due.grace_until|date:"j M Y" }}
-
€{{ due.amount|floatformat:2 }}€{{ due.amount_paid|floatformat:2 }} - {% if due.status == "paid" %} - {% lucide "check" size=12 %} Paid - {% elif due.status == "waived" %} - {% lucide "check" size=12 %} Waived - {% elif due.grace_until < today %} - {% lucide "triangle-alert" size=12 %} Overdue - {% elif due.period_end < today %} - {% lucide "hourglass" size=12 %} In grace - {% else %} - {{ due.get_status_display }} - {% endif %} - - {% if due.is_owing %} - {% lucide "banknote" size=14 %} Pay - {% if not due.payments.all %} -
- {% csrf_token %} - -
- {% endif %} - {% endif %} - {% lucide "file-down" size=14 %} Download invoice -
- {% lucide "corner-down-right" size=12 %} - {{ payment.paid_at|date:"j M Y" }} · {{ payment.get_method_display }}{% if payment.reference %} · {{ payment.reference }}{% endif %} - €{{ payment.amount|floatformat:2 }}
No periods billed yet.
-
- {% endif %} -
-
- -
-
-
-

{% lucide "shield-user" size=18 %} Club admins

- {% lucide "user-plus" size=16 %} Add admin -
-
- - - - - - - - - - {% for role in admins %} - - - - - - {% empty %} - - - - {% endfor %} - -
NameEmail
{{ role.member }}{{ role.member.user.email|default:"—" }} -
- {% csrf_token %} - -
-
No admins yet.
-
-
-
+ {% include "controlpanel/_club_features_card.html" %} + {% include "controlpanel/_club_billing_card.html" %} + {% include "controlpanel/_club_admins_card.html" %} {% endblock panel %} {% block extra_body %} diff --git a/controlpanel/templates/controlpanel/features.html b/controlpanel/templates/controlpanel/features.html index 6527131..4410bb5 100644 --- a/controlpanel/templates/controlpanel/features.html +++ b/controlpanel/templates/controlpanel/features.html @@ -16,14 +16,21 @@
-
-

{% lucide "wrench" size=18 %} Maintenance mode

+
+

{% lucide "wrench" size=18 %} Maintenance mode

{% if maintenance.is_active %} -

+

{% lucide "lock" size=12 %} Platform closed - since {{ maintenance.started_at|date:"j M Y, H:i" }}{% if maintenance.started_by %} by {{ maintenance.started_by.email }}{% endif %}. -

- {% if maintenance.message %}

“{{ maintenance.message }}”

{% endif %} +
·
+
since {{ maintenance.started_at|date:"j M Y, H:i" }}{% if maintenance.started_by %} by {{ maintenance.started_by.email }}{% endif %}
+
+ + {% if maintenance.message %} +
+
Message
+
{{ maintenance.message }}
+
+ {% endif %} {% else %}

Closes every club subdomain and stands the scheduled jobs down. The control panel and the sign-in screens stay open. @@ -35,12 +42,12 @@

{% csrf_token %} {% if not maintenance.is_active %} -
+
{{ maintenance_form.message|daisy }} - {{ maintenance_form.message.help_text }} + {{ maintenance_form.message.help_text }}
{% else %} diff --git a/controlpanel/templates/controlpanel/flag_form.html b/controlpanel/templates/controlpanel/flag_form.html index 9e9f830..5b9c833 100644 --- a/controlpanel/templates/controlpanel/flag_form.html +++ b/controlpanel/templates/controlpanel/flag_form.html @@ -1,5 +1,5 @@ {% extends "controlpanel/base.html" %} -{% load lucide ui %} +{% load lucide %} {% block heading %}{% if object %}Edit {{ object.name }}{% else %}New feature{% endif %}{% endblock heading %} @@ -8,21 +8,7 @@
{% csrf_token %} - {% for error in form.non_field_errors %} -
- {{ error }} -
- {% endfor %} - {% for field in form %} -
- - {{ field|daisy }} - {% if field.help_text %}{{ field.help_text }}{% endif %} - {% for error in field.errors %}{{ error }}{% endfor %} -
- {% endfor %} + {% include "controlpanel/_form_fields.html" %}
{% lucide "arrow-left" size=16 %} Cancel diff --git a/controlpanel/templates/controlpanel/payment_form.html b/controlpanel/templates/controlpanel/payment_form.html deleted file mode 100644 index b547539..0000000 --- a/controlpanel/templates/controlpanel/payment_form.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "controlpanel/base.html" %} - -{% block heading %}Record payment — {{ due.club }}{% endblock heading %} - -{% block subheading %} -

- {{ due.period_start|date:"j M Y" }} to {{ due.period_end|date:"j M Y" }} · €{{ due.amount|floatformat:2 }} billed · €{{ due.balance|floatformat:2 }} outstanding -

-{% endblock subheading %} - -{% block panel %} - {% url 'controlpanel:club_detail' due.club.pk as club_url %} - {% include "controlpanel/_billing_form.html" with cancel_url=club_url submit_label="Record payment" submit_icon="banknote" blurb="Part payments are fine: they accumulate against the period until it is settled." %} -{% endblock panel %} diff --git a/controlpanel/templates/controlpanel/period_form.html b/controlpanel/templates/controlpanel/period_form.html deleted file mode 100644 index 0d5bbe6..0000000 --- a/controlpanel/templates/controlpanel/period_form.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "controlpanel/base.html" %} - -{% block heading %}{% if club.is_archived %}Reactivate{% else %}Open period{% endif %} — {{ club }}{% endblock heading %} - -{% block subheading %} -

Next period starts {{ next_start|date:"j M Y" }} unless you say otherwise.

-{% endblock subheading %} - -{% block panel %} - {% url 'controlpanel:club_detail' club.pk as club_url %} - {% include "controlpanel/_billing_form.html" with cancel_url=club_url submit_label="Open period" submit_icon="calendar-plus" blurb="By default the period continues from the end of the last one, so a lapsed year is still owed. Pick a start date to forgive the gap." %} -{% endblock panel %} diff --git a/controlpanel/templates/controlpanel/subscription_form.html b/controlpanel/templates/controlpanel/subscription_form.html deleted file mode 100644 index 0557502..0000000 --- a/controlpanel/templates/controlpanel/subscription_form.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends "controlpanel/base.html" %} - -{% block heading %}{% if subscription %}Change plan{% else %}Start billing{% endif %} — {{ club }}{% endblock heading %} - -{% block panel %} - {% url 'controlpanel:club_detail' club.pk as club_url %} - {% include "controlpanel/_billing_form.html" with cancel_url=club_url submit_label="Save plan" submit_icon="layers" blurb="Changing tier does not re-bill: the current period keeps the amount it was issued at, and the new rate applies from the next one." %} -{% endblock panel %} diff --git a/controlpanel/templates/controlpanel/tier_form.html b/controlpanel/templates/controlpanel/tier_form.html deleted file mode 100644 index d38cb77..0000000 --- a/controlpanel/templates/controlpanel/tier_form.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends "controlpanel/base.html" %} - -{% block heading %}{% if object %}Edit {{ object }}{% else %}New tier{% endif %}{% endblock heading %} - -{% block panel %} - {% url 'controlpanel:billing' as billing_url %} - {% include "controlpanel/_billing_form.html" with cancel_url=billing_url submit_label="Save tier" submit_icon="layers" blurb="A tier has no price until you add one. A tier with no price cannot be billed." %} -{% endblock panel %} diff --git a/controlpanel/templates/controlpanel/tier_price_form.html b/controlpanel/templates/controlpanel/tier_price_form.html deleted file mode 100644 index 5802ebf..0000000 --- a/controlpanel/templates/controlpanel/tier_price_form.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends "controlpanel/base.html" %} - -{% block heading %}New price for {{ tier }}{% endblock heading %} - -{% block panel %} - {% url 'controlpanel:billing' as billing_url %} - {% include "controlpanel/_billing_form.html" with cancel_url=billing_url submit_label="Add price" submit_icon="euro" blurb="Prices are dated, never edited: periods already opened keep the amount they were billed at, so this cannot rewrite an invoice you have already sent." %} -{% endblock panel %} diff --git a/controlpanel/templatetags/ui.py b/controlpanel/templatetags/ui.py index 4c2cfda..0d79141 100644 --- a/controlpanel/templatetags/ui.py +++ b/controlpanel/templatetags/ui.py @@ -99,6 +99,17 @@ def excluded(field, names): return field.name in (names or "").split(",") +@register.filter +def dom_id(pk, prefix): + """A stable per-row DOM id, e.g. ``{{ due.pk|dom_id:"due_pay_modal" }}``. + + Django templates cannot concatenate a string literal with a non-string filter + argument directly (``"prefix_"|add:some_uuid`` raises), which is what a per-row modal + id needs. This is the one place that builds one, so every call site reads the same way. + """ + return f"{prefix}_{pk}" + + @register.filter def daisy(field, css=None): """Render a bound form field with the right daisyUI classes. diff --git a/controlpanel/tests.py b/controlpanel/tests.py index e129590..f58423d 100644 --- a/controlpanel/tests.py +++ b/controlpanel/tests.py @@ -1297,7 +1297,9 @@ class BillingFormRenderTests(ControlPanelTestBase): self.tier = Tier.objects.create(name="Standard") TierPrice.objects.create(tier=self.tier, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00")) - def test_the_billing_forms_render(self): + def test_the_billing_forms_are_post_only(self): + # Every one of these is reachable only through a modal on the billing or club + # detail page: there is no standalone template to render on a GET. subscribe(self.club, self.tier) due = self.club.dues.first() @@ -1309,17 +1311,33 @@ class BillingFormRenderTests(ControlPanelTestBase): reverse("controlpanel:club_open_period", args=[self.club.pk]), reverse("controlpanel:due_pay", args=[due.pk]), ): - self.assertEqual(self.client.get(url).status_code, 200, url) + self.assertEqual(self.client.get(url).status_code, 405, url) - def test_the_payment_form_defaults_to_the_outstanding_balance(self): + def test_the_billing_forms_redirect_with_a_message_on_invalid_input(self): + # Rejected input has nowhere to re-render — the modal that submitted it is on a + # page this view no longer serves — so it must bounce back with an error message + # rather than 500 or silently drop the submission. + subscribe(self.club, self.tier) + due = self.club.dues.first() + + response = self.client.post(reverse("controlpanel:tier_create"), {"name": "", "description": "", "is_active": "on"}, follow=True) + self.assertRedirects(response, reverse("controlpanel:billing")) + self.assertContains(response, "This field is required") + + response = self.client.post(reverse("controlpanel:due_pay", args=[due.pk]), {"amount": "not-a-number", "method": "bank_transfer", "reference": "", "paid_at": "", "note": ""}, follow=True) + self.assertRedirects(response, reverse("controlpanel:club_detail", args=[self.club.pk])) + self.assertContains(response, "Enter a number") + + def test_the_payment_modal_defaults_to_the_outstanding_balance(self): subscribe(self.club, self.tier) due = self.club.dues.first() record_payment(due, Decimal("200.00")) due.refresh_from_db() - response = self.client.get(reverse("controlpanel:due_pay", args=[due.pk])) + response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk])) - self.assertEqual(response.context["form"].initial["amount"], Decimal("300.00")) + rendered_due = next(rendered for rendered in response.context["dues"] if rendered.pk == due.pk) + self.assertEqual(rendered_due.payment_form.initial["amount"], Decimal("300.00")) def test_a_tier_can_be_renamed(self): self.client.post(reverse("controlpanel:tier_update", args=[self.tier.pk]), {"name": "Standard plus", "description": "", "is_active": "on"}) diff --git a/controlpanel/views.py b/controlpanel/views.py index 0bb70fe..25b3538 100644 --- a/controlpanel/views.py +++ b/controlpanel/views.py @@ -1,3 +1,5 @@ +from contextlib import contextmanager + from django.contrib import messages from django.contrib.auth import get_user_model from django.db.models import Count @@ -5,6 +7,7 @@ from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse from django.utils import timezone +from django.utils.formats import date_format from django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View from waffle import get_waffle_flag_model, get_waffle_switch_model @@ -16,7 +19,7 @@ from club.models import Club, ClubRole from features.models import Maintenance from .forms import ClubAdminForm, ClubForm, DuePaymentForm, FlagForm, MaintenanceForm, OpenPeriodForm, PlatformAdminForm, SubscriptionForm, TierForm, TierPriceForm -from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin +from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin, RedirectOnInvalidMixin from .services.admins import grant_club_admin, revoke_club_admin from .services.platform_admins import ( PlatformAdminError, @@ -25,12 +28,26 @@ from .services.platform_admins import ( revoke_platform_access, set_platform_access, ) -from .services.statistics import club_attention, club_charts, club_statistics, clubs_with_health, flag_adoption, onboarding_funnel, platform_attention, platform_charts, platform_totals +from .services.statistics import club_attention, club_charts, club_statistics, clubs_with_health, flag_adoption, flags_for_club, onboarding_funnel, platform_attention, platform_charts, platform_totals Flag = get_waffle_flag_model() Switch = get_waffle_switch_model() +@contextmanager +def suppress_billing_errors(request): + """Turn a BillingError into an error message rather than letting it propagate. + + Fits call sites that fall through to the same redirect on the happy and unhappy path + alike — the success message is set inside the block, the failure message by this + context manager, and whichever fired, the caller's next line runs unchanged. + """ + try: + yield + except BillingError as error: + messages.error(request, str(error)) + + class DashboardView(PlatformStaffRequiredMixin, TemplateView): template_name = "controlpanel/dashboard.html" @@ -107,16 +124,29 @@ class ClubDetailView(PlatformStaffRequiredMixin, DetailView): context_object_name = "club" def get_context_data(self, **kwargs): + subscription = getattr(self.object, "subscription", None) + next_start = next_period_start(self.object) + + # Bound per-row so each due's "Add payment" modal can render its own form without + # the template calling DuePaymentForm(initial=...) itself. + dues = list(self.object.dues.select_related("tier", "invoice").prefetch_related("payments")) + for due in dues: + if due.is_owing: + due.payment_form = DuePaymentForm(initial={"amount": due.balance}) + return super().get_context_data( nav="clubs", groups=club_statistics(self.object), attention=club_attention(self.object), charts=club_charts(self.object), - subscription=getattr(self.object, "subscription", None), - dues=self.object.dues.select_related("tier", "invoice").prefetch_related("payments"), + subscription=subscription, + dues=dues, today=timezone.localdate(), admins=ClubRole.objects.filter(club=self.object, role=ClubRole.Roles.ADMIN).select_related("member", "member__user"), flags=flags_for_club(self.object), + open_period_form=OpenPeriodForm(), + open_period_blurb=f"Next period starts {date_format(next_start, 'j M Y')} unless you say otherwise. By default it continues from the end of the last one, so a lapsed year is still owed — pick a start date to forgive the gap.", + subscription_form=SubscriptionForm(instance=subscription) if subscription else SubscriptionForm(), **kwargs, ) @@ -165,20 +195,6 @@ class ClubAdminRemoveView(PlatformStaffRequiredMixin, View): return redirect("controlpanel:club_detail", pk=pk) -def flags_for_club(club): - """Every flag, annotated with whether it is on for this club and why.""" - enabled_ids = set(club.flags.values_list("pk", flat=True)) - return [ - { - "flag": flag, - "enabled": flag.pk in enabled_ids, - # `everyone` overrides club targeting, so the per-club toggle is moot. - "overridden": flag.everyone is not None, - } - for flag in Flag.objects.order_by("name") - ] - - class ClubFeatureToggleView(PlatformStaffRequiredMixin, View): """Turn a feature on or off for one club.""" @@ -328,56 +344,74 @@ class BillingView(PlatformStaffRequiredMixin, TemplateView): def get_context_data(self, **kwargs): today = timezone.localdate() + + # Bound per-row so each "Edit" / "New price" modal can render its own form: the + # template can't call TierForm(instance=tier) itself, so the form rides along on + # the object it belongs to. + tiers = list(Tier.objects.prefetch_related("prices").annotate(club_count=Count("subscriptions"))) + for tier in tiers: + tier.edit_form = TierForm(instance=tier) + tier.price_form = TierPriceForm() + + owing = list(Due.objects.filter(status__in=Due.OWING).select_related("club", "tier").order_by("grace_until")) + for due in owing: + due.payment_form = DuePaymentForm(initial={"amount": due.balance}) + return super().get_context_data( nav="billing", - tiers=Tier.objects.prefetch_related("prices").annotate(club_count=Count("subscriptions")), - owing=Due.objects.filter(status__in=Due.OWING).select_related("club", "tier").order_by("grace_until"), + tiers=tiers, + tier_form=TierForm(), + owing=owing, today=today, **kwargs, ) -class TierCreateView(PlatformStaffRequiredMixin, CreateView): +class TierCreateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, CreateView): + """Reachable only via the "New plan" modal on the billing page — POST-only, and there + is no standalone template to render on GET or on a rejected submission.""" + model = Tier form_class = TierForm - template_name = "controlpanel/tier_form.html" - - def get_context_data(self, **kwargs): - return super().get_context_data(nav="billing", **kwargs) + http_method_names = ["post"] + invalid_redirect_url_name = "controlpanel:billing" def get_success_url(self): messages.success(self.request, f"Tier “{self.object}” created. Give it a price before billing anyone.") return reverse("controlpanel:billing") -class TierUpdateView(PlatformStaffRequiredMixin, UpdateView): +class TierUpdateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, UpdateView): + """Reachable only via a tier's "Edit" modal on the billing page — POST-only, and there + is no standalone template to render on GET or on a rejected submission.""" + model = Tier form_class = TierForm - template_name = "controlpanel/tier_form.html" - - def get_context_data(self, **kwargs): - return super().get_context_data(nav="billing", **kwargs) + http_method_names = ["post"] + invalid_redirect_url_name = "controlpanel:billing" def get_success_url(self): messages.success(self.request, f"Tier “{self.object}” updated.") return reverse("controlpanel:billing") -class TierPriceCreateView(PlatformStaffRequiredMixin, CreateView): +class TierPriceCreateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, CreateView): """A rate change is a new dated price, never an edit of the old one — periods already - billed keep the amount they were billed at.""" + billed keep the amount they were billed at. + + Reachable only via a tier's "New price" modal on the billing page — POST-only, and + there is no standalone template to render on GET or on a rejected submission. + """ model = TierPrice form_class = TierPriceForm - template_name = "controlpanel/tier_price_form.html" + http_method_names = ["post"] + invalid_redirect_url_name = "controlpanel:billing" @property def tier(self): return get_object_or_404(Tier, pk=self.kwargs["pk"]) - def get_context_data(self, **kwargs): - return super().get_context_data(nav="billing", tier=self.tier, **kwargs) - def form_valid(self, form): form.instance.tier = self.tier response = super().form_valid(form) @@ -388,16 +422,24 @@ class TierPriceCreateView(PlatformStaffRequiredMixin, CreateView): return reverse("controlpanel:billing") -class SubscribeClubView(PlatformStaffRequiredMixin, FormView): - """Put a club on a tier, which opens its first period.""" +class SubscribeClubView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormView): + """Put a club on a tier, which opens its first period. + + Reachable only via the "Change plan" modal on the club detail page — POST-only, and + there is no standalone template to render on GET or on a rejected submission. + """ form_class = SubscriptionForm - template_name = "controlpanel/subscription_form.html" + http_method_names = ["post"] + invalid_redirect_url_name = "controlpanel:club_detail" @property def club(self): return get_object_or_404(Club, pk=self.kwargs["pk"]) + def get_invalid_redirect_kwargs(self): + return {"pk": self.club.pk} + def get_form_kwargs(self): kwargs = super().get_form_kwargs() subscription = getattr(self.club, "subscription", None) @@ -405,13 +447,10 @@ class SubscribeClubView(PlatformStaffRequiredMixin, FormView): kwargs["instance"] = subscription return kwargs - def get_context_data(self, **kwargs): - return super().get_context_data(nav="clubs", club=self.club, subscription=getattr(self.club, "subscription", None), **kwargs) - def form_valid(self, form): club = self.club existing = getattr(club, "subscription", None) - try: + with suppress_billing_errors(self.request): if existing: # Changing tier does not re-bill: the current period keeps the amount it was # issued at, and the new rate applies from the next one. @@ -422,29 +461,29 @@ class SubscribeClubView(PlatformStaffRequiredMixin, FormView): else: subscribe(club, form.cleaned_data["tier"], start=form.cleaned_data.get("start"), auto_archive=form.cleaned_data["auto_archive"], auto_renew=form.cleaned_data["auto_renew"]) messages.success(self.request, f"{club} is on {form.cleaned_data['tier']}. Its first period is open.") - except BillingError as error: - messages.error(self.request, str(error)) return redirect("controlpanel:club_detail", pk=club.pk) -class RecordPaymentView(PlatformStaffRequiredMixin, FormView): +class RecordPaymentView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormView): + """Reachable only via a due's "Record payment" modal on the club or billing page — + POST-only, and there is no standalone template to render on GET or on a rejected + submission.""" + form_class = DuePaymentForm - template_name = "controlpanel/payment_form.html" + http_method_names = ["post"] + invalid_redirect_url_name = "controlpanel:club_detail" @property def due(self): return get_object_or_404(Due.objects.select_related("club", "tier"), pk=self.kwargs["pk"]) - def get_initial(self): - return {"amount": self.due.balance} - - def get_context_data(self, **kwargs): - return super().get_context_data(nav="clubs", due=self.due, **kwargs) + def get_invalid_redirect_kwargs(self): + return {"pk": self.due.club_id} def form_valid(self, form): due = self.due - try: + with suppress_billing_errors(self.request): record_payment( due, form.cleaned_data["amount"], @@ -456,8 +495,6 @@ class RecordPaymentView(PlatformStaffRequiredMixin, FormView): ) due.refresh_from_db() messages.success(self.request, f"€{form.cleaned_data['amount']} recorded. {due.get_status_display().capitalize()} — €{due.balance} outstanding.") - except BillingError as error: - messages.error(self.request, str(error)) return redirect("controlpanel:club_detail", pk=due.club_id) @@ -465,37 +502,37 @@ class RecordPaymentView(PlatformStaffRequiredMixin, FormView): class WaiveDueView(PlatformStaffRequiredMixin, View): def post(self, request, pk): due = get_object_or_404(Due, pk=pk) - try: + with suppress_billing_errors(request): waive(due) messages.warning(request, f"Period {due.period_start} to {due.period_end} waived. Nothing is owed and the club will not be archived for it.") - except BillingError as error: - messages.error(request, str(error)) return redirect("controlpanel:club_detail", pk=due.club_id) -class OpenPeriodView(PlatformStaffRequiredMixin, FormView): - """Renew a club, or reactivate an archived one.""" +class OpenPeriodView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, FormView): + """Renew a club, or reactivate an archived one. + + Reachable only via the "Open period" modal on the club detail page — POST-only, and + there is no standalone template to render on GET or on a rejected submission. + """ form_class = OpenPeriodForm - template_name = "controlpanel/period_form.html" + http_method_names = ["post"] + invalid_redirect_url_name = "controlpanel:club_detail" @property def club(self): return get_object_or_404(Club, pk=self.kwargs["pk"]) - def get_context_data(self, **kwargs): - club = self.club - return super().get_context_data(nav="clubs", club=club, next_start=next_period_start(club), **kwargs) + def get_invalid_redirect_kwargs(self): + return {"pk": self.club.pk} def form_valid(self, form): club = self.club start = form.cleaned_data.get("start") - try: + with suppress_billing_errors(self.request): due = reactivate(club, start=start) if club.is_archived else open_period(club, start=start) messages.success(self.request, f"Period {due.period_start} to {due.period_end} opened for €{due.amount}. Invoice {due.invoice.number}.") - except BillingError as error: - messages.error(self.request, str(error)) return redirect("controlpanel:club_detail", pk=club.pk) diff --git a/static/css/app.css b/static/css/app.css index 834591d..2dfb048 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -196,6 +196,69 @@ } } @layer utilities { + .modal { + @layer daisyui.l1.l2.l3 { + pointer-events: none; + visibility: hidden; + position: fixed; + inset: 0; + margin: 0; + display: grid; + height: 100%; + max-height: none; + width: 100%; + max-width: none; + align-items: center; + justify-items: center; + background-color: transparent; + padding: 0; + color: inherit; + transition: overlay 0.3s allow-discrete, visibility 0.3s allow-discrete, background-color 0.3s ease-out, opacity 0.1s ease-out; + overflow: clip; + overscroll-behavior: contain; + z-index: 999; + scrollbar-gutter: auto; + &::backdrop { + display: none; + } + &[popover] { + inset: 0; + margin: 0; + border: 0; + padding: 0; + background: transparent; + color: inherit; + max-width: none; + max-height: none; + &::backdrop { + background-color: oklch(0% 0 0/ 0.4); + transition: background-color 0.3s ease-out; + } + } + } + @layer daisyui.l1.l2 { + &.modal-open, &[open], &:popover-open, &:target, .modal-toggle:checked + & { + pointer-events: auto; + visibility: visible; + opacity: 100%; + transition: visibility 0s allow-discrete, background-color 0.3s ease-out, opacity 0.1s ease-out; + background-color: oklch(0% 0 0/ 0.4); + .modal-box { + translate: 0 0; + scale: 1; + opacity: 1; + } + :root:has(&) { + --page-scroll-lock: ; + } + } + @starting-style { + &.modal-open, &[open], &:popover-open, &:target, .modal-toggle:checked + & { + opacity: 0%; + } + } + } + } .tooltip { @layer daisyui.l1.l2.l3 { position: relative; @@ -627,6 +690,26 @@ } } } + .collapse-plus { + @layer daisyui.l1.l2 { + > .collapse-title:after { + position: absolute; + display: block; + height: 0.5rem; + width: 0.5rem; + @media (prefers-reduced-motion: no-preference) { + transition-property: all; + transition-duration: 300ms; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + } + top: 0.9rem; + inset-inline-end: 1.4rem; + --tw-content: "+"; + content: var(--tw-content); + pointer-events: none; + } + } + } .dropdown { @layer daisyui.l1.l2.l3 { position: relative; @@ -1080,6 +1163,21 @@ } } } + .collapse-open { + @layer daisyui.l1.l2 { + grid-template-rows: max-content 1fr; + > .collapse-content { + --overflow-delay: 0.2s; + overflow: revert-layer; + content-visibility: visible; + min-height: fit-content; + padding-bottom: 1rem; + @supports not (content-visibility: visible) { + visibility: visible; + } + } + } + } .collapse { visibility: collapse; } @@ -1148,6 +1246,27 @@ } } } + .toast { + @layer daisyui.l1.l2.l3 { + position: fixed; + inset-inline-start: auto; + inset-inline-end: calc(0.25rem * 4); + top: auto; + bottom: calc(0.25rem * 4); + display: flex; + flex-direction: column; + gap: calc(0.25rem * 2); + background-color: transparent; + translate: var(--toast-x, 0) var(--toast-y, 0); + width: max-content; + max-width: calc(100vw - 2rem); + & > * { + @media (prefers-reduced-motion: no-preference) { + animation: toast 0.25s ease-out; + } + } + } + } .toggle { @layer daisyui.l1.l2.l3 { border: var(--border) solid currentColor; @@ -1501,6 +1620,51 @@ } } } + .aura { + @layer daisyui.l1.l2.l3 { + position: relative; + display: inline-block; + --aura-padding: 0.125rem; + padding: var(--aura-padding); + border-radius: calc(var(--aura-padding) + var(--aura-radius, var(--radius-box))); + animation: aura var(--tw-duration, 6s) linear infinite; + background-image: conic-gradient(from var(--aura-angle), transparent 225deg, currentColor); + &:has( > .card, > .alert) { + --aura-radius: var(--radius-box); + } + &:has( > .btn, > .input, > .select) { + --aura-radius: var(--radius-field); + } + &:has( > .checkbox, > .toggle, > .badge) { + --aura-radius: var(--radius-selector); + } + &:before, &:after { + animation: inherit; + background-color: inherit; + background-image: inherit; + border-radius: inherit; + position: absolute; + top: calc(1 / 2 * 100%); + left: calc(1 / 2 * 100%); + z-index: 0; + display: block; + opacity: 70%; + filter: blur(0.25rem); + translate: -50% -50%; + width: 100%; + height: 100%; + content: ""; + } + &:after { + opacity: 30%; + filter: blur(1rem); + } + & > * { + position: relative; + z-index: 1; + } + } + } .steps { @layer daisyui.l1.l2.l3 { display: inline-grid; @@ -2030,6 +2194,48 @@ } } } + .rating { + @layer daisyui.l1.l2.l3 { + position: relative; + display: inline-flex; + vertical-align: middle; + --size: var(--size-selector, 0.25rem) * 6; + input { + cursor: pointer; + appearance: none; + } + * { + border-radius: 0; + background-color: var(--color-base-content); + opacity: 20%; + width: calc(var(--size) * 1); + height: calc(var(--size)); + @media (prefers-reduced-motion: no-preference) { + animation: rating 0.25s ease-out; + } + } + .rating-hidden { + width: calc(0.25rem * 2); + background-color: transparent; + } + :checked, [aria-checked="true"], [aria-current="true"], :has( ~ :checked, ~ [aria-checked="true"], ~ [aria-current="true"]) { + opacity: 100%; + } + :focus-visible { + scale: 1.1; + @media (prefers-reduced-motion: no-preference) { + transition: scale 0.2s ease-out; + } + } + :active:focus { + animation: none; + scale: 1.1; + } + } + @layer daisyui.l1.l2 { + --size: var(--size-selector, 0.25rem) * 6; + } + } .navbar { @layer daisyui.l1.l2.l3 { display: flex; @@ -2163,6 +2369,30 @@ .sticky { position: sticky; } + .dropdown-right { + @layer daisyui.l1.l2 { + --anchor-h: right; + --anchor-v: span-bottom; + .dropdown-content { + inset-inline-start: 100%; + top: 0; + bottom: auto; + transform-origin: 0; + } + } + } + .dropdown-left { + @layer daisyui.l1.l2 { + --anchor-h: left; + --anchor-v: span-bottom; + .dropdown-content { + inset-inline-end: 100%; + top: 0; + bottom: auto; + transform-origin: 100%; + } + } + } .dropdown-end { @layer daisyui.l1.l2 { --anchor-h: span-left; @@ -2544,6 +2774,20 @@ } } } + .modal-backdrop { + @layer daisyui.l1.l2.l3 { + grid-column-start: 1; + grid-row-start: 1; + display: grid; + align-self: stretch; + justify-self: stretch; + color: transparent; + z-index: -1; + button { + cursor: pointer; + } + } + } .z-10 { z-index: 10; } @@ -2560,6 +2804,27 @@ } } } + .modal-box { + @layer daisyui.l1.l2.l3 { + grid-column-start: 1; + grid-row-start: 1; + max-height: 100vh; + width: calc(11 / 12 * 100%); + max-width: 32rem; + background-color: var(--color-base-100); + padding: calc(0.25rem * 6); + transition: translate 0.3s ease-out, scale 0.3s ease-out, opacity 0.2s ease-out 0.05s, box-shadow 0.3s ease-out; + border-top-left-radius: var(--modal-tl, var(--radius-box)); + border-top-right-radius: var(--modal-tr, var(--radius-box)); + border-bottom-left-radius: var(--modal-bl, var(--radius-box)); + border-bottom-right-radius: var(--modal-br, var(--radius-box)); + scale: 95%; + opacity: 0; + box-shadow: oklch(0% 0 0/ 0.25) 0px 25px 50px -12px; + overflow-y: auto; + overscroll-behavior: contain; + } + } .container { width: 100%; @media (width >= 40rem) { @@ -2662,6 +2927,14 @@ } } } + .modal-action { + @layer daisyui.l1.l2.l3 { + margin-top: calc(0.25rem * 6); + display: flex; + justify-content: flex-end; + gap: calc(0.25rem * 2); + } + } .mt-1 { margin-top: var(--spacing); } @@ -2943,6 +3216,9 @@ .inline-flex { display: inline-flex; } + .inline-grid { + display: inline-grid; + } .table { display: table; } @@ -2988,15 +3264,52 @@ .flex-1 { flex: 1; } + .flex-shrink { + flex-shrink: 1; + } .shrink-0 { flex-shrink: 0; } + .flex-grow { + flex-grow: 1; + } .grow { flex-grow: 1; } .border-collapse { border-collapse: collapse; } + .transform { + transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); + } + .skeleton { + @layer daisyui.l1.l2.l3 { + border-radius: var(--radius-box); + background-color: var(--color-base-300); + @media (prefers-reduced-motion: reduce) { + transition-duration: 15s; + } + will-change: background-position; + background-image: linear-gradient( 105deg, #0000 0% 40%, var(--color-base-100) 50%, #0000 60% 100% ); + background-size: 200% auto; + background-position-x: -50%; + @media (prefers-reduced-motion: no-preference) { + animation: skeleton 1.8s ease-in-out infinite; + } + } + } + .aura-glow { + @layer daisyui.l1.l2 { + animation: none; + background-image: radial-gradient(closest-corner at center, currentColor 0%, transparent 90%); + &:before { + animation: aura-glow var(--tw-duration, 6s) ease-out infinite; + } + &:after { + animation: aura-glow-after var(--tw-duration, 6s) ease-out infinite; + } + } + } .link { @layer daisyui.l1.l2.l3 { cursor: pointer; @@ -3202,6 +3515,9 @@ .bg-base-200 { background-color: var(--color-base-200); } + .bg-base-300 { + background-color: var(--color-base-300); + } .bg-neutral { background-color: var(--color-neutral); } @@ -3287,6 +3603,9 @@ --btn-shadow: 0 0 0 0 oklch(0% 0 0/0); } } + .mask-repeat { + mask-repeat: repeat; + } .object-contain { object-fit: contain; } @@ -3299,6 +3618,9 @@ .p-4 { padding: calc(var(--spacing) * 4); } + .px-4 { + padding-inline: calc(var(--spacing) * 4); + } .px-6 { padding-inline: calc(var(--spacing) * 6); } @@ -3451,6 +3773,9 @@ color: var(--color-warning); } } + .text-base-content { + color: var(--color-base-content); + } .text-base-content\/50 { color: var(--color-base-content); @supports (color: color-mix(in lab, red, red)) { @@ -3503,6 +3828,9 @@ text-decoration-line: none; } } + .underline { + text-decoration-line: underline; + } .opacity-50 { opacity: 50%; } @@ -3534,6 +3862,19 @@ .filter { filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } + .transition { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .ease-in-out { + --tw-ease: var(--ease-in-out); + transition-timing-function: var(--ease-in-out); + } + .ease-out { + --tw-ease: var(--ease-out); + transition-timing-function: var(--ease-out); + } .input-lg { @layer daisyui.l1.l2 { --in-size-mul: 12; @@ -4098,6 +4439,26 @@ opacity: 0; } } +@property --tw-rotate-x { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-y { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-z { + syntax: "*"; + inherits: false; +} +@property --tw-skew-x { + syntax: "*"; + inherits: false; +} +@property --tw-skew-y { + syntax: "*"; + inherits: false; +} @property --tw-space-y-reverse { syntax: "*"; inherits: false; @@ -4264,9 +4625,18 @@ syntax: "*"; inherits: false; } +@property --tw-ease { + syntax: "*"; + inherits: false; +} @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { *, ::before, ::after, ::backdrop { + --tw-rotate-x: initial; + --tw-rotate-y: initial; + --tw-rotate-z: initial; + --tw-skew-x: initial; + --tw-skew-y: initial; --tw-space-y-reverse: 0; --tw-divide-y-reverse: 0; --tw-border-style: solid; @@ -4305,6 +4675,7 @@ --tw-drop-shadow-color: initial; --tw-drop-shadow-alpha: 100%; --tw-drop-shadow-size: initial; + --tw-ease: initial; } } }