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
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue