diff --git a/ak/views.py b/ak/views.py index 6f1e2b720..4c282c8e1 100644 --- a/ak/views.py +++ b/ak/views.py @@ -13,6 +13,7 @@ from core.templatetags.custom_static import large_static from libraries.constants import LATEST_RELEASE_URL_PATH_STR from libraries.mixins import ContributorMixin +from mailing_list.constants import MAILING_LIST_LABELS from news.models import Entry from testimonials.models import Testimonial from core.mock_data import SharedResources @@ -191,6 +192,22 @@ def get_v3_context_data(self, **kwargs): ctx["hero_image_url"] = SharedResources.hero_image_url ctx["hero_image_url_light"] = SharedResources.hero_image_url_light ctx["hero_image_url_dark"] = SharedResources.hero_image_url_dark + + user = self.request.user + if user.is_authenticated and self.request.session.pop( + "show_ml_post_auth_modal", False + ): + user.data["ml_post_auth_seen"] = True + user.save(update_fields=["data"]) + ctx["show_ml_post_auth_modal"] = True + ctx["post_auth_modal_subscribe_url"] = reverse( + "mailing-list-post-auth-subscribe" + ) + ctx["post_auth_modal_mailing_lists"] = [ + {**v} for v in MAILING_LIST_LABELS.values() + ] + ctx["post_auth_modal_user_email"] = user.email + return ctx diff --git a/mailing_list/urls.py b/mailing_list/urls.py index 714f2c1ca..0c609c633 100644 --- a/mailing_list/urls.py +++ b/mailing_list/urls.py @@ -2,6 +2,7 @@ from mailing_list.views import ConfirmSubscriptionView from mailing_list.views import ModalSubscribeView +from mailing_list.views import PostAuthSubscribeView from mailing_list.views import QuickSubscribeView from mailing_list.views import SubscribeView @@ -17,6 +18,11 @@ ModalSubscribeView.as_view(), name="mailing-list-modal-subscribe", ), + path( + "post-auth-subscribe/", + PostAuthSubscribeView.as_view(), + name="mailing-list-post-auth-subscribe", + ), path( "confirm//", ConfirmSubscriptionView.as_view(), diff --git a/mailing_list/views.py b/mailing_list/views.py index 600ae46e9..9878a8385 100644 --- a/mailing_list/views.py +++ b/mailing_list/views.py @@ -7,9 +7,9 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.core import signing from django.core.cache import cache -from django.db import IntegrityError, transaction from django.core.mail import send_mail -from django.http import HttpResponseRedirect +from django.db import IntegrityError, transaction +from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import render from django.template.loader import render_to_string from django.urls import reverse @@ -83,6 +83,43 @@ def _is_rate_limited(request) -> bool: return cache.incr(key) > _SUBSCRIBE_RATE_LIMIT +def _subscribe_pending( + request, user, email: str, list_ids: list[str] +) -> tuple[list[str], str | None]: + """Create PENDING subscription records and send a confirmation email. + + Returns (succeeded, error_message). On email failure the records are + rolled back and error_message is set; on partial IntegrityError the + affected list is silently skipped. + """ + succeeded = [] + for lid in list_ids: + try: + with transaction.atomic(): + UserMailingListSubscription.objects.update_or_create( + user=user, + list_id=lid, + defaults={"email": email, "status": SubscriptionStatus.PENDING}, + ) + succeeded.append(lid) + except IntegrityError: + pass + + if not succeeded: + return [], None + + try: + _send_confirmation_email(request, email, user.pk, succeeded) + except Exception as exc: + logger.error("Failed to send confirmation email to %s: %s", email, exc) + UserMailingListSubscription.objects.filter( + user=user, list_id__in=succeeded + ).delete() + return [], "Could not send confirmation email. Please try again." + + return succeeded, None + + def _send_confirmation_email( request, email: str, user_id: int | None, list_ids: list[str] ) -> None: @@ -520,9 +557,27 @@ class ModalSubscribeView(View): any currently tracked lists that were unchecked. Anonymous flow: subscribe-only - sends a single confirmation email for all checked lists. Unsubscribe not supported for anonymous users. + + With JS the form posts via HTMX and the card partial is swapped in place. + Without JS the form posts natively; we redirect (PRG) back to the page so + the card re-renders from DB state on the next GET. """ def _card(self, request, **ctx): + if not _is_htmx(request): + state = ctx.get("state") + if state == "error": + return _prg_redirect( + request, + ml_state="error", + ml_error=ctx.get("error_message", ""), + ml_email=ctx.get("user_email", ""), + ) + if state == "pending" and not request.user.is_authenticated: + return _prg_redirect( + request, ml_state="pending", ml_email=ctx.get("user_email", "") + ) + return _prg_redirect(request) return render( request, "v3/includes/_mailing_list_card.html", @@ -594,38 +649,20 @@ def _handle_authenticated(self, request, email, list_ids, managed_lists): manage_url=manage_url, ) - succeeded = [] - for lid in to_subscribe: - try: - with transaction.atomic(): - UserMailingListSubscription.objects.update_or_create( - user=request.user, - list_id=lid, - defaults={"email": email, "status": SubscriptionStatus.PENDING}, - ) - succeeded.append(lid) - except IntegrityError: - pass + succeeded, error = _subscribe_pending( + request, request.user, email, to_subscribe + ) - if not succeeded: + if error: return self._card( - request, - state="error", - error_message="Could not subscribe. Please try again.", - user_email=email, + request, state="error", error_message=error, user_email=email ) - try: - _send_confirmation_email(request, email, request.user.pk, succeeded) - except Exception as exc: - logger.error("Failed to send confirmation email to %s: %s", email, exc) - UserMailingListSubscription.objects.filter( - user=request.user, list_id__in=succeeded - ).delete() + if not succeeded: return self._card( request, state="error", - error_message="Could not send confirmation email. Please try again.", + error_message="Could not subscribe. Please try again.", user_email=email, ) @@ -657,3 +694,33 @@ def _handle_anonymous(self, request, email, list_ids): ) return self._card(request, state="pending", user_email=email) + + +class PostAuthSubscribeView(LoginRequiredMixin, View): + """Subscribe to one or more lists from the post-login homepage modal. + + Only for authenticated users. Returns an empty fragment so HTMX removes + the modal from the DOM. Falls back to a homepage redirect for non-HTMX. + """ + + def post(self, request): + email = (request.POST.get("email") or "").strip() or request.user.email + managed_lists = set(constants.MAILMAN_LISTS) + list_ids = [ + lid for lid in request.POST.getlist("list_id") if lid in managed_lists + ] + + if list_ids and not _is_rate_limited(request): + current = { + sub.list_id + for sub in UserMailingListSubscription.objects.filter( + user=request.user, list_id__in=managed_lists + ) + } + to_subscribe = [lid for lid in list_ids if lid not in current] + + _subscribe_pending(request, request.user, email, to_subscribe) + + if _is_htmx(request): + return HttpResponse("") + return _prg_redirect(request) diff --git a/static/css/v3/dialog.css b/static/css/v3/dialog.css index f87964667..1a1f2788f 100644 --- a/static/css/v3/dialog.css +++ b/static/css/v3/dialog.css @@ -40,8 +40,19 @@ display: flex !important; } +/* Checkbox-driven open (no-JS auto-open / toggle): a checked .dialog-modal__toggle + shows the adjacent .dialog-modal, mirroring the :target behaviour. */ +.dialog-modal__toggle { + display: none; +} + +.dialog-modal__toggle:checked ~ .dialog-modal { + display: flex !important; +} + /* Lock background page scroll while any dialog is open. */ -html:has(.dialog-modal:target) { +html:has(.dialog-modal:target), +html:has(.dialog-modal__toggle:checked) { overflow: hidden; } diff --git a/static/css/v3/mailing-list-card.css b/static/css/v3/mailing-list-card.css index 803c9480c..18a426334 100644 --- a/static/css/v3/mailing-list-card.css +++ b/static/css/v3/mailing-list-card.css @@ -203,3 +203,124 @@ input.mailing-list-modal__checkbox:focus-visible + .mailing-list-modal__checkbox line-height: var(--line-height-default); color: var(--color-text-secondary); } + +/* ============================================ + POST-AUTH MODAL — first-login subscription prompt + ============================================ */ + +/* Header contains only the title (no close X per design) */ +.ml-post-auth-modal__header { + padding: var(--space-large) var(--space-large) 0 var(--space-large); + width: 100%; + box-sizing: border-box; +} + +.ml-post-auth-modal__header .dialog-modal__title { + margin: 0; +} + +/* Email input row */ +.ml-post-auth-modal__email-row { + padding: 0 var(--space-large); + width: 100%; + box-sizing: border-box; +} + +.ml-post-auth-modal__email-field { + display: block; + width: 100%; + height: 40px; + padding: 0 var(--space-large); + box-sizing: border-box; + background-color: var(--color-surface-weak) !important; + border: 1px solid var(--color-stroke-weak) !important; + border-radius: var(--border-radius-xl) !important; + font-family: var(--font-sans); + font-size: var(--font-size-base); + font-weight: var(--font-weight-regular); + line-height: var(--line-height-default); + letter-spacing: var(--letter-spacing-tight); + color: var(--color-text-secondary); + outline: none; + box-shadow: none; + -webkit-appearance: none; + appearance: none; +} + +.ml-post-auth-modal__email-field::placeholder { + color: var(--color-text-secondary); +} + +.ml-post-auth-modal__email-field:focus-visible { + background-color: var(--color-surface-mid); + border-color: var(--color-stroke-strong); +} + +.ml-post-auth-modal__email-field--error { + background-color: var(--color-surface-error-weak) !important; + border-color: var(--color-stroke-error) !important; + color: var(--color-text-error); +} + +.ml-post-auth-modal__email-row .field__error { + margin-top: var(--space-s); +} + +.ml-post-auth-modal__container .dialog-modal__buttons { + padding-top: var(--space-large); +} + +/* Description + list card section */ +.ml-post-auth-modal__body { + display: flex; + flex-direction: column; + gap: var(--space-default); + width: 100%; + box-sizing: border-box; +} + +.ml-post-auth-modal__description { + padding: 0 var(--space-large); + margin: 0; + font-family: var(--font-sans); + font-size: var(--font-size-base); + font-weight: var(--font-weight-regular); + line-height: var(--line-height-default); + letter-spacing: var(--letter-spacing-tight); + color: var(--color-text-secondary); +} + +.ml-post-auth-modal__list-section { + display: flex; + flex-direction: column; + gap: var(--space-large); + padding: 0 var(--space-large) var(--space-large); + width: 100%; + box-sizing: border-box; +} + +/* Override the bottom margin that .mailing-list-modal__list-card applies + since margin is handled by the parent flex gap here */ +.ml-post-auth-modal__list-card { + margin: 0; +} + +/* Unselect All link + "X of Y selected" counter */ +.ml-post-auth-modal__footer-row { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + width: 100%; + box-sizing: border-box; + font-size: var(--font-size-small); + line-height: var(--line-height-relaxed); + letter-spacing: var(--letter-spacing-tight); + white-space: nowrap; +} + +.ml-post-auth-modal__count { + font-family: var(--font-sans); + font-weight: var(--font-weight-regular); + color: var(--color-text-secondary); +} diff --git a/static/js/dialog.js b/static/js/dialog.js index a77c86c66..095ae6abe 100644 --- a/static/js/dialog.js +++ b/static/js/dialog.js @@ -80,6 +80,15 @@ if (hashModal) { return hashModal.querySelector('[role="dialog"]') || hashModal; } + // Checkbox-based dialog modals (.dialog-modal__toggle drives an adjacent .dialog-modal) + var dialogToggle = document.querySelector('.dialog-modal__toggle:checked'); + if (dialogToggle) { + var modal = dialogToggle.nextElementSibling; + while (modal && !modal.classList.contains('dialog-modal')) { + modal = modal.nextElementSibling; + } + if (modal) return modal.querySelector('[role="dialog"]') || modal; + } // Checkbox-based modals (library filter) var toggle = document.querySelector('.library-filter__toggle:checked'); if (toggle) { @@ -99,8 +108,10 @@ window.location.href = closeLink.href; } } else { - // Checkbox-based: uncheck the toggle - var toggle = document.querySelector('.library-filter__toggle:checked'); + // Checkbox-based (dialog modal or library filter): uncheck the toggle + var toggle = document.querySelector( + '.dialog-modal__toggle:checked, .library-filter__toggle:checked' + ); if (toggle) { toggle.checked = false; } @@ -130,15 +141,33 @@ // Checkbox-based modals open via toggle change document.addEventListener('change', function (e) { - if (e.target.matches('.library-filter__toggle') && e.target.checked) { + if ( + e.target.matches('.library-filter__toggle, .dialog-modal__toggle') && + e.target.checked + ) { var dialog = getActiveDialog(); if (dialog) { - triggerElement = e.target.parentElement.querySelector('.library-filter__trigger'); + triggerElement = e.target.matches('.library-filter__toggle') + ? e.target.parentElement.querySelector('.library-filter__trigger') + : document.activeElement; setInitialFocus(dialog); } } }); + // Auto-opened checkbox dialog (server-renders the toggle checked): focus on load. + function focusAutoOpenedDialog() { + var dialog = getActiveDialog(); + if (dialog) { + setInitialFocus(dialog); + } + } + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', focusAutoOpenedDialog); + } else { + focusAutoOpenedDialog(); + } + // Enter key activates label[role="button"] elements (labels only natively respond to click) document.addEventListener('keydown', function (e) { if (e.key === 'Enter' && e.target.matches('label[role="button"]')) { diff --git a/templates/v3/homepage.html b/templates/v3/homepage.html index f81b984ac..791457685 100644 --- a/templates/v3/homepage.html +++ b/templates/v3/homepage.html @@ -62,4 +62,8 @@ + + {% if show_ml_post_auth_modal %} + {% include "v3/includes/_mailing_list_post_auth_modal.html" with post_auth_modal_subscribe_url=post_auth_modal_subscribe_url post_auth_modal_mailing_lists=post_auth_modal_mailing_lists post_auth_modal_user_email=post_auth_modal_user_email %} + {% endif %} {% endblock %} diff --git a/templates/v3/includes/_content_modal.html b/templates/v3/includes/_content_modal.html index 5a81db231..d0fedde27 100644 --- a/templates/v3/includes/_content_modal.html +++ b/templates/v3/includes/_content_modal.html @@ -18,7 +18,7 @@ Trigger: {% endcomment %} {% if manage_url %}
- - + Manage your lists →
{% endif %} @@ -99,7 +95,7 @@ {% if modal_subscribe_url and mailing_lists %}
- + -
{% csrf_token %} - +
    {% for ml in mailing_lists %} diff --git a/templates/v3/includes/_mailing_list_post_auth_modal.html b/templates/v3/includes/_mailing_list_post_auth_modal.html new file mode 100644 index 000000000..25d2869dc --- /dev/null +++ b/templates/v3/includes/_mailing_list_post_auth_modal.html @@ -0,0 +1,133 @@ +{% comment %} +Post-login mailing list subscription modal. + +Shows automatically on the homepage the first time a user visits after logging +in. The "seen" flag is set server-side when the homepage renders, so +cancel/close need no backend call. + +Variables: + post_auth_modal_subscribe_url - POST endpoint (mailing-list-post-auth-subscribe) + post_auth_modal_mailing_lists - list of dicts: {id, name, address, description} + post_auth_modal_user_email - logged-in user's email (pre-fills the input) + +No-JS: a server-rendered, pre-checked checkbox toggle (.dialog-modal__toggle) +opens the modal via CSS, and the form submits natively (method/action) — the +view redirects (PRG) for non-HTMX requests. The backdrop and Cancel are +