i18n: bilingual landing page (EN / IT) with auto-detect routing

Public landing page is now served in English and Italian via path-
prefixed URLs (/en/ and /it/), with the bare / detecting the
visitor's language and 302-ing to the right one.

Routing
-------
* GET / : if authed → dashboard (unchanged). Otherwise the visitor's
  language is resolved from (in priority order) the rtm.lang cookie,
  the Accept-Language header, the cf-ipcountry geolocation header,
  and finally DEFAULT_LANG, then a 302 redirects to /<lang>/.
* GET /en/ + /it/ : render the localised landing template, set the
  rtm.lang cookie (1-year, SameSite=Lax) so the next /-visit goes
  straight to the same translation. Logged-in users with user.lang
  set bypass detection for / (same priority chain — user.lang wins
  on every public surface they touch).

Storage
-------
* app/locales/<lang>.yaml — flat-ish nested copy files. YAML chosen
  so a future translator can edit without touching Python. Strings
  containing inline HTML (<strong>, <em>, <a>) are rendered with the
  Jinja `safe` filter in the template.
* app/services/locales.py — loads at startup, exposes get_locale()
  and detect_public_lang(). Wraps each YAML tree in a small _Dotted
  view so templates can write {{ t.hero.subhead }} (deliberately NOT
  a dict subclass — dict's built-in method names would shadow YAML
  keys like `items`).

Template + chrome
-----------------
* landing.html ported in full to {{ t.<key> }} references.
* public_base.html gets <html lang="…"> + hreflang link tags (en/it/
  x-default) when a route opts in via lang_switch=true. A tiny
  EN | IT link group lands in the public header, only visible on
  surfaces that opt in.
* public.css picks up the small lang-switch widget styles.

Scope (intentionally narrow)
----------------------------
* Only the landing page is localised. Pricing, terms, privacy,
  disclaimer, login, verify all stay English-only for now; their
  header chrome stays English too because localising labels there
  while the linked content is still EN would be a worse mismatch.
* When other public pages get translated, lang_switch=true on those
  routes will surface the same widget there with no template changes.

Tests
-----
* tests/test_locales.py covers YAML load parity (every active
  language has a file), dotted access through the tree, the
  detection precedence chain, and the unknown-locale fallback.

Deps
----
* pyyaml was already in requirements.lock as a transitive but not
  declared. Added to pyproject so it stays pinned as an explicit
  direct dependency.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-29 17:10:45 +02:00
parent d98a90f35e
commit ee8384f1ba
10 changed files with 721 additions and 93 deletions

View file

@ -1,26 +1,19 @@
{% extends "public_base.html" %}
{% block title %}{{ BRAND_NAME }} &middot; {{ TAGLINE }}{% endblock %}
{% block title %}{{ BRAND_NAME }} &middot; {{ t.hero.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 through the
trading day. A media service, not a financial one.
</p>
<h1 class="hero__headline">{{ t.hero.tagline }}</h1>
<p class="hero__subhead">{{ t.hero.subhead | safe }}</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>
<a class="btn-primary" href="/">{{ t.hero.cta_dashboard }}</a>
<a class="btn-secondary" href="/pricing">{{ t.hero.cta_pricing }}</a>
{% else %}
<a class="btn-primary" href="/login">Sign up free</a>
<a class="btn-secondary" href="/pricing">See pricing</a>
<a class="btn-primary" href="/login">{{ t.hero.cta_signup }}</a>
<a class="btn-secondary" href="/pricing">{{ t.hero.cta_pricing }}</a>
{% endif %}
</div>
</section>
@ -28,135 +21,96 @@
<section class="shot-hero">
<button class="shot shot--hero"
data-full="{{ url_for('static', path='/images/dashboard.png') }}?v={{ ASSET_VERSION }}"
data-alt="Read the Markets dashboard"
data-caption="The dashboard. An aggregate cross-asset read at the top, hand-picked indicator groups underneath. Reading level toggle (Novice / Intermediate) flips every AI-generated panel between plain-English and terse-pro framing.">
data-alt="{{ t.shot_dashboard.alt }}"
data-caption="{{ t.shot_dashboard.caption }}">
<img src="{{ url_for('static', path='/images/dashboard.png') }}?v={{ ASSET_VERSION }}"
alt="Dashboard preview" loading="lazy">
<span class="shot__zoom" aria-hidden="true">Click to enlarge</span>
alt="{{ t.shot_dashboard.alt }}" loading="lazy">
<span class="shot__zoom" aria-hidden="true">{{ t.shot_dashboard.zoom_hint }}</span>
</button>
</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 class="feature-card__tag">{{ t.features.news.tag }}</div>
<h3 class="feature-card__title">{{ t.features.news.title }}</h3>
<p class="feature-card__body">{{ t.features.news.body | safe }}</p>
<button class="shot feature-card__shot"
data-full="{{ url_for('static', path='/images/news-feed.png') }}?v={{ ASSET_VERSION }}"
data-alt="News feed with auto-tagged headlines"
data-caption="The news feed. Each headline carries one or more theme tags (rates, AI, energy, geopolitics, …) so you can keep the threads you care about and mute the ones you don't. Click a tag to include; shift-click to exclude.">
data-alt="{{ t.features.news.shot_alt }}"
data-caption="{{ t.features.news.shot_caption }}">
<img src="{{ url_for('static', path='/images/news-feed.png') }}?v={{ ASSET_VERSION }}"
alt="News feed thumbnail" loading="lazy">
alt="{{ t.features.news.shot_alt }}" loading="lazy">
</button>
</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 class="feature-card__tag">{{ t.features.indicators.tag }}</div>
<h3 class="feature-card__title">{{ t.features.indicators.title }}</h3>
<p class="feature-card__body">{{ t.features.indicators.body | safe }}</p>
<button class="shot feature-card__shot"
data-full="{{ url_for('static', path='/images/indicators-read.png') }}?v={{ ASSET_VERSION }}"
data-alt="Indicators panel with AI commentary"
data-caption="The indicators panel. Tabs across asset classes (equity, rates, commodities, FX, bonds, …); each tab carries a one-paragraph 'read' written by the model on top of the live prices. The numbers anchor the prose so the commentary is checkable, not floating.">
data-alt="{{ t.features.indicators.shot_alt }}"
data-caption="{{ t.features.indicators.shot_caption }}">
<img src="{{ url_for('static', path='/images/indicators-read.png') }}?v={{ ASSET_VERSION }}"
alt="Indicators panel thumbnail" loading="lazy">
alt="{{ t.features.indicators.shot_alt }}" loading="lazy">
</button>
</div>
<div class="feature-card">
<div class="feature-card__tag">The strategic 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 class="feature-card__tag">{{ t.features.strategic.tag }}</div>
<h3 class="feature-card__title">{{ t.features.strategic.title }}</h3>
<p class="feature-card__body">{{ t.features.strategic.body | safe }}</p>
<button class="shot feature-card__shot"
data-full="{{ url_for('static', path='/images/strategic-log.png') }}?v={{ ASSET_VERSION }}"
data-alt="Strategic log — the editorial AI read"
data-caption="The strategic log. The model writes a fresh interpretation through the trading day, taking the previous draft as context so it updates rather than starts over. Sections are typed: date header, TL;DR, what moved, what to watch, system temperature. Paid users get a refresh every hour; free users get one every six.">
data-alt="{{ t.features.strategic.shot_alt }}"
data-caption="{{ t.features.strategic.shot_caption }}">
<img src="{{ url_for('static', path='/images/strategic-log.png') }}?v={{ ASSET_VERSION }}"
alt="Strategic log thumbnail" loading="lazy">
alt="{{ t.features.strategic.shot_alt }}" loading="lazy">
</button>
</div>
</section>
<section class="public-section" style="text-align:center;">
<p style="font-size: 14px; color: var(--muted); max-width: 60ch; margin: 0 auto;">
Every AI-generated surface &mdash; strategic log, indicator reads,
portfolio analysis, chat, daily digest &mdash; is available in
<strong>English</strong> and <strong>Italian</strong>. Toggle the
language pill in the header and the panels refresh in place;
ticker symbols, currency codes and numbers stay verbatim across
languages.
{{ t.multilang_callout | safe }}
</p>
</section>
<section class="public-section shots-section">
<h2 class="public-section__head">More views</h2>
<h2 class="public-section__head">{{ t.more_views.head }}</h2>
<div class="shots-grid">
<button class="shot"
data-full="{{ url_for('static', path='/images/chat-with-log.png') }}?v={{ ASSET_VERSION }}"
data-alt="Ask follow-up questions against any past log"
data-caption="Ask follow-up questions against any past log. The chat panel inherits the log's full context, so you can pull on a thread without re-pasting headlines or re-explaining the setup.">
data-alt="{{ t.more_views.chat.alt }}"
data-caption="{{ t.more_views.chat.caption }}">
<img src="{{ url_for('static', path='/images/chat-with-log.png') }}?v={{ ASSET_VERSION }}"
alt="Chat-with-log thumbnail" loading="lazy">
alt="{{ t.more_views.chat.alt }}" loading="lazy">
<div class="shot__caption">
<strong>Ask anything about a log</strong>
<span>Conversational follow-ups with the day's context loaded.</span>
<strong>{{ t.more_views.chat.caption_strong }}</strong>
<span>{{ t.more_views.chat.caption_span }}</span>
</div>
</button>
</div>
</section>
<section class="public-section">
<p style="font-size: 13.5px; color: var(--muted);">
Paid users can also drop a portfolio CSV from their broker
&mdash; Trading 212 natively, other formats auto-detected &mdash;
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>
<p style="font-size: 13.5px; color: var(--muted);">{{ t.portfolio_blurb | safe }}</p>
</section>
<section class="not-strip">
<strong>What this isn&rsquo;t.</strong>
<strong>{{ t.not_strip.head }}</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>
{% for item in t.not_strip.items %}<li>{{ item }}</li>{% endfor %}
</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>
<p style="font-size: 13px; color: var(--muted);">{{ t.footer.legal | safe }}</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>
<a class="btn-primary" href="/">{{ t.hero.cta_dashboard }}</a>
{% else %}
<a class="btn-primary" href="/login">Sign up free</a>
<a class="btn-primary" href="/login">{{ t.hero.cta_signup }}</a>
{% endif %}
</div>
</section>

View file

@ -1,10 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<html lang="{{ lang|default('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 }}">
<meta name="description" content="{{ (t.meta.description if t is defined and t else None) or TAGLINE }}">
{# Hreflang signals for search engines: tells crawlers that the
same page exists in another language at a different URL. Only
rendered when the route explicitly opts in via lang_switch=true. #}
{% if lang_switch %}
<link rel="alternate" hreflang="en" href="/en/">
<link rel="alternate" hreflang="it" href="/it/">
<link rel="alternate" hreflang="x-default" href="/">
{% endif %}
{# Same flash-prevention theme bootstrap as the app shell. #}
<script>
(function() {
@ -30,6 +38,15 @@
<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>
{# Lang switch — currently only the landing page opts in
(lang_switch=true). When other public pages get translated
this widget will surface there too automatically. #}
{% if lang_switch %}
<span class="public-header__lang-switch" role="group" aria-label="Language">
<a href="/en/" class="public-header__lang {% if lang == 'en' %}active{% endif %}">EN</a>
<a href="/it/" class="public-header__lang {% if lang == 'it' %}active{% endif %}">IT</a>
</span>
{% endif %}
{% if cu and (cu.user or cu.is_admin) %}
<a href="/" class="public-header__cta">Dashboard</a>
{% else %}