feat(banner): per-campaign colour overrides and urgent alerts #182
@@ -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 { 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 { color: var(--ink-dim); padding: 0.1rem 0; }
|
||||||
.replay-log li.cur { color: var(--ink); font-weight: 600; }
|
.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; }
|
||||||
|
|||||||
@@ -11,10 +11,72 @@
|
|||||||
<label>Starts (UTC) <input type="datetime-local" name="starts_at" value="{{.StartsAt}}"></label>
|
<label>Starts (UTC) <input type="datetime-local" name="starts_at" value="{{.StartsAt}}"></label>
|
||||||
<label>Ends (UTC) <input type="datetime-local" name="ends_at" value="{{.EndsAt}}"></label>
|
<label>Ends (UTC) <input type="datetime-local" name="ends_at" value="{{.EndsAt}}"></label>
|
||||||
<label><input type="checkbox" name="enabled"{{if .Enabled}} checked{{end}}> Enabled</label>
|
<label><input type="checkbox" name="enabled"{{if .Enabled}} checked{{end}}> Enabled</label>
|
||||||
|
<fieldset class="ovr">
|
||||||
|
<legend><label><input type="checkbox" name="urgent"{{if .Urgent}} checked{{end}}> Urgent</label></legend>
|
||||||
|
<p class="note">Shows to <em>everyone</em>, always — bypassing paid accounts, hint wallets and the no-banner role. While any urgent campaign is live it is the only thing the strip shows (other campaigns and the default are suppressed). For system alerts.</p>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="ovr" data-ovr-group>
|
||||||
|
<legend><label><input type="checkbox" name="override_all_on" data-ovr="all"{{if .OverrideAllOn}} checked{{end}}> Colour override — all themes</label></legend>
|
||||||
|
<div class="swatches">
|
||||||
|
<label>Background <input type="color" name="override_bg" value="{{.AllBg}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
|
||||||
|
<label>Text <input type="color" name="override_fg" value="{{.AllFg}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
|
||||||
|
<label>Link <input type="color" name="override_link" value="{{.AllLink}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="ovr" data-ovr-group>
|
||||||
|
<legend><label><input type="checkbox" name="override_dark_on" data-ovr="dark"{{if .OverrideDarkOn}} checked{{end}}> Colour override — dark theme only</label></legend>
|
||||||
|
<div class="swatches">
|
||||||
|
<label>Background <input type="color" name="override_bg_dark" value="{{.DarkBg}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
|
||||||
|
<label>Text <input type="color" name="override_fg_dark" value="{{.DarkFg}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
|
||||||
|
<label>Link <input type="color" name="override_link_dark" value="{{.DarkLink}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
{{end}}
|
{{end}}
|
||||||
<div><button type="submit">Save</button></div>
|
<div><button type="submit">Save</button></div>
|
||||||
</form>
|
</form>
|
||||||
{{if not .IsDefault}}
|
{{if not .IsDefault}}
|
||||||
|
<div class="banner-preview">
|
||||||
|
<div class="bp"><span class="bp-label">Light theme</span><div class="ad-frame light"><div class="ad-sample" id="prev-light"><span>Sample banner text — <span class="ad-link">a link</span></span></div></div></div>
|
||||||
|
<div class="bp"><span class="bp-label">Dark theme</span><div class="ad-frame dark"><div class="ad-sample" id="prev-dark"><span>Sample banner text — <span class="ad-link">a link</span></span></div></div></div>
|
||||||
|
</div>
|
||||||
|
<p class="note">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.</p>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
// Neutral fallbacks — mirror ui/src/app.css (--ad-bg / --text-muted / --accent).
|
||||||
|
var TOK={light:{bg:'#e3e7ee',fg:'#6b7280',link:'#2f6df6'},dark:{bg:'#272f3c',fg:'#9aa3b2',link:'#5b8cff'}};
|
||||||
|
function byName(n){return document.querySelector('[name="'+n+'"]');}
|
||||||
|
var allOn=byName('override_all_on'), darkOn=byName('override_dark_on');
|
||||||
|
if(!allOn||!darkOn){return;}
|
||||||
|
var f={ab:byName('override_bg'),af:byName('override_fg'),al:byName('override_link'),db:byName('override_bg_dark'),df:byName('override_fg_dark'),dl:byName('override_link_dark')};
|
||||||
|
var lightEl=document.getElementById('prev-light'), darkEl=document.getElementById('prev-dark');
|
||||||
|
function hexToRgb(h){h=h.replace('#','');return [parseInt(h.slice(0,2),16),parseInt(h.slice(2,4),16),parseInt(h.slice(4,6),16)];}
|
||||||
|
function pad(x){x=Math.max(0,Math.min(255,Math.round(x))).toString(16);return x.length<2?'0'+x:x;}
|
||||||
|
function rgbToHex(r){return '#'+pad(r[0])+pad(r[1])+pad(r[2]);}
|
||||||
|
function mix(a,b,t){return [a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t,a[2]+(b[2]-a[2])*t];}
|
||||||
|
function lum(r){return (0.2126*r[0]+0.7152*r[1]+0.0722*r[2])/255;}
|
||||||
|
// Derived border: nudge the background 14% toward black on a light bg, toward white on a dark bg.
|
||||||
|
function border(bg){var r=hexToRgb(bg);return rgbToHex(mix(r, lum(r)>0.5?[0,0,0]:[255,255,255], 0.14));}
|
||||||
|
function paint(el,c){
|
||||||
|
el.style.background=c.bg; el.style.color=c.fg;
|
||||||
|
el.style.borderTop='1px solid '+border(c.bg); el.style.borderBottom='1px solid '+border(c.bg);
|
||||||
|
var a=el.querySelector('.ad-link'); if(a){a.style.color=c.link;}
|
||||||
|
}
|
||||||
|
function resolve(){
|
||||||
|
var light=allOn.checked?{bg:f.ab.value,fg:f.af.value,link:f.al.value}:TOK.light;
|
||||||
|
var dark=darkOn.checked?{bg:f.db.value,fg:f.df.value,link:f.dl.value}
|
||||||
|
:(allOn.checked?{bg:f.ab.value,fg:f.af.value,link:f.al.value}:TOK.dark);
|
||||||
|
paint(lightEl,light); paint(darkEl,dark);
|
||||||
|
document.querySelectorAll('.ovr .swatches label').forEach(function(lab){
|
||||||
|
var inp=lab.querySelector('input[type=color]'), hx=lab.querySelector('.hex');
|
||||||
|
if(inp&&hx){hx.textContent=inp.value;}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function toggleGroup(chk){chk.closest('fieldset').querySelectorAll('[data-ovr-color]').forEach(function(inp){inp.disabled=!chk.checked;});}
|
||||||
|
[allOn,darkOn].forEach(function(chk){chk.addEventListener('change',function(){toggleGroup(chk);resolve();});});
|
||||||
|
Object.keys(f).forEach(function(k){f[k].addEventListener('input',resolve);});
|
||||||
|
resolve();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<form class="form" method="post" action="/_gm/banners/{{.ID}}/delete" onsubmit="return confirm('Delete this campaign and its messages?')">
|
<form class="form" method="post" action="/_gm/banners/{{.ID}}/delete" onsubmit="return confirm('Delete this campaign and its messages?')">
|
||||||
<button type="submit" class="danger">Delete campaign</button>
|
<button type="submit" class="danger">Delete campaign</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -498,15 +498,30 @@ type BannerCampaignRow struct {
|
|||||||
|
|
||||||
// BannerDetailView is the campaign detail/edit page. StartsAt/EndsAt are the
|
// 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.
|
// "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 {
|
type BannerDetailView struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Weight int
|
Weight int
|
||||||
IsDefault bool
|
IsDefault bool
|
||||||
Enabled bool
|
Enabled bool
|
||||||
StartsAt string
|
StartsAt string
|
||||||
EndsAt string
|
EndsAt string
|
||||||
Messages []BannerMessageRow
|
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
|
// BannerMessageRow is one bilingual message of a campaign. First/Last drive the
|
||||||
|
|||||||
+77
-13
@@ -30,6 +30,15 @@ var ErrDefaultImmutable = errors.New("ads: the default campaign cannot be modifi
|
|||||||
// window or message body).
|
// window or message body).
|
||||||
var ErrValidation = errors.New("ads: validation")
|
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.
|
// Campaign is one advertising placement order with its messages.
|
||||||
type Campaign struct {
|
type Campaign struct {
|
||||||
ID uuid.UUID // uuid.Nil on create
|
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
|
StartsAt *time.Time // nil = open-ended start; always nil for the default
|
||||||
EndsAt *time.Time // nil = open-ended end; always nil for the default
|
EndsAt *time.Time // nil = open-ended end; always nil for the default
|
||||||
Messages []Message
|
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
|
CreatedAt time.Time
|
||||||
UpdatedAt 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:
|
// 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
|
// 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 {
|
type ActiveCampaign struct {
|
||||||
Weight int
|
Weight int
|
||||||
Messages []string
|
Messages []string
|
||||||
|
OverrideAll *ColorSet
|
||||||
|
OverrideDark *ColorSet
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eligible reports whether an account should be shown the advertising banner: a
|
// 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
|
// computeActiveSet builds the resolved rotation feed from the enabled campaigns
|
||||||
// at time now, in language lang. Campaigns outside their validity window, and
|
// at time now, in language lang, and reports whether the feed is an urgent one.
|
||||||
// campaigns with no messages, are dropped. The default campaign's effective
|
// Campaigns outside their validity window, and campaigns with no messages, are
|
||||||
// weight is the remainder up to 100% — max(0, 100 - sum of active timed
|
// dropped.
|
||||||
// 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
|
// While any active campaign is urgent, the feed is the urgent campaigns alone —
|
||||||
// fair rotation cycle stays short. The input is expected to be the enabled
|
// every non-urgent timed campaign and the default remainder are suppressed for
|
||||||
// campaigns (ActiveCampaigns); disabled ones must already be excluded.
|
// the duration (and the caller shows the feed to every viewer, bypassing
|
||||||
func computeActiveSet(campaigns []Campaign, now time.Time, lang string) []ActiveCampaign {
|
// 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 timed []Campaign
|
||||||
var def *Campaign
|
var def *Campaign
|
||||||
for i := range campaigns {
|
for i := range campaigns {
|
||||||
@@ -108,20 +138,54 @@ func computeActiveSet(campaigns []Campaign, now time.Time, lang string) []Active
|
|||||||
timed = append(timed, c)
|
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
|
sumTimed := 0
|
||||||
for _, c := range timed {
|
for _, c := range timed {
|
||||||
sumTimed += c.Weight
|
sumTimed += c.Weight
|
||||||
}
|
}
|
||||||
out := make([]ActiveCampaign, 0, len(timed)+1)
|
out := make([]ActiveCampaign, 0, len(timed)+1)
|
||||||
for _, c := range timed {
|
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 def != nil {
|
||||||
if dw := 100 - sumTimed; dw > 0 {
|
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)
|
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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,12 +46,19 @@ func TestComputeActiveSet(t *testing.T) {
|
|||||||
timed := func(name string, weight int, starts, ends *time.Time, msgs []Message) Campaign {
|
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}
|
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 {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
campaigns []Campaign
|
campaigns []Campaign
|
||||||
lang string
|
lang string
|
||||||
want []ActiveCampaign
|
want []ActiveCampaign
|
||||||
|
wantUrgent bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "default only reduces to weight 1",
|
name: "default only reduces to weight 1",
|
||||||
@@ -152,22 +159,71 @@ func TestComputeActiveSet(t *testing.T) {
|
|||||||
lang: "en",
|
lang: "en",
|
||||||
want: []ActiveCampaign{{Weight: 1, Messages: []string{"one-en", "two-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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
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) {
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
t.Errorf("computeActiveSet() =\n %#v\nwant\n %#v", 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) {
|
func TestComputeActiveSetEmpty(t *testing.T) {
|
||||||
// No campaigns at all yields an empty (non-nil-or-nil) feed without panicking.
|
// No campaigns at all yields an empty (non-nil-or-nil) feed without panicking.
|
||||||
if got := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 {
|
if got, urgent := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 || urgent {
|
||||||
t.Errorf("computeActiveSet(nil) = %#v, want empty", got)
|
t.Errorf("computeActiveSet(nil) = %#v urgent=%v, want empty non-urgent", got, urgent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ads
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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
|
// ActiveSet returns the resolved rotation feed for a viewer in language lang
|
||||||
// (en/ru) together with the global display timings: the currently-active
|
// (en/ru) together with the global display timings: the currently-active
|
||||||
// campaigns, each with its GCD-reduced show weight and its messages resolved to
|
// campaigns, each with its GCD-reduced show weight and its messages resolved to
|
||||||
// lang, ready for the client's weighted round-robin.
|
// lang, ready for the client's weighted round-robin. The bool result reports
|
||||||
func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, error) {
|
// 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)
|
campaigns, err := s.store.ActiveCampaigns(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, Timings{}, err
|
return nil, Timings{}, false, err
|
||||||
}
|
}
|
||||||
timings, err := s.store.Settings(ctx)
|
timings, err := s.store.Settings(ctx)
|
||||||
if err != nil {
|
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.
|
// 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 {
|
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
|
||||||
return uuid.Nil, err
|
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{
|
return s.store.CreateCampaign(ctx, Campaign{
|
||||||
Name: name, Weight: c.Weight, Enabled: c.Enabled, StartsAt: c.StartsAt, EndsAt: c.EndsAt,
|
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.Enabled = true
|
||||||
upd.StartsAt = nil
|
upd.StartsAt = nil
|
||||||
upd.EndsAt = nil
|
upd.EndsAt = nil
|
||||||
|
// The default (house) campaign stays plain: no colour overrides, never urgent.
|
||||||
} else {
|
} else {
|
||||||
if err := validWeight(c.Weight); err != nil {
|
if err := validWeight(c.Weight); err != nil {
|
||||||
return err
|
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 {
|
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
all, dark, err := validOverrides(c.OverrideAll, c.OverrideDark)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
upd.Weight = c.Weight
|
upd.Weight = c.Weight
|
||||||
upd.Enabled = c.Enabled
|
upd.Enabled = c.Enabled
|
||||||
upd.StartsAt = c.StartsAt
|
upd.StartsAt = c.StartsAt
|
||||||
upd.EndsAt = c.EndsAt
|
upd.EndsAt = c.EndsAt
|
||||||
|
upd.OverrideAll = all
|
||||||
|
upd.OverrideDark = dark
|
||||||
|
upd.Urgent = c.Urgent
|
||||||
}
|
}
|
||||||
return s.store.UpdateCampaign(ctx, upd)
|
return s.store.UpdateCampaign(ctx, upd)
|
||||||
}
|
}
|
||||||
@@ -254,6 +271,46 @@ func validWindow(starts, ends *time.Time) error {
|
|||||||
return nil
|
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.
|
// validBodies trims and bounds both mandatory language bodies of a message.
|
||||||
func validBodies(bodyEn, bodyRu string) (string, string, error) {
|
func validBodies(bodyEn, bodyRu string) (string, string, error) {
|
||||||
en := strings.TrimSpace(bodyEn)
|
en := strings.TrimSpace(bodyEn)
|
||||||
|
|||||||
@@ -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) {
|
func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) {
|
||||||
id := uuid.New()
|
id := uuid.New()
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
abg, afg, alink := colorCols(c.OverrideAll)
|
||||||
|
dbg, dfg, dlink := colorCols(c.OverrideDark)
|
||||||
stmt := table.AdCampaigns.INSERT(
|
stmt := table.AdCampaigns.INSERT(
|
||||||
table.AdCampaigns.CampaignID, table.AdCampaigns.Name, table.AdCampaigns.Weight,
|
table.AdCampaigns.CampaignID, table.AdCampaigns.Name, table.AdCampaigns.Weight,
|
||||||
table.AdCampaigns.IsDefault, table.AdCampaigns.Enabled,
|
table.AdCampaigns.IsDefault, table.AdCampaigns.Enabled,
|
||||||
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt,
|
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,
|
table.AdCampaigns.CreatedAt, table.AdCampaigns.UpdatedAt,
|
||||||
).VALUES(
|
).VALUES(
|
||||||
postgres.UUID(id), postgres.String(c.Name), postgres.Int(int64(c.Weight)),
|
postgres.UUID(id), postgres.String(c.Name), postgres.Int(int64(c.Weight)),
|
||||||
postgres.Bool(false), postgres.Bool(c.Enabled),
|
postgres.Bool(false), postgres.Bool(c.Enabled),
|
||||||
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt),
|
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt),
|
||||||
|
abg, afg, alink,
|
||||||
|
dbg, dfg, dlink,
|
||||||
|
postgres.Bool(c.Urgent),
|
||||||
postgres.TimestampzT(now), postgres.TimestampzT(now),
|
postgres.TimestampzT(now), postgres.TimestampzT(now),
|
||||||
)
|
)
|
||||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
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
|
// window. The default flag is never touched here. Returns ErrNotFound when no
|
||||||
// campaign matches.
|
// campaign matches.
|
||||||
func (s *Store) UpdateCampaign(ctx context.Context, c Campaign) error {
|
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(
|
stmt := table.AdCampaigns.UPDATE(
|
||||||
table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.Enabled,
|
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(
|
).SET(
|
||||||
postgres.String(c.Name), postgres.Int(int64(c.Weight)), postgres.Bool(c.Enabled),
|
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)))
|
).WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(c.ID)))
|
||||||
return execOne(ctx, s.db, stmt, "update campaign")
|
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 {
|
func modelToCampaign(r model.AdCampaigns) Campaign {
|
||||||
return Campaign{
|
return Campaign{
|
||||||
ID: r.CampaignID,
|
ID: r.CampaignID,
|
||||||
Name: r.Name,
|
Name: r.Name,
|
||||||
Weight: int(r.Weight),
|
Weight: int(r.Weight),
|
||||||
IsDefault: r.IsDefault,
|
IsDefault: r.IsDefault,
|
||||||
Enabled: r.Enabled,
|
Enabled: r.Enabled,
|
||||||
StartsAt: r.StartsAt,
|
StartsAt: r.StartsAt,
|
||||||
EndsAt: r.EndsAt,
|
EndsAt: r.EndsAt,
|
||||||
CreatedAt: r.CreatedAt,
|
OverrideAll: colorSetFrom(r.OverrideBg, r.OverrideFg, r.OverrideLink),
|
||||||
UpdatedAt: r.UpdatedAt,
|
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 {
|
func modelToMessage(r model.AdMessages) Message {
|
||||||
return Message{
|
return Message{
|
||||||
ID: r.MessageID,
|
ID: r.MessageID,
|
||||||
|
|||||||
@@ -192,8 +192,9 @@ func TestBannerMessageOwnership(t *testing.T) {
|
|||||||
type profileBanner struct {
|
type profileBanner struct {
|
||||||
Banner *struct {
|
Banner *struct {
|
||||||
Campaigns []struct {
|
Campaigns []struct {
|
||||||
Weight int `json:"weight"`
|
Weight int `json:"weight"`
|
||||||
Messages []string `json:"messages"`
|
Messages []string `json:"messages"`
|
||||||
|
OverrideBg string `json:"override_bg"`
|
||||||
} `json:"campaigns"`
|
} `json:"campaigns"`
|
||||||
Timings struct {
|
Timings struct {
|
||||||
HoldMs int `json:"hold_ms"`
|
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)
|
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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,13 +13,20 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AdCampaigns struct {
|
type AdCampaigns struct {
|
||||||
CampaignID uuid.UUID `sql:"primary_key"`
|
CampaignID uuid.UUID `sql:"primary_key"`
|
||||||
Name string
|
Name string
|
||||||
Weight int32
|
Weight int32
|
||||||
IsDefault bool
|
IsDefault bool
|
||||||
Enabled bool
|
Enabled bool
|
||||||
StartsAt *time.Time
|
StartsAt *time.Time
|
||||||
EndsAt *time.Time
|
EndsAt *time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
|
OverrideBg *string
|
||||||
|
OverrideFg *string
|
||||||
|
OverrideLink *string
|
||||||
|
OverrideBgDark *string
|
||||||
|
OverrideFgDark *string
|
||||||
|
OverrideLinkDark *string
|
||||||
|
Urgent bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,15 +17,22 @@ type adCampaignsTable struct {
|
|||||||
postgres.Table
|
postgres.Table
|
||||||
|
|
||||||
// Columns
|
// Columns
|
||||||
CampaignID postgres.ColumnString
|
CampaignID postgres.ColumnString
|
||||||
Name postgres.ColumnString
|
Name postgres.ColumnString
|
||||||
Weight postgres.ColumnInteger
|
Weight postgres.ColumnInteger
|
||||||
IsDefault postgres.ColumnBool
|
IsDefault postgres.ColumnBool
|
||||||
Enabled postgres.ColumnBool
|
Enabled postgres.ColumnBool
|
||||||
StartsAt postgres.ColumnTimestampz
|
StartsAt postgres.ColumnTimestampz
|
||||||
EndsAt postgres.ColumnTimestampz
|
EndsAt postgres.ColumnTimestampz
|
||||||
CreatedAt postgres.ColumnTimestampz
|
CreatedAt postgres.ColumnTimestampz
|
||||||
UpdatedAt 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
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
@@ -67,33 +74,47 @@ func newAdCampaignsTable(schemaName, tableName, alias string) *AdCampaignsTable
|
|||||||
|
|
||||||
func newAdCampaignsTableImpl(schemaName, tableName, alias string) adCampaignsTable {
|
func newAdCampaignsTableImpl(schemaName, tableName, alias string) adCampaignsTable {
|
||||||
var (
|
var (
|
||||||
CampaignIDColumn = postgres.StringColumn("campaign_id")
|
CampaignIDColumn = postgres.StringColumn("campaign_id")
|
||||||
NameColumn = postgres.StringColumn("name")
|
NameColumn = postgres.StringColumn("name")
|
||||||
WeightColumn = postgres.IntegerColumn("weight")
|
WeightColumn = postgres.IntegerColumn("weight")
|
||||||
IsDefaultColumn = postgres.BoolColumn("is_default")
|
IsDefaultColumn = postgres.BoolColumn("is_default")
|
||||||
EnabledColumn = postgres.BoolColumn("enabled")
|
EnabledColumn = postgres.BoolColumn("enabled")
|
||||||
StartsAtColumn = postgres.TimestampzColumn("starts_at")
|
StartsAtColumn = postgres.TimestampzColumn("starts_at")
|
||||||
EndsAtColumn = postgres.TimestampzColumn("ends_at")
|
EndsAtColumn = postgres.TimestampzColumn("ends_at")
|
||||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||||
allColumns = postgres.ColumnList{CampaignIDColumn, NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn}
|
OverrideBgColumn = postgres.StringColumn("override_bg")
|
||||||
mutableColumns = postgres.ColumnList{NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn}
|
OverrideFgColumn = postgres.StringColumn("override_fg")
|
||||||
defaultColumns = postgres.ColumnList{IsDefaultColumn, EnabledColumn, CreatedAtColumn, UpdatedAtColumn}
|
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{
|
return adCampaignsTable{
|
||||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
//Columns
|
//Columns
|
||||||
CampaignID: CampaignIDColumn,
|
CampaignID: CampaignIDColumn,
|
||||||
Name: NameColumn,
|
Name: NameColumn,
|
||||||
Weight: WeightColumn,
|
Weight: WeightColumn,
|
||||||
IsDefault: IsDefaultColumn,
|
IsDefault: IsDefaultColumn,
|
||||||
Enabled: EnabledColumn,
|
Enabled: EnabledColumn,
|
||||||
StartsAt: StartsAtColumn,
|
StartsAt: StartsAtColumn,
|
||||||
EndsAt: EndsAtColumn,
|
EndsAt: EndsAtColumn,
|
||||||
CreatedAt: CreatedAtColumn,
|
CreatedAt: CreatedAtColumn,
|
||||||
UpdatedAt: UpdatedAtColumn,
|
UpdatedAt: UpdatedAtColumn,
|
||||||
|
OverrideBg: OverrideBgColumn,
|
||||||
|
OverrideFg: OverrideFgColumn,
|
||||||
|
OverrideLink: OverrideLinkColumn,
|
||||||
|
OverrideBgDark: OverrideBgDarkColumn,
|
||||||
|
OverrideFgDark: OverrideFgDarkColumn,
|
||||||
|
OverrideLinkDark: OverrideLinkDarkColumn,
|
||||||
|
Urgent: UrgentColumn,
|
||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -22,10 +22,20 @@ type bannerDTO struct {
|
|||||||
|
|
||||||
// bannerCampaignDTO is one campaign in the rotation feed: its GCD-reduced show
|
// 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
|
// 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 {
|
type bannerCampaignDTO struct {
|
||||||
Weight int `json:"weight"`
|
Weight int `json:"weight"`
|
||||||
Messages []string `json:"messages"`
|
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.
|
// 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 {
|
if s.ads == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
hasNoBanner, err := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner)
|
set, timings, urgent, err := s.ads.ActiveSet(ctx, bannerLang(acc))
|
||||||
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))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("banner: active set failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
s.log.Warn("banner: active set failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
||||||
return nil
|
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))
|
campaigns := make([]bannerCampaignDTO, 0, len(set))
|
||||||
for _, c := range set {
|
for _, c := range set {
|
||||||
campaigns = append(campaigns, bannerCampaignDTO{Weight: c.Weight, Messages: c.Messages})
|
campaigns = append(campaigns, bannerCampaignFromActive(c))
|
||||||
}
|
}
|
||||||
return &bannerDTO{
|
return &bannerDTO{
|
||||||
Campaigns: campaigns,
|
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
|
// bannerLang resolves the message language for a viewer: their interface language
|
||||||
// (Russian, or English by default).
|
// (Russian, or English by default).
|
||||||
func bannerLang(acc account.Account) string {
|
func bannerLang(acc account.Account) string {
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ const bannersBack = "/_gm/banners"
|
|||||||
// bound; the operator's entry is interpreted as UTC.
|
// bound; the operator's entry is interpreted as UTC.
|
||||||
const localTimeLayout = "2006-01-02T15:04"
|
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
|
// consoleBanners lists the advertising campaigns (default first), each with its
|
||||||
// weight, validity window, enabled flag, message count and live-now status.
|
// weight, validity window, enabled flag, message count and live-now status.
|
||||||
func (s *Server) consoleBanners(c *gin.Context) {
|
func (s *Server) consoleBanners(c *gin.Context) {
|
||||||
@@ -56,14 +65,19 @@ func (s *Server) consoleBannerDetail(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
view := adminconsole.BannerDetailView{
|
view := adminconsole.BannerDetailView{
|
||||||
ID: cm.ID.String(),
|
ID: cm.ID.String(),
|
||||||
Name: cm.Name,
|
Name: cm.Name,
|
||||||
Weight: cm.Weight,
|
Weight: cm.Weight,
|
||||||
IsDefault: cm.IsDefault,
|
IsDefault: cm.IsDefault,
|
||||||
Enabled: cm.Enabled,
|
Enabled: cm.Enabled,
|
||||||
StartsAt: localInput(cm.StartsAt),
|
StartsAt: localInput(cm.StartsAt),
|
||||||
EndsAt: localInput(cm.EndsAt),
|
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 {
|
for i, m := range cm.Messages {
|
||||||
view.Messages = append(view.Messages, adminconsole.BannerMessageRow{
|
view.Messages = append(view.Messages, adminconsole.BannerMessageRow{
|
||||||
ID: m.ID.String(),
|
ID: m.ID.String(),
|
||||||
@@ -111,12 +125,15 @@ func (s *Server) consoleUpdateBanner(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
err = s.ads.UpdateCampaign(c.Request.Context(), ads.Campaign{
|
err = s.ads.UpdateCampaign(c.Request.Context(), ads.Campaign{
|
||||||
ID: id,
|
ID: id,
|
||||||
Name: trimForm(c, "name"),
|
Name: trimForm(c, "name"),
|
||||||
Weight: atoiForm(c, "weight"),
|
Weight: atoiForm(c, "weight"),
|
||||||
Enabled: c.PostForm("enabled") != "",
|
Enabled: c.PostForm("enabled") != "",
|
||||||
StartsAt: starts,
|
StartsAt: starts,
|
||||||
EndsAt: ends,
|
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 {
|
if err != nil {
|
||||||
s.bannerError(c, err, back)
|
s.bannerError(c, err, back)
|
||||||
@@ -321,3 +338,29 @@ func atoiForm(c *gin.Context, name string) int {
|
|||||||
n, _ := strconv.Atoi(trimForm(c, name))
|
n, _ := strconv.Atoi(trimForm(c, name))
|
||||||
return n
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+13
-1
@@ -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
|
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),
|
`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*
|
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
|
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
|
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
|
browser), so an open in-app session reflects it at once. The live stream is single-shot with no
|
||||||
|
|||||||
+15
-4
@@ -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 —
|
**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.
|
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
|
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
|
and schedule campaigns, write each campaign's bilingual messages (reorder or remove them), pick a
|
||||||
the global display **timings** (how long a message holds, the scroll of an over-long message, and the
|
non-default campaign's optional override colours (a native colour picker with a live light-and-dark
|
||||||
fade-out → gap → fade-in transition between messages). The default campaign cannot be deleted and
|
preview of the strip) and mark it urgent, and set the global display **timings** (how long a message
|
||||||
keeps at least one 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).
|
||||||
|
|||||||
+14
-3
@@ -496,8 +496,19 @@ high-rate флага. С карточки пользователя операт
|
|||||||
**честно** — каждая получает свою долю по весу за цикл, равномерно размазанную, а не случайно — а
|
**честно** — каждая получает свою долю по весу за цикл, равномерно размазанную, а не случайно — а
|
||||||
кампания с несколькими сообщениями показывает их по очереди.
|
кампания с несколькими сообщениями показывает их по очереди.
|
||||||
|
|
||||||
|
Недефолтная кампания может нести собственные **цвета** — фон, текст и ссылку — чтобы рекламное или
|
||||||
|
служебное сообщение выделялось на фоне нейтральной ленты. Цвета задаются двумя наборами: один
|
||||||
|
применяется на **любой теме**, а необязательный второй переопределяет только **тёмную тему**, чтобы
|
||||||
|
кампания хорошо читалась на обеих; рамка ленты выводится из фона автоматически. Кампанию можно также
|
||||||
|
пометить как **срочную (urgent)**: срочная кампания показывается **всем** — даже платным игрокам,
|
||||||
|
владельцам подсказок и пользователям с ролью `no_banner` — и, пока она активна, лента показывает
|
||||||
|
**только её** (обычные кампании и домашняя дефолтная уступают место). Срочность — для настоящих
|
||||||
|
системных уведомлений (тех.работы, сбои), а не для рекламы.
|
||||||
|
|
||||||
Всем этим оператор управляет в админ-консоли по адресу **`/_gm/banners`**: создаёт, редактирует,
|
Всем этим оператор управляет в админ-консоли по адресу **`/_gm/banners`**: создаёт, редактирует,
|
||||||
включает/выключает и планирует кампании, пишет двуязычные сообщения кампании (переставляет и удаляет
|
включает/выключает и планирует кампании, пишет двуязычные сообщения кампании (переставляет и удаляет
|
||||||
их) и задаёт общие **тайминги** показа (сколько держится сообщение, прокрутка слишком длинного, и
|
их), выбирает необязательные цвета недефолтной кампании (нативный color-picker с живым превью ленты
|
||||||
переход fade-out → пауза → fade-in между сообщениями). Дефолтную кампанию нельзя удалить, и в ней
|
на светлой и тёмной темах) и помечает её срочной, а также задаёт общие **тайминги** показа (сколько
|
||||||
всегда остаётся хотя бы одно сообщение.
|
держится сообщение, прокрутка слишком длинного, и переход fade-out → пауза → fade-in между
|
||||||
|
сообщениями). Дефолтную кампанию нельзя удалить, в ней всегда остаётся хотя бы одно сообщение, и она
|
||||||
|
остаётся простой (без цветов, никогда не срочная).
|
||||||
|
|||||||
@@ -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
|
operator-set (`/_gm/banner-settings`). Under **reduce-motion** the fades collapse to an instant swap
|
||||||
and a long message does not scroll.
|
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`)
|
## Result / status iconography (`lib/result.ts`)
|
||||||
|
|
||||||
Lobby rows show two lines (opponents, then result + score) with a large place-based emoji
|
Lobby rows show two lines (opponents, then result + score) with a large place-based emoji
|
||||||
|
|||||||
@@ -53,9 +53,18 @@ type BannerResp struct {
|
|||||||
|
|
||||||
// BannerCampaignResp is one campaign in the rotation feed: a GCD-reduced show
|
// BannerCampaignResp is one campaign in the rotation feed: a GCD-reduced show
|
||||||
// weight and its messages, in display order, already resolved to one language.
|
// 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 {
|
type BannerCampaignResp struct {
|
||||||
Weight int `json:"weight"`
|
Weight int `json:"weight"`
|
||||||
Messages []string `json:"messages"`
|
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.
|
// BannerTimingsResp mirrors the backend's global display timings.
|
||||||
|
|||||||
@@ -118,6 +118,23 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
|||||||
return b.FinishedBytes()
|
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
|
// 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
|
// 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
|
// 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])
|
b.PrependUOffsetT(msgOffsets[j])
|
||||||
}
|
}
|
||||||
msgs := b.EndVector(len(msgOffsets))
|
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.BannerCampaignStart(b)
|
||||||
fb.BannerCampaignAddWeight(b, int32(c.Weight))
|
fb.BannerCampaignAddWeight(b, int32(c.Weight))
|
||||||
fb.BannerCampaignAddMessages(b, msgs)
|
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)
|
campOffsets[i] = fb.BannerCampaignEnd(b)
|
||||||
}
|
}
|
||||||
fb.BannerInfoStartCampaignsVector(b, len(campOffsets))
|
fb.BannerInfoStartCampaignsVector(b, len(campOffsets))
|
||||||
|
|||||||
@@ -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
|
// TestProfileGetNoBanner verifies an ineligible viewer's profile carries no
|
||||||
// banner block (the backend omits it).
|
// banner block (the backend omits it).
|
||||||
func TestProfileGetNoBanner(t *testing.T) {
|
func TestProfileGetNoBanner(t *testing.T) {
|
||||||
|
|||||||
+11
-1
@@ -180,10 +180,20 @@ table Ack {
|
|||||||
// BannerCampaign is one campaign in the rotation feed: a GCD-reduced show weight
|
// 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)
|
// (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
|
// 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 {
|
table BannerCampaign {
|
||||||
weight:int;
|
weight:int;
|
||||||
messages:[string];
|
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
|
// BannerInfo is the advertising-banner block of an eligible viewer's profile: the
|
||||||
|
|||||||
@@ -70,8 +70,56 @@ func (rcv *BannerCampaign) MessagesLength() int {
|
|||||||
return 0
|
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) {
|
func BannerCampaignStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(2)
|
builder.StartObject(8)
|
||||||
}
|
}
|
||||||
func BannerCampaignAddWeight(builder *flatbuffers.Builder, weight int32) {
|
func BannerCampaignAddWeight(builder *flatbuffers.Builder, weight int32) {
|
||||||
builder.PrependInt32Slot(0, weight, 0)
|
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 {
|
func BannerCampaignStartMessagesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||||
return builder.StartVector(4, numElems, 4)
|
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 {
|
func BannerCampaignEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
import {
|
import {
|
||||||
attachBannerHost,
|
attachBannerHost,
|
||||||
bannerCurrent,
|
bannerCurrent,
|
||||||
|
bannerCurrentColors,
|
||||||
configureBanner,
|
configureBanner,
|
||||||
detachBannerHost,
|
detachBannerHost,
|
||||||
remeasureBanner,
|
remeasureBanner,
|
||||||
} from '../lib/bannerEngine';
|
} from '../lib/bannerEngine';
|
||||||
import { defaultBannerTimings, linkify, type BannerHost } from '../lib/banner';
|
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';
|
import { onExternalLinkClick } from '../lib/links';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -21,12 +23,33 @@
|
|||||||
// current message and is visible at once (no fade — see inFade), so a screen change does not
|
// 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).
|
// replay the fade. Empty on the very first mount (engine not yet started).
|
||||||
let current = $state(bannerCurrent());
|
let current = $state(bannerCurrent());
|
||||||
|
let currentColors = $state<BannerColors>(bannerCurrentColors());
|
||||||
let visible = $state(bannerCurrent() !== '');
|
let visible = $state(bannerCurrent() !== '');
|
||||||
let tx = $state(0);
|
let tx = $state(0);
|
||||||
let txDur = $state(0);
|
let txDur = $state(0);
|
||||||
let track = $state<HTMLElement>();
|
let track = $state<HTMLElement>();
|
||||||
let viewport = $state<HTMLElement>();
|
let viewport = $state<HTMLElement>();
|
||||||
|
|
||||||
|
// The rendered theme (light/dark), tracked so a campaign's colour override resolves against the
|
||||||
|
// theme actually on screen and re-resolves live when the operator/OS flips it. It follows the
|
||||||
|
// [data-theme] attribute (theme.ts), or the OS preference when unset ('auto').
|
||||||
|
let themeMode = $state<ThemeMode>(resolveThemeMode());
|
||||||
|
function resolveThemeMode(): ThemeMode {
|
||||||
|
if (typeof document === 'undefined') return 'light';
|
||||||
|
const attr = document.documentElement.getAttribute('data-theme');
|
||||||
|
if (attr === 'dark' || attr === 'light') return attr;
|
||||||
|
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
// The strip's colours for the current campaign + theme (null = the neutral tokens). Applied as
|
||||||
|
// inline CSS variables scoped to .ad, so an override never leaks to the rest of the page.
|
||||||
|
const adColors = $derived(resolveBannerColors(currentColors, themeMode));
|
||||||
|
const adStyle = $derived(
|
||||||
|
adColors
|
||||||
|
? `--ad-bg:${adColors.bg};--text-muted:${adColors.fg};--accent:${adColors.link};--ad-border:${adColors.border}`
|
||||||
|
: '',
|
||||||
|
);
|
||||||
|
|
||||||
// The first appearance after mounting onto an already-running cycle is instant; every later
|
// The first appearance after mounting onto an already-running cycle is instant; every later
|
||||||
// message change fades. (Consumed by the first in:fade.)
|
// message change fades. (Consumed by the first in:fade.)
|
||||||
let instantOnce = bannerCurrent() !== '';
|
let instantOnce = bannerCurrent() !== '';
|
||||||
@@ -50,8 +73,9 @@
|
|||||||
// The DOM host the engine drives. The fade lives on the {#if} layer (transition:fade), the scroll
|
// The DOM host the engine drives. The fade lives on the {#if} layer (transition:fade), the scroll
|
||||||
// on the inner track's transform, so a long message's scroll never blocks its fade in/out.
|
// on the inner track's transform, so a long message's scroll never blocks its fade in/out.
|
||||||
const host: BannerHost = {
|
const host: BannerHost = {
|
||||||
show(md) {
|
show(md, colors) {
|
||||||
current = md;
|
current = md;
|
||||||
|
currentColors = colors;
|
||||||
tx = 0;
|
tx = 0;
|
||||||
txDur = 0;
|
txDur = 0;
|
||||||
visible = true;
|
visible = true;
|
||||||
@@ -93,6 +117,20 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
configureBanner(campaigns, eff);
|
configureBanner(campaigns, eff);
|
||||||
});
|
});
|
||||||
|
// Track the rendered theme so a colour override re-resolves when the theme flips: watch the
|
||||||
|
// [data-theme] attribute (Settings toggle / Telegram) and, for 'auto', the OS colour scheme.
|
||||||
|
$effect(() => {
|
||||||
|
const update = () => (themeMode = resolveThemeMode());
|
||||||
|
update();
|
||||||
|
const obs = new MutationObserver(update);
|
||||||
|
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||||
|
const mq = window.matchMedia?.('(prefers-color-scheme: dark)');
|
||||||
|
mq?.addEventListener('change', update);
|
||||||
|
return () => {
|
||||||
|
obs.disconnect();
|
||||||
|
mq?.removeEventListener('change', update);
|
||||||
|
};
|
||||||
|
});
|
||||||
// Re-measure on a viewport size change (e.g. a portrait↔landscape rotation): a message that fit
|
// Re-measure on a viewport size change (e.g. a portrait↔landscape rotation): a message that fit
|
||||||
// may now overflow, or vice versa, so the scroll must be re-evaluated. Debounced.
|
// may now overflow, or vice versa, so the scroll must be re-evaluated. Debounced.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -116,7 +154,7 @@
|
|||||||
confirmation, the VK away-redirect keeps the Android WebView on the game. -->
|
confirmation, the VK away-redirect keeps the Android WebView on the game. -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div class="ad" bind:this={viewport} onclick={onExternalLinkClick}>
|
<div class="ad" bind:this={viewport} style={adStyle} onclick={onExternalLinkClick}>
|
||||||
<!-- An always-present, invisible spacer reserves exactly one line of height, so the strip never
|
<!-- An always-present, invisible spacer reserves exactly one line of height, so the strip never
|
||||||
collapses while the message layer is absent during the fade gap (the message is overlaid
|
collapses while the message layer is absent during the fade gap (the message is overlaid
|
||||||
absolutely, so its presence/absence does not change the strip height). -->
|
absolutely, so its presence/absence does not change the strip height). -->
|
||||||
@@ -145,8 +183,8 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18);
|
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--ad-border, var(--border));
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--ad-border, var(--border));
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
/* Reserves one line of height inside the padding so .ad keeps a constant height even during the
|
/* Reserves one line of height inside the padding so .ad keeps a constant height even during the
|
||||||
|
|||||||
@@ -37,8 +37,50 @@ messagesLength():number {
|
|||||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
overrideBg():string|null
|
||||||
|
overrideBg(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
overrideBg(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
overrideFg():string|null
|
||||||
|
overrideFg(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
overrideFg(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 10);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
overrideLink():string|null
|
||||||
|
overrideLink(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
overrideLink(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 12);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
overrideBgDark():string|null
|
||||||
|
overrideBgDark(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
overrideBgDark(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 14);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
overrideFgDark():string|null
|
||||||
|
overrideFgDark(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
overrideFgDark(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 16);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
overrideLinkDark():string|null
|
||||||
|
overrideLinkDark(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
overrideLinkDark(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 18);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
static startBannerCampaign(builder:flatbuffers.Builder) {
|
static startBannerCampaign(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(2);
|
builder.startObject(8);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addWeight(builder:flatbuffers.Builder, weight:number) {
|
static addWeight(builder:flatbuffers.Builder, weight:number) {
|
||||||
@@ -61,15 +103,45 @@ static startMessagesVector(builder:flatbuffers.Builder, numElems:number) {
|
|||||||
builder.startVector(4, numElems, 4);
|
builder.startVector(4, numElems, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addOverrideBg(builder:flatbuffers.Builder, overrideBgOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(2, overrideBgOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addOverrideFg(builder:flatbuffers.Builder, overrideFgOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(3, overrideFgOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addOverrideLink(builder:flatbuffers.Builder, overrideLinkOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(4, overrideLinkOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addOverrideBgDark(builder:flatbuffers.Builder, overrideBgDarkOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(5, overrideBgDarkOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addOverrideFgDark(builder:flatbuffers.Builder, overrideFgDarkOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(6, overrideFgDarkOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addOverrideLinkDark(builder:flatbuffers.Builder, overrideLinkDarkOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(7, overrideLinkDarkOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
static endBannerCampaign(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endBannerCampaign(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
static createBannerCampaign(builder:flatbuffers.Builder, weight:number, messagesOffset:flatbuffers.Offset):flatbuffers.Offset {
|
static createBannerCampaign(builder:flatbuffers.Builder, weight:number, messagesOffset:flatbuffers.Offset, overrideBgOffset:flatbuffers.Offset, overrideFgOffset:flatbuffers.Offset, overrideLinkOffset:flatbuffers.Offset, overrideBgDarkOffset:flatbuffers.Offset, overrideFgDarkOffset:flatbuffers.Offset, overrideLinkDarkOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
BannerCampaign.startBannerCampaign(builder);
|
BannerCampaign.startBannerCampaign(builder);
|
||||||
BannerCampaign.addWeight(builder, weight);
|
BannerCampaign.addWeight(builder, weight);
|
||||||
BannerCampaign.addMessages(builder, messagesOffset);
|
BannerCampaign.addMessages(builder, messagesOffset);
|
||||||
|
BannerCampaign.addOverrideBg(builder, overrideBgOffset);
|
||||||
|
BannerCampaign.addOverrideFg(builder, overrideFgOffset);
|
||||||
|
BannerCampaign.addOverrideLink(builder, overrideLinkOffset);
|
||||||
|
BannerCampaign.addOverrideBgDark(builder, overrideBgDarkOffset);
|
||||||
|
BannerCampaign.addOverrideFgDark(builder, overrideFgDarkOffset);
|
||||||
|
BannerCampaign.addOverrideLinkDark(builder, overrideLinkDarkOffset);
|
||||||
return BannerCampaign.endBannerCampaign(builder);
|
return BannerCampaign.endBannerCampaign(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ describe('createScheduler', () => {
|
|||||||
{ weight: 7, messages: ['b'] },
|
{ weight: 7, messages: ['b'] },
|
||||||
]);
|
]);
|
||||||
expect(sched.total).toBe(2);
|
expect(sched.total).toBe(2);
|
||||||
const cycle = Array.from({ length: 10 }, () => sched.next());
|
const cycle = Array.from({ length: 10 }, () => sched.next().md);
|
||||||
const a = cycle.filter((m) => m === 'a').length;
|
const a = cycle.filter((m) => m === 'a').length;
|
||||||
const b = cycle.filter((m) => m === 'b').length;
|
const b = cycle.filter((m) => m === 'b').length;
|
||||||
expect(a).toBe(3);
|
expect(a).toBe(3);
|
||||||
@@ -42,7 +42,7 @@ describe('createScheduler', () => {
|
|||||||
it('round-robins messages within a campaign', () => {
|
it('round-robins messages within a campaign', () => {
|
||||||
const sched = createScheduler([{ weight: 1, messages: ['m0', 'm1', 'm2'] }]);
|
const sched = createScheduler([{ weight: 1, messages: ['m0', 'm1', 'm2'] }]);
|
||||||
expect(sched.total).toBe(3);
|
expect(sched.total).toBe(3);
|
||||||
expect([sched.next(), sched.next(), sched.next(), sched.next()]).toEqual(['m0', 'm1', 'm2', 'm0']);
|
expect([sched.next().md, sched.next().md, sched.next().md, sched.next().md]).toEqual(['m0', 'm1', 'm2', 'm0']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops empty and non-positive-weight campaigns', () => {
|
it('drops empty and non-positive-weight campaigns', () => {
|
||||||
@@ -52,13 +52,27 @@ describe('createScheduler', () => {
|
|||||||
{ weight: 2, messages: ['keep'] },
|
{ weight: 2, messages: ['keep'] },
|
||||||
]);
|
]);
|
||||||
expect(sched.total).toBe(1);
|
expect(sched.total).toBe(1);
|
||||||
expect(sched.next()).toBe('keep');
|
expect(sched.next().md).toBe('keep');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is empty for no campaigns', () => {
|
it('is empty for no campaigns', () => {
|
||||||
const sched = createScheduler([]);
|
const sched = createScheduler([]);
|
||||||
expect(sched.total).toBe(0);
|
expect(sched.total).toBe(0);
|
||||||
expect(sched.next()).toBe('');
|
expect(sched.next().md).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries each campaign colour override with its message', () => {
|
||||||
|
const red = { bg: '#aa0000', fg: '#ffffff', link: '#ffdd00' };
|
||||||
|
const darkRed = { bg: '#330000', fg: '#eeeeee', link: '#ffcc00' };
|
||||||
|
const sched = createScheduler([
|
||||||
|
{ weight: 1, messages: ['plain'] },
|
||||||
|
{ weight: 1, messages: ['branded'], overrideAll: red, overrideDark: darkRed },
|
||||||
|
]);
|
||||||
|
const two = [sched.next(), sched.next()];
|
||||||
|
const branded = two.find((it) => it.md === 'branded');
|
||||||
|
const plain = two.find((it) => it.md === 'plain');
|
||||||
|
expect(branded?.colors).toEqual({ all: red, dark: darkRed });
|
||||||
|
expect(plain?.colors).toEqual({ all: null, dark: null });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+21
-12
@@ -4,7 +4,7 @@
|
|||||||
// unit-testable with fake timers. The campaigns + timings come from the profile's
|
// unit-testable with fake timers. The campaigns + timings come from the profile's
|
||||||
// banner block (see lib/model, ARCHITECTURE §10); the host is AdBanner.svelte.
|
// banner block (see lib/model, ARCHITECTURE §10); the host is AdBanner.svelte.
|
||||||
|
|
||||||
import type { BannerCampaign, BannerTimings } from './model';
|
import type { BannerCampaign, BannerColors, BannerTimings } from './model';
|
||||||
|
|
||||||
/** Fallback display timings, matching the seeded ad_settings defaults. */
|
/** Fallback display timings, matching the seeded ad_settings defaults. */
|
||||||
export const defaultBannerTimings: BannerTimings = {
|
export const defaultBannerTimings: BannerTimings = {
|
||||||
@@ -16,9 +16,15 @@ export const defaultBannerTimings: BannerTimings = {
|
|||||||
fadeInMs: 1_000,
|
fadeInMs: 1_000,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** BannerItem is one scheduled message plus its campaign's colour override (for the strip). */
|
||||||
|
export interface BannerItem {
|
||||||
|
md: string;
|
||||||
|
colors: BannerColors;
|
||||||
|
}
|
||||||
|
|
||||||
/** Scheduler yields the next message to show, fairly across weighted campaigns. */
|
/** Scheduler yields the next message to show, fairly across weighted campaigns. */
|
||||||
export interface Scheduler {
|
export interface Scheduler {
|
||||||
next(): string;
|
next(): BannerItem;
|
||||||
/** Total messages across all (non-empty, positive-weight) campaigns. */
|
/** Total messages across all (non-empty, positive-weight) campaigns. */
|
||||||
readonly total: number;
|
readonly total: number;
|
||||||
}
|
}
|
||||||
@@ -33,15 +39,17 @@ export interface Scheduler {
|
|||||||
*/
|
*/
|
||||||
export function createScheduler(campaigns: BannerCampaign[]): Scheduler {
|
export function createScheduler(campaigns: BannerCampaign[]): Scheduler {
|
||||||
const cs = campaigns.filter((c) => c.weight > 0 && c.messages.length > 0);
|
const cs = campaigns.filter((c) => c.weight > 0 && c.messages.length > 0);
|
||||||
|
const colors: BannerColors[] = cs.map((c) => ({ all: c.overrideAll ?? null, dark: c.overrideDark ?? null }));
|
||||||
const current = cs.map(() => 0); // SWRR running weights
|
const current = cs.map(() => 0); // SWRR running weights
|
||||||
const cursor = cs.map(() => 0); // per-campaign round-robin position
|
const cursor = cs.map(() => 0); // per-campaign round-robin position
|
||||||
const totalWeight = cs.reduce((sum, c) => sum + c.weight, 0);
|
const totalWeight = cs.reduce((sum, c) => sum + c.weight, 0);
|
||||||
const total = cs.reduce((sum, c) => sum + c.messages.length, 0);
|
const total = cs.reduce((sum, c) => sum + c.messages.length, 0);
|
||||||
|
const empty: BannerColors = { all: null, dark: null };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total,
|
total,
|
||||||
next(): string {
|
next(): BannerItem {
|
||||||
if (cs.length === 0) return '';
|
if (cs.length === 0) return { md: '', colors: empty };
|
||||||
let best = 0;
|
let best = 0;
|
||||||
for (let i = 0; i < cs.length; i++) {
|
for (let i = 0; i < cs.length; i++) {
|
||||||
current[i] += cs[i].weight;
|
current[i] += cs[i].weight;
|
||||||
@@ -51,15 +59,16 @@ export function createScheduler(campaigns: BannerCampaign[]): Scheduler {
|
|||||||
const c = cs[best];
|
const c = cs[best];
|
||||||
const md = c.messages[cursor[best] % c.messages.length];
|
const md = c.messages[cursor[best] % c.messages.length];
|
||||||
cursor[best]++;
|
cursor[best]++;
|
||||||
return md;
|
return { md, colors: colors[best] };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The host the rotator drives; AdBanner.svelte supplies the DOM measurements and effects. */
|
/** The host the rotator drives; AdBanner.svelte supplies the DOM measurements and effects. */
|
||||||
export interface BannerHost {
|
export interface BannerHost {
|
||||||
/** Render md and fade it in (over fadeInMs), with the scroll reset to the start. */
|
/** Render md and fade it in (over fadeInMs), with the scroll reset to the start. colors is the
|
||||||
show(md: string): void;
|
* source campaign's colour override, applied to the strip (its neutral tokens when unset). */
|
||||||
|
show(md: string, colors: BannerColors): void;
|
||||||
/** Reset the scroll to the start without re-fading (to loop a long message). */
|
/** Reset the scroll to the start without re-fading (to loop a long message). */
|
||||||
resetScroll(): void;
|
resetScroll(): void;
|
||||||
/** Fade the current message out over durationMs. */
|
/** Fade the current message out over durationMs. */
|
||||||
@@ -98,7 +107,7 @@ export function createBannerRotator(
|
|||||||
const sched = createScheduler(campaigns);
|
const sched = createScheduler(campaigns);
|
||||||
let running = false;
|
let running = false;
|
||||||
let cycleStart = 0;
|
let cycleStart = 0;
|
||||||
let lastShown = ''; // the message currently presented, for restart() (re-measure)
|
let lastShown: BannerItem | null = null; // the item currently presented, for restart() (re-measure)
|
||||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
|
||||||
const at = (ms: number, fn: () => void) => {
|
const at = (ms: number, fn: () => void) => {
|
||||||
@@ -109,11 +118,11 @@ export function createBannerRotator(
|
|||||||
timers.length = 0;
|
timers.length = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
function present(md: string) {
|
function present(item: BannerItem) {
|
||||||
if (!running) return;
|
if (!running) return;
|
||||||
clear();
|
clear();
|
||||||
lastShown = md;
|
lastShown = item;
|
||||||
host.show(md);
|
host.show(item.md, item.colors);
|
||||||
at(config.fadeInMs, measure);
|
at(config.fadeInMs, measure);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +152,7 @@ export function createBannerRotator(
|
|||||||
// the same fade as a message change, not a hard jump.
|
// the same fade as a message change, not a hard jump.
|
||||||
host.hide(config.fadeOutMs);
|
host.hide(config.fadeOutMs);
|
||||||
at(config.fadeOutMs + config.gapMs, () => {
|
at(config.fadeOutMs + config.gapMs, () => {
|
||||||
host.show(lastShown);
|
if (lastShown) host.show(lastShown.md, lastShown.colors);
|
||||||
at(config.fadeInMs, () => scrollCycle(over));
|
at(config.fadeInMs, () => scrollCycle(over));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { derivedBorder, resolveBannerColors } from './bannerColors';
|
||||||
|
import type { BannerColors } from './model';
|
||||||
|
|
||||||
|
const red = { bg: '#aa0000', fg: '#ffffff', link: '#ffdd00' };
|
||||||
|
const darkRed = { bg: '#330000', fg: '#eeeeee', link: '#ffcc00' };
|
||||||
|
|
||||||
|
describe('resolveBannerColors', () => {
|
||||||
|
it('returns null when the campaign has no override (neutral tokens)', () => {
|
||||||
|
const none: BannerColors = { all: null, dark: null };
|
||||||
|
expect(resolveBannerColors(none, 'light')).toBeNull();
|
||||||
|
expect(resolveBannerColors(none, 'dark')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the all-themes set on both themes when no dark override', () => {
|
||||||
|
const c: BannerColors = { all: red, dark: null };
|
||||||
|
expect(resolveBannerColors(c, 'light')).toMatchObject({ bg: red.bg, fg: red.fg, link: red.link });
|
||||||
|
expect(resolveBannerColors(c, 'dark')).toMatchObject({ bg: red.bg, fg: red.fg, link: red.link });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the dark override on the dark theme, and the all set on light', () => {
|
||||||
|
const c: BannerColors = { all: red, dark: darkRed };
|
||||||
|
expect(resolveBannerColors(c, 'light')).toMatchObject({ bg: red.bg });
|
||||||
|
expect(resolveBannerColors(c, 'dark')).toMatchObject({ bg: darkRed.bg });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports a dark-only override (light keeps the neutral tokens)', () => {
|
||||||
|
const c: BannerColors = { all: null, dark: darkRed };
|
||||||
|
expect(resolveBannerColors(c, 'light')).toBeNull();
|
||||||
|
expect(resolveBannerColors(c, 'dark')).toMatchObject({ bg: darkRed.bg });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives the border from the resolved background', () => {
|
||||||
|
const c: BannerColors = { all: red, dark: null };
|
||||||
|
expect(resolveBannerColors(c, 'light')?.border).toBe(derivedBorder(red.bg));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('derivedBorder', () => {
|
||||||
|
it('darkens a light background and lightens a dark one', () => {
|
||||||
|
expect(derivedBorder('#ffffff')).toBe('#dbdbdb');
|
||||||
|
expect(derivedBorder('#000000')).toBe('#242424');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a malformed colour unchanged', () => {
|
||||||
|
expect(derivedBorder('nope')).toBe('nope');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Banner colour resolution. A campaign may carry two optional colour sets (see
|
||||||
|
// lib/model BannerColors): one for every theme and one that further overrides the
|
||||||
|
// dark theme. resolveBannerColors collapses them to the colours the strip paints on
|
||||||
|
// the currently-rendered theme, or null when the campaign keeps the neutral
|
||||||
|
// --ad-bg / --text-muted / --accent tokens. Pure and DOM-free, so it is unit-tested
|
||||||
|
// directly; AdBanner.svelte applies the result as inline CSS variables.
|
||||||
|
|
||||||
|
import type { BannerColors } from './model';
|
||||||
|
|
||||||
|
export type ThemeMode = 'light' | 'dark';
|
||||||
|
|
||||||
|
/** ResolvedBannerColors is the strip's colours for one theme, border already derived. */
|
||||||
|
export interface ResolvedBannerColors {
|
||||||
|
bg: string;
|
||||||
|
fg: string;
|
||||||
|
link: string;
|
||||||
|
border: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resolveBannerColors picks the colour set for the rendered theme — dark ← dark ?? all,
|
||||||
|
* light ← all — and returns null when there is no override for that theme (the strip then
|
||||||
|
* keeps the neutral tokens). The border is derived from the background so it stays subtle
|
||||||
|
* on any colour.
|
||||||
|
*/
|
||||||
|
export function resolveBannerColors(colors: BannerColors, mode: ThemeMode): ResolvedBannerColors | null {
|
||||||
|
const set = mode === 'dark' ? (colors.dark ?? colors.all) : colors.all;
|
||||||
|
if (!set) return null;
|
||||||
|
return { bg: set.bg, fg: set.fg, link: set.link, border: derivedBorder(set.bg) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* derivedBorder nudges the background 14% toward black on a light background and toward
|
||||||
|
* white on a dark one — a subtle edge that suits any override colour. It is computed in
|
||||||
|
* JS (no CSS color-mix) so it works on the old Android WebView floor (Chrome 67), and
|
||||||
|
* mirrors the admin console preview (banner_detail.gohtml). A malformed colour is
|
||||||
|
* returned unchanged.
|
||||||
|
*/
|
||||||
|
export function derivedBorder(bgHex: string): string {
|
||||||
|
const rgb = hexToRgb(bgHex);
|
||||||
|
if (!rgb) return bgHex;
|
||||||
|
const target: RGB = luminance(rgb) > 0.5 ? [0, 0, 0] : [255, 255, 255];
|
||||||
|
return rgbToHex(mix(rgb, target, 0.14));
|
||||||
|
}
|
||||||
|
|
||||||
|
type RGB = [number, number, number];
|
||||||
|
|
||||||
|
function hexToRgb(h: string): RGB | null {
|
||||||
|
const m = /^#([0-9a-fA-F]{6})$/.exec(h.trim());
|
||||||
|
if (!m) return null;
|
||||||
|
const n = m[1];
|
||||||
|
return [parseInt(n.slice(0, 2), 16), parseInt(n.slice(2, 4), 16), parseInt(n.slice(4, 6), 16)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mix(a: RGB, b: RGB, t: number): RGB {
|
||||||
|
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
|
||||||
|
}
|
||||||
|
|
||||||
|
function luminance(r: RGB): number {
|
||||||
|
return (0.2126 * r[0] + 0.7152 * r[1] + 0.0722 * r[2]) / 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(x: number): string {
|
||||||
|
return Math.max(0, Math.min(255, Math.round(x))).toString(16).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToHex(r: RGB): string {
|
||||||
|
return '#' + pad(r[0]) + pad(r[1]) + pad(r[2]);
|
||||||
|
}
|
||||||
@@ -4,11 +4,14 @@
|
|||||||
// independent of route transitions (the cycle is not restarted on navigation).
|
// independent of route transitions (the cycle is not restarted on navigation).
|
||||||
|
|
||||||
import { createBannerRotator, type BannerHost, type Rotator } from './banner';
|
import { createBannerRotator, type BannerHost, type Rotator } from './banner';
|
||||||
import type { BannerCampaign, BannerTimings } from './model';
|
import type { BannerCampaign, BannerColors, BannerTimings } from './model';
|
||||||
|
|
||||||
|
const noColors: BannerColors = { all: null, dark: null };
|
||||||
|
|
||||||
let rotator: Rotator | null = null;
|
let rotator: Rotator | null = null;
|
||||||
let mounted: BannerHost | null = null;
|
let mounted: BannerHost | null = null;
|
||||||
let current = '';
|
let current = '';
|
||||||
|
let currentColors: BannerColors = noColors;
|
||||||
let key = '';
|
let key = '';
|
||||||
// The in-flight horizontal scroll of the current message, so a view mounted by navigation can
|
// The in-flight horizontal scroll of the current message, so a view mounted by navigation can
|
||||||
// resume it from the same offset instead of restarting at the left. Null when not scrolling.
|
// resume it from the same offset instead of restarting at the left. Null when not scrolling.
|
||||||
@@ -17,10 +20,11 @@ let activeScroll: { toPx: number; dur: number; start: number } | null = null;
|
|||||||
// proxy is the rotator's host: it records the current message + scroll (so a freshly-mounted view
|
// proxy is the rotator's host: it records the current message + scroll (so a freshly-mounted view
|
||||||
// can resume them) and forwards every effect to the currently-attached DOM host, if any.
|
// can resume them) and forwards every effect to the currently-attached DOM host, if any.
|
||||||
const proxy: BannerHost = {
|
const proxy: BannerHost = {
|
||||||
show(md) {
|
show(md, colors) {
|
||||||
current = md;
|
current = md;
|
||||||
|
currentColors = colors;
|
||||||
activeScroll = null;
|
activeScroll = null;
|
||||||
mounted?.show(md);
|
mounted?.show(md, colors);
|
||||||
},
|
},
|
||||||
resetScroll() {
|
resetScroll() {
|
||||||
activeScroll = null;
|
activeScroll = null;
|
||||||
@@ -58,6 +62,7 @@ export function configureBanner(campaigns: BannerCampaign[], timings: BannerTimi
|
|||||||
key = k;
|
key = k;
|
||||||
rotator?.stop();
|
rotator?.stop();
|
||||||
current = '';
|
current = '';
|
||||||
|
currentColors = noColors;
|
||||||
rotator = createBannerRotator(campaigns, proxy, timings);
|
rotator = createBannerRotator(campaigns, proxy, timings);
|
||||||
rotator.start();
|
rotator.start();
|
||||||
}
|
}
|
||||||
@@ -71,6 +76,15 @@ export function bannerCurrent(): string {
|
|||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* bannerCurrentColors returns the colour override of the campaign whose message is currently
|
||||||
|
* displayed (neutral tokens — both sets null — before the first message). A freshly-mounted view
|
||||||
|
* reads it alongside bannerCurrent so the strip paints the right colours immediately on mount.
|
||||||
|
*/
|
||||||
|
export function bannerCurrentColors(): BannerColors {
|
||||||
|
return currentColors;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* attachBannerHost connects a freshly-mounted view as the DOM host. It does NOT re-show the
|
* attachBannerHost connects a freshly-mounted view as the DOM host. It does NOT re-show the
|
||||||
* current message (the view renders it itself from bannerCurrent on mount): re-showing here would
|
* current message (the view renders it itself from bannerCurrent on mount): re-showing here would
|
||||||
|
|||||||
@@ -647,7 +647,10 @@ describe('codec', () => {
|
|||||||
const uid = b.createString('u-1');
|
const uid = b.createString('u-1');
|
||||||
const m0 = b.createString('promo-en');
|
const m0 = b.createString('promo-en');
|
||||||
const msgs = fb.BannerCampaign.createMessagesVector(b, [m0]);
|
const msgs = fb.BannerCampaign.createMessagesVector(b, [m0]);
|
||||||
const camp = fb.BannerCampaign.createBannerCampaign(b, 3, msgs);
|
fb.BannerCampaign.startBannerCampaign(b);
|
||||||
|
fb.BannerCampaign.addWeight(b, 3);
|
||||||
|
fb.BannerCampaign.addMessages(b, msgs);
|
||||||
|
const camp = fb.BannerCampaign.endBannerCampaign(b);
|
||||||
const camps = fb.BannerInfo.createCampaignsVector(b, [camp]);
|
const camps = fb.BannerInfo.createCampaignsVector(b, [camp]);
|
||||||
const banner = fb.BannerInfo.createBannerInfo(b, camps, 60000, 5000, 40, 1000, 250, 1000);
|
const banner = fb.BannerInfo.createBannerInfo(b, camps, 60000, 5000, 40, 1000, 250, 1000);
|
||||||
fb.Profile.startProfile(b);
|
fb.Profile.startProfile(b);
|
||||||
@@ -656,7 +659,8 @@ describe('codec', () => {
|
|||||||
b.finish(fb.Profile.endProfile(b));
|
b.finish(fb.Profile.endProfile(b));
|
||||||
|
|
||||||
const p = decodeProfile(b.asUint8Array());
|
const p = decodeProfile(b.asUint8Array());
|
||||||
expect(p.banner?.campaigns).toEqual([{ weight: 3, messages: ['promo-en'] }]);
|
// A plain campaign carries no override (both sets null → the neutral theme tokens).
|
||||||
|
expect(p.banner?.campaigns).toEqual([{ weight: 3, messages: ['promo-en'], overrideAll: null, overrideDark: null }]);
|
||||||
expect(p.banner?.timings).toEqual({
|
expect(p.banner?.timings).toEqual({
|
||||||
holdMs: 60000,
|
holdMs: 60000,
|
||||||
edgePauseMs: 5000,
|
edgePauseMs: 5000,
|
||||||
@@ -667,6 +671,26 @@ describe('codec', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('decodes a campaign colour override (both sets)', () => {
|
||||||
|
const b = new Builder(256);
|
||||||
|
const uid = b.createString('u-1');
|
||||||
|
const m0 = b.createString('branded');
|
||||||
|
const msgs = fb.BannerCampaign.createMessagesVector(b, [m0]);
|
||||||
|
const s = (v: string) => b.createString(v);
|
||||||
|
const [obg, ofg, ol, obgd, ofgd, old] = ['#aa0000', '#ffffff', '#ffdd00', '#330000', '#eeeeee', '#ffcc00'].map(s);
|
||||||
|
const camp = fb.BannerCampaign.createBannerCampaign(b, 1, msgs, obg, ofg, ol, obgd, ofgd, old);
|
||||||
|
const camps = fb.BannerInfo.createCampaignsVector(b, [camp]);
|
||||||
|
const banner = fb.BannerInfo.createBannerInfo(b, camps, 60000, 5000, 40, 1000, 250, 1000);
|
||||||
|
fb.Profile.startProfile(b);
|
||||||
|
fb.Profile.addUserId(b, uid);
|
||||||
|
fb.Profile.addBanner(b, banner);
|
||||||
|
b.finish(fb.Profile.endProfile(b));
|
||||||
|
|
||||||
|
const p = decodeProfile(b.asUint8Array());
|
||||||
|
expect(p.banner?.campaigns[0].overrideAll).toEqual({ bg: '#aa0000', fg: '#ffffff', link: '#ffdd00' });
|
||||||
|
expect(p.banner?.campaigns[0].overrideDark).toEqual({ bg: '#330000', fg: '#eeeeee', link: '#ffcc00' });
|
||||||
|
});
|
||||||
|
|
||||||
it('leaves the profile banner undefined when absent', () => {
|
it('leaves the profile banner undefined when absent', () => {
|
||||||
const b = new Builder(64);
|
const b = new Builder(64);
|
||||||
const uid = b.createString('u-1');
|
const uid = b.createString('u-1');
|
||||||
|
|||||||
+13
-1
@@ -19,6 +19,7 @@ import type {
|
|||||||
BestMoveTile,
|
BestMoveTile,
|
||||||
BlockStatus,
|
BlockStatus,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
|
ColorTriple,
|
||||||
EvalResult,
|
EvalResult,
|
||||||
FeedbackState,
|
FeedbackState,
|
||||||
FriendCode,
|
FriendCode,
|
||||||
@@ -368,6 +369,12 @@ function decodeVariantPreferences(p: fb.Profile): Variant[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bannerTriple builds a colour override from the three wire fields, or null when the
|
||||||
|
// set is absent (any field empty/missing) — the strip then keeps the neutral tokens.
|
||||||
|
function bannerTriple(bg: string | null, fg: string | null, link: string | null): ColorTriple | null {
|
||||||
|
return bg && fg && link ? { bg, fg, link } : null;
|
||||||
|
}
|
||||||
|
|
||||||
// decodeBanner projects the optional advertising-banner block of a Profile, or
|
// decodeBanner projects the optional advertising-banner block of a Profile, or
|
||||||
// undefined when the viewer is not eligible (the field is absent).
|
// undefined when the viewer is not eligible (the field is absent).
|
||||||
function decodeBanner(p: fb.Profile): Banner | undefined {
|
function decodeBanner(p: fb.Profile): Banner | undefined {
|
||||||
@@ -379,7 +386,12 @@ function decodeBanner(p: fb.Profile): Banner | undefined {
|
|||||||
if (!c) continue;
|
if (!c) continue;
|
||||||
const messages: string[] = [];
|
const messages: string[] = [];
|
||||||
for (let j = 0; j < c.messagesLength(); j++) messages.push(c.messages(j));
|
for (let j = 0; j < c.messagesLength(); j++) messages.push(c.messages(j));
|
||||||
campaigns.push({ weight: c.weight(), messages });
|
campaigns.push({
|
||||||
|
weight: c.weight(),
|
||||||
|
messages,
|
||||||
|
overrideAll: bannerTriple(c.overrideBg(), c.overrideFg(), c.overrideLink()),
|
||||||
|
overrideDark: bannerTriple(c.overrideBgDark(), c.overrideFgDark(), c.overrideLinkDark()),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
campaigns,
|
campaigns,
|
||||||
|
|||||||
@@ -169,12 +169,33 @@ export interface Banner {
|
|||||||
timings: BannerTimings;
|
timings: BannerTimings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ColorTriple is a banner colour override's three "#rrggbb" colours. */
|
||||||
|
export interface ColorTriple {
|
||||||
|
bg: string;
|
||||||
|
fg: string;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BannerColors is a campaign's optional palette: `all` overrides every theme, `dark`
|
||||||
|
* further overrides the dark theme (either may be null — the neutral tokens then apply).
|
||||||
|
* resolveBannerColors (lib/bannerColors) collapses it to the rendered theme's colours.
|
||||||
|
*/
|
||||||
|
export interface BannerColors {
|
||||||
|
all: ColorTriple | null;
|
||||||
|
dark: ColorTriple | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** BannerCampaign is one campaign in the rotation feed. */
|
/** BannerCampaign is one campaign in the rotation feed. */
|
||||||
export interface BannerCampaign {
|
export interface BannerCampaign {
|
||||||
/** GCD-reduced show weight for the client's smooth weighted round-robin. */
|
/** GCD-reduced show weight for the client's smooth weighted round-robin. */
|
||||||
weight: number;
|
weight: number;
|
||||||
/** Messages in display order, already resolved to the viewer's bot language. */
|
/** Messages in display order, already resolved to the viewer's bot language. */
|
||||||
messages: string[];
|
messages: string[];
|
||||||
|
/** Colour override for every theme; null (or absent) keeps the neutral tokens. */
|
||||||
|
overrideAll?: ColorTriple | null;
|
||||||
|
/** Colour override for the dark theme only; null (or absent) falls back to overrideAll/tokens. */
|
||||||
|
overrideDark?: ColorTriple | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** BannerTimings are the global banner display timings (ms, except scrollPxPerSec). */
|
/** BannerTimings are the global banner display timings (ms, except scrollPxPerSec). */
|
||||||
|
|||||||
Reference in New Issue
Block a user