From abb2720e3696e95ebccf5390e7005ce2c32b07e1 Mon Sep 17 00:00:00 2001 From: "Teodoro B. Mendes" Date: Thu, 28 May 2026 11:04:06 -0300 Subject: [PATCH] feat: set post-auth modal session flag on first login feat: inject post-auth modal context in HomepageView feat: add PostAuthSubscribeView for post-login modal feat: add post-auth mailing list modal template feat: add post-auth modal CSS feat: wire post-auth modal into v3 homepage fix: correct post-auth modal width, email field, and button padding fix: use $refs for checkbox container to fix unselect all fix: drop only from modal include so CSRF token is available refactor: extract _subscribe_pending helper to remove duplicate sub logic feat: toggle select all / unselect all button based on checked state fix: add missing bottom padding below select/unselect all row fix: title padding fix: correct email field text color and font size to match text field spec fix: revert email field color and font-size to match figma spec fix: colors and radii fix: remove aria-hidden from backdrop links to prevent focus conflict warning --- ak/views.py | 19 ++- mailing_list/urls.py | 6 + mailing_list/views.py | 103 ++++++++++++---- static/css/v3/mailing-list-card.css | 111 +++++++++++++++++ templates/v3/homepage.html | 4 + templates/v3/includes/_content_modal.html | 2 +- templates/v3/includes/_dialog.html | 2 +- templates/v3/includes/_mailing_list_card.html | 2 +- .../_mailing_list_post_auth_modal.html | 116 ++++++++++++++++++ users/signals.py | 3 + 10 files changed, 337 insertions(+), 31 deletions(-) create mode 100644 templates/v3/includes/_mailing_list_post_auth_modal.html diff --git a/ak/views.py b/ak/views.py index 64a26a320..75072fd1e 100644 --- a/ak/views.py +++ b/ak/views.py @@ -13,11 +13,11 @@ 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 - logger = structlog.get_logger() @@ -192,6 +192,23 @@ 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"]) + managed = set(settings.MAILMAN_LISTS) + 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"] = [ + {"id": k, **v} for k, v in MAILING_LIST_LABELS.items() if k in managed + ] + 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 b2bde01db..d6ec1d051 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 @@ -78,6 +78,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: @@ -589,38 +626,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, ) @@ -652,3 +671,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(settings.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/mailing-list-card.css b/static/css/v3/mailing-list-card.css index 803c9480c..0c7aaa785 100644 --- a/static/css/v3/mailing-list-card.css +++ b/static/css/v3/mailing-list-card.css @@ -203,3 +203,114 @@ 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__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/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 %}