Cross-widget wiring
Every GomaPulse widget is an independent custom element in its own npm package. Drop two of them on a page and they will, in several cases, start cooperating immediately: tap a sport in one and the other re-scopes its feed. This page is the map of that behaviour: which widgets talk to which, what carries the message, and what you have to write yourself.
If you are integrating a single widget, you don't need this page: go to its entry under Widgets. Come back when you have two or more on a page, or when you want to drive a widget from your own UI.
Vocabulary
A few sports-betting terms show up throughout. An event (or fixture, or match) is one contest: "Arsenal vs Chelsea". A market is one question you can bet on within an event: "who wins?", "how many goals?". An outcome is one answer to that question, and it carries the odds (the price). A competition (also tournament) is the league or cup an event belongs to; a region is the country it sits under. A betslip is the shopping basket of selected outcomes. A leg is one selection inside a betslip: a single outcome from a single event. In single mode each leg is its own separate bet with its own stake and payout; in multiple mode every leg combines into one bet whose odds multiply together. A topic is the WAMP subscription string a feed widget is bound to: a slash-delimited path such as custom-matches-aggregator/{sportId}/{locationId}/..., and some follow* handlers work by rewriting one segment of it in place. WAMP (Web Application Messaging Protocol) is the pub/sub-and-RPC protocol the live connection speaks over socketUrl; both "subscribing to a topic" and "making an RPC call" go over the same WAMP session.
One mechanism: composed custom events
Nothing is imported between widget packages. A widget never holds a reference to another widget, never reaches into another widget's Shadow DOM, and never shares a Pinia store with one. Cooperation happens through exactly two channels:
- DOM
CustomEvents: every widget event is dispatched on the widget's own host element withbubbles: true, composed: true. localStorageenvelopes: for state that must survive a reload or cross a browser tab (the betslip, favourites, the session).
The events come from a single place. @gomagaming/core provides a gomaEmit(eventName, detail) function to every component in every widget tree, and it does exactly one thing (packages/core/src/createWidgetElement.js):
js
this.dispatchEvent(
new CustomEvent(eventName, { bubbles: true, composed: true, detail })
)composed: true is the load-bearing flag. Without it the event would stop at the boundary of the widget's Shadow DOM and never reach your page. With it, the event crosses out of the shadow tree, bubbles up through your DOM, and reaches window. So a single listener catches a given event from every instance of every widget on the page:
js
window.addEventListener('goma:sport-select', (e) => {
console.log(e.detail.sportId, e.detail.sportName)
// e.target is the <goma-...> host element that emitted it: NOT the
// inner button. Shadow DOM retargets the event as it crosses out.
})That retargeting is what makes the *Selector props described below work: because e.target is the custom element itself, a listener can ask e.target.matches('#my-picker') and ignore everything else.
Three ways widgets cooperate
1. Host-mediated: you listen, you assign
The baseline. Your page listens for an event from one widget and sets a property on another. Nothing is automatic, and you get to interpose whatever you like: a route change, an analytics call, a permission check.
html
<goma-sports-navigation-horizontal id="sports"></goma-sports-navigation-horizontal>
<goma-top-competitions id="tops"></goma-top-competitions>
<script type="module">
import '@gomagaming/sports-navigation-horizontal'
import '@gomagaming/top-competitions'
const tops = document.getElementById('tops')
window.addEventListener('goma:sport-select', (e) => {
// <goma-top-competitions> has no follow* prop: wire it by hand.
tops.sportId = e.detail.sportId
})
</script>Use this whenever the pairing you want is not in the follow* table below, or when you want the transition to go through your own router first.
2. follow* auto-wiring: zero host code
Some pairings are common enough that the listening widget subscribes to the emitter's event itself. You drop both elements on the page and they cooperate with no JavaScript from you at all.
This is the most useful table on the page. Note that the defaults are not uniform: some of these are on out of the box, some are off, and assuming the wrong one is the usual cause of "why is my feed ignoring the picker".
| Property | Attribute | Widget | Listens for | Default | What it does |
|---|---|---|---|---|---|
followSportPicker | follow-sport-picker | <goma-events-horizontal> | goma:sport-select | true | Rewrites segment 0 (sportId) of the feed's topic. |
followRegionPicker | follow-region-picker | <goma-events-horizontal> | goma:region-select | true | Rewrites segment 1 (locationId) of the topic. |
followCompetitionPicker | follow-competition-picker | <goma-events-horizontal> | goma:competition-select | true | Rewrites segment 2 (tournamentId) of the topic. |
followTimePicker | follow-time-picker | <goma-events-horizontal> | goma:time-select | true | Rewrites segment 3 (hoursInterval) of the topic. |
followSportPicker | follow-sport-picker | <goma-region-filter> | goma:sport-select | true | Sets its own sportId and re-fetches the region list. |
followSportPicker | follow-sport-picker | <goma-competitions-list> | goma:sport-select | true | Sets its own sportId and re-fetches the competition list. |
followEventNavigate | follow-event-navigate | <goma-game-details> | goma:event-navigate | true | Sets its own eventId, so a card tap anywhere swaps the details view. |
followMissionSelect | follow-mission-select | <goma-mission-details> | goma:mission-select | true | Shows the selected mission. |
followSportPicker | follow-sport-picker | <goma-top-competitions-pills> | goma:sport-select | true | Sets its own sportId and re-fetches. |
followSportPicker | follow-sport-picker | <goma-favorites> | goma:sport-select and goma:filters-apply | false | Narrows the rendered favourites to that sport, client-side. |
followFilters | follow-filters | <goma-favorites> | goma:filters-apply and goma:time-select | false | Applies the default-format market, odds range and time window to the favourite event cards. |
Every one of these is a boolean, settable as a JS property or as an attribute. As attributes they are parsed as "anything except the string false is true", so follow-sport-picker="false" is the way to opt out from markup.
Two details worth knowing:
Rewriting a topic is not a reconnect. The four <goma-events-horizontal> opt-ins work by editing one segment of the WAMP topic string the feed is subscribed to. The subscription rebinds in place. All four also reset the topic's eventLimit segment back to its starting value at the same time, so re-scoping after you have pressed Load more does not carry the grown page size into the new scope.
Scoping to one emitter. If you have two sport pickers on a page and only one should drive a given feed, the listening widgets accept an optional CSS selector matched against the emitting host element: sportPickerSelector (<goma-events-horizontal>, <goma-region-filter>, <goma-competitions-list>, <goma-top-competitions-pills>, <goma-favorites>), regionPickerSelector, competitionPickerSelector, timePickerSelector (all <goma-events-horizontal>), eventNavigateSelector (<goma-game-details>), and missionSelectSelector (<goma-mission-details>). Left empty (the default) the widget listens to any emitter.
eventNavigateSelector matters more than the rest, because goma:event-navigate has about a dozen emitters and followEventNavigate is on by default: with two <goma-game-details> on a page, both jump to every card tap anywhere until you scope them.
Debugging "why is my feed ignoring the picker"
Every follow* handler logs the topic it rewrites, but only when the widget's debug config flag is on (config: { debug: true }, off by default like every widget's debug key). Set it on the listening widget and watch the console: a silent picker tap usually means the handler never attached (wrong default, *Selector scoped to a different emitter) rather than a broken rewrite.
3. Shared state: localStorage envelopes
Events are fire-and-forget: a widget that mounts after an event was dispatched never sees it. For state that has to survive a mount, a page reload, or a second browser tab, @gomagaming/sports-domain uses versioned localStorage envelopes instead, wrapped in a Vue ref by useSharedStorage(name, initial, { version }) (packages/sports-domain/src/shared-state/useSharedStorage.js). Keys follow the shape goma: + name + :v + version. Three layers keep instances in sync: an in-module bus for widgets sharing one module instance, a BroadcastChannel for other realms on the same origin, and the storage event for other tabs. If localStorage is unavailable (privacy mode, SSR) it silently degrades to an in-memory ref.
| Envelope | name | version | Carries | Written by |
|---|---|---|---|---|
| Betslip | betslip | 3 | The selection list plus stake / bet-type / accept-odds-change | useSharedBetslipSync |
| Favourites | favorites | 1 | { competitions: string[], events: string[] } | useSharedFavoritesSync |
| Session | session | 1 | The resolved player identity | useSharedSession |
This is why <goma-events-horizontal> and <goma-betslip-floating> cooperate with no host code and no follow* prop between them: the odds button writes a selection into the betslip envelope, and every mounted betslip reads the same envelope. Widgets that only contribute selections call useSharedBetslipSync({ subscribeToState: false }) so they don't adopt the betslip's stake and tab state: that opt-out is taken by events-horizontal, competitions-list, favorites, game-details, hero-card, highlight-card, multi-banners, pros-choice and suggested-bet, while the two betslip widgets subscribe to the full envelope.
The betslip has a second, belt-and-braces path: useSharedBetslipSync also attaches a window listener for goma:outcome-select, so a host that dispatches that event itself (with no GomaPulse card widget on the page at all) still fills the slip. When both paths fire, the store de-duplicates on outcomeId.
The storage contracts (the exact shape of each envelope, who's allowed to write which fields, legacy keys, and cross-tab behaviour) get their own page: Shared state.
Driving a selection from the host
Pickers own a selection: selectedRegionId, selectedSportId, selectedCompetitionId. You can set those from your page, and you will need to for anything the user did not click: a menu entry that opens a scope directly, a deep link, state restored after a refresh.
Two things behave differently from a tap, and both matter.
A host write announces itself, exactly like a tap
Every picker below emits its selection event when you set the property, not only when a user clicks. So a menu entry or a deep link drives follow*-wired siblings with no extra work from you:
js
// The feed follows, because the picker emits.
regionFilter.selectedRegionId = brazilId| Widget | Property | Event |
|---|---|---|
<goma-region-filter> | selectedRegionId | goma:region-select |
<goma-top-competitions-pills> | selectedCompetitionId | goma:competition-select |
<goma-top-competitions> | selectedCompetitionId | goma:top-competition-select |
<goma-competitions-list> | selectedCompetitionId | goma:competition-select |
<goma-sports-navigation-horizontal> | selectedSportId | goma:sport-select |
<goma-sports-navigation-dialog> | selectedSportId | goma:sport-select |
<goma-competition-filter> | selectedId | goma:filter-select |
Three rules apply across all of them:
Nothing is emitted on mount. A starting value in your markup is where the widget begins, not a selection somebody made:
html<!-- Opens on Brazil. Silent: no event during boot. --> <goma-region-filter selected-region-id="19"></goma-region-filter>Changing that same attribute later, or assigning the property at any point after the element is connected, is a host action and announces itself. So seed the opening scope from markup, and use the property for navigation afterwards: that way your page boots without firing the whole
follow*chain, and still drives it on every subsequent move.Re-assigning the current value emits nothing. A write that changes nothing is not an event, so you can assign freely from a render or a store subscription without generating a storm.
Ids are the contract; names are best-effort. Set a selection before the widget's list has loaded and it still emits immediately with the right id and
nulldescriptors, becausefollow*listeners read the id. You never have to wait for data to deep-link.nullis the one sentinel across the whole family. Four widgets emitgoma:competition-select(the three competition pickers plus<goma-search>) and a host wires a single listener for it, so they must not disagree about what "unknown" looks like.
Two exceptions, both deliberate:
<goma-competitions-list>stays silent when you clearselectedCompetitionId. Closing the drill-in detail has its own event (goma:competition-detail-close), and an empty selection would otherwise tell a following feed to scope to nothing.<goma-sports-navigation-dialog>in apply-footer mode (showApplyButton) treats the selection as pending: tapping only highlights, and the event is emitted when Apply is pressed.
Writing two coupled properties at once
Setting a sport and a narrower scope together ("Football › Brazil" from a side menu) runs into ordering that is not obvious.
A picker that scopes itself to a sport clears its selection when sportId changes, because the scopes are sport-specific server-side and yesterday's region almost certainly is not in the new sport's list. That clearing runs in a watcher, and watchers are batched: they observe the state after both of your writes have landed, not at the moment sportId changed. So the widget sees your freshly-set region, cannot tell it apart from a leftover, and clears it.
There is no write order that survives this. Setting the region first and setting it second fail identically:
js
el.sportId = 8
el.selectedRegionId = brazilId // cleared
el.selectedRegionId = brazilId
el.sportId = 8 // also clearedDeferring the second write by a tick does leave the pill correct, but the clear has already emitted its own "no filter" event in the meantime, so a following feed ends up unfiltered while the pill shows a region. The visible selection and the data disagree, which is harder to notice than an outright failure. Do not reach for setTimeout here.
The supported answer is to turn the clearing off and own it:
js
const el = document.getElementById('rf')
el.resetRegionOnSportChange = false
function openSportAndRegion(sportId, regionId) {
el.sportId = sportId
el.selectedRegionId = regionId // survives, and emits
}
function openSport(sportId) {
el.sportId = sportId
el.selectedRegionId = 'all' // now yours to clear
}The opt-out is on by default everywhere, so leaving it alone keeps the automatic behaviour. Two widgets clear a selection on a sportId change, and both offer one:
| Widget | Cleared on a sport change | Opt-out |
|---|---|---|
<goma-region-filter> | selectedRegionId → 'all' | resetRegionOnSportChange |
<goma-top-competitions-pills> | selectedCompetitionId → null | resetCompetitionOnSportChange |
No other picker clears itself, so elsewhere you can simply set both properties and both stick.
The cooperation graph
Solid arrows are automatic (a follow* prop or shared storage); dashed arrows need a listener in your page.
<goma-sports-navigation-horizontal>
<goma-sports-navigation-dialog>
│ goma:sport-select
├────────────────────────────► <goma-events-horizontal> (followSportPicker, ON)
├────────────────────────────► <goma-region-filter> (followSportPicker, ON)
├────────────────────────────► <goma-competitions-list> (followSportPicker, ON)
├────────────────────────────► <goma-top-competitions-pills> (followSportPicker, ON)
└─ – – – – – – – – – – – – – ► <goma-favorites> (followSportPicker, OFF)
<goma-region-filter> ──goma:region-select──► <goma-events-horizontal>
<goma-time-filter> ─┐
<goma-filters-settings> ┴─goma:time-select───► <goma-events-horizontal>
<goma-competitions-list> ─┐
<goma-top-competitions-pills> ┴─goma:competition-select─► <goma-events-horizontal>
<goma-filters-settings> ──goma:filters-apply──► <goma-favorites> (followFilters / followSportPicker, OFF)
any card widget ──goma:event-navigate──► <goma-game-details> (followEventNavigate, ON)
<goma-missions-hub> ──goma:mission-select──► <goma-mission-details> (followMissionSelect, ON)
any odds button ──── shared betslip envelope ───► <goma-betslip-floating>
└─── goma:outcome-select ───────► <goma-betslip-sidebar>
your page ──goma:betslip-open / -close / -toggle──► the betslip widgets
your page ──goma:sports-dialog-open───────────────► <goma-sports-navigation-dialog>Emitter → listener matrix
These are the events at least one widget listens for: the ones that produce cooperation without you writing a listener. Everything else in the catalogue below is for your page to consume.
| Event | Emitted by | Listened for by | Gate | Effect on the listener |
|---|---|---|---|---|
goma:sport-select | <goma-sports-navigation-horizontal>, <goma-sports-navigation-dialog> | <goma-events-horizontal> | followSportPicker (on) | Rewrites topic segment 0. |
<goma-region-filter> | followSportPicker (on) | New sportId, re-fetch. | ||
<goma-competitions-list> | followSportPicker (on) | New sportId, re-fetch. | ||
<goma-top-competitions-pills> | followSportPicker (on) | New sportId, re-fetch. | ||
<goma-favorites> | followSportPicker (off) | Narrows rendered favourites. | ||
goma:region-select | <goma-region-filter> | <goma-events-horizontal> | followRegionPicker (on) | Rewrites topic segment 1. |
goma:competition-select | <goma-competitions-list>, <goma-top-competitions-pills>, <goma-search> | <goma-events-horizontal> | followCompetitionPicker (on) | Rewrites topic segment 2. |
goma:time-select | <goma-time-filter>, <goma-filters-settings> (on Apply, when the window changed) | <goma-events-horizontal> | followTimePicker (on) | Rewrites topic segment 3. |
<goma-favorites> | followFilters (off) | Sets the active time window. | ||
goma:event-navigate | every game card: <goma-events-horizontal>, <goma-competitions-list>, <goma-favorites>, <goma-hero-card>, <goma-multi-banners>, <goma-game-details>, <goma-highlight-card>, <goma-pros-choice>, plus <goma-search> on an event result | <goma-game-details> | followEventNavigate (on), optionally scoped by eventNavigateSelector | Swaps the displayed eventId. |
goma:filters-apply | <goma-filters-settings> | <goma-favorites> | followSportPicker (off) | Reads detail.sportId and narrows. |
<goma-favorites> | followFilters (off) | Reads marketId, oddsFilter, time.timeValue. | ||
goma:mission-select | <goma-missions-hub> | <goma-mission-details> | followMissionSelect (on) | Shows that mission. |
goma:outcome-select | every odds button (@gomagaming/sports-domain's shared card components): including the host-fed cards <goma-highlight-card>, <goma-pros-choice>, <goma-multi-banners> and <goma-hero-card> | <goma-betslip-floating>, <goma-betslip-sidebar> | always | Adds the selection to the slip. |
<goma-betslip-floating>, <goma-betslip-sidebar> | openOnSelect (off) | Surfaces the slip: desktop viewports only. | ||
goma:bet-the-same | <goma-suggested-bet> | <goma-betslip-floating>, <goma-betslip-sidebar> | openOnSelect (off) | Surfaces the slip, any viewport. |
goma:cashout-success | <goma-my-bets> (also the My Bets tab inside both betslips) | <goma-betslip-floating>, <goma-betslip-sidebar> (their own embedded My Bets only) | always | Refreshes the player balance now and again after 2.5 s. |
goma:betslip-open | your page | <goma-betslip-floating>, <goma-betslip-sidebar> | always | Opens (floating) and optionally selects a tab. |
goma:betslip-close | your page | <goma-betslip-floating> | always | Closes. No-op on the sidebar. |
goma:betslip-toggle | your page | <goma-betslip-floating>, <goma-betslip-sidebar> | always | Flips open/closed; optionally selects a tab. |
goma:sports-dialog-open | your page | <goma-sports-navigation-dialog> | always | Opens the sport picker dialog. |
The openOnSelect gate above is not a follow* prop, and it defaults to false (off): set el.openOnSelect = true, or the open-on-select attribute, to turn it on. Watch out: its attribute parser is the opposite of the follow* convention a few sections up. follow* attributes are "anything but the string false is true," so a bare follow-sport-picker (no value) is on. open-on-select is "the string true or an empty string is true, anything else is false," so a bare open-on-select (no value) is also on, but open-on-select="yes" or any other non-true, non-empty value is off, where the follow* rule would have read it as on. Set it explicitly (open-on-select="true" or the empty-attribute form) rather than guessing from the follow* habit.
Two things in that table trip people up:
<goma-competition-filter>is not in it. Despite the name, it is a generic host-fed pill bar and emitsgoma:filter-select, notgoma:competition-select. Nothing follows it automatically: wire it yourself. The widgets that do emitgoma:competition-selectare<goma-competitions-list>,<goma-top-competitions-pills>and<goma-search>.<goma-search>can re-scope a feed across sports. Picking a competition from the search panel emits the samegoma:competition-selecta picker does, and a following feed rewrites only segment 2 of its topic. Search is not sport-scoped, so a result from another sport leaves the feed with a topic that mixes the two: set the feed'ssportIdfrom the same handler if that matters.<goma-top-competitions>is not in it either. The vertical list emitsgoma:top-competition-select, which no widget listens for. Its horizontal sibling<goma-top-competitions-pills>emitsgoma:competition-select, which<goma-events-horizontal>does follow. If you want the vertical list to drive a feed, listen and assign.
Inbound command events
Four events run the other way: nothing in GomaPulse emits them, and a widget listens. These are the remote controls you dispatch from your own UI: a cart button in your header, a filter icon in your nav.
js
// Open the betslip on the My Bets tab.
window.dispatchEvent(new CustomEvent('goma:betslip-open', {
detail: { target: 'myBets' },
}))
// Open the sport picker dialog from your own button.
window.dispatchEvent(new CustomEvent('goma:sports-dialog-open'))detail.target accepts 'betslip' or 'myBets' and is normalised case-, space- and dash-insensitively, so 'My Bets', 'my-bets' and 'bets' all resolve. Switching to My Bets is refused when that tab is not currently reachable. All mounted betslips react: there is no per-instance targeting.
Full event catalogue
Every goma:* event in the shipped source, grouped by domain. The Listeners column names the widget that reacts; host-only means no widget listens and the event exists purely for your page.
detail shapes below are read off the actual dispatch sites. Where a field can be absent the source uses ?? null or a conditional spread: treat every field as possibly null and guard accordingly.
Selection and filtering
| Event | Emitted by | detail | Listeners |
|---|---|---|---|
goma:sport-select | <goma-sports-navigation-horizontal>, <goma-sports-navigation-dialog> | { sportId, sportName, sportIconId, previousSportId } | events feed, region filter, competitions list, top-competitions pills, favourites |
goma:region-select | <goma-region-filter> | { regionId, regionName, regionShortName, regionCode, previousRegionId }: the "All" pill emits regionId: 'all' | events feed |
goma:competition-select | <goma-competitions-list>, <goma-top-competitions-pills> | { competitionId, competitionName, competitionSportId, previousCompetitionId }; the list also adds competitionVenueId | events feed |
goma:top-competition-select | <goma-top-competitions> | { competitionId, competitionName, competitionSportId, competitionVenueId, isFeatured, previousCompetitionId } | host-only |
goma:time-select | <goma-time-filter>; <goma-filters-settings> also re-emits it on Apply when its time window changed, immediately before goma:filters-apply | { timeValue, timeLabel, previousTimeValue }: timeValue is '{start}-{end}' in hours, or 'all' at both extremes | events feed, favourites |
goma:filter-select | <goma-competition-filter> | { id, label, previousId } | host-only |
goma:filter-left / goma:filter-right | <goma-competition-filter> | the edge-trigger object you supplied ({ icon?, text?, color?, textColor? }) | host-only |
goma:filters-apply | <goma-filters-settings> | { time: { timeValue, timeLabel } | null, sportId, sportName, marketId, marketLabel, oddsFilter } (time is null when the widget's showTimeFilter is false, because a hidden section owns no window) the consolidated summary; a changed time window also raises goma:time-select just before it | favourites |
goma:filters-reset | <goma-filters-settings> | same shape as goma:filters-apply, carrying the reset pending values | host-only |
goma:region-toggle | <goma-competitions-list> | { regionId, regionName, isOpen } | host-only |
goma:feed-load-failed | <goma-events-horizontal>, <goma-competitions-list>, <goma-top-competitions>, <goma-top-competitions-pills>, <goma-region-filter> | { source, sportId?, topic? }: source is 'events' (with topic), 'competitions', 'top-competitions' or 'locations' (with sportId, null when cross-sport). Informational: the feed or list call got no answer after every retry. The widget shows its empty state only when it had no rows: after Load more or a reconnect the earlier rows stay. The competition and region lists send it in 'rpc' mode only | host-only (use it to show your own message or to reload) |
goma:tab-select | <goma-game-details> | { eventId, groupKey, groupName } | host-only |
goma:tab-change | <goma-missions-hub> | { view }: 'challenges' or 'my-challenges' | host-only |
Navigation
| Event | Emitted by | detail | Listeners |
|---|---|---|---|
goma:event-navigate | every game card: <goma-events-horizontal>, <goma-competitions-list> (detail view), <goma-favorites>, <goma-hero-card>, <goma-multi-banners> (sport banner), <goma-game-details> (outright card), <goma-highlight-card> and <goma-pros-choice> (card body and the "See other markets" link, on both), and the shared live / prelive / market / outright cards in @gomagaming/sports-domain | { eventId, homeTeam, awayTeam, competitionName } | game details |
goma:competition-detail-open | <goma-competitions-list> | { competitionId, competitionName, competitionSportId, competitionVenueId } | host-only |
goma:competition-detail-close | <goma-competitions-list> | { competitionId } | host-only |
goma:back | <goma-game-details> | { eventId } | host-only |
goma:banner-select | <goma-multi-banners> | { type, id, ctaUrl } plus eventId on sport banners and gameId on casino banners; type is 'sport', 'casino' or 'info' | host-only |
goma:casino-game-select | <goma-casino-games> | { id, name, ctaUrl } | host-only |
goma:casino-games-all-select | <goma-casino-games> | { url }: the header's opt-in "All slots" link was tapped; url is the host-fed allSlotsUrl, null when unset | host-only |
goma:highlight-select | <goma-highlight-card> | { id, eventId, ctaUrl }: id is the card's own CMS slot key, eventId the provider id (null when the card declares none), ctaUrl the host-fed destination | host-only |
goma:highlight-cta | <goma-highlight-card> | { id, eventId, label, url }: label is the resolved CTA copy, url the card's cta.url falling back to ctaUrl (null when neither is set) | host-only |
goma:pros-choice-select | <goma-pros-choice> | { id, eventId, marketId, marketName }: the promoted market on the card that was tapped | host-only |
goma:pros-choice-cta | <goma-pros-choice> | { id, eventId, ctaType: 'markets', label, url }: ctaType is a constant today, carried so a second CTA variant would add a value rather than change the shape | host-only |
goma:my-bet-select | <goma-my-bets> and the My Bets tab inside both betslips | { bet, tab } | host-only |
goma:navigate | any widget with internal routing, via @gomagaming/core's emit-router | { name, params, path, query }: the navigation is then cancelled inside the widget; your page decides what it means | host-only |
goma:dialog-open / goma:dialog-close | <goma-sports-navigation-dialog>, <goma-filters-settings>, <goma-betslip-floating> | {} | host-only |
goma:dialog-back | <goma-sports-navigation-dialog> | {} | host-only |
goma:sports-dialog-open | your page: dispatch this yourself | {} | sports picker dialog |
The four card events above are host-only, but the taps that raise them are not inert. Both <goma-highlight-card> and <goma-pros-choice> emit goma:event-navigate alongside their -select (card body) and -cta ("See other markets") events, so either tap opens <goma-game-details> with no host code. That extra emission is gated on a real eventId: these are host-fed CMS cards, and a card that declares no eventId emits its own -select / -cta event only. Its slot id is never advertised as an event id, because <goma-game-details> would then subscribe to a topic that does not exist. Give a card an eventId if you want the automatic navigation; use the url on the -cta payload if you route somewhere of your own.
Favourites and session
| Event | Emitted by | detail | Listeners |
|---|---|---|---|
goma:favorite-toggle | every favourite star, through @gomagaming/sports-domain's shared gate: the game cards (including the outright card's competition star), <goma-favorites>, <goma-hero-card>, <goma-highlight-card> and <goma-pros-choice> | { type, id, isFavorite }, plus eventId (same value as id) when type is 'event'. A 'competition' star carries no eventId | host-only |
goma:favorites-browse | <goma-favorites> | { target: 'upcoming' }: emitted by the upcoming events link in the empty state | host-only |
goma:favorite-select | <goma-hero-card> | { eventId, isFavorite }: deprecated, emitted alongside goma:favorite-toggle for one release. No other widget emits it; migrate to goma:favorite-toggle | host-only |
goma:login-required | any favourite star tapped while signed out; also <goma-hero-card> | { reason: 'favorite', type, id } | host-only: the star does not write. Your page drives login |
goma:session-ready | any session-aware widget once its identity resolves | { userId, username }: username is null unless the full exchange ran | host-only. Fires once per mounted widget per token; handlers must be idempotent |
goma:session-expired | any session-aware widget when the provider invalidates the session | { reason, invalidationId } | host-only. Treat as a logout, not a warning |
Betslip
| Event | Emitted by | detail | Listeners |
|---|---|---|---|
goma:outcome-select | every odds button | { bettingOfferId, outcomeId, outcomeName, odds, eventId, marketId, bettingTypeId, eventPartId, line } | both betslips |
line is the market's line at tap time (a handicap's 12.5, a total's 2.5; null for markets that have none). A betslip keeps it to tell a line that MOVED during the game apart from an ordinary price tick: the provider rewrites the same market and re-prices the same offer, and without it the row silently becomes a different bet. | |||
goma:outcome-deselect | odds button (deselect), and the betslip panel's row-remove button | { bettingOfferId, outcomeId } from both surfaces. Dedupe on outcomeId: the panel keys its rows by outcome id and never holds an offer id, so its bettingOfferId is null | host-only |
goma:bet-removed | the betslip panel | { outcomeId } | host-only |
goma:betslip-cleared | the betslip panel | {} | host-only |
goma:stake-change | the betslip panel | { stake } | host-only |
goma:selection-stake-change | the betslip panel, in single-bet mode | { outcomeId, stake } | host-only |
goma:bonus-select | the betslip panel, when a bonus is applied | { walletId, type, name, amount, currency, selectionId, previousWalletId }. selectionId is null at slip level (multiple/system), the selection's outcomeId in single mode. amount is null for types whose wallet amount is not money (stakeback, cashback) or when none is set. | host-only. Gated on bonusEnabled |
goma:bonus-remove | the betslip panel, when an applied bonus is cleared | { walletId, type, name, amount, currency, selectionId } | host-only |
goma:bonus-unavailable | the betslip panel, when the applied bonus is dropped as no longer eligible | { walletId, type, name, amount, currency, selectionId, reason: 'ineligible' } | host-only |
goma:bet-the-same | <goma-suggested-bet> | { rank, stake, totalOdds, winnings, selectionCount, selections }: the legs are also pushed into the shared betslip envelope before this fires | both betslips (openOnSelect) |
goma:cashout-success | <goma-my-bets> and the My Bets tab inside both betslips | { betId, cashoutType: 'FULL' | 'PARTIAL', cashoutAmount, pending, partialCashoutStake? }. pending is true when the provider holds the cash out for approval. partialCashoutStake is present only for a 'PARTIAL' cash out. | both betslips, from their own My Bets tab |
goma:cashout-error | <goma-my-bets> and the My Bets tab inside both betslips | { betId, cashoutType: 'FULL' | 'PARTIAL', reason, status?, message? } | host-only |
goma:combination-rejected | the betslip, when the provider refuses the current combination | { message, errorCode, selections: [{ outcomeId, eventId, name, outcomeName, marketName }] } | host-only |
goma:combination-resolved | the betslip, when a previously rejected combination becomes valid | {} | host-only |
goma:betslip-open / goma:betslip-close / goma:betslip-toggle | nothing: dispatch these yourself | { target? } on open and toggle | both betslips |
bettingOfferId and outcomeId are different things and both matter. outcomeId is the abstract outcome ("Portugal wins"); bettingOfferId is the price-tied offer under it ("Portugal wins @ 2.0"), which changes as prices move. If you dispatch goma:outcome-select yourself, include bettingTypeId too: the provider rejects a bet whose leg has no betting type.
Place-bet lifecycle
Fired in this order when the user presses Place bet. batchId correlates them.
| Event | detail | Listeners |
|---|---|---|
goma:place-bet-blocked | { reason }: emitted instead of starting, when the user presses a refused button | host-only |
goma:place-bet-start | { batchId, betType, betCount, selectionCount, totalStake }: once per press. betCount is how many bet-placed / bet-failed events will follow, so the batch is done at placed + failed === betCount | host-only |
goma:place-bet-request | the full request payload (uniqueId, batchId, type, amount, oddsValidationType, terminalType, freeBet, selections[], plus auto-derived identity fields): once per submitted bet | host-only. In placeBetMode: 'event' this is the integration point: you place the bet with your own backend |
goma:bet-placed | the receipt. In placeBetMode: 'api' it is the provider's response; in 'event' mode a mock receipt ({ success, batchId, betId, type, amount, odds, possibleWinnings, selections, placedAt }) | host-only |
goma:bet-failed | { batchId, message, payload, status, body }, plus unavailableSelections and movedLineSelections on a multiple: legs the provider withdrew, and legs whose market line moved after they were tapped (the match is still on; the bet the row names no longer exists) | host-only |
Missions
| Event | Emitted by | detail | Listeners |
|---|---|---|---|
goma:mission-select | <goma-missions-hub> | { missionId, challengeId, mission } | mission details |
goma:mission-cta | <goma-missions-hub>, <goma-mission-details> | { missionId, challengeId, internalUserId, action } | host-only |
goma:mission-optout | <goma-mission-details> | { missionId, challengeId } | host-only: the widget forfeits nothing; you show the confirmation and call the provider |
goma:mission-game-select | <goma-mission-details> | { missionId, game } | host-only |
goma:mission-back | <goma-mission-details> | { missionId } | host-only |
goma:missions-offers | <goma-missions-hub> | { tab, offersUrl } | host-only |
goma:missions-count | <goma-missions-hub> | { view, status, counts: { active, pending, finished, total, catalog } }: view is 'challenges' or 'my-challenges', status is 'ok' or 'error'. A count is null when this instance never asked for that feed (unknown, not zero) | host-only. Fires once the feed settles and on every change after, deduped on the serialised payload: for a page that hides the widget's own tab strip and draws its own counters |
SEO
| Event | Emitted by | detail | Listeners |
|---|---|---|---|
goma:seo-data | <goma-events-horizontal>, <goma-game-details>, <goma-hero-card>, <goma-highlight-card>, <goma-pros-choice>, <goma-multi-banners> (sport banners only), <goma-competitions-list> (drill-in detail only), <goma-casino-games> | { widget, type: 'sports-event' | 'casino-game', items }: fired once a widget's data resolves, and again only when an item's identity changes, never on an odds tick | host-only. See SEO data for the full payload contract and the emission rules |
Lifecycle, connection and errors
| Event | Emitted by | detail | Listeners |
|---|---|---|---|
ready | every widget except <goma-live-chat> and <goma-messenger> | {} | host-only. Fires once, after the widget's app has mounted in its shadow root. It does not mean the data connection is open: listen for goma:connection { state: 'connected' } for that. A widget mounted inside another widget renders through the parent's app and fires no ready of its own. The name has no goma: prefix because it predates the namespace, and it stays that way, because hosts pinned to 0.34.0 or earlier await it before they assign properties (see CDN delivery). It is the one un-namespaced event these widgets emit. The two chat widgets emit their own gc:* events instead |
goma:connection | any widget that opens a live data connection | { state, attempt?, nextRetryMs?, lastError?, attempts? }: state is 'connecting', 'connected', 'reconnecting', 'disconnected' or 'failed'. A 'disconnected' → 'reconnecting' pair also fires when the page returns from a long spell in the background, with no failure having occurred: see Coming back from sleep | internal (the connection layer tracks whether it ever connected, and the POC's sport pickers re-arm their default pick on 'connected'); also for your page |
goma:connected | same, on success | {}: kept for backwards compatibility; prefer goma:connection | host-only |
goma:error | two paths: see below | { message, code, component? }: code is 'API_INIT_ERROR' on the connection path, and the error's name (e.g. 'TypeError', or 'RUNTIME_ERROR' when it has none) on the root-scope path | host-only. Connection layer: a widget's WAMP adapter exhausts its 5 retry attempts, alongside goma:connection { state: 'failed' }. Widget root scope: an error raised in the widget's own outermost component: its setup, its render, its lifecycle hooks or its watchers. Errors raised inside a widget's child components never reach this event: the onErrorCaptured boundary in each App.vue catches those and returns false, so they surface only as the in-shadow banner plus goma:retry. The root is the gap in that boundary, and it is structural rather than a bug: Vue starts its errorCaptured walk at the throwing component's parent, and the root component has none, so its own boundary can never see its own errors. createWidgetElement's app.config.errorHandler is the net underneath. |
goma:event-not-found | <goma-game-details> | { eventId }: the subscription answered, but that event does not exist | host-only |
goma:game-ended | <goma-game-details> | { eventId, homeTeam, awayTeam, homeScore, awayScore }: the final teams and derived match score, snapshotted from the last live tick | host-only. Fires once per eventId, and only for a match this widget actually saw live: a provider terminal status, or the event disappearing from the feed for longer than a 2.5 s grace window. A non-terminal status (interrupted, back to pending) is not an end: the match can resume |
goma:retry | the inline error banner's Retry button, in every widget except <goma-hero-card> | {} | host-only |
Declared but never emitted
goma:action and goma:search appear in the TypeScript event map at packages/core/src/types/index.d.ts, but no shipped widget dispatches them. They are listed here only so the catalogue matches the source tree: do not build against them.
One type declaration in that same file has also drifted from the runtime and the table above is the authority: goma:tab-change is declared as { tabId } but dispatched as { view }.
Page recipes
A sports page: pickers driving a feed, no host code
Everything here is automatic once the picker has a live data source. The three filter widgets emit; the feed follows all three because followSportPicker, followRegionPicker and followTimePicker all default to true. <goma-region-filter> already defaults to dataSource: 'rpc', so it fetches its own list with no setup. But <goma-sports-navigation-horizontal> defaults to dataSource: 'prop' (host-fed, you'd have to assign el.sports yourself) and sports: [], so left at its defaults the strip renders no tiles and there is nothing to tap. Switch it to 'subscribe' so it populates itself from the live sports feed over the same socketUrl.
html
<goma-sports-navigation-horizontal id="sports"></goma-sports-navigation-horizontal>
<goma-region-filter id="regions"></goma-region-filter>
<goma-time-filter id="time"></goma-time-filter>
<goma-events-horizontal id="feed"></goma-events-horizontal>
<script type="module">
import '@gomagaming/sports-navigation-horizontal'
import '@gomagaming/region-filter'
import '@gomagaming/time-filter'
import '@gomagaming/events-horizontal'
const config = {
socketUrl: 'wss://example.com/ws',
locale: 'en',
}
for (const id of ['sports', 'regions', 'feed']) {
document.getElementById(id).config = config
}
// <goma-sports-navigation-horizontal> defaults to dataSource: 'prop',
// which renders nothing until you feed it el.sports yourself. Switch
// it to 'subscribe' so it fetches and live-updates its own tile list
// from the connection above: the same picker-chain automation the
// rest of this recipe relies on needs tiles to tap in the first place.
document.getElementById('sports').dataSource = 'subscribe'
// <goma-time-filter> is purely host-fed: it needs no connection.
// This overrides its default 8-tick set with a smaller custom one.
document.getElementById('time').ticks = [
{ hours: 0, label: 'Now' },
{ hours: 1, label: '1h' },
{ hours: 24, label: '24h' },
{ hours: 48, label: '48h' },
]
// The feed's starting scope. Segment indices are counted AFTER the
// 'custom-matches-aggregator/' prefix is stripped: segment 0 is the
// first slash-separated piece following the prefix, not the first
// piece of the full string below. Reading this topic left to right,
// post-prefix:
// 1 → segment 0, sportId
// all → segment 1, locationId
// all → segment 2, tournamentId
// all → segment 3, hoursInterval
// POPULAR → segment 4, sortEventsBy
// LIVE → segment 5, liveStatus
// 10 → segment 6, eventLimit
// 5 → segment 7, mainMarketsLimit
// The three pickers above rewrite segments 0 (sport), 1 (region) and
// 3 (time) in place.
document.getElementById('feed').topic =
'custom-matches-aggregator/1/all/all/all/POPULAR/LIVE/10/5'
</script>Tap a sport and the region filter re-fetches its list and the feed re-scopes: two hops from one event, no listener in this page.
A betting page: feed plus betslip, cooperating through shared state
No follow* prop connects these two, and no event you have to forward. The odds button writes the selection into the shared betslip envelope and the floating betslip reads it.
html
<goma-events-horizontal id="feed"></goma-events-horizontal>
<goma-betslip-floating id="slip"></goma-betslip-floating>
<!-- Your own header button, driving the betslip by event. -->
<button id="cart">Betslip</button>
<script type="module">
import '@gomagaming/events-horizontal'
import '@gomagaming/betslip-floating'
const config = { socketUrl: 'wss://example.com/ws', locale: 'en' }
document.getElementById('feed').config = config
document.getElementById('slip').config = config
document.getElementById('feed').topic =
'custom-matches-aggregator/1/all/all/all/POPULAR/LIVE/10/5'
document.getElementById('cart').addEventListener('click', () => {
window.dispatchEvent(new CustomEvent('goma:betslip-toggle'))
})
// In the default placeBetMode ('event') the widget does not talk to a
// betting backend: it hands you the payload and resolves with a mock
// receipt. This is where you POST to your own service.
window.addEventListener('goma:place-bet-request', (e) => {
console.log('place bet', e.detail)
})
</script>Because the envelope lives in localStorage, the slip survives a reload and stays in step across browser tabs on the same origin.
A detail flow: feed to game details
<goma-game-details> follows goma:event-navigate by default, and every game card emits it, so a tap on a card anywhere on the page swaps the details view.
html
<goma-events-horizontal id="feed"></goma-events-horizontal>
<goma-game-details id="details"></goma-game-details>
<script type="module">
import '@gomagaming/events-horizontal'
import '@gomagaming/game-details'
const config = { socketUrl: 'wss://example.com/ws', locale: 'en' }
document.getElementById('feed').config = config
document.getElementById('details').config = config
document.getElementById('feed').topic =
'custom-matches-aggregator/1/all/all/all/POPULAR/LIVE/10/5'
</script>With more than one card widget on the page, or more than one <goma-game-details>, scope the panel to the emitter that should drive it. Unscoped, every panel jumps to every card tap anywhere:
html
<goma-events-horizontal id="feed"></goma-events-horizontal>
<goma-game-details event-navigate-selector="#feed"></goma-game-details>To route through your own URL first, opt out and drive it by hand:
html
<goma-game-details id="details" follow-event-navigate="false"></goma-game-details>js
window.addEventListener('goma:event-navigate', (e) => {
history.pushState({}, '', `/event/${e.detail.eventId}`)
document.getElementById('details').eventId = String(e.detail.eventId)
})Note the String(...): provider event and competition ids are 18-digit values that exceed Number.MAX_SAFE_INTEGER, so passing them through Number() silently corrupts them. Keep them as strings end to end.
Adding a widget to the graph
If you are building a new widget for GomaPulse, four rules keep it consistent with the graph above.
- Reuse an event name before inventing one. A new picker that emits
goma:sport-selectis followed by five existing widgets for free. The same picker under a bespoke event name is followed by nothing, forever. Check the catalogue first. - If you must invent a name, match the payload conventions. Ids as strings, a
previous*Idfield on single-selection changes, and the same field names the neighbouring events use (competitionId, nottournamentId, even though the topic segment is calledtournamentId). composed: trueis mandatory. Use the injectedgomaEmitrather than callingdispatchEventyourself and the flag comes for free; a hand-rolled event without it never leaves the Shadow DOM, and the bug looks exactly like "the host listener is broken".- Default a
follow*opt-in totrueonly when the pairing is the common case.<goma-game-details>follows card taps by default because that is what you almost always want.<goma-favorites>defaults both of its opt-ins tofalsebecause a favourites page is just as often standalone. Whichever you pick, document the default on the widget's page: a wrong assumption about a default is the most common wiring bug on this page's subject matter.
Match the existing shape while you are at it: a follow* prop is a boolean with a kebab-case attribute parsed as "anything but false", paired with an optional *Selector CSS-selector prop, and the listener is attached and detached from a watch(..., { immediate: true }) on the prop so it can be flipped at runtime.
What to read next
- Shared state: the
localStorageenvelope contracts (goma:betslip:v4,goma:favorites:v1,goma:session:v1) behind the third cooperation channel introduced above. - SEO data: the full payload contract and emission rules behind
goma:seo-data, the one event in the catalogue above meant for your page's structured-data / meta-tag pipeline rather than another widget. - Architecture: the package layering these events travel between, and why nothing is imported across widgets.
- Introduction: the Shadow DOM anatomy and the reactive property bridge that the
detailpayloads come out of. - Widget reference: per-widget config, props and the full event list for each of the twenty-three sports widgets. The two chat widgets use a separate
gc:*namespace and are documented alongside them.