From ee8384f1baad997c68827a901d4199879cfc7f79 Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Fri, 29 May 2026 17:10:45 +0200 Subject: [PATCH] i18n: bilingual landing page (EN / IT) with auto-detect routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 //. * 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/.yaml — flat-ish nested copy files. YAML chosen so a future translator can edit without touching Python. Strings containing inline HTML (, , ) 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. }} references. * public_base.html gets + 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 --- app/locales/en.yaml | 121 +++++++++++++++++++++++ app/locales/it.yaml | 128 ++++++++++++++++++++++++ app/main.py | 6 ++ app/routers/pages.py | 70 ++++++++++++- app/services/locales.py | 176 +++++++++++++++++++++++++++++++++ app/static/css/public.css | 24 +++++ app/templates/landing.html | 130 ++++++++---------------- app/templates/public_base.html | 21 +++- pyproject.toml | 1 + tests/test_locales.py | 137 +++++++++++++++++++++++++ 10 files changed, 721 insertions(+), 93 deletions(-) create mode 100644 app/locales/en.yaml create mode 100644 app/locales/it.yaml create mode 100644 app/services/locales.py create mode 100644 tests/test_locales.py diff --git a/app/locales/en.yaml b/app/locales/en.yaml new file mode 100644 index 0000000..a36e314 --- /dev/null +++ b/app/locales/en.yaml @@ -0,0 +1,121 @@ +# English copy for the public landing page. +# +# Strings that contain inline HTML are rendered with the `safe` filter +# in the template. Keep markup minimal — , , and the +# brand placeholders only. + +hero: + tagline: "Understand markets. Don't gamble on them." + subhead: >- + Built for investors who want to act rationally 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. + cta_dashboard: "Open dashboard" + cta_pricing: "See pricing" + cta_signup: "Sign up free" + +shot_dashboard: + alt: "Dashboard preview" + zoom_hint: "Click to enlarge" + caption: >- + The dashboard. An aggregate cross-asset read at the top, + hand-picked indicator groups underneath. Reading level toggle + (Novice / Pro) flips every AI-generated panel between + plain-English and terse-pro framing. + +features: + news: + tag: "News, aggregated" + title: "Headlines from across the macro universe" + 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 — + off-hours stay quiet. + shot_alt: "News feed with auto-tagged headlines" + shot_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. + indicators: + tag: "Macro signals" + title: "A curated cross-asset tape" + 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 means, not what it + was. Anchored in earnings, policy, valuation — not chart + patterns. + shot_alt: "Indicators panel with AI commentary" + shot_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. + strategic: + tag: "The strategic read" + title: "Rational vs irrational, every paragraph" + body: >- + We tie the day's headlines and the cross-asset signals into a + single short interpretation. Each paragraph separates + rational drivers (earnings, policy, valuation) + from irrational ones (positioning, narrative, + flows) and names the gap. Two reading levels: Novice and Pro. + This is editorial commentary on public data — not a forecast + and not advice on any investment decision. + shot_alt: "Strategic log — the editorial AI read" + shot_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. Paid users get a refresh + every hour; free users get one every six. + +multilang_callout: >- + Every AI-generated surface — strategic log, indicator reads, + portfolio analysis, chat, daily digest — is available in + English and Italian. Toggle the + language pill in the header and the panels refresh in place; + ticker symbols, currency codes and numbers stay verbatim across + languages. + +more_views: + head: "More views" + chat: + alt: "Ask follow-up questions against any past log" + 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. + caption_strong: "Ask anything about a log" + caption_span: "Conversational follow-ups with the day's context loaded." + +portfolio_blurb: >- + Paid users can also drop a portfolio CSV from their broker 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. + +not_strip: + head: "What this isn't." + items: + - "Not investment advice." + - "Not trading signals." + - "Not a day-trading tool." + - "No buy/sell calls, ever." + - "No chart-pattern predictions." + - "Not a regulated service." + +footer: + legal: >- + By signing up you agree to our Terms and + Privacy notice, and confirm you've read + the financial disclaimer. + +meta: + description: "Understand markets. Don't gamble on them." diff --git a/app/locales/it.yaml b/app/locales/it.yaml new file mode 100644 index 0000000..b0188aa --- /dev/null +++ b/app/locales/it.yaml @@ -0,0 +1,128 @@ +# Italian copy for the public landing page. +# +# Note: brand names ("Read the Markets") and proper nouns +# (tickers, currency codes) stay verbatim — they aren't translated. + +hero: + tagline: "Capisci i mercati. Non scommetterci sopra." + subhead: >- + Pensato per investitori che vogliono ragionare con + lucidità e tagliare fuori il rumore ad alta frequenza + che nasce dal trattare i mercati come un casinò. Aggreghiamo + notizie cross-asset e segnali macro, e ne scriviamo una lettura + in prosa chiara: cosa giustificano i fondamentali sottostanti, + rispetto a cosa sta facendo la folla. Aggiornato durante la + giornata di trading. Un servizio editoriale, non finanziario. + cta_dashboard: "Apri dashboard" + cta_pricing: "Vedi i piani" + cta_signup: "Iscriviti gratis" + +shot_dashboard: + alt: "Anteprima dashboard" + zoom_hint: "Clicca per ingrandire" + caption: >- + La dashboard. In alto una lettura cross-asset aggregata, sotto + gruppi di indicatori selezionati a mano. Il toggle del livello + di lettura (Novice / Pro) cambia il taglio di ogni pannello + generato dall'IA, da prosa accessibile a registro tecnico + sintetico. + +features: + news: + tag: "Notizie, aggregate" + title: "Titoli dall'intero universo macro" + body: >- + Feed RSS e per-ticker che coprono azioni, tassi, credito, FX, + materie prime e geopolitica. Ogni titolo è etichettato + automaticamente per tema, così il rumore resta rumore e il + materiale rilevante per i fondamentali è facile da trovare. + L'ingestione segue il calendario di trading — fuori orario + tutto resta in pausa. + shot_alt: "Feed notizie con tag automatici" + shot_caption: >- + Il feed delle notizie. Ogni titolo porta uno o più tag tematici + (tassi, AI, energia, geopolitica, …) per tenere i fili che ti + interessano e silenziare gli altri. Click su un tag per + includerlo; shift-click per escluderlo. + indicators: + tag: "Segnali macro" + title: "Una tape cross-asset curata" + body: >- + Un set di indicatori selezionato a mano su ogni classe di + attivo, aggiornato ogni ora durante le sessioni di mercato. + Ogni gruppo ha una lettura breve che spiega cosa il movimento + significa, non cosa è stato. Ancorata a utili, + politica monetaria, valutazione — non a pattern grafici. + shot_alt: "Pannello indicatori con commento IA" + shot_caption: >- + Il pannello indicatori. Tab per classe di attivo (azionario, + tassi, materie prime, FX, obbligazioni, …); ogni tab porta una + 'lettura' di un paragrafo scritta dal modello sopra i prezzi + live. I numeri ancorano la prosa, così il commento è + verificabile, non sospeso nel vuoto. + strategic: + tag: "Lettura strategica" + title: "Razionale vs irrazionale, paragrafo per paragrafo" + body: >- + Leghiamo i titoli della giornata e i segnali cross-asset in + un'unica interpretazione breve. Ogni paragrafo distingue i + driver razionali (utili, politica, valutazione) + dagli irrazionali (posizionamento, narrativa, + flussi) e dà un nome al divario. Due livelli di lettura: Novice + e Pro. È commento editoriale su dati pubblici — non una + previsione e non un consiglio su nessuna decisione di + investimento. + shot_alt: "Log strategico — la lettura editoriale IA" + shot_caption: >- + Il log strategico. Il modello scrive un'interpretazione + aggiornata durante la giornata, partendo dalla versione + precedente come contesto, in modo da aggiornare invece di + ricominciare. Gli utenti paganti ricevono un refresh ogni ora; + gli utenti gratuiti uno ogni sei. + +multilang_callout: >- + Ogni superficie generata dall'IA — log strategico, letture degli + indicatori, analisi di portafoglio, chat, digest giornaliero — è + disponibile in Inglese e Italiano. + Usa il selettore di lingua nell'header e i pannelli si aggiornano + in tempo reale; simboli ticker, codici valuta e numeri restano + invariati tra le lingue. + +more_views: + head: "Altre viste" + chat: + alt: "Fai domande di approfondimento su qualunque log passato" + caption: >- + Fai domande di approfondimento su qualunque log passato. Il + pannello chat eredita tutto il contesto del log, così puoi + tirare un filo senza re-incollare titoli o re-spiegare la + situazione. + caption_strong: "Chiedi qualsiasi cosa su un log" + caption_span: "Domande conversazionali con il contesto della giornata già caricato." + +portfolio_blurb: >- + Gli utenti paganti possono anche caricare un CSV di portafoglio + dal loro broker per un sense-check IA su concentrazione, + coerenza con il regime di mercato ed esposizione valutaria. Le + posizioni restano nel browser per impostazione predefinita; + l'attivazione opzionale del cloud sync (crittografato) + permette di ritrovarle su un altro dispositivo. + +not_strip: + head: "Cosa questo NON è." + items: + - "Non è consulenza finanziaria." + - "Non sono segnali di trading." + - "Non è uno strumento per day-trading." + - "Nessuna chiamata buy/sell, mai." + - "Nessuna previsione basata su pattern grafici." + - "Non è un servizio regolamentato." + +footer: + legal: >- + Iscrivendoti accetti i nostri Termini e + l'Informativa privacy, e confermi di aver + letto il disclaimer finanziario. + +meta: + description: "Capisci i mercati. Non scommetterci sopra." diff --git a/app/main.py b/app/main.py index 7f1729f..148f5dc 100644 --- a/app/main.py +++ b/app/main.py @@ -66,6 +66,12 @@ async def lifespan(app: FastAPI): async with get_session_factory()() as session: inserted = await bootstrap_feeds(session) log.info("cassandra.feeds.bootstrap", inserted=inserted) + # Load public-page translation YAMLs into memory once at startup. + # Lazy-loaded otherwise, but pre-loading lets startup fail loudly + # if a YAML file is corrupted instead of failing on the first + # landing-page request. + from app.services.locales import load_locales + load_locales() yield log.info("cassandra.shutdown") diff --git a/app/routers/pages.py b/app/routers/pages.py index 1801f93..01d68a8 100644 --- a/app/routers/pages.py +++ b/app/routers/pages.py @@ -13,9 +13,44 @@ from app.config import get_settings, load_groups from app.db import get_session from app.models import EmailSend, Referral, StrategicLog, User from app.services.access import is_paid_active, paid_status +from app.services.locales import ( + ACTIVE_PUBLIC_LANGS, + DEFAULT_LANG, + detect_public_lang, + get_locale, +) from app.services.referral_service import assign_code_if_missing from app.templates_env import templates +# Cookie used to remember an explicit language toggle on public pages. +# Distinct from the in-app user.lang preference (which lives in the +# DB for authenticated users). +_LANG_COOKIE = "rtm.lang" +_LANG_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 # 1 year + + +def _render_landing( + request: Request, cu: CurrentUser | None, lang: str, +) -> HTMLResponse: + """Render the localised landing page and stamp the language + cookie so a return visitor lands on the same translation without + another detection pass.""" + t = get_locale(lang) + response = templates.TemplateResponse( + request, + "landing.html", + {"cu": cu, "t": t, "lang": lang, "lang_switch": True}, + ) + # `secure` would block the cookie in local-dev HTTP; rely on the + # reverse proxy to upgrade everything to HTTPS in prod. samesite=Lax + # is the cookie we want for first-party navigation. + response.set_cookie( + _LANG_COOKIE, lang, + max_age=_LANG_COOKIE_MAX_AGE, samesite="lax", + httponly=False, + ) + return response + # 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. @@ -27,11 +62,19 @@ async def root_page( request: Request, cu: CurrentUser | None = Depends(maybe_current_user), ): - """Dual-purpose root: dashboard when authenticated, landing otherwise.""" + """Dual-purpose root: dashboard when authenticated, otherwise + detect the visitor's language and redirect to the localised + landing URL. Detection considers (in order) the rtm.lang cookie, + the Accept-Language header, the cf-ipcountry geolocation header, + and finally DEFAULT_LANG.""" if cu is None: - return templates.TemplateResponse( - request, "landing.html", {"cu": None}, + lang = detect_public_lang( + cookie_lang=request.cookies.get(_LANG_COOKIE), + accept_language=request.headers.get("accept-language"), + cf_country=request.headers.get("cf-ipcountry"), + user_lang=None, ) + return RedirectResponse(url=f"/{lang}/", status_code=302) s = get_settings() groups = load_groups(s.BASELINE_TOML, s.PORTFOLIO_TOML) return templates.TemplateResponse( @@ -42,6 +85,27 @@ async def root_page( ) +@router.get("/en/", response_class=HTMLResponse) +async def landing_en( + request: Request, + cu: CurrentUser | None = Depends(maybe_current_user), +): + """English landing. For logged-in users with a non-en `user.lang` + we still serve EN content here because the URL is an explicit + request — same shape as a manual toggle click. The cookie gets + set to en so subsequent /-visits keep them in English.""" + return _render_landing(request, cu, lang="en") + + +@router.get("/it/", response_class=HTMLResponse) +async def landing_it( + request: Request, + cu: CurrentUser | None = Depends(maybe_current_user), +): + """Italian landing. Same explicit-URL contract as landing_en.""" + return _render_landing(request, cu, lang="it") + + @router.get( "/news", response_class=HTMLResponse, diff --git a/app/services/locales.py b/app/services/locales.py new file mode 100644 index 0000000..cb12dcf --- /dev/null +++ b/app/services/locales.py @@ -0,0 +1,176 @@ +"""Locale loader for the public landing page (and, eventually, other +public surfaces). Loads YAML translation files from ``app/locales/`` +at process startup into in-memory dicts; templates access them via the +``t`` context variable. + +Adding a new language: drop ``.yaml`` into app/locales/ and add +the code to ``ACTIVE_PUBLIC_LANGS`` here. No code changes elsewhere +should be required. + +Format: arbitrary nested dict. Values that contain inline HTML are +rendered with the Jinja2 ``safe`` filter in the template — keep +markup minimal (````, ````, ````) and don't put user +input through here. The dicts are wrapped in a small dotted-access +helper so templates can write ``{{ t.hero.subhead }}`` instead of +``{{ t['hero']['subhead'] }}``. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml + +from app.logging import get_logger + +log = get_logger("locales") + +LOCALES_DIR = Path(__file__).resolve().parent.parent / "locales" + +# Public-surface languages. Mirrors ``services.i18n.ACTIVE_LANGUAGES`` +# but is kept separate because the public surface may roll a new +# language out independently of the in-app translations. +ACTIVE_PUBLIC_LANGS = ("en", "it") +DEFAULT_LANG = "en" + + +class _Dotted: + """Read-only dotted-access view over a YAML-loaded dict tree. + + Templates write ``{{ t.hero.tagline }}`` instead of + ``{{ t['hero']['tagline'] }}``. The wrapper deliberately does NOT + subclass ``dict`` — dict's built-in methods (``items``, ``keys``, + ``values``, ``copy``, ``update``, ``pop`` …) would shadow YAML + keys with the same name. With this wrapper, a YAML key called + ``items`` (which the landing copy actually has: ``not_strip.items``) + resolves through ``__getattr__`` like any other key. + + Mutability is intentionally not supported: locale data is loaded + once at startup and read-only afterwards. + """ + __slots__ = ("_data",) + + def __init__(self, data: dict): + self._data = data + + def __getattr__(self, key: str) -> Any: + if key.startswith("_"): + raise AttributeError(key) + if key in self._data: + return _wrap(self._data[key]) + raise AttributeError(key) + + def __getitem__(self, key: str) -> Any: + return _wrap(self._data[key]) + + def __contains__(self, key: object) -> bool: + return key in self._data + + def __iter__(self): + return iter(self._data) + + def __len__(self) -> int: + return len(self._data) + + def __bool__(self) -> bool: + return bool(self._data) + + def __eq__(self, other: object) -> bool: + if isinstance(other, _Dotted): + return self._data == other._data + return self._data == other + + def __repr__(self) -> str: + return f"_Dotted({self._data!r})" + + +def _wrap(value: Any) -> Any: + if isinstance(value, dict): + return _Dotted(value) + if isinstance(value, list): + return [_wrap(v) for v in value] + return value + + +_LOADED: dict[str, _Dotted] = {} + + +def _load_one(lang: str) -> _Dotted: + path = LOCALES_DIR / f"{lang}.yaml" + if not path.exists(): + log.warning("locale.missing_file", lang=lang, path=str(path)) + return _Dotted({}) + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + return _Dotted(raw) + + +def load_locales() -> None: + """Populate the module-level cache. Called once at app startup; + subsequent calls re-read from disk (useful for tests).""" + _LOADED.clear() + for lang in ACTIVE_PUBLIC_LANGS: + _LOADED[lang] = _load_one(lang) + log.info("locales.loaded", languages=list(_LOADED.keys())) + + +def get_locale(lang: str) -> _Dotted: + """Return the loaded translations for ``lang``, falling back to + ``DEFAULT_LANG`` for an unknown code. Lazy-loads on first call + so importers don't have to remember to call ``load_locales()`` + explicitly.""" + if not _LOADED: + load_locales() + if lang in _LOADED: + return _LOADED[lang] + return _LOADED.get(DEFAULT_LANG, _Dotted({})) + + +# ----- request-time language detection --------------------------------------- + + +# Country codes whose primary language is Italian. cf-ipcountry uses +# ISO-3166 alpha-2. +_GEO_TO_LANG = { + "IT": "it", "SM": "it", "VA": "it", + # Italian-speaking Swiss canton: we can't detect canton from country + # so a Swiss visitor defaults to en here. The Accept-Language path + # above catches the actual Italian-speakers in CH. +} + + +def detect_public_lang( + cookie_lang: str | None, + accept_language: str | None, + cf_country: str | None, + user_lang: str | None, +) -> str: + """Resolve the language for a public-page request. + + Priority (highest first): + 1. user_lang — a logged-in user's stored preference. Consistent + with their dashboard, never overridden by detection. + 2. cookie_lang — sticky from a previous explicit toggle. + 3. accept_language — browser locale. First language tag only, + stripped to its base subtag ("en-US" -> "en"). + 4. cf_country — Cloudflare's IP-derived country code, mapped to + a primary language via ``_GEO_TO_LANG``. + 5. ``DEFAULT_LANG`` ("en"). + + Returns a value guaranteed to be in ``ACTIVE_PUBLIC_LANGS``. + """ + if user_lang and user_lang in ACTIVE_PUBLIC_LANGS: + return user_lang + if cookie_lang and cookie_lang in ACTIVE_PUBLIC_LANGS: + return cookie_lang + if accept_language: + first = accept_language.split(",", 1)[0].split(";", 1)[0] + base = first.split("-", 1)[0].strip().lower() + if base in ACTIVE_PUBLIC_LANGS: + return base + if cf_country: + cc = cf_country.strip().upper() + if cc in _GEO_TO_LANG: + mapped = _GEO_TO_LANG[cc] + if mapped in ACTIVE_PUBLIC_LANGS: + return mapped + return DEFAULT_LANG diff --git a/app/static/css/public.css b/app/static/css/public.css index 9b1f753..bbd01d7 100644 --- a/app/static/css/public.css +++ b/app/static/css/public.css @@ -46,6 +46,30 @@ } .public-header__cta:hover { background: var(--accent); color: var(--bg) !important; } +/* Tiny EN | IT link group sitting in the public header next to the + nav links. Rendered only on pages that opt in via lang_switch=true. + Visually low-key — the language pair is informational, not a + primary CTA. */ +.public-header__lang-switch { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.public-header__lang-switch a.public-header__lang { + color: var(--muted); + padding: 2px 6px; + border: 1px solid transparent; + border-radius: 2px; +} +.public-header__lang-switch a.public-header__lang:hover { color: var(--accent); } +.public-header__lang-switch a.public-header__lang.active { + color: var(--text); + border-color: var(--border); +} + .public-main { flex: 1; padding: 48px 0 64px; diff --git a/app/templates/landing.html b/app/templates/landing.html index 77fcbf8..62af9f8 100644 --- a/app/templates/landing.html +++ b/app/templates/landing.html @@ -1,26 +1,19 @@ {% extends "public_base.html" %} -{% block title %}{{ BRAND_NAME }} · {{ TAGLINE }}{% endblock %} +{% block title %}{{ BRAND_NAME }} · {{ t.hero.tagline }}{% endblock %} {% block main %}
{{ BRAND_NAME }}
-

{{ TAGLINE }}

-

- Built for investors who want to act rationally 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. -

+

{{ t.hero.tagline }}

+

{{ t.hero.subhead | safe }}

@@ -28,135 +21,96 @@
-
News, aggregated
-

Headlines from across the macro universe

-

- 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 — - off-hours stay quiet. -

+
{{ t.features.news.tag }}
+

{{ t.features.news.title }}

+

{{ t.features.news.body | safe }}

-
Macro signals
-

A curated cross-asset tape

-

- 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 means, not what it was. Anchored - in earnings, policy, valuation — not chart patterns. -

+
{{ t.features.indicators.tag }}
+

{{ t.features.indicators.title }}

+

{{ t.features.indicators.body | safe }}

-
The strategic read
-

Rational vs irrational, every paragraph

-

- We tie the day’s headlines and the cross-asset signals into - a single short interpretation. Each paragraph separates - rational drivers (earnings, policy, valuation) - from irrational ones (positioning, narrative, - flows) and names the gap. Two reading levels: novice and - intermediate. This is editorial commentary on public data — - not a forecast and not advice on any investment decision. -

+
{{ t.features.strategic.tag }}
+

{{ t.features.strategic.title }}

+

{{ t.features.strategic.body | safe }}

- Every AI-generated surface — strategic log, indicator reads, - portfolio analysis, chat, daily digest — is available in - English and Italian. 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 }}

-

More views

+

{{ t.more_views.head }}

-

- Paid users can also drop a portfolio CSV from their broker - — Trading 212 natively, other formats auto-detected — - 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. -

+

{{ t.portfolio_blurb | safe }}

- What this isn’t. + {{ t.not_strip.head }}
    -
  • Not investment advice.
  • -
  • Not trading signals.
  • -
  • Not a day-trading tool.
  • -
  • No buy/sell calls, ever.
  • -
  • No chart-pattern predictions.
  • -
  • Not a regulated service.
  • + {% for item in t.not_strip.items %}
  • {{ item }}
  • {% endfor %}
-

- By signing up you agree to our Terms and - Privacy notice, and confirm you’ve read - the financial disclaimer. -

+

{{ t.footer.legal | safe }}

{% if cu and (cu.user or cu.is_admin) %} - Open dashboard + {{ t.hero.cta_dashboard }} {% else %} - Sign up free + {{ t.hero.cta_signup }} {% endif %}
diff --git a/app/templates/public_base.html b/app/templates/public_base.html index b1cef24..47dd096 100644 --- a/app/templates/public_base.html +++ b/app/templates/public_base.html @@ -1,10 +1,18 @@ - + {% block title %}{{ BRAND_NAME }}{% endblock %} - + + {# 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 %} + + + + {% endif %} {# Same flash-prevention theme bootstrap as the app shell. #}