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>
148 lines
6 KiB
HTML
148 lines
6 KiB
HTML
{% extends "public_base.html" %}
|
|
{% block title %}{{ BRAND_NAME }} · {{ t.hero.tagline }}{% endblock %}
|
|
|
|
{% block main %}
|
|
|
|
<section class="hero">
|
|
<div class="hero__brand">{{ BRAND_NAME }}</div>
|
|
<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="/">{{ t.hero.cta_dashboard }}</a>
|
|
<a class="btn-secondary" href="/pricing">{{ t.hero.cta_pricing }}</a>
|
|
{% else %}
|
|
<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>
|
|
|
|
<section class="shot-hero">
|
|
<button class="shot shot--hero"
|
|
data-full="{{ url_for('static', path='/images/dashboard.png') }}?v={{ ASSET_VERSION }}"
|
|
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="{{ 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">{{ 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="{{ 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="{{ t.features.news.shot_alt }}" loading="lazy">
|
|
</button>
|
|
</div>
|
|
|
|
<div class="feature-card">
|
|
<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="{{ 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="{{ t.features.indicators.shot_alt }}" loading="lazy">
|
|
</button>
|
|
</div>
|
|
|
|
<div class="feature-card">
|
|
<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="{{ 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="{{ 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;">
|
|
{{ t.multilang_callout | safe }}
|
|
</p>
|
|
</section>
|
|
|
|
<section class="public-section shots-section">
|
|
<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="{{ 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="{{ t.more_views.chat.alt }}" loading="lazy">
|
|
<div class="shot__caption">
|
|
<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);">{{ t.portfolio_blurb | safe }}</p>
|
|
</section>
|
|
|
|
<section class="not-strip">
|
|
<strong>{{ t.not_strip.head }}</strong>
|
|
<ul>
|
|
{% 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);">{{ 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="/">{{ t.hero.cta_dashboard }}</a>
|
|
{% else %}
|
|
<a class="btn-primary" href="/login">{{ t.hero.cta_signup }}</a>
|
|
{% endif %}
|
|
</div>
|
|
</section>
|
|
|
|
<dialog id="shot-modal" class="shot-modal" aria-label="Screenshot preview">
|
|
<button class="shot-modal__close" type="button" aria-label="Close">×</button>
|
|
<img id="shot-modal__img" alt="">
|
|
<p id="shot-modal__caption"></p>
|
|
</dialog>
|
|
|
|
<script>
|
|
(function () {
|
|
var dlg = document.getElementById('shot-modal');
|
|
var img = document.getElementById('shot-modal__img');
|
|
var cap = document.getElementById('shot-modal__caption');
|
|
if (!dlg || !dlg.showModal) return; // gracefully skip on ancient browsers
|
|
document.querySelectorAll('.shot').forEach(function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
img.src = btn.dataset.full;
|
|
img.alt = btn.dataset.alt || '';
|
|
cap.textContent = btn.dataset.caption || '';
|
|
dlg.showModal();
|
|
});
|
|
});
|
|
// Backdrop click closes; clicking the image itself does not.
|
|
dlg.addEventListener('click', function (e) {
|
|
if (e.target === dlg) dlg.close();
|
|
});
|
|
dlg.querySelector('.shot-modal__close').addEventListener('click', function () {
|
|
dlg.close();
|
|
});
|
|
})();
|
|
</script>
|
|
|
|
{% endblock %}
|