diff --git a/backend/internal/adminconsole/assets/console.css b/backend/internal/adminconsole/assets/console.css index 3cda4a0..0b6c35f 100644 --- a/backend/internal/adminconsole/assets/console.css +++ b/backend/internal/adminconsole/assets/console.css @@ -193,3 +193,24 @@ code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; } .replay-log { margin: 0.4rem 0 0; padding-left: 1.4rem; max-height: 14rem; overflow: auto; font-size: 0.85rem; } .replay-log li { color: var(--ink-dim); padding: 0.1rem 0; } .replay-log li.cur { color: var(--ink); font-weight: 600; } + +/* Banner colour override editor + live preview (banner_detail). The override + fieldsets group the enable toggle with the native colour swatches; the preview + renders a sample strip on both themes from those inputs (see the inline script). */ +.ovr { border: 1px solid var(--line); border-radius: 6px; padding: 0.3rem 0.8rem 0.7rem; margin: 0.2rem 0; } +.ovr legend { padding: 0 0.3rem; font-size: 0.85rem; color: var(--ink); } +.ovr legend label { flex-direction: row; align-items: center; gap: 0.4rem; color: var(--ink); } +.ovr .note { margin: 0.2rem 0 0.4rem; } +.ovr .swatches { display: flex; flex-wrap: wrap; gap: 1rem; } +.ovr .swatches label { flex-direction: column; gap: 0.25rem; align-items: flex-start; } +.ovr input[type=color] { width: 3rem; height: 1.8rem; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: var(--bg); cursor: pointer; } +.ovr input[type=color]:disabled { opacity: 0.4; cursor: default; } +.ovr .hex { font-size: 0.72rem; color: var(--ink-dim); font-variant-numeric: tabular-nums; } +.banner-preview { display: flex; flex-wrap: wrap; gap: 0.8rem; margin: 0.7rem 0 0.2rem; } +.banner-preview .bp { flex: 1 1 18rem; } +.banner-preview .bp-label { display: block; font-size: 0.75rem; color: var(--ink-dim); margin-bottom: 0.25rem; } +.ad-frame { padding: 0.7rem; border-radius: 6px; border: 1px solid var(--line); } +.ad-frame.light { background: #f4f6f9; } +.ad-frame.dark { background: #0f1420; } +.ad-sample { padding: 0.35rem 0.7rem; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ad-sample .ad-link { text-decoration: underline; } diff --git a/backend/internal/adminconsole/templates/pages/banner_detail.gohtml b/backend/internal/adminconsole/templates/pages/banner_detail.gohtml index aeeb1dc..38c028c 100644 --- a/backend/internal/adminconsole/templates/pages/banner_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/banner_detail.gohtml @@ -11,10 +11,72 @@ +
+ + {{end}} {{if not .IsDefault}} + +Live preview of the strip on both themes. An empty override falls back to the neutral theme colours; the top/bottom border is derived from the background.
+ diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index deddfe2..5abfeff 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -498,15 +498,30 @@ type BannerCampaignRow struct { // BannerDetailView is the campaign detail/edit page. StartsAt/EndsAt are the // "YYYY-MM-DDTHH:MM" (UTC) values for the datetime-local inputs, empty when open. +// +// The colour-override and urgent fields drive the non-default campaign's editor: +// OverrideAllOn/OverrideDarkOn report whether each colour set is active, and the +// six *Bg/*Fg/*Link values seed the native colour inputs — the stored override +// when a set is on, otherwise the neutral theme token so the picker starts from a +// sensible colour and the live preview shows the real fallback. type BannerDetailView struct { - ID string - Name string - Weight int - IsDefault bool - Enabled bool - StartsAt string - EndsAt string - Messages []BannerMessageRow + ID string + Name string + Weight int + IsDefault bool + Enabled bool + StartsAt string + EndsAt string + Urgent bool + OverrideAllOn bool + AllBg string + AllFg string + AllLink string + OverrideDarkOn bool + DarkBg string + DarkFg string + DarkLink string + Messages []BannerMessageRow } // BannerMessageRow is one bilingual message of a campaign. First/Last drive the diff --git a/backend/internal/ads/ads.go b/backend/internal/ads/ads.go index 91c3d80..ef38c91 100644 --- a/backend/internal/ads/ads.go +++ b/backend/internal/ads/ads.go @@ -30,6 +30,15 @@ var ErrDefaultImmutable = errors.New("ads: the default campaign cannot be modifi // window or message body). var ErrValidation = errors.New("ads: validation") +// ColorSet is an optional per-campaign colour override for the banner strip: +// background, foreground (text) and link, each a "#rrggbb" hex string. The three +// are set together or the whole set is absent (a nil *ColorSet). +type ColorSet struct { + Bg string + Fg string + Link string +} + // Campaign is one advertising placement order with its messages. type Campaign struct { ID uuid.UUID // uuid.Nil on create @@ -40,6 +49,16 @@ type Campaign struct { StartsAt *time.Time // nil = open-ended start; always nil for the default EndsAt *time.Time // nil = open-ended end; always nil for the default Messages []Message + // OverrideAll paints the strip on every theme; OverrideDark, when set, further + // overrides the dark theme (the client resolves dark ← dark ?? all ?? token, + // light ← all ?? token). Both are nil for the default campaign and for a + // campaign that keeps the neutral theme tokens. Non-default only. + OverrideAll *ColorSet + OverrideDark *ColorSet + // Urgent forces the banner on every viewer (bypassing eligibility) and, while + // any urgent campaign is active, suppresses every non-urgent campaign and the + // default remainder. Non-default only; always false for the default campaign. + Urgent bool CreatedAt time.Time UpdatedAt time.Time } @@ -70,10 +89,15 @@ type Timings struct { // ActiveCampaign is one campaign in the resolved rotation feed sent to a client: // its GCD-reduced show weight and its messages, already resolved to the viewer's -// language and in display (round-robin) order. +// language and in display (round-robin) order, plus the optional colour overrides +// the client applies to the strip (nil = the neutral theme tokens). Urgency is not +// carried here: it is resolved server-side into the set's membership and the +// eligibility bypass, so the client only ever renders what it is sent. type ActiveCampaign struct { - Weight int - Messages []string + Weight int + Messages []string + OverrideAll *ColorSet + OverrideDark *ColorSet } // Eligible reports whether an account should be shown the advertising banner: a @@ -85,14 +109,20 @@ func Eligible(paidAccount bool, hintBalance int, hasNoBanner bool) bool { } // computeActiveSet builds the resolved rotation feed from the enabled campaigns -// at time now, in language lang. Campaigns outside their validity window, and -// campaigns with no messages, are dropped. The default campaign's effective -// weight is the remainder up to 100% — max(0, 100 - sum of active timed -// weights) — so it fills unsold inventory and is dropped entirely when timed -// campaigns already reach 100%. Weights are then reduced by their GCD so the -// fair rotation cycle stays short. The input is expected to be the enabled -// campaigns (ActiveCampaigns); disabled ones must already be excluded. -func computeActiveSet(campaigns []Campaign, now time.Time, lang string) []ActiveCampaign { +// at time now, in language lang, and reports whether the feed is an urgent one. +// Campaigns outside their validity window, and campaigns with no messages, are +// dropped. +// +// While any active campaign is urgent, the feed is the urgent campaigns alone — +// every non-urgent timed campaign and the default remainder are suppressed for +// the duration (and the caller shows the feed to every viewer, bypassing +// eligibility). Otherwise the default campaign's effective weight is the +// remainder up to 100% — max(0, 100 - sum of active timed weights) — so it fills +// unsold inventory and is dropped entirely when timed campaigns already reach +// 100%. Weights are then reduced by their GCD so the fair rotation cycle stays +// short. The input is expected to be the enabled campaigns (ActiveCampaigns); +// disabled ones must already be excluded. +func computeActiveSet(campaigns []Campaign, now time.Time, lang string) ([]ActiveCampaign, bool) { var timed []Campaign var def *Campaign for i := range campaigns { @@ -108,20 +138,54 @@ func computeActiveSet(campaigns []Campaign, now time.Time, lang string) []Active timed = append(timed, c) } } + if urgent := filterUrgent(timed); len(urgent) > 0 { + out := make([]ActiveCampaign, 0, len(urgent)) + for _, c := range urgent { + out = append(out, activeFrom(c, lang)) + } + reduceByGCD(out) + return out, true + } sumTimed := 0 for _, c := range timed { sumTimed += c.Weight } out := make([]ActiveCampaign, 0, len(timed)+1) for _, c := range timed { - out = append(out, ActiveCampaign{Weight: c.Weight, Messages: resolveBodies(c.Messages, lang)}) + out = append(out, activeFrom(c, lang)) } if def != nil { if dw := 100 - sumTimed; dw > 0 { - out = append(out, ActiveCampaign{Weight: dw, Messages: resolveBodies(def.Messages, lang)}) + a := activeFrom(*def, lang) + a.Weight = dw // the default's stored weight is nominal; it fills the remainder + out = append(out, a) } } reduceByGCD(out) + return out, false +} + +// activeFrom projects a campaign to its rotation-feed entry: its show weight, its +// language-resolved messages and its colour overrides (carried through by +// reference, they are read-only). +func activeFrom(c Campaign, lang string) ActiveCampaign { + return ActiveCampaign{ + Weight: c.Weight, + Messages: resolveBodies(c.Messages, lang), + OverrideAll: c.OverrideAll, + OverrideDark: c.OverrideDark, + } +} + +// filterUrgent returns the urgent campaigns among cs, preserving order. It is the +// preempt selector: a non-empty result makes the whole feed urgent. +func filterUrgent(cs []Campaign) []Campaign { + var out []Campaign + for _, c := range cs { + if c.Urgent { + out = append(out, c) + } + } return out } diff --git a/backend/internal/ads/ads_test.go b/backend/internal/ads/ads_test.go index 9cb1efe..5f9f31b 100644 --- a/backend/internal/ads/ads_test.go +++ b/backend/internal/ads/ads_test.go @@ -46,12 +46,19 @@ func TestComputeActiveSet(t *testing.T) { timed := func(name string, weight int, starts, ends *time.Time, msgs []Message) Campaign { return Campaign{Name: name, Weight: weight, Enabled: true, StartsAt: starts, EndsAt: ends, Messages: msgs} } + urgent := func(name string, weight int, msgs []Message) Campaign { + c := timed(name, weight, nil, nil, msgs) + c.Urgent = true + return c + } + red := &ColorSet{Bg: "#aa0000", Fg: "#ffffff", Link: "#ffdd00"} tests := []struct { - name string - campaigns []Campaign - lang string - want []ActiveCampaign + name string + campaigns []Campaign + lang string + want []ActiveCampaign + wantUrgent bool }{ { name: "default only reduces to weight 1", @@ -152,22 +159,71 @@ func TestComputeActiveSet(t *testing.T) { lang: "en", want: []ActiveCampaign{{Weight: 1, Messages: []string{"one-en", "two-en"}}}, }, + { + name: "urgent preempts default and normal timed", + campaigns: []Campaign{ + def(msg("house")), + timed("promo", 40, nil, nil, msg("promo")), + urgent("alert", 50, msg("alert")), + }, + lang: "en", + // only the urgent campaign survives; a lone weight reduces to 1. + want: []ActiveCampaign{{Weight: 1, Messages: []string{"alert-en"}}}, + wantUrgent: true, + }, + { + name: "multiple urgent share the feed by gcd", + campaigns: []Campaign{ + def(msg("house")), + urgent("a", 60, msg("a")), + urgent("b", 40, msg("b")), + }, + lang: "en", + // default and any non-urgent dropped; gcd(60,40)=20 -> 3 and 2. + want: []ActiveCampaign{ + {Weight: 3, Messages: []string{"a-en"}}, + {Weight: 2, Messages: []string{"b-en"}}, + }, + wantUrgent: true, + }, + { + name: "out-of-window urgent does not preempt", + campaigns: []Campaign{ + def(msg("house")), + func() Campaign { c := urgent("future", 50, msg("future")); c.StartsAt = &future; return c }(), + }, + lang: "en", + // the urgent campaign is not yet live, so the normal default feed stands. + want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}}, + }, + { + name: "colour overrides ride the active campaign", + campaigns: []Campaign{ + def(msg("house")), + func() Campaign { c := timed("promo", 100, nil, nil, msg("promo")); c.OverrideAll = red; return c }(), + }, + lang: "en", + want: []ActiveCampaign{{Weight: 1, Messages: []string{"promo-en"}, OverrideAll: red}}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := computeActiveSet(tt.campaigns, now, tt.lang) + got, gotUrgent := computeActiveSet(tt.campaigns, now, tt.lang) if !reflect.DeepEqual(got, tt.want) { t.Errorf("computeActiveSet() =\n %#v\nwant\n %#v", got, tt.want) } + if gotUrgent != tt.wantUrgent { + t.Errorf("computeActiveSet() urgent = %v, want %v", gotUrgent, tt.wantUrgent) + } }) } } func TestComputeActiveSetEmpty(t *testing.T) { // No campaigns at all yields an empty (non-nil-or-nil) feed without panicking. - if got := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 { - t.Errorf("computeActiveSet(nil) = %#v, want empty", got) + if got, urgent := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 || urgent { + t.Errorf("computeActiveSet(nil) = %#v urgent=%v, want empty non-urgent", got, urgent) } } diff --git a/backend/internal/ads/service.go b/backend/internal/ads/service.go index 939771d..5bd1a2c 100644 --- a/backend/internal/ads/service.go +++ b/backend/internal/ads/service.go @@ -3,6 +3,7 @@ package ads import ( "context" "fmt" + "regexp" "strings" "time" @@ -40,17 +41,20 @@ func NewService(store *Store) *Service { return &Service{store: store} } // ActiveSet returns the resolved rotation feed for a viewer in language lang // (en/ru) together with the global display timings: the currently-active // campaigns, each with its GCD-reduced show weight and its messages resolved to -// lang, ready for the client's weighted round-robin. -func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, error) { +// lang, ready for the client's weighted round-robin. The bool result reports +// whether the feed is urgent — an urgent feed is shown to every viewer +// regardless of eligibility (the caller skips the eligibility gate for it). +func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, bool, error) { campaigns, err := s.store.ActiveCampaigns(ctx) if err != nil { - return nil, Timings{}, err + return nil, Timings{}, false, err } timings, err := s.store.Settings(ctx) if err != nil { - return nil, Timings{}, err + return nil, Timings{}, false, err } - return computeActiveSet(campaigns, time.Now().UTC(), lang), timings, nil + set, urgent := computeActiveSet(campaigns, time.Now().UTC(), lang) + return set, timings, urgent, nil } // ListCampaigns returns every campaign with its messages, for the admin console. @@ -76,8 +80,13 @@ func (s *Service) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, er if err := validWindow(c.StartsAt, c.EndsAt); err != nil { return uuid.Nil, err } + all, dark, err := validOverrides(c.OverrideAll, c.OverrideDark) + if err != nil { + return uuid.Nil, err + } return s.store.CreateCampaign(ctx, Campaign{ Name: name, Weight: c.Weight, Enabled: c.Enabled, StartsAt: c.StartsAt, EndsAt: c.EndsAt, + OverrideAll: all, OverrideDark: dark, Urgent: c.Urgent, }) } @@ -99,6 +108,7 @@ func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error { upd.Enabled = true upd.StartsAt = nil upd.EndsAt = nil + // The default (house) campaign stays plain: no colour overrides, never urgent. } else { if err := validWeight(c.Weight); err != nil { return err @@ -106,10 +116,17 @@ func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error { if err := validWindow(c.StartsAt, c.EndsAt); err != nil { return err } + all, dark, err := validOverrides(c.OverrideAll, c.OverrideDark) + if err != nil { + return err + } upd.Weight = c.Weight upd.Enabled = c.Enabled upd.StartsAt = c.StartsAt upd.EndsAt = c.EndsAt + upd.OverrideAll = all + upd.OverrideDark = dark + upd.Urgent = c.Urgent } return s.store.UpdateCampaign(ctx, upd) } @@ -254,6 +271,46 @@ func validWindow(starts, ends *time.Time) error { return nil } +// hexColor matches a "#rrggbb" colour — the format the console's native colour +// input emits and the wire carries. +var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) + +// validOverrides validates the two optional colour sets together and returns +// their normalised copies (nil when a set is absent), so a caller can store them +// directly. +func validOverrides(all, dark *ColorSet) (*ColorSet, *ColorSet, error) { + va, err := validColorSet(all) + if err != nil { + return nil, nil, err + } + vd, err := validColorSet(dark) + if err != nil { + return nil, nil, err + } + return va, vd, nil +} + +// validColorSet validates one optional colour override: a nil set passes (no +// override); otherwise all three colours must be present and "#rrggbb". It +// returns a trimmed, lower-cased copy. +func validColorSet(cs *ColorSet) (*ColorSet, error) { + if cs == nil { + return nil, nil + } + bg := strings.TrimSpace(cs.Bg) + fg := strings.TrimSpace(cs.Fg) + link := strings.TrimSpace(cs.Link) + if bg == "" || fg == "" || link == "" { + return nil, fmt.Errorf("%w: a colour override needs all of background, text and link", ErrValidation) + } + for _, h := range []string{bg, fg, link} { + if !hexColor.MatchString(h) { + return nil, fmt.Errorf("%w: colours must be #rrggbb hex", ErrValidation) + } + } + return &ColorSet{Bg: strings.ToLower(bg), Fg: strings.ToLower(fg), Link: strings.ToLower(link)}, nil +} + // validBodies trims and bounds both mandatory language bodies of a message. func validBodies(bodyEn, bodyRu string) (string, string, error) { en := strings.TrimSpace(bodyEn) diff --git a/backend/internal/ads/store.go b/backend/internal/ads/store.go index 4074418..247d5d4 100644 --- a/backend/internal/ads/store.go +++ b/backend/internal/ads/store.go @@ -90,15 +90,23 @@ func (s *Store) Campaign(ctx context.Context, id uuid.UUID) (Campaign, error) { func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) { id := uuid.New() now := time.Now().UTC() + abg, afg, alink := colorCols(c.OverrideAll) + dbg, dfg, dlink := colorCols(c.OverrideDark) stmt := table.AdCampaigns.INSERT( table.AdCampaigns.CampaignID, table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.IsDefault, table.AdCampaigns.Enabled, table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt, + table.AdCampaigns.OverrideBg, table.AdCampaigns.OverrideFg, table.AdCampaigns.OverrideLink, + table.AdCampaigns.OverrideBgDark, table.AdCampaigns.OverrideFgDark, table.AdCampaigns.OverrideLinkDark, + table.AdCampaigns.Urgent, table.AdCampaigns.CreatedAt, table.AdCampaigns.UpdatedAt, ).VALUES( postgres.UUID(id), postgres.String(c.Name), postgres.Int(int64(c.Weight)), postgres.Bool(false), postgres.Bool(c.Enabled), tsOrNull(c.StartsAt), tsOrNull(c.EndsAt), + abg, afg, alink, + dbg, dfg, dlink, + postgres.Bool(c.Urgent), postgres.TimestampzT(now), postgres.TimestampzT(now), ) if _, err := stmt.ExecContext(ctx, s.db); err != nil { @@ -111,12 +119,20 @@ func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, erro // window. The default flag is never touched here. Returns ErrNotFound when no // campaign matches. func (s *Store) UpdateCampaign(ctx context.Context, c Campaign) error { + abg, afg, alink := colorCols(c.OverrideAll) + dbg, dfg, dlink := colorCols(c.OverrideDark) stmt := table.AdCampaigns.UPDATE( table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.Enabled, - table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt, table.AdCampaigns.UpdatedAt, + table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt, + table.AdCampaigns.OverrideBg, table.AdCampaigns.OverrideFg, table.AdCampaigns.OverrideLink, + table.AdCampaigns.OverrideBgDark, table.AdCampaigns.OverrideFgDark, table.AdCampaigns.OverrideLinkDark, + table.AdCampaigns.Urgent, table.AdCampaigns.UpdatedAt, ).SET( postgres.String(c.Name), postgres.Int(int64(c.Weight)), postgres.Bool(c.Enabled), - tsOrNull(c.StartsAt), tsOrNull(c.EndsAt), postgres.TimestampzT(time.Now().UTC()), + tsOrNull(c.StartsAt), tsOrNull(c.EndsAt), + abg, afg, alink, + dbg, dfg, dlink, + postgres.Bool(c.Urgent), postgres.TimestampzT(time.Now().UTC()), ).WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(c.ID))) return execOne(ctx, s.db, stmt, "update campaign") } @@ -257,18 +273,40 @@ func execOne(ctx context.Context, db qrm.Executable, stmt postgres.Statement, wh func modelToCampaign(r model.AdCampaigns) Campaign { return Campaign{ - ID: r.CampaignID, - Name: r.Name, - Weight: int(r.Weight), - IsDefault: r.IsDefault, - Enabled: r.Enabled, - StartsAt: r.StartsAt, - EndsAt: r.EndsAt, - CreatedAt: r.CreatedAt, - UpdatedAt: r.UpdatedAt, + ID: r.CampaignID, + Name: r.Name, + Weight: int(r.Weight), + IsDefault: r.IsDefault, + Enabled: r.Enabled, + StartsAt: r.StartsAt, + EndsAt: r.EndsAt, + OverrideAll: colorSetFrom(r.OverrideBg, r.OverrideFg, r.OverrideLink), + OverrideDark: colorSetFrom(r.OverrideBgDark, r.OverrideFgDark, r.OverrideLinkDark), + Urgent: r.Urgent, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, } } +// colorSetFrom rebuilds an optional ColorSet from three nullable colour columns. +// The all-or-nothing CHECK keeps the trio consistent, so a nil in any one means +// the set is absent. +func colorSetFrom(bg, fg, link *string) *ColorSet { + if bg == nil || fg == nil || link == nil { + return nil + } + return &ColorSet{Bg: *bg, Fg: *fg, Link: *link} +} + +// colorCols renders a *ColorSet as its three column value-expressions in +// bg, fg, link order — NULL for each when the set is absent. +func colorCols(cs *ColorSet) (bg, fg, link postgres.Expression) { + if cs == nil { + return postgres.NULL, postgres.NULL, postgres.NULL + } + return postgres.String(cs.Bg), postgres.String(cs.Fg), postgres.String(cs.Link) +} + func modelToMessage(r model.AdMessages) Message { return Message{ ID: r.MessageID, diff --git a/backend/internal/inttest/banner_console_test.go b/backend/internal/inttest/banner_console_test.go index d858213..4db5042 100644 --- a/backend/internal/inttest/banner_console_test.go +++ b/backend/internal/inttest/banner_console_test.go @@ -192,8 +192,9 @@ func TestBannerMessageOwnership(t *testing.T) { type profileBanner struct { Banner *struct { Campaigns []struct { - Weight int `json:"weight"` - Messages []string `json:"messages"` + Weight int `json:"weight"` + Messages []string `json:"messages"` + OverrideBg string `json:"override_bg"` } `json:"campaigns"` Timings struct { HoldMs int `json:"hold_ms"` @@ -274,3 +275,142 @@ func TestBannerSurvivesProfileUpdate(t *testing.T) { t.Fatalf("profile update dropped the banner block: %v", p.Banner) } } + +// TestBannerConsoleColorsAndUrgent drives the colour-override + urgent editor over +// HTTP against real Postgres: an incomplete or malformed colour set is refused, a +// full two-set override + urgent round-trips through the store (exercising the new +// columns and CHECK constraints), clearing the sets removes the override, and the +// default campaign stays plain (colours + urgent are ignored for it). +func TestBannerConsoleColorsAndUrgent(t *testing.T) { + ctx := context.Background() + srv, adsSvc := bannerServer(t) + h := srv.Handler() + base := "http://admin.test/_gm" + const origin = "http://admin.test" + + name := "Brand-" + uuid.NewString()[:8] + if code, body := consoleDo(h, http.MethodPost, base+"/banners", "name="+name+"&weight=20&enabled=on", origin); code != http.StatusOK || !strings.Contains(body, "Created") { + t.Fatalf("create = %d, has Created=%v", code, strings.Contains(body, "Created")) + } + id := findCampaign(t, adsSvc, name) + t.Cleanup(func() { _ = adsSvc.DeleteCampaign(ctx, id) }) + detail := base + "/banners/" + id.String() + + // A partial colour set (a missing colour) is refused by validation. + partial := "name=" + name + "&weight=20&enabled=on&override_all_on=on&override_bg=%23aa0000&override_fg=&override_link=%23ffdd00" + if code, b := consoleDo(h, http.MethodPost, detail, partial, origin); code != http.StatusOK || !strings.Contains(b, "Not saved") { + t.Fatalf("partial colour = %d, has 'Not saved'=%v", code, strings.Contains(b, "Not saved")) + } + // A malformed hex is refused. + badHex := "name=" + name + "&weight=20&enabled=on&override_all_on=on&override_bg=red&override_fg=%23ffffff&override_link=%23ffdd00" + if code, b := consoleDo(h, http.MethodPost, detail, badHex, origin); code != http.StatusOK || !strings.Contains(b, "Not saved") { + t.Fatalf("bad hex = %d, has 'Not saved'=%v", code, strings.Contains(b, "Not saved")) + } + // Both colour sets + urgent persist and round-trip through the store. + full := "name=" + name + "&weight=20&enabled=on&urgent=on" + + "&override_all_on=on&override_bg=%23aa0000&override_fg=%23ffffff&override_link=%23ffdd00" + + "&override_dark_on=on&override_bg_dark=%23330000&override_fg_dark=%23eeeeee&override_link_dark=%23ffcc00" + if code, b := consoleDo(h, http.MethodPost, detail, full, origin); code != http.StatusOK || !strings.Contains(b, "Saved") { + t.Fatalf("full save = %d, has Saved=%v", code, strings.Contains(b, "Saved")) + } + cm, err := adsSvc.Campaign(ctx, id) + if err != nil { + t.Fatalf("read campaign: %v", err) + } + if cm.OverrideAll == nil || cm.OverrideAll.Bg != "#aa0000" || cm.OverrideAll.Fg != "#ffffff" || cm.OverrideAll.Link != "#ffdd00" { + t.Fatalf("override all = %+v", cm.OverrideAll) + } + if cm.OverrideDark == nil || cm.OverrideDark.Bg != "#330000" || cm.OverrideDark.Fg != "#eeeeee" || cm.OverrideDark.Link != "#ffcc00" { + t.Fatalf("override dark = %+v", cm.OverrideDark) + } + if !cm.Urgent { + t.Fatal("urgent not persisted") + } + // Clearing the sets (both checkboxes off, urgent off) removes the override. + if code, _ := consoleDo(h, http.MethodPost, detail, "name="+name+"&weight=20&enabled=on", origin); code != http.StatusOK { + t.Fatalf("clear = %d", code) + } + if cm, _ := adsSvc.Campaign(ctx, id); cm.OverrideAll != nil || cm.OverrideDark != nil || cm.Urgent { + t.Fatalf("override not cleared: all=%v dark=%v urgent=%v", cm.OverrideAll, cm.OverrideDark, cm.Urgent) + } + + // The default campaign stays plain: submitted colours + urgent are ignored for it. + def := defaultCampaign(t, adsSvc) + defForm := "name=Default+%28house%29&urgent=on&override_all_on=on&override_bg=%23aa0000&override_fg=%23ffffff&override_link=%23ffdd00" + if code, _ := consoleDo(h, http.MethodPost, base+"/banners/"+def.ID.String(), defForm, origin); code != http.StatusOK { + t.Fatalf("update default = %d", code) + } + if again := defaultCampaign(t, adsSvc); again.OverrideAll != nil || again.OverrideDark != nil || again.Urgent { + t.Fatalf("default not plain: all=%v dark=%v urgent=%v", again.OverrideAll, again.OverrideDark, again.Urgent) + } +} + +// TestBannerUrgentBypassesEligibility checks the urgent flag at the profile level: +// an urgent campaign preempts the feed (only it shows, with its colours) and reaches +// every viewer — including the no_banner role and a non-empty hint wallet that +// otherwise suppress the banner. +func TestBannerUrgentBypassesEligibility(t *testing.T) { + ctx := context.Background() + srv, adsSvc := bannerServer(t) + accounts := account.NewStore(testDB) + id := provisionAccount(t) + + urgentID, err := adsSvc.CreateCampaign(ctx, ads.Campaign{ + Name: "Alert-" + uuid.NewString()[:8], Weight: 10, Enabled: true, Urgent: true, + OverrideAll: &ads.ColorSet{Bg: "#aa0000", Fg: "#ffffff", Link: "#ffdd00"}, + }) + if err != nil { + t.Fatalf("create urgent: %v", err) + } + t.Cleanup(func() { _ = adsSvc.DeleteCampaign(ctx, urgentID) }) + if _, err := adsSvc.AddMessage(ctx, urgentID, "System maintenance tonight", "Ночью тех.работы"); err != nil { + t.Fatalf("add urgent message: %v", err) + } + + get := func() profileBanner { + t.Helper() + rec := userGet(t, srv, "/api/v1/user/profile", id) + if rec.Code != http.StatusOK { + t.Fatalf("profile = %d", rec.Code) + } + var p profileBanner + if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil { + t.Fatalf("decode: %v", err) + } + return p + } + assertUrgentOnly := func(what string, p profileBanner) { + t.Helper() + if p.Banner == nil { + t.Fatalf("%s: no banner", what) + } + if len(p.Banner.Campaigns) != 1 { + t.Fatalf("%s: %d campaigns, want only the urgent one (default preempted)", what, len(p.Banner.Campaigns)) + } + c := p.Banner.Campaigns[0] + if len(c.Messages) != 1 || c.Messages[0] != "System maintenance tonight" { + t.Fatalf("%s: messages = %v, want only the urgent one", what, c.Messages) + } + if c.OverrideBg != "#aa0000" { + t.Fatalf("%s: override_bg = %q, want #aa0000", what, c.OverrideBg) + } + } + + // A normal viewer sees only the urgent campaign (the default is preempted). + assertUrgentOnly("eligible", get()) + + // The no_banner role no longer suppresses it — urgent reaches everyone. + if err := accounts.GrantRole(ctx, id, account.RoleNoBanner); err != nil { + t.Fatalf("grant role: %v", err) + } + assertUrgentOnly("no_banner", get()) + if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil { + t.Fatalf("revoke role: %v", err) + } + + // A non-empty hint wallet no longer suppresses it either. + if _, err := accounts.GrantHints(ctx, id, 5); err != nil { + t.Fatalf("grant hints: %v", err) + } + assertUrgentOnly("hints", get()) +} diff --git a/backend/internal/postgres/jet/backend/model/ad_campaigns.go b/backend/internal/postgres/jet/backend/model/ad_campaigns.go index 718bab8..e266a9e 100644 --- a/backend/internal/postgres/jet/backend/model/ad_campaigns.go +++ b/backend/internal/postgres/jet/backend/model/ad_campaigns.go @@ -13,13 +13,20 @@ import ( ) type AdCampaigns struct { - CampaignID uuid.UUID `sql:"primary_key"` - Name string - Weight int32 - IsDefault bool - Enabled bool - StartsAt *time.Time - EndsAt *time.Time - CreatedAt time.Time - UpdatedAt time.Time + CampaignID uuid.UUID `sql:"primary_key"` + Name string + Weight int32 + IsDefault bool + Enabled bool + StartsAt *time.Time + EndsAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time + OverrideBg *string + OverrideFg *string + OverrideLink *string + OverrideBgDark *string + OverrideFgDark *string + OverrideLinkDark *string + Urgent bool } diff --git a/backend/internal/postgres/jet/backend/table/ad_campaigns.go b/backend/internal/postgres/jet/backend/table/ad_campaigns.go index 891586b..088d461 100644 --- a/backend/internal/postgres/jet/backend/table/ad_campaigns.go +++ b/backend/internal/postgres/jet/backend/table/ad_campaigns.go @@ -17,15 +17,22 @@ type adCampaignsTable struct { postgres.Table // Columns - CampaignID postgres.ColumnString - Name postgres.ColumnString - Weight postgres.ColumnInteger - IsDefault postgres.ColumnBool - Enabled postgres.ColumnBool - StartsAt postgres.ColumnTimestampz - EndsAt postgres.ColumnTimestampz - CreatedAt postgres.ColumnTimestampz - UpdatedAt postgres.ColumnTimestampz + CampaignID postgres.ColumnString + Name postgres.ColumnString + Weight postgres.ColumnInteger + IsDefault postgres.ColumnBool + Enabled postgres.ColumnBool + StartsAt postgres.ColumnTimestampz + EndsAt postgres.ColumnTimestampz + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + OverrideBg postgres.ColumnString + OverrideFg postgres.ColumnString + OverrideLink postgres.ColumnString + OverrideBgDark postgres.ColumnString + OverrideFgDark postgres.ColumnString + OverrideLinkDark postgres.ColumnString + Urgent postgres.ColumnBool AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -67,33 +74,47 @@ func newAdCampaignsTable(schemaName, tableName, alias string) *AdCampaignsTable func newAdCampaignsTableImpl(schemaName, tableName, alias string) adCampaignsTable { var ( - CampaignIDColumn = postgres.StringColumn("campaign_id") - NameColumn = postgres.StringColumn("name") - WeightColumn = postgres.IntegerColumn("weight") - IsDefaultColumn = postgres.BoolColumn("is_default") - EnabledColumn = postgres.BoolColumn("enabled") - StartsAtColumn = postgres.TimestampzColumn("starts_at") - EndsAtColumn = postgres.TimestampzColumn("ends_at") - CreatedAtColumn = postgres.TimestampzColumn("created_at") - UpdatedAtColumn = postgres.TimestampzColumn("updated_at") - allColumns = postgres.ColumnList{CampaignIDColumn, NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn} - mutableColumns = postgres.ColumnList{NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn} - defaultColumns = postgres.ColumnList{IsDefaultColumn, EnabledColumn, CreatedAtColumn, UpdatedAtColumn} + CampaignIDColumn = postgres.StringColumn("campaign_id") + NameColumn = postgres.StringColumn("name") + WeightColumn = postgres.IntegerColumn("weight") + IsDefaultColumn = postgres.BoolColumn("is_default") + EnabledColumn = postgres.BoolColumn("enabled") + StartsAtColumn = postgres.TimestampzColumn("starts_at") + EndsAtColumn = postgres.TimestampzColumn("ends_at") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + OverrideBgColumn = postgres.StringColumn("override_bg") + OverrideFgColumn = postgres.StringColumn("override_fg") + OverrideLinkColumn = postgres.StringColumn("override_link") + OverrideBgDarkColumn = postgres.StringColumn("override_bg_dark") + OverrideFgDarkColumn = postgres.StringColumn("override_fg_dark") + OverrideLinkDarkColumn = postgres.StringColumn("override_link_dark") + UrgentColumn = postgres.BoolColumn("urgent") + allColumns = postgres.ColumnList{CampaignIDColumn, NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn, OverrideBgColumn, OverrideFgColumn, OverrideLinkColumn, OverrideBgDarkColumn, OverrideFgDarkColumn, OverrideLinkDarkColumn, UrgentColumn} + mutableColumns = postgres.ColumnList{NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn, OverrideBgColumn, OverrideFgColumn, OverrideLinkColumn, OverrideBgDarkColumn, OverrideFgDarkColumn, OverrideLinkDarkColumn, UrgentColumn} + defaultColumns = postgres.ColumnList{IsDefaultColumn, EnabledColumn, CreatedAtColumn, UpdatedAtColumn, UrgentColumn} ) return adCampaignsTable{ Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), //Columns - CampaignID: CampaignIDColumn, - Name: NameColumn, - Weight: WeightColumn, - IsDefault: IsDefaultColumn, - Enabled: EnabledColumn, - StartsAt: StartsAtColumn, - EndsAt: EndsAtColumn, - CreatedAt: CreatedAtColumn, - UpdatedAt: UpdatedAtColumn, + CampaignID: CampaignIDColumn, + Name: NameColumn, + Weight: WeightColumn, + IsDefault: IsDefaultColumn, + Enabled: EnabledColumn, + StartsAt: StartsAtColumn, + EndsAt: EndsAtColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + OverrideBg: OverrideBgColumn, + OverrideFg: OverrideFgColumn, + OverrideLink: OverrideLinkColumn, + OverrideBgDark: OverrideBgDarkColumn, + OverrideFgDark: OverrideFgDarkColumn, + OverrideLinkDark: OverrideLinkDarkColumn, + Urgent: UrgentColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/backend/internal/postgres/migrations/00009_banner_colors_urgent.sql b/backend/internal/postgres/migrations/00009_banner_colors_urgent.sql new file mode 100644 index 0000000..96fb959 --- /dev/null +++ b/backend/internal/postgres/migrations/00009_banner_colors_urgent.sql @@ -0,0 +1,60 @@ +-- Per-campaign banner colour overrides and the urgent (always-show) flag. +-- +-- ad_campaigns gains two optional colour sets and an urgent flag, all for +-- non-default campaigns only: +-- override_bg/fg/link — paints the strip on every theme +-- override_bg/fg/link_dark — further overrides the dark theme +-- urgent — force the banner on every viewer (bypassing +-- eligibility) and suppress all non-urgent +-- campaigns while any urgent one is active +-- +-- Each colour set is all-or-nothing (bg+fg+link together, or absent) and every +-- colour is a "#rrggbb" hex string. The default (house) campaign stays plain: no +-- colours, never urgent. +-- +-- Expand-contract: additive (nullable columns plus one NOT NULL boolean with a +-- default), so a backend image rollback stays DB-safe — older code ignores them. +-- The ad_campaigns table shape changes, so its generated go-jet model is +-- regenerated (by hand here, to keep the diff to this one table). + +-- +goose Up +ALTER TABLE backend.ad_campaigns + ADD COLUMN override_bg text, + ADD COLUMN override_fg text, + ADD COLUMN override_link text, + ADD COLUMN override_bg_dark text, + ADD COLUMN override_fg_dark text, + ADD COLUMN override_link_dark text, + ADD COLUMN urgent boolean DEFAULT false NOT NULL, + ADD CONSTRAINT ad_campaigns_override_all_chk CHECK ( + (override_bg IS NULL AND override_fg IS NULL AND override_link IS NULL) + OR (override_bg IS NOT NULL AND override_fg IS NOT NULL AND override_link IS NOT NULL) + ), + ADD CONSTRAINT ad_campaigns_override_dark_chk CHECK ( + (override_bg_dark IS NULL AND override_fg_dark IS NULL AND override_link_dark IS NULL) + OR (override_bg_dark IS NOT NULL AND override_fg_dark IS NOT NULL AND override_link_dark IS NOT NULL) + ), + ADD CONSTRAINT ad_campaigns_override_hex_chk CHECK ( + (override_bg IS NULL OR override_bg ~ '^#[0-9a-fA-F]{6}$') + AND (override_fg IS NULL OR override_fg ~ '^#[0-9a-fA-F]{6}$') + AND (override_link IS NULL OR override_link ~ '^#[0-9a-fA-F]{6}$') + AND (override_bg_dark IS NULL OR override_bg_dark ~ '^#[0-9a-fA-F]{6}$') + AND (override_fg_dark IS NULL OR override_fg_dark ~ '^#[0-9a-fA-F]{6}$') + AND (override_link_dark IS NULL OR override_link_dark ~ '^#[0-9a-fA-F]{6}$') + ), + ADD CONSTRAINT ad_campaigns_default_plain_chk CHECK ( + (NOT is_default) + OR (override_bg IS NULL AND override_fg IS NULL AND override_link IS NULL + AND override_bg_dark IS NULL AND override_fg_dark IS NULL AND override_link_dark IS NULL + AND NOT urgent) + ); + +-- +goose Down +ALTER TABLE backend.ad_campaigns + DROP COLUMN override_bg, + DROP COLUMN override_fg, + DROP COLUMN override_link, + DROP COLUMN override_bg_dark, + DROP COLUMN override_fg_dark, + DROP COLUMN override_link_dark, + DROP COLUMN urgent; diff --git a/backend/internal/server/banner.go b/backend/internal/server/banner.go index f3f5892..f86088d 100644 --- a/backend/internal/server/banner.go +++ b/backend/internal/server/banner.go @@ -22,10 +22,20 @@ type bannerDTO struct { // bannerCampaignDTO is one campaign in the rotation feed: its GCD-reduced show // weight (for the client's smooth weighted round-robin) and its messages, in -// display order, already resolved to one language. +// display order, already resolved to one language. The override_* colours are +// present only for a campaign that carries a colour override: override_bg/fg/link +// paint every theme, and the *_dark trio further overrides the dark theme (the +// client resolves the cascade). They are absent for a plain campaign, which keeps +// the neutral theme tokens. type bannerCampaignDTO struct { - Weight int `json:"weight"` - Messages []string `json:"messages"` + Weight int `json:"weight"` + Messages []string `json:"messages"` + OverrideBg string `json:"override_bg,omitempty"` + OverrideFg string `json:"override_fg,omitempty"` + OverrideLink string `json:"override_link,omitempty"` + OverrideBgDark string `json:"override_bg_dark,omitempty"` + OverrideFgDark string `json:"override_fg_dark,omitempty"` + OverrideLinkDark string `json:"override_link_dark,omitempty"` } // bannerTimingsDTO mirrors ads.Timings on the wire. @@ -83,22 +93,27 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO if s.ads == nil { return nil } - hasNoBanner, err := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner) - if err != nil { - s.log.Warn("banner: role check failed", zap.String("account", acc.ID.String()), zap.Error(err)) - return nil - } - if !ads.Eligible(acc.PaidAccount, acc.HintBalance, hasNoBanner) { - return nil - } - set, timings, err := s.ads.ActiveSet(ctx, bannerLang(acc)) + set, timings, urgent, err := s.ads.ActiveSet(ctx, bannerLang(acc)) if err != nil { s.log.Warn("banner: active set failed", zap.String("account", acc.ID.String()), zap.Error(err)) return nil } + // An urgent campaign is shown to every viewer; otherwise the normal eligibility + // gate applies (a paid account, a non-empty hint wallet or the no_banner role + // hides the banner). + if !urgent { + hasNoBanner, err := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner) + if err != nil { + s.log.Warn("banner: role check failed", zap.String("account", acc.ID.String()), zap.Error(err)) + return nil + } + if !ads.Eligible(acc.PaidAccount, acc.HintBalance, hasNoBanner) { + return nil + } + } campaigns := make([]bannerCampaignDTO, 0, len(set)) for _, c := range set { - campaigns = append(campaigns, bannerCampaignDTO{Weight: c.Weight, Messages: c.Messages}) + campaigns = append(campaigns, bannerCampaignFromActive(c)) } return &bannerDTO{ Campaigns: campaigns, @@ -113,6 +128,20 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO } } +// bannerCampaignFromActive flattens a resolved campaign into its wire DTO, +// projecting each optional colour set into its three "#rrggbb" fields (empty when +// the set is absent, so JSON omitempty drops them). +func bannerCampaignFromActive(c ads.ActiveCampaign) bannerCampaignDTO { + d := bannerCampaignDTO{Weight: c.Weight, Messages: c.Messages} + if c.OverrideAll != nil { + d.OverrideBg, d.OverrideFg, d.OverrideLink = c.OverrideAll.Bg, c.OverrideAll.Fg, c.OverrideAll.Link + } + if c.OverrideDark != nil { + d.OverrideBgDark, d.OverrideFgDark, d.OverrideLinkDark = c.OverrideDark.Bg, c.OverrideDark.Fg, c.OverrideDark.Link + } + return d +} + // bannerLang resolves the message language for a viewer: their interface language // (Russian, or English by default). func bannerLang(acc account.Account) string { diff --git a/backend/internal/server/handlers_admin_banners.go b/backend/internal/server/handlers_admin_banners.go index 0a8ecc6..5ca8033 100644 --- a/backend/internal/server/handlers_admin_banners.go +++ b/backend/internal/server/handlers_admin_banners.go @@ -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), + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c92b9e4..99e47b1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -980,7 +980,19 @@ edge-pause, scroll speed, and the fade-out → gap → fade-in transition) are o eligibility inputs (grants hints, grants/revokes `no_banner`; a future payment flow sets `paid_account`), the backend emits a `notify` **`banner`** sub-kind (a payload-free re-poll signal), and the open client re-fetches `profile.get` to show or hide the banner in place. Operator *content* -edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session. The same +edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session. A +non-default campaign may also carry an optional **colour override** (background, text, link) in two +sets — one for every theme, one for the dark theme only — plus an **urgent** flag. The colours ride +the same block (six optional strings appended to each `BannerCampaign` on the wire — trailing, +backward-compatible); the client resolves the cascade (dark ← dark ?? all, light ← all) and derives +the strip's border from the background in JS (no CSS `color-mix`, for the old-WebView floor). +**Urgent is resolved entirely server-side, with no wire field**: while any enabled, in-window urgent +campaign exists, `computeActiveSet` returns *only* the urgent campaigns (preempting the timed set and +the default remainder) and `bannerFor` **skips the eligibility gate** for that feed, so an urgent +notice reaches every viewer — paid, hint-holding or `no_banner` included. There is no instant +broadcast on an urgent toggle (the notify path is per-user); an urgent campaign appears on each +viewer's next `profile.get`. The default campaign stays plain (no colours, never urgent), enforced by +both the service and a DB CHECK. The same mechanism carries a **`profile`** sub-kind — a payload-free re-fetch signal emitted when a viewer's own account changed out of band (an email confirmed through the one-tap deeplink opened in another browser), so an open in-app session reflects it at once. The live stream is single-shot with no diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 196ad0a..806e3a8 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -485,8 +485,19 @@ through**, regardless of their interface language. The client rotates the eligib **fairly** — every campaign gets its weighted share each cycle, evenly spread rather than at random — and a campaign with several messages shows them in turn. +A non-default campaign can carry its own **colours** — background, text and link — so a sponsored or +operational message stands out from the neutral strip. Colours come in two sets: one that applies on +**every theme**, and an optional second that overrides the **dark theme** only, so a campaign reads +well on both; the strip's border is derived from the background automatically. A campaign can also be +marked **urgent**: an urgent campaign is shown to **everyone** — even paid players, hint holders and +`no_banner` users — and, while it is live, it is the **only** thing the strip shows (ordinary +campaigns and the house default step aside). Urgent is for genuine system notices (maintenance, +outages), not advertising. + Operators manage all of this in the admin console at **`/_gm/banners`**: create, edit, enable/disable -and schedule campaigns, write each campaign's bilingual messages (reorder or remove them), and set -the global display **timings** (how long a message holds, the scroll of an over-long message, and the -fade-out → gap → fade-in transition between messages). The default campaign cannot be deleted and -keeps at least one message. +and schedule campaigns, write each campaign's bilingual messages (reorder or remove them), pick a +non-default campaign's optional override colours (a native colour picker with a live light-and-dark +preview of the strip) and mark it urgent, and set the global display **timings** (how long a message +holds, the scroll of an over-long message, and the fade-out → gap → fade-in transition between +messages). The default campaign cannot be deleted, keeps at least one message, and stays plain (no +colours, never urgent). diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index ff23181..333ff76 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -496,8 +496,19 @@ high-rate флага. С карточки пользователя операт **честно** — каждая получает свою долю по весу за цикл, равномерно размазанную, а не случайно — а кампания с несколькими сообщениями показывает их по очереди. +Недефолтная кампания может нести собственные **цвета** — фон, текст и ссылку — чтобы рекламное или +служебное сообщение выделялось на фоне нейтральной ленты. Цвета задаются двумя наборами: один +применяется на **любой теме**, а необязательный второй переопределяет только **тёмную тему**, чтобы +кампания хорошо читалась на обеих; рамка ленты выводится из фона автоматически. Кампанию можно также +пометить как **срочную (urgent)**: срочная кампания показывается **всем** — даже платным игрокам, +владельцам подсказок и пользователям с ролью `no_banner` — и, пока она активна, лента показывает +**только её** (обычные кампании и домашняя дефолтная уступают место). Срочность — для настоящих +системных уведомлений (тех.работы, сбои), а не для рекламы. + Всем этим оператор управляет в админ-консоли по адресу **`/_gm/banners`**: создаёт, редактирует, включает/выключает и планирует кампании, пишет двуязычные сообщения кампании (переставляет и удаляет -их) и задаёт общие **тайминги** показа (сколько держится сообщение, прокрутка слишком длинного, и -переход fade-out → пауза → fade-in между сообщениями). Дефолтную кампанию нельзя удалить, и в ней -всегда остаётся хотя бы одно сообщение. +их), выбирает необязательные цвета недефолтной кампании (нативный color-picker с живым превью ленты +на светлой и тёмной темах) и помечает её срочной, а также задаёт общие **тайминги** показа (сколько +держится сообщение, прокрутка слишком длинного, и переход fade-out → пауза → fade-in между +сообщениями). Дефолтную кампанию нельзя удалить, в ней всегда остаётся хотя бы одно сообщение, и она +остаётся простой (без цветов, никогда не срочная). diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 5078d9f..cf904b0 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -315,6 +315,19 @@ left. A **viewport size change** (e.g. a portrait↔landscape rotation) re-measu operator-set (`/_gm/banner-settings`). Under **reduce-motion** the fades collapse to an instant swap and a long message does not scroll. +**Per-campaign colours.** A campaign may override the strip's colours (background, text, link). The +engine carries the current message's campaign colours to the host, and `AdBanner` resolves them for +the **rendered theme** (`lib/bannerColors`: dark ← dark-set ?? all-set, light ← all-set) and applies +them as inline CSS variables scoped to `.ad` (`--ad-bg`, `--text-muted`, `--accent`, and a derived +`--ad-border`) — so an override never leaks past the strip, and a campaign with no override keeps the +neutral tokens. The border is computed from the background in JS (a luminance-aware nudge toward +black/white — no CSS `color-mix`, which the old Android WebView floor lacks), matching the admin +console's live preview. The resolution re-runs when the theme flips (a `[data-theme]` / +`prefers-color-scheme` observer), so switching light↔dark repaints an overridden strip at once. An +**urgent** campaign has no client-specific styling — it simply arrives (with its colours) as the only +campaign in the feed; the preempt-and-show-everyone behaviour is entirely server-side (ARCHITECTURE +§10). + ## Result / status iconography (`lib/result.ts`) Lobby rows show two lines (opponents, then result + score) with a large place-based emoji diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 2b4d019..ef5ce5c 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -53,9 +53,18 @@ type BannerResp struct { // BannerCampaignResp is one campaign in the rotation feed: a GCD-reduced show // weight and its messages, in display order, already resolved to one language. +// The override_* colours are the optional per-campaign palette (empty when the +// campaign keeps the neutral theme tokens); they ride through to the client +// verbatim, mirroring the backend DTO. type BannerCampaignResp struct { - Weight int `json:"weight"` - Messages []string `json:"messages"` + Weight int `json:"weight"` + Messages []string `json:"messages"` + OverrideBg string `json:"override_bg,omitempty"` + OverrideFg string `json:"override_fg,omitempty"` + OverrideLink string `json:"override_link,omitempty"` + OverrideBgDark string `json:"override_bg_dark,omitempty"` + OverrideFgDark string `json:"override_fg_dark,omitempty"` + OverrideLinkDark string `json:"override_link_dark,omitempty"` } // BannerTimingsResp mirrors the backend's global display timings. diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index afc5434..3258ba4 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -118,6 +118,23 @@ func encodeProfile(p backendclient.ProfileResp) []byte { return b.FinishedBytes() } +// optString creates a FlatBuffers string for a non-empty value, or 0 to omit the +// optional field (the client then reads it as absent). +func optString(b *flatbuffers.Builder, s string) flatbuffers.UOffsetT { + if s == "" { + return 0 + } + return b.CreateString(s) +} + +// addOptString adds an optional string slot only when it was created (a 0 offset +// leaves the field absent). add is the generated BannerCampaignAdd* setter. +func addOptString(b *flatbuffers.Builder, add func(*flatbuffers.Builder, flatbuffers.UOffsetT), off flatbuffers.UOffsetT) { + if off != 0 { + add(b, off) + } +} + // encodeBanner builds a BannerInfo table from the resolved banner block and // returns its offset. It is bottom-up: each campaign's messages vector and table // are built first, then the campaigns vector, then the BannerInfo table. The @@ -134,9 +151,23 @@ func encodeBanner(b *flatbuffers.Builder, banner backendclient.BannerResp) flatb b.PrependUOffsetT(msgOffsets[j]) } msgs := b.EndVector(len(msgOffsets)) + // The optional colour strings must be created before the table starts; an + // empty colour is omitted (offset 0), so the client reads it as absent. + obg := optString(b, c.OverrideBg) + ofg := optString(b, c.OverrideFg) + olink := optString(b, c.OverrideLink) + obgD := optString(b, c.OverrideBgDark) + ofgD := optString(b, c.OverrideFgDark) + olinkD := optString(b, c.OverrideLinkDark) fb.BannerCampaignStart(b) fb.BannerCampaignAddWeight(b, int32(c.Weight)) fb.BannerCampaignAddMessages(b, msgs) + addOptString(b, fb.BannerCampaignAddOverrideBg, obg) + addOptString(b, fb.BannerCampaignAddOverrideFg, ofg) + addOptString(b, fb.BannerCampaignAddOverrideLink, olink) + addOptString(b, fb.BannerCampaignAddOverrideBgDark, obgD) + addOptString(b, fb.BannerCampaignAddOverrideFgDark, ofgD) + addOptString(b, fb.BannerCampaignAddOverrideLinkDark, olinkD) campOffsets[i] = fb.BannerCampaignEnd(b) } fb.BannerInfoStartCampaignsVector(b, len(campOffsets)) diff --git a/gateway/internal/transcode/transcode_banner_test.go b/gateway/internal/transcode/transcode_banner_test.go index 9c27dcf..6d986b2 100644 --- a/gateway/internal/transcode/transcode_banner_test.go +++ b/gateway/internal/transcode/transcode_banner_test.go @@ -61,6 +61,54 @@ func TestProfileGetEncodesBanner(t *testing.T) { } } +// TestProfileGetEncodesBannerColors verifies the gateway carries a campaign's +// optional colour overrides into the FlatBuffers payload, and leaves an absent +// colour (or an absent set) empty so the client falls back to the theme tokens. +func TestProfileGetEncodesBannerColors(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"en",` + + `"banner":{"campaigns":[` + + `{"weight":1,"messages":["promo-en"],` + + `"override_bg":"#aa0000","override_fg":"#ffffff","override_link":"#ffdd00",` + + `"override_bg_dark":"#330000","override_fg_dark":"#eeeeee","override_link_dark":"#ffcc00"},` + + `{"weight":1,"messages":["plain-en"]}],` + + `"timings":{"hold_ms":60000,"edge_pause_ms":5000,"scroll_px_per_sec":40,` + + `"fade_out_ms":1000,"gap_ms":250,"fade_in_ms":1000}}}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, _ := reg.Lookup(transcode.MsgProfileGet) + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"}) + if err != nil { + t.Fatalf("handler: %v", err) + } + banner := fb.GetRootAsProfile(payload, 0).Banner(nil) + if banner == nil { + t.Fatal("profile carries no banner block") + } + var c fb.BannerCampaign + banner.Campaigns(&c, 0) + if got := string(c.OverrideBg()); got != "#aa0000" { + t.Errorf("override_bg = %q, want #aa0000", got) + } + if got := string(c.OverrideLink()); got != "#ffdd00" { + t.Errorf("override_link = %q, want #ffdd00", got) + } + if got := string(c.OverrideBgDark()); got != "#330000" { + t.Errorf("override_bg_dark = %q, want #330000", got) + } + if got := string(c.OverrideLinkDark()); got != "#ffcc00" { + t.Errorf("override_link_dark = %q, want #ffcc00", got) + } + // The second campaign has no override: every colour field must be absent (nil). + banner.Campaigns(&c, 1) + if c.OverrideBg() != nil || c.OverrideFg() != nil || c.OverrideLink() != nil || + c.OverrideBgDark() != nil || c.OverrideFgDark() != nil || c.OverrideLinkDark() != nil { + t.Error("plain campaign unexpectedly carries a colour override") + } +} + // TestProfileGetNoBanner verifies an ineligible viewer's profile carries no // banner block (the backend omits it). func TestProfileGetNoBanner(t *testing.T) { diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 570b98c..0afd743 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -180,10 +180,20 @@ table Ack { // BannerCampaign is one campaign in the rotation feed: a GCD-reduced show weight // (the client runs a smooth weighted round-robin over campaigns by this weight) // and its messages in display order, each already resolved to the viewer's bot -// language (the stored en/ru pair is picked server-side). +// language (the stored en/ru pair is picked server-side). The override_* colours +// are an optional per-campaign palette: override_bg/fg/link paint the strip on +// every theme, and the *_dark trio further overrides the dark theme (the client +// resolves dark ← dark ?? all ?? token, light ← all ?? token). Empty means the +// campaign keeps the neutral theme tokens (all added trailing — backward-compatible). table BannerCampaign { weight:int; messages:[string]; + override_bg:string; + override_fg:string; + override_link:string; + override_bg_dark:string; + override_fg_dark:string; + override_link_dark:string; } // BannerInfo is the advertising-banner block of an eligible viewer's profile: the diff --git a/pkg/fbs/scrabblefb/BannerCampaign.go b/pkg/fbs/scrabblefb/BannerCampaign.go index 2a01ac7..c9219b9 100644 --- a/pkg/fbs/scrabblefb/BannerCampaign.go +++ b/pkg/fbs/scrabblefb/BannerCampaign.go @@ -70,8 +70,56 @@ func (rcv *BannerCampaign) MessagesLength() int { return 0 } +func (rcv *BannerCampaign) OverrideBg() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *BannerCampaign) OverrideFg() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *BannerCampaign) OverrideLink() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *BannerCampaign) OverrideBgDark() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(14)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *BannerCampaign) OverrideFgDark() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(16)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *BannerCampaign) OverrideLinkDark() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(18)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + func BannerCampaignStart(builder *flatbuffers.Builder) { - builder.StartObject(2) + builder.StartObject(8) } func BannerCampaignAddWeight(builder *flatbuffers.Builder, weight int32) { builder.PrependInt32Slot(0, weight, 0) @@ -82,6 +130,24 @@ func BannerCampaignAddMessages(builder *flatbuffers.Builder, messages flatbuffer func BannerCampaignStartMessagesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { return builder.StartVector(4, numElems, 4) } +func BannerCampaignAddOverrideBg(builder *flatbuffers.Builder, overrideBg flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(overrideBg), 0) +} +func BannerCampaignAddOverrideFg(builder *flatbuffers.Builder, overrideFg flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(overrideFg), 0) +} +func BannerCampaignAddOverrideLink(builder *flatbuffers.Builder, overrideLink flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(overrideLink), 0) +} +func BannerCampaignAddOverrideBgDark(builder *flatbuffers.Builder, overrideBgDark flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(overrideBgDark), 0) +} +func BannerCampaignAddOverrideFgDark(builder *flatbuffers.Builder, overrideFgDark flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(6, flatbuffers.UOffsetT(overrideFgDark), 0) +} +func BannerCampaignAddOverrideLinkDark(builder *flatbuffers.Builder, overrideLinkDark flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(7, flatbuffers.UOffsetT(overrideLinkDark), 0) +} func BannerCampaignEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/ui/src/components/AdBanner.svelte b/ui/src/components/AdBanner.svelte index 28b1e10..6a2613e 100644 --- a/ui/src/components/AdBanner.svelte +++ b/ui/src/components/AdBanner.svelte @@ -3,12 +3,14 @@ import { attachBannerHost, bannerCurrent, + bannerCurrentColors, configureBanner, detachBannerHost, remeasureBanner, } from '../lib/bannerEngine'; import { defaultBannerTimings, linkify, type BannerHost } from '../lib/banner'; - import type { BannerCampaign, BannerTimings } from '../lib/model'; + import { resolveBannerColors, type ThemeMode } from '../lib/bannerColors'; + import type { BannerCampaign, BannerColors, BannerTimings } from '../lib/model'; import { onExternalLinkClick } from '../lib/links'; let { @@ -21,12 +23,33 @@ // current message and is visible at once (no fade — see inFade), so a screen change does not // replay the fade. Empty on the very first mount (engine not yet started). let current = $state(bannerCurrent()); + let currentColors = $state