feat(banner): per-campaign colour overrides and urgent alerts
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s

Non-default campaigns gain an optional colour override (background / text /
link) in two sets — one for every theme, one for the dark theme only — and an
"urgent" flag.

- Colours ride profile.get as six trailing FlatBuffers strings on
  BannerCampaign (backward-compatible). The client resolves the cascade
  (dark <- dark ?? all, light <- all) per rendered theme and derives the strip
  border from the background in JS (no CSS color-mix, for the old Android
  WebView floor); AdBanner applies them as inline vars scoped to the strip.
- Urgent is resolved entirely server-side: while any enabled, in-window urgent
  campaign exists, computeActiveSet returns only the urgent campaigns and
  bannerFor skips the eligibility gate — so a system notice reaches every viewer
  (paid / hint-holding / no_banner included) and preempts the ordinary feed. No
  wire field; it appears on each viewer's next profile.get.
- Admin console (/_gm/banners): native colour pickers + a live light/dark
  preview of the strip, and an urgent toggle. The default campaign stays plain,
  enforced by the service and a DB CHECK.

Migration 00009 is additive (nullable colour columns + a bool default +
all-or-nothing / hex / default-plain CHECKs) — expand-contract, rollback-safe.

Docs: ARCHITECTURE §10, UI_DESIGN, FUNCTIONAL (+ru). Tests: ads unit (urgent
preempt + colour validation), codec + resolver unit, gateway transcode, and
integration (colour round-trip + urgent bypass against real Postgres).
This commit is contained in:
Ilia Denisov
2026-07-05 15:36:35 +02:00
parent ac383880b7
commit 6db9178449
32 changed files with 1297 additions and 152 deletions
@@ -19,6 +19,15 @@ const bannersBack = "/_gm/banners"
// bound; the operator's entry is interpreted as UTC.
const localTimeLayout = "2006-01-02T15:04"
// Neutral banner theme tokens, mirrored from ui/src/app.css (--ad-bg,
// --text-muted, --accent for the light and dark themes). They seed the console's
// colour pickers and the preview fallback when a campaign has no override, so the
// operator always edits against the real neutral colours.
const (
tokenLightBg, tokenLightFg, tokenLightLink = "#e3e7ee", "#6b7280", "#2f6df6"
tokenDarkBg, tokenDarkFg, tokenDarkLink = "#272f3c", "#9aa3b2", "#5b8cff"
)
// consoleBanners lists the advertising campaigns (default first), each with its
// weight, validity window, enabled flag, message count and live-now status.
func (s *Server) consoleBanners(c *gin.Context) {
@@ -56,14 +65,19 @@ func (s *Server) consoleBannerDetail(c *gin.Context) {
return
}
view := adminconsole.BannerDetailView{
ID: cm.ID.String(),
Name: cm.Name,
Weight: cm.Weight,
IsDefault: cm.IsDefault,
Enabled: cm.Enabled,
StartsAt: localInput(cm.StartsAt),
EndsAt: localInput(cm.EndsAt),
ID: cm.ID.String(),
Name: cm.Name,
Weight: cm.Weight,
IsDefault: cm.IsDefault,
Enabled: cm.Enabled,
StartsAt: localInput(cm.StartsAt),
EndsAt: localInput(cm.EndsAt),
Urgent: cm.Urgent,
OverrideAllOn: cm.OverrideAll != nil,
OverrideDarkOn: cm.OverrideDark != nil,
}
view.AllBg, view.AllFg, view.AllLink = seedColors(cm.OverrideAll, tokenLightBg, tokenLightFg, tokenLightLink)
view.DarkBg, view.DarkFg, view.DarkLink = seedColors(cm.OverrideDark, tokenDarkBg, tokenDarkFg, tokenDarkLink)
for i, m := range cm.Messages {
view.Messages = append(view.Messages, adminconsole.BannerMessageRow{
ID: m.ID.String(),
@@ -111,12 +125,15 @@ func (s *Server) consoleUpdateBanner(c *gin.Context) {
return
}
err = s.ads.UpdateCampaign(c.Request.Context(), ads.Campaign{
ID: id,
Name: trimForm(c, "name"),
Weight: atoiForm(c, "weight"),
Enabled: c.PostForm("enabled") != "",
StartsAt: starts,
EndsAt: ends,
ID: id,
Name: trimForm(c, "name"),
Weight: atoiForm(c, "weight"),
Enabled: c.PostForm("enabled") != "",
StartsAt: starts,
EndsAt: ends,
Urgent: c.PostForm("urgent") != "",
OverrideAll: colorSetForm(c, "override_all_on", "override_bg", "override_fg", "override_link"),
OverrideDark: colorSetForm(c, "override_dark_on", "override_bg_dark", "override_fg_dark", "override_link_dark"),
})
if err != nil {
s.bannerError(c, err, back)
@@ -321,3 +338,29 @@ func atoiForm(c *gin.Context, name string) int {
n, _ := strconv.Atoi(trimForm(c, name))
return n
}
// seedColors returns the three colour-input seed values for a campaign's optional
// override: the stored colours when the set is present, otherwise the given
// neutral theme tokens (so the native picker starts sensibly and the preview
// shows the real fallback).
func seedColors(cs *ads.ColorSet, tokenBg, tokenFg, tokenLink string) (bg, fg, link string) {
if cs == nil {
return tokenBg, tokenFg, tokenLink
}
return cs.Bg, cs.Fg, cs.Link
}
// colorSetForm reads an optional colour override from the form: the on flag gates
// it (an unchecked set is absent, nil), otherwise the three posted colours are
// returned for the ads service to validate. The colour inputs are disabled in the
// browser while the set is off, so they are not posted then.
func colorSetForm(c *gin.Context, on, bg, fg, link string) *ads.ColorSet {
if c.PostForm(on) == "" {
return nil
}
return &ads.ColorSet{
Bg: trimForm(c, bg),
Fg: trimForm(c, fg),
Link: trimForm(c, link),
}
}