Merge pull request 'Landing v2: icon switchers, ephemeral theme, channel link, drop browser CTA' (#22) from feature/landing-v2 into development
This commit was merged in pull request #22.
This commit is contained in:
@@ -267,8 +267,8 @@ jobs:
|
|||||||
TELEGRAM_TEST_ENV: "true"
|
TELEGRAM_TEST_ENV: "true"
|
||||||
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
|
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
|
||||||
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
||||||
VITE_TELEGRAM_LINK_EN: ${{ vars.TEST_VITE_TELEGRAM_LINK_EN }}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_EN }}
|
||||||
VITE_TELEGRAM_LINK_RU: ${{ vars.TEST_VITE_TELEGRAM_LINK_RU }}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_RU }}
|
||||||
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
|
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
|
||||||
GATEWAY_DEFAULT_SUPPORTED_LANGUAGES: ${{ vars.TEST_GATEWAY_DEFAULT_SUPPORTED_LANGUAGES }}
|
GATEWAY_DEFAULT_SUPPORTED_LANGUAGES: ${{ vars.TEST_GATEWAY_DEFAULT_SUPPORTED_LANGUAGES }}
|
||||||
# Unset vars render empty -> the compose ":-" defaults apply.
|
# Unset vars render empty -> the compose ":-" defaults apply.
|
||||||
|
|||||||
@@ -1348,6 +1348,12 @@ provided cert) at the contour caddy; prod VPN; rollback.
|
|||||||
timeout (constant reconnects in the caddy log); now an **immediate heartbeat on open** + a **10 s**
|
timeout (constant reconnects in the caddy log); now an **immediate heartbeat on open** + a **10 s**
|
||||||
default interval. Both surfaced while diagnosing a reported "slow load in Telegram" that was actually
|
default interval. Both surfaced while diagnosing a reported "slow load in Telegram" that was actually
|
||||||
the owner's **external network** (the server is sub-ms end-to-end) — not a regression.
|
the owner's **external network** (the server is sub-ms end-to-end) — not a regression.
|
||||||
|
- **Landing follow-up (owner review pass):** reworked from the first cut — the per-language Telegram
|
||||||
|
link var renamed `VITE_TELEGRAM_LINK_EN/_RU` → **`VITE_TELEGRAM_GAME_CHANNEL_NAME_EN/_RU`** (it carries
|
||||||
|
a channel **username**, the landing builds `https://t.me/<name>`; the connector keeps the matching
|
||||||
|
`..._CHANNEL_ID_..` to post). Switchers became icons — a 🌐 language dropdown (saved, synced to the app)
|
||||||
|
and a ☼/☾ theme toggle that is **ephemeral** (follows the system scheme, never persisted, no "auto").
|
||||||
|
The "Play in browser" CTA was dropped (no standalone-web onboarding yet).
|
||||||
|
|
||||||
## Deferred TODOs (cross-stage)
|
## Deferred TODOs (cross-stage)
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -25,8 +25,8 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt
|
|||||||
# --- UI build args (baked into the gateway image) ---------------------------
|
# --- UI build args (baked into the gateway image) ---------------------------
|
||||||
VITE_TELEGRAM_BOT_ID=
|
VITE_TELEGRAM_BOT_ID=
|
||||||
VITE_TELEGRAM_LINK=
|
VITE_TELEGRAM_LINK=
|
||||||
VITE_TELEGRAM_LINK_EN= # landing "Play in Telegram" link, English bot
|
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN= # landing "Play in Telegram" link, English bot
|
||||||
VITE_TELEGRAM_LINK_RU= # landing "Play in Telegram" link, Russian bot
|
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU= # landing "Play in Telegram" link, Russian bot
|
||||||
VITE_GATEWAY_URL=
|
VITE_GATEWAY_URL=
|
||||||
|
|
||||||
# --- Gateway ----------------------------------------------------------------
|
# --- Gateway ----------------------------------------------------------------
|
||||||
|
|||||||
+2
-2
@@ -84,8 +84,8 @@ connector **fails at boot** if both are empty.
|
|||||||
| `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` | variable | `en,ru` | Variant-gating set for non-Telegram logins (web/email/guest). |
|
| `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` | variable | `en,ru` | Variant-gating set for non-Telegram logins (web/email/guest). |
|
||||||
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
|
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
|
||||||
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: deep-link base for share-to-Telegram (e.g. `https://t.me/<bot>/<app>`). |
|
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: deep-link base for share-to-Telegram (e.g. `https://t.me/<bot>/<app>`). |
|
||||||
| `VITE_TELEGRAM_LINK_EN` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **English** bot (e.g. `https://t.me/Scrabble_Game`). |
|
| `VITE_TELEGRAM_GAME_CHANNEL_NAME_EN` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **English** bot (e.g. `https://t.me/Scrabble_Game`). |
|
||||||
| `VITE_TELEGRAM_LINK_RU` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **Russian** bot (e.g. `https://t.me/Erudit_Game`). |
|
| `VITE_TELEGRAM_GAME_CHANNEL_NAME_RU` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **Russian** bot (e.g. `https://t.me/Erudit_Game`). |
|
||||||
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
|
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
|
||||||
|
|
||||||
The five `VITE_*` are **build-args** baked into the gateway image at build time, so
|
The five `VITE_*` are **build-args** baked into the gateway image at build time, so
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ services:
|
|||||||
args:
|
args:
|
||||||
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
|
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
|
||||||
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
|
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
|
||||||
VITE_TELEGRAM_LINK_EN: ${VITE_TELEGRAM_LINK_EN:-}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_EN:-}
|
||||||
VITE_TELEGRAM_LINK_RU: ${VITE_TELEGRAM_LINK_RU:-}
|
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_RU:-}
|
||||||
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
||||||
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
+3
-2
@@ -22,8 +22,9 @@ Settings also pick the board's bonus-label style (beginner / classic / none). A
|
|||||||
costs nothing when the rack has no legal move. The word-check accepts only the
|
costs nothing when the rack has no legal move. The word-check accepts only the
|
||||||
variant's alphabet, remembers answers within the session and rate-limits repeats.
|
variant's alphabet, remembers answers within the session and rate-limits repeats.
|
||||||
A public **landing page** at the site root introduces the game, switches language and
|
A public **landing page** at the site root introduces the game, switches language and
|
||||||
theme, and links into the web app or the matching Telegram bot; the game itself runs at
|
theme, and links to the matching per-language Telegram channel; the game itself runs at
|
||||||
`/app/` (web) and `/telegram/` (the Telegram Mini App).
|
`/app/` (web) and `/telegram/` (the Telegram Mini App). The landing's theme is ephemeral
|
||||||
|
(it follows the system scheme, not the saved preference); its language choice is saved.
|
||||||
|
|
||||||
### Identity & sessions *(Stage 1 / 6 / 9 / 15)*
|
### Identity & sessions *(Stage 1 / 6 / 9 / 15)*
|
||||||
A player arrives from a platform (Telegram first), via email login, or as an
|
A player arrives from a platform (Telegram first), via email login, or as an
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ top-1 подсказку, безлимитную проверку слова с
|
|||||||
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
|
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
|
||||||
и ограничивает частоту повторов.
|
и ограничивает частоту повторов.
|
||||||
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
|
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
|
||||||
тему и ведёт в веб-приложение или в соответствующего Telegram-бота; сама игра живёт по
|
тему и ведёт в соответствующий по-язычный Telegram-канал; сама игра живёт по адресам
|
||||||
адресам `/app/` (веб) и `/telegram/` (Telegram Mini App).
|
`/app/` (веб) и `/telegram/` (Telegram Mini App). Тема на странице эфемерна (берётся из
|
||||||
|
системной настройки, а не из сохранённой), выбор языка сохраняется.
|
||||||
|
|
||||||
### Личность и сессии *(Stage 1 / 6 / 9 / 15)*
|
### Личность и сессии *(Stage 1 / 6 / 9 / 15)*
|
||||||
Игрок приходит с платформы (сначала Telegram), через email-вход или как
|
Игрок приходит с платформы (сначала Telegram), через email-вход или как
|
||||||
|
|||||||
+4
-4
@@ -20,14 +20,14 @@ RUN corepack enable && corepack prepare pnpm@11.0.9 --activate
|
|||||||
# VITE_APP_VERSION carries `git describe` for the About screen (defaults to "dev").
|
# VITE_APP_VERSION carries `git describe` for the About screen (defaults to "dev").
|
||||||
ARG VITE_TELEGRAM_BOT_ID=
|
ARG VITE_TELEGRAM_BOT_ID=
|
||||||
ARG VITE_TELEGRAM_LINK=
|
ARG VITE_TELEGRAM_LINK=
|
||||||
ARG VITE_TELEGRAM_LINK_EN=
|
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_EN=
|
||||||
ARG VITE_TELEGRAM_LINK_RU=
|
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_RU=
|
||||||
ARG VITE_GATEWAY_URL=
|
ARG VITE_GATEWAY_URL=
|
||||||
ARG VITE_APP_VERSION=
|
ARG VITE_APP_VERSION=
|
||||||
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
|
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
|
||||||
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
|
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
|
||||||
VITE_TELEGRAM_LINK_EN=$VITE_TELEGRAM_LINK_EN \
|
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN=$VITE_TELEGRAM_GAME_CHANNEL_NAME_EN \
|
||||||
VITE_TELEGRAM_LINK_RU=$VITE_TELEGRAM_LINK_RU \
|
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU=$VITE_TELEGRAM_GAME_CHANNEL_NAME_RU \
|
||||||
VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
|
VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
|
||||||
VITE_APP_VERSION=$VITE_APP_VERSION
|
VITE_APP_VERSION=$VITE_APP_VERSION
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@ pnpm codegen # regenerate src/gen from edge.proto + scrabble.fbs (dev-time)
|
|||||||
gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID` (Stage 11)
|
gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID` (Stage 11)
|
||||||
enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site
|
enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site
|
||||||
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
|
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
|
||||||
share-to-Telegram deep-link base (Stage 9). `VITE_TELEGRAM_LINK_EN` / `VITE_TELEGRAM_LINK_RU`
|
share-to-Telegram deep-link base (Stage 9). `VITE_TELEGRAM_GAME_CHANNEL_NAME_EN` / `VITE_TELEGRAM_GAME_CHANNEL_NAME_RU`
|
||||||
are the per-language "Play in Telegram" links shown on the landing page (Stage 17).
|
are the per-language "Play in Telegram" links shown on the landing page (Stage 17).
|
||||||
|
|
||||||
The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
|
The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
|
||||||
|
|||||||
+14
-7
@@ -1,14 +1,21 @@
|
|||||||
import { expect, test } from './fixtures';
|
import { expect, test } from './fixtures';
|
||||||
|
|
||||||
// The landing page is a separate Vite entry (landing.html), served at "/" in production while
|
// The landing page is a separate Vite entry (landing.html), served at "/" in production while
|
||||||
// the game SPA moves to /app/ and /telegram/ (Stage 17). In dev it is reachable at /landing.html.
|
// the game SPA lives at /app/ and /telegram/ (Stage 17). In dev it is reachable at /landing.html.
|
||||||
test('landing shows the pitch, a browser CTA to /app/, and switches language', async ({ page }) => {
|
test('landing shows the pitch, switches language via the dropdown, and toggles theme', async ({ page }) => {
|
||||||
await page.goto('/landing.html');
|
await page.goto('/landing.html');
|
||||||
|
|
||||||
// The primary call to action opens the web app mount.
|
// The tagline renders (English in the default test browser).
|
||||||
await expect(page.getByRole('link', { name: /Play in browser/i })).toHaveAttribute('href', '/app/');
|
await expect(page.getByText(/Play Scrabble/i)).toBeVisible();
|
||||||
|
|
||||||
// The language switch flips the copy to Russian (reusing the app i18n).
|
// The language dropdown switches the copy to Russian.
|
||||||
await page.getByRole('button', { name: 'Русский' }).click();
|
await page.getByRole('button', { name: 'Language' }).click();
|
||||||
await expect(page.getByRole('link', { name: /Играть в браузере/ })).toBeVisible();
|
await page.getByRole('menuitem', { name: /Русский/ }).click();
|
||||||
|
await expect(page.getByText(/Играй в Скрэббл/)).toBeVisible();
|
||||||
|
|
||||||
|
// The theme toggle flips the document theme (ephemeral, light<->dark).
|
||||||
|
const before = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
||||||
|
await page.getByRole('button', { name: 'Theme' }).click();
|
||||||
|
const after = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
||||||
|
expect(after).not.toBe(before);
|
||||||
});
|
});
|
||||||
|
|||||||
+103
-89
@@ -1,89 +1,84 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { applyReduceMotion, applyTheme, type ThemePref } from './lib/theme';
|
import { applyTheme } from './lib/theme';
|
||||||
import { i18n, localeFrom, setLocale, t, type Locale, type MessageKey } from './lib/i18n/index.svelte';
|
import { i18n, localeFrom, setLocale, t, type Locale } from './lib/i18n/index.svelte';
|
||||||
import { loadPrefs, savePrefs, type Prefs } from './lib/session';
|
import { loadPrefs, savePrefs, type Prefs } from './lib/session';
|
||||||
import { aboutContent } from './lib/aboutContent';
|
import { aboutContent } from './lib/aboutContent';
|
||||||
import { telegramBotLink } from './lib/landing';
|
import { telegramChannelLink } from './lib/landing';
|
||||||
|
|
||||||
// Standalone landing page (Stage 17): the public entry at "/", separate from the game SPA
|
// Standalone landing page (Stage 17), the public entry at "/" (the game SPA lives at /app/ and
|
||||||
// (served at /app/ and /telegram/). It reuses the app's theme/i18n/prefs leaf modules — but
|
// /telegram/). It reuses the app's theme/i18n/prefs leaf modules — not the app store — so it
|
||||||
// not the app store — so it stays light (no gateway, auth or live stream).
|
// 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).
|
||||||
|
|
||||||
const themes: ThemePref[] = ['auto', 'light', 'dark'];
|
let theme = $state<'light' | 'dark'>('light');
|
||||||
const themeLabel: Record<ThemePref, MessageKey> = {
|
let langOpen = $state(false);
|
||||||
auto: 'settings.themeAuto',
|
|
||||||
light: 'settings.themeLight',
|
|
||||||
dark: 'settings.themeDark',
|
|
||||||
};
|
|
||||||
const locales: Locale[] = ['en', 'ru'];
|
|
||||||
|
|
||||||
let theme = $state<ThemePref>('auto');
|
|
||||||
let prefs: Partial<Prefs> = {};
|
let prefs: Partial<Prefs> = {};
|
||||||
|
|
||||||
// The away/move clock the random-game copy mentions (backend game.DefaultTurnTimeout = 24h).
|
const about = $derived(aboutContent(i18n.locale, 24)); // 24h = the auto-match move clock
|
||||||
const about = $derived(aboutContent(i18n.locale, 24));
|
const tgLink = $derived(telegramChannelLink(i18n.locale));
|
||||||
const tgLink = $derived(telegramBotLink(i18n.locale));
|
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 () => {
|
onMount(async () => {
|
||||||
prefs = await loadPrefs();
|
prefs = await loadPrefs();
|
||||||
theme = prefs.theme ?? 'auto';
|
theme = systemTheme();
|
||||||
applyTheme(theme);
|
applyTheme(theme);
|
||||||
applyReduceMotion(prefs.reduceMotion ?? false);
|
|
||||||
setLocale(prefs.locale ?? localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en'));
|
setLocale(prefs.locale ?? localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en'));
|
||||||
});
|
});
|
||||||
|
|
||||||
function persist(): void {
|
function toggleTheme(): void {
|
||||||
// savePrefs takes the full set, so keep the labels/lines the app may have stored.
|
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({
|
void savePrefs({
|
||||||
theme,
|
theme: prefs.theme ?? 'auto',
|
||||||
locale: i18n.locale,
|
locale: lc,
|
||||||
reduceMotion: prefs.reduceMotion ?? false,
|
reduceMotion: prefs.reduceMotion ?? false,
|
||||||
boardLabels: prefs.boardLabels ?? 'beginner',
|
boardLabels: prefs.boardLabels ?? 'beginner',
|
||||||
boardLines: prefs.boardLines ?? false,
|
boardLines: prefs.boardLines ?? false,
|
||||||
});
|
});
|
||||||
}
|
prefs = { ...prefs, locale: lc };
|
||||||
function chooseTheme(th: ThemePref): void {
|
|
||||||
theme = th;
|
|
||||||
applyTheme(th);
|
|
||||||
persist();
|
|
||||||
}
|
|
||||||
function chooseLocale(lc: Locale): void {
|
|
||||||
setLocale(lc);
|
|
||||||
persist();
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="landing">
|
<main class="landing">
|
||||||
<header class="bar">
|
<header class="bar">
|
||||||
<div class="seg">
|
<div class="lang">
|
||||||
{#each locales as lc (lc)}
|
<button class="icon" aria-label="Language" aria-expanded={langOpen} onclick={() => (langOpen = !langOpen)}>🌐</button>
|
||||||
<button class="opt" class:active={i18n.locale === lc} onclick={() => chooseLocale(lc)}>
|
{#if langOpen}
|
||||||
{t(lc === 'en' ? 'lang.en' : 'lang.ru')}
|
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||||
</button>
|
<button class="backdrop" onclick={() => (langOpen = false)}></button>
|
||||||
{/each}
|
<div class="menu" role="menu">
|
||||||
</div>
|
{#each locales as l (l.code)}
|
||||||
<div class="seg">
|
<button role="menuitem" class:on={i18n.locale === l.code} onclick={() => chooseLocale(l.code)}>{l.label}</button>
|
||||||
{#each themes as th (th)}
|
{/each}
|
||||||
<button class="opt" class:active={theme === th} onclick={() => chooseTheme(th)}>
|
</div>
|
||||||
{t(themeLabel[th])}
|
{/if}
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
|
<button class="icon" aria-label="Theme" onclick={toggleTheme}>{theme === 'light' ? '☼' : '☾'}</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<h1>{about.title}</h1>
|
<h1>{about.title}</h1>
|
||||||
<p class="tagline">{t('landing.tagline')}</p>
|
<p class="tagline">{t('landing.tagline')}</p>
|
||||||
<div class="cta">
|
{#if tgLink}
|
||||||
<a class="play primary" href="/app/">{t('landing.playWeb')}</a>
|
<a class="play" href={tgLink} target="_blank" rel="noopener noreferrer">
|
||||||
{#if tgLink}
|
<img src="telegram-logo.svg" alt="" width="22" height="22" />
|
||||||
<a class="play tg" href={tgLink} target="_blank" rel="noopener noreferrer">
|
{t('landing.playTelegram')}
|
||||||
<img src="telegram-logo.svg" alt="" width="22" height="22" />
|
</a>
|
||||||
{t('landing.playTelegram')}
|
{/if}
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="info">
|
<section class="info">
|
||||||
@@ -125,32 +120,65 @@
|
|||||||
.bar {
|
.bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 10px;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
.seg {
|
.lang {
|
||||||
display: flex;
|
position: relative;
|
||||||
gap: 6px;
|
|
||||||
}
|
}
|
||||||
.opt {
|
.icon {
|
||||||
padding: 7px 12px;
|
min-width: 40px;
|
||||||
border: 1px solid var(--border);
|
min-height: 40px;
|
||||||
background: var(--surface);
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
background: transparent;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
user-select: none;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
}
|
||||||
.opt.active {
|
.backdrop {
|
||||||
background: var(--accent);
|
position: fixed;
|
||||||
color: var(--accent-text);
|
inset: 0;
|
||||||
border-color: var(--accent);
|
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 {
|
.hero {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 16px;
|
||||||
padding: 24px 0 8px;
|
padding: 24px 0 8px;
|
||||||
}
|
}
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
@@ -164,33 +192,20 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
}
|
}
|
||||||
.cta {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
justify-content: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
.play {
|
.play {
|
||||||
|
align-self: center;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
padding: 12px 22px;
|
padding: 12px 24px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.play.primary {
|
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--accent-text);
|
color: var(--accent-text);
|
||||||
border-color: var(--accent);
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
.play.tg {
|
.play img {
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.play.tg img {
|
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.info {
|
.info {
|
||||||
@@ -225,7 +240,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
color: var(--text);
|
|
||||||
}
|
}
|
||||||
.ft {
|
.ft {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
|
|||||||
@@ -153,7 +153,6 @@ export const en = {
|
|||||||
'about.version': 'Version {v}',
|
'about.version': 'Version {v}',
|
||||||
|
|
||||||
'landing.tagline': 'Play Scrabble with friends or a smart robot — in your browser or on Telegram.',
|
'landing.tagline': 'Play Scrabble with friends or a smart robot — in your browser or on Telegram.',
|
||||||
'landing.playWeb': 'Play in browser',
|
|
||||||
'landing.playTelegram': 'Play in Telegram',
|
'landing.playTelegram': 'Play in Telegram',
|
||||||
|
|
||||||
'lang.en': 'English',
|
'lang.en': 'English',
|
||||||
|
|||||||
@@ -154,7 +154,6 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'about.version': 'Версия {v}',
|
'about.version': 'Версия {v}',
|
||||||
|
|
||||||
'landing.tagline': 'Играй в Скрэббл с друзьями или умным роботом — в браузере или в Telegram.',
|
'landing.tagline': 'Играй в Скрэббл с друзьями или умным роботом — в браузере или в Telegram.',
|
||||||
'landing.playWeb': 'Играть в браузере',
|
|
||||||
'landing.playTelegram': 'Играть в Telegram',
|
'landing.playTelegram': 'Играть в Telegram',
|
||||||
|
|
||||||
'lang.en': 'English',
|
'lang.en': 'English',
|
||||||
|
|||||||
+12
-12
@@ -1,20 +1,20 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { telegramBotLink } from './landing';
|
import { telegramChannelLink } from './landing';
|
||||||
|
|
||||||
describe('telegramBotLink', () => {
|
describe('telegramChannelLink', () => {
|
||||||
afterEach(() => vi.unstubAllEnvs());
|
afterEach(() => vi.unstubAllEnvs());
|
||||||
|
|
||||||
it('returns the per-language bot link when configured', () => {
|
it('builds the per-language t.me link from the channel name', () => {
|
||||||
vi.stubEnv('VITE_TELEGRAM_LINK_EN', 'https://t.me/Scrabble_Game');
|
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_EN', 'Scrabble_Game');
|
||||||
vi.stubEnv('VITE_TELEGRAM_LINK_RU', 'https://t.me/Erudit_Game');
|
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_RU', '@Erudit_Game'); // a leading @ is tolerated
|
||||||
expect(telegramBotLink('en')).toBe('https://t.me/Scrabble_Game');
|
expect(telegramChannelLink('en')).toBe('https://t.me/Scrabble_Game');
|
||||||
expect(telegramBotLink('ru')).toBe('https://t.me/Erudit_Game');
|
expect(telegramChannelLink('ru')).toBe('https://t.me/Erudit_Game');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null when the locale link is unset or blank', () => {
|
it('returns null when the locale channel is unset or blank', () => {
|
||||||
vi.stubEnv('VITE_TELEGRAM_LINK_EN', '');
|
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_EN', '');
|
||||||
vi.stubEnv('VITE_TELEGRAM_LINK_RU', ' ');
|
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_RU', ' ');
|
||||||
expect(telegramBotLink('en')).toBeNull();
|
expect(telegramChannelLink('en')).toBeNull();
|
||||||
expect(telegramBotLink('ru')).toBeNull();
|
expect(telegramChannelLink('ru')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-9
@@ -1,16 +1,20 @@
|
|||||||
// Pure helpers for the public landing page (Stage 17), kept out of the Svelte component so
|
// Pure helpers for the public landing page (Stage 17), kept out of the Svelte component so
|
||||||
// the per-language Telegram-bot link selection is unit-testable.
|
// the per-language Telegram-channel link selection is unit-testable.
|
||||||
|
|
||||||
import type { Locale } from './i18n/index.svelte';
|
import type { Locale } from './i18n/index.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* telegramBotLink returns the t.me link for the locale's game bot, or null when it is not
|
* telegramChannelLink returns the t.me link for the locale's game channel, or null when it is
|
||||||
* configured. The two links are build-time vars (VITE_TELEGRAM_LINK_EN / VITE_TELEGRAM_LINK_RU)
|
* not configured. The channel usernames are build-time vars (VITE_TELEGRAM_GAME_CHANNEL_NAME_EN
|
||||||
* because the test and prod contours run different bots (different usernames), so the link
|
* / VITE_TELEGRAM_GAME_CHANNEL_NAME_RU) because the test and prod contours run different
|
||||||
* cannot be hardcoded.
|
* channels; they are the same channels the connector posts to via TELEGRAM_GAME_CHANNEL_ID_*
|
||||||
|
* (the id to post, the name to link). A leading "@" is tolerated.
|
||||||
*/
|
*/
|
||||||
export function telegramBotLink(locale: Locale): string | null {
|
export function telegramChannelLink(locale: Locale): string | null {
|
||||||
const raw = locale === 'ru' ? import.meta.env.VITE_TELEGRAM_LINK_RU : import.meta.env.VITE_TELEGRAM_LINK_EN;
|
const raw =
|
||||||
const link = (raw as string | undefined)?.trim();
|
locale === 'ru'
|
||||||
return link ? link : null;
|
? import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME_RU
|
||||||
|
: import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME_EN;
|
||||||
|
const name = (raw as string | undefined)?.trim().replace(/^@/, '');
|
||||||
|
return name ? `https://t.me/${name}` : null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user