0f3b4dbcff
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 40s
Author ui/legal/{privacy,eula}_ru.md, reworked from the source documents: the seller INN is unified (290210610742), contacts unified to the Telegram bot + email + postal address, the EULA governing-law clause leads with Russian law + jurisdiction as residence tiers, the single-binding-language clause is dropped, data collection is scoped to voluntary/support provision, and "Компания" is no longer shout-cased.
Serve both as static pages through the render sidecar, reusing a shared renderLegalHtml generalised from renderOfferHtml: new GET /privacy/ and GET /eula/ (301 from the slashless form), the markdown baked into the image, no backend fetch. Route them at the edge via the @legal caddy matcher with a CI probe asserting each page's canonical URL + the seller INN, so a missing route cannot silently fall through to the landing. Add the two links to the landing footer.
Docs baked in: ARCHITECTURE (renderer legal pages + edge routing), FUNCTIONAL (+_ru) footer, renderer/README routes. Tests: renderer legal.test.mjs, ui renderLegalHtml unit, landing footer e2e.
317 lines
8.9 KiB
Svelte
317 lines
8.9 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="doc" href="/eula/">{t('landing.eula')}</a>
|
|
<span class="sep" aria-hidden="true">|</span>
|
|
<a class="doc" href="/privacy/">{t('landing.privacy')}</a>
|
|
<span class="sep" aria-hidden="true">|</span>
|
|
<a class="doc" href="/offer/">{t('landing.offer')}</a>
|
|
<span class="sep" aria-hidden="true">|</span>
|
|
<!-- The feedback bot: the Telegram contact the legal documents list as the seller's contact;
|
|
external, opens in a new tab. -->
|
|
<a class="doc" 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;
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.ft .sep {
|
|
color: var(--text-muted);
|
|
}
|
|
.ft .doc {
|
|
color: inherit;
|
|
}
|
|
.ft .doc:hover {
|
|
color: var(--accent);
|
|
}
|
|
</style>
|