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
| Prop | Type | Description |
|---|---|---|
eventId | string | Event ID to render, e.g. "fb:m:12345". |
sdk | FansUnitedSDKModel | SDK instance. |
language | LanguageType | Display language. |
Optional props
| Prop | Type | Description |
|---|---|---|
themeOptions | CustomThemeOptions | See Theming. |
type | EventCardType | "header" | "detailed". The kind of card. Defaults to "header". "detailed" is reserved for a future release. |
template | EventCardTemplateType | "compact" | "standard" | "hero". Visual density of the card. Defaults to "standard". |
showCompetition | boolean | Show the competition header (crest + name + stage/round or country subtitle). Defaults to true. |
showOdds | boolean | Render betting odds in each template. Defaults to true. Not yet supported by the backend. |
liveRefresh | boolean | Poll the events API while the event is LIVE so score/status stay current. Defaults to true. |
refreshInterval | number | Live-refresh poll interval in milliseconds. Defaults to 30000 (30s). |
userIsLoggedIn | boolean | Whether the visitor is signed in. Required for predictions. Defaults to false. |
predictions | EventCardPredictionsConfig | Let 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.
| Value | Layout |
|---|---|
"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:
| Value | Behavior |
|---|---|
"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
EventCardPredictionsConfig| Field | Type | Description |
|---|---|---|
markets | EventCardPredictionMarket[] | 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"). |
cutoffMinutes | number | How 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. |
allowDelete | boolean | Let users remove a prediction they already made. Defaults to true. Removal stays available while the match is in play. |
defaultExpanded | boolean | Start the drawer expanded instead of collapsed. Defaults to false. Only applies when display is "drawer" — it keeps the collapse control, unlike display: "inline". |
showSummary | boolean | Also 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);
userIsLoggedInistrue— an anonymous visitor sees no prediction UI and no sign-in prompt;predictions.marketscontains 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.
| Value | Behaviour |
|---|---|
"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.
| Template | Drawer |
|---|---|
"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 by | Markets |
|---|---|
| Home / Draw / Away | FT_1X2, HT_1X2 |
Two of three outcomes (1X / 12 / X2) | DOUBLE_CHANCE |
| Half-time / full-time double | HT_FT |
| Exact scoreline | CORRECT_SCORE, CORRECT_SCORE_HT, CORRECT_SCORE_ADVANCED |
| A corner count | CORNERS_MATCH |
| Yes / No | BOTH_TEAMS_SCORE, OVER_GOALS_0_5…OVER_GOALS_6_5, OVER_CORNERS_6_5…OVER_CORNERS_13_5, RED_CARD_MATCH, PENALTY_MATCH |
| A player | PLAYER_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 save0:0for 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 (unlessallowDeleteisfalse). 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.
| Market | Reads as |
|---|---|
| 1X2 / half-time 1X2 | the competitor's name, or "Draw" |
| Double chance | the 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-time | e.g. "Real Madrid / Draw" |
| Exact scoreline | e.g. "1 – 1" |
| Corners | the count |
| Yes / no markets | "Yes" or "No" |
| Player markets | the 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.getMatchSummary — inside 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;
userIsLoggedInistrue;predictions.marketscontains at least one supported market, andpredictions.showSummaryistrue;- 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:
| Surface | Behaviour |
|---|---|
| The heading figure | While 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 reveal | A 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:
| Markets | Drawn as |
|---|---|
FT_1X2, HT_1X2, DOUBLE_CHANCE, and every yes/no market | One 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 markets | Ranked 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:
| Action | Applied 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 (
compacttemplate) 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, showsFinished. - 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,
}}
/>;Updated 12 days ago
