Reverses the polarity of71155a6to match the actual semantics: - "Novice" stays labelled "Novice" → glossary tooltips, plainer prose. - "Intermediate" is relabelled "Pro" → terse, assumes fluency, no hand-holding. This is the mode an expert reader wants, so the "Pro" badge actually fits. Backend tone values (NOVICE, INTERMEDIATE) are unchanged — no API, prompt, or stored-preference impact. Only the display strings flip. Also drops the .tone-toggle button min-width: 10em override added in71155a6. With "Intermediate" gone from the visible label, the longest remaining label is "Novice" (6 chars), which fits the shared 5.5em just like the theme and language toggles. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
363 lines
16 KiB
HTML
363 lines
16 KiB
HTML
{% 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 · 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> — {{ 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 %}
|
||
</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 — Trading 212 is recognised
|
||
natively and other formats (IBKR, Fidelity, Schwab…) are
|
||
auto-detected. We’ll parse it and show a preview before importing
|
||
anywhere.
|
||
<br><span class="muted">T212 export path:
|
||
<span class="neu">Investing → Your Pie → ··· → 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> · max 1 MB · 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–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 %}> Novice</label>
|
||
<label><input type="radio" name="digest-tone" value="INTERMEDIATE"
|
||
{% if (user.digest_tone or 'INTERMEDIATE') == 'INTERMEDIATE' %}checked{% endif %}> Pro</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 — {{ last_email_send.status }}{% else %}—{% 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 · coming soon</option>
|
||
<option value="fr" disabled>Français · coming soon</option>
|
||
<option value="de" disabled>Deutsch · 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…</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–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 %}
|