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,
PredictorCallbacks,
ComponentEvent,
} from "fansunited-frontend-core";Required props
| Prop | Type | Description |
|---|---|---|
entityId | string | Predictor template identifier. |
sdk | FansUnitedSDKModel | SDK instance. |
language | LanguageType | Display language. |
Optional props
| Prop | Type | Default | Description |
|---|---|---|---|
template | PredictorTemplateType ("standard" | "embed") | "standard" | Which presentation to render. See Templates below. |
embed | PredictorEmbedConfig | — | Only read when template === "embed". See Embed template. |
themeOptions | CustomThemeOptions | — | See Theming. |
userIsLoggedIn | boolean | false | Host auth state. |
signInCTA | SignInCTADetails | — | See Sign-in CTA. |
tabs | PredictorTab[] | all tabs | Which tabs to enable. Ignored by embed. |
defaultImagePlaceholderUrl | string | — | Fallback image shown when the template has no image configured. Shared between the standard hero header and the embed template's image block. |
header | PredictorHeaderConfig | — | Size and framing of the standard template's hero banner. See Header banner. Ignored by embed. |
playTabBanners | PlayTabBanner[] | — | Custom banners injected into the Play tab. Ignored by embed. |
consents | ConsentDef[] | — | Consent definitions required before predicting. |
matchCardBgImageUrl | string | — | Background image URL for match prediction cards. Ignored by embed. |
showOdds | boolean | false | Render betting odds on the match cards. See Odds below. |
odds | Omit<OddsConfig, "market"> | — | Operator preference, poll interval, flash mode and affiliate ids. See Odds below. |
operatorId | string | — | Deprecated — use odds.operators. Still honoured when odds.operators is unset. |
betslip | PredictorBetslipConfig | — | Betslip integration. See below. |
callbacks | PredictorCallbacks | — | onEvent — one observer for every journey event the component emits. See Analytics below. |
Templates
type PredictorTemplateType = "standard" | "embed";| Template | Use 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 todefaultImagePlaceholderUrl), alongside the branding/description column. Controlled byembed.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.matchIdpins a specific match; otherwise the soonest upcoming match across the whole template (not just the current matchweek) is auto-selected. Requires nogroupId—getTemplateMatchesis 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: onClick → url. 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.
| Field | Notes |
|---|---|
height / minHeight | height pins the banner; minHeight only sets a floor, so the banner still grows if the text needs more room. |
aspectRatio | Height follows the banner's own width, so the artwork keeps its proportions on every screen size. Ignored when height is set. |
maxWidth | Keeps 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. |
imageAlign | Which part survives the crop under "cover" — any background-position value ("top", "left center", "50% 20%"). |
background | Any 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. |
textPosition | Where the title/description sit when the banner is taller than they are. No effect when the banner's height comes from the text. |
hideText | Drops 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 rem — rem 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";| Tab | Description |
|---|---|
play | Main prediction interface — submit match score forecasts. |
leaderboard | Global rankings with pagination and user highlight. |
private-leagues | Create and join private leagues. |
rules | Searchable game rules and scoring system. |
prizes | Prize 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;
}| Position | Location |
|---|---|
above-group-nav | Above the matchweek group navigation. |
below-group-nav | Below the matchweek group navigation. |
below-matches | Below the list of match cards. |
bottom | Very bottom of the Play tab. |
left | Left sidebar (desktop only). |
right | Right 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.
- The same shape is shared with
ClassicQuizPlay— see Consents for the full reference, including the{{privacyPolicyUrl}}/{{termsAndConditionsUrl}}body tokens.
<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,
},
]}
/>Odds
Odds are off by default. With showOdds set, every answered market shows the price of its own answer, from one operator:
- The main market's price sits above the submit button.
- Each additional market shows its price beside the saved answer, inside its drawer.
- A scoreline (
CORRECT_SCORE_ADVANCED) shows the price of the 1X2 result it implies. - Markets the odds API doesn't price show none: corners count, red card, penalty and half-time score.
The embed card also shows the full 1X2 market. A league member's predictions show the price of each predicted market. Odds refresh every odds.pollingInterval and fail silently: when the odds API has no prices for a match, the card simply shows none.
odds?: {
operators?: string[]; // preferred operator config ids
pollingInterval?: number; // ms, default 30000
mode?: "default" | "flash"; // default "default"
flashInterval?: number; // ms, default 3000
affiliateId?: string; // substituted into the operator URLs
affiliateIds?: Record<string, string>; // per-operator, wins over affiliateId
}showOdds: while it's false (the default), the odds request is skipped altogether. A betslip withtrigger: "odds-only"is fed by clicking those odds, so that trigger needsshowOddsset.odds.operatorsis a preference order. Each match shows the first operator that prices its 1X2 completely, and every other market on that card comes from the same operator. Without it, each match shows whichever operator the API lists first, which isn't guaranteed to stay the same.
<Predictor entityId="..." sdk={sdk} language="en" showOdds odds={{ operators: ["betano-bg"] }} />The card and the betslip show the same operator unless you set them to different values:
| You set | Card odds | Betslip |
|---|---|---|
odds.operators | first preferred | first of odds.operators |
betslip.operatorId | betslip.operatorId | betslip.operatorId |
| Both | odds.operators | betslip.operatorId |
The deprecated operatorId still works and stands in for odds.operators: [operatorId].
See Choosing a bookmaker on the Betslip page for the list of available operator ids.
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;
operatorId?: string; // falls back to the first of odds.operators — see Odds
affiliateId?: string;
labels?: BetslipLabels;
themeOptions?: CustomThemeOptions;
}Trigger modes
| Mode | When 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 a price — any market's, once that market is predicted. The price becomes a command-bus trigger (matchId:MARKET:pick) instead of a direct bookmaker link. Requires showOdds. |
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.
Analytics
The Predictor emits a typed event for every meaningful step — widget_view, tab_view,
prediction_started, prediction_submitted, private_league_joined, odds_click, and more.
<Predictor
entityId="predictor-template-123"
sdk={sdk}
language="en"
callbacks={{
onEvent: (event) => analytics.track(event.name, { ...event.params, ...event.context }),
}}
/>The same stream is also available without a prop, via the componentEvents bus — which is what
you want for a tag manager, since it can subscribe after the component has already mounted:
import { componentEvents } from "fansunited-frontend-components";
componentEvents.subscribe((event) => {
window.dataLayer.push({ event: `fu_${event.name}`, ...event.params, ...event.context });
});Payloads carry no personal data. Full event catalogue, parameter tables and the Google Tag Manager
setup (including why GTM's built-in click triggers cannot see into the component's shadow root) are
in Analytics.
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
showOdds
betslip={{
trigger: "odds-only",
position: "bottom-right",
currency: "€",
ctaUrlTemplate:
"https://your-bookmaker.com/bet?ids={selectionIds}&stake={stake}",
}}
/>Updated 7 days ago
