Stage 17 round 6 (#16-20): landing page, /app/ move, cache + stream fixes #21

Merged
developer merged 1 commits from feature/stage-17-round-6-landing into development 2026-06-08 14:07:50 +00:00
27 changed files with 519 additions and 82 deletions
+2
View File
@@ -267,6 +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_LINK_RU: ${{ vars.TEST_VITE_TELEGRAM_LINK_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.
+29 -17
View File
@@ -1319,23 +1319,35 @@ provided cert) at the contour caddy; prod VPN; rollback.
`game_drafts` table (migration 00011) + raw-SQL store/service (`GetDraft`/`SaveDraft`) that, on every `game_drafts` table (migration 00011) + raw-SQL store/service (`GetDraft`/`SaveDraft`) that, on every
committed move, clears the actor's own draft and resets any opponent's board draft whose cell the play committed move, clears the actor's own draft and resets any opponent's board draft whose cell the play
overlapped — 5 integration tests. overlapped — 5 integration tests.
- **Stage 17 round 6 — REMAINING (next pass), designs ready:** - **Stage 17 round 6 — final pass (#4/#5/#6 + #1620), shipped:**
1. **Persistence gateway slice + UI (#4/#5/#6).** *FB (lean):* `DraftRequest{game_id, json}` (save) + 1. **Draft persistence gateway slice + UI (#4/#5/#6, PR #20).** FB `DraftRequest{game_id, json}`
a game-id request (get) + `DraftView{json}` — one string field; the client serializes/deserializes (save) + `DraftView{json}` (get reuses `GameActionRequest`); the client serializes
`{rack_order, board_tiles}` itself (no FB tile array). Regen Go (`make -C pkg fbs`) + TS `{rack_order, board_tiles}` itself (no FB tile array), the gateway forwards it as `json.RawMessage`
(`pnpm codegen`); flatc is pinned **23.5.26** (the local one matches). *Gateway* forwards the JSON as both ways (no double-encode), and `GET`/`PUT /games/:id/draft` (a server `draftDTO` ↔ `game.Draft`)
`json.RawMessage` (no double-encode). *REST:* `GET`/`PUT /games/:id/draft` (decodes `game.Draft`). is the only place that reads the shape. UI: debounced save of the rack order (#4) + board draft (#6)
*UI:* save the rack order (#4) and board draft (#6) on change (debounced) and restore on load and restore on load (`lib/draft.ts`, reconciling against the committed board); **#5** — tiles may be
(next to `gameState`); **#5** — allow placing tiles on the opponent's turn (relax the `isMyTurn` arranged on the opponent's turn (placement relaxed; the preview and Make-move stay your-turn-only,
gate on placement only; the evaluate-preview and Make-move stay your-turn-only, so an off-turn draft so an off-turn draft is position-only). Off-turn tiles keep the **existing pending highlight** — no
is position-only — never scored/submitted). caption, no new style (owner's call). The backend draft endpoint is sub-ms.
2. **Landing + `/app/` move (#1620).** An extra Svelte page at `/`, the game SPA under `/app/` (Vite 2. **Landing + `/app/` move (#1620, this PR).** One Vite build with **two HTML entries** — the game
`base` conditional: `/app/` for web, `./` for Capacitor); the gateway serves the landing at `/` and SPA (`index.html`) and a new lightweight landing (`landing.html` → `Landing.svelte`, reusing the
the SPA at `/app/*`; a bundled Telegram logo (from `.claude/telegram-logo.svg`, **copied into theme/i18n/`aboutContent` leaf modules, not the app store, so it stays small). The gateway serves the
`ui/public/`, the reference itself not committed**) linking to the per-language t.me bot (ru **landing at `/`** and the **game SPA at `/app/` and `/telegram/`** (`webui.Handler(stripPrefix,
`Erudit_Game` / en `Scrabble_Game`); theme + language switchers reusing the app stores; reuse the indexName)`); relative base keeps one build serving every mount with a shared `dist/assets/` (the
`aboutContent` copy. **Note:** moving the game to `/app/` means the Telegram Mini App URL must point planned per-target `base` conditional proved unnecessary). **Correction to the original note:** the
to `/app/`. Telegram **Mini App stays at `/telegram/`** — only the plain web app moved off `/` to `/app/`, so
BotFather is untouched. The landing's "Play in Telegram" link is **per-language** via two new build
vars `VITE_TELEGRAM_LINK_EN` / `VITE_TELEGRAM_LINK_RU` (test/prod bots differ → no hardcoding; the
button hides when unset). Logo copied `.claude/telegram-logo.svg` → `ui/public/` (source stays
untracked).
- **Edge robustness (folded into the landing PR).** (a) **Static cache headers** — the embedded
`http.FileServer` over `go:embed` has a zero modtime, so it emitted no validators → the client
re-downloaded the whole bundle every launch; now hash-named `/assets/*` are `immutable` (a relaunch
is a cache hit) and the HTML shells are `no-cache`. (b) **Live-stream 15 s abort** — the `Subscribe`
heartbeat only fired after the first 15 s tick, so the stream sat silent and raced a ~15 s edge idle
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.
## Deferred TODOs (cross-stage) ## Deferred TODOs (cross-stage)
+2
View File
@@ -25,6 +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_LINK_RU= # landing "Play in Telegram" link, Russian bot
VITE_GATEWAY_URL= VITE_GATEWAY_URL=
# --- Gateway ---------------------------------------------------------------- # --- Gateway ----------------------------------------------------------------
+4 -2
View File
@@ -12,7 +12,7 @@ operational reference for **every environment variable**.
| Service | Image | Role | | Service | Image | Role |
| --- | --- | --- | | --- | --- | --- |
| `caddy` | `caddy:2-alpine` | Edge proxy (alias `scrabble` on `edge`): single `/_gm` Basic-Auth → admin console + Grafana; everything else → gateway. TLS per `CADDY_SITE_ADDRESS`. | | `caddy` | `caddy:2-alpine` | Edge proxy (alias `scrabble` on `edge`): single `/_gm` Basic-Auth → admin console + Grafana; everything else → gateway. TLS per `CADDY_SITE_ADDRESS`. |
| `gateway` | built (`gateway/Dockerfile`) | Public edge; serves the embedded SPA at `/` and `/telegram/`; Connect-RPC edge. | | `gateway` | built (`gateway/Dockerfile`) | Public edge; serves the embedded landing at `/` and the game SPA at `/app/` + `/telegram/`; Connect-RPC edge. |
| `backend` | built (`backend/Dockerfile`) | Domain service; bakes in the DAWG dictionaries; runs migrations at boot. | | `backend` | built (`backend/Dockerfile`) | Domain service; bakes in the DAWG dictionaries; runs migrations at boot. |
| `postgres` | `postgres:17-alpine` | Database (named volume, `pg_isready` healthcheck). | | `postgres` | `postgres:17-alpine` | Database (named volume, `pg_isready` healthcheck). |
| `vpn` + `telegram` | sidecar + built (`platform/telegram/Dockerfile`) | Telegram connector; egresses through the AmneziaWG sidecar; internal gRPC at `telegram:9091`. | | `vpn` + `telegram` | sidecar + built (`platform/telegram/Dockerfile`) | Telegram connector; egresses through the AmneziaWG sidecar; internal gRPC at `telegram:9091`. |
@@ -84,9 +84,11 @@ 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_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_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 three `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
changing them requires a rebuild (`--build`), not just a restart. changing them requires a rebuild (`--build`), not just a restart.
## Fixed internal wiring (not operator-set) ## Fixed internal wiring (not operator-set)
+2
View File
@@ -78,6 +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_LINK_RU: ${VITE_TELEGRAM_LINK_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
+7 -4
View File
@@ -573,13 +573,16 @@ a dedicated redeem sub-limit or a longer code is the hardening step if abuse app
## 13. Deployment (informational) ## 13. Deployment (informational)
Single public origin, path-routed. The gateway **embeds** the static UI build Single public origin, path-routed. The gateway **embeds** the static UI build
(`go:embed`, baked in by a node stage in `gateway/Dockerfile`) and serves the one (`go:embed`, baked in by a node stage in `gateway/Dockerfile`). The Vite build has two
SPA at both `/` (web) and `/telegram/` (the Telegram Mini App; outside Telegram that entries: a lightweight **landing page** served at `/`, and the game **SPA** served at
path redirects to the root — the client-side guard). An in-compose **caddy** is the `/app/` (web) and `/telegram/` (the Telegram Mini App; outside Telegram that path
redirects to the root — the client-side guard). Hash-named `/assets/*` are served
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
`no-cache` so a new deploy is picked up. An in-compose **caddy** is the
contour's edge: it owns a single `/_gm` Basic-Auth and routes `/_gm/grafana/*` to contour's edge: it owns a single `/_gm` Basic-Auth and routes `/_gm/grafana/*` to
**Grafana** (anonymous-admin, so the one shared login gates it with no per-user **Grafana** (anonymous-admin, so the one shared login gates it with no per-user
Grafana accounts) and the rest of `/_gm/*` to the backend-rendered **admin console**; Grafana accounts) and the rest of `/_gm/*` to the backend-rendered **admin console**;
everything else (`/`, `/telegram/`, the Connect edge) goes to the gateway. The everything else (`/`, `/app/`, `/telegram/`, the Connect edge) goes to the gateway. The
**Telegram connector** runs as a separate container with **no public ingress** — it **Telegram connector** runs as a separate container with **no public ingress** — it
long-polls Telegram and egresses through a VPN sidecar, answering only internal gRPC. long-polls Telegram and egresses through a VPN sidecar, answering only internal gRPC.
+8 -1
View File
@@ -21,6 +21,9 @@ email, the statistics screen, and the in-game history viewer with GCG export.
Settings also pick the board's bonus-label style (beginner / classic / none). A hint **lays the suggested tiles on the board** for the player to confirm and Settings also pick the board's bonus-label style (beginner / classic / none). A hint **lays the suggested tiles on the board** for the player to confirm and
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
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).
### 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
@@ -83,7 +86,11 @@ the other player and the leaver keeps their score. In a game with three or four
players the leaver's seat is dropped and the others play on, the game ending when a players the leaver's seat is dropped and the others play on, the game ending when a
single active player remains; the disposition of the leaver's tiles (returned to single active player remains; the disposition of the leaver's tiles (returned to
the bag or removed from play) is chosen when the game is created, and the leaver's the bag or removed from play) is chosen when the game is created, and the leaver's
rack is never shown to the others. rack is never shown to the others. A player's **board composition is kept per game**:
the rack arrangement and the tiles laid but not yet submitted are saved as they compose
and restored on return (including on another device); a player may **arrange tiles during
the opponent's turn**, but that draft is position-only — the score preview and submission
stay available only on the player's own turn.
### Robot opponent *(Stage 5)* ### Robot opponent *(Stage 5)*
When auto-match finds no human within ten seconds, a robot opponent takes the empty When auto-match finds no human within ten seconds, a robot opponent takes the empty
+8 -1
View File
@@ -22,6 +22,9 @@ top-1 подсказку, безлимитную проверку слова с
доску** — игрок сам решает сделать ход, и подсказка не тратится, если ходов нет. доску** — игрок сам решает сделать ход, и подсказка не тратится, если ходов нет.
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
и ограничивает частоту повторов. и ограничивает частоту повторов.
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
тему и ведёт в веб-приложение или в соответствующего Telegram-бота; сама игра живёт по
адресам `/app/` (веб) и `/telegram/` (Telegram Mini App).
### Личность и сессии *(Stage 1 / 6 / 9 / 15)* ### Личность и сессии *(Stage 1 / 6 / 9 / 15)*
Игрок приходит с платформы (сначала Telegram), через email-вход или как Игрок приходит с платформы (сначала Telegram), через email-вход или как
@@ -85,7 +88,11 @@ Mini App** авторизует по подписанным `initData` плат
место вышедшего убирается, остальные играют дальше, и партия завершается, когда место вышедшего убирается, остальные играют дальше, и партия завершается, когда
остаётся один активный игрок; что делать с фишками вышедшего (вернуть в мешок или остаётся один активный игрок; что делать с фишками вышедшего (вернуть в мешок или
убрать из игры) выбирается при создании партии, а его стойка никогда не убрать из игры) выбирается при создании партии, а его стойка никогда не
показывается остальным. показывается остальным. **Композиция на доске сохраняется по партии**: расположение
фишек на стойке и выложенные, но не отправленные фишки сохраняются по мере составления
хода и восстанавливаются при возврате (в том числе на другом устройстве); игрок может
**раскладывать фишки и в ход соперника**, но такой черновик только позиционный —
предпросмотр счёта и отправка доступны лишь в собственный ход.
### Робот-соперник *(Stage 5)* ### Робот-соперник *(Stage 5)*
Если авто-подбор не находит человека за десять секунд, свободное место занимает Если авто-подбор не находит человека за десять секунд, свободное место занимает
+4
View File
@@ -20,10 +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_LINK_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_LINK_RU=$VITE_TELEGRAM_LINK_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
+5 -4
View File
@@ -6,8 +6,9 @@ cleartext (`h2c`), authenticates the originating credential, mints/resolves a
thin opaque session, rate-limits, injects `X-User-ID` when forwarding to the thin opaque session, rate-limits, injects `X-User-ID` when forwarding to the
backend over REST/JSON, and bridges the backend's gRPC push stream to each backend over REST/JSON, and bridges the backend's gRPC push stream to each
client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked
in by the gateway image's node stage) and serves the one SPA at `/` (web) and in by the gateway image's node stage) and serves a **landing page** at `/` and the game
`/telegram/` (the Mini App) — the single-origin model. It can also serve the **SPA** at `/app/` (web) and `/telegram/` (the Mini App) — the single-origin model.
Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the
backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run; backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
in the deployed contour the front caddy owns `/_gm` (see in the deployed contour the front caddy owns `/_gm` (see
[`../deploy`](../deploy)). See [`../deploy`](../deploy)). See
@@ -28,7 +29,7 @@ internal/push/ # live-event fan-out hub (per-user client streams)
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
internal/connectsrv/ # the Connect Gateway service over h2c (+ the in-memory active_users gauge) internal/connectsrv/ # the Connect Gateway service over h2c (+ the in-memory active_users gauge)
internal/admin/ # Basic-Auth reverse proxy mounting the backend admin console at /_gm (verbatim) internal/admin/ # Basic-Auth reverse proxy mounting the backend admin console at /_gm (verbatim)
internal/webui/ # embedded SPA build (go:embed dist) served at / and /telegram/ internal/webui/ # embedded UI build (go:embed dist): landing at /, SPA at /app/ + /telegram/
``` ```
The FlatBuffers payloads and the backend push proto are the shared wire The FlatBuffers payloads and the backend push proto are the shared wire
@@ -77,7 +78,7 @@ connector (`ValidateLoginWidget`) and forward the trusted `external_id`. These
| `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` | `en,ru` | New Game variant gating set placed on the Session for non-platform logins (web / email / guest); a deployment may narrow it | | `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` | `en,ru` | New Game variant gating set placed on the Session for non-platform logins (web / email / guest); a deployment may narrow it |
| `GATEWAY_SESSION_TTL` | `10m` | cached session lifetime | | `GATEWAY_SESSION_TTL` | `10m` | cached session lifetime |
| `GATEWAY_SESSION_CACHE_MAX` | `50000` | cached session cap | | `GATEWAY_SESSION_CACHE_MAX` | `50000` | cached session cap |
| `GATEWAY_PUSH_HEARTBEAT_INTERVAL` | `15s` | live-stream keep-alive | | `GATEWAY_PUSH_HEARTBEAT_INTERVAL` | `10s` | live-stream keep-alive (an immediate heartbeat also fires on open, under the ~15s edge idle timeout) |
| `GATEWAY_SERVICE_NAME` | `scrabble-gateway` | OpenTelemetry `service.name` | | `GATEWAY_SERVICE_NAME` | `scrabble-gateway` | OpenTelemetry `service.name` |
| `GATEWAY_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from `OTEL_EXPORTER_OTLP_*`) | | `GATEWAY_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from `OTEL_EXPORTER_OTLP_*`) |
| `GATEWAY_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp` | | `GATEWAY_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp` |
+1 -1
View File
@@ -73,7 +73,7 @@ const (
defaultBackendTimeout = 5 * time.Second defaultBackendTimeout = 5 * time.Second
defaultSessionTTL = 10 * time.Minute defaultSessionTTL = 10 * time.Minute
defaultSessionCacheMax = 50000 defaultSessionCacheMax = 50000
defaultPushHeartbeatInterval = 15 * time.Second defaultPushHeartbeatInterval = 10 * time.Second // under the ~15 s edge idle timeout (Stage 17)
defaultServiceName = "scrabble-gateway" defaultServiceName = "scrabble-gateway"
) )
+16 -6
View File
@@ -99,12 +99,14 @@ func (s *Server) HTTPHandler() http.Handler {
// does not serve the app shell at the operator path. // does not serve the app shell at the operator path.
mux.Handle("/_gm/", http.NotFoundHandler()) mux.Handle("/_gm/", http.NotFoundHandler())
} }
// The embedded single-page UI is served at the site root and, for the Telegram // The embedded UI: the game SPA under /app/ (web) and /telegram/ (the Telegram Mini
// Mini App, under /telegram/ — the single-origin model (docs/ARCHITECTURE.md // App), with a separate landing page at the catch-all "/" — the single-origin model
// §13). Both mounts sit below the h2c wrap so the Connect edge (a more specific // (docs/ARCHITECTURE.md §13). All sit below the h2c wrap so the Connect edge (a more
// prefix) keeps priority; "/" is the catch-all SPA fallback for the hash router. // specific prefix) keeps priority. Each SPA mount falls back to the app shell
mux.Handle("/telegram/", webui.Handler("/telegram/")) // (index.html) for the hash router; "/" falls back to the landing (landing.html).
mux.Handle("/", webui.Handler("")) mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html"))
mux.Handle("/app/", webui.Handler("/app/", "index.html"))
mux.Handle("/", webui.Handler("", "landing.html"))
return h2c.NewHandler(mux, &http2.Server{}) return h2c.NewHandler(mux, &http2.Server{})
} }
@@ -184,6 +186,14 @@ func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.Subs
events, cancel := s.hub.Subscribe(uid) events, cancel := s.hub.Subscribe(uid)
defer cancel() defer cancel()
// Send an immediate heartbeat so the stream's first byte flushes through the proxy chain
// right away and resets edge/client idle timers, instead of the connection sitting silent
// until the first tick — which otherwise raced a ~15 s idle timeout and forced a reconnect
// every interval (Stage 17).
if err := stream.Send(&edgev1.Event{Kind: heartbeatKind}); err != nil {
return err
}
ticker := time.NewTicker(s.heartbeat) ticker := time.NewTicker(s.heartbeat)
defer ticker.Stop() defer ticker.Stop()
+4 -3
View File
@@ -6,10 +6,11 @@
<title>Scrabble</title> <title>Scrabble</title>
</head> </head>
<body> <body>
<!-- scrabble-app-shell -->
<p> <p>
UI build placeholder. The production gateway image embeds the real Vite App shell build placeholder. The production gateway image embeds the real Vite
build (see gateway/Dockerfile); seeing this page means the binary was build (see gateway/Dockerfile); seeing this page means the binary was built
built without a UI build. without a UI build.
</p> </p>
</body> </body>
</html> </html>
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Scrabble</title>
</head>
<body>
<!-- scrabble-landing -->
<p>
Landing build placeholder. The production gateway image embeds the real Vite
build (see gateway/Dockerfile); seeing this page means the binary was built
without a UI build.
</p>
</body>
</html>
+30 -19
View File
@@ -1,12 +1,16 @@
// Package webui serves the embedded single-page UI build over the public edge. // Package webui serves the embedded static UI build over the public edge.
// //
// The committed dist/ holds only a placeholder index.html so the gateway module // The committed dist/ holds only placeholder index.html / landing.html so the gateway
// compiles with a plain `go build` (and in CI) without a UI build. The production // module compiles with a plain `go build` (and in CI) without a UI build. The production
// gateway image replaces dist/ with the real Vite build before compiling (see // gateway image replaces dist/ with the real Vite build before compiling (see
// gateway/Dockerfile), so the binary ships the UI inside it. Because Vite is built // gateway/Dockerfile), so the binary ships the UI inside it. Because Vite is built with a
// with a relative asset base, one build serves under any path: Handler is mounted // relative asset base, one build serves under any path: the game SPA is mounted at /app/
// both at "/" (web) and at "/telegram/" (the Telegram Mini App), matching the // (web) and /telegram/ (the Telegram Mini App), with a separate landing page at / — the
// single-origin model in docs/ARCHITECTURE.md §13. // single-origin model in docs/ARCHITECTURE.md §13.
//
// Caching (Stage 17): Vite emits hash-named files under assets/, so those are immutable and
// cached hard (a reload/relaunch is a cache hit, not a re-download); the HTML shells carry
// no-cache so a new deploy is picked up immediately.
package webui package webui
import ( import (
@@ -30,25 +34,30 @@ func distFS() fs.FS {
return sub return sub
} }
// Handler serves the embedded SPA. An existing file is served directly (with the // Handler serves the embedded UI. An existing file is served directly (hash-named assets get
// standard content-type and caching headers); every other path falls back to // an immutable cache); every other path falls back to indexName (the SPA shell or the landing
// index.html so the client-side hash router can take over a deep link. When // page) so a client-side deep link still loads. When stripPrefix is non-empty it is removed
// stripPrefix is non-empty it is removed from the request path before lookup, so // from the request path before lookup, so the same build serves under a sub-path (e.g.
// the same build serves under a sub-path (e.g. "/telegram/"). // "/app/" or "/telegram/").
func Handler(stripPrefix string) http.Handler { func Handler(stripPrefix, indexName string) http.Handler {
content := distFS() content := distFS()
files := http.FileServer(http.FS(content)) files := http.FileServer(http.FS(content))
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/") name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
if name == "" { if name == "" {
serveIndex(w, content) serveIndex(w, content, indexName)
return return
} }
if info, err := fs.Stat(content, name); err != nil || info.IsDir() { if info, err := fs.Stat(content, name); err != nil || info.IsDir() {
// Unknown path or a directory: serve the SPA shell, never a listing. // Unknown path or a directory: serve the shell, never a listing.
serveIndex(w, content) serveIndex(w, content, indexName)
return return
} }
// Hash-named build assets are immutable — cache them for a year so reopening the
// app (notably a relaunched Telegram Mini App) is a cache hit, not a re-download.
if strings.HasPrefix(name, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
files.ServeHTTP(w, r) files.ServeHTTP(w, r)
}) })
if p := strings.TrimSuffix(stripPrefix, "/"); p != "" { if p := strings.TrimSuffix(stripPrefix, "/"); p != "" {
@@ -57,14 +66,16 @@ func Handler(stripPrefix string) http.Handler {
return h return h
} }
// serveIndex writes the SPA shell with a 200 status, so a client-routed deep link // serveIndex writes the named HTML shell with a 200 status, so a client-routed deep link
// still loads the app rather than a 404. // still loads the app rather than a 404. The shell is marked no-cache so a new deploy's
func serveIndex(w http.ResponseWriter, content fs.FS) { // shell (and the asset URLs it references) is fetched fresh.
data, err := fs.ReadFile(content, "index.html") func serveIndex(w http.ResponseWriter, content fs.FS, indexName string) {
data, err := fs.ReadFile(content, indexName)
if err != nil { if err != nil {
http.Error(w, "ui not built", http.StatusInternalServerError) http.Error(w, "ui not built", http.StatusInternalServerError)
return return
} }
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write(data) _, _ = w.Write(data)
+38 -25
View File
@@ -16,37 +16,50 @@ func get(t *testing.T, h http.Handler, target string) *http.Response {
return rec.Result() return rec.Result()
} }
func TestHandlerServesIndexAndFallsBack(t *testing.T) { func body(t *testing.T, resp *http.Response) string {
h := Handler("") t.Helper()
b, _ := io.ReadAll(resp.Body)
// The embedded placeholder index is served at the root. return string(b)
if resp := get(t, h, "/"); resp.StatusCode != http.StatusOK {
t.Fatalf("GET / status = %d, want 200", resp.StatusCode)
} }
// An existing (non-index) file is served directly by the file server. // TestLandingMountServesLandingAndFallsBack: "/" serves the landing shell (no-cache) and
if resp := get(t, h, "/assets/.gitkeep"); resp.StatusCode != http.StatusOK { // any unknown path falls back to it.
t.Fatalf("GET /assets/.gitkeep status = %d, want 200 (served file)", resp.StatusCode) func TestLandingMountServesLandingAndFallsBack(t *testing.T) {
h := Handler("", "landing.html")
resp := get(t, h, "/")
if resp.StatusCode != http.StatusOK || !strings.Contains(body(t, resp), "scrabble-landing") {
t.Fatalf("GET / did not serve the landing shell (status %d)", resp.StatusCode)
}
if cc := get(t, h, "/").Header.Get("Cache-Control"); cc != "no-cache" {
t.Errorf("landing Cache-Control = %q, want no-cache", cc)
}
if resp := get(t, h, "/whatever"); resp.StatusCode != http.StatusOK {
t.Fatalf("GET /whatever status = %d, want 200 (fallback)", resp.StatusCode)
}
} }
// An unknown deep link falls back to the SPA shell (200, not 404) so the // TestAppMountServesShellStripsPrefixAndCachesAssets: "/app/" and "/telegram/" serve the app
// client-side hash router can take over. // shell (index.html), strip their prefix, fall back for deep links, and mark hash-named
resp := get(t, h, "/game/abc/deep") // assets immutable.
func TestAppMountServesShellStripsPrefixAndCachesAssets(t *testing.T) {
for _, prefix := range []string{"/app/", "/telegram/"} {
h := Handler(prefix, "index.html")
if resp := get(t, h, prefix); resp.StatusCode != http.StatusOK || !strings.Contains(body(t, resp), "scrabble-app-shell") {
t.Fatalf("GET %s did not serve the app shell (status %d)", prefix, resp.StatusCode)
}
// A deep link falls back to the shell so the hash router can take over.
if resp := get(t, h, prefix+"game/abc"); resp.StatusCode != http.StatusOK || !strings.Contains(body(t, resp), "scrabble-app-shell") {
t.Fatalf("GET %sgame/abc did not fall back to the app shell (status %d)", prefix, resp.StatusCode)
}
// A hash-named asset is served directly and marked immutable.
resp := get(t, h, prefix+"assets/.gitkeep")
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
t.Fatalf("GET /game/abc/deep status = %d, want 200 (SPA fallback)", resp.StatusCode) t.Fatalf("GET %sassets/.gitkeep status = %d, want 200", prefix, resp.StatusCode)
} }
body, _ := io.ReadAll(resp.Body) if cc := resp.Header.Get("Cache-Control"); !strings.Contains(cc, "immutable") {
if !strings.Contains(string(body), "<html") { t.Errorf("asset Cache-Control = %q, want immutable", cc)
t.Fatalf("fallback body is not the index HTML: %q", body)
}
}
func TestHandlerStripsPrefix(t *testing.T) {
h := Handler("/telegram/")
for _, target := range []string{"/telegram/", "/telegram/assets/.gitkeep", "/telegram/lobby/x"} {
if resp := get(t, h, target); resp.StatusCode != http.StatusOK {
t.Fatalf("GET %s status = %d, want 200", target, resp.StatusCode)
} }
} }
} }
+5 -1
View File
@@ -29,7 +29,11 @@ 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). share-to-Telegram deep-link base (Stage 9). `VITE_TELEGRAM_LINK_EN` / `VITE_TELEGRAM_LINK_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
`/telegram/`) and a lightweight landing page (`landing.html`, served at `/`).
## How it talks to the gateway ## How it talks to the gateway
+14
View File
@@ -0,0 +1,14 @@
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 }) => {
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 language switch flips the copy to Russian (reusing the app i18n).
await page.getByRole('button', { name: 'Русский' }).click();
await expect(page.getByRole('link', { name: /Играть в браузере/ })).toBeVisible();
});
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- A normal scrollable page (no board, no Telegram SDK), so allow the browser's zoom. -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Scrabble</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/landing.ts"></script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1000px" height="1000px" viewBox="0 0 1000 1000" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 53.2 (72643) - https://sketchapp.com -->
<title>Artboard</title>
<desc>Created with Sketch.</desc>
<defs>
<linearGradient x1="50%" y1="0%" x2="50%" y2="99.2583404%" id="linearGradient-1">
<stop stop-color="#2AABEE" offset="0%"></stop>
<stop stop-color="#229ED9" offset="100%"></stop>
</linearGradient>
</defs>
<g id="Artboard" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<circle id="Oval" fill="url(#linearGradient-1)" cx="500" cy="500" r="500"></circle>
<path d="M226.328419,494.722069 C372.088573,431.216685 469.284839,389.350049 517.917216,369.122161 C656.772535,311.36743 685.625481,301.334815 704.431427,301.003532 C708.567621,300.93067 717.815839,301.955743 723.806446,306.816707 C728.864797,310.92121 730.256552,316.46581 730.922551,320.357329 C731.588551,324.248848 732.417879,333.113828 731.758626,340.040666 C724.234007,419.102486 691.675104,610.964674 675.110982,699.515267 C668.10208,736.984342 654.301336,749.547532 640.940618,750.777006 C611.904684,753.448938 589.856115,731.588035 561.733393,713.153237 C517.726886,684.306416 492.866009,666.349181 450.150074,638.200013 C400.78442,605.66878 432.786119,587.789048 460.919462,558.568563 C468.282091,550.921423 596.21508,434.556479 598.691227,424.000355 C599.00091,422.680135 599.288312,417.758981 596.36474,415.160431 C593.441168,412.561881 589.126229,413.450484 586.012448,414.157198 C581.598758,415.158943 511.297793,461.625274 375.109553,553.556189 C355.154858,567.258623 337.080515,573.934908 320.886524,573.585046 C303.033948,573.199351 268.692754,563.490928 243.163606,555.192408 C211.851067,545.013936 186.964484,539.632504 189.131547,522.346309 C190.260287,513.342589 202.659244,504.134509 226.328419,494.722069 Z" id="Path-3" fill="#FFFFFF"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+236
View File
@@ -0,0 +1,236 @@
<script lang="ts">
import { onMount } from 'svelte';
import { applyReduceMotion, applyTheme, type ThemePref } from './lib/theme';
import { i18n, localeFrom, setLocale, t, type Locale, type MessageKey } from './lib/i18n/index.svelte';
import { loadPrefs, savePrefs, type Prefs } from './lib/session';
import { aboutContent } from './lib/aboutContent';
import { telegramBotLink } from './lib/landing';
// Standalone landing page (Stage 17): the public entry at "/", separate from the game SPA
// (served at /app/ and /telegram/). It reuses the app's theme/i18n/prefs leaf modules — but
// not the app store — so it stays light (no gateway, auth or live stream).
const themes: ThemePref[] = ['auto', 'light', 'dark'];
const themeLabel: Record<ThemePref, MessageKey> = {
auto: 'settings.themeAuto',
light: 'settings.themeLight',
dark: 'settings.themeDark',
};
const locales: Locale[] = ['en', 'ru'];
let theme = $state<ThemePref>('auto');
let prefs: Partial<Prefs> = {};
// The away/move clock the random-game copy mentions (backend game.DefaultTurnTimeout = 24h).
const about = $derived(aboutContent(i18n.locale, 24));
const tgLink = $derived(telegramBotLink(i18n.locale));
onMount(async () => {
prefs = await loadPrefs();
theme = prefs.theme ?? 'auto';
applyTheme(theme);
applyReduceMotion(prefs.reduceMotion ?? false);
setLocale(prefs.locale ?? localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en'));
});
function persist(): void {
// savePrefs takes the full set, so keep the labels/lines the app may have stored.
void savePrefs({
theme,
locale: i18n.locale,
reduceMotion: prefs.reduceMotion ?? false,
boardLabels: prefs.boardLabels ?? 'beginner',
boardLines: prefs.boardLines ?? false,
});
}
function chooseTheme(th: ThemePref): void {
theme = th;
applyTheme(th);
persist();
}
function chooseLocale(lc: Locale): void {
setLocale(lc);
persist();
}
</script>
<main class="landing">
<header class="bar">
<div class="seg">
{#each locales as lc (lc)}
<button class="opt" class:active={i18n.locale === lc} onclick={() => chooseLocale(lc)}>
{t(lc === 'en' ? 'lang.en' : 'lang.ru')}
</button>
{/each}
</div>
<div class="seg">
{#each themes as th (th)}
<button class="opt" class:active={theme === th} onclick={() => chooseTheme(th)}>
{t(themeLabel[th])}
</button>
{/each}
</div>
</header>
<section class="hero">
<h1>{about.title}</h1>
<p class="tagline">{t('landing.tagline')}</p>
<div class="cta">
<a class="play primary" href="/app/">{t('landing.playWeb')}</a>
{#if tgLink}
<a class="play tg" href={tgLink} target="_blank" rel="noopener noreferrer">
<img src="telegram-logo.svg" alt="" width="22" height="22" />
{t('landing.playTelegram')}
</a>
{/if}
</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">{t('about.version', { v: __APP_VERSION__ })}</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;
gap: 10px;
flex-wrap: wrap;
}
.seg {
display: flex;
gap: 6px;
}
.opt {
padding: 7px 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
user-select: none;
font-size: 0.85rem;
}
.opt.active {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.hero {
text-align: center;
display: flex;
flex-direction: column;
gap: 14px;
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;
}
.cta {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
margin-top: 6px;
}
.play {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 12px 22px;
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);
}
.play.tg {
background: var(--surface);
color: var(--text);
}
.play.tg img {
display: block;
}
.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;
color: var(--text);
}
.ft {
margin-top: auto;
text-align: center;
color: var(--text-muted);
font-size: 0.8rem;
}
</style>
+7
View File
@@ -0,0 +1,7 @@
import { mount } from 'svelte';
import './app.css';
import Landing from './Landing.svelte';
// Entry for the standalone landing page (served at "/" by the gateway; the game SPA lives at
// /app/ and /telegram/). Mounts into the same #app node as the SPA's main.ts.
export default mount(Landing, { target: document.getElementById('app')! });
+4
View File
@@ -152,6 +152,10 @@ export const en = {
'about.description': 'A multiplatform Scrabble game.', 'about.description': 'A multiplatform Scrabble game.',
'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.playWeb': 'Play in browser',
'landing.playTelegram': 'Play in Telegram',
'lang.en': 'English', 'lang.en': 'English',
'lang.ru': 'Русский', 'lang.ru': 'Русский',
+4
View File
@@ -153,6 +153,10 @@ export const ru: Record<MessageKey, string> = {
'about.description': 'Мультиплатформенная игра в скрабл.', 'about.description': 'Мультиплатформенная игра в скрабл.',
'about.version': 'Версия {v}', 'about.version': 'Версия {v}',
'landing.tagline': 'Играй в Скрэббл с друзьями или умным роботом — в браузере или в Telegram.',
'landing.playWeb': 'Играть в браузере',
'landing.playTelegram': 'Играть в Telegram',
'lang.en': 'English', 'lang.en': 'English',
'lang.ru': 'Русский', 'lang.ru': 'Русский',
+20
View File
@@ -0,0 +1,20 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { telegramBotLink } from './landing';
describe('telegramBotLink', () => {
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('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();
});
});
+16
View File
@@ -0,0 +1,16 @@
// 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.
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.
*/
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;
}
+10
View File
@@ -1,3 +1,4 @@
import { resolve } from 'node:path';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte'; import { svelte } from '@sveltejs/vite-plugin-svelte';
@@ -34,5 +35,14 @@ export default defineConfig(({ mode }) => ({
build: { build: {
target: 'es2022', target: 'es2022',
sourcemap: true, sourcemap: true,
// Two entries (Stage 17): the game SPA (index.html, served at /app/ + /telegram/) and the
// public landing page (landing.html, served at /). Assets are shared in dist/assets/, and
// the relative base lets one build serve under any path.
rollupOptions: {
input: {
main: resolve(import.meta.dirname, 'index.html'),
landing: resolve(import.meta.dirname, 'landing.html'),
},
},
}, },
})); }));