Skip to content

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:

  1. DOM CustomEvents: every widget event is dispatched on the widget's own host element with bubbles: true, composed: true.
  2. localStorage envelopes: 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".

PropertyAttributeWidgetListens forDefaultWhat it does
followSportPickerfollow-sport-picker<goma-events-horizontal>goma:sport-selecttrueRewrites segment 0 (sportId) of the feed's topic.
followRegionPickerfollow-region-picker<goma-events-horizontal>goma:region-selecttrueRewrites segment 1 (locationId) of the topic.
followCompetitionPickerfollow-competition-picker<goma-events-horizontal>goma:competition-selecttrueRewrites segment 2 (tournamentId) of the topic.
followTimePickerfollow-time-picker<goma-events-horizontal>goma:time-selecttrueRewrites segment 3 (hoursInterval) of the topic.
followSportPickerfollow-sport-picker<goma-region-filter>goma:sport-selecttrueSets its own sportId and re-fetches the region list.
followSportPickerfollow-sport-picker<goma-competitions-list>goma:sport-selecttrueSets its own sportId and re-fetches the competition list.
followEventNavigatefollow-event-navigate<goma-game-details>goma:event-navigatetrueSets its own eventId, so a card tap anywhere swaps the details view.
followMissionSelectfollow-mission-select<goma-mission-details>goma:mission-selecttrueShows the selected mission.
followSportPickerfollow-sport-picker<goma-top-competitions-pills>goma:sport-selecttrueSets its own sportId and re-fetches.
followSportPickerfollow-sport-picker<goma-favorites>goma:sport-select and goma:filters-applyfalseNarrows the rendered favourites to that sport, client-side.
followFiltersfollow-filters<goma-favorites>goma:filters-apply and goma:time-selectfalseApplies 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.

EnvelopenameversionCarriesWritten by
Betslipbetslip3The selection list plus stake / bet-type / accept-odds-changeuseSharedBetslipSync
Favouritesfavorites1{ competitions: string[], events: string[] }useSharedFavoritesSync
Sessionsession1The resolved player identityuseSharedSession

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
WidgetPropertyEvent
<goma-region-filter>selectedRegionIdgoma:region-select
<goma-top-competitions-pills>selectedCompetitionIdgoma:competition-select
<goma-top-competitions>selectedCompetitionIdgoma:top-competition-select
<goma-competitions-list>selectedCompetitionIdgoma:competition-select
<goma-sports-navigation-horizontal>selectedSportIdgoma:sport-select
<goma-sports-navigation-dialog>selectedSportIdgoma:sport-select
<goma-competition-filter>selectedIdgoma: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 null descriptors, because follow* listeners read the id. You never have to wait for data to deep-link. null is the one sentinel across the whole family. Four widgets emit goma: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 cleared

Deferring 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:

WidgetCleared on a sport changeOpt-out
<goma-region-filter>selectedRegionId → 'all'resetRegionOnSportChange
<goma-top-competitions-pills>selectedCompetitionId → nullresetCompetitionOnSportChange

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.

EventEmitted byListened for byGateEffect 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-navigateevery 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 eventNavigateSelectorSwaps 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-selectevery 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>alwaysAdds 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)alwaysRefreshes the player balance now and again after 2.5 s.
goma:betslip-openyour page<goma-betslip-floating>, <goma-betslip-sidebar>alwaysOpens (floating) and optionally selects a tab.
goma:betslip-closeyour page<goma-betslip-floating>alwaysCloses. No-op on the sidebar.
goma:betslip-toggleyour page<goma-betslip-floating>, <goma-betslip-sidebar>alwaysFlips open/closed; optionally selects a tab.
goma:sports-dialog-openyour page<goma-sports-navigation-dialog>alwaysOpens 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 emits goma:filter-select, not goma:competition-select. Nothing follows it automatically: wire it yourself. The widgets that do emit goma:competition-select are <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 same goma:competition-select a 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's sportId from the same handler if that matters.
  • <goma-top-competitions> is not in it either. The vertical list emits goma:top-competition-select, which no widget listens for. Its horizontal sibling <goma-top-competitions-pills> emits goma: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 ​

EventEmitted bydetailListeners
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 competitionVenueIdevents 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 extremesevents 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 itfavourites
goma:filters-reset<goma-filters-settings>same shape as goma:filters-apply, carrying the reset pending valueshost-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 onlyhost-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
EventEmitted bydetailListeners
goma:event-navigateevery 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 unsethost-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 destinationhost-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 tappedhost-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 shapehost-only
goma:my-bet-select<goma-my-bets> and the My Bets tab inside both betslips{ bet, tab }host-only
goma:navigateany 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 meanshost-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-openyour 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 ​

EventEmitted bydetailListeners
goma:favorite-toggleevery 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 eventIdhost-only
goma:favorites-browse<goma-favorites>{ target: 'upcoming' }: emitted by the upcoming events link in the empty statehost-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-togglehost-only
goma:login-requiredany 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-readyany session-aware widget once its identity resolves{ userId, username }: username is null unless the full exchange ranhost-only. Fires once per mounted widget per token; handlers must be idempotent
goma:session-expiredany session-aware widget when the provider invalidates the session{ reason, invalidationId }host-only. Treat as a logout, not a warning

Betslip ​

EventEmitted bydetailListeners
goma:outcome-selectevery 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-deselectodds 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 nullhost-only
goma:bet-removedthe betslip panel{ outcomeId }host-only
goma:betslip-clearedthe betslip panel{}host-only
goma:stake-changethe betslip panel{ stake }host-only
goma:selection-stake-changethe betslip panel, in single-bet mode{ outcomeId, stake }host-only
goma:bonus-selectthe 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-removethe betslip panel, when an applied bonus is cleared{ walletId, type, name, amount, currency, selectionId }host-only
goma:bonus-unavailablethe 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 firesboth 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-rejectedthe betslip, when the provider refuses the current combination{ message, errorCode, selections: [{ outcomeId, eventId, name, outcomeName, marketName }] }host-only
goma:combination-resolvedthe betslip, when a previously rejected combination becomes valid{}host-only
goma:betslip-open / goma:betslip-close / goma:betslip-togglenothing: dispatch these yourself{ target? } on open and toggleboth 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.

EventdetailListeners
goma:place-bet-blocked{ reason }: emitted instead of starting, when the user presses a refused buttonhost-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 === betCounthost-only
goma:place-bet-requestthe full request payload (uniqueId, batchId, type, amount, oddsValidationType, terminalType, freeBet, selections[], plus auto-derived identity fields): once per submitted bethost-only. In placeBetMode: 'event' this is the integration point: you place the bet with your own backend
goma:bet-placedthe 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 ​

EventEmitted bydetailListeners
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 ​

EventEmitted bydetailListeners
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 tickhost-only. See SEO data for the full payload contract and the emission rules

Lifecycle, connection and errors ​

EventEmitted bydetailListeners
readyevery 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:connectionany 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 sleepinternal (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:connectedsame, on success{}: kept for backwards compatibility; prefer goma:connectionhost-only
goma:errortwo 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 pathhost-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 existhost-only
goma:game-ended<goma-game-details>{ eventId, homeTeam, awayTeam, homeScore, awayScore }: the final teams and derived match score, snapshotted from the last live tickhost-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:retrythe 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.

  1. Reuse an event name before inventing one. A new picker that emits goma:sport-select is followed by five existing widgets for free. The same picker under a bespoke event name is followed by nothing, forever. Check the catalogue first.
  2. If you must invent a name, match the payload conventions. Ids as strings, a previous*Id field on single-selection changes, and the same field names the neighbouring events use (competitionId, not tournamentId, even though the topic segment is called tournamentId).
  3. composed: true is mandatory. Use the injected gomaEmit rather than calling dispatchEvent yourself 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".
  4. Default a follow* opt-in to true only 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 to false because 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.

  • Shared state: the localStorage envelope 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 detail payloads 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.