@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.
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,storyorwidget. Stories and Moments sharecta_clickandshare_click, so this is what tells them apart. (It wasevent_categoryuntil 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_idandpage_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.
Consent
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 Storiescta_clickorshare_clickcarries 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,likeandshare_clickshould carry a prefix (wsc_…) so they cannot meet another feature's events of the same name; thewidget_idscheme. - WSC — how the Widget Identifier reaches WSC Reports: the SDK's
widgetRemoteIdentifierlinks 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 |