Stage 17 round 6 (#16-20): landing page, /app/ move, cache + stream fixes
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 55s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 55s
Close out Stage 17 round 6: - Landing page at / — one Vite build with two entries (index.html = game SPA, landing.html = a lightweight landing reusing the theme/i18n/ aboutContent leaf modules, not the app store). - Move the web game SPA to /app/; the Telegram Mini App stays at /telegram/ (gateway webui.Handler(stripPrefix, indexName): landing at /, SPA at /app/ + /telegram/). Per-language "Play in Telegram" link via new VITE_TELEGRAM_LINK_EN/_RU build vars (button hides when unset). - Cache headers: hash-named /assets/* immutable, HTML shells no-cache (the go:embed zero modtime emitted no validators, so the client re-downloaded the whole bundle every launch). - Live-stream 15s abort fix: an immediate heartbeat on open + a 10s default interval (the first tick at 15s raced the edge idle timeout -> reconnect storm). PLAN/ARCHITECTURE(§13)/FUNCTIONAL(+ru)/gateway+ui+deploy READMEs updated; round 6 closed. Tests: gateway webui/connectsrv units, ui landing unit + e2e, full e2e (60) green.
This commit is contained in:
@@ -267,6 +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_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.
|
||||
|
||||
@@ -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
|
||||
committed move, clears the actor's own draft and resets any opponent's board draft whose cell the play
|
||||
overlapped — 5 integration tests.
|
||||
- **Stage 17 round 6 — REMAINING (next pass), designs ready:**
|
||||
1. **Persistence gateway slice + UI (#4/#5/#6).** *FB (lean):* `DraftRequest{game_id, json}` (save) +
|
||||
a game-id request (get) + `DraftView{json}` — one string field; the client serializes/deserializes
|
||||
`{rack_order, board_tiles}` itself (no FB tile array). Regen Go (`make -C pkg fbs`) + TS
|
||||
(`pnpm codegen`); flatc is pinned **23.5.26** (the local one matches). *Gateway* forwards the JSON as
|
||||
`json.RawMessage` (no double-encode). *REST:* `GET`/`PUT /games/:id/draft` (decodes `game.Draft`).
|
||||
*UI:* save the rack order (#4) and board draft (#6) on change (debounced) and restore on load
|
||||
(next to `gameState`); **#5** — allow placing tiles on the opponent's turn (relax the `isMyTurn`
|
||||
gate on placement only; the evaluate-preview and Make-move stay your-turn-only, so an off-turn draft
|
||||
is position-only — never scored/submitted).
|
||||
2. **Landing + `/app/` move (#16–20).** An extra Svelte page at `/`, the game SPA under `/app/` (Vite
|
||||
`base` conditional: `/app/` for web, `./` for Capacitor); the gateway serves the landing at `/` and
|
||||
the SPA at `/app/*`; a bundled Telegram logo (from `.claude/telegram-logo.svg`, **copied into
|
||||
`ui/public/`, the reference itself not committed**) linking to the per-language t.me bot (ru
|
||||
`Erudit_Game` / en `Scrabble_Game`); theme + language switchers reusing the app stores; reuse the
|
||||
`aboutContent` copy. **Note:** moving the game to `/app/` means the Telegram Mini App URL must point
|
||||
to `/app/`.
|
||||
- **Stage 17 round 6 — final pass (#4/#5/#6 + #16–20), shipped:**
|
||||
1. **Draft persistence — gateway slice + UI (#4/#5/#6, PR #20).** FB `DraftRequest{game_id, json}`
|
||||
(save) + `DraftView{json}` (get reuses `GameActionRequest`); the client serializes
|
||||
`{rack_order, board_tiles}` itself (no FB tile array), the gateway forwards it as `json.RawMessage`
|
||||
both ways (no double-encode), and `GET`/`PUT /games/:id/draft` (a server `draftDTO` ↔ `game.Draft`)
|
||||
is the only place that reads the shape. UI: debounced save of the rack order (#4) + board draft (#6)
|
||||
and restore on load (`lib/draft.ts`, reconciling against the committed board); **#5** — tiles may be
|
||||
arranged on the opponent's turn (placement relaxed; the preview and Make-move stay your-turn-only,
|
||||
so an off-turn draft is position-only). Off-turn tiles keep the **existing pending highlight** — no
|
||||
caption, no new style (owner's call). The backend draft endpoint is sub-ms.
|
||||
2. **Landing + `/app/` move (#16–20, this PR).** One Vite build with **two HTML entries** — the game
|
||||
SPA (`index.html`) and a new lightweight landing (`landing.html` → `Landing.svelte`, reusing the
|
||||
theme/i18n/`aboutContent` leaf modules, not the app store, so it stays small). The gateway serves the
|
||||
**landing at `/`** and the **game SPA at `/app/` and `/telegram/`** (`webui.Handler(stripPrefix,
|
||||
indexName)`); relative base keeps one build serving every mount with a shared `dist/assets/` (the
|
||||
planned per-target `base` conditional proved unnecessary). **Correction to the original note:** the
|
||||
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)
|
||||
|
||||
|
||||
@@ -25,6 +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_GATEWAY_URL=
|
||||
|
||||
# --- Gateway ----------------------------------------------------------------
|
||||
|
||||
+4
-2
@@ -12,7 +12,7 @@ operational reference for **every environment variable**.
|
||||
| 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`. |
|
||||
| `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. |
|
||||
| `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`. |
|
||||
@@ -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). |
|
||||
| `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_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). |
|
||||
|
||||
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.
|
||||
|
||||
## Fixed internal wiring (not operator-set)
|
||||
|
||||
@@ -78,6 +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_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
|
||||
VITE_APP_VERSION: ${APP_VERSION:-dev}
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -573,13 +573,16 @@ a dedicated redeem sub-limit or a longer code is the hardening step if abuse app
|
||||
## 13. Deployment (informational)
|
||||
|
||||
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
|
||||
SPA at both `/` (web) and `/telegram/` (the Telegram Mini App; outside Telegram that
|
||||
path redirects to the root — the client-side guard). An in-compose **caddy** is the
|
||||
(`go:embed`, baked in by a node stage in `gateway/Dockerfile`). The Vite build has two
|
||||
entries: a lightweight **landing page** served at `/`, and the game **SPA** served at
|
||||
`/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
|
||||
**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**;
|
||||
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
|
||||
long-polls Telegram and egresses through a VPN sidecar, answering only internal gRPC.
|
||||
|
||||
|
||||
+8
-1
@@ -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
|
||||
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).
|
||||
|
||||
### Identity & sessions *(Stage 1 / 6 / 9 / 15)*
|
||||
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
|
||||
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
|
||||
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)*
|
||||
When auto-match finds no human within ten seconds, a robot opponent takes the empty
|
||||
|
||||
@@ -22,6 +22,9 @@ top-1 подсказку, безлимитную проверку слова с
|
||||
доску** — игрок сам решает сделать ход, и подсказка не тратится, если ходов нет.
|
||||
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
|
||||
и ограничивает частоту повторов.
|
||||
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
|
||||
тему и ведёт в веб-приложение или в соответствующего Telegram-бота; сама игра живёт по
|
||||
адресам `/app/` (веб) и `/telegram/` (Telegram Mini App).
|
||||
|
||||
### Личность и сессии *(Stage 1 / 6 / 9 / 15)*
|
||||
Игрок приходит с платформы (сначала Telegram), через email-вход или как
|
||||
@@ -85,7 +88,11 @@ Mini App** авторизует по подписанным `initData` плат
|
||||
место вышедшего убирается, остальные играют дальше, и партия завершается, когда
|
||||
остаётся один активный игрок; что делать с фишками вышедшего (вернуть в мешок или
|
||||
убрать из игры) выбирается при создании партии, а его стойка никогда не
|
||||
показывается остальным.
|
||||
показывается остальным. **Композиция на доске сохраняется по партии**: расположение
|
||||
фишек на стойке и выложенные, но не отправленные фишки сохраняются по мере составления
|
||||
хода и восстанавливаются при возврате (в том числе на другом устройстве); игрок может
|
||||
**раскладывать фишки и в ход соперника**, но такой черновик только позиционный —
|
||||
предпросмотр счёта и отправка доступны лишь в собственный ход.
|
||||
|
||||
### Робот-соперник *(Stage 5)*
|
||||
Если авто-подбор не находит человека за десять секунд, свободное место занимает
|
||||
|
||||
@@ -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").
|
||||
ARG VITE_TELEGRAM_BOT_ID=
|
||||
ARG VITE_TELEGRAM_LINK=
|
||||
ARG VITE_TELEGRAM_LINK_EN=
|
||||
ARG VITE_TELEGRAM_LINK_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_GATEWAY_URL=$VITE_GATEWAY_URL \
|
||||
VITE_APP_VERSION=$VITE_APP_VERSION
|
||||
|
||||
|
||||
+5
-4
@@ -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
|
||||
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
|
||||
in by the gateway image's node stage) and serves the one SPA at `/` (web) and
|
||||
`/telegram/` (the Mini App) — the single-origin model. It can also serve the
|
||||
in by the gateway image's node stage) and serves a **landing page** at `/` and the game
|
||||
**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;
|
||||
in the deployed contour the front caddy owns `/_gm` (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/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/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
|
||||
@@ -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_SESSION_TTL` | `10m` | cached session lifetime |
|
||||
| `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_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from `OTEL_EXPORTER_OTLP_*`) |
|
||||
| `GATEWAY_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp` |
|
||||
|
||||
@@ -73,7 +73,7 @@ const (
|
||||
defaultBackendTimeout = 5 * time.Second
|
||||
defaultSessionTTL = 10 * time.Minute
|
||||
defaultSessionCacheMax = 50000
|
||||
defaultPushHeartbeatInterval = 15 * time.Second
|
||||
defaultPushHeartbeatInterval = 10 * time.Second // under the ~15 s edge idle timeout (Stage 17)
|
||||
defaultServiceName = "scrabble-gateway"
|
||||
)
|
||||
|
||||
|
||||
@@ -99,12 +99,14 @@ func (s *Server) HTTPHandler() http.Handler {
|
||||
// does not serve the app shell at the operator path.
|
||||
mux.Handle("/_gm/", http.NotFoundHandler())
|
||||
}
|
||||
// The embedded single-page UI is served at the site root and, for the Telegram
|
||||
// Mini App, under /telegram/ — the single-origin model (docs/ARCHITECTURE.md
|
||||
// §13). Both mounts sit below the h2c wrap so the Connect edge (a more specific
|
||||
// prefix) keeps priority; "/" is the catch-all SPA fallback for the hash router.
|
||||
mux.Handle("/telegram/", webui.Handler("/telegram/"))
|
||||
mux.Handle("/", webui.Handler(""))
|
||||
// The embedded UI: the game SPA under /app/ (web) and /telegram/ (the Telegram Mini
|
||||
// App), with a separate landing page at the catch-all "/" — the single-origin model
|
||||
// (docs/ARCHITECTURE.md §13). All sit below the h2c wrap so the Connect edge (a more
|
||||
// specific prefix) keeps priority. Each SPA mount falls back to the app shell
|
||||
// (index.html) for the hash router; "/" falls back to the landing (landing.html).
|
||||
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{})
|
||||
}
|
||||
|
||||
@@ -184,6 +186,14 @@ func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.Subs
|
||||
events, cancel := s.hub.Subscribe(uid)
|
||||
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)
|
||||
defer ticker.Stop()
|
||||
|
||||
|
||||
+4
-3
@@ -6,10 +6,11 @@
|
||||
<title>Scrabble</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- scrabble-app-shell -->
|
||||
<p>
|
||||
UI 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.
|
||||
App shell 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>
|
||||
|
||||
+16
@@ -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>
|
||||
@@ -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
|
||||
// compiles with a plain `go build` (and in CI) without a UI build. The production
|
||||
// The committed dist/ holds only placeholder index.html / landing.html so the gateway
|
||||
// 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/Dockerfile), so the binary ships the UI inside it. Because Vite is built
|
||||
// with a relative asset base, one build serves under any path: Handler is mounted
|
||||
// both at "/" (web) and at "/telegram/" (the Telegram Mini App), matching the
|
||||
// gateway/Dockerfile), so the binary ships the UI inside it. Because Vite is built with a
|
||||
// relative asset base, one build serves under any path: the game SPA is mounted at /app/
|
||||
// (web) and /telegram/ (the Telegram Mini App), with a separate landing page at / — the
|
||||
// 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
|
||||
|
||||
import (
|
||||
@@ -30,25 +34,30 @@ func distFS() fs.FS {
|
||||
return sub
|
||||
}
|
||||
|
||||
// Handler serves the embedded SPA. An existing file is served directly (with the
|
||||
// standard content-type and caching headers); every other path falls back to
|
||||
// index.html so the client-side hash router can take over a deep link. When
|
||||
// stripPrefix is non-empty it is removed from the request path before lookup, so
|
||||
// the same build serves under a sub-path (e.g. "/telegram/").
|
||||
func Handler(stripPrefix string) http.Handler {
|
||||
// Handler serves the embedded UI. An existing file is served directly (hash-named assets get
|
||||
// an immutable cache); every other path falls back to indexName (the SPA shell or the landing
|
||||
// page) so a client-side deep link still loads. When stripPrefix is non-empty it is removed
|
||||
// from the request path before lookup, so the same build serves under a sub-path (e.g.
|
||||
// "/app/" or "/telegram/").
|
||||
func Handler(stripPrefix, indexName string) http.Handler {
|
||||
content := distFS()
|
||||
files := http.FileServer(http.FS(content))
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
|
||||
if name == "" {
|
||||
serveIndex(w, content)
|
||||
serveIndex(w, content, indexName)
|
||||
return
|
||||
}
|
||||
if info, err := fs.Stat(content, name); err != nil || info.IsDir() {
|
||||
// Unknown path or a directory: serve the SPA shell, never a listing.
|
||||
serveIndex(w, content)
|
||||
// Unknown path or a directory: serve the shell, never a listing.
|
||||
serveIndex(w, content, indexName)
|
||||
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)
|
||||
})
|
||||
if p := strings.TrimSuffix(stripPrefix, "/"); p != "" {
|
||||
@@ -57,14 +66,16 @@ func Handler(stripPrefix string) http.Handler {
|
||||
return h
|
||||
}
|
||||
|
||||
// serveIndex writes the SPA shell with a 200 status, so a client-routed deep link
|
||||
// still loads the app rather than a 404.
|
||||
func serveIndex(w http.ResponseWriter, content fs.FS) {
|
||||
data, err := fs.ReadFile(content, "index.html")
|
||||
// serveIndex writes the named HTML shell with a 200 status, so a client-routed deep link
|
||||
// still loads the app rather than a 404. The shell is marked no-cache so a new deploy's
|
||||
// shell (and the asset URLs it references) is fetched fresh.
|
||||
func serveIndex(w http.ResponseWriter, content fs.FS, indexName string) {
|
||||
data, err := fs.ReadFile(content, indexName)
|
||||
if err != nil {
|
||||
http.Error(w, "ui not built", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
|
||||
@@ -16,37 +16,50 @@ func get(t *testing.T, h http.Handler, target string) *http.Response {
|
||||
return rec.Result()
|
||||
}
|
||||
|
||||
func TestHandlerServesIndexAndFallsBack(t *testing.T) {
|
||||
h := Handler("")
|
||||
func body(t *testing.T, resp *http.Response) string {
|
||||
t.Helper()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// The embedded placeholder index is served at the root.
|
||||
if resp := get(t, h, "/"); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET / status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
// TestLandingMountServesLandingAndFallsBack: "/" serves the landing shell (no-cache) and
|
||||
// any unknown path falls back to it.
|
||||
func TestLandingMountServesLandingAndFallsBack(t *testing.T) {
|
||||
h := Handler("", "landing.html")
|
||||
|
||||
// An existing (non-index) file is served directly by the file server.
|
||||
if resp := get(t, h, "/assets/.gitkeep"); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET /assets/.gitkeep status = %d, want 200 (served file)", resp.StatusCode)
|
||||
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)
|
||||
}
|
||||
|
||||
// An unknown deep link falls back to the SPA shell (200, not 404) so the
|
||||
// client-side hash router can take over.
|
||||
resp := get(t, h, "/game/abc/deep")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET /game/abc/deep status = %d, want 200 (SPA fallback)", resp.StatusCode)
|
||||
if cc := get(t, h, "/").Header.Get("Cache-Control"); cc != "no-cache" {
|
||||
t.Errorf("landing Cache-Control = %q, want no-cache", cc)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(body), "<html") {
|
||||
t.Fatalf("fallback body is not the index HTML: %q", body)
|
||||
if resp := get(t, h, "/whatever"); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET /whatever status = %d, want 200 (fallback)", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerStripsPrefix(t *testing.T) {
|
||||
h := Handler("/telegram/")
|
||||
// TestAppMountServesShellStripsPrefixAndCachesAssets: "/app/" and "/telegram/" serve the app
|
||||
// shell (index.html), strip their prefix, fall back for deep links, and mark hash-named
|
||||
// assets immutable.
|
||||
func TestAppMountServesShellStripsPrefixAndCachesAssets(t *testing.T) {
|
||||
for _, prefix := range []string{"/app/", "/telegram/"} {
|
||||
h := Handler(prefix, "index.html")
|
||||
|
||||
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)
|
||||
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 {
|
||||
t.Fatalf("GET %sassets/.gitkeep status = %d, want 200", prefix, resp.StatusCode)
|
||||
}
|
||||
if cc := resp.Header.Get("Cache-Control"); !strings.Contains(cc, "immutable") {
|
||||
t.Errorf("asset Cache-Control = %q, want immutable", cc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -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)
|
||||
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).
|
||||
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
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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')! });
|
||||
@@ -152,6 +152,10 @@ export const en = {
|
||||
'about.description': 'A multiplatform Scrabble game.',
|
||||
'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.ru': 'Русский',
|
||||
|
||||
|
||||
@@ -153,6 +153,10 @@ export const ru: Record<MessageKey, string> = {
|
||||
'about.description': 'Мультиплатформенная игра в скрабл.',
|
||||
'about.version': 'Версия {v}',
|
||||
|
||||
'landing.tagline': 'Играй в Скрэббл с друзьями или умным роботом — в браузере или в Telegram.',
|
||||
'landing.playWeb': 'Играть в браузере',
|
||||
'landing.playTelegram': 'Играть в Telegram',
|
||||
|
||||
'lang.en': 'English',
|
||||
'lang.ru': 'Русский',
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
@@ -34,5 +35,14 @@ export default defineConfig(({ mode }) => ({
|
||||
build: {
|
||||
target: 'es2022',
|
||||
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'),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user