read.markets/app/templates/settings.html
Giorgio Gilestro 71155a67be ui: rename tone "Novice" → "Pro"; fit tone-toggle to longest option
User-visible relabel only. Backend tone value stays NOVICE — no API
contract change, no migration on stored user.digest_tone, the
glossary/plain-prose depth of analysis is unchanged. The marketing
intent is that "Pro" reads better than "Novice" on the dashboard
header; landing/pricing/privacy copy still uses the word "Novice" in
flowing prose, so leaving those alone keeps the existing explanations
coherent until they get a copy pass.

Toggle width: the popup expansion (positioned left:0/right:0) is
sized by the container, which previously sized to the active button.
When "Pro" was active the popup was too narrow to fit "Intermediate".
Bumped .tone-toggle button min-width to 10em so both buttons reserve
enough room for the longest label regardless of which one is active.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 11:17:43 +02:00

363 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}{{ BRAND_NAME }} · Settings{% endblock %}
{% block main %}
<section class="panel" style="grid-column: 1 / -1; max-width: 760px; margin: 0 auto;">
<div class="panel-header">
<span class="title">Settings</span>
<span class="meta">your account &middot; client-only data unchanged</span>
</div>
<div class="panel-body" style="padding: 18px clamp(16px, 4vw, 32px) 24px;">
{% if not user %}
<div class="empty">no per-user settings (admin bearer-token session)</div>
{% else %}
<div class="settings-row">
<div class="settings-row__label">Email</div>
<div class="settings-row__value">{{ user.email }}</div>
</div>
<div class="settings-row">
<div class="settings-row__label">Tier</div>
<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">
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 %}
<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 %}
</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>
{% if paid and paid.active and paid.source != "credit" and user.stripe_customer_id %}
<script>
(function () {
var btn = document.getElementById('stripe-portal-btn');
if (!btn) return;
btn.addEventListener('click', async function () {
btn.disabled = true;
var prev = btn.textContent;
btn.textContent = 'Opening portal…';
try {
var r = await fetch('/api/stripe/portal', {
method: 'POST',
credentials: 'same-origin',
});
if (!r.ok) {
var detail = '';
try { detail = (await r.json()).detail || ''; } catch (e) {}
throw new Error('Could not open portal: ' + (detail || r.status));
}
var data = await r.json();
window.location.href = data.url;
} catch (e) {
alert(e.message || 'Could not open portal. Please try again.');
btn.disabled = false;
btn.textContent = prev;
}
});
})();
</script>
{% endif %}
{# --- Import portfolio --------------------------------------------- #}
{# 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 (CSV)</summary>
<p class="settings-section__lede">
Drop a portfolio CSV from any broker &mdash; Trading 212 is recognised
natively and other formats (IBKR, Fidelity, Schwab&hellip;) are
auto-detected. We&rsquo;ll parse it and show a preview before importing
anywhere.
<br><span class="muted">T212 export path:
<span class="neu">Investing &rarr; Your Pie &rarr; &middot;&middot;&middot; &rarr; Export</span>.</span>
</p>
<div id="drop-zone" class="dz" data-paid="{{ 'true' if paid and paid.active else 'false' }}">
<input type="file" id="file-input" name="file" accept=".csv,text/csv" hidden>
<div class="dz__icon"></div>
<div class="dz__label">Drop your broker's portfolio CSV here</div>
<div class="dz__hint">or <a href="#" id="browse-link">browse</a> &middot; max 1 MB &middot; T212, IBKR and others auto-detected</div>
<div class="dz__filename" id="dz-filename"></div>
</div>
<div id="import-preview" hidden style="margin-top:14px;"></div>
<div id="import-result" class="result" hidden style="margin-top:14px;"></div>
</details>
{# --- Referral block ---------------------------------------------- #}
<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>45 days of paid access</strong> credited
to your account.
</p>
<div class="invite-block">
<label class="invite-block__label">Your code</label>
<div class="invite-block__code">{{ user.referral_code }}</div>
<label class="invite-block__label">Invite link</label>
<div class="invite-block__link">
<input type="text" id="invite-url" readonly value="{{ invite_url }}">
<button type="button" id="invite-copy">Copy</button>
</div>
</div>
<div class="invite-stats">
<div>
<div class="invite-stats__label">Pending signups</div>
<div class="invite-stats__value">{{ pending_count }}</div>
</div>
<div>
<div class="invite-stats__label">Converted (paid)</div>
<div class="invite-stats__value">{{ converted_count }}</div>
</div>
<div>
<div class="invite-stats__label">Active credits</div>
<div class="invite-stats__value">{{ active_credit_count }}</div>
{% if own_credit_days %}
<div class="settings-row__hint" style="margin-left:0;">
+{{ own_credit_days }} day{{ '' if own_credit_days == 1 else 's' }} on your account
</div>
{% endif %}
</div>
</div>
</details>
{# --- Email digests block ------------------------------------------ #}
<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>
<div class="settings-row">
<div class="settings-row__label">Subscription</div>
<div class="settings-row__value">
<label style="display:block; margin-bottom:8px;">
<input type="checkbox" id="digest-opt-in"
{% if user.email_digest_opt_in %}checked{% endif %}>
Send me digests
</label>
<div class="settings-row__hint" style="margin-bottom:8px;">
One-click unsubscribe in every email.
</div>
</div>
</div>
<div class="settings-row">
<div class="settings-row__label">Reading level</div>
<div class="settings-row__value">
<div style="display:flex; gap:14px;">
<label><input type="radio" name="digest-tone" value="NOVICE"
{% if (user.digest_tone or 'INTERMEDIATE') == 'NOVICE' %}checked{% endif %}> Pro</label>
<label><input type="radio" name="digest-tone" value="INTERMEDIATE"
{% if (user.digest_tone or 'INTERMEDIATE') == 'INTERMEDIATE' %}checked{% endif %}> Intermediate</label>
</div>
</div>
</div>
<div class="settings-row">
<div class="settings-row__label">Last delivery</div>
<div class="settings-row__value settings-row__hint">
<span id="digest-last">{% if last_email_send %}{{ last_email_send.sent_at.strftime("%Y-%m-%d %H:%M") }} UTC &mdash; {{ last_email_send.status }}{% else %}&mdash;{% endif %}</span>
</div>
</div>
<div id="digest-feedback" class="settings-row__hint" style="margin-top:6px;"></div>
</details>
<script>
(function () {
const opt = document.getElementById('digest-opt-in');
const tones = document.querySelectorAll('input[name="digest-tone"]');
const fb = document.getElementById('digest-feedback');
if (!opt || !fb) return;
function patch() {
fb.textContent = 'Saving…';
const tone = Array.from(tones).find(t => t.checked)?.value || 'INTERMEDIATE';
fetch('/api/settings/digest', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ opt_in: opt.checked, tone: tone }),
}).then(r => {
fb.textContent = r.ok ? 'Saved.' : 'Could not save — try again.';
}).catch(() => { fb.textContent = 'Network error.'; });
}
opt.addEventListener('change', patch);
tones.forEach(t => t.addEventListener('change', patch));
})();
</script>
{# --- Language block ------------------------------------------------ #}
<details class="settings-section">
<summary class="settings-section__head">Language</summary>
<p class="settings-section__lede">
Language the AI uses for the strategic log, your daily digest, and
portfolio commentary. The interface itself stays in English for now.
</p>
<div class="settings-row">
<select id="lang-select" class="settings-select">
<option value="en" {% if (user.lang or 'en') == 'en' %}selected{% endif %}>English</option>
<option value="it" {% if (user.lang or 'en') == 'it' %}selected{% endif %}>Italiano</option>
<option value="es" disabled>Español &middot; coming soon</option>
<option value="fr" disabled>Français &middot; coming soon</option>
<option value="de" disabled>Deutsch &middot; coming soon</option>
</select>
<span id="lang-status" class="settings-status" aria-live="polite"></span>
</div>
<script>
(function () {
var sel = document.getElementById('lang-select');
var status = document.getElementById('lang-status');
if (!sel) return;
sel.addEventListener('change', async function () {
status.textContent = 'saving…';
try {
var r = await fetch('/api/settings/language', {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({lang: sel.value}),
});
if (!r.ok) throw new Error('HTTP ' + r.status);
status.textContent = '✓ saved';
setTimeout(function () { status.textContent = ''; }, 1500);
} catch (e) {
status.textContent = '✗ failed';
}
});
})();
</script>
</details>
{# --- Cloud sync block --------------------------------------------- #}
<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).
</p>
{% if paid and paid.active %}
<div id="sync-status" class="settings-row">
<div class="settings-row__label">Status</div>
<div class="settings-row__value">
<span class="settings-row__hint">checking&hellip;</span>
</div>
</div>
<div id="sync-actions" style="display:flex; gap:8px; flex-wrap:wrap; margin-top:8px;"></div>
<div id="sync-feedback" class="settings-row__hint" style="margin-top:10px;"></div>
{% else %}
<p class="settings-row__hint">
Available on the paid tier. Upgrade or apply an invite credit
above to enable cloud sync.
</p>
{% endif %}
</details>
{# Future: Paddle subscription block, AI-spend ledger summary, etc. #}
{% endif %}
</div>
</section>
{% if user and paid and paid.active %}
<div id="sync-modal" class="modal"
style="position:fixed;inset:0;background:rgba(0,0,0,0.45);
display:none;align-items:center;justify-content:center;z-index:1000;">
<div style="background:var(--surface);color:var(--text);
padding:22px 26px;border-radius:8px;max-width:440px;width:90%;">
<div class="result__head" id="sync-modal-title" style="margin-bottom:8px;">
Enable cloud sync
</div>
<div id="sync-modal-body" style="font-size:13px;line-height:1.55;
color:var(--muted,#666);margin-bottom:14px;">
Choose a PIN (4&ndash;12 characters). The same PIN unlocks the
portfolio on any device. There is no recovery if you forget it.
</div>
<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"
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"
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>
I understand that losing this PIN means I'll have to re-import my CSV.
</label>
<div id="sync-modal-err" class="pf-warn" hidden style="margin-bottom:10px;"></div>
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button type="button" id="sync-modal-cancel" class="pf-secondary">Cancel</button>
<button type="submit" id="sync-modal-submit">Enable</button>
</div>
</form>
</div>
</div>
<script src="{{ url_for(static, path=/js/portfolio-sync.js) }}" defer></script>
<script src="{{ url_for(static, path=/js/settings-sync.js) }}" defer></script>
{% endif %}
<script>
(function () {
var btn = document.getElementById('invite-copy');
var fld = document.getElementById('invite-url');
if (!btn || !fld) return;
btn.addEventListener('click', async function () {
try {
await navigator.clipboard.writeText(fld.value);
var orig = btn.textContent;
btn.textContent = 'Copied';
setTimeout(function () { btn.textContent = orig; }, 1500);
} catch (e) {
// Fallback for older browsers: select the input.
fld.select();
}
});
})();
</script>
{% if user %}
{# Import widget wiring — auto-parse on drop, preview, then commit. #}
<script src="{{ url_for('static', path='/js/portfolio.js') }}?v={{ ASSET_VERSION }}" defer></script>
<script src="{{ url_for('static', path='/js/settings-import.js') }}?v={{ ASSET_VERSION }}" defer></script>
{% endif %}
{% endblock %}