Event Card

A single-event card — a competition header over the two competitors and their kickoff time, live score, or final result. Team sports show one score column; tennis shows a per-set breakdown with an expandable scoreboard. Live events auto-refresh on an interval. Sport-agnostic: works for any sport the Sports API covers (football, basketball, tennis, ice hockey, American football, …), adapting its status labels and score layout to each. Football events can additionally let signed-in users place predictions across several markets and show them how the whole audience predicted the event.

Import

import { EventCard } from "fansunited-frontend-components";
import { EventCardProps } from "fansunited-frontend-core";

Required props

PropTypeDescription
eventIdstringEvent ID to render, e.g. "fb:m:12345".
sdkFansUnitedSDKModelSDK instance.
languageLanguageTypeDisplay language.

Optional props

PropTypeDescription
themeOptionsCustomThemeOptionsSee Theming.
typeEventCardType"header" | "detailed". The kind of card. Defaults to "header". "detailed" is reserved for a future release.
templateEventCardTemplateType"compact" | "standard" | "hero". Visual density of the card. Defaults to "standard".
showCompetitionbooleanShow the competition header (crest + name + stage/round or country subtitle). Defaults to true.
showOddsbooleanRender betting odds in each template. Defaults to true. Not yet supported by the backend.
liveRefreshbooleanPoll the events API while the event is LIVE so score/status stay current. Defaults to true.
refreshIntervalnumberLive-refresh poll interval in milliseconds. Defaults to 30000 (30s).
userIsLoggedInbooleanWhether the visitor is signed in. Required for predictions. Defaults to false.
predictionsEventCardPredictionsConfigLet signed-in users place single predictions on the event, and optionally show how everybody else predicted it.

Templates

template picks the visual prominence of the same card — it does not change what data is shown.

ValueLayout
"compact"A livescore-style row: competition header over a match row, kickoff time / status on the left, competitors stacked on the right with a single score column (team sports) or a per-set grid (tennis).
"standard" (default)The two competitors flank a central column showing the kickoff time (upcoming) or the score / final result, each crest beside its name.
"hero"A large card — each competitor's crest stacked over its name, flanking a centered score / kickoff time in an inset panel, with a left accent bar on live events.

Card type

type is a higher-level switch than template:

ValueBehavior
"header" (default)The condensed event card described on this page (competition header + competitors + time/score). The only kind implemented today.
"detailed"A richer event view (lineups, stats, timeline, …). Reserved, not implemented yet — the prop and its contract are in place so it's stable before the feature lands. Passing it has no effect today; the header card is rendered regardless.

Predictions

Signed-in users can place Fans United single predictions on the event, across several markets, in
a collapsible drawer at the bottom of the card. All three templates support it, each with a drawer
designed for its own layout.

<EventCard
  eventId="fb:m:12345"
  sdk={sdk}
  language="en"
  userIsLoggedIn
  predictions={{
    markets: ["FT_1X2", "CORRECT_SCORE_ADVANCED", "OVER_GOALS_2_5"],
  }}
/>;

EventCardPredictionsConfig

FieldTypeDescription
marketsEventCardPredictionMarket[]Markets to offer, rendered in this order. Omitted or empty means no prediction UI. Values outside the supported set are skipped.
display"drawer" | "inline"Whether the markets sit behind a collapsible footer row ("drawer", the default) or are rendered directly ("inline").
cutoffMinutesnumberHow many minutes before kickoff predictions close. Defaults to 15, which is also the minimum — the Prediction API rejects predictions made inside the last 15 minutes before kickoff, so smaller values are raised to 15. Use a larger value to close the drawer earlier.
allowDeletebooleanLet users remove a prediction they already made. Defaults to true. Removal stays available while the match is in play.
defaultExpandedbooleanStart the drawer expanded instead of collapsed. Defaults to false. Only applies when display is "drawer" — it keeps the collapse control, unlike display: "inline".
showSummarybooleanAlso show how everybody predicted the event, inside the same drawer. Defaults to false.

Requirements

There is no enabled flag — the drawer renders when all of these hold, and is absent otherwise
(the card then looks exactly as it does without predictions configured):

  • the event is a football event (the prediction API is football-only);
  • userIsLoggedIn is true — an anonymous visitor sees no prediction UI and no sign-in prompt;
  • predictions.markets contains at least one supported market;
  • the event resolves to a Football API match.

The SDK must also be able to authenticate the user (an authProvider supplying their id token) —
the prediction endpoints all require it.

Event ids vs match ids

The card renders events from the Sports API, but predictions are made against the Football
API
, and those are two different data providers. Their ids are kept aligned, so an event and its
match usually share one id — but that isn't guaranteed: a newly created football match can be 123
in the Football API and 456 in the Sports API.

Because the Prediction API only understands Football API match ids, the component never sends an event id
to it. Instead, for a football event with predictions configured, it looks the id up via
sdk.football.getMatchById and then verifies the result is the same fixture — both team ids must
match exactly (the two APIs do share competitor ids) and the kickoff times must agree. Only then are
predictions offered, against the id the Football API returned.

If the lookup 404s, fails, or comes back as a different fixture, the prediction drawer simply isn't
rendered
and the card behaves as it does without predictions configured. Predicting on the wrong
match would be worse than not offering predictions at all, so the check errs toward hiding.

This costs one extra getMatchById call, made only for football events that actually have predictions
configured for a signed-in user — never for other sports, anonymous visitors, or cards without a
market list. The same response supplies the match's availableMarkets.

Display mode

predictions.display picks how prominent the markets are. Both modes are available in every
template, styled to suit each one, and behave identically otherwise.

ValueBehaviour
"drawer" (default)The markets sit behind a collapsible "Make your prediction" footer row. The card keeps the footprint it has without predictions until the user engages, so adding a market list doesn't make every card taller.
"inline"The markets render directly, with no collapse control and no chevron. For placements where predicting is the point of the card.

The heading row is kept in both modes — as the toggle in "drawer", as a plain label in "inline"
because without it the market rows would sit under the score with no context.

defaultExpanded is a third, distinct option: "drawer" with defaultExpanded: true opens on load
but the user can still collapse it, whereas "inline" has nothing to collapse.

<EventCard
  eventId="fb:m:12345"
  sdk={sdk}
  language="en"
  userIsLoggedIn
  predictions={{
    markets: ["FT_1X2", "CORRECT_SCORE_ADVANCED"],
    display: "inline",
  }}
/>;

Prediction drawer per template

The behaviour is identical across templates; only the presentation differs, so the drawer belongs to
the card it sits in rather than looking bolted on.

TemplateDrawer
"compact"A thin footer strip below the match row. Each market is one line — question on the left, control on the right. Competitors are identified by a small crest (no name — the rows above already spell those out) and the steppers run horizontally, so no row grows taller than the livescore rhythm above it.
"standard"A full-bleed footer whose divider spans the card. Each market is a block: the question above its control, with competitors shown as crest + name.
"hero"The same layout as "standard", in a second inset panel below the score panel so the card reads as two deliberate blocks. The live accent bar stays on the score panel.

Supported markets

Every MarketEnum value is supported.

Answered byMarkets
Home / Draw / AwayFT_1X2, HT_1X2
Two of three outcomes (1X / 12 / X2)DOUBLE_CHANCE
Half-time / full-time doubleHT_FT
Exact scorelineCORRECT_SCORE, CORRECT_SCORE_HT, CORRECT_SCORE_ADVANCED
A corner countCORNERS_MATCH
Yes / NoBOTH_TEAMS_SCORE, OVER_GOALS_0_5OVER_GOALS_6_5, OVER_CORNERS_6_5OVER_CORNERS_13_5, RED_CARD_MATCH, PENALTY_MATCH
A playerPLAYER_SCORE, PLAYER_SCORE_FIRST_GOAL, PLAYER_SCORE_TWICE, PLAYER_SCORE_HATTRICK, PLAYER_YELLOW_CARD, PLAYER_RED_CARD

A market outside this set is skipped and a console.warn names it, rather than the row silently
rendering nothing.

Markets are also narrowed to what the fixture itself accepts. The SDK Football namespace reports an
availableMarkets list per match, and the Prediction API answers 400 for anything outside it — a cup
fixture may carry no corners, half-time/full-time or player markets at all. Any configured market the
match doesn't accept is dropped before it's ever rendered, with a console.warn naming both what was
omitted and what the match does offer, so a client can fix the configuration. If every configured market
is unavailable the drawer isn't rendered at all.

An empty or absent availableMarkets is treated as unknown and filters nothing — a football match
always accepts at least a 1X2, so an empty list is far likelier to be an unpopulated field than a real
answer, and acting on it would silently remove the feature.

Player markets pick from both squads, grouped by team, with shirt numbers and headshots.
Configuring one costs two extra getTeamById calls (made only when a player market is present) —
squads rather than lineups, because lineups are confirmed about an hour before kickoff. PLAYER_SCORE_FIRST_GOAL
additionally offers Own goal and No goal will be scored.

Layout by option count. Markets with a handful of options render as pills; HT_FT renders as a
labelled 3×3 grid in standard / hero; and anything with a long list — HT_FT in compact, and
the player markets everywhere — uses a one-line select, so a compact row never grows taller than the
livescore rows above it.

Behaviour

  • Before the cutoff each market is a row with its own selector. Picking values and hitting submit
    sends one prediction per market; only markets the user actually touched are sent, so a card with a
    correct-score market doesn't silently save 0:0 for someone who only picked a winner. A market
    that fails to save keeps its value inline for a retry — the ones that succeeded stay saved.
  • A saved market shows the value read-only with edit and remove affordances. Editing re-opens the
    selector and re-submitting overwrites; removing deletes that market's prediction only (each market
    is its own prediction record).
  • After the cutoff, while the match is in play, the drawer becomes a read-only summary of the
    markets the user predicted. Editing is no longer possible, but they can still remove a
    prediction (unless allowDelete is false). Removing the last one hides the drawer entirely.
  • Once the match is finished or cancelled the summary is fully read-only — the result is graded,
    so removal stops too — and each market shows its outcome and the points earned. With nothing
    predicted, the drawer never appears. Each market also states what actually happened.

Match results

Once the event is FINISHED, each market states what actually happened, on its own line between the
question and the user's pick — so a settled market reads: what was asked, what happened, what you said,
and (with the summary on) what everyone said.

It comes from predictor.getMatchMarketsResults, and needs no configuration of its own: it renders
whenever the prediction drawer does on a finished match, with or without
showSummary. That's one extra request, made only for finished football
events that have predictions configured.

MarketReads as
1X2 / half-time 1X2the competitor's name, or "Draw"
Double chancethe single outcome that came in — the API grades this as the two covered codes, and naming both ("home or draw, home or away") is noise
Half-time / full-timee.g. "Real Madrid / Draw"
Exact scorelinee.g. "1 – 1"
Cornersthe count
Yes / no markets"Yes" or "No"
Player marketsthe player's name; several qualifying players are listed, with a "+N" once past three

Skipped markets are listed too. Before the results are in, a locked drawer shows only the markets the
user answered — listing the rest as "not predicted" is noise while nothing can be done about them. Once
graded, a skipped market has real content again (what happened, and how everyone else answered), so every
configured market is shown, with the ones they missed marked as not predicted.

A market the API returns no result for is simply left without a result line — including a player market
graded as an empty list, where "nobody qualified" and "not graded yet" are indistinguishable, so nothing
is claimed either way.

Prediction summary

Show how the whole Fans United audience predicted the event — the aggregate split per market, from
predictor.getMatchSummaryinside the prediction drawer. All three templates support it.

<EventCard
  eventId="fb:m:12345"
  sdk={sdk}
  language="en"
  userIsLoggedIn
  predictions={{
    markets: ["FT_1X2", "CORRECT_SCORE_ADVANCED", "BOTH_TEAMS_SCORE"],
    showSummary: true,
  }}
/>;

Requirements

The summary is part of the prediction drawer, so it inherits every one of that drawer's
requirements
— including userIsLoggedIn:

  • the event is a football event;
  • userIsLoggedIn is true;
  • predictions.markets contains at least one supported market, and predictions.showSummary is true;
  • the event resolves to a Football API match;
  • the drawer itself is rendering (predictions still open, or the user already has some).

The underlying data is public — getMatchSummary needs no authentication — but with no drawer on the
card there is nowhere to put it, so anonymous visitors see no crowd data.

If the fetch fails the crowd data is simply absent and the drawer renders exactly as it does without
the feature configured. It's supplementary, so it never surfaces an error to the visitor — but it does
console.warn once, naming the match, so a silent fetch failure is distinguishable from a match nobody
has predicted.

A market the user has predicted is never hidden for want of data. Where the aggregate reports zero
predictions for a market but the user has a saved one, the snapshot is provably incomplete, so their own
vote is counted — the market shows at 100%. This matters because the aggregate is cached for an hour: a
user who predicts and then reloads is very likely to be handed a snapshot that predates their own votes,
and without this the whole feature would vanish from a card they had just finished filling in.

It only applies to a zero total. Above zero there is no way to tell whether their vote is already
included, so nothing is added. It also doesn't apply where the aggregate is present but unreadable (the
player markets), since "zero" there means "we couldn't parse it", not "nobody predicted" — those markets
stay hidden rather than presenting one pick as 100% of a crowd that wasn't read.

Where it appears

Two places, both inside the drawer:

SurfaceBehaviour
The heading figureWhile the match is ungraded, the number of predictions made on it, beside the progress pips — visible whether the drawer is open or collapsed. Once graded it gives way to the user's own hit rate;
Per-market revealA market's split, directly under its row, only once the user has answered that market (saved, or picked and pending submit).

The reveal waits on the user's answer deliberately. A crowd split sitting beside a live selector
anchors the choice to the majority, which makes both the experience and the aggregate we collect worse.
Shown after the fact it's a payoff — "you're with the 64%". Until then the heading chip acts as the
teaser: the crowd is visibly there, but seeing how it voted costs a pick.

The heading's prediction count is the sum of every market's total. A prediction is per market, not per match —
each one is its own record, posted and deleted independently — so a user who answers three markets has
made three predictions, and the heading counts all of them. Each market's own total appears in its reveal,
where it's the figure for that question alone.

It covers the markets the card is configured with, since those are the only ones an aggregate is held
for — not every market the match may have attracted predictions on.

How each market is drawn

The shape follows the market, not the template:

MarketsDrawn as
FT_1X2, HT_1X2, DOUBLE_CHANCE, and every yes/no marketOne stacked bar over a legend naming every outcome and its share, each with a swatch matching its segment.
CORRECT_SCORE*, HT_FT, CORNERS_MATCH, player marketsRanked rows — the three most popular first, with the remainder behind an expandable "N more outcomes" toggle.

A stacked bar would be wrong for the second group: the leading scoreline in a real match is usually
under 20%, so the bar would be a dozen unreadable slivers.

Both shapes put bars on the same absolute 0–100 axis, so a given percentage is drawn the same length
wherever it appears on the card. That means a wide-open market genuinely shows short bars — no single
scoreline is likely, and the ranking still reads from the order and the numbers.

The stacked bar's legend is a plain wrapping row rather than labels sized to the segments, so every
outcome keeps its full name and share however small its slice is
— a 7% outcome is still named. Each
entry carries a swatch in its segment's colour, which is what ties the two together.

Finding your own pick

The outcome the user predicted is called out in the accent colour. In a ranked market its row gets a
soft accent wash, a left accent bar and an accent-coloured label; in a stacked bar its label and
percentage take the accent colour. If their pick is a long shot sitting in the collapsed tail, the
"N more outcomes" toggle turns accent-coloured so they can tell it's down there without opening it.
Hovering any of these gives the spelled-out "Your pick".

Two rules make this readable after full time, when everything else fades back:

  • The user's own outcome is never faded, even when it's wrong. "Where did my pick land against the
    truth" is the main reason to look at a settled market, so dimming it like any other wrong outcome would
    hide the one row they came for.
  • The graded colour outranks the accent where an outcome is both what happened and their pick, so
    the green is free to mean "this is what happened" and nothing else. Every element of an outcome takes
    the same colour — tick, label, share and bar — so one answer never reads as three different states.

The template decides density instead. compact folds a market's whole split onto its own line — a bar
spanning the line with the crowd's favourite and its share after it — so no market makes the card taller
than one livescore row; standard and hero give each reveal its own block.

Every chart shades its segments by rank — the biggest share takes the accent at full strength, the rest
step down in opacity — rather than by which outcome they are. A colour per outcome encoded nothing but the
outcome's position in the list, so the same market could draw a blue bar or a grey one depending only on
which outcome the crowd happened to favour.

Player markets are usually dropped. Their outcomes arrive keyed by player id with an object value
whose prediction-count field isn't documented; any market whose totals can't be read is skipped rather
than drawn as an all-zero split. The exception is a genuinely empty one the user has predicted, which
shows their own pick.

After the match

Once the event is FINISHED the component additionally reads predictor.getMatchMarketsResults — the
predictor's own graded outcome per market — and the drawer becomes a scorecard:

  • the outcome that actually happened is ticked and keeps full strength, with its label, share and bar
    all in the success colour; every other outcome fades back;
  • the heading reports the user's own hit rate across their graded predictions ("2/3 right") beside
    their points total, and the crowd's prediction count gives way to it;
  • a market whose real outcome is one of the less popular ones carries the tick on its collapsed "N more
    outcomes" toggle, so a correct-but-unpopular result is never silently unmarked — expanding the toggle
    then shows the ticked row itself. The user's own pick is flagged there the same way.

The graded result comes from the API rather than from the score on the card, and that matters: single
predictions are graded on the first 90 minutes, so a match decided in extra time or on penalties has
an aggregate score that contradicts its own FT_1X2 result. Deriving "what happened" from the score
would mislabel exactly those matches.

Markets where the crowd is deadlocked (a genuine tie for most-predicted) get no verdict and are left
out of the heading's tally — there is no favourite to be right or wrong.

Some markets have more than one winning outcome. DOUBLE_CHANCE covers two of three outcomes, so an
away win makes both "either team wins" and "draw or away" correct; the array-graded player markets behave
the same way when several players qualify. Every winning outcome is ticked, including one nobody predicted,
and the reveal additionally reports the combined correct share — that number only appears on
multi-winner markets, where it's what explains two ticks instead of one. Winners sitting behind the
"N more outcomes" toggle still count towards it.

This costs one extra request, made only for finished football events with the summary configured.

Freshness and the user's own vote

getMatchSummary is cached by the API for an hour, so the component reads it with disableCache — a cached
read would otherwise hand a user who has just predicted a snapshot that predates their own votes. It is
still fetched once per card, not on the live refresh poll, since an aggregate of
thousands of predictions doesn't move meaningfully minute to minute.

The user's own votes are applied immediately, and computed rather than re-fetched. When they submit,
edit or delete a prediction, the component folds the change into the split it is already showing, so the
count and the percentages update the instant the API confirms it.

That's deliberate: because the endpoint is cached for an hour, asking again right after a submit can
easily answer with a snapshot that predates the vote — the count would rise, fall back, and reappear
minutes later. The component knows exactly what the user did, so applying it locally is both instant and
more accurate than re-reading.

Each change is applied as a transition — one off the outcome they had, one onto the outcome they now
have — which is what makes it correct in every case without having to know whether the snapshot already
counted them:

ActionApplied as
First prediction on a market+1 on the chosen outcome
Editing an existing prediction−1 on the old outcome, +1 on the new one
Removing a prediction−1 on the outcome they had

These adjustments belong to the snapshot they were made against, so they are dropped whenever a fresh
one arrives (a new event, or the match finishing) — the new snapshot may already contain them, and
re-applying them would double-count. A count is never driven below zero.

Example

The markets rendered directly rather than behind a toggle, with the crowd split on:

<EventCard
  eventId="fb:m:12345"
  sdk={sdk}
  language="en"
  userIsLoggedIn=USERISSIGNEDIN
  predictions={{
    markets: ["FT_1X2", "CORRECT_SCORE_ADVANCED", "HT_FT"],
    display: "inline",
    showSummary: true,
  }}
/>;

Live refresh

When liveRefresh is true (the default) and the event is LIVE, the card re-fetches on refreshInterval so the score and status stay current. Only live events poll — upcoming and finished events are fetched once. Set liveRefresh={false} to disable polling entirely.

Sports & statuses

  • Team sports render a single aggregate score (goals / points).
  • Tennis renders a per-set breakdown — a compact per-set grid (compact template) or a result-first summary with an expandable full scoreboard (standard / hero), including tiebreak superscripts.
  • Status labels are localized and sport-aware. An event resolves to one of four states — upcoming, live, finished, cancelled. The finished label adapts to the sport: clock-based sports (football, basketball, hockey) show FT; tennis, which has no game clock, shows Finished.
  • If the event can't be loaded, the card renders a contained "load failed" / "not available" message rather than breaking the host layout.

Examples

Basic (standard template)

import { EventCard } from "fansunited-frontend-components";

<EventCard eventId="fb:m:12345" sdk={sdk} language="en" />;

Compact livescore row

<EventCard
  eventId="fb:m:12345"
  sdk={sdk}
  language="en"
  template="compact"
/>;

Hero card without the competition header

<EventCard
  eventId="fb:m:12345"
  sdk={sdk}
  language="en"
  template="hero"
  showCompetition={false}
/>;

Finished event, no live polling

<EventCard
  eventId="fb:m:12345"
  sdk={sdk}
  language="en"
  liveRefresh={false}
/>;

Slower live refresh (60s)

<EventCard
  eventId="fb:m:12345"
  sdk={sdk}
  language="en"
  refreshInterval={60000}
/>;

Predictions closing an hour early, expanded, no delete

<EventCard
  eventId="fb:m:12345"
  sdk={sdk}
  language="en"
  userIsLoggedIn=USERISSIGNEDIN
  predictions={{
    markets: ["FT_1X2", "BOTH_TEAMS_SCORE", "CORNERS_MATCH"],
    cutoffMinutes: 60,
    allowDelete: false,
    defaultExpanded: true,
  }}
/>;

Did this page help you?