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:
parent
d98a90f35e
commit
ee8384f1ba
10 changed files with 721 additions and 93 deletions
121
app/locales/en.yaml
Normal file
121
app/locales/en.yaml
Normal file
|
|
@ -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 — <strong>, <em>, and the
|
||||
# brand placeholders only.
|
||||
|
||||
hero:
|
||||
tagline: "Understand markets. Don't gamble on them."
|
||||
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.
|
||||
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 <em>means</em>, 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
|
||||
<strong>rational drivers</strong> (earnings, policy, valuation)
|
||||
from <strong>irrational ones</strong> (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
|
||||
<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.
|
||||
|
||||
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 <a href="/terms">Terms</a> and
|
||||
<a href="/privacy">Privacy notice</a>, and confirm you've read
|
||||
the <a href="/disclaimer">financial disclaimer</a>.
|
||||
|
||||
meta:
|
||||
description: "Understand markets. Don't gamble on them."
|
||||
128
app/locales/it.yaml
Normal file
128
app/locales/it.yaml
Normal file
|
|
@ -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 <strong>ragionare con
|
||||
lucidità</strong> 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
|
||||
<em>significa</em>, 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
|
||||
<strong>driver razionali</strong> (utili, politica, valutazione)
|
||||
dagli <strong>irrazionali</strong> (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 <strong>Inglese</strong> e <strong>Italiano</strong>.
|
||||
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 <a href="/terms">Termini</a> e
|
||||
l'<a href="/privacy">Informativa privacy</a>, e confermi di aver
|
||||
letto il <a href="/disclaimer">disclaimer finanziario</a>.
|
||||
|
||||
meta:
|
||||
description: "Capisci i mercati. Non scommetterci sopra."
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
176
app/services/locales.py
Normal file
176
app/services/locales.py
Normal file
|
|
@ -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 ``<code>.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 (``<strong>``, ``<em>``, ``<a>``) 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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,26 +1,19 @@
|
|||
{% extends "public_base.html" %}
|
||||
{% block title %}{{ BRAND_NAME }} · {{ TAGLINE }}{% endblock %}
|
||||
{% block title %}{{ BRAND_NAME }} · {{ 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 —
|
||||
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 — 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’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 —
|
||||
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 — strategic log, indicator reads,
|
||||
portfolio analysis, chat, daily digest — 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
|
||||
— 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.
|
||||
</p>
|
||||
<p style="font-size: 13.5px; color: var(--muted);">{{ t.portfolio_blurb | safe }}</p>
|
||||
</section>
|
||||
|
||||
<section class="not-strip">
|
||||
<strong>What this isn’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’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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue