Files
scrabble-game/ui/src/Landing.svelte
T
Ilia Denisov b6c2598710
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 16m19s
feat(offer): live catalog price list in the public offer, served by the render sidecar
Robokassa moderation requires the public offer to list every digital good with
its price. Move /offer/ off the static landing container to the render sidecar:
it splices the live catalog price list (§4.4) into the owner-edited
ui/legal/offer_ru.md and renders it with the shared ui/src/lib/offer.ts — one
renderer, no drift, always matching the current catalog with no redeploy.

- backend: /api/v1/internal/offer/pricing (internal, off the edge allow-list)
  projects the active catalog into two markdown tables — chip packs priced per
  rail (roubles / VK votes / Telegram Stars) and chip-priced values — through
  payments.Money so no float reaches the page. Cached in memory: warmed at boot,
  marked stale on every catalog mutation, so a served render issues no query.
- renderer: GET /offer/ fetches the tables and substitutes them at the
  <#pricing_template#> marker, then renders; offer_ru.md is baked into the image
  and marked is bundled from ui. GET /offer -> 301. Only /offer/ is edge-exposed.
- caddy: route /offer/ to the sidecar; drop the now-dead landing /offer/
  handlers and the vite emit-offer plugin.
- offer: fill §4.3 (the chip-payment wording) and drop the in-page back link.
- landing footer: a feedback link (the offer's Telegram contact) beside the
  offer link.
- docs (ARCHITECTURE, FUNCTIONAL +_ru, renderer README), CI /offer/ probe,
  unit + integration + node tests.
2026-07-10 20:25:41 +02:00

311 lines
8.6 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { applyTheme } from './lib/theme';
import { i18n, setLocale, t, type Locale } from './lib/i18n/index.svelte';
import { loadPrefs, savePrefs, type Prefs } from './lib/session';
import { aboutContent } from './lib/aboutContent';
import { telegramChannelLink, vkAppLink } from './lib/landing';
// Standalone landing page, the public entry at "/" (the game SPA lives at /app/ and
// /telegram/). It reuses the app's theme/i18n/prefs leaf modules — not the app store — so it
// stays light. Theme is EPHEMERAL here (no auto, no persistence): it starts from the system
// scheme and the icon toggles light<->dark in memory. Language IS persisted (synced with the
// app) but defaults to Russian, never to the browser language — see landing.ts.
let theme = $state<'light' | 'dark'>('light');
let langOpen = $state(false);
let prefs: Partial<Prefs> = {};
const about = $derived(aboutContent(i18n.locale, 24)); // 24h = the auto-match move clock
const tgLink = $derived(telegramChannelLink());
const vkLink = $derived(vkAppLink());
const locales: { code: Locale; label: string }[] = [
{ code: 'en', label: '🇬🇧 English' },
{ code: 'ru', label: '🇷🇺 Русский' },
];
function systemTheme(): 'light' | 'dark' {
return typeof matchMedia !== 'undefined' && matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
onMount(async () => {
prefs = await loadPrefs();
theme = systemTheme();
applyTheme(theme);
setLocale(prefs.locale ?? 'ru');
});
// Keep the document language honest for assistive tech and crawlers: the static shell
// declares lang="ru" (the default), and a 🌐 switch to English must follow.
$effect(() => {
document.documentElement.lang = i18n.locale;
});
function toggleTheme(): void {
theme = theme === 'light' ? 'dark' : 'light';
applyTheme(theme); // ephemeral — deliberately not persisted
}
function chooseLocale(lc: Locale): void {
setLocale(lc);
langOpen = false;
// Persist the language only, keeping the app's other prefs (notably its own persisted theme).
void savePrefs({
theme: prefs.theme ?? 'auto',
locale: lc,
reduceMotion: prefs.reduceMotion ?? false,
boardLabels: prefs.boardLabels ?? 'beginner',
zoomBoard: prefs.zoomBoard ?? true,
});
prefs = { ...prefs, locale: lc };
}
</script>
<main class="landing">
<header class="bar">
<div class="lang">
<button class="icon" aria-label="Language" aria-expanded={langOpen} onclick={() => (langOpen = !langOpen)}>🌐</button>
{#if langOpen}
<!-- svelte-ignore a11y_consider_explicit_label -->
<button class="backdrop" onclick={() => (langOpen = false)}></button>
<div class="menu" role="menu">
{#each locales as l (l.code)}
<button role="menuitem" class:on={i18n.locale === l.code} onclick={() => chooseLocale(l.code)}>{l.label}</button>
{/each}
</div>
{/if}
</div>
<button class="icon" aria-label="Theme" onclick={toggleTheme}>{theme === 'light' ? '☼' : '☾'}</button>
</header>
<section class="hero">
<h1>{about.title}</h1>
<p class="tagline">{t('landing.tagline')}</p>
<!-- Platform entries: Telegram/VK are gated by their build vars; the web version (/app/) is
always available. A caption under each clarifies what it opens. -->
<div class="entries">
{#if tgLink}
<a class="entry" href={tgLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playTelegram')}>
<img src="telegram-logo.svg" alt="" width="64" height="64" />
<span class="caption">{t('landing.captionTelegram')}</span>
</a>
{/if}
{#if vkLink}
<a class="entry" href={vkLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playVK')}>
<img src="vk-logo.svg" alt="" width="64" height="64" />
<span class="caption">{t('landing.captionVK')}</span>
</a>
{/if}
<a class="entry" href="/app/" aria-label={t('landing.playWeb')}>
<img src="favicon.svg" alt="" width="64" height="64" />
<span class="caption">{t('landing.captionWeb')}</span>
</a>
</div>
</section>
<section class="info">
<p class="rules">
<a href={about.rulesUrl} target="_blank" rel="noopener noreferrer">{about.rulesPrefix}{about.rulesLink}</a>
</p>
<div class="cols">
<div class="col">
<h2>{about.randomTitle}</h2>
<p class="respect">❗️ {about.randomRespect}</p>
<ul>
{#each about.random as r (r)}<li>{r}</li>{/each}
</ul>
</div>
<div class="col">
<h2>{about.friendsTitle}</h2>
<ul>
{#each about.friends as f (f)}<li>{f}</li>{/each}
</ul>
</div>
</div>
</section>
<footer class="ft">
<span class="legal">
<a class="offer" href="/offer/">{t('landing.offer')}</a>
<span class="sep" aria-hidden="true">|</span>
<!-- The feedback bot: the same Telegram contact the offer lists (section 11 «Реквизиты»);
external, opens in a new tab. -->
<a class="offer" href="https://t.me/Erudit_GameBot" target="_blank" rel="noopener noreferrer">{t('landing.feedback')}</a>
</span>
<span>{t('about.version', { v: __APP_VERSION__ })}</span>
</footer>
</main>
<style>
.landing {
min-height: 100%;
box-sizing: border-box;
max-width: 760px;
margin: 0 auto;
padding: 16px var(--pad) 40px;
display: flex;
flex-direction: column;
gap: 28px;
color: var(--text);
}
.bar {
display: flex;
justify-content: space-between;
align-items: center;
}
.lang {
position: relative;
}
.icon {
min-width: 40px;
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
background: transparent;
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.backdrop {
position: fixed;
inset: 0;
z-index: 8;
background: none;
border: none;
}
.menu {
position: absolute;
left: 0;
top: calc(100% + 6px);
z-index: 9;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
min-width: 150px;
overflow: hidden;
}
.menu button {
text-align: left;
padding: 10px 14px;
background: none;
border: none;
color: var(--text);
white-space: nowrap;
}
.menu button:hover {
background: var(--surface-2);
}
.menu button.on {
color: var(--accent);
font-weight: 600;
}
.hero {
text-align: center;
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px 0 8px;
}
.hero h1 {
margin: 0;
font-size: 2.4rem;
letter-spacing: 0.5px;
}
.tagline {
margin: 0 auto;
max-width: 30ch;
color: var(--text-muted);
font-size: 1.05rem;
}
/* The platform entries are the bigger logos in a row, each with a short caption below; the
link keeps an aria-label for assistive tech. */
.entries {
align-self: center;
display: flex;
gap: 20px;
margin-top: 8px;
}
.entry {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-decoration: none;
color: var(--text);
}
.entry img {
display: block;
transition: transform 0.12s ease;
}
.entry:hover img {
transform: scale(1.06);
}
.caption {
font-size: 0.9rem;
color: var(--text-muted);
}
.info {
background: var(--surface-2);
border-radius: var(--radius-sm);
padding: 18px var(--pad);
}
.rules {
margin: 0 0 14px;
text-align: center;
}
.rules a {
color: var(--accent);
}
.cols {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 20px;
}
.col h2 {
margin: 0 0 8px;
font-size: 1.05rem;
}
.respect {
margin: 0 0 8px;
color: var(--text-muted);
font-size: 0.9rem;
}
.col ul {
margin: 0;
padding-left: 18px;
display: flex;
flex-direction: column;
gap: 5px;
}
.ft {
margin-top: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
text-align: center;
color: var(--text-muted);
font-size: 0.8rem;
}
.ft .legal {
display: flex;
align-items: center;
gap: 8px;
}
.ft .sep {
color: var(--text-muted);
}
.ft .offer {
color: inherit;
}
.ft .offer:hover {
color: var(--accent);
}
</style>