@v4/wsc

The WSC Sports integration: the Stories and Moments clip rows drawn by WSC's Web SDK, how they are labelled, and the events they report.

2026-11-07

Overview

@v4/wsc is the integration with WSC Sports: the Stories and Moments rows, drawn in the reader's browser by WSC's Web SDK (@wscsports/blaze-web-sdk, pinned at 0.36.5). It is the only package that imports the SDK. How it sits among the club's other third-party seams is in Club integrations. A server-safe root holds the vocabulary, the label rules and the event mapping; @v4/wsc/client holds the provider, the row's mount (WscWidget) and the consent switch.

Rows reach a page two ways: an editor places a WSC Widget block on the homepage or a content page, or a template builds one in (a team's Stories, a player's Moments). Both render through @v4/content-app's WscRail, in the ClipsBlock frame.

Configuration

The feature is features.stories in apps/web/club.config.ts, declared only when NEXT_PUBLIC_WSC_API_KEY is set (a blank key counts as none). No key is the feature off: no WSC row renders and the SDK is never downloaded, so CI, verify and local dev stay green without one. NEXT_PUBLIC_WSC_ENVIRONMENT names the WSC stage the key belongs to (default prod). Both values are public by nature — the SDK writes the key into the page.

The WSC Widget block

Editors place rows with Content 🧱 WSC Widget | WEB (id: wscWidget, migrations/club/0009-wsc-widget.cjs). Contentful carries the query, never the clips: Title, Stories or Moments (clipType), Layout, Clip source (source: the editor's labels, or automatic — the player, team or game the page is about) and WSC Labels (labels, JSON, validated by the transform). The fragment is packages/model/src/content/fragments/wsc-widget.graphql. Every row, editor-placed or built into a template, is drawn in @v4/ghost-content's ClipsBlock frame by @v4/content-app's WscRail.

Labels

A template row filters by its page's subject with no editor action, using the labels WSC generates from the ids on each clip (WSC_ENTITY_LABEL_PREFIXES in src/entity.ts): a team is team_<team code> (team_tel), a player p_p<person code> (p_p014027) and a game g_<game code>. The whole label is lower-cased. A blank id, or one that would make an invalid label, yields no expression — and the block is not shown, never widened to unfiltered clips.

What it exports

Entry Contents
@v4/wsc Server-safe. The vocabulary (WSC_CLIP_TYPES, WSC_LAYOUTS, effectiveWscLayout — the animated row is Moments-only — toSdkContentType), the labels (isValidWscLabel, WscLabelExpression, WSC_ENTITY_LABEL_PREFIXES, entityLabelExpression), WSC_APPEARANCE_KEYS, WSC_READY_TIMEOUT_MS, toWscDomId, and the tracking schema (WSC_TRACKED_EVENTS, WSC_PAGE_TYPES, normaliseWscEvent)
@v4/wsc/client WscProvider, useWscSettings, WscWidget (the row's mount), setWscEventSink, setWscTrackingConsent

Three internal modules shape the SDK: init-options.ts (the page's one Initialize call — tracking off until consent), theme.ts (the SDK preset a row is created with; under reduced motion the animated row becomes its static row-rectangle sibling) and appearance.ts (the gap, ring, caption, flag and duration the host's design gives the tiles, as CSS values or var(--name) read off the mount, because no CSS reaches the SDK's own theme object).

Event tracking

The site reports what readers do with the WSC rows to ELB's GTM/GA4, through the dataLayer. Nothing is reported by the CMS, and nothing is sent anywhere else by the site. GTM and GA4 configuration is ELB's (Joe Dawber).

The events

The agreed list ("List of WSC Events to track", Santi Vives) is pushed exactly as below, with the opening of a story or of the Moments player that the tracking plan asks for. Every push is one object on window.dataLayer; event is the name GTM triggers on. The events go through the site's measurement (@v4/analytics): each also carries the tracking plan's common properties (event_id, timestamp, event_category "Video & Streaming", platform, language, competition, page_path, user_id, the consent snapshot).

Category event Properties pushed
Moments moment_opened moments_session_id, content_id, content_title, open_trigger (how the player was opened)
Moments cta_click moments_session_id, content_id, content_title, moment_duration_viewed_percent
Moments like the same, and is_liked: true
Moments unlike the same, and is_liked: false
Moments share_click moments_session_id, content_id, content_title, moment_duration_viewed_percent
Stories story_opened story_session_id, content_id, content_title, open_trigger
Stories cta_click story_session_id, content_id, content_title, story_page_duration_viewed_percent (see below)
Stories share_click the same
Widgets widget_visible placement_distance, widget_id, widget_type, widget_size, content_type, page_type
Widgets widget_click the same, and content_id, content_name (the clip that was tapped)

Every push also carries:

  • wsc_event_category — moment, story or widget. Stories and Moments share cta_click and share_click, so this is what tells them apart. (It was event_category until the events moved into the site's measurement: that name is the tracking plan's own taxonomy property, which the site now sets on every event.)
  • widget_id and page_type — the row the event came from (below), on Stories and Moments events too, so every event says where it happened.

Example — a reader taps the third Stories circle on the homepage:

{
  "event": "widget_click",
  "wsc_event_category": "widget",
  "widget_id": "homepage_stories",
  "page_type": "homepage",
  "placement_distance": 412,
  "widget_type": "row",
  "widget_size": "1328x132",
  "content_id": "3f9c…",
  "content_type": "story",
  "content_name": "Lessort signs new deal in Athens"
}

Names are in lower case, as the list writes them (the list's Share_click and Moment_duration_viewed_percent are pushed as share_click and moment_duration_viewed_percent). Values follow WSC's unified Oct 2026 schema: enumerations in lower case (row, story, moment), numbers as numbers. A property the SDK did not send is left out, never sent empty. story_opened is the SDK's story_start and moment_opened its moments_playlist_start; their names are the plan's convention, not yet checked against its rows. No other WSC event is pushed (story_page_start, moment_start, widget_load and the rest stay off the dataLayer).

Which row: widget_id and page_type

Each row is named by WHERE it is, not by what it shows, so the name is the same on every page of a kind and filters cleanly in GA4:

page_type Row widget_id
homepage the editor's WSC blocks, in page order homepage_stories, homepage_moments; a second of the same kind homepage_stories_2
content the editor's WSC blocks on a content page content_stories, content_moments, …
team the Stories row on every team page team_stories
player the Moments row on every player page player_moments
game reserved: the Game Centre has no Stories row yet game_stories

news is reserved for the news listing, which takes no WSC block today. Which team or player a row belongs to is in its clips already (the WSC labels, team_bes, p_p006661), so the name does not repeat it.

No WSC event is pushed, and WSC's own analytics stay off, until the reader consents. This is the site's ONE consent check, @v4/analytics: the site loads OneTrust itself, and WSC follows the tracking plan's Analytics category as OneTrust reports it (OneTrust's group C0002 by default; ELB's own group ids are set in club.config.ts) and changes with it. An event seen before the reader answers the banner waits, and is sent once they accept (dropped if they refuse), as every event of the site's does (setWscEventSink).

To check the events on Dev before OneTrust is on the site, Dev can allow a tester's opt-in: with NEXT_PUBLIC_WSC_TRACKING_PREVIEW=true set on Dev only, a tester runs localStorage.setItem("wsc:tracking-consent", "granted") in the browser console and reloads. A local dev server allows it without the variable. Production ignores it, and wherever OneTrust is present it decides regardless. The same switch now opens every event the site sends, page views included.

What the SDK cannot send yet

WSC's Web SDK 0.36.5 sends the older event format; only Web 2.0.x emits the unified schema, and 0.37.1 is the newest release. The site maps the older fields onto the agreed names (story_id / moment_id → content_id, story_title / moment_title → content_title). Three properties need more from WSC:

  • is_liked — not sent by 0.36.5. The site derives it from the event: like → true, unlike → false.
  • story_page_duration_viewed_percent — 0.36.5 sends it only when a story page closes, an event that is not on the list. A Stories cta_click or share_click carries it once WSC adds it there.
  • page_type — the SDK's own guess ("Homepage" or "Category/Article") is not used; the site sends its own (above).

Before sign-off

  • Santi — the names above; whether cta_click, like and share_click should carry a prefix (wsc_…) so they cannot meet another feature's events of the same name; the widget_id scheme.
  • WSC — how the Widget Identifier reaches WSC Reports: the SDK's widgetRemoteIdentifier links a row to a widget configured in WSC's CMS, which can then override its labels and look, so it is not switched on until WSC confirms how they want it used.
  • ELB / Joe Dawber — the OneTrust analytics group, and ELB's GTM container id per environment: GTM's preview needs the container on the page, which the site's measurement puts there once the id is set.

Where it lives

File What
packages/wsc/src/analytics/schema.ts The agreed list, the mapping onto it, the page types
packages/wsc/src/client/analytics.ts The page-wide listener: consent, the row an event came from, the push
packages/wsc/src/client/provider.tsx Starts the listener wherever WSC is on
packages/content-app/src/lib/prepare-blocks.tsx Names the editor's rows (wscPlacements)
packages/basketball-app/src/lib/wsc-rails.ts Names the rows the team, player and game pages build in
packages/analytics/src/consent.ts The consent rule (OneTrust, the Dev preview), shared by every event
apps/web/src/components/wsc-tracking-consent.tsx Hands WSC the Analytics answer, on load and whenever OneTrust changes