ui: collapsible settings sections + welcome-email + larger auth inputs

Settings page tidy-up driven by user feedback that it had grown too busy:

  - Each section (Import, Invite, Email digests, Cloud sync) is now a
    native <details>/<summary> accordion. Import stays open by default
    because /settings#import is the deep-link target from the dashboard
    CTA; the others collapse so the page lands quiet.
  - Manage subscription is a right-aligned gear-icon button instead of
    a rectangular text button — the descriptive copy moves into the
    tooltip. Frees up the Tier row of visual weight.

Auth + modal inputs were too small (verify code box, portfolio restore
PIN): the auth-card selector now covers text inputs as well, and a new
.modal-input class standardises 16px / 12px-padding fields used in the
cloud-sync enable modal and the portfolio restore prompt.

The verify page no longer carries the "Email me the digest" checkbox —
it was misleading on repeat logins (server-side it only applied on
first sign-up but rendered every time). Default-opt-in lives in the
User row at creation; per-user changes happen on /settings. First
successful verify now triggers a one-shot welcome email explaining the
digest cadence and pointing at /settings for opt-out; SMTP failure is
logged but does not block the login.

Tests rewritten to cover the new welcome-email path:
  - first login sends exactly one welcome email
  - returning user gets none
  - SMTP failure does not break the redirect
  - regression guard: returning user who opted out stays opted out

Also lands the paddle merchant-summary doc that was written earlier
during the Paddle → Polar → Stripe onboarding pivot.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-26 22:32:59 +02:00
parent a07fd144ea
commit 00211fec02
8 changed files with 553 additions and 124 deletions

View file

@ -37,7 +37,7 @@ from app.db import get_session, utcnow
from app.logging import get_logger
from app.services.auth_service import AuthError, get_or_create_user, get_user
from app.services import otp_service, referral_service
from app.services.email_service import EmailSendError, send_otp
from app.services.email_service import EmailSendError, send_otp, send_welcome_email
from app.templates_env import templates
@ -216,7 +216,6 @@ async def verify_page(request: Request, error: str | None = None, sent: str | No
async def verify_submit(
request: Request,
code: str = Form(...),
subscribe_to_digests: str | None = Form(default=None),
session: AsyncSession = Depends(get_session),
):
cookie = request.cookies.get(PENDING_COOKIE_NAME)
@ -242,15 +241,24 @@ async def verify_submit(
return RedirectResponse(url="/login", status_code=303)
is_first_login = user.last_login_at is None
user.last_login_at = utcnow()
# Apply the verify-page subscribe checkbox ONLY at first sign-up. After
# that, Settings (and the one-click unsubscribe link) own the preference
# — re-applying on every login would silently re-subscribe users who
# explicitly opted out.
if is_first_login:
user.email_digest_opt_in = subscribe_to_digests is not None
# Default opt-in is set on User row creation; we don't touch it here.
# The one-time welcome email below explains the digest and the Settings
# opt-out path — re-applying a checkbox state on every login would
# silently re-subscribe users who explicitly opted out later.
await session.commit()
log.info("user.login", user_id=user.id, email=email)
# First-login welcome email — best effort. SMTP failure must not block
# the login itself; we log and continue. Idempotent because we commit
# last_login_at above before this point, so a retried verify won't
# re-trigger send.
if is_first_login:
try:
await send_welcome_email(email)
except Exception as e: # noqa: BLE001
log.warning("welcome_email.send_failed",
user_id=user.id, error=str(e)[:200])
resp = RedirectResponse(url="/", status_code=303)
_set_session_cookie(resp, user.id)
_clear_pending_cookie(resp)

View file

@ -200,6 +200,130 @@ async def send_otp(to: str, code: str, ttl_minutes: int) -> None:
await send_email(to, subject, text, html_body=html)
# ---------------------------------------------------------------------------
# Welcome email — sent once on first successful login.
# ---------------------------------------------------------------------------
_WELCOME_HTML_TEMPLATE = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Welcome to {brand}</title>
<style>
@media (prefers-color-scheme: dark) {{
body {{ background:{D_bg} !important; }}
.card {{ background:{D_surface} !important; border-color:{D_border} !important; }}
.h1 {{ color:{D_text} !important; }}
.muted {{ color:{D_muted} !important; }}
.lead {{ color:{D_text} !important; }}
.divider {{ border-color:{D_border} !important; }}
a {{ color:{D_accent} !important; }}
}}
@media (max-width: 540px) {{
.card {{ padding:24px 18px !important; }}
}}
</style>
</head>
<body style="margin:0; padding:24px 12px; background:{L_bg}; font-family:{FONT_MONO}; color:{L_text}; -webkit-font-smoothing:antialiased;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" width="100%" style="max-width:520px; margin:0 auto; border-collapse:separate;">
<tr><td class="card" style="background:{L_surface}; border:1px solid {L_border}; padding:32px 28px;">
<div class="muted" style="font-size:11px; letter-spacing:0.32em; color:{L_muted}; text-transform:uppercase;">
&#9648;&nbsp;{brand_upper}
</div>
<div style="height:22px; line-height:22px; font-size:0;">&nbsp;</div>
<div class="h1" style="font-size:17px; font-weight:normal; color:{L_text}; letter-spacing:0.02em;">
Welcome to {brand}.
</div>
<div style="height:14px; line-height:14px; font-size:0;">&nbsp;</div>
<div class="lead muted" style="font-size:13px; color:{L_muted}; line-height:1.65;">
You&rsquo;re signed in. The dashboard is at
<a href="{app_url}" style="color:{L_accent}; text-decoration:none;">{app_url_short}</a> &mdash;
a rolling news feed, cross-asset indicator panels, and a written
strategic read of the session, all updated through the day.
</div>
<div style="height:20px; line-height:20px; font-size:0;">&nbsp;</div>
<div class="divider" style="border-top:1px solid {L_border};"></div>
<div style="height:18px; line-height:18px; font-size:0;">&nbsp;</div>
<div class="h1" style="font-size:14px; font-weight:normal; color:{L_text};">
About the email digest
</div>
<div style="height:10px; line-height:10px; font-size:0;">&nbsp;</div>
<div class="lead muted" style="font-size:13px; color:{L_muted}; line-height:1.65;">
We send one Sunday digest to every account &mdash; the week behind +
the week ahead. Paid subscribers also get a short daily digest
(Mon&ndash;Sat), each a ~600-word read of the session.
You&rsquo;re opted in by default; you can switch the digest off
at any time on the
<a href="{settings_url}" style="color:{L_accent}; text-decoration:none;">Settings page</a>,
or use the one-click unsubscribe link in every digest email.
</div>
<div style="height:20px; line-height:20px; font-size:0;">&nbsp;</div>
<div class="divider" style="border-top:1px solid {L_border};"></div>
<div style="height:14px; line-height:14px; font-size:0;">&nbsp;</div>
<div class="muted" style="font-size:10.5px; color:{L_dim}; letter-spacing:0.06em;">
Sent automatically by {brand} &middot; do not reply
</div>
</td></tr>
</table>
</body>
</html>
"""
_WELCOME_TEXT_TEMPLATE = """\
{brand_upper} welcome
You're signed in. The dashboard is at {app_url}: a rolling news feed,
cross-asset indicator panels, and a written strategic read of the
session, all updated through the day.
About the email digest
----------------------
We send one Sunday digest to every account (the week behind + the
week ahead). Paid subscribers also get a short daily digest (Mon-Sat),
each a ~600-word read of the session.
You're opted in by default; switch it off any time at {settings_url},
or use the one-click unsubscribe link in every digest email.
Sent automatically by {brand} · do not reply
"""
def render_welcome_email() -> tuple[str, str, str]:
"""Returns (subject, text_body, html_body) for the post-signup welcome.
Single-shot email, sent the first time a user successfully verifies
an OTP. Explains the digest (which is opt-in by default) and how to
turn it off replaces the old verify-page checkbox which appeared
on every login and was misleading."""
subject = f"Welcome to {branding.BRAND_NAME}"
fmt = dict(
brand=branding.BRAND_NAME,
brand_upper=branding.BRAND_NAME.upper(),
app_url=branding.APP_URL,
app_url_short=branding.APP_URL.replace("https://", "").replace("http://", ""),
settings_url=f"{branding.APP_URL}/settings",
FONT_MONO=branding.FONT_MONO,
**{f"L_{k.replace('-', '_')}": v for k, v in branding.LIGHT.items()},
**{f"D_{k.replace('-', '_')}": v for k, v in branding.DARK.items()},
)
text = _WELCOME_TEXT_TEMPLATE.format(**fmt)
html = _WELCOME_HTML_TEMPLATE.format(**fmt)
return subject, text, html
async def send_welcome_email(to: str) -> None:
subject, text, html = render_welcome_email()
await send_email(to, subject, text, html_body=html)
# ---------------------------------------------------------------------------
# Digest email rendering
# ---------------------------------------------------------------------------

View file

@ -566,6 +566,53 @@ table.dense tr.row-stale td { color: var(--dim); }
.pf-actions button:disabled { opacity: 0.5; cursor: not-allowed; }
.pf-actions .pf-secondary { color: var(--muted); }
.pf-actions .pf-secondary:hover { color: var(--negative); border-color: var(--negative); }
/* Settings-page action button same visual language as .pf-actions
button so buttons across /settings (Manage subscription, future
actions) read as one family. Standalone class (not nested under a
parent) so it can be dropped onto any button anywhere on the page. */
.settings-btn {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
background: var(--surface-2);
color: var(--accent);
border: 1px solid var(--border);
padding: 7px 14px;
cursor: pointer;
border-radius: 2px;
text-decoration: none;
display: inline-block;
}
.settings-btn:hover { border-color: var(--accent); }
.settings-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Icon-button variant for inline row actions (e.g. Manage subscription
gear in the Tier row). Square hit area, accent on hover, tooltip via
title attribute. */
.settings-icon-btn {
background: transparent;
border: 1px solid transparent;
color: var(--muted);
width: 32px;
height: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 3px;
flex-shrink: 0;
transition: color 80ms linear, border-color 80ms linear, background 80ms linear;
}
.settings-icon-btn:hover {
color: var(--accent);
border-color: var(--border);
background: var(--surface-2);
}
.settings-icon-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.settings-icon-btn svg { display: block; }
.pf-analysis {
margin-top: 14px;
background: var(--surface-2);
@ -859,16 +906,46 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
letter-spacing: 0.06em;
gap: 4px;
}
.auth-card input[type="email"], .auth-card input[type="password"] {
.auth-card input[type="email"],
.auth-card input[type="password"],
.auth-card input[type="text"] {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
font-family: var(--font-mono);
font-size: 13px;
padding: 8px 10px;
font-size: 16px;
padding: 12px 14px;
outline: none;
border-radius: 3px;
}
/* The 6-digit OTP input wants to be visually loud it's the only
thing the user is doing on that page. Bigger, more spacing, taller. */
.auth-card input[name="code"] {
font-size: 24px;
padding: 16px 14px;
letter-spacing: 0.5em;
text-align: center;
}
.auth-card input:focus { border-color: var(--accent); }
/* --- Modal text inputs (cloud-sync PIN modal, etc.) ---------------- */
/* Same visual treatment as auth-card so prompts read as a coherent
family. Replaces the inline `style="padding:8px"` that left these
inputs feeling cramped. */
.modal-input {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
font-family: var(--font-mono);
font-size: 16px;
padding: 12px 14px;
margin-bottom: 12px;
outline: none;
border-radius: 3px;
box-sizing: border-box;
}
.modal-input:focus { border-color: var(--accent); }
.auth-card button {
margin-top: 8px;
background: transparent;
@ -943,7 +1020,13 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
margin-left: 8px;
}
.settings-section { margin-top: 22px; }
/* Sections are <details> elements collapsed by default to keep the
settings page scannable. Click the summary to expand. */
.settings-section {
margin-top: 14px;
border-top: 1px solid var(--surface-2);
padding-top: 14px;
}
.settings-section__head {
font-family: var(--font-mono);
font-size: 11px;
@ -951,8 +1034,30 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
text-transform: uppercase;
color: var(--accent);
margin-bottom: 6px;
cursor: pointer;
list-style: none;
user-select: none;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.settings-section__head::before { content: "▸ "; color: var(--accent); }
/* Suppress the native disclosure marker (Webkit + Firefox). */
.settings-section__head::-webkit-details-marker { display: none; }
.settings-section__head::marker { content: ""; }
.settings-section__head::before {
content: "▸";
color: var(--accent);
display: inline-block;
transition: transform 120ms ease-out;
font-size: 10px;
}
.settings-section[open] > .settings-section__head::before {
transform: rotate(90deg);
}
.settings-section[open] > .settings-section__head { margin-bottom: 10px; }
.settings-section__head:hover { color: var(--text); }
.settings-section__head:hover::before { color: var(--text); }
.settings-section__lede {
color: var(--muted);
font-size: 12.5px;

View file

@ -221,10 +221,10 @@
'A synced portfolio is available for this account (last synced ' +
esc(lastSynced) + '). Enter your PIN to load it on this browser.' +
'</div>' +
'<form id="pf-restore-form" style="display:flex; gap:8px; align-items:center;">' +
'<form id="pf-restore-form" style="display:flex; gap:10px; align-items:center;">' +
'<input id="pf-restore-pin" type="password" inputmode="numeric" ' +
'autocomplete="off" placeholder="PIN" ' +
'style="flex:0 0 140px;">' +
'class="modal-input" style="flex:0 0 200px; margin-bottom:0;">' +
'<button type="submit">Restore</button>' +
'<a href="/settings#import" class="settings-row__hint" style="margin-left:auto;">' +
'or import a new CSV →</a>' +

View file

@ -20,41 +20,42 @@
<div class="settings-row">
<div class="settings-row__label">Tier</div>
<div class="settings-row__value">
<span class="badge {% if paid and paid.active %}badge--ok{% else %}badge--ver{% endif %}">
{{ user.tier }}{% if paid and paid.active and paid.source == "credit" %} · credit{% endif %}
</span>
{% if paid and paid.active %}
{% if paid.source == "credit" %}
<span class="settings-row__hint">
Paid features active via credit · {{ paid.days_remaining }} day(s) remaining
(expires {{ paid.expires_at.strftime("%Y-%m-%d") }}).
</span>
{% else %}
{% if trial_days_remaining %}
<div class="settings-row__value" style="display:flex; align-items:flex-start; gap:10px; flex:1;">
<div style="flex:1; min-width:0;">
<span class="badge {% if paid and paid.active %}badge--ok{% else %}badge--ver{% endif %}">
{{ user.tier }}{% if paid and paid.active and paid.source == "credit" %} · credit{% endif %}
</span>
{% if paid and paid.active %}
{% if paid.source == "credit" %}
<span class="settings-row__hint">
<strong>Free trial</strong> &mdash; {{ trial_days_remaining }}
day{{ '' if trial_days_remaining == 1 else 's' }} remaining.
Cancel before the trial ends and you won&rsquo;t be charged.
Paid features active via credit · {{ paid.days_remaining }} day(s) remaining
(expires {{ paid.expires_at.strftime("%Y-%m-%d") }}).
</span>
{% else %}
<span class="settings-row__hint">Paid subscription active.</span>
{% endif %}
{% if user.stripe_customer_id %}
<button type="button" id="stripe-portal-btn"
class="btn-secondary"
style="margin-left:10px; padding:6px 14px; font-size:12px;">
Manage subscription
</button>
<div class="settings-row__hint" style="margin-top:6px;">
Update payment method, view invoices, switch monthly &harr; annual, or cancel any time. Opens the Stripe-hosted billing portal.
</div>
{% if trial_days_remaining %}
<span class="settings-row__hint">
<strong>Free trial</strong> &mdash; {{ trial_days_remaining }}
day{{ '' if trial_days_remaining == 1 else 's' }} remaining.
Cancel before the trial ends and you won&rsquo;t be charged.
</span>
{% else %}
<span class="settings-row__hint">Paid subscription active.</span>
{% endif %}
{% endif %}
{% else %}
<span class="settings-row__hint">
Free tier &mdash; <a href="/pricing">upgrade for £7/month or £70/year</a>.
</span>
{% endif %}
{% else %}
<span class="settings-row__hint">
Free tier &mdash; <a href="/pricing">upgrade for £7/month or £70/year</a>.
</span>
</div>
{% if paid and paid.active and paid.source != "credit" and user.stripe_customer_id %}
<button type="button" id="stripe-portal-btn" class="settings-icon-btn"
title="Manage subscription — payment method, invoices, plan, cancel"
aria-label="Manage subscription">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/>
</svg>
</button>
{% endif %}
</div>
</div>
@ -91,8 +92,11 @@
{% endif %}
{# --- Import portfolio --------------------------------------------- #}
<div class="settings-section" id="import">
<div class="settings-section__head">Import portfolio (Trading 212 CSV)</div>
{# Open by default because /settings#import is the deep-link target
from the dashboard's "import a portfolio" CTA — if you arrive via
that link the section should already be expanded. #}
<details class="settings-section" id="import" open>
<summary class="settings-section__head">Import portfolio (Trading 212 CSV)</summary>
<p class="settings-section__lede">
Export your pie from T212
(<span class="neu">Investing &rarr; Your Pie &rarr; &middot;&middot;&middot; &rarr; Export</span>)
@ -110,11 +114,11 @@
<div id="import-preview" hidden style="margin-top:14px;"></div>
<div id="import-result" class="result" hidden style="margin-top:14px;"></div>
</div>
</details>
{# --- Referral block ---------------------------------------------- #}
<div class="settings-section">
<div class="settings-section__head">Invite a friend</div>
<details class="settings-section">
<summary class="settings-section__head">Invite a friend</summary>
<p class="settings-section__lede">
Share your invite link. When your friend subscribes, you and
they each get <strong>50% off for 3 months</strong>.
@ -145,11 +149,11 @@
<div class="invite-stats__value settings-row__hint">— (D.3)</div>
</div>
</div>
</div>
</details>
{# --- Email digests block ------------------------------------------ #}
<div class="settings-section">
<div class="settings-section__head">Email digests</div>
<details class="settings-section">
<summary class="settings-section__head">Email digests</summary>
<p class="settings-section__lede">
Editorial commentary delivered to your inbox. Daily for paid (Mon&ndash;Sat) plus the Sunday recap; free tier gets the Sunday recap.
</p>
@ -188,7 +192,7 @@
</div>
<div id="digest-feedback" class="settings-row__hint" style="margin-top:6px;"></div>
</div>
</details>
<script>
(function () {
@ -213,8 +217,8 @@
</script>
{# --- Cloud sync block --------------------------------------------- #}
<div class="settings-section">
<div class="settings-section__head">Cloud sync (encrypted)</div>
<details class="settings-section">
<summary class="settings-section__head">Cloud sync (encrypted)</summary>
<p class="settings-section__lede">
Manage the encrypted server-side copy of your portfolio. Sync is
opted-in per import (see the Import section above).
@ -235,7 +239,7 @@
above to enable cloud sync.
</p>
{% endif %}
</div>
</details>
{# Future: Paddle subscription block, AI-spend ledger summary, etc. #}
@ -261,10 +265,10 @@
<form id="sync-modal-form" autocomplete="off">
<label style="display:block;margin-bottom:6px;font-size:12px;">PIN</label>
<input id="sync-pin1" type="password" inputmode="numeric"
style="width:100%;padding:8px;margin-bottom:10px;" required>
class="modal-input" required>
<label style="display:block;margin-bottom:6px;font-size:12px;">Confirm PIN</label>
<input id="sync-pin2" type="password" inputmode="numeric"
style="width:100%;padding:8px;margin-bottom:10px;" required>
class="modal-input" required>
<label style="display:flex;align-items:flex-start;gap:8px;
font-size:12px;color:var(--muted,#666);margin:10px 0 16px;">
<input id="sync-ack" type="checkbox" required>

View file

@ -29,14 +29,7 @@
<form method="post" action="/verify" autocomplete="off">
<label>Verification code
<input type="text" name="code" inputmode="numeric" pattern="[0-9]{6}"
minlength="6" maxlength="6" required autofocus
style="font-family:var(--font-mono); letter-spacing:0.4em; text-align:center;">
</label>
<label style="display:block; margin:14px 0 0; font-size:12.5px; color:var(--muted); line-height:1.55;">
<input type="checkbox" name="subscribe_to_digests" value="on" checked
style="vertical-align:middle; margin-right:6px;">
Email me the digest — daily for paid, Sunday for everyone.
One-click unsubscribe in every email.
minlength="6" maxlength="6" required autofocus>
</label>
<button type="submit">Verify</button>
</form>