public: landing + pricing + legal pages, apex-ready, lawyer-reviewed

Adds the unauthenticated surface that's needed to invite outsiders:

  - Landing (/) — dual-purpose root: dashboard for logged-in users,
    landing for everyone else. New maybe_current_user soft-auth helper
    in app/auth.py supports it without disturbing the per-route
    require_token deps on /news, /log, /upload, /settings.
  - About, Pricing, Disclaimer, Terms, Privacy — own router
    (app/routers/public.py), no auth dep, shared public_base layout
    (brand link, thin nav, footer with legal links + ICO ref + date).
  - Editorial positioning: news aggregator with a macro brain; tagline
    "Understand markets. Don't gamble on them."; anti-trading-as-gambling
    stance carried through About and Landing.

Legal pass following an independent lawyer-style review:

  - Privacy: explicit UK-GDPR Art. 6 lawful-basis section; Art. 22
    automated-decision line; explicit consent for sessionStorage sync
    key (PECR); 30-day IP-log retention; Art. 21 objection right;
    Children clause; Art. 33/34 breach-notification clause;
    international-transfer mechanism (IDTA + UK Addendum). ICO
    registration ZC098928 surfaced at the top.
  - Pricing: paid-card AI-portfolio-analysis bullet rewritten to remove
    advice-shaped wording ("what would invalidate the posture" gone);
    added italic carve-out citing FSMA / FCA COBS.
  - Disclaimer: separate EU/EEA carve-out + MAR 596/2014 Art. 3(1)(34)
    commentator safe-harbour; "qualifies the Terms" line; hallucination
    wording fixed.
  - Terms: cl.4 explicit AI-training prohibition + harassment line;
    cl.5 CCR 2013 14-day cancellation; cl.7 softened AI copyright
    claim under CDPA s.9(3) ambiguity; cl.8 proportionate suspension +
    pro-rata refund for paid users; cl.10 CRA 2015 Pt 1 statutory-rights
    carve-out from the liability cap; cl.11 right to close account on
    material change; cl.12 non-exclusive jurisdiction + UK consumer
    local courts.

Code-side enforcement of the Privacy claim:

  - openrouter.py: outbound OpenRouter calls now carry
    X-OR-Allow-Training: false. DeepSeek doesn't expose a per-request
    flag; the Privacy page discloses this caveat verbatim.

Apex domain prep:

  - branding.APP_URL flipped to https://read.markets (was app.). DNS for
    the apex already resolves; pending operator NPM step is a cert that
    covers the bare apex + a 301 from app.read.markets. No hard-coded
    subdomain references remain in code (verified with grep).

Nav + chrome:

  - app dropdown gains Pricing / Terms / Privacy / Disclaimer links.
  - login.html gains a small legal-links footer for the
    highest-leverage moment to surface them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-24 00:08:02 +02:00
parent 6f9a710726
commit f1903e1e61
17 changed files with 1436 additions and 10 deletions

View file

@ -161,6 +161,37 @@ async def require_auth(
) )
async def maybe_current_user(
request: Request,
authorization: str | None = Header(default=None),
) -> CurrentUser | None:
"""Soft-auth: same resolution as `require_auth`, but returns None on
miss instead of raising. Used on dual-purpose routes (e.g. `/` where
a logged-in user sees the dashboard and a logged-out visitor sees the
landing page) and on the public marketing/legal pages, so their
templates can show a "Dashboard" link when a session is present."""
s = get_settings()
if s.CASSANDRA_TOKEN and authorization and authorization.lower().startswith("bearer "):
provided = authorization.split(" ", 1)[1].strip()
if secrets.compare_digest(provided.encode(), s.CASSANDRA_TOKEN.encode()):
principal = CurrentUser(is_admin=True, user=None)
request.state.current_user = principal
return principal
cookie = request.cookies.get(SESSION_COOKIE_NAME)
if cookie:
uid = verify_session(cookie)
if uid is not None:
async with get_session_factory()() as db_session:
user = await get_user(db_session, uid)
if user is not None:
principal = CurrentUser(is_admin=False, user=user)
request.state.current_user = principal
return principal
return None
def _raise_redirect_to_login(next_path: str = "/") -> None: def _raise_redirect_to_login(next_path: str = "/") -> None:
# Some pages (login itself) are paths a redirect loop would be silly # Some pages (login itself) are paths a redirect loop would be silly
# to send back to. The auth router opts out of this dependency # to send back to. The auth router opts out of this dependency

View file

@ -29,9 +29,26 @@ BRAND_NAME = "Read the Markets"
BRAND_SHORT = "Read" BRAND_SHORT = "Read"
DOMAIN = "read.markets" DOMAIN = "read.markets"
SITE_URL = "https://read.markets" SITE_URL = "https://read.markets"
APP_URL = "https://app.read.markets" # The app lives at the apex too — same host serves landing/legal *and*
# the dashboard. SITE_URL and APP_URL are kept as separate symbols so a
# future split (marketing/apex, app/subdomain) is a two-line change.
APP_URL = "https://read.markets"
EMAIL_FROM_DEFAULT = f"noreply@{DOMAIN}" EMAIL_FROM_DEFAULT = f"noreply@{DOMAIN}"
# Marketing line — printed in the landing hero. Single source of truth so
# OG cards, email subjects, and the landing template stay in sync. The
# wording is a stance, not just a description: this is for people who
# treat investing and gambling as different activities. The "news
# aggregator / media service" framing still lives in the body copy and
# the disclaimer, where it does the legal distancing work.
TAGLINE = "Understand markets. Don't gamble on them."
# Legal-page operator details. Placeholders until the user supplies real
# ones; only the legal pages read them.
LEGAL_OPERATOR = BRAND_NAME
OPERATOR_EMAIL = f"hello@{DOMAIN}"
OPERATOR_JURISDICTION = "United Kingdom"
DARK: dict[str, str] = { DARK: dict[str, str] = {
"bg": "#0a0e14", "bg": "#0a0e14",

View file

@ -20,6 +20,7 @@ from app.logging import configure_logging, get_logger
from app.routers import api as api_router from app.routers import api as api_router
from app.routers import auth as auth_router from app.routers import auth as auth_router
from app.routers import pages as pages_router from app.routers import pages as pages_router
from app.routers import public as public_router
from app.routers import sync as sync_router from app.routers import sync as sync_router
from app.routers import universe as universe_router from app.routers import universe as universe_router
from app.services.feeds_bootstrap import bootstrap_feeds from app.services.feeds_bootstrap import bootstrap_feeds
@ -85,4 +86,7 @@ app.include_router(auth_router.router, tags=["auth"])
app.include_router(api_router.router, prefix="/api", tags=["api"]) app.include_router(api_router.router, prefix="/api", tags=["api"])
app.include_router(universe_router.router, prefix="/api", tags=["universe"]) app.include_router(universe_router.router, prefix="/api", tags=["universe"])
app.include_router(sync_router.router, tags=["portfolio-sync"]) app.include_router(sync_router.router, tags=["portfolio-sync"])
# Public router (no auth dep) before pages_router so the marketing/legal
# paths can never collide with future authenticated routes.
app.include_router(public_router.router)
app.include_router(pages_router.router, tags=["pages"]) app.include_router(pages_router.router, tags=["pages"])

View file

@ -8,7 +8,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy import desc, func, select from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import CurrentUser, require_auth, require_token from app.auth import CurrentUser, maybe_current_user, require_auth, require_token
from app.config import get_settings, load_groups from app.config import get_settings, load_groups
from app.db import get_session from app.db import get_session
from app.models import Referral, StrategicLog, User from app.models import Referral, StrategicLog, User
@ -16,26 +16,41 @@ from app.services.access import paid_status
from app.services.referral_service import assign_code_if_missing from app.services.referral_service import assign_code_if_missing
from app.templates_env import templates from app.templates_env import templates
router = APIRouter(dependencies=[Depends(require_token)]) # Router-level auth removed in favour of per-route deps so that `/` can be
# dual-purpose: logged-in users see the dashboard, logged-out visitors see
# the landing page.
router = APIRouter()
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
async def dashboard(request: Request): async def root_page(
request: Request,
cu: CurrentUser | None = Depends(maybe_current_user),
):
"""Dual-purpose root: dashboard when authenticated, landing otherwise."""
if cu is None:
return templates.TemplateResponse(
request, "landing.html", {"cu": None},
)
s = get_settings() s = get_settings()
groups = load_groups(s.BASELINE_TOML, s.PORTFOLIO_TOML) groups = load_groups(s.BASELINE_TOML, s.PORTFOLIO_TOML)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"dashboard.html", "dashboard.html",
{"groups": list(groups.keys()), "anchor": s.CASSANDRA_ANCHOR_DATE}, {"groups": list(groups.keys()), "anchor": s.CASSANDRA_ANCHOR_DATE, "cu": cu},
) )
@router.get("/news", response_class=HTMLResponse) @router.get(
"/news",
response_class=HTMLResponse,
dependencies=[Depends(require_token)],
)
async def news_page(request: Request): async def news_page(request: Request):
return templates.TemplateResponse(request, "news.html", {}) return templates.TemplateResponse(request, "news.html", {})
@router.get("/upload") @router.get("/upload", dependencies=[Depends(require_token)])
async def upload_page(request: Request): async def upload_page(request: Request):
"""Legacy bookmark — the import widget now lives in /settings.""" """Legacy bookmark — the import widget now lives in /settings."""
return RedirectResponse(url="/settings#import", status_code=302) return RedirectResponse(url="/settings#import", status_code=302)
@ -69,7 +84,11 @@ def _log_page_context(target: date) -> dict:
} }
@router.get("/log", response_class=HTMLResponse) @router.get(
"/log",
response_class=HTMLResponse,
dependencies=[Depends(require_token)],
)
async def log_page( async def log_page(
request: Request, request: Request,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
@ -78,7 +97,11 @@ async def log_page(
return templates.TemplateResponse(request, "log.html", _log_page_context(target)) return templates.TemplateResponse(request, "log.html", _log_page_context(target))
@router.get("/log/{day}", response_class=HTMLResponse) @router.get(
"/log/{day}",
response_class=HTMLResponse,
dependencies=[Depends(require_token)],
)
async def log_page_day( async def log_page_day(
request: Request, request: Request,
day: str, day: str,

68
app/routers/public.py Normal file
View file

@ -0,0 +1,68 @@
"""Unauthenticated marketing + legal pages.
This router carries no auth dependency every route is reachable to
anonymous visitors and is also reachable to logged-in users (the
templates branch off `cu` to flip the top-right CTA between
"Sign in / sign up" and "Dashboard").
The dual-purpose root (`/`) lives in `app/routers/pages.py` because it
also has to render the dashboard when authenticated. Pure public-only
pages live here.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from app.auth import CurrentUser, maybe_current_user
from app.templates_env import templates
router = APIRouter(tags=["public"])
def _ctx(request: Request, cu: CurrentUser | None) -> dict:
"""Minimal context every public template expects. `cu` is injected
into the template so the header CTA can flip between
'Sign in / sign up' and 'Dashboard'."""
return {"cu": cu}
@router.get("/pricing", response_class=HTMLResponse)
async def pricing_page(
request: Request,
cu: CurrentUser | None = Depends(maybe_current_user),
):
return templates.TemplateResponse(request, "pricing.html", _ctx(request, cu))
@router.get("/about", response_class=HTMLResponse)
async def about_page(
request: Request,
cu: CurrentUser | None = Depends(maybe_current_user),
):
return templates.TemplateResponse(request, "about.html", _ctx(request, cu))
@router.get("/terms", response_class=HTMLResponse)
async def terms_page(
request: Request,
cu: CurrentUser | None = Depends(maybe_current_user),
):
return templates.TemplateResponse(request, "terms.html", _ctx(request, cu))
@router.get("/privacy", response_class=HTMLResponse)
async def privacy_page(
request: Request,
cu: CurrentUser | None = Depends(maybe_current_user),
):
return templates.TemplateResponse(request, "privacy.html", _ctx(request, cu))
@router.get("/disclaimer", response_class=HTMLResponse)
async def disclaimer_page(
request: Request,
cu: CurrentUser | None = Depends(maybe_current_user),
):
return templates.TemplateResponse(request, "disclaimer.html", _ctx(request, cu))

View file

@ -549,6 +549,13 @@ def _endpoint_for(provider: str) -> tuple[str, str, str, dict[str, str]]:
# OpenRouter dashboard — keep aligned with the live brand. # OpenRouter dashboard — keep aligned with the live brand.
"HTTP-Referer": branding.SITE_URL, "HTTP-Referer": branding.SITE_URL,
"X-Title": branding.BRAND_NAME, "X-Title": branding.BRAND_NAME,
# No-train opt-out. Tells OpenRouter (and any compatible
# upstream) that this request must not be used to train
# or improve models. The Privacy notice promises this; the
# header is what makes the promise truthful. If a future
# upstream ignores the header, fix the provider — not the
# header — so the contract stays auditable.
"X-OR-Allow-Training": "false",
}, },
) )
raise RuntimeError(f"Unknown LLM provider: {provider!r}") raise RuntimeError(f"Unknown LLM provider: {provider!r}")

View file

@ -1421,3 +1421,290 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
::-webkit-scrollbar-track { background: var(--bg); } ::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--dim); border-radius: 0; } ::-webkit-scrollbar-thumb { background: var(--dim); border-radius: 0; }
::-webkit-scrollbar-thumb:hover { background: var(--muted); } ::-webkit-scrollbar-thumb:hover { background: var(--muted); }
/* ============================================================
* Public pages landing, pricing, about, terms, privacy, disclaimer.
* Shared by all templates extending public_base.html. Visual language
* matches the app shell (same palette, monospace brand, restrained
* typography) but without dashboard chrome.
* ============================================================ */
.public-page { background: var(--bg); }
.public-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
max-width: 1080px;
margin: 0 auto;
padding: 0 24px;
}
.public-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 22px 0 16px;
border-bottom: 1px solid var(--border);
}
.public-header__brand {
color: var(--accent);
font-weight: 700;
text-decoration: none;
font-family: var(--font-mono);
font-size: 15px;
letter-spacing: 0.01em;
}
.public-header__brand::before { content: "▰ "; opacity: 0.6; }
.public-header__brand:hover { color: var(--text); }
.public-header__nav { display: flex; align-items: center; gap: 22px; }
.public-header__nav a {
color: var(--muted);
font-size: 13px;
text-decoration: none;
}
.public-header__nav a:hover,
.public-header__nav a.active { color: var(--text); }
.public-header__cta {
color: var(--accent) !important;
border: 1px solid var(--accent);
padding: 6px 14px;
border-radius: 3px;
}
.public-header__cta:hover { background: var(--accent); color: var(--bg) !important; }
.public-main {
flex: 1;
padding: 48px 0 64px;
}
.public-footer {
border-top: 1px solid var(--border);
padding: 28px 0 36px;
margin-top: 24px;
font-size: 12px;
color: var(--muted);
}
.public-footer__inner {
display: flex;
flex-direction: column;
gap: 14px;
}
.public-footer__brand strong { color: var(--text); margin-right: 10px; }
.public-footer__tagline { color: var(--muted); }
.public-footer__links { display: flex; flex-wrap: wrap; gap: 16px; }
.public-footer__links a { color: var(--muted); text-decoration: none; }
.public-footer__links a:hover { color: var(--accent); }
.public-footer__meta { color: var(--dim); font-size: 11px; }
/* --- Hero (landing) -------------------------------------------------- */
.hero {
padding: 32px 0 48px;
border-bottom: 1px solid var(--border);
margin-bottom: 48px;
}
.hero__brand {
color: var(--muted);
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hero__headline {
font-size: clamp(28px, 5vw, 44px);
font-weight: 700;
line-height: 1.15;
color: var(--text);
margin: 12px 0 14px;
letter-spacing: -0.01em;
}
.hero__subhead {
font-size: 16px;
color: var(--muted);
max-width: 640px;
line-height: 1.55;
margin: 0 0 24px;
}
.hero__ctas { display: flex; flex-wrap: wrap; gap: 12px; }
.hero__ctas .btn-primary,
.hero__ctas .btn-secondary {
display: inline-block;
padding: 10px 22px;
border-radius: 3px;
font-size: 13.5px;
font-weight: 500;
text-decoration: none;
}
/* Qualify with `a` so we beat `a { color: var(--accent) }` and any
:link/:visited UA defaults. Without `a.btn-primary` the cascade can
resolve in favour of the visited-link color on some browsers and the
label disappears against the accent background. */
a.btn-primary,
a.btn-primary:link,
a.btn-primary:visited {
background: var(--accent);
color: var(--bg);
border: 1px solid var(--accent);
}
a.btn-primary:hover { background: transparent; color: var(--accent); }
a.btn-secondary,
a.btn-secondary:link,
a.btn-secondary:visited {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
}
a.btn-secondary:hover { color: var(--accent); border-color: var(--accent); }
/* --- Feature blocks (landing) --------------------------------------- */
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 24px;
margin: 8px 0 56px;
}
.feature-card {
border: 1px solid var(--border);
border-radius: 4px;
padding: 22px 22px 24px;
background: var(--surface);
}
.feature-card__tag {
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--accent);
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 10px;
}
.feature-card__title {
font-size: 17px;
font-weight: 600;
color: var(--text);
margin: 0 0 10px;
}
.feature-card__body {
font-size: 13.5px;
line-height: 1.6;
color: var(--muted);
margin: 0;
}
/* --- Section primitives reused across pricing/about/legal ---------- */
.public-section {
margin: 0 0 56px;
}
.public-section__head {
font-size: 20px;
font-weight: 600;
color: var(--text);
margin: 0 0 16px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.public-section h3 {
font-size: 15px;
font-weight: 600;
color: var(--text);
margin: 24px 0 8px;
}
.public-section p,
.public-section li {
font-size: 14px;
line-height: 1.65;
color: var(--text);
}
.public-section p { margin: 0 0 14px; }
.public-section ul {
margin: 0 0 16px;
padding-left: 22px;
}
.public-section li { margin-bottom: 6px; }
.public-section a { color: var(--accent); }
.public-section--callout {
border-left: 3px solid var(--accent);
padding: 16px 22px;
background: var(--surface);
border-radius: 0 4px 4px 0;
margin: 0 0 32px;
}
.public-section--warning {
border-left-color: var(--negative);
background: color-mix(in srgb, var(--negative) 6%, var(--bg));
}
.public-section--warning a { color: var(--text); }
/* --- "What this is not" strip on landing --------------------------- */
.not-strip {
border: 1px dashed var(--border);
padding: 18px 22px;
border-radius: 4px;
margin: 0 0 56px;
background: var(--surface);
}
.not-strip strong { color: var(--text); }
.not-strip ul { display: flex; flex-wrap: wrap; gap: 18px 28px; margin: 8px 0 0; padding: 0; list-style: none; }
.not-strip li { color: var(--muted); font-size: 13px; }
.not-strip li::before { content: "✕ "; color: var(--negative); font-weight: 700; margin-right: 4px; }
/* --- Pricing comparison -------------------------------------------- */
.tier-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 18px;
margin: 8px 0 24px;
}
.tier-card {
border: 1px solid var(--border);
border-radius: 4px;
padding: 22px 22px 26px;
background: var(--surface);
display: flex;
flex-direction: column;
}
.tier-card--featured {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent) inset;
}
.tier-card__name {
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted);
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 8px;
}
.tier-card__price {
font-size: 22px;
font-weight: 700;
color: var(--text);
margin-bottom: 4px;
}
.tier-card__price-hint {
font-size: 12px;
color: var(--muted);
margin-bottom: 18px;
}
.tier-card ul {
list-style: none;
padding: 0;
margin: 0 0 22px;
flex: 1;
}
.tier-card li {
font-size: 13.5px;
color: var(--text);
padding: 6px 0;
border-bottom: 1px solid var(--border);
}
.tier-card li:last-child { border-bottom: 0; }
.tier-card li::before { content: "✓ "; color: var(--positive); font-weight: 700; margin-right: 4px; }
.tier-card li.tier-card__excluded { color: var(--muted); }
.tier-card li.tier-card__excluded::before { content: "✕ "; color: var(--dim); }
.tier-card__cta { margin-top: auto; }

76
app/templates/about.html Normal file
View file

@ -0,0 +1,76 @@
{% extends "public_base.html" %}
{% block title %}{{ BRAND_NAME }} &middot; About{% endblock %}
{% block main %}
<section class="public-section">
<h1 class="public-section__head">About {{ BRAND_NAME }}</h1>
<p>
{{ BRAND_NAME }} is a <strong>news aggregator with a macro brain</strong>.
We pull market headlines and a curated set of cross-asset signals,
auto-tag the news by theme, and use a large language model to write
a short interpretation every hour &mdash; in plain English, with a fixed
editorial discipline.
</p>
<p>
Editorially we&rsquo;re a media service, not a financial one. We
don&rsquo;t make buy/sell calls, we don&rsquo;t do technical
analysis, and we don&rsquo;t pretend to know which way the tape goes
next.
</p>
</section>
<section class="public-section">
<h2 class="public-section__head">Who it&rsquo;s for</h2>
<p>
Investors who&rsquo;d rather <em>understand</em> than trade. People
who want a coherent read of the underlying fundamentals &mdash; what the
real economy, policy, and valuation are doing &mdash; not the next 30
minutes of price action, not chart patterns, and not which ETF to
buy.
</p>
<p>
Particularly: investors new enough to markets that the gambling
framing of social media is doing real damage. Every read here is
deliberately calm, anti-technical-analysis, and rooted in
fundamentals. We treat trading and gambling as different activities,
and we&rsquo;re built for the people who already see them that way.
</p>
</section>
<section class="public-section">
<h2 class="public-section__head">How it&rsquo;s built</h2>
<p>
Architecturally, the product is deliberately privacy-shaped:
</p>
<ul>
<li>Your portfolio lives in your browser. The server&rsquo;s view is
an aggregate set of tickers held across the whole user base,
which on its own does not identify any individual user &mdash; see
the <a href="/privacy">Privacy notice</a> for the exact data
structures.</li>
<li>Cloud sync of your portfolio is opt-in and end-to-end encrypted
with a PIN only you know.</li>
<li>No third-party tracking, no analytics SDKs, no ad cookies.</li>
</ul>
<p>
Full details on the <a href="/privacy">privacy page</a>.
</p>
</section>
<section class="public-section public-section--callout">
<p style="margin:0 0 8px;">
{{ BRAND_NAME }} is operated from {{ OPERATOR_JURISDICTION }} by an
individual operator. It is <strong>not a regulated firm</strong>, and
nothing here is investment advice. See the
<a href="/disclaimer">disclaimer</a>.
</p>
<p style="margin:0; font-size:12.5px; color: var(--muted);">
If you are in financial distress, please consider speaking to a
free service such as
<a href="https://www.moneyhelper.org.uk/" target="_blank" rel="noopener">MoneyHelper</a>
before relying on anything you read here.
</p>
</section>
{% endblock %}

View file

@ -167,7 +167,11 @@
{% if cu.user %} {% if cu.user %}
<a href="/settings" role="menuitem" class="user-menu__item">Settings</a> <a href="/settings" role="menuitem" class="user-menu__item">Settings</a>
{% endif %} {% endif %}
<a href="/logout" role="menuitem" class="user-menu__item">Logout</a> <a href="/pricing" role="menuitem" class="user-menu__item">Pricing</a>
<a href="/terms" role="menuitem" class="user-menu__item">Terms</a>
<a href="/privacy" role="menuitem" class="user-menu__item">Privacy</a>
<a href="/disclaimer" role="menuitem" class="user-menu__item">Disclaimer</a>
<a href="/logout" role="menuitem" class="user-menu__item">Logout</a>
</div> </div>
</div> </div>
{% endif %} {% endif %}

View file

@ -0,0 +1,110 @@
{% extends "public_base.html" %}
{% block title %}{{ BRAND_NAME }} &middot; Disclaimer{% endblock %}
{% block main %}
<section class="public-section public-section--callout public-section--warning">
<h1 class="public-section__head" style="border:0; margin:0 0 6px;">
Financial disclaimer
</h1>
<p style="margin:0; font-size:15px;">
<strong>{{ BRAND_NAME }} is not investment advice.</strong>
</p>
<p style="margin-top:6px; font-size:12px; color: var(--muted);">
This page is part of, and qualifies, the
<a href="/terms">Terms of Service</a>.
</p>
</section>
<section class="public-section">
<h2 class="public-section__head">In short</h2>
<ul>
<li>Everything published here is for <strong>educational and
informational purposes only</strong>.</li>
<li>Nothing on this site is a buy or sell recommendation, a personal
recommendation, or an inducement to deal in any financial
instrument.</li>
<li>{{ BRAND_NAME }} is not a regulated financial firm. It is not
authorised by the Financial Conduct Authority and is not a
registered investment adviser in any jurisdiction.</li>
<li>The output is not tailored to your circumstances, objectives,
tax position, or risk tolerance.</li>
<li>Past performance does not predict future returns. Investing
carries the risk of losing money, including the principal.</li>
</ul>
</section>
<section class="public-section">
<h2 class="public-section__head">About the AI output</h2>
<p>
The strategic log, indicator summaries, and portfolio analysis are
generated by large language models from publicly available market
data and news. They can be wrong, incomplete, or out of date. Numbers
can be misread. Models occasionally generate inaccurate or invented
information (often called &ldquo;hallucinations&rdquo;). Treat them
as a <em>prompt to think</em>, not as facts to act on.
</p>
<p>
The portfolio analysis is an interpretation of holdings <em>you
supplied</em>. It does not consider your overall wealth, debts, tax
position, or anything we don&rsquo;t see. It is not personalised
advice.
</p>
</section>
<section class="public-section">
<h2 class="public-section__head">Before you act on anything</h2>
<p>
Consult a properly qualified, regulated financial adviser who knows
your full situation. Read the source documents (issuer accounts,
fund prospectus, etc.). If you are not in a position to lose the
money you would put at risk, do not put it at risk.
</p>
</section>
<section class="public-section">
<h2 class="public-section__head">Jurisdiction</h2>
<p>
{{ BRAND_NAME }} is operated from {{ OPERATOR_JURISDICTION }}. The
Service is not directed at, or intended for distribution to or use
by, any person in any jurisdiction where such distribution or use
would be contrary to local law or regulation.
</p>
<p>
No part of this Service constitutes an offer or solicitation of
securities to any US person within the meaning of US securities law.
</p>
<p>
The Service is not directed at retail or professional clients in any
EU/EEA member state, nor in any jurisdiction where its provision
would require local licensing or registration. Where any output of
the Service could be construed as an &ldquo;investment
recommendation&rdquo; under Regulation (EU) 596/2014 (Market Abuse
Regulation) or its UK equivalent, it is non-personalised, produced
by a non-regulated source for educational purposes only, and the
operator (a) has no position in, or remuneration linked to, the
specific instruments mentioned in any individual piece of commentary,
and (b) is not a &ldquo;relevant person&rdquo; within MAR Art.
3(1)(34).
</p>
</section>
<section class="public-section">
<h2 class="public-section__head">No warranty</h2>
<p>
The service is provided &ldquo;as is&rdquo; without warranties of any
kind. To the maximum extent permitted by applicable law, the operator
excludes liability for any loss arising from use of, or reliance on,
the content. See the <a href="/terms">Terms of Service</a> for the
full limitation of liability.
</p>
</section>
<section class="public-section">
<p style="font-size: 12px; color: var(--muted);">
Questions about this disclaimer:
<a href="mailto:{{ OPERATOR_EMAIL }}">{{ OPERATOR_EMAIL }}</a>.
</p>
</section>
{% endblock %}

103
app/templates/landing.html Normal file
View file

@ -0,0 +1,103 @@
{% extends "public_base.html" %}
{% block title %}{{ BRAND_NAME }} &middot; {{ TAGLINE }}{% endblock %}
{% block main %}
<section class="hero">
<div class="hero__brand">{{ BRAND_NAME }}</div>
<h1 class="hero__headline">{{ TAGLINE }}</h1>
<p class="hero__subhead">
Built for investors who want to <strong>act rationally</strong> and
tune out the high-frequency noise that comes from treating markets
like a casino. We aggregate cross-asset news and macro signals,
then write a plain-English read of what the underlying fundamentals
justify versus what the crowd is doing. Refreshed hourly. A media
service, not a financial one.
</p>
<div class="hero__ctas">
{% if cu and (cu.user or cu.is_admin) %}
<a class="btn-primary" href="/">Open dashboard</a>
<a class="btn-secondary" href="/pricing">See pricing</a>
{% else %}
<a class="btn-primary" href="/login">Sign up free</a>
<a class="btn-secondary" href="/pricing">See pricing</a>
{% endif %}
</div>
</section>
<section class="feature-grid">
<div class="feature-card">
<div class="feature-card__tag">News, aggregated</div>
<h3 class="feature-card__title">Headlines from across the macro universe</h3>
<p class="feature-card__body">
RSS and per-ticker feeds covering equities, rates, credit, FX,
commodities, and geopolitics. Every headline is auto-tagged by
theme so the noise stays as noise and the fundamentals-relevant
stuff is easy to find. Ingestion follows the trading calendar &mdash;
off-hours stay quiet.
</p>
</div>
<div class="feature-card">
<div class="feature-card__tag">Macro signals</div>
<h3 class="feature-card__title">A curated cross-asset tape</h3>
<p class="feature-card__body">
A hand-picked set of indicators across every asset class, refreshed
hourly during market hours. Each group gets a short read that
explains what the move <em>means</em>, not what it was. Anchored
in earnings, policy, valuation &mdash; not chart patterns.
</p>
</div>
<div class="feature-card">
<div class="feature-card__tag">The hourly read</div>
<h3 class="feature-card__title">Rational vs irrational, every paragraph</h3>
<p class="feature-card__body">
We tie the day&rsquo;s headlines and the cross-asset signals into
a single short interpretation. Each paragraph separates
<strong>rational drivers</strong> (earnings, policy, valuation)
from <strong>irrational ones</strong> (positioning, narrative,
flows) and names the gap. Two reading levels: novice and
intermediate. This is editorial commentary on public data &mdash;
not a forecast and not advice on any investment decision.
</p>
</div>
</section>
<section class="public-section">
<p style="font-size: 13.5px; color: var(--muted);">
Paid users can also drop a Trading 212 pie CSV for an AI
sense-check on concentration, regime fit, and currency exposure.
Holdings stay in your browser by default; opt in to encrypted cloud
sync to restore on another device.
</p>
</section>
<section class="not-strip">
<strong>What this isn&rsquo;t.</strong>
<ul>
<li>Not investment advice.</li>
<li>Not trading signals.</li>
<li>Not a day-trading tool.</li>
<li>No buy/sell calls, ever.</li>
<li>No chart-pattern predictions.</li>
<li>Not a regulated service.</li>
</ul>
</section>
<section class="public-section">
<p style="font-size: 13px; color: var(--muted);">
By signing up you agree to our <a href="/terms">Terms</a> and
<a href="/privacy">Privacy notice</a>, and confirm you&rsquo;ve read
the <a href="/disclaimer">financial disclaimer</a>.
</p>
<div class="hero__ctas" style="margin-top:8px;">
{% if cu and (cu.user or cu.is_admin) %}
<a class="btn-primary" href="/">Open dashboard</a>
{% else %}
<a class="btn-primary" href="/login">Sign up free</a>
{% endif %}
</div>
</section>
{% endblock %}

View file

@ -41,6 +41,13 @@
</label> </label>
<button type="submit">Send code</button> <button type="submit">Send code</button>
</form> </form>
<p class="auth-card__legal" style="margin-top:18px; font-size:11px; color: var(--muted); line-height:1.6;">
By signing in you agree to our
<a href="/terms">Terms</a> and
<a href="/privacy">Privacy notice</a>, and confirm you&rsquo;ve read
the <a href="/disclaimer">financial disclaimer</a>.
</p>
</div> </div>
</div> </div>
</body> </body>

View file

@ -0,0 +1,96 @@
{% extends "public_base.html" %}
{% block title %}{{ BRAND_NAME }} &middot; Pricing{% endblock %}
{% block main %}
<section class="public-section">
<h1 class="public-section__head">Pricing</h1>
<p>
Two tiers. The news aggregator and the hourly macro interpretation
are free for everyone &mdash; we want the read out where people can use
it. The paid tier extends the same editorial commentary to the
specific tickers in a portfolio you upload &mdash; an educational read
of public data, not advice on whether to hold them.
</p>
</section>
<section class="tier-grid">
<div class="tier-card">
<div class="tier-card__name">Free</div>
<div class="tier-card__price">&pound;0</div>
<div class="tier-card__price-hint">Forever. No card needed.</div>
<ul>
<li>News aggregator &mdash; auto-tagged by theme</li>
<li>Cross-asset macro signals across every asset class</li>
<li>Hourly AI interpretation of the news + the tape</li>
<li>Per-group cross-asset summaries</li>
<li>Novice / Intermediate reading levels</li>
<li class="tier-card__excluded">Portfolio import &amp; analysis</li>
<li class="tier-card__excluded">Encrypted cloud sync</li>
</ul>
<div class="tier-card__cta">
{% if cu and (cu.user or cu.is_admin) %}
<a class="btn-secondary" href="/">Open dashboard</a>
{% else %}
<a class="btn-primary" href="/login">Sign up free</a>
{% endif %}
</div>
</div>
<div class="tier-card tier-card--featured">
<div class="tier-card__name">Paid</div>
<div class="tier-card__price">Coming soon</div>
<div class="tier-card__price-hint">Checkout opens with our payments rollout.</div>
<ul>
<li>Everything in Free</li>
<li>Portfolio import (Trading 212 CSV)</li>
<li>AI commentary on diversification, sector and currency concentration, and macro-regime context for the holdings you upload</li>
<li>Optional encrypted cloud sync across devices</li>
<li>Priority email when something material changes (later)</li>
</ul>
<div class="tier-card__cta">
{% if cu and (cu.user or cu.is_admin) %}
<a class="btn-secondary" href="/settings">Manage account</a>
{% else %}
<a class="btn-primary" href="/login">Sign up &mdash; paid unlocks soon</a>
{% endif %}
</div>
<p style="margin-top:14px; font-size:11.5px; color: var(--muted); font-style: italic; line-height:1.55;">
The portfolio feature does not produce buy, sell or hold
recommendations. It does not consider your wider finances, debts,
tax position or objectives. It is not regulated investment advice
or a personal recommendation under FSMA / FCA COBS.
</p>
</div>
</section>
<section class="public-section">
<h2 class="public-section__head">How the data is handled</h2>
<p>
Your portfolio holdings live in your browser&rsquo;s local storage by
default. The server only learns which Yahoo tickers appear across the
user base &mdash; an anonymous union, with no link back to any specific
user.
</p>
<p>
If you opt in to <strong>encrypted cloud sync</strong>, your pie is
encrypted in your browser with a PIN you choose, then sent to the
server. We add a second layer of encryption with a key only the
server holds. We never see your holdings as plaintext, and forgetting
the PIN means we can&rsquo;t recover it for you. Full details on the
<a href="/privacy">privacy page</a>.
</p>
</section>
<section class="public-section public-section--callout">
<p style="margin:0;">
<strong>Not investment advice.</strong> Every output here is an
interpretation of public data &mdash; not personalised advice, not a
recommendation, and not produced by a regulated entity. Read the full
<a href="/disclaimer">disclaimer</a> before relying on anything you see.
</p>
</section>
{% endblock %}

311
app/templates/privacy.html Normal file
View file

@ -0,0 +1,311 @@
{% extends "public_base.html" %}
{% block title %}{{ BRAND_NAME }} &middot; Privacy{% endblock %}
{% block main %}
<section class="public-section">
<h1 class="public-section__head">Privacy notice</h1>
<p style="color: var(--muted); font-size: 13px;">
Last updated: 2026-05-24. The operator (data controller) is
{{ LEGAL_OPERATOR }}, {{ OPERATOR_JURISDICTION }}. Registered with
the UK Information Commissioner&rsquo;s Office under reference
<strong>ZC098928</strong>. Questions:
<a href="mailto:{{ OPERATOR_EMAIL }}">{{ OPERATOR_EMAIL }}</a>.
</p>
<p>
This page describes exactly what we collect, what we don&rsquo;t,
where it lives, and how long we keep it. It is written from the code,
not from a template &mdash; every claim corresponds to an explicit
code path we&rsquo;re happy to point a reviewer at.
</p>
</section>
<section class="public-section">
<h2 class="public-section__head">What we collect</h2>
<ul>
<li>
<strong>Your email address</strong>, when you sign in. We use it
only to send one-time login codes.
</li>
<li>
<strong>An argon2 hash of each login code</strong>, plus expiry
and attempt counts. The plaintext code is sent to your inbox and
never written to disk on our side.
</li>
<li>
<strong>A signed session cookie</strong> after you verify a code.
It contains your user id only and is signed so we can detect
tampering. Cookie is marked Secure and HttpOnly.
</li>
<li>
<strong>Anonymous ticker universe</strong>: when you upload a
portfolio CSV we record which Yahoo tickers appear, with
<em>no link</em> to your account. The same row would exist whether
any specific user holds the ticker or not &mdash; once a ticker is in
the universe, the row carries no signal as to whose import added it.
</li>
<li>
<strong>If you opt in to encrypted cloud sync</strong>: an opaque
blob of bytes per user. The blob is your portfolio, encrypted in
your browser with a PIN you choose, then wrapped a second time on
the server with a key only the server holds. We can&rsquo;t decrypt
the blob to plaintext without your PIN, and we can&rsquo;t recover
your PIN if you forget it. By enabling cloud sync you give your
consent (UK-GDPR Art. 6(1)(a)) to this processing; you can
withdraw consent at any time by disabling sync in Settings, which
also removes the server-side blob.
</li>
<li>
<strong>Anonymised cost ledger</strong> of AI calls (model, tokens,
cost). No portfolio or personal data is attached to ledger rows.
</li>
<li>
<strong>Referral linkage</strong>: if you signed up via an invite
link, we record which existing user&rsquo;s code you used so we can
apply the agreed referral credit later.
</li>
<li>
<strong>Job-run telemetry</strong>: success/failure timestamps for
the scheduled jobs that fetch market data and generate AI reads.
No user identifiers are attached.
</li>
</ul>
</section>
<section class="public-section">
<h2 class="public-section__head">What we don&rsquo;t collect</h2>
<ul>
<li>
<strong>Your portfolio holdings as plaintext on the server.</strong>
Parsed pies are returned to your browser and kept in
<code>localStorage</code>. The server&rsquo;s view is the anonymous
ticker universe described above.
</li>
<li>
<strong>Third-party analytics or ad cookies.</strong> No Google
Analytics, no Hotjar, no Segment, no Facebook pixel, no LinkedIn
tag. (You can verify by viewing-source on any page.)
</li>
<li>
<strong>Browser fingerprints.</strong>
</li>
<li>
<strong>IP-address joins to your user identity.</strong> IP
addresses are processed transiently by the reverse proxy for
security and access logging, retained for up to 30 days, and not
linked to your account record.
</li>
</ul>
</section>
<section class="public-section">
<h2 class="public-section__head">Lawful basis (UK-GDPR Art. 6)</h2>
<p>We rely on the following lawful bases:</p>
<ul>
<li>
<strong>Performance of a contract</strong> (Art. 6(1)(b)) &mdash; for
operating your account, the sign-in flow, paid features, and the
mechanics of encrypted cloud sync.
</li>
<li>
<strong>Legitimate interests</strong> (Art. 6(1)(f)) &mdash; for the
anonymous ticker universe, the anonymised cost ledger, job-run
telemetry, and reverse-proxy access logs. Our interest is the
secure, abuse-resistant, cost-controlled operation of a free
public service, balanced against the minimal and de-identified
nature of the data.
</li>
<li>
<strong>Consent</strong> (Art. 6(1)(a)) &mdash; where you opt in to
encrypted cloud sync (and the related caching of a derived
encryption key in your browser&rsquo;s <code>sessionStorage</code>).
You can withdraw consent at any time by disabling sync in
Settings; the cached key is cleared and the server-side blob is
removed.
</li>
</ul>
</section>
<section class="public-section">
<h2 class="public-section__head">Automated decisions and profiling</h2>
<p>
The Service does not make decisions about you that produce legal or
similarly significant effects in an automated way (UK-GDPR Art. 22).
The AI portfolio analysis is editorial commentary on the holdings
you upload; it does not approve, reject or rank you, and you remain
the sole decision-maker about anything in your account.
</p>
</section>
<section class="public-section">
<h2 class="public-section__head">Cookies and local storage</h2>
<ul>
<li>
<strong>Session cookie</strong> &mdash; strictly necessary for keeping
you signed in (PECR reg. 6(4)). No prior consent required.
</li>
<li>
<strong>Local preferences</strong> &mdash; your chosen theme (light /
dark) and reading level (Novice / Intermediate) are stored in
<code>localStorage</code> on your device. They never leave the
browser.
</li>
<li>
<strong>Local portfolio + cached sync key</strong> &mdash; parsed pies
live in <code>localStorage</code> on your device. If you enable
cloud sync, the derived encryption key is cached in
<code>sessionStorage</code> so you don&rsquo;t have to re-enter
your PIN on every navigation. This caching is performed only with
your consent (given when you enable sync); it is cleared when you
close the tab or disable sync.
</li>
</ul>
</section>
<section class="public-section">
<h2 class="public-section__head">Where the data lives, and international transfers</h2>
<p>
The server runs in {{ OPERATOR_JURISDICTION }}. Data is stored in a
MariaDB database on the same host, backed up locally.
</p>
<p>
Two flows can take personal data outside the UK:
</p>
<ul>
<li>
<strong>SMTP</strong> for sending one-time login codes. Operator-hosted,
currently inside the UK; if that changes we will update this notice.
</li>
<li>
<strong>AI provider calls</strong> for the strategic log, indicator
summaries, and (paid) portfolio analysis. Where the provider sits
outside the UK, we rely on the UK International Data Transfer
Agreement (IDTA) / the UK Addendum to the EU Standard Contractual
Clauses where no adequacy decision applies. Each outbound request
carries an explicit no-training opt-out header
(<code>X-OR-Allow-Training: false</code> on OpenRouter); see the
Third parties section below for the caveats.
</li>
</ul>
</section>
<section class="public-section">
<h2 class="public-section__head">Retention</h2>
<ul>
<li>
<strong>Login codes</strong>: expire after a few minutes; row
remains briefly to enforce single-use, then is purged.
</li>
<li>
<strong>Session cookies</strong>: expire automatically; you can
sign out at any time to revoke.
</li>
<li>
<strong>Ticker universe</strong>: rows untouched for 60 days are
evicted by a nightly job. Active tickers remain.
</li>
<li>
<strong>Encrypted portfolio blob</strong>: kept until you disable
cloud sync (one click in Settings) or delete your account. We hold
one row per user; new uploads overwrite the previous blob.
</li>
<li>
<strong>Account</strong>: held until you ask us to delete it.
Email <a href="mailto:{{ OPERATOR_EMAIL }}">{{ OPERATOR_EMAIL }}</a>.
</li>
<li>
<strong>Cost ledger and job telemetry</strong>: retained for
operational accounting; no personal data attached.
</li>
</ul>
</section>
<section class="public-section">
<h2 class="public-section__head">Third parties</h2>
<ul>
<li>
<strong>SMTP provider</strong>: an operator-hosted Mailu server
sends the one-time login codes. The provider sees your email
address and the code body (the code itself).
</li>
<li>
<strong>AI provider(s)</strong>: DeepSeek (primary) with OpenRouter
as a fallback. They see the prompt for the strategic log, the
indicator summaries, and the portfolio analysis call &mdash; which
contains your holdings only when you press
&ldquo;Generate AI analysis&rdquo; on a paid plan, and only for the
duration of that single call. The portfolio analysis output is not
persisted on the server.
<br>
<strong>No-training opt-out.</strong> Every OpenRouter request
carries the <code>X-OR-Allow-Training: false</code> header, which
signals to OpenRouter and any compatible upstream that the prompt
must not be used to train or improve models. DeepSeek does not
currently expose a per-request opt-out; if you do not want your
holdings to leave our server at all, do not use the AI portfolio
analysis feature. We do not control retention or training policies
on the provider side beyond the headers we set &mdash; the provider&rsquo;s
own published data policy is the binding statement on that point.
</li>
<li>
<strong>Market-data sources</strong>: Yahoo Finance and a small set
of public RSS feeds. We request prices and headlines; we don&rsquo;t
send them any user identifier.
</li>
</ul>
</section>
<section class="public-section">
<h2 class="public-section__head">Your rights (UK-GDPR)</h2>
<p>You have the right to:</p>
<ul>
<li>Ask what personal data we hold about you (Art. 15, right of access).</li>
<li>Have inaccurate data corrected (Art. 16, rectification).</li>
<li>Have your account and associated data deleted (Art. 17, erasure).</li>
<li>Export the data you can recognise (Art. 20, portability): your
email, any active encrypted blob, your referral linkage.</li>
<li>Restrict processing (Art. 18).</li>
<li>Object specifically to processing carried out on the basis of
legitimate interests (Art. 21), including any direct marketing.</li>
<li>Withdraw consent at any time where processing is based on
consent (Art. 7(3)), e.g. by disabling cloud sync.</li>
<li>Lodge a complaint with the
<a href="https://ico.org.uk/" target="_blank" rel="noopener">Information Commissioner&rsquo;s Office</a>
if you think we&rsquo;re mishandling your data.</li>
</ul>
<p>
Email <a href="mailto:{{ OPERATOR_EMAIL }}">{{ OPERATOR_EMAIL }}</a> to
exercise any of these.
</p>
</section>
<section class="public-section">
<h2 class="public-section__head">Children</h2>
<p>
The Service is not directed at, and is not intended for use by,
anyone under 18. Do not create an account if you are under 18. If
you believe a child has provided personal data to us, contact
<a href="mailto:{{ OPERATOR_EMAIL }}">{{ OPERATOR_EMAIL }}</a> and we
will delete it.
</p>
</section>
<section class="public-section">
<h2 class="public-section__head">Security incidents</h2>
<p>
If we discover a personal-data breach likely to result in a risk to
your rights and freedoms, we will notify the ICO within 72 hours of
becoming aware of it, as required by UK-GDPR Art. 33, and notify
affected users without undue delay where Art. 34 requires.
</p>
</section>
<section class="public-section">
<h2 class="public-section__head">Changes to this notice</h2>
<p>
Material changes will be flagged in-app and dated above. Trivial
edits (grammar, restructuring) won&rsquo;t.
</p>
</section>
{% endblock %}

View file

@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}{{ BRAND_NAME }}{% endblock %}</title>
<meta name="description" content="{{ TAGLINE }}">
{# Same flash-prevention theme bootstrap as the app shell. #}
<script>
(function() {
try {
var t = localStorage.getItem('cassandra.theme') || 'light';
document.documentElement.dataset.theme = t;
} catch (e) { document.documentElement.dataset.theme = 'light'; }
})();
</script>
<link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" />
</head>
<body class="public-page">
<div class="public-shell">
<header class="public-header">
<a class="public-header__brand" href="/" aria-label="{{ BRAND_NAME }} home">
{{ BRAND_NAME }}
</a>
<nav class="public-header__nav">
<a href="/pricing" class="{% if request.url.path == '/pricing' %}active{% endif %}">Pricing</a>
<a href="/about" class="{% if request.url.path == '/about' %}active{% endif %}">About</a>
{% if cu and (cu.user or cu.is_admin) %}
<a href="/" class="public-header__cta">Dashboard</a>
{% else %}
<a href="/login" class="public-header__cta">Sign in / sign up</a>
{% endif %}
</nav>
</header>
<main class="public-main">
{% block main %}{% endblock %}
</main>
<footer class="public-footer">
<div class="public-footer__inner">
<div class="public-footer__brand">
<strong>{{ BRAND_NAME }}</strong>
<span class="public-footer__tagline">{{ TAGLINE }}</span>
</div>
<nav class="public-footer__links">
<a href="/pricing">Pricing</a>
<a href="/about">About</a>
<a href="/terms">Terms</a>
<a href="/privacy">Privacy</a>
<a href="/disclaimer">Disclaimer</a>
</nav>
<div class="public-footer__meta">
&copy; 2026 {{ LEGAL_OPERATOR }} &middot;
Not investment advice &middot;
{{ OPERATOR_JURISDICTION }} &middot;
ICO ZC098928 &middot;
Last reviewed 2026-05-24
</div>
</div>
</footer>
</div>
</body>
</html>

214
app/templates/terms.html Normal file
View file

@ -0,0 +1,214 @@
{% extends "public_base.html" %}
{% block title %}{{ BRAND_NAME }} &middot; Terms of Service{% endblock %}
{% block main %}
<section class="public-section">
<h1 class="public-section__head">Terms of Service</h1>
<p style="color: var(--muted); font-size: 13px;">
Last updated: 2026-05-24. {{ LEGAL_OPERATOR }} operates {{ BRAND_NAME }}
(the &ldquo;Service&rdquo;) from {{ OPERATOR_JURISDICTION }}.
</p>
</section>
<section class="public-section">
<h2 class="public-section__head">1. Acceptance</h2>
<p>
By creating an account or otherwise using the Service you agree to
these Terms and to the <a href="/privacy">Privacy notice</a> and
<a href="/disclaimer">Disclaimer</a>. If you do not agree, do not
use the Service.
</p>
</section>
<section class="public-section">
<h2 class="public-section__head">2. The Service</h2>
<p>
{{ BRAND_NAME }} provides a macro-strategy dashboard with curated
market data, news, and AI-generated commentary. Paid features include
portfolio import, AI portfolio analysis, and optional end-to-end
encrypted cloud sync of your portfolio. Feature lists, tiers, and
pricing are described on the <a href="/pricing">Pricing page</a> and
may change over time.
</p>
<p>
Nothing produced by the Service is investment advice. See the
<a href="/disclaimer">Disclaimer</a> for the full position.
</p>
</section>
<section class="public-section">
<h2 class="public-section__head">3. Accounts &amp; authentication</h2>
<ul>
<li>Accounts are tied to a working email address. Authentication is
by one-time codes sent to that address &mdash; no passwords.</li>
<li>You are responsible for the security of the email inbox you sign
up with. Anyone with access to that inbox can sign in.</li>
<li>One account per person. Do not share accounts or sign in on
behalf of someone else without their explicit permission.</li>
<li>You may delete your account at any time by writing to
<a href="mailto:{{ OPERATOR_EMAIL }}">{{ OPERATOR_EMAIL }}</a>.</li>
</ul>
</section>
<section class="public-section">
<h2 class="public-section__head">4. Acceptable use</h2>
<p>You agree not to:</p>
<ul>
<li>Use automated tools to scrape, mirror, or repackage the Service
or any of its data, including market data and AI-generated
commentary, without prior written permission.</li>
<li>Use the Service, or any of its output, to ingest, train, fine-tune
or evaluate any machine-learning model without prior written
permission.</li>
<li>Abuse the one-time-code (OTP) system, including by submitting
emails you do not control.</li>
<li>Attempt to bypass authentication, paid-tier gating, or rate
limits.</li>
<li>Use the Service in any way that could damage, disable,
overburden, or impair it.</li>
<li>Use the Service to harass, defame, or threaten any person.</li>
<li>Reverse engineer, decompile, or otherwise extract source code,
except where applicable law expressly permits it.</li>
<li>Use the Service in a way that is unlawful in your jurisdiction.</li>
</ul>
</section>
<section class="public-section">
<h2 class="public-section__head">5. Paid plans</h2>
<p>
If and when paid plans become available, you will be told the
applicable fees at point of sale. Paid features remain active for as
long as the subscription is current or any time-bounded credit
granted to your account is still valid. You can cancel a paid
subscription at any time; cancellation takes effect at the end of
the current billing period unless otherwise stated.
</p>
<p>
Where the law gives you a 14-day right to cancel a subscription
(Consumer Contracts (Information, Cancellation and Additional
Charges) Regulations 2013, UK), that right applies. By starting to
use a paid feature immediately on purchase you agree we may begin
supplying the service within the cancellation period, and you
acknowledge that you lose the right to cancel in respect of any
digital content already delivered.
</p>
</section>
<section class="public-section">
<h2 class="public-section__head">6. Service availability</h2>
<p>
The Service is provided on a best-effort basis. There is no service
level agreement: outages, data delays, and feature changes may occur
without notice. Scheduled and unscheduled downtime is possible.
</p>
</section>
<section class="public-section">
<h2 class="public-section__head">7. Content &amp; ownership</h2>
<p>
The Service&rsquo;s code, design, indicator selection, and prompts
are owned or licensed by {{ LEGAL_OPERATOR }}. To the extent any
copyright subsists in the AI-generated commentary under UK law, it
vests in or is licensed to {{ LEGAL_OPERATOR }}. Market data is
sourced from third parties (e.g. Yahoo Finance and public RSS
feeds); their respective rights apply. You may view, save, and
quote short excerpts for personal use; you may not republish or
redistribute substantial portions, or use the Service&rsquo;s
output to train machine-learning models, without prior written
permission.
</p>
<p>
Any portfolio you upload remains your data. The Service does not
persist your holdings as plaintext (see the
<a href="/privacy">Privacy notice</a>).
</p>
</section>
<section class="public-section">
<h2 class="public-section__head">8. Suspension &amp; termination</h2>
<p>
We may suspend or terminate access without notice for violation of
these Terms or for activity that risks the integrity, security, or
legality of the Service. You may stop using the Service at any time.
</p>
<p>
For paid subscribers, suspension for suspected breach will, where
reasonably practicable, be preceded by notice and an opportunity to
respond, unless immediate suspension is necessary to protect the
Service, its users, or any third party. If we terminate a paid
subscription for a breach you did not cause, we will refund the
unused portion of any prepaid fees on a pro-rata basis.
</p>
</section>
<section class="public-section">
<h2 class="public-section__head">9. No warranty</h2>
<p>
The Service is provided &ldquo;as is&rdquo; and &ldquo;as
available&rdquo;, without warranties of any kind, express or implied,
including merchantability, fitness for a particular purpose, accuracy,
and non-infringement, to the maximum extent permitted by law.
</p>
</section>
<section class="public-section">
<h2 class="public-section__head">10. Limitation of liability</h2>
<p>
To the maximum extent permitted by law, {{ LEGAL_OPERATOR }} is not
liable for any indirect, incidental, special, consequential, or
punitive damages, or for any investment loss or missed gain, arising
from your use of the Service. Subject to the carve-outs below, the
total aggregate liability of {{ LEGAL_OPERATOR }} to you for any
claim is capped at the greater of (a) the fees you have paid in the
12 months immediately preceding the claim, or (b) GBP 50.
</p>
<p>
Nothing in these Terms limits or excludes liability that cannot
lawfully be limited or excluded, including (i) death or personal
injury caused by negligence, (ii) fraud or fraudulent
misrepresentation, (iii) for consumers, any statutory rights under
Part 1 of the Consumer Rights Act 2015 (including the right to
services performed with reasonable care and skill), and (iv) any
other liability that cannot be excluded under applicable mandatory
law in your country of residence.
</p>
<p>
If you are a consumer, the cap in the first paragraph of this
clause does not apply to claims for direct losses arising from a
breach of those statutory rights, which remain at the level the law
requires.
</p>
</section>
<section class="public-section">
<h2 class="public-section__head">11. Changes</h2>
<p>
These Terms may change. Material changes will be flagged in-app or
by email. Continued use after a change means you accept the updated
Terms. If you do not accept a material change, you may close your
account before the change takes effect; we will refund any prepaid
fees for the unused period.
</p>
</section>
<section class="public-section">
<h2 class="public-section__head">12. Governing law and jurisdiction</h2>
<p>
These Terms are governed by the laws of England and Wales. Subject
to any mandatory law of the consumer&rsquo;s country of residence,
the courts of England and Wales have <strong>non-exclusive</strong>
jurisdiction over any dispute. UK consumers may also bring
proceedings in the courts of the part of the United Kingdom in
which they are habitually resident.
</p>
</section>
<section class="public-section">
<h2 class="public-section__head">13. Contact</h2>
<p>
<a href="mailto:{{ OPERATOR_EMAIL }}">{{ OPERATOR_EMAIL }}</a>
</p>
</section>
{% endblock %}

View file

@ -71,3 +71,7 @@ templates.env.globals["BRAND_NAME"] = branding.BRAND_NAME
templates.env.globals["BRAND_SHORT"] = branding.BRAND_SHORT templates.env.globals["BRAND_SHORT"] = branding.BRAND_SHORT
templates.env.globals["SITE_URL"] = branding.SITE_URL templates.env.globals["SITE_URL"] = branding.SITE_URL
templates.env.globals["APP_URL"] = branding.APP_URL templates.env.globals["APP_URL"] = branding.APP_URL
templates.env.globals["TAGLINE"] = branding.TAGLINE
templates.env.globals["LEGAL_OPERATOR"] = branding.LEGAL_OPERATOR
templates.env.globals["OPERATOR_EMAIL"] = branding.OPERATOR_EMAIL
templates.env.globals["OPERATOR_JURISDICTION"] = branding.OPERATOR_JURISDICTION