Files
scrabble-game/backend/internal/server/banner.go
T
Ilia Denisov dc582e9f73
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
fix(ads): restore reliable banner fades + keep banner on profile update
Two regressions from the previous banner pass:

- Fade (#2): the manual-opacity fade could paint opacity 0 and 1 in one frame and
  skip the transition — most visible for a single (default) campaign message,
  whose only fade is the first show. Revert the fade to Svelte transition:fade
  (which forces the from-state, so even the first/only message fades), keeping it
  on its own {#if} layer independent of the scroll. A freshly-mounted view onto a
  running cycle still renders the live message instantly (inFade duration 0 once),
  so navigation does not replay the fade. Verified by opacity sampling: advances
  fade, navigation stays at opacity 1.

- Profile update (#3): the banner block was attached only to GET /profile, so a
  profile.update (e.g. a language switch) returned a profile without it and the
  banner vanished until reload. A shared profileResponse() now attaches the banner
  to GET, PUT and the link/merge profile responses. Regression test added
  (TestBannerSurvivesProfileUpdate).

Still open: the scroll position is not preserved across navigation (the view
remounts); discussed separately.
2026-06-16 06:09:35 +02:00

111 lines
3.9 KiB
Go

package server
import (
"context"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/ads"
"scrabble/backend/internal/notify"
)
// bannerDTO is the advertising-banner block attached to an eligible viewer's
// profile: the campaigns to rotate (each already resolved to the viewer's bot
// language) and the global display timings the client rotator reads. It is
// absent for a viewer who should see no banner.
type bannerDTO struct {
Campaigns []bannerCampaignDTO `json:"campaigns"`
Timings bannerTimingsDTO `json:"timings"`
}
// bannerCampaignDTO is one campaign in the rotation feed: its GCD-reduced show
// weight (for the client's smooth weighted round-robin) and its messages, in
// display order, already resolved to one language.
type bannerCampaignDTO struct {
Weight int `json:"weight"`
Messages []string `json:"messages"`
}
// bannerTimingsDTO mirrors ads.Timings on the wire.
type bannerTimingsDTO struct {
HoldMs int `json:"hold_ms"`
EdgePauseMs int `json:"edge_pause_ms"`
ScrollPxPerSec int `json:"scroll_px_per_sec"`
FadeOutMs int `json:"fade_out_ms"`
GapMs int `json:"gap_ms"`
FadeInMs int `json:"fade_in_ms"`
}
// profileResponse builds the account's profile DTO with the advertising-banner block attached when
// the viewer is eligible. Every endpoint returning the caller's own profile (get, update, link)
// uses it, so the banner never drops off the client's profile after a non-get profile change (e.g.
// a language switch).
func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse {
r := profileResponseFor(acc)
r.Banner = s.bannerFor(ctx, acc)
return r
}
// bannerFor builds the advertising-banner block for the account's profile, or
// nil when the ads service is not configured or the viewer is not eligible to
// see a banner. The message language follows the account's bot (service)
// language, falling back to its interface language and then English. A failure
// reading roles or campaigns is logged and treated as "no banner" so the profile
// response still succeeds.
func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO {
if s.ads == nil {
return nil
}
hasNoBanner, err := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner)
if err != nil {
s.log.Warn("banner: role check failed", zap.String("account", acc.ID.String()), zap.Error(err))
return nil
}
if !ads.Eligible(acc.PaidAccount, acc.HintBalance, hasNoBanner) {
return nil
}
set, timings, err := s.ads.ActiveSet(ctx, bannerLang(acc))
if err != nil {
s.log.Warn("banner: active set failed", zap.String("account", acc.ID.String()), zap.Error(err))
return nil
}
campaigns := make([]bannerCampaignDTO, 0, len(set))
for _, c := range set {
campaigns = append(campaigns, bannerCampaignDTO{Weight: c.Weight, Messages: c.Messages})
}
return &bannerDTO{
Campaigns: campaigns,
Timings: bannerTimingsDTO{
HoldMs: timings.HoldMs,
EdgePauseMs: timings.EdgePauseMs,
ScrollPxPerSec: timings.ScrollPxPerSec,
FadeOutMs: timings.FadeOutMs,
GapMs: timings.GapMs,
FadeInMs: timings.FadeInMs,
},
}
}
// bannerLang resolves the message language for a viewer: the bot (service)
// language they last signed in through, falling back to the interface language
// and then English.
func bannerLang(acc account.Account) string {
if acc.ServiceLanguage == "ru" || acc.ServiceLanguage == "en" {
return acc.ServiceLanguage
}
if acc.PreferredLanguage == "ru" {
return "ru"
}
return "en"
}
// publishBannerChange emits the in-app "banner eligibility may have changed"
// re-poll signal to the account, so an open client re-fetches profile.get and
// shows or hides the banner without a reload. Best-effort (notify.Nop when no
// notifier is wired).
func (s *Server) publishBannerChange(id uuid.UUID) {
s.notifier.Publish(notify.BannerChanged(id))
}