read.markets/app/templates/settings.html
Giorgio Gilestro dbb14340db fix: ascii quotes in settings.html script tags
The two <script src="{{ url_for(...) }}"> lines for the sync scripts
had Unicode smart-quotes (' / ') instead of ASCII apostrophes —
left over from a copy-paste at some point. Jinja's tokenizer hit the
first one and raised TemplateSyntaxError, so /settings returned a
500. Replaced with ASCII quotes and added the missing ?v=ASSET_VERSION
cache-buster the other static URLs already use.
2026-05-29 15:34:45 +02:00

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 &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 %}> 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 &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') }}?v={{ ASSET_VERSION }}" defer></script>
<script src="{{ url_for('static', path='/js/settings-sync.js') }}?v={{ ASSET_VERSION }}" 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 %}