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:
parent
a07fd144ea
commit
00211fec02
8 changed files with 553 additions and 124 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;">
|
||||
▰ {brand_upper}
|
||||
</div>
|
||||
<div style="height:22px; line-height:22px; font-size:0;"> </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;"> </div>
|
||||
<div class="lead muted" style="font-size:13px; color:{L_muted}; line-height:1.65;">
|
||||
You’re signed in. The dashboard is at
|
||||
<a href="{app_url}" style="color:{L_accent}; text-decoration:none;">{app_url_short}</a> —
|
||||
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;"> </div>
|
||||
<div class="divider" style="border-top:1px solid {L_border};"></div>
|
||||
<div style="height:18px; line-height:18px; font-size:0;"> </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;"> </div>
|
||||
<div class="lead muted" style="font-size:13px; color:{L_muted}; line-height:1.65;">
|
||||
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; 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;"> </div>
|
||||
<div class="divider" style="border-top:1px solid {L_border};"></div>
|
||||
<div style="height:14px; line-height:14px; font-size:0;"> </div>
|
||||
<div class="muted" style="font-size:10.5px; color:{L_dim}; letter-spacing:0.06em;">
|
||||
Sent automatically by {brand} · 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>' +
|
||||
|
|
|
|||
|
|
@ -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> — {{ trial_days_remaining }}
|
||||
day{{ '' if trial_days_remaining == 1 else 's' }} remaining.
|
||||
Cancel before the trial ends and you won’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 ↔ 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> — {{ trial_days_remaining }}
|
||||
day{{ '' if trial_days_remaining == 1 else 's' }} remaining.
|
||||
Cancel before the trial ends and you won’t be charged.
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="settings-row__hint">Paid subscription active.</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="settings-row__hint">
|
||||
Free tier — <a href="/pricing">upgrade for £7/month or £70/year</a>.
|
||||
</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="settings-row__hint">
|
||||
Free tier — <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 → Your Pie → ··· → 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–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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue