diff --git a/PLAN.md b/PLAN.md index c4214b1..fee5f9b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1428,9 +1428,9 @@ cannot submit; three-way admin filter. docker-network caddy hop `172.18.0.x` for chat moderation, and bucketing the gateway's per-IP rate limiter on it). Correct + spoof-safe in **both** contours (prod has no host caddy → public clients untrusted → real peer used). `peerIP` unit-tested. - - **Ad banner** gated **off** behind a compile-time `SHOW_AD_BANNER=false` in `Screen.svelte` - — the `{#if}` branch, the `AdBanner` import and `banner.ts` are tree-shaken out of the prod - bundle (code kept for post-release polish). + - **Ad banner** was gated off here behind `SHOW_AD_BANNER=false`; the **AD** phase + (PRERELEASE.md) later turned it into the real server-driven advertising network and removed + the gate (`Screen` renders it from `app.profile.banner`). - **Landing** Telegram entry is now just the **64px logo** (clickable, no button/caption). - **TG-fullscreen header** reworked again: title + menu are one **centred pair** (hamburger right of the title) pinned to the **bottom** of the TG nav band, lining up with Telegram's diff --git a/PRERELEASE.md b/PRERELEASE.md index 9bf751a..accdc65 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -32,7 +32,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l | DA | Dictionary admin: online release-archive upload → word-diff preview → install/activate; versioned dict volume; active version persisted in DB; resident label = release tag | owner ad-hoc | **done** | | AB | Manual account block (admin suspension): permanent/temporary with an editable en+ru reason picklist; a block forfeits the player's active games + cancels their open ones; a backend gate refuses a blocked account with **403 `account_blocked`**; the UI shows a terminal blocked screen and stops all push/poll; manual unblock; temporary blocks self-expire (migration `00003`) | owner ad-hoc | **done** | | AI | Honest AI opponent in quick game: an explicit 🤖 AI / 👤 random selector (AI default); the robot is seated and moves at once; 7-day inactivity loss (the per-turn timeout reused); chat/nudge disabled, no statistics; the opponent is shown as 🤖 everywhere | owner ad-hoc | **done** | -| AD | Advertising banner ("ad network"): server-driven weighted campaigns (percent weight + validity window; the perpetual default fills the remainder up to 100%), bilingual messages shown by bot (`service_language`); eligibility = free account + empty hint wallet + no `no_banner` role (guests included); the resolved feed rides `profile.get` with a `notify` `banner` re-poll on eligibility change; `/_gm/banners` admin + global display timings; client smooth-weighted-round-robin rotation + fade-out/gap/fade-in UX. A single `app.load` bootstrap aggregator was considered and **deferred** (see ARCHITECTURE §10). | owner ad-hoc | PR1 backend+admin **done**; PR2 UI rotation **next** | +| AD | Advertising banner ("ad network"): server-driven weighted campaigns (percent weight + validity window; the perpetual default fills the remainder up to 100%), bilingual messages shown by bot (`service_language`); eligibility = free account + empty hint wallet + no `no_banner` role (guests included); the resolved feed rides `profile.get` with a `notify` `banner` re-poll on eligibility change; `/_gm/banners` admin + global display timings; client smooth-weighted-round-robin rotation + fade-out/gap/fade-in UX. A single `app.load` bootstrap aggregator was considered and **deferred** (see ARCHITECTURE §10). | owner ad-hoc | **done** (PR1 backend+admin, PR2 UI rotation) | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | ## Key findings (these reshaped the raw list — read before starting a phase) diff --git a/backend/internal/adminconsole/assets/console.css b/backend/internal/adminconsole/assets/console.css index 8002a48..2f53bcc 100644 --- a/backend/internal/adminconsole/assets/console.css +++ b/backend/internal/adminconsole/assets/console.css @@ -86,6 +86,22 @@ h1 { font-size: 1.4rem; margin: 0 0 0.4rem; } font: inherit; } .form textarea { min-height: 4rem; resize: vertical; } +/* A static help aside (e.g. message-formatting hints) beside an intro note at the top of a + section: the note fills the row, the aside takes ~40% and both wrap on a narrow viewport. */ +.help-top { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start; margin: 0.2rem 0 0.6rem; } +.help-top .note { flex: 1 1 16rem; margin: 0; } +.help { + flex: 0 1 40%; + min-width: 16rem; + background: var(--panel-hi); + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.5rem 0.8rem; + font-size: 0.85rem; + color: var(--ink-dim); +} +.help h4 { margin: 0 0 0.4rem; font-size: 0.85rem; color: var(--ink); } +.help p { margin: 0.35rem 0; } button { background: var(--accent); color: #06121f; diff --git a/backend/internal/adminconsole/templates/pages/banner_detail.gohtml b/backend/internal/adminconsole/templates/pages/banner_detail.gohtml index 90b154d..aeeb1dc 100644 --- a/backend/internal/adminconsole/templates/pages/banner_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/banner_detail.gohtml @@ -21,12 +21,21 @@ {{end}}

Messages

+

Each message must be filled in both languages; the viewer sees the variant for the bot they play through. The messages of a campaign share its show weight, rotating in this order.

+ +
{{range .Messages}}
- - + +
@@ -40,8 +49,8 @@ {{else}}

no messages yet

{{end}}

Add message

- - + +
diff --git a/backend/internal/inttest/banner_console_test.go b/backend/internal/inttest/banner_console_test.go index 67ee9fa..8c33350 100644 --- a/backend/internal/inttest/banner_console_test.go +++ b/backend/internal/inttest/banner_console_test.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "net/http" + "net/http/httptest" "strings" "testing" @@ -247,3 +248,28 @@ func TestBannerProfileEligibility(t *testing.T) { t.Fatal("a non-empty hint wallet still shows the banner") } } + +// TestBannerSurvivesProfileUpdate guards that a profile update (e.g. a language switch) returns the +// banner block too, so the client's profile keeps the banner instead of losing it until reload. +func TestBannerSurvivesProfileUpdate(t *testing.T) { + srv, _ := bannerServer(t) + id := provisionAccount(t) + + body := `{"display_name":"Tester","preferred_language":"ru","time_zone":"UTC","away_start":"00:00",` + + `"away_end":"00:00","block_chat":false,"block_friend_requests":false,"notifications_in_app_only":true}` + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/v1/user/profile", strings.NewReader(body)) + req.Header.Set("X-User-ID", id.String()) + req.Header.Set("Content-Type", "application/json") + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("update profile = %d: %s", rec.Code, rec.Body.String()) + } + var p profileBanner + if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil { + t.Fatalf("decode: %v", err) + } + if p.Banner == nil || len(p.Banner.Campaigns) == 0 { + t.Fatalf("profile update dropped the banner block: %v", p.Banner) + } +} diff --git a/backend/internal/server/banner.go b/backend/internal/server/banner.go index 1fa0f94..af91626 100644 --- a/backend/internal/server/banner.go +++ b/backend/internal/server/banner.go @@ -38,6 +38,16 @@ type bannerTimingsDTO struct { FadeInMs int `json:"fade_in_ms"` } +// profileResponse builds the account's profile DTO with the advertising-banner block attached when +// the viewer is eligible. Every endpoint returning the caller's own profile (get, update, link) +// uses it, so the banner never drops off the client's profile after a non-get profile change (e.g. +// a language switch). +func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse { + r := profileResponseFor(acc) + r.Banner = s.bannerFor(ctx, acc) + return r +} + // bannerFor builds the advertising-banner block for the account's profile, or // nil when the ads service is not configured or the viewer is not eligible to // see a banner. The message language follows the account's bot (service) diff --git a/backend/internal/server/handlers_account.go b/backend/internal/server/handlers_account.go index 1bd974a..55ae838 100644 --- a/backend/internal/server/handlers_account.go +++ b/backend/internal/server/handlers_account.go @@ -83,7 +83,7 @@ func (s *Server) handleUpdateProfile(c *gin.Context) { s.abortErr(c, err) return } - c.JSON(http.StatusOK, profileResponseFor(acc)) + c.JSON(http.StatusOK, s.profileResponse(c.Request.Context(), acc)) } // blockStatusResponse reports the caller's current block to the client. Until is an RFC3339 UTC diff --git a/backend/internal/server/handlers_link.go b/backend/internal/server/handlers_link.go index 27a318d..de37628 100644 --- a/backend/internal/server/handlers_link.go +++ b/backend/internal/server/handlers_link.go @@ -180,7 +180,7 @@ func (s *Server) profileFor(ctx context.Context, id uuid.UUID) *profileResponse if err != nil { return nil } - p := profileResponseFor(acc) + p := s.profileResponse(ctx, acc) return &p } diff --git a/backend/internal/server/handlers_user.go b/backend/internal/server/handlers_user.go index e46e408..bd5973a 100644 --- a/backend/internal/server/handlers_user.go +++ b/backend/internal/server/handlers_user.go @@ -23,9 +23,7 @@ func (s *Server) handleProfile(c *gin.Context) { s.abortErr(c, err) return } - resp := profileResponseFor(acc) - resp.Banner = s.bannerFor(c.Request.Context(), acc) - c.JSON(http.StatusOK, resp) + c.JSON(http.StatusOK, s.profileResponse(c.Request.Context(), acc)) } // submitPlayRequest places tiles on the player's turn; the engine infers the diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 83b1a85..f95410b 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -12,12 +12,13 @@ runtime; opened outside Telegram, the `/telegram/` path redirects to the site ro ## Layout shell (`components/Screen.svelte`) -A full-height flex column: the nav bar, the announcement strip, the content, and an -optional bottom tab bar (the tab bar always sits at the screen bottom). On most screens -the nav is minimal and the **content fills** between nav and tab bar. **Only in the -game** (`growNav`) does the nav bar grow to absorb spare height (buttons top-aligned), -pinning the board and controls to the **bottom** for thumb reach. Every screen except -Login uses `Screen`. +A full-height flex column: the nav bar, the content, and an optional bottom tab bar (the +tab bar always sits at the screen bottom). The **advertising strip** is docked **inside the +nav** (`Header`), directly under the title, so it sits in the same place on every screen. On +most screens the nav is minimal and the **content fills** between nav and tab bar. **Only in +the game** (`growNav`) does the nav bar grow to absorb spare height, so the strip stays under +the title while the board and controls pin to the **bottom** for thumb reach. Every screen +except Login uses `Screen`. ## Navigation @@ -195,16 +196,34 @@ Login uses `Screen`. under-board slot shows the **Scores: N** preview. The screen **title** is the variant's display name (Scrabble / Скрэббл / Erudite / Эрудит), not a constant "Scrabble". -## Announcement banner (`components/AdBanner.svelte`, `lib/banner.ts`) +## Advertising banner (`components/AdBanner.svelte` in `components/Header.svelte`, `lib/banner.ts` + `lib/bannerEngine.ts`) -A one-line inset strip under the nav bar, drawn on a dedicated `--ad-bg` token — -a subtle accent, a touch darker than the surroundings in the light theme and a touch lighter -in the dark theme, mapped to Telegram's `secondary_bg_color` inside the Mini App. Content is -minimal markdown (text + links, escaped + linkified). A parameterised **rotator** drives messages: a fitting message -holds `holdMs` (default 60 s) then cross-fades to the next; a message wider than the strip -pauses (`edgePauseMs`), scrolls to its right edge at `scrollPxPerSec`, pauses, and repeats -until the cycle exceeds `holdMs`. Today a **mock** provider rotates a long and a short -message; the source becomes a server-driven channel later (see ARCHITECTURE). +A one-line inset strip docked inside the nav bar, directly under the title (so its position never +jumps between screens), drawn on a dedicated `--ad-bg` token — a subtle accent, a touch darker than +the surroundings in the light theme and a touch lighter in the dark theme, mapped to Telegram's +`secondary_bg_color` inside the Mini App. Content is minimal markdown (text + links, escaped + +linkified). It is **server-driven**: the campaigns and display timings ride the `profile.get` +response (`app.profile.banner`, present only for an eligible viewer — see ARCHITECTURE §10), so +`Header` renders the strip only when that block is present, and a `notify` `banner` event re-fetches +the profile to show or hide it in place. + +The rotation runs in a **persistent module engine** (`lib/bannerEngine`): the scheduler and timer +live outside the components, so navigating between screens (which remounts the view) **continues the +cycle** rather than restarting it. A mounted `AdBanner` reads the engine's live message at init and +renders it **immediately, without a fade** (`bannerCurrent`), and attaches only as the DOM host — so +a screen change does not replay the fade; only a real message advance fades. A **smooth weighted +round-robin** (`createScheduler`) picks the next message: campaigns compete by their weight (each +appears its weight share per cycle, evenly interleaved, not at random), and a campaign's own messages +advance round-robin. One message fades in (`fadeInMs`), holds `holdMs` (a message wider than the +strip pauses `edgePauseMs`, scrolls to its right edge at `scrollPxPerSec`, pauses, and repeats while +under `holdMs`), then — with more than one message — fades out (`fadeOutMs`), waits `gapMs`, and the +next fades in. The fade (opacity on a `.fadewrap` layer) is independent of the scroll (the inner +track's transform), so a long, scrolling message still fades at both ends. A lone message stays put +(a long one keeps scrolling). A **viewport size change** (e.g. a portrait↔landscape rotation) +re-measures the current message (`remeasureBanner`, debounced), so its scroll re-evaluates for the +new width. All timings are operator-set (`/_gm/banner-settings`). Under **reduce-motion** the fades +collapse to an instant swap +and a long message does not scroll. ## Result / status iconography (`lib/result.ts`) diff --git a/ui/src/components/AdBanner.svelte b/ui/src/components/AdBanner.svelte index b9e37fc..2a527fa 100644 --- a/ui/src/components/AdBanner.svelte +++ b/ui/src/components/AdBanner.svelte @@ -1,43 +1,114 @@
- {#key current} -
- {@html linkify(items[current]?.md ?? '')} + + + {#if visible} +
+
+ {@html linkify(current)} +
- {/key} + {/if}