fansunited-frontend-components 0.0.74 → 0.0.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -237,6 +237,179 @@ import { ClassicQuizPlay } from "fansunited-frontend-components";
237
237
  />
238
238
  ```
239
239
 
240
+ ## Analytics
241
+
242
+ `Predictor` emits a typed event for every meaningful step of the user journey — viewed, started a
243
+ prediction, submitted, joined a private league, clicked an odd. Use them for funnels, conversion
244
+ goals and remarketing audiences.
245
+
246
+ > Currently `Predictor` only. The bus is component-agnostic, so other components can join without a
247
+ > breaking change. This is separate from the `onFinish` / `onShare` callbacks above.
248
+
249
+ ### Two ways to consume
250
+
251
+ Both receive the identical stream, and can be used together.
252
+
253
+ **1. The `callbacks` prop** — for when you own the JSX:
254
+
255
+ ```tsx
256
+ <Predictor
257
+ entityId="predictor-template-123"
258
+ sdk={sdk}
259
+ language="en"
260
+ callbacks={{
261
+ onEvent: (event) => analytics.track(event.name, { ...event.params, ...event.context }),
262
+ }}
263
+ />
264
+ ```
265
+
266
+ **2. The `componentEvents` bus** — for when you don't: a tag manager, a separately-bundled script,
267
+ anything that loads after the component mounts.
268
+
269
+ ```ts
270
+ import { componentEvents } from "fansunited-frontend-components";
271
+
272
+ const unsubscribe = componentEvents.subscribe((event) => {
273
+ console.log(event.component, event.name, event.params);
274
+ });
275
+ ```
276
+
277
+ `subscribe` returns an unsubscribe function, and **replays recent events** to a new subscriber (the
278
+ last 50, within the last 5 minutes) — so a listener that attaches after mount still sees
279
+ `widget_view` and anything else it missed. Without that, funnel denominators under-count.
280
+
281
+ The bus is subscribe-only by design. There is no public way to emit, so nothing else on the page can
282
+ forge events into your analytics.
283
+
284
+ ### Event shape
285
+
286
+ ```ts
287
+ type ComponentEventParams = Record<string, string | number | boolean | null>;
288
+
289
+ interface PredictorComponentEvent {
290
+ component: "predictor";
291
+ name: PredictorAnalyticsEventName;
292
+ params: ComponentEventParams;
293
+ context: PredictorAnalyticsContext;
294
+ }
295
+
296
+ /** Discriminated on `component`. One member today. */
297
+ type ComponentEvent = PredictorComponentEvent;
298
+
299
+ interface PredictorAnalyticsContext {
300
+ entityId: string;
301
+ template: "standard" | "embed";
302
+ language: LanguageType;
303
+ userIsLoggedIn: boolean;
304
+ templateTitle: string | null;
305
+ }
306
+ ```
307
+
308
+ `context` is identical on every event from an instance; `params` are per-event. Narrow on
309
+ `component` before reading them — when a second component starts emitting, the union widens and code
310
+ that already narrows keeps compiling:
311
+
312
+ ```ts
313
+ componentEvents.subscribe((event) => {
314
+ if (event.component !== "predictor") return;
315
+ event.context.entityId; // typed here
316
+ });
317
+ ```
318
+
319
+ Three properties you can rely on:
320
+
321
+ - **Flat primitives only.** No nested objects or arrays; multi-value fields are comma-joined
322
+ strings, because tag managers and GA4 handle array parameters poorly.
323
+ - **Every key an event defines is always present**, `null` rather than absent.
324
+ - **No personal data.** No profile id, email, display name, token, or private-league invitation
325
+ code. The only user-level fact is `context.userIsLoggedIn`.
326
+
327
+ ### Events
328
+
329
+ Every event also carries `context`.
330
+
331
+ | Group | Event | Params |
332
+ | --- | --- | --- |
333
+ | Lifecycle | `widget_view` | — |
334
+ | | `tab_view` | `tab` |
335
+ | | `matchweek_change` | `direction`, `group_id` |
336
+ | Prediction | `prediction_started` | match descriptors + `market` |
337
+ | | `prediction_submitted` | match descriptors + `markets`, `market_count`, `is_edit` |
338
+ | | `prediction_failed` | match descriptors + `markets`, `market_count`, `is_edit` |
339
+ | | `prediction_edited` | match descriptors + `market` |
340
+ | | `prediction_removed` | match descriptors + `market` |
341
+ | Gates | `sign_in_prompt_shown` | match descriptors + `trigger` |
342
+ | | `sign_in_click` | `location` |
343
+ | | `consent_gate_shown` / `consent_accepted` / `consent_declined` | `consent_ids` |
344
+ | Engagement | `odds_click` | `match_id`, `outcome`, `odd_value`, `operator` |
345
+ | | `leaderboard_page_change` | `page`, `total_pages` |
346
+ | | `embed_success_cta_click` | `match_id`, `cta_label`, `url` |
347
+ | Private leagues | `private_league_created` / `_joined` / `_view` / `_invite_shared` / `_invitation_accepted` / `_invitation_declined` / `_left` / `_deleted` | `league_id`, `league_name` |
348
+
349
+ **Match descriptors** are `match_id`, `home_team`, `home_team_id`, `away_team`, `away_team_id`,
350
+ `kickoff_at`.
351
+
352
+ **Join on the ids, not the names.** `match_id` and the team ids are canonical, provider-agnostic
353
+ identifiers (`fb:m:*`, `fb:t:*`) — stable across languages and across whichever football data
354
+ provider is behind the account. `match_id` is the same value the prediction itself is posted with,
355
+ so an event joins 1:1 with its prediction record. The `home_team` / `away_team` names are
356
+ **localised by the `language` prop** and exist for readable reports only.
357
+
358
+ Notes on firing:
359
+
360
+ - `widget_view` fires once per `entityId`, held until the template request settles — before that the
361
+ component is a skeleton.
362
+ - `prediction_started` fires once per match, on the user's first answer, however much they then
363
+ change it.
364
+ - A partially-failed multi-market submit emits **both** `prediction_submitted` and
365
+ `prediction_failed`, each listing only its own markets.
366
+ - `sign_in_click` does not fire for a `signInCTA.component` you supply — that component owns its own
367
+ clicks. Where it does fire, your `signInCTA.onClick` still runs.
368
+
369
+ ### Wiring to a tag manager
370
+
371
+ Every component renders inside a **shadow root**, so a tag manager's built-in no-code triggers (all
372
+ clicks, form submission, element visibility) cannot see into it: a click arrives at `document`
373
+ retargeted to the shadow host, giving one anonymous `<div>` with no text, classes or id, and native
374
+ `submit` events don't escape a shadow root at all. That's inherent to the CSS isolation, not a
375
+ configuration mistake — pushing named events is the answer, and it's more stable anyway.
376
+
377
+ ```ts
378
+ import { componentEvents } from "fansunited-frontend-components";
379
+
380
+ window.dataLayer = window.dataLayer || [];
381
+
382
+ componentEvents.subscribe((event) => {
383
+ window.dataLayer.push({
384
+ event: `fu_${event.name}`,
385
+ ...event.params,
386
+ ...event.context,
387
+ });
388
+ });
389
+ ```
390
+
391
+ Then create a Custom Event trigger on `fu_prediction_submitted`, a GA4 Event tag, and Data Layer
392
+ Variables for the parameters you want. A single trigger matching `^fu_` with `{{Event}}` as the tag's
393
+ event name forwards the whole catalogue at once.
394
+
395
+ Two things to get right:
396
+
397
+ - **Prefix the event names.** `dataLayer` is shared with everything else on the page. The library
398
+ deliberately does not prefix for you — that would bake its naming into your container.
399
+ - **GTM merges pushes rather than replacing them.** If one push sets `match_id` and a later event
400
+ omits that key, GTM still reports the old value. Either clear the keys an event doesn't define, or
401
+ nest everything under one key and read it with dot notation:
402
+
403
+ ```ts
404
+ window.dataLayer.push({ event: `fu_${event.name}`, fu: { ...event.params, ...event.context } });
405
+ ```
406
+
407
+ Verify with:
408
+
409
+ ```js
410
+ window.dataLayer.filter((e) => String(e.event).startsWith("fu_"));
411
+ ```
412
+
240
413
  ## Components
241
414
 
242
415
  ### ClassicQuizPlay
@@ -3460,6 +3633,7 @@ Football score predictor component with two templates: the default **multi-tab**
3460
3633
  | `consents` | `ConsentDef[]` | Consent definitions required before predicting |
3461
3634
  | `matchCardBgImageUrl` | `string` | Background image URL for match prediction cards. Ignored by `"embed"` |
3462
3635
  | `betslip` | `PredictorBetslipConfig` | Betslip integration config. Omit to disable. See [Betslip Integration](#betslip-integration) |
3636
+ | `callbacks` | `PredictorCallbacks` | `onEvent` — one observer for every journey event the component emits. See [Analytics](#analytics) |
3463
3637
 
3464
3638
  #### Predictor Templates
3465
3639
 
@@ -3763,6 +3937,37 @@ type PredictorBetslipTrigger =
3763
3937
 
3764
3938
  **Theme fallback:** If `betslip.themeOptions` is not provided, the Betslip widget inherits the Predictor's own `themeOptions`. A single `themeOptions` on the Predictor is enough to theme both components consistently.
3765
3939
 
3940
+ #### Analytics
3941
+
3942
+ The Predictor emits a typed event for every meaningful step — `widget_view`, `tab_view`,
3943
+ `prediction_started`, `prediction_submitted`, `private_league_joined`, `odds_click` and more.
3944
+
3945
+ ```tsx
3946
+ <Predictor
3947
+ entityId="predictor-template-123"
3948
+ sdk={sdk}
3949
+ language="en"
3950
+ callbacks={{
3951
+ onEvent: (event) => analytics.track(event.name, { ...event.params, ...event.context }),
3952
+ }}
3953
+ />
3954
+ ```
3955
+
3956
+ The same stream is available without a prop, via the `componentEvents` bus — which is what you want
3957
+ for a tag manager, since it can subscribe after the component has already mounted:
3958
+
3959
+ ```ts
3960
+ import { componentEvents } from "fansunited-frontend-components";
3961
+
3962
+ componentEvents.subscribe((event) => {
3963
+ window.dataLayer.push({ event: `fu_${event.name}`, ...event.params, ...event.context });
3964
+ });
3965
+ ```
3966
+
3967
+ Payloads carry no personal data. Full event catalogue, parameter tables, the join-key rules, and the
3968
+ tag-manager setup (including why built-in click triggers cannot see into the component's shadow root)
3969
+ are in [Analytics](#analytics).
3970
+
3766
3971
  #### TypeScript Support
3767
3972
 
3768
3973
  Import types from `fansunited-frontend-core`:
@@ -3783,9 +3988,23 @@ import {
3783
3988
  OnSuccessCTADetails,
3784
3989
  CustomThemeOptions,
3785
3990
  LanguageType,
3991
+ // Analytics
3992
+ PredictorCallbacks,
3993
+ ComponentEvent,
3994
+ ComponentEventParams,
3995
+ PredictorComponentEvent,
3996
+ PredictorAnalyticsContext,
3997
+ PredictorAnalyticsEventName,
3786
3998
  } from "fansunited-frontend-core";
3787
3999
  ```
3788
4000
 
4001
+ The `componentEvents` bus itself is imported from `fansunited-frontend-components`, not from core:
4002
+
4003
+ ```tsx
4004
+ import { componentEvents } from "fansunited-frontend-components";
4005
+ import type { ComponentEventsApi } from "fansunited-frontend-components";
4006
+ ```
4007
+
3789
4008
  #### Examples
3790
4009
 
3791
4010
  ##### Basic Predictor
@@ -5173,7 +5392,7 @@ This package exports the following components:
5173
5392
  - **`EitherOrPlay`** - Binary choice game with standings panel and customizable templates
5174
5393
  - **`Discussion`** - Threaded comments with reactions, replies, reporting, and authentication support
5175
5394
  - **`Leaderboard`** - Ranked standings for Classic Quiz, Match Quiz, Top X, and Template entities with multi-entity support
5176
- - **`Predictor`** - Multi-tab football score predictor with leaderboard, private leagues, rules, and prizes
5395
+ - **`Predictor`** - Multi-tab football score predictor with leaderboard, private leagues, rules, prizes, and a journey-event analytics bus
5177
5396
  - **`TopX`** - Multi-tab competitive picks predictor with game navigation, leaderboard, private leagues, rules, and prizes
5178
5397
  - **`Betslip`** - Floating betslip with bookmaker deep-link integration, six viewport positions, and command bus API
5179
5398
  - **`Standings`** - Sport-agnostic competition standings table with zone highlighting, rank movement, and team form