Skip to main content

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.

SlotWhereRenderer
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 BOTTOMlive
home_all_sports_strip78px promo strip above the casino sectionlive
home_casinocasino promoauthorable 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.

TemplateThe artworkWhat the app drawsThe link
overlay (default)flat panel, no copythe white panel: tag chip, stacked headline, meta line, CTA platethe CTA plate only
artworkheadline, logos and sponsor marks baked in, no pillthe CTA pill onlythe 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 the linkedSportId + linkedTournamentName pair, 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.
  • ctaLabel is the pill's text. Blank gives Bet now.
  • layout.ctaBottom lifts the pill off the bottom edge, in px, default 10. 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 artwork object-bottom — the artwork's bottom edge is the card's.
  • titleLine1 is the card's accessible name, since the artwork is decorative to a screen reader. It is optional here: leave it blank and linkedTournamentName is 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: omitting aria-label does 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".

Three conditions, all resolved against the state the write produces, never the payload alone:

  1. 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;
  2. layout.template is artwork;
  3. linkedTournamentName is 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 stateRenders
A visible banner existsthat banner
No rows at allthe artwork and copy compiled into the app
Rows exist, none currently visiblenothing

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 old hidden flag
  • publishAt is null or in the past
  • expiresAt is 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_tournaments has 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 a warnings entry
  • 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, immutable and 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​

TemplateExportNotes
overlay279×156 at 2x (558×312)panel flush to the BOTTOM; the top 20px is the transparent bleed strip the figures rise into
artworkthe full card at 2x, panel flush to the BOTTOMcopy 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)​

  1. Create an R2 bucket.
  2. Bind it to a custom domain on the existing strykr.io zone — 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.
  3. Create an R2 API token with object read/write on that bucket.
  4. Set the backend env vars below.
  5. Set NEXT_PUBLIC_MEDIA_HOST for the frontend and deploy once so remotePatterns picks up the host.

Backend env​

Deliberately a separate namespace from the S3_BACKUP_* backup variables — different bucket, different purpose, possibly a different account.

VarRequiredNotes
MEDIA_S3_ENDPOINTfor R2e.g. https://<account>.r2.cloudflarestorage.com. Unset ⇒ AWS S3
MEDIA_S3_BUCKETyes
MEDIA_S3_REGIONnodefaults to auto (correct for R2)
MEDIA_S3_ACCESS_KEY_IDyes
MEDIA_S3_SECRET_ACCESS_KEYyes
MEDIA_PUBLIC_BASE_URLyespublic 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​

VarNotes
NEXT_PUBLIC_MEDIA_HOSThostname 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 /api responses are no-store globally, 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.