Skip to content

Commit c8e16c5

Browse files
committed
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 fix: rebase fixes
1 parent 83e72a6 commit c8e16c5

10 files changed

Lines changed: 336 additions & 31 deletions

File tree

ak/views.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@
1313
from core.templatetags.custom_static import large_static
1414
from libraries.constants import LATEST_RELEASE_URL_PATH_STR
1515
from libraries.mixins import ContributorMixin
16+
from mailing_list.constants import MAILING_LIST_LABELS
1617
from news.models import Entry
1718
from testimonials.models import Testimonial
1819
from core.mock_data import SharedResources
1920

20-
2121
logger = structlog.get_logger()
2222

2323

@@ -192,6 +192,22 @@ def get_v3_context_data(self, **kwargs):
192192
ctx["hero_image_url"] = SharedResources.hero_image_url
193193
ctx["hero_image_url_light"] = SharedResources.hero_image_url_light
194194
ctx["hero_image_url_dark"] = SharedResources.hero_image_url_dark
195+
196+
user = self.request.user
197+
if user.is_authenticated and self.request.session.pop(
198+
"show_ml_post_auth_modal", False
199+
):
200+
user.data["ml_post_auth_seen"] = True
201+
user.save(update_fields=["data"])
202+
ctx["show_ml_post_auth_modal"] = True
203+
ctx["post_auth_modal_subscribe_url"] = reverse(
204+
"mailing-list-post-auth-subscribe"
205+
)
206+
ctx["post_auth_modal_mailing_lists"] = [
207+
{**v} for v in MAILING_LIST_LABELS.values()
208+
]
209+
ctx["post_auth_modal_user_email"] = user.email
210+
195211
return ctx
196212

197213

mailing_list/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from mailing_list.views import ConfirmSubscriptionView
44
from mailing_list.views import ModalSubscribeView
5+
from mailing_list.views import PostAuthSubscribeView
56
from mailing_list.views import QuickSubscribeView
67
from mailing_list.views import SubscribeView
78

@@ -17,6 +18,11 @@
1718
ModalSubscribeView.as_view(),
1819
name="mailing-list-modal-subscribe",
1920
),
21+
path(
22+
"post-auth-subscribe/",
23+
PostAuthSubscribeView.as_view(),
24+
name="mailing-list-post-auth-subscribe",
25+
),
2026
path(
2127
"confirm/<str:token>/",
2228
ConfirmSubscriptionView.as_view(),

mailing_list/views.py

Lines changed: 76 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
from django.contrib.auth.mixins import LoginRequiredMixin
88
from django.core import signing
99
from django.core.cache import cache
10-
from django.db import IntegrityError, transaction
1110
from django.core.mail import send_mail
12-
from django.http import HttpResponseRedirect
11+
from django.db import IntegrityError, transaction
12+
from django.http import HttpResponse, HttpResponseRedirect
1313
from django.shortcuts import render
1414
from django.template.loader import render_to_string
1515
from django.urls import reverse
@@ -83,6 +83,43 @@ def _is_rate_limited(request) -> bool:
8383
return cache.incr(key) > _SUBSCRIBE_RATE_LIMIT
8484

8585

86+
def _subscribe_pending(
87+
request, user, email: str, list_ids: list[str]
88+
) -> tuple[list[str], str | None]:
89+
"""Create PENDING subscription records and send a confirmation email.
90+
91+
Returns (succeeded, error_message). On email failure the records are
92+
rolled back and error_message is set; on partial IntegrityError the
93+
affected list is silently skipped.
94+
"""
95+
succeeded = []
96+
for lid in list_ids:
97+
try:
98+
with transaction.atomic():
99+
UserMailingListSubscription.objects.update_or_create(
100+
user=user,
101+
list_id=lid,
102+
defaults={"email": email, "status": SubscriptionStatus.PENDING},
103+
)
104+
succeeded.append(lid)
105+
except IntegrityError:
106+
pass
107+
108+
if not succeeded:
109+
return [], None
110+
111+
try:
112+
_send_confirmation_email(request, email, user.pk, succeeded)
113+
except Exception as exc:
114+
logger.error("Failed to send confirmation email to %s: %s", email, exc)
115+
UserMailingListSubscription.objects.filter(
116+
user=user, list_id__in=succeeded
117+
).delete()
118+
return [], "Could not send confirmation email. Please try again."
119+
120+
return succeeded, None
121+
122+
86123
def _send_confirmation_email(
87124
request, email: str, user_id: int | None, list_ids: list[str]
88125
) -> None:
@@ -594,38 +631,20 @@ def _handle_authenticated(self, request, email, list_ids, managed_lists):
594631
manage_url=manage_url,
595632
)
596633

597-
succeeded = []
598-
for lid in to_subscribe:
599-
try:
600-
with transaction.atomic():
601-
UserMailingListSubscription.objects.update_or_create(
602-
user=request.user,
603-
list_id=lid,
604-
defaults={"email": email, "status": SubscriptionStatus.PENDING},
605-
)
606-
succeeded.append(lid)
607-
except IntegrityError:
608-
pass
634+
succeeded, error = _subscribe_pending(
635+
request, request.user, email, to_subscribe
636+
)
609637

610-
if not succeeded:
638+
if error:
611639
return self._card(
612-
request,
613-
state="error",
614-
error_message="Could not subscribe. Please try again.",
615-
user_email=email,
640+
request, state="error", error_message=error, user_email=email
616641
)
617642

618-
try:
619-
_send_confirmation_email(request, email, request.user.pk, succeeded)
620-
except Exception as exc:
621-
logger.error("Failed to send confirmation email to %s: %s", email, exc)
622-
UserMailingListSubscription.objects.filter(
623-
user=request.user, list_id__in=succeeded
624-
).delete()
643+
if not succeeded:
625644
return self._card(
626645
request,
627646
state="error",
628-
error_message="Could not send confirmation email. Please try again.",
647+
error_message="Could not subscribe. Please try again.",
629648
user_email=email,
630649
)
631650

@@ -657,3 +676,33 @@ def _handle_anonymous(self, request, email, list_ids):
657676
)
658677

659678
return self._card(request, state="pending", user_email=email)
679+
680+
681+
class PostAuthSubscribeView(LoginRequiredMixin, View):
682+
"""Subscribe to one or more lists from the post-login homepage modal.
683+
684+
Only for authenticated users. Returns an empty fragment so HTMX removes
685+
the modal from the DOM. Falls back to a homepage redirect for non-HTMX.
686+
"""
687+
688+
def post(self, request):
689+
email = (request.POST.get("email") or "").strip() or request.user.email
690+
managed_lists = set(constants.MAILMAN_LISTS)
691+
list_ids = [
692+
lid for lid in request.POST.getlist("list_id") if lid in managed_lists
693+
]
694+
695+
if list_ids and not _is_rate_limited(request):
696+
current = {
697+
sub.list_id
698+
for sub in UserMailingListSubscription.objects.filter(
699+
user=request.user, list_id__in=managed_lists
700+
)
701+
}
702+
to_subscribe = [lid for lid in list_ids if lid not in current]
703+
704+
_subscribe_pending(request, request.user, email, to_subscribe)
705+
706+
if _is_htmx(request):
707+
return HttpResponse("")
708+
return _prg_redirect(request)

static/css/v3/mailing-list-card.css

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,114 @@ input.mailing-list-modal__checkbox:focus-visible + .mailing-list-modal__checkbox
203203
line-height: var(--line-height-default);
204204
color: var(--color-text-secondary);
205205
}
206+
207+
/* ============================================
208+
POST-AUTH MODAL — first-login subscription prompt
209+
============================================ */
210+
211+
/* Header contains only the title (no close X per design) */
212+
.ml-post-auth-modal__header {
213+
padding: var(--space-large) var(--space-large) 0 var(--space-large);
214+
width: 100%;
215+
box-sizing: border-box;
216+
}
217+
218+
.ml-post-auth-modal__header .dialog-modal__title {
219+
margin: 0;
220+
}
221+
222+
/* Email input row */
223+
.ml-post-auth-modal__email-row {
224+
padding: 0 var(--space-large);
225+
width: 100%;
226+
box-sizing: border-box;
227+
}
228+
229+
.ml-post-auth-modal__email-field {
230+
display: block;
231+
width: 100%;
232+
height: 40px;
233+
padding: 0 var(--space-large);
234+
box-sizing: border-box;
235+
background-color: var(--color-surface-weak) !important;
236+
border: 1px solid var(--color-stroke-weak) !important;
237+
border-radius: var(--border-radius-xl) !important;
238+
font-family: var(--font-sans);
239+
font-size: var(--font-size-base);
240+
font-weight: var(--font-weight-regular);
241+
line-height: var(--line-height-default);
242+
letter-spacing: var(--letter-spacing-tight);
243+
color: var(--color-text-secondary);
244+
outline: none;
245+
box-shadow: none;
246+
-webkit-appearance: none;
247+
appearance: none;
248+
}
249+
250+
.ml-post-auth-modal__email-field::placeholder {
251+
color: var(--color-text-secondary);
252+
}
253+
254+
.ml-post-auth-modal__email-field:focus-visible {
255+
background-color: var(--color-surface-mid);
256+
border-color: var(--color-stroke-strong);
257+
}
258+
259+
.ml-post-auth-modal__container .dialog-modal__buttons {
260+
padding-top: var(--space-large);
261+
}
262+
263+
/* Description + list card section */
264+
.ml-post-auth-modal__body {
265+
display: flex;
266+
flex-direction: column;
267+
gap: var(--space-default);
268+
width: 100%;
269+
box-sizing: border-box;
270+
}
271+
272+
.ml-post-auth-modal__description {
273+
padding: 0 var(--space-large);
274+
margin: 0;
275+
font-family: var(--font-sans);
276+
font-size: var(--font-size-base);
277+
font-weight: var(--font-weight-regular);
278+
line-height: var(--line-height-default);
279+
letter-spacing: var(--letter-spacing-tight);
280+
color: var(--color-text-secondary);
281+
}
282+
283+
.ml-post-auth-modal__list-section {
284+
display: flex;
285+
flex-direction: column;
286+
gap: var(--space-large);
287+
padding: 0 var(--space-large) var(--space-large);
288+
width: 100%;
289+
box-sizing: border-box;
290+
}
291+
292+
/* Override the bottom margin that .mailing-list-modal__list-card applies
293+
since margin is handled by the parent flex gap here */
294+
.ml-post-auth-modal__list-card {
295+
margin: 0;
296+
}
297+
298+
/* Unselect All link + "X of Y selected" counter */
299+
.ml-post-auth-modal__footer-row {
300+
display: flex;
301+
flex-direction: row;
302+
align-items: center;
303+
justify-content: space-between;
304+
width: 100%;
305+
box-sizing: border-box;
306+
font-size: var(--font-size-small);
307+
line-height: var(--line-height-relaxed);
308+
letter-spacing: var(--letter-spacing-tight);
309+
white-space: nowrap;
310+
}
311+
312+
.ml-post-auth-modal__count {
313+
font-family: var(--font-sans);
314+
font-weight: var(--font-weight-regular);
315+
color: var(--color-text-secondary);
316+
}

templates/v3/homepage.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,8 @@
6262
</div>
6363
</div>
6464
</div>
65+
66+
{% if show_ml_post_auth_modal %}
67+
{% 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 %}
68+
{% endif %}
6569
{% endblock %}

templates/v3/includes/_content_modal.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
Trigger: <a href="#{{ modal_id }}"></a>
1919
{% endcomment %}
2020
<div class="dialog-modal content-modal" id="{{ modal_id }}">
21-
<a class="dialog-modal__backdrop" href="{{ close_url|default:'#_' }}" aria-hidden="true" tabindex="-1"></a>
21+
<a class="dialog-modal__backdrop" href="{{ close_url|default:'#_' }}" tabindex="-1"></a>
2222
<div class="content-modal__container"
2323
role="dialog"
2424
aria-modal="true"

templates/v3/includes/_dialog.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
{% endcomment %}
2626

2727
<div class="dialog-modal" id="{{ dialog_id }}">
28-
<a class="dialog-modal__backdrop" href="#_" aria-hidden="true" tabindex="-1"></a>
28+
<a class="dialog-modal__backdrop" href="#_" tabindex="-1"></a>
2929
<div class="dialog-modal__container"
3030
role="dialog"
3131
aria-modal="true"

templates/v3/includes/_mailing_list_card.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@
9999

100100
{% if modal_subscribe_url and mailing_lists %}
101101
<div class="dialog-modal" id="mailing-list-modal">
102-
<a class="dialog-modal__backdrop" href="#_" tabindex="-1" aria-hidden="true"></a>
102+
<a class="dialog-modal__backdrop" href="#_" tabindex="-1"></a>
103103
<div class="dialog-modal__container"
104104
role="dialog"
105105
aria-modal="true"

0 commit comments

Comments
 (0)