From 3fd279cf8c38579ab456e717c4688e55017fb2d2 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 8 Jun 2026 16:40:07 +0200 Subject: [PATCH] Landing v2: icon switchers, ephemeral theme, channel link, drop browser CTA Owner review-pass rework of the landing page: - Rename the per-language Telegram link build var VITE_TELEGRAM_LINK_EN/_RU -> VITE_TELEGRAM_GAME_CHANNEL_NAME_EN/_RU (it carries a channel username; the landing builds https://t.me/ -- the same channels the connector posts to via TELEGRAM_GAME_CHANNEL_ID_*). - Language switcher -> a globe icon dropdown (flags + names), saved + synced to the app prefs. - Theme switcher -> a sun/moon icon toggle, ephemeral (follows the system scheme, no auto, never persisted) -- galaxy-game style. - Drop the "Play in browser" CTA (no standalone-web onboarding yet). Docs: FUNCTIONAL(+ru), PLAN, deploy + ui READMEs. --- .gitea/workflows/ci.yaml | 4 +- PLAN.md | 6 ++ deploy/.env.example | 4 +- deploy/README.md | 4 +- deploy/docker-compose.yml | 4 +- docs/FUNCTIONAL.md | 5 +- docs/FUNCTIONAL_ru.md | 5 +- gateway/Dockerfile | 8 +- ui/README.md | 2 +- ui/e2e/landing.spec.ts | 21 ++-- ui/src/Landing.svelte | 192 ++++++++++++++++++++----------------- ui/src/lib/i18n/en.ts | 1 - ui/src/lib/i18n/ru.ts | 1 - ui/src/lib/landing.test.ts | 24 ++--- ui/src/lib/landing.ts | 22 +++-- 15 files changed, 167 insertions(+), 136 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 940953a..235eb45 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -267,8 +267,8 @@ jobs: TELEGRAM_TEST_ENV: "true" VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }} VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }} - VITE_TELEGRAM_LINK_EN: ${{ vars.TEST_VITE_TELEGRAM_LINK_EN }} - VITE_TELEGRAM_LINK_RU: ${{ vars.TEST_VITE_TELEGRAM_LINK_RU }} + VITE_TELEGRAM_GAME_CHANNEL_NAME_EN: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_EN }} + VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_RU }} VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }} GATEWAY_DEFAULT_SUPPORTED_LANGUAGES: ${{ vars.TEST_GATEWAY_DEFAULT_SUPPORTED_LANGUAGES }} # Unset vars render empty -> the compose ":-" defaults apply. diff --git a/PLAN.md b/PLAN.md index 4bfa5ef..83b0c5e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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** 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. + - **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/`; 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) diff --git a/deploy/.env.example b/deploy/.env.example index 8b98c68..851e36f 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -25,8 +25,8 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt # --- UI build args (baked into the gateway image) --------------------------- VITE_TELEGRAM_BOT_ID= VITE_TELEGRAM_LINK= -VITE_TELEGRAM_LINK_EN= # landing "Play in Telegram" link, English bot -VITE_TELEGRAM_LINK_RU= # landing "Play in Telegram" link, Russian bot +VITE_TELEGRAM_GAME_CHANNEL_NAME_EN= # landing "Play in Telegram" link, English bot +VITE_TELEGRAM_GAME_CHANNEL_NAME_RU= # landing "Play in Telegram" link, Russian bot VITE_GATEWAY_URL= # --- Gateway ---------------------------------------------------------------- diff --git a/deploy/README.md b/deploy/README.md index e30f417..9435c8a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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). | | `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//`). | -| `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_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_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_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). | The five `VITE_*` are **build-args** baked into the gateway image at build time, so diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 10585ce..86ef2e2 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -78,8 +78,8 @@ services: args: VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-} VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} - VITE_TELEGRAM_LINK_EN: ${VITE_TELEGRAM_LINK_EN:-} - VITE_TELEGRAM_LINK_RU: ${VITE_TELEGRAM_LINK_RU:-} + VITE_TELEGRAM_GAME_CHANNEL_NAME_EN: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_EN:-} + VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_RU:-} VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-} VITE_APP_VERSION: ${APP_VERSION:-dev} restart: unless-stopped diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 0320e7c..350f045 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 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 -theme, and links into the web app or the matching Telegram bot; the game itself runs at -`/app/` (web) and `/telegram/` (the Telegram Mini App). +theme, and links to the matching per-language Telegram channel; the game itself runs at +`/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)* A player arrives from a platform (Telegram first), via email login, or as an diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 7edfee6..cf56299 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -23,8 +23,9 @@ top-1 подсказку, безлимитную проверку слова с Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии и ограничивает частоту повторов. Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и -тему и ведёт в веб-приложение или в соответствующего Telegram-бота; сама игра живёт по -адресам `/app/` (веб) и `/telegram/` (Telegram Mini App). +тему и ведёт в соответствующий по-язычный Telegram-канал; сама игра живёт по адресам +`/app/` (веб) и `/telegram/` (Telegram Mini App). Тема на странице эфемерна (берётся из +системной настройки, а не из сохранённой), выбор языка сохраняется. ### Личность и сессии *(Stage 1 / 6 / 9 / 15)* Игрок приходит с платформы (сначала Telegram), через email-вход или как diff --git a/gateway/Dockerfile b/gateway/Dockerfile index ab6cca1..c65dcf4 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -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"). ARG VITE_TELEGRAM_BOT_ID= ARG VITE_TELEGRAM_LINK= -ARG VITE_TELEGRAM_LINK_EN= -ARG VITE_TELEGRAM_LINK_RU= +ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_EN= +ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_RU= ARG VITE_GATEWAY_URL= ARG VITE_APP_VERSION= ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \ VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \ - VITE_TELEGRAM_LINK_EN=$VITE_TELEGRAM_LINK_EN \ - VITE_TELEGRAM_LINK_RU=$VITE_TELEGRAM_LINK_RU \ + VITE_TELEGRAM_GAME_CHANNEL_NAME_EN=$VITE_TELEGRAM_GAME_CHANNEL_NAME_EN \ + VITE_TELEGRAM_GAME_CHANNEL_NAME_RU=$VITE_TELEGRAM_GAME_CHANNEL_NAME_RU \ VITE_GATEWAY_URL=$VITE_GATEWAY_URL \ VITE_APP_VERSION=$VITE_APP_VERSION diff --git a/ui/README.md b/ui/README.md index 84ea239..faa051a 100644 --- a/ui/README.md +++ b/ui/README.md @@ -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) 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 -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). The build has **two entries**: the game SPA (`index.html`, served at `/app/` and diff --git a/ui/e2e/landing.spec.ts b/ui/e2e/landing.spec.ts index f9d33a9..7e6df0a 100644 --- a/ui/e2e/landing.spec.ts +++ b/ui/e2e/landing.spec.ts @@ -1,14 +1,21 @@ import { expect, test } from './fixtures'; // 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. -test('landing shows the pitch, a browser CTA to /app/, and switches language', async ({ page }) => { +// the game SPA lives at /app/ and /telegram/ (Stage 17). In dev it is reachable at /landing.html. +test('landing shows the pitch, switches language via the dropdown, and toggles theme', async ({ page }) => { await page.goto('/landing.html'); - // The primary call to action opens the web app mount. - await expect(page.getByRole('link', { name: /Play in browser/i })).toHaveAttribute('href', '/app/'); + // The tagline renders (English in the default test browser). + await expect(page.getByText(/Play Scrabble/i)).toBeVisible(); - // The language switch flips the copy to Russian (reusing the app i18n). - await page.getByRole('button', { name: 'Русский' }).click(); - await expect(page.getByRole('link', { name: /Играть в браузере/ })).toBeVisible(); + // The language dropdown switches the copy to Russian. + await page.getByRole('button', { name: 'Language' }).click(); + 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); }); diff --git a/ui/src/Landing.svelte b/ui/src/Landing.svelte index 930fd49..16587da 100644 --- a/ui/src/Landing.svelte +++ b/ui/src/Landing.svelte @@ -1,89 +1,84 @@
-
- {#each locales as lc (lc)} - - {/each} -
-
- {#each themes as th (th)} - - {/each} +
+ + {#if langOpen} + + + + {/if}
+

{about.title}

{t('landing.tagline')}

- + {#if tgLink} + + + {t('landing.playTelegram')} + + {/if}
@@ -125,32 +120,65 @@ .bar { display: flex; justify-content: space-between; - gap: 10px; - flex-wrap: wrap; + align-items: center; } - .seg { - display: flex; - gap: 6px; + .lang { + position: relative; } - .opt { - padding: 7px 12px; - border: 1px solid var(--border); - background: var(--surface); + .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); - user-select: none; - font-size: 0.85rem; + cursor: pointer; } - .opt.active { - background: var(--accent); - color: var(--accent-text); - border-color: var(--accent); + .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: 14px; + gap: 16px; padding: 24px 0 8px; } .hero h1 { @@ -164,33 +192,20 @@ color: var(--text-muted); font-size: 1.05rem; } - .cta { - display: flex; - gap: 12px; - justify-content: center; - flex-wrap: wrap; - margin-top: 6px; - } .play { + align-self: center; display: inline-flex; align-items: center; gap: 9px; - padding: 12px 22px; + padding: 12px 24px; border-radius: var(--radius-sm); font-weight: 700; text-decoration: none; - border: 1px solid var(--border); - } - .play.primary { background: var(--accent); color: var(--accent-text); - border-color: var(--accent); + margin-top: 6px; } - .play.tg { - background: var(--surface); - color: var(--text); - } - .play.tg img { + .play img { display: block; } .info { @@ -225,7 +240,6 @@ display: flex; flex-direction: column; gap: 5px; - color: var(--text); } .ft { margin-top: auto; diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 362daca..afd94d7 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -153,7 +153,6 @@ export const en = { 'about.version': 'Version {v}', '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', 'lang.en': 'English', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 592461e..b318565 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -154,7 +154,6 @@ export const ru: Record = { 'about.version': 'Версия {v}', 'landing.tagline': 'Играй в Скрэббл с друзьями или умным роботом — в браузере или в Telegram.', - 'landing.playWeb': 'Играть в браузере', 'landing.playTelegram': 'Играть в Telegram', 'lang.en': 'English', diff --git a/ui/src/lib/landing.test.ts b/ui/src/lib/landing.test.ts index fb050d2..25bfad8 100644 --- a/ui/src/lib/landing.test.ts +++ b/ui/src/lib/landing.test.ts @@ -1,20 +1,20 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { telegramBotLink } from './landing'; +import { telegramChannelLink } from './landing'; -describe('telegramBotLink', () => { +describe('telegramChannelLink', () => { afterEach(() => vi.unstubAllEnvs()); - it('returns the per-language bot link when configured', () => { - vi.stubEnv('VITE_TELEGRAM_LINK_EN', 'https://t.me/Scrabble_Game'); - vi.stubEnv('VITE_TELEGRAM_LINK_RU', 'https://t.me/Erudit_Game'); - expect(telegramBotLink('en')).toBe('https://t.me/Scrabble_Game'); - expect(telegramBotLink('ru')).toBe('https://t.me/Erudit_Game'); + it('builds the per-language t.me link from the channel name', () => { + vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_EN', 'Scrabble_Game'); + vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_RU', '@Erudit_Game'); // a leading @ is tolerated + expect(telegramChannelLink('en')).toBe('https://t.me/Scrabble_Game'); + expect(telegramChannelLink('ru')).toBe('https://t.me/Erudit_Game'); }); - it('returns null when the locale link is unset or blank', () => { - vi.stubEnv('VITE_TELEGRAM_LINK_EN', ''); - vi.stubEnv('VITE_TELEGRAM_LINK_RU', ' '); - expect(telegramBotLink('en')).toBeNull(); - expect(telegramBotLink('ru')).toBeNull(); + it('returns null when the locale channel is unset or blank', () => { + vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_EN', ''); + vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_RU', ' '); + expect(telegramChannelLink('en')).toBeNull(); + expect(telegramChannelLink('ru')).toBeNull(); }); }); diff --git a/ui/src/lib/landing.ts b/ui/src/lib/landing.ts index d3915b8..4368b2f 100644 --- a/ui/src/lib/landing.ts +++ b/ui/src/lib/landing.ts @@ -1,16 +1,20 @@ // 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'; /** - * telegramBotLink returns the t.me link for the locale's game bot, or null when it is not - * configured. The two links are build-time vars (VITE_TELEGRAM_LINK_EN / VITE_TELEGRAM_LINK_RU) - * because the test and prod contours run different bots (different usernames), so the link - * cannot be hardcoded. + * telegramChannelLink returns the t.me link for the locale's game channel, or null when it is + * not configured. The channel usernames are build-time vars (VITE_TELEGRAM_GAME_CHANNEL_NAME_EN + * / VITE_TELEGRAM_GAME_CHANNEL_NAME_RU) because the test and prod contours run different + * 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 { - const raw = locale === 'ru' ? import.meta.env.VITE_TELEGRAM_LINK_RU : import.meta.env.VITE_TELEGRAM_LINK_EN; - const link = (raw as string | undefined)?.trim(); - return link ? link : null; +export function telegramChannelLink(locale: Locale): string | null { + const raw = + locale === 'ru' + ? 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; }