Predictor

Season-long football score predictor. Two templates: the default multi-tab interface (Play, Leaderboard, Private Leagues, Rules, Prizes) for a dedicated predictor page, and embed — a lightweight single-match card designed to sit inside an article body. Includes consent gating, custom Play-tab banners, and first-class Betslip integration.

Import

import { Predictor } from "fansunited-frontend-components";
import {
  PredictorProps,
  PredictorTab,
  PredictorTemplateType,
  PredictorEmbedConfig,
  PredictorHeaderConfig,
  PredictorBetslipConfig,
  PlayTabBanner,
  ConsentDef,
} from "fansunited-frontend-core";

Required props

PropTypeDescription
entityIdstringPredictor template identifier.
sdkFansUnitedSDKModelSDK instance.
languageLanguageTypeDisplay language.

Optional props

PropTypeDefaultDescription
templatePredictorTemplateType ("standard" | "embed")"standard"Which presentation to render. See Templates below.
embedPredictorEmbedConfigOnly read when template === "embed". See Embed template.
themeOptionsCustomThemeOptionsSee Theming.
userIsLoggedInbooleanfalseHost auth state.
signInCTASignInCTADetailsSee Sign-in CTA.
tabsPredictorTab[]all tabsWhich tabs to enable. Ignored by embed.
defaultImagePlaceholderUrlstringFallback image shown when the template has no image configured. Shared between the standard hero header and the embed template's image block.
headerPredictorHeaderConfigSize and framing of the standard template's hero banner. See Header banner. Ignored by embed.
playTabBannersPlayTabBanner[]Custom banners injected into the Play tab. Ignored by embed.
consentsConsentDef[]Consent definitions required before predicting.
matchCardBgImageUrlstringBackground image URL for match prediction cards. Ignored by embed.
betslipPredictorBetslipConfigBetslip integration. See below.

Templates

type PredictorTemplateType = "standard" | "embed";
TemplateUse case
"standard" (default)The full multi-tab interface described throughout this page — Play, Leaderboard, Private Leagues, Rules, Prizes.
"embed"A single upcoming match rendered as a compact card, meant to be dropped inside an article. No tabs, no leaderboard — just branding, a description/image split, one match to predict, and (optionally) a follow-up CTA to the full experience.
<Predictor
  entityId="predictor-template-123"
  sdk={sdk}
  language="en"
  template="embed"
/>

Embed template

interface PredictorEmbedConfig {
  imagePosition?: "left" | "right"; // default: "right" — description takes the other side
  matchId?: string; // pin a specific match; otherwise the soonest upcoming match across the whole template is used
  successMessage?: {
    title?: string;
    description?: string;
    cta: OnSuccessCTADetails; // required — label + (onClick | url) + target
  };
}

Layout

  • Gradient background (top to bottom, from the theme's primary color) wrapping the whole card.
  • Branding + title, then description, stacked in a column — the template's own logo/title/description from sdk.loyalty.getTemplateById().
  • Image (the template's own image, falling back to defaultImagePlaceholderUrl), alongside the branding/description column. Controlled by embed.imagePosition ("left" or "right", default "right"); on mobile it stacks below regardless of position. When there's no image, the branding/description column simply takes the full width.
  • Match card (white, inside the gradient): kickoff date, team crests, and centered score steppers (▲ number ▼) instead of the stacked left/right-chevron rows used by the standard Play tab. The full 1X2 odds market is always shown here — before and after a prediction — unlike the standard view, which only reveals the single matching odd once a prediction exists.
  • Submit CTA: a fixed-size button below the card (not full-width), sharing styling with the post-submission CTA described next.
  • Match selection: embed.matchId pins a specific match; otherwise the soonest upcoming match across the whole template (not just the current matchweek) is auto-selected. Requires no groupIdgetTemplateMatches is called for the whole template.

Success message

Optional. When embed.successMessage is configured, submitting a prediction reveals a CTA button (same styling as the submit button, but with successMessage.cta's label/action) followed by a success alert, with a subtle pop-in animation. Omit successMessage to show nothing extra after submission — the match card still switches to its odds display.

successMessage.cta uses the familiar OnSuccessCTADetails shape (see CollectLead), but note the priority differs here: onClickurl. component is not supported — the CTA always renders as EmbedActionButton so it keeps the same fixed styling as the submit button; only the label and click behavior change.

<Predictor
  entityId="predictor-template-123"
  sdk={sdk}
  language="en"
  template="embed"
  embed={{
    imagePosition: "left",
    successMessage: {
      title: "Prediction submitted!",
      description: "Keep playing for a chance to win amazing prizes.",
      cta: {
        defaultLabel: "Play Now",
        url: "https://your-site.com/predictor",
        target: "_blank",
      },
    },
  }}
/>

Header banner

Applies to template="standard" only.

The hero banner paints the template image (or defaultImagePlaceholderUrl) over the brand gradient. By default it is full width and its height comes from the title and description, so a tall or square artwork gets cropped to a thin strip. header sizes the banner to the artwork instead.

interface PredictorHeaderConfig {
  height?: string;      // fixed height, any CSS length ("240px", "15em") — wins over aspectRatio
  minHeight?: string;   // height floor; the banner still grows for long descriptions
  aspectRatio?: string; // height as a ratio of the banner's width, e.g. "3 / 1"
  maxWidth?: string;    // caps the banner width and centres it
  imageFit?: "cover" | "contain";       // default: "cover"
  imageAlign?: string;                  // background-position, default: "center"
  background?: string;  // what sits around the image, default: the theme's primary gradient
  textPosition?: "top" | "center" | "bottom"; // default: "center"
  hideText?: boolean;   // hide title/description — needs height or aspectRatio
}

The gradient band always spans the widget's full width — maxWidth narrows only the artwork inside it, so the banner never becomes a floating island on the page background.

FieldNotes
height / minHeightheight pins the banner; minHeight only sets a floor, so the banner still grows if the text needs more room.
aspectRatioHeight follows the banner's own width, so the artwork keeps its proportions on every screen size. Ignored when height is set.
maxWidthKeeps a banner narrower than the widget; the widget background fills the sides.
imageFit"cover" fills the banner and crops the overflow. "contain" fits the whole image inside it — nothing is cropped, and the space either side is filled with a blurred blow-up of the same artwork. Use "contain" for artwork that already carries a sponsor logo or the game name.
imageAlignWhich part survives the crop under "cover" — any background-position value ("top", "left center", "50% 20%").
backgroundAny CSS background value, replacing what sits behind the image. Setting it also turns off the blurred backdrop that "contain" uses by default — reach for it only when you want a flat colour or a gradient of your own there.
textPositionWhere the title/description sit when the banner is taller than they are. No effect when the banner's height comes from the text.
hideTextDrops the title and description for artwork that already includes them. Pair it with height or aspectRatio, otherwise the banner falls back to 16 / 9.

Wide artwork (3:1–4:1) is what a header slot is shaped for — it fills the band edge to edge under the default "cover" fit:

<Predictor
  entityId="predictor-template-123"
  sdk={sdk}
  language="en"
  header={{ aspectRatio: "4 / 1" }}
/>

Sizing a poster-shaped asset (3:2, 1:1)

Prefer aspectRatio over height. "cover" scales the image to the banner's width, so with a fixed pixel height the visible slice shrinks as the page gets wider — the same height: "200px" that frames the artwork on a 900px page shows a magnified fragment of it on a 1900px one. An aspectRatio ties height to width, so the same slice of the artwork survives at every screen size.

Work out the ratio from the asset. A 3:2 image renders 1.5× the banner's width in height, so a 3 / 1 band shows half of it, 4 / 1 shows 37%, 6 / 1 shows 25%. Pick the ratio that covers the part carrying the logo and the message, then aim imageAlign at it — the visible window runs from imageAlign × (1 − visible fraction) downward:

// 3:2 poster whose logo + headline occupy 17%-66% of its height:
// a 3 / 1 band shows a 50% slice, aligned at 33% => the window is 16.5%-66.5%.
<Predictor
  {...otherProps}
  header={{ maxWidth: "1000px", aspectRatio: "3 / 1", imageAlign: "center 33%", hideText: true }}
/>

Note the maxWidth: at 3 / 1 a full-bleed banner on a 1900px page is 630px tall. Capping the artwork's width caps the height with it, and the brand gradient still spans the full width behind it. That trade-off is unavoidable — full width, short, and the whole message visible cannot all hold at once for a poster-shaped asset. The only way to have all three is a wide crop of the artwork.

imageFit: "contain" is the alternative: the whole image is shown at whatever height you set, and the space either side is filled with a blurred, darkened blow-up of the same artwork. It suits busy photographic backdrops; artwork with flat colours or hard graphic edges tends to smear. Pass background to replace that backdrop with a flat colour or your own gradient.

A taller header that still shows the title, over the top of the artwork:

<Predictor
  {...otherProps}
  header={{ minHeight: "220px", imageAlign: "top", textPosition: "bottom" }}
/>

Use em or percentage lengths rather than remrem resolves against the host page's root font size, which the widget does not control.

Tabs

Applies to template="standard" only.

type PredictorTab = "play" | "leaderboard" | "private-leagues" | "rules" | "prizes";
TabDescription
playMain prediction interface — submit match score forecasts.
leaderboardGlobal rankings with pagination and user highlight.
private-leaguesCreate and join private leagues.
rulesSearchable game rules and scoring system.
prizesPrize distribution and rewards.

Pass tabs to limit the visible tabs:

<Predictor {...otherProps} tabs={["play", "leaderboard"]} />

Play tab banners

Inject custom React content into specific slots inside the Play tab.

type PlayTabBannerPosition =
  | "above-group-nav"
  | "below-group-nav"
  | "below-matches"
  | "bottom"
  | "left"
  | "right";

interface PlayTabBanner {
  position: PlayTabBannerPosition;
  render: () => React.ReactNode;
}
PositionLocation
above-group-navAbove the matchweek group navigation.
below-group-navBelow the matchweek group navigation.
below-matchesBelow the list of match cards.
bottomVery bottom of the Play tab.
leftLeft sidebar (desktop only).
rightRight sidebar (desktop only).
<Predictor
  {...otherProps}
  playTabBanners={[
    { position: "above-group-nav", render: () => <SponsorBanner /> },
    { position: "right", render: () => <AdSlot id="predictor-sidebar" /> },
  ]}
/>

Consent gating

interface ConsentDef {
  consentId: string;
  body: string;             // HTML allowed — links, formatting
  required?: boolean;       // default false
  defaultChecked?: boolean; // default false
  position?: "before" | "after"; // ClassicQuizPlay only — ignored here
}
  • Required consents block prediction submission until accepted.
  • Optional consents appear in the same modal but do not block progression.
  • Once accepted, the modal does not reappear for that user.
<Predictor
  {...otherProps}
  consents={[
    {
      consentId: "tos",
      body: 'I accept the <a href="https://example.com/tos" target="_blank">Terms of Service</a>.',
      required: true,
      defaultChecked: false,
    },
  ]}
/>

Betslip integration

When the betslip prop is provided, Predictor renders a Betslip widget internally as a sibling — you do not add a separate <Betslip /> component to the page. See the Betslip component page for the standalone widget reference.

interface PredictorBetslipConfig {
  trigger?: PredictorBetslipTrigger; // "predictions-only" | "odds-only"
  position?: BetslipPosition;
  maxSelections?: number;
  stakePresets?: number[];
  oddsPollingInterval?: number;
  currency?: string;
  ctaUrlTemplate?: string;
  brandingLogoUrl?: string;
  labels?: BetslipLabels;
  themeOptions?: CustomThemeOptions;
}

Trigger modes

ModeWhen selections are sent to Betslip
"predictions-only" (default)Every time the user edits a score (home/away increment or decrement). If the user submits without editing (e.g. default 0-0), the selection is sent at submit time as a fallback. Outcome derivation: "1" (home win), "X" (draw), "2" (away win).
"odds-only"Only when the user clicks an odds button on the post-submit odds display. The odds button becomes a command-bus trigger instead of a direct bookmaker link.

Selection format sent by Predictor

"{matchId}:FT_1X2:{outcomeKey}"
// Example: "fb:m:451634:FT_1X2:1"  (home win)

Automatic removal

When the user deletes a submitted prediction, the matching betslip selection is removed via betslipApi.removeSelection(). When a score is edited in "predictions-only" mode after submission, the new outcome upserts the old one (no explicit removal needed).

Theme inheritance

If betslip.themeOptions is not provided, the Betslip widget inherits the Predictor's own themeOptions. A single theme on the Predictor is enough to style both consistently.

Examples

Basic

<Predictor
  entityId="predictor-template-123"
  sdk={sdk}
  language="en"
/>

Authenticated with selected tabs

<Predictor
  entityId="predictor-template-123"
  sdk={sdk}
  language="en"
  userIsLoggedIn
  tabs={["play", "leaderboard", "private-leagues"]}
  matchCardBgImageUrl="https://your-cdn.com/stadium-bg.jpg"
  themeOptions={{ mode: "dark" }}
/>

Full setup with banners and consents

<Predictor
  entityId="predictor-template-123"
  sdk={sdk}
  language="en"
  userIsLoggedIn={false}
  signInCTA={{ defaultLabel: "Sign in to Predict", onClick: handleSignIn }}
  tabs={["play", "leaderboard", "private-leagues", "rules", "prizes"]}
  matchCardBgImageUrl="https://your-cdn.com/matchcard-bg.jpg"
  playTabBanners={[
    { position: "above-group-nav", render: () => <SponsorBanner /> },
    { position: "right", render: () => <AdSlot id="predictor-sidebar" /> },
  ]}
  consents={[
    {
      consentId: "tos",
      body: 'I accept the <a href="https://example.com/tos" target="_blank">Terms of Service</a>.',
      required: true,
      defaultChecked: false,
    },
  ]}
  themeOptions={{ mode: "light" }}
/>

Predictor + Betslip (predictions trigger)

<Predictor
  entityId="predictor-template-123"
  sdk={sdk}
  language="en"
  userIsLoggedIn
  tabs={["play", "leaderboard"]}
  betslip={{
    trigger: "predictions-only",
    position: "side-right",
    currency: "£",
    stakePresets: [5, 10, 25, 50],
    ctaUrlTemplate:
      "https://your-bookmaker.com/bet?ids={selectionIds}&stake={stake}&ref={currentUrl}",
    labels: { disclaimer: "18+ | Please gamble responsibly" },
  }}
  themeOptions={{ mode: "dark" }}
/>

Predictor + Betslip (odds trigger)

<Predictor
  entityId="predictor-template-123"
  sdk={sdk}
  language="en"
  userIsLoggedIn
  betslip={{
    trigger: "odds-only",
    position: "bottom-right",
    currency: "€",
    ctaUrlTemplate:
      "https://your-bookmaker.com/bet?ids={selectionIds}&stake={stake}",
  }}
/>

Did this page help you?