diff --git a/app/routers/auth.py b/app/routers/auth.py index e567aa4..28a7d4d 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -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) diff --git a/app/services/email_service.py b/app/services/email_service.py index 8546182..d3ed9f7 100644 --- a/app/services/email_service.py +++ b/app/services/email_service.py @@ -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 = """\ + + + + + + + + Welcome to {brand} + + + + + +
+
+ ▰ {brand_upper} +
+
 
+
+ Welcome to {brand}. +
+
 
+
+ You’re signed in. The dashboard is at + {app_url_short} — + 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; you can switch the digest off + at any time on the + Settings page, + or use the one-click unsubscribe link in every digest email. +
+
 
+
+
 
+
+ Sent automatically by {brand} · do not reply +
+
+ + +""" + + +_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 # --------------------------------------------------------------------------- diff --git a/app/static/css/cassandra.css b/app/static/css/cassandra.css index 128fcad..a8a75eb 100644 --- a/app/static/css/cassandra.css +++ b/app/static/css/cassandra.css @@ -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
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; diff --git a/app/static/js/portfolio.js b/app/static/js/portfolio.js index bedea08..742d748 100644 --- a/app/static/js/portfolio.js +++ b/app/static/js/portfolio.js @@ -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.' + '' + - '
' + + '' + '' + + 'class="modal-input" style="flex:0 0 200px; margin-bottom:0;">' + '' + '' + 'or import a new CSV →' + diff --git a/app/templates/settings.html b/app/templates/settings.html index 7e849cb..1ada212 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -20,41 +20,42 @@
Tier
-
- - {% if paid and paid.active %} - {% if paid.source == "credit" %} - - Paid features active via credit · {{ paid.days_remaining }} day(s) remaining - (expires {{ paid.expires_at.strftime("%Y-%m-%d") }}). - - {% else %} - {% if trial_days_remaining %} +
+
+ + {% if paid and paid.active %} + {% if paid.source == "credit" %} - Free trial — {{ 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") }}). {% else %} - Paid subscription active. - {% endif %} - {% if user.stripe_customer_id %} - -
- Update payment method, view invoices, switch monthly ↔ annual, or cancel any time. Opens the Stripe-hosted billing portal. -
+ {% if trial_days_remaining %} + + Free trial — {{ trial_days_remaining }} + day{{ '' if trial_days_remaining == 1 else 's' }} remaining. + Cancel before the trial ends and you won’t be charged. + + {% else %} + Paid subscription active. + {% endif %} {% endif %} + {% else %} + + Free tier — upgrade for £7/month or £70/year. + {% endif %} - {% else %} - - Free tier — upgrade for £7/month or £70/year. - +
+ {% if paid and paid.active and paid.source != "credit" and user.stripe_customer_id %} + {% endif %}
@@ -91,8 +92,11 @@ {% endif %} {# --- Import portfolio --------------------------------------------- #} -
-
Import portfolio (Trading 212 CSV)
+ {# 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. #} +
+ Import portfolio (Trading 212 CSV)

Export your pie from T212 (Investing → Your Pie → ··· → Export) @@ -110,11 +114,11 @@

-
+
{# --- Referral block ---------------------------------------------- #} -
-
Invite a friend
+
+ Invite a friend

Share your invite link. When your friend subscribes, you and they each get 50% off for 3 months. @@ -145,11 +149,11 @@

— (D.3)
- + {# --- Email digests block ------------------------------------------ #} -
-
Email digests
+
+ Email digests

Editorial commentary delivered to your inbox. Daily for paid (Mon–Sat) plus the Sunday recap; free tier gets the Sunday recap.

@@ -188,7 +192,7 @@
- + {# --- Cloud sync block --------------------------------------------- #} -
-
Cloud sync (encrypted)
+
+ Cloud sync (encrypted)

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.

{% endif %} -
+ {# Future: Paddle subscription block, AI-spend ledger summary, etc. #} @@ -261,10 +265,10 @@ + class="modal-input" required> + class="modal-input" required>