Homepage Banners
Admin- and agent-authored promotional content for the player app, replacing the hardcoded
strykr-fe/src/config/featuredTournaments.ts array that required a frontend redeploy to change
a date, hide a card, or reorder the rail.
What changed and why
Before, the five cards on the /home TOURNAMENTS rail lived in a TypeScript array. Changing
one — a date, a headline, taking a card down when its season ended — meant a code change, a
review, and a full frontend deploy. The array also had no order field: reordering the rail
meant reordering source lines. And the "take this card down" lever was a hidden: true boolean
someone had to remember to flip, which is why the English Premier League card sat hidden in the
source for weeks.
Now banners are rows in homepage_banners, served by an API, edited from the admin dashboard,
and optionally maintained by an agent. No redeploy is needed to add, edit, reorder, hide, or
remove a banner.
The one exception: next/image only loads images from hosts listed in remotePatterns, which
is build-time config. Adding a new image host needs one deploy. Adding images to an
already-allowed host does not.
Surfaces (slots)
A banner is addressed by slot. Adding a surface means adding a slot value and a renderer —
not a new table.
| Slot | Where | Renderer |
|---|---|---|
home_tournaments_rail | /home TOURNAMENTS carousel, 279×156 cards — a 136px panel under a 20px transparent bleed strip the figures rise into; export at 2x (558×312) with the panel flush to the BOTTOM | live |
home_all_sports_strip | 78px promo strip above the casino section | live |
home_casino | casino promo | authorable and served, but its renderer is still commented out inside HomeCasinoSection — a banner here is stored and invisible |
The rail's two card templates
A rail card can compose its copy in one of two ways, chosen per banner by
layout.template. Absent means overlay, so every row written before this existed keeps
rendering exactly as it did.
| Template | The artwork | What the app draws | The link |
|---|---|---|---|
overlay (default) | flat panel, no copy | the white panel: tag chip, stacked headline, meta line, CTA plate | the CTA plate only |
artwork | headline, logos and sponsor marks baked in, no pill | the CTA pill only | the whole card |
Export artwork frames WITHOUT a CTA pill. The app draws one. A frame that already contains a pill renders two. The pill is deliberately the app's, because its label is the one thing that has to change without re-exporting a creative.
Per banner rather than per slot, and deliberately not a global switch: the two kinds of asset coexist in one rail while a set is being swapped over, and every pre-existing row (plus the compiled-in fallback and the seed) carries panel-less artwork that would render title-less under a global flip. Stage a changeover by creating the new cards as drafts, checking each in the admin preview, publishing, then deactivating — not deleting — the old ones.
On an artwork card the copy columns are still stored, but only these do anything:
ctaHref, or thelinkedSportId+linkedTournamentNamepair, is where the whole card points. No resolvable link means no pill and no link — the card renders as inert artwork rather than as a control that goes nowhere, the same rule the overlay template follows for its CTA plate.ctaLabelis the pill's text. Blank givesBet now.layout.ctaBottomlifts the pill off the bottom edge, in px, default10. Horizontal placement is not authorable: every V3 frame centres the pill, so centring is the contract. Anchoring to the card's bottom is safe at any artwork ratio, because the renderer draws artworkobject-bottom— the artwork's bottom edge is the card's.titleLine1is the card's accessible name, since the artwork is decorative to a screen reader. It is optional here: leave it blank andlinkedTournamentNameis announced instead. One of the two must exist — the API refuses a write that would leave neither, and the admin form blocks the save. That is not pedantry: omittingaria-labeldoes not make a screen reader announce the href. An anchor with no label is named by its contents, and this card's only contents are the CTA pill, so the whole rail would announce as "Bet now", "Bet now", "Bet now".
When an empty headline is legal
Three conditions, all resolved against the state the write produces, never the payload alone:
- the slot is
home_tournaments_rail— it is the only one with an artwork renderer. The strip slots pass their stored title lines straight to their overlay, so a blank headline there is lost, not suppressed; layout.templateisartwork;linkedTournamentNameis non-empty, so the card still has a name.
Because it is the resulting state that matters, a PATCH is judged against the stored row
merged with the payload. A partial update carrying only {"layout": {"template": "overlay"}}
mentions no headline at all, yet would strand an empty one under the template that paints
headlines — so it is refused. Note layout is replaced wholesale on update: a payload
with a layout object that omits template lands on overlay too.
tag, metaLabel, titleLine2 and the overlay layout knobs (overlayRight,
elevated, overlayClassName) go unpainted, and the admin form hides them. It hides rather
than clears them, so switching the template back restores the card. There is also no LIVE
mark on an artwork card: it has no panel to carry one.
An unrecognised template value is rejected at the write by the API's enum, and dropped by
the read projection if one ever reaches the column by another route. Both land on overlay,
because the failure the other way is a second headline painted over a creative that already
has one.
The strip's three states
The tournaments rail is a list, so an empty slot simply means "nothing is being promoted".
The all-sports strip is a single banner that has been on /home since before it was
authorable, so an empty slot is ambiguous — and the two readings must render differently:
| Slot state | Renders |
|---|---|
| A visible banner exists | that banner |
| No rows at all | the artwork and copy compiled into the app |
| Rows exist, none currently visible | nothing |
The middle case is the safety net: an environment whose seed has not run must not lose the strip. The last is what keeps the CMS honest — unpublishing the banner, or letting its window close, is deliberate, and a fallback that overrode it would leave the dashboard unable to switch its own banner off.
banners.length cannot tell those apart, which is why the public read also returns
meta.publishedRowCount — rows with status = 'published', whatever their window or
isActive. Published, not every row: counting drafts meant the first draft an operator
created in an unseeded slot suppressed the fallback and blanked the surface until they
published it. A draft is work in progress, not a decision about what players see. The
decision itself is resolveStripSource / resolveRailSource in
strykr-fe/src/lib/bannerSlotSource.ts.
What is authorable on the strip
Content is: headline, CTA label, CTA target, artwork, layout.bleedTop, and the overlay's
position. The strip's identity is not: the STRYKR chip is a vector wordmark rather than
text and cannot be expressed as an authorable string at all, and the three-ball CTA glyph,
the #fde958 chip fill, the capitalize transform and the 110px panel width are one Figma
frame's measurements. Exposing colour and width is how an operator ships a broken panel.
The live mark stays driven by real fixture state and is not authorable — it is also the one field the admin preview cannot honestly show, which the preview says on the page.
Visibility rules
A banner reaches a player only when all of these hold:
status = 'published'(not'draft')isActive = true— the operator kill switch, the successor to the oldhiddenflagpublishAtis null or in the pastexpiresAtis null or in the future
publishAt / expiresAt are what let a banner retire itself. That matters most when an agent
is authoring them: without a window, taking down a stale promo depends on someone noticing.
Ordering within a slot is sortOrder ASC, ties broken by createdAt so the order is always
total.
The date on the card is authored copy, not fixture data
metaLabel ("13 AUG – 24 AUG") is written by a human or an agent. It is not derived from
the odds feed, deliberately:
catalogue_tournamentshas no tournament start or end date. Its only dates are ingestion bookkeeping (firstSeenAt/lastSeenAt).- Fixtures are not a database table at all — they come from the live OddsPAPI feed.
- The earliest upcoming fixture is not a season start. For a mid-season tournament it is just the next match; for a tournament the provider has not published yet, there is nothing.
Deriving the date would let the card claim dates the feed can contradict. Instead, admin reads
carry a derived, read-only fixtureCount and nextFixtureStartTime as a sanity check on
the authored copy.
The empty-CTA problem
A banner names a tournament in linkedTournamentName, and the CTA deep-links to
/sports?sportId=…&tournament=…. That name must be the provider's catalogue name, not the
marketing title — the filter matches it (normalized) against Fixture.tournamentName straight
off the odds feed, and the two routinely disagree. The original config file documents several
real cases, including a card headlined "ENGLISH PREMIER LEAGUE" that must filter on
"England Premier League", because a catalogue row literally named "English Premier League"
exists and carries zero matches.
If fixtureCount is 0, the CTA lands the player on an empty list. The admin UI shows this
prominently and publishing surfaces a warning — but it does not block, because a banner can
legitimately be scheduled ahead of the provider publishing its fixtures.
fixtureCount: null means "could not check" (feed call failed), not "checked, found none".
The two are never conflated.
One caveat on a zero: the check scans a bounded page of the provider feed per sport and
status, so fixtureCount: 0 means "not in the window we looked at" rather than a proof of
absence. For a tournament far down a very long fixture list it can read 0 while fixtures do
exist. Treat it as a strong hint, not a verdict.
Agent access
Agents authenticate with an API key, not a user account, so their writes are attributable and their access is revocable without touching a person's login.
Headers:
x-api-key-id: <public key id>
x-api-key-secret: <secret>
Scopes are deny-by-default with no wildcard: banners:read grants reads, banners:write grants
writes. The secret is stored SHA-256 hashed and compared timing-safely — a leaked database row
does not yield a working key, and there is no path that reads a key back out. Lost means rotate.
Keys can be revoked (revokedAt) or given a hard expiry (expiresAt), both enforced on every
request.
Auto-publish
Controlled by the banners.agentAutoPublish setting in platform_settings, toggleable from the
admin dashboard. Default off.
On — the agent's requested status is honoured, and it may edit live banners freely.
Off — an agent may:
- create drafts, and edit drafts
- unpublish a live banner (
PATCH { status: 'draft' }) — the sanctioned retraction
and may not:
- publish a draft — forced back to
draft, answered 200 with awarningsentry - modify a banner that is already published — refused 409
AGENT_CANNOT_EDIT_PUBLISHED - reorder a slot that contains published banners — same refusal
- hard-delete a published banner — same refusal; unpublish instead, which keeps the row and its authorship
So with the gate closed an agent cannot maintain live banners at all. That is what "wait for approval" means, and it is deliberate.
Two weaker rules were tried and rejected. Forcing every agent write to draft means an agent
fixing a typo silently pulls a live card off the homepage — a player-visible regression
caused by the safety feature. Gating only the draft → published transition lets an agent
rewrite the artwork, CTA target or copy of a live banner, which reaches players unreviewed
and is exactly what the switch exists to prevent.
The setting gates machines only. Admin writes are always honoured either way.
Only an admin can change the setting. PUT /api/admin/banners/settings refuses API keys
whatever their scopes. Gating it on banners:write would be meaningless — that is exactly
the scope every functional content-writing key must hold, so an agent could switch its own
approval gate off and then publish anything. A separate scope would survive only until
someone provisioned a key with both. Agents may still read the setting; knowing whether a
write lands as draft grants nothing.
Every row records who wrote it: agent:<keyId> for machine writes, an admin userId for human
ones.
Images
One flattened image per banner. The design layers a watermark and athlete cut-outs over a flat
panel, composited offline into a single asset — so the rail costs one request per card
instead of three, and multi-megabyte source cut-outs stay out of the repo. The API stores a URL
string, matching the CasinoGame.urlThumb convention.
Uploads go through the backend rather than a presigned direct-to-bucket PUT. That is deliberate:
keys are content-addressed (banners/<sha256:16>.<ext>), and the hash has to be computed
over the actual bytes. A presigned upload would mean trusting a client-supplied hash and would
let a client store arbitrary content under an image-shaped key. The backend sniffs real magic
bytes (PNG/JPEG/WebP/AVIF only — the Content-Type header and the filename extension are not
trusted), caps size at 5 MB, then uploads.
Because the key is the content hash:
- The same image never uploads twice.
- A changed image is always a new URL, so objects are stored
Cache-Control: public, max-age=31536000, immutableand cache purges are never needed. This matters specifically because an agent will rewrite banners often, and purge-on-update is the part that breaks at 3am.
Export spec per template
| Template | Export | Notes |
|---|---|---|
overlay | 279×156 at 2x (558×312) | panel flush to the BOTTOM; the top 20px is the transparent bleed strip the figures rise into |
artwork | the full card at 2x, panel flush to the BOTTOM | copy and logos baked in, no CTA pill — the app draws that. The renderer is object-contain object-bottom, so an asset shorter than 156 simply leaves the strip empty rather than being cropped or upscaled. Leave clear room at the bottom centre for the pill, or raise it with layout.ctaBottom |
object-contain object-bottom is why an artwork-only frame does not need to match the
rail's box exactly: whatever its ratio, it draws at its natural size pinned to the bottom
edge, which is the edge the cards line up on.
Delivery is further optimized by next/image, which resizes and negotiates AVIF/WebP. The rail
requests sizes="279px", so it serves a 279/558px variant, never the source.
Provisioning object storage
Storage is S3-compatible. Setting MEDIA_S3_ENDPOINT points it at Cloudflare R2; leaving it
unset points it at real AWS S3. Same code either way — the choice is one env var.
R2 is the recommendation because strykr.io already runs on Cloudflare, so a bucket bound to a
custom domain or a strykr.io/img/* route sits behind the CDN that is already in place, on the
same host, with no second CDN to configure and no cross-vendor egress.
Steps (Cloudflare R2)
- Create an R2 bucket.
- Bind it to a custom domain on the existing
strykr.iozone — either a subdomain or a route. Prefer same-host (strykr.io/img/*) over a subdomain: a new hostname costs an extra DNS + TLS handshake on first paint, and these images are on the LCP path. - Create an R2 API token with object read/write on that bucket.
- Set the backend env vars below.
- Set
NEXT_PUBLIC_MEDIA_HOSTfor the frontend and deploy once soremotePatternspicks up the host.
Backend env
Deliberately a separate namespace from the S3_BACKUP_* backup variables — different bucket,
different purpose, possibly a different account.
| Var | Required | Notes |
|---|---|---|
MEDIA_S3_ENDPOINT | for R2 | e.g. https://<account>.r2.cloudflarestorage.com. Unset ⇒ AWS S3 |
MEDIA_S3_BUCKET | yes | |
MEDIA_S3_REGION | no | defaults to auto (correct for R2) |
MEDIA_S3_ACCESS_KEY_ID | yes | |
MEDIA_S3_SECRET_ACCESS_KEY | yes | |
MEDIA_PUBLIC_BASE_URL | yes | public base the stored URL is built from, e.g. https://strykr.io/img |
If these are unset, the upload endpoint fails loud with MEDIA_STORAGE_NOT_CONFIGURED naming
the missing variables. It never falls back to local disk — a banner pointing at container-local
storage would break on the next deploy, and would do so silently.
Frontend env
| Var | Notes |
|---|---|
NEXT_PUBLIC_MEDIA_HOST | hostname appended to next.config.ts remotePatterns. Build-time |
Set it in the repo-root .env on the server, alongside the other NEXT_PUBLIC_* values —
that is the file docker compose reads to fill the frontend build args. The backend's own vars
go in backend/.env, which the compose file loads via env_file. It is plumbed through
docker-compose.{dev,dev1,dev2,staging,prod}.yml as ${NEXT_PUBLIC_MEDIA_HOST:-}, so leaving
it unset is safe: no extra remote pattern is added and nothing fails to build. Images simply
will not load until it is set, which is the correct failure — visible, not silent.
Caching
Two layers, and it is worth knowing which does what:
- Redis caches the per-slot banner list. Every write calls
invalidateHomepageBanners()immediately after commit, so an edit is visible on the next request — there is no TTL to wait out. - HTTP does not cache. All
/apiresponses areno-storeglobally, so a browser never serves a stale banner list. - Images are cached forever at the edge, which is safe only because keys are content-addressed.
A player already sitting on /home picks up changes via the React Query refetch interval rather
than only on next navigation.
Rollback
The migration is additive — two new tables, no existing table, column, or constraint touched, so
it cannot affect an in-flight bet. To roll the feature back without a schema change, set every
banner in a slot to isActive = false; the rail renders empty. The tables can be dropped
independently of any other migration.