Analytics

Predictor emits a typed event for every meaningful step of the user journey — viewed, started a
prediction, submitted, joined a private league, and so on. Use them to build funnels, conversion
goals and remarketing audiences in whatever analytics stack you already run.

Events are fire-and-forget observers: they never block rendering, never receive a return value,
and never change what the component does. A handler that throws is caught and ignored.

Currently Predictor only. The bus is component-agnostic, so other components can join without
a breaking change. For the participation-model callbacks on the play components
(onFinish / onShare), see Callbacks.

Two ways to consume

Both receive the identical stream — pick whichever fits your integration. You can use both at once.

1. The callbacks prop

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

<Predictor
  entityId="predictor-template-123"
  sdk={sdk}
  language="en"
  callbacks={{
    onEvent: (event) => {
      analytics.track(event.name, { ...event.params, ...event.context });
    },
  }}
/>

2. The global bus

For when you can't reach the JSX — a tag manager, a separately-bundled script, or anything that
loads after the component mounts.

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

const unsubscribe = componentEvents.subscribe((event) => {
  console.log(event.component, event.name, event.params);
});

subscribe returns an unsubscribe function. It also replays recent events to a new
subscriber — the last 50, within the last 5 minutes — so a listener that attaches after the
component has mounted still sees widget_view and anything else it missed. Without that, funnel
denominators would silently under-count.

The bus is subscribe-only by design. There is no public way to emit, so nothing on the page can
forge events into your analytics.

The event shape

type ComponentEventParams = Record<string, string | number | boolean | null>;

interface PredictorComponentEvent {
  component: "predictor";
  name: PredictorAnalyticsEventName;
  params: ComponentEventParams;
  context: PredictorAnalyticsContext;
}

/** Discriminated on `component`. One member today. */
type ComponentEvent = PredictorComponentEvent;

interface PredictorAnalyticsContext {
  entityId: string;
  template: "standard" | "embed";
  language: LanguageType;
  userIsLoggedIn: boolean;
  templateTitle: string | null;
}

context is the same on every event from a given instance; params are per-event and listed
below.

ComponentEvent is a discriminated union keyed on component, and every component publishes
to the one shared bus — so component is how a subscriber tells whose event it is. Narrowing it
narrows name and context together:

componentEvents.subscribe((event) => {
  if (event.component !== "predictor") return;
  event.context.entityId; // typed as PredictorAnalyticsContext here
});

Narrowing now is worth the two lines: when a second component starts emitting, the union widens
and code that already narrows keeps compiling unchanged.

Three properties worth relying on:

  • Flat primitives only. No nested objects or arrays. Multi-value fields (several markets) are
    comma-joined strings, because tag managers and GA4 handle array parameters poorly.
  • Every key an event defines is always present, null rather than absent. This matters for
    tag managers specifically — see the GTM section.
  • No personal data. No profile id, email, display name or token, and no private-league
    invitation code. The only user-level fact is context.userIsLoggedIn. Identity stitching is
    yours to do, in your own stack, under your own consent rules.

Event reference

Every event also carries context. PredictorAnalyticsEventName is the union of all name values.

Lifecycle

EventFires whenParams
widget_viewThe template request settles, once per mount. Held until then, because before it the component is a skeleton.
tab_viewA tab becomes visible — the initial one, a tab click, or a #hash change.tab
matchweek_changePrev/next matchweek navigation on the Play tab.direction ("prev"/"next"), group_id

Prediction funnel

All five carry the match descriptors match_id, home_team, home_team_id, away_team,
away_team_id, kickoff_at.

Use the ids as join keys, not the names. match_id and the team ids are Fans United's canonical,
provider-agnostic identifiers (fb:m:*, fb:t:*) — stable across languages and across whichever
football data provider sits behind the account. match_id is also the exact value posted to
makeFootballPrediction, so an event joins 1:1 with its prediction record. The home_team /
away_team names are localised by the language prop ("Manchester City" vs "Манчестър Сити")
and exist for readable reports only.

EventFires whenExtra params
prediction_startedThe user's first answer on a card — a stepper, a 1X2 pick, any control. Once per match, however much they then change it.market
prediction_submittedAt least one market saved.markets (comma-joined), market_count, is_edit
prediction_failedAt least one market failed to save.markets, market_count, is_edit
prediction_editedA saved market is re-opened for editing.market
prediction_removedA saved prediction is deleted.market

A partially-failed multi-market submit emits both prediction_submitted and
prediction_failed, each listing its own markets — so a funnel counts what saved without
losing what didn't.

Gates

EventFires whenParams
sign_in_prompt_shownA logged-out user submits a prediction.trigger, plus the match descriptors above
sign_in_clickThe sign-in CTA is clicked. Your signInCTA.onClick still runs.location ("play_tab" / "private_leagues")
consent_gate_shownRequired consents are missing and the gate opens.consent_ids (comma-joined)
consent_acceptedThe gate is accepted.consent_ids
consent_declinedThe gate is dismissed.consent_ids

A signInCTA.component you supply owns its own clicks, so sign_in_click does not fire for it —
instrument your own component instead.

Engagement

EventFires whenParams
odds_clickA bookmaker odd is clicked. Reports the odd clicked, not the one the prediction implies.match_id, outcome, odd_value, operator
leaderboard_page_changeLeaderboard pagination.page, total_pages
embed_success_cta_clickThe post-submission CTA on the embed template.match_id, cta_label, url

Private leagues

All carry league_id and league_name; either can be null where the calling surface doesn't
have it.

EventFires when
private_league_createdA league is created.
private_league_joinedA league is joined by code.
private_league_viewA league's detail view is opened.
private_league_invite_sharedAn invitation code is copied. The code itself is never reported.
private_league_invitation_acceptedA pending invitation is accepted.
private_league_invitation_declinedA pending invitation is declined.
private_league_leftThe user leaves a league.
private_league_deletedAn owner deletes a league.

Wiring this to Google Tag Manager

Why this is needed at all

Every component renders inside its own shadow root. GTM's built-in no-code triggers — All
Clicks
, Form Submission, Element Visibility — attach listeners at the document level, and a
click from inside a shadow root arrives retargeted to the shadow host. GTM sees one anonymous
<div> for every click anywhere in the component, with empty Click Text, Click Classes and
Click ID. Native submit events don't escape a shadow root at all.

That's inherent to the CSS isolation, not something a GTM setting can undo. Pushing named events
into the data layer is the standard answer, and it's better anyway: the events are stable and
documented, rather than tied to markup that changes between releases.

The adapter

One push per event, from either sink:

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

window.dataLayer = window.dataLayer || [];

componentEvents.subscribe((event) => {
  window.dataLayer.push({
    event: `fu_${event.name}`,
    ...event.params,
    ...event.context,
  });
});

Then in GTM: a Custom Event trigger on fu_prediction_submitted, a GA4 Event tag, and
Data Layer Variables for the parameters you want. A single trigger with the regex ^fu_ and
{{Event}} as the tag's event name forwards the whole catalogue in one tag.

Two things to get right

Prefix the event names. dataLayer is shared with everything else on the page. fu_ keeps
the component's vocabulary from colliding with the host site's own events. The library
deliberately does not prefix for you — that would bake our naming into your container.

GTM merges pushes, it does not replace them. If one push sets match_id: "match-1" and a
later event omits that key, GTM still reports "match-1" for the later one. That's the most
common integration bug in this area. Two mitigations, both fine:

// Explicitly clear the keys this event doesn't define.
const ALL_KEYS = ["match_id", "market", "markets", "tab", "league_id", "page" /* … */];

componentEvents.subscribe((event) => {
  const cleared = Object.fromEntries(ALL_KEYS.map((k) => [k, undefined]));
  window.dataLayer.push({ event: `fu_${event.name}`, ...cleared, ...event.params, ...event.context });
});

…or nest everything under one key and read it with dot notation (fu.match_id) in GTM:

window.dataLayer.push({ event: `fu_${event.name}`, fu: { ...event.params, ...event.context } });

Campaign patterns this supports

  • Conversion tracking — mark prediction_submitted as a GA4 key event; combined with UTMs you
    get cost per prediction, per campaign.
  • Abandonment audience — users who fired prediction_started but never
    prediction_submitted. A direct remarketing segment.
  • Viralityprivate_league_created and private_league_joined against
    private_league_invite_shared gives an invite-to-join rate.
  • Frictionsign_in_prompt_shown and consent_declined show where the funnel leaks before
    a prediction is ever saved.

Consent mode

The component pushes nothing on its own — your adapter does. Gate it however your consent policy
requires; remember that once events are in dataLayer, every tag in the container can read them.
That's exactly why the payloads carry no personal data.

Testing without a container

window.dataLayer.filter((e) => String(e.event).startsWith("fu_"));

The Predictor's Analytics Storybook story logs every event from both sinks side by side, which
is the quickest way to see the catalogue without wiring anything up.


Did this page help you?