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; } } }