Skip to content

<goma-suggested-bet> ​

A featured "Bet the same" suggested-bet card: a premium, high-intent card for a ranked carousel. It renders a rank badge, an N-selections header, the per-selection legs (competition + flag, highlighted pick, market, fixture), a Bet / Total Odds / Winnings summary, and a CTA that appends every leg to the shared cross-widget betslip. The card's stake is display-only: the CTA does not write it into the slip; the player enters their own.

Element tag ​

html
<goma-suggested-bet></goma-suggested-bet>

Installation ​

bash
pnpm add @gomagaming/suggested-bet vue pinia vue-router vue-i18n @vueuse/core autobahn-browser

The framework packages (plus autobahn-browser, needed for rpc / subscribe / recsys mode) are peer dependencies installed once at the app level; bundlers dedup them across every @gomagaming/* widget. See Installation for the one-time .npmrc token setup.

Configuration ​

js
el.config = {
  locale: 'en',
  // Required only when dataSource is 'rpc', 'subscribe' or 'recsys':
  socketUrl: 'wss://…',
  socketRealm: 'sportsbook',
  ucsOperatorId: '2838',
  // Optional restyle:
  theme: { '--goma-buttonBackgroundPrimary': '#ff6600' },
}
KeyTypeRequiredDescription
socketUrlstringOnly for rpc/subscribe/recsysWAMP socket URL the widget itself connects to resolve legs (from { eventId, bettingOfferId } in rpc/subscribe, or from the shortlisted recommendations in recsys).
socketRealmstringOnly for rpc/subscribe/recsysWAMP realm.
ucsOperatorIdstringOnly for rpc/subscribe/recsysOperator ID for the WAMP connection. Distinct from the recsysOperatorId property below, which is a query parameter on the recommendation-service REST call.
localestringNoi18n locale. Defaults to 'en'.
themeobjectNoCSS variable overrides: see Theming.
debugbooleanNoVerbose console logging of leg resolution (offer/outcome/market lookups, subscription topics, recsys fetch/shortlist steps, and a provider's refusal to price the combo). Defaults to false.

In prop mode (the default) the card is fully host-fed and needs no connection config at all: the fields above only matter once dataSource is 'rpc', 'subscribe' or 'recsys', because in those modes the widget owns its own WAMP connection to resolve legs (it does not depend on another widget's socket). recsys additionally needs recsysBaseUrl (see below) to reach the recommendation service over plain REST: the WAMP connection there is only for pricing the legs the service recommends, not for fetching the recommendation list itself.

Data sources ​

The dataSource prop chooses how each leg's event data (teams, competition, odds) is obtained:

dataSourceHow legs resolve
'prop' (default)Fully host-fed: each selection carries its own display fields and odds. Works offline.
'subscribe'Each selection is just { eventId, bettingOfferId }. The widget subscribes to those events by id (via the same multi-event sports.bannerEvents mechanism <goma-multi-banners> uses) and resolves teams, competition, market name, pick name and live odds. Total Odds and Winnings come from the provider's bettingOptionsV2 answer, asked again (debounced) whenever a leg's price or availability moves.
'rpc'Same resolution as subscribe, but a one-shot snapshot: the subscription is released once every leg's offer has been priced, so odds don't keep ticking. The snapshot is per subscription, not per widget: swap the bet afterwards and the new legs resolve and release the same way.
'recsys'No host-supplied bet at all: the widget fetches a ranked list from EveryMatrix's recommendation service, picks a combo out of it, and prices the legs live. See Recommendations (recsys) below.

In rpc/subscribe mode you send only the two IDs per leg:

js
el.dataSource = 'subscribe'
el.config = { socketUrl: 'wss://…', socketRealm: 'sportsbook', ucsOperatorId: '2838' }
el.bet = {
  rank: 1,
  stake: 10,
  selections: [
    { eventId: '123', bettingOfferId: '456' },
    // …more legs (a leg needs no `sportId`: the event is looked up by id.)
  ],
}

The widget walks bettingOfferId → outcome → market through the betting store's global entity maps (the same model <goma-events-horizontal> uses, the offer carries outcomeId / marketId / bettingTypeName), resolving the market name and pick (outcome) name, and reads teams / competition / live odds off the resolved event, falling back to the event's attached market tree when an entity isn't in the global maps. The eventId is optional (it's derived from the offer when omitted) but giving it tells the widget which events to subscribe to by id. Two caveats:

  • Only an offer the subscribed feed actually streams resolves from ids alone. For an offer the feed doesn't carry, supply market / pick (and odd) labels inline as a fallback, or use prop mode.
  • Until the feed returns the offer/event, that leg renders a resolving skeleton (pulsing placeholder rows). Any host-fed label on a selection is merged in as soon as it resolves: a label-rich bet still renders in every mode.

Recommendations (recsys) ​

In recsys mode the widget builds its own combo: do not set bet at all. The recommendation pipeline's own result is written onto it whenever that result changes, so any value you assign is overwritten the next time it does; it is not restored on a stable pipeline result, so avoid assigning bet in this mode altogether.

The recommended events now receive live match updates (status and score) after the page loads. The card does not show a score, so you see no change on it.

html
<goma-suggested-bet id="sb"></goma-suggested-bet>
<script type="module">
  import '@gomagaming/suggested-bet'
  const el = document.getElementById('sb')
  el.config = { socketUrl: 'wss://…', locale: 'en' }
  el.dataSource = 'recsys'
  el.recsysBaseUrl = 'https://recommendation-service.everymatrix.com'
  el.recsysOperatorId = '2838'
</script>

The widget calls the recommendation service directly from the browser: it sends a permissive CORS header (Access-Control-Allow-Origin: *), so no proxy or host-side call is needed, and the request goes out with credentials: 'omit' (a credentialed request against a wildcard origin is rejected by the browser anyway, and identity travels as a header, not a cookie).

Guest vs. personalised. By default the widget calls the guest endpoint, which needs no identity and returns popular recommendations. Setting a sessionId together with recsysOperatorId switches it to the personalised endpoint (X-Session-Id header): either one alone is not enough, since the personalised call requires both. Any non-2xx response from a personalised call falls back to the guest endpoint automatically; a guest call that itself fails has nowhere left to fall back to and the widget shows its empty state instead, logging one warning.

How a combo is assembled, in order:

  1. Fetch the ranked recommendation list (cached per {recsysBaseUrl, recsysOperatorId, sessionId} for recsysCacheTtlMs, several cards on the same page sharing that combination make one request, not one each).
  2. Shortlist candidates: drop anything starting more than recsysMaxStartsInHours from now and keep only the first (best-ranked) recommendation per event, in the service's own rank order, however deep its list runs. recsysCandidatePoolSize is NOT applied here: see the next point.
  3. Subscribe to that shortlist's events over WAMP (one batched call covering the whole pool) and resolve each recommendation's actual market, pick and live odds, exactly like rpc / subscribe resolution does for a host-fed selection. A recommendation the feed never prices is dropped. A separate, narrower subscription opens per-match odds only for the candidates the card is actually trying to display: see "Pool size vs. display size" below.
  4. Take the first recsysLegCount priced legs, in the service's own rank order: a hard cap on how many matches the card displays, with no check of any kind on their combined odds. Below recsysMinLegs priced legs, the widget shows its empty state instead of a card: a one- or two-leg "combo" reads as broken, not as a deliberate suggestion.
  5. If the provider refuses to price the combo because it has too many selections, the card drops its lowest-ranked leg and asks for a price again, until the provider prices it. An operator can set such a limit per sport (for example, at most 3 selections when one leg is ice hockey). If fewer than recsysMinLegs legs would be left, the card shows its empty state. The limit holds until the next recommendation fetch. "Bet the same" loads only the legs the card shows, so the slip it loads is one the provider prices.

Multiple recsys cards on one page automatically divide up the recommendation pool instead of repeating each other's picks. Every card runs the same ranking over the same shared fetch, so two cards with identical config would otherwise resolve to the same fixtures, and a player tapping "Bet the same" on both would feed a bookmaker the same event twice, which a combo normally rejects. As each card's leg set settles, it claims those events for itself; a sibling card checks that claim list and skips anything already taken, filling its own combo from whatever is left. This needs no configuration: it works automatically across every <goma-suggested-bet dataSource="recsys"> on the page that shares the same recsysBaseUrl / recsysOperatorId / sessionId combination (cards pointed at a different service or a different signed-in player never compete for the same pool).

recsysCandidatePoolSize sizes each card's pool against what it can actually use, not the service's raw ranking: a card counts its own recsysCandidatePoolSize only from candidates NO OTHER card has claimed, reaching as deep into the service's list as it needs to. A card never opens more than recsysCandidatePoolSize batched-events subscriptions: deeper candidates widen the search, never the number of open topics, and it never excludes its own claims from its own count, so a card's combo stays put once it has settled instead of emptying itself out on the next recompute. If a card still can't find enough unclaimed, priced events to reach recsysMinLegs even after searching the whole list, it shows its empty state: the same outcome as an ordinary short candidate pool, covered above.

Pool size vs. display size. recsysCandidatePoolSize is the search: it bounds the batched events subscription that lets the driver discover which candidates can price at all. It does not bound how many per-match odds feeds open. Each one is the recommended market's own feed, {matchId}/match-odds/{bettingTypeId}-{eventPartId} (tens of KB), built from the recommendation's bettingTypeId and eventPartId; a recommendation missing either key gets the full {matchId}/match-odds feed, which carries every market of the match (several MB on a real operator). Opening one for every pool candidate (up to 12 by default) to ever display recsysLegCount (4 by default) would cost three times the feeds actually shown. Instead, a per-match odds subscription opens only for the candidates the card is currently trying to display, walked in the service's own rank order: a candidate whose market already resolves off the batched events feed needs no subscription at all; a candidate that is live but not yet priced gets exactly one fair shot. Its subscription opens, and if it still hasn't priced when its 3-second grace period ends it is released and the next unclaimed candidate down the rank order takes its place. Net effect: the search stays exactly as deep as recsysCandidatePoolSize allows, while the number of open per-match feeds tracks what's on screen, never the pool.

Because the recommendation service supplies no rank or stake, the card's rank badge does not render in recsys mode and the Bet / Winnings figures show a hyphen (-) placeholder, unless the host fills the gap with the rank / stake element properties: see Rank and stake fallback below. recsys fills the legs, not the rank or the stake, by itself. Total Odds and Winnings come from the provider's bettingOptionsV2 answer for the resolved legs, exactly as in subscribe mode, asked again whenever a leg's price or availability moves.

recsysBaseUrl has no built-in default host. Leaving it unset means the widget makes no request at all and renders its empty state, logging one console warning (recsysBaseUrl is not configured…) rather than failing silently.

The whole pipeline above (fetch, shortlist, subscribe, resolve) can take a few seconds. Until the widget has subscribed to the recommended events it gives up after 15 seconds. Once the subscribe has gone out it waits up to 55 seconds for the first answer, measured from the subscribe, so the connection layer can ask for a lost answer again. After an answer that still leaves no priced combo, it falls back to the empty state 15 seconds later. For as long as it's still working, the widget shows a whole-card loading skeleton in place of the card, sized to match it (see Empty / loading / error states), so a recsys card never leaves a bare gap in a rail of otherwise-populated cards while it resolves.

The bet shape (prop mode) ​

In prop mode the card is fully host-fed via the bet property (a JS object, or a JSON string via the bet attribute):

js
el.bet = {
  rank: 1,            // badge index; omit / set showRank=false to hide
  stake: 10,          // the "Bet" figure (display only: never written to the betslip)
  totalOdds: 8.0,     // shown as-is in prop mode; the widget makes no call and no local calculation
  winnings: 80,       // same: shown as-is, no call, no local calculation
  selections: [
    {
      // ── betslip identity (handed to the betslip on CTA) ──
      outcomeId: 'o1',
      bettingOfferId: 'o1',
      eventId: 'evt-1',
      marketId: 'm1',
      marketName: 'Double chance',
      bettingTypeId: '18', // REQUIRED to place the bet: see note below
      eventPartId: '0',    // pairs with bettingTypeId to key the market
      outcomeName: 'Barcelona ou Match nul',
      odd: 2.0,
      sportId: 1,
      competitionId: 'c1',
      homeParticipantName: 'Barcelona',
      awayParticipantName: 'Real Madrid',
      // ── display helpers (optional; sensible fallbacks below) ──
      competitionName: 'La Liga',
      countryName: 'Spain',
      flagUrl: 'https://flagcdn.com/w40/es.png', // overrides flagBaseUrl+venueId
      venueId: 11,                                // used with flagBaseUrl
      sportIconUrl: '',                           // overrides iconBaseUrl+sportId
    },
    // …more legs
  ],
}

totalOdds and winnings are optional and display-only in prop mode: the widget renders whatever the host sends and makes no bettingOptionsV2 call. Leaving either unset (or null) renders the hyphen (-) placeholder for that figure; the widget never derives one from the legs' odds. In rpc, subscribe and recsys mode both fields are ignored on the bet object and come from the provider's answer instead (see Data sources).

bettingTypeId is required to place the bet ​

bettingTypeId is mandatory on every leg that will be placed. It identifies the market on the place-bet payload, and without it the provider rejects the entire slip, not just the offending leg, with "Invalid or missing Betting Type Id on placed bet". eventPartId is its pair: the two together key a market.

Nothing surfaces the omission before the bet is submitted, so a card that renders perfectly can still be unplaceable. In prop mode both fields must come from the host.

In rpc / subscribe mode the widget resolves them off the feed's market, but resolution is not guaranteed, so supply them anyway when you have them. The widget subscribes to each leg's event by id, so any live or prelive event resolves. That feed streams about five main markets per event, so a leg on a market outside them can still miss. A host-supplied value always wins over a resolved one, so passing it costs nothing and removes the dependency on the feed.

Display fallbacks ​

Each leg renders from the display helpers, falling back to the betslip identity fields, so a minimal selection still renders:

RowFieldFallback
CompetitioncompetitionLabel"{competitionName} - {countryName}", then tournamentName
Pick (highlighted)pickoutcomeName
MarketmarketmarketName
Fixturefixture"{homeParticipantName} x {awayParticipantName}", then eventName

Scrollable variant ​

When the number of legs exceeds scrollAfter (default 3), the legs list becomes a fixed-height scrollable region with a themed scrollbar: matching the design's scrollable variant. Set scrollAfter to 0 to never scroll.

For a carousel where every card must be the same height regardless of leg count, set cardHeight (number → px, or a CSS length string). The card is then exactly that tall and the legs list fills the middle and scrolls when it overflows: the header, summary and CTA stay pinned. cardHeight takes precedence over scrollAfter.

Card layout (variant) ​

The variant property (and matching variant attribute) switches between two summary layouts. Any value other than the two below renders as 'default'.

variantTop rowSummary row
'default' (default)An N-selections count, right-alignedBet / Total Odds / Winnings, three columns
'compact'NoneTotal Odds / Selections, two columns

Both layouts read Total Odds the same way: the provider's answer in rpc / subscribe / recsys mode, the host-fed figure in prop mode.

'compact' keeps the same outer card height as 'default': the top row it drops is not simply removed, its space is redistributed. In compact, the card always leaves 32px above the first leg (12px more than 'default''s 20px), clearing the rank badge that hangs down from the card's top edge. The remaining 8px of the removed row goes to the legs list: with cardHeight set, the legs list is already a flexible region and absorbs it automatically; without cardHeight, when the legs list is not already scrolling (see Scrollable variant above), those 8px are added back below the legs list instead. When the legs list is already scrolling, its scroll limit grows by the same 8px (354px to 362px). Either way, the card's total outer height matches 'default' exactly.

The loading skeleton follows the same variant.

Rank and stake fallback ​

rank (the "#N" badge) and stake (the "Bet" figure, and the base of "Winnings") are host-owned facts about the card's place in a rail: in prop / rpc / subscribe mode they normally arrive inline on the bet object (see the shape above). recsys is the mode that needs a fallback, since the recommendation service supplies neither: without one, a recsys card shows no rank badge and a hyphen (-) placeholder for Bet and Winnings.

Two element properties fill that gap:

js
el.rank = 1     // this card's position in the host's OWN rail (1-based)
el.stake = 10   // the figure to display as "Bet": never written to the betslip

They are read only when the active bet does not already carry the field: a host-fed bet.rank / bet.stake always wins, in every mode. That also makes them general-purpose rather than recsys-only: they equally patch a partial host-fed bet in prop, rpc or subscribe mode (say, one that carries a stake but no rank).

The widget never derives rank itself, not from claim order, not from mount order, not from anything tied to subscription timing. Rail position is a layout fact the host already knows; deriving it from timing would mean a slow-resolving first card could end up badged #2.

stake stays a display figure in every mode, exactly like a host-fed bet.stake: the CTA never writes it into the shared betslip (see Events).

Leaving both unset (null, the default) reproduces today's behaviour exactly: no rank badge, and a hyphen (-) placeholder for Bet and Winnings.

Props / attributes ​

PropertyAttributeTypeDefaultDescription
betbetobject | nullnullThe host-fed featured bet (see shape above). Ignored in recsys mode: the driver overwrites it.
dataSourcedata-source'prop' | 'rpc' | 'subscribe' | 'recsys''prop'How legs resolve (see Data sources). rpc/subscribe/recsys need socket config.
sportEventLimitsport-event-limitnumber50Deprecated, no effect. Legs are resolved by event id, so there is no sport feed to cap. Still accepted so existing pages do not break.
sessionIdsession-idstring''Player session id, forwarded to the recsys fetch. Personalised recommendations only fire when this and recsysOperatorId are both set.
recsysBaseUrlrecsys-base-urlstring''Base URL of the recommendation service. Unset means no request is made and the widget shows its empty state.
recsysOperatorIdrecsys-operator-idstring | nullnullOperator id sent to the personalised endpoint. Required (with sessionId) for personalised recommendations; ignored by the guest endpoint.
recsysLegCountrecsys-leg-countnumber4Hard cap on how many matches the assembled combo displays, taken in the service's own rank order with no check on their combined odds. Also the hard cap on how many per-match odds feeds the card ever holds open at once (see "Pool size vs. display size" below).
recsysMinLegsrecsys-min-legsnumber2Floor below which the widget shows its empty state instead of a combo.
recsysCandidatePoolSizerecsys-candidate-pool-sizenumber12Hard cap on how many candidates THIS card can use, and therefore on the batched events subscription it opens, after excluding whatever a sibling card has already claimed (see "Multiple recsys cards on one page" below). Never the service's literal first N, and never the per-match odds count (see "Pool size vs. display size" below).
recsysMaxStartsInHoursrecsys-max-starts-in-hoursnumber48Recommendations starting further out than this are dropped before any subscription opens.
recsysCacheTtlMsrecsys-cache-ttl-msnumber300000How long an identical {recsysBaseUrl, recsysOperatorId, sessionId} fetch is shared across cards, in milliseconds. 0 disables the cache.
currencycurrencystring'€'Currency symbol for the Bet / Winnings figures.
currencyPositioncurrency-position'suffix' | 'prefix''suffix''suffix' → 10,00€; 'prefix' → $10.00. The numeric part is formatted with the active locale.
showRankshow-rankbooleantrueShow the rank badge.
variantvariant'default' | 'compact''default'Card layout, see Card layout (variant). Any other value renders as 'default'.
rankranknumber | nullnullFallback rank badge index, read only when the active bet has none: see Rank and stake fallback. Not derived by the widget; the host supplies the card's rail position.
stakestakenumber | nullnullFallback "Bet" figure, read only when the active bet has none: see Rank and stake fallback. Display-only in every mode; never written to the betslip.
scrollAfterscroll-afternumber3Legs above this count scroll; 0 disables scrolling. Ignored when cardHeight is set.
cardHeightcard-heightnumber | string | nullnullFixed card height (number → px, or a CSS length). All cards line up and the legs list scrolls if it overflows.
iconBaseUrlicon-base-urlstring''${iconBaseUrl}/icons/sports/dark_mode/{sportId}.svg. Per-leg sportIconUrl overrides it.
flagBaseUrlflag-base-urlstringglastcoper flag CDN${flagBaseUrl}{venueId}.png. Per-leg flagUrl overrides it.
localelocalestring'en'i18n locale; also drives currency number formatting.
themethemeobject{}--goma-* overrides.

Events ​

EventDetailWhen
goma:bet-the-same{ rank, stake, totalOdds, winnings, selectionCount, selections[] }The CTA was activated. Every leg has already been appended to the shared betslip (the stake is not written to it; detail.stake is the card's display figure); the detail carries the loaded selections.
ready{}Widget mounted, initial render complete.
goma:error{ message, code, component? }Two paths. Connection layer: the WAMP adapter (rpc/subscribe data sources) exhausts its 5 retry attempts (code: 'API_INIT_ERROR'), 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), where code is the error's name, e.g. 'TypeError' ('RUNTIME_ERROR' when it has none). Errors raised inside the widget's child components do not reach this event: the in-shadow error boundary catches those and shows the retry banner instead, emitting only goma:retry.

All events are composed + bubbles, so they cross the Shadow DOM boundary to host listeners.

Integrates with ​

<goma-suggested-bet> listens for nothing: it has no follow* prop and no inbound event handling.

It emits goma:bet-the-same on CTA click; <goma-betslip-floating> and <goma-betslip-sidebar> listen for that event globally (gated by their own openOnSelect prop, default false) to auto-surface the betslip panel. This is one-directional: suggested-bet only emits, the betslip widgets are the consumers.

On CTA click it also writes into the shared goma:betslip:v4 envelope via useSharedBetslipSync({ subscribeToState: false }): appending every leg (dedup on outcomeId). Like the rest of the selection sources (e.g. <goma-events-horizontal>), it contributes selection identities only and leaves slip-level state (stake, tab, …) alone: the card's stake is not injected, so the betslip keeps whatever stake the player entered, or none. Earlier releases injected the card's stake on CTA and opted in with subscribeToState: true. See Cross-widget wiring and Shared state for the full contracts.

Each leg is also written with an isAvailable flag, which the place-bet gate requires (isAvailable !== true blocks the whole slip).

Availability is one-way: either authority may suspend a leg, neither may unlock one the other suspended. In rpc / subscribe:

The feed saysYou saidWritten
falseanythingfalse: the provider has suspended it
anythingfalsefalse: you have suspended it
truenot falsetrue
nothinganythingyour value, or nothing (see the warning below)

Both directions matter. The feed cannot be trusted to release: a resolved offer that never carried an isAvailable key at all is unknown, not unavailable, and reading it as false would block the slip permanently. And it cannot be trusted to unlock: these offers come from the events dump, which is hydrated once and receives no availability deltas, so a stale true there must not override a leg you marked dead.

In prop mode you are the only authority, so whatever you set passes through.

Mark suspended legs explicitly

A leg nobody has ruled on is not held back: where no authority exists to ask (no socket, a failed provider round-trip, a socket still connecting) the betslip releases unruled legs so the button isn't bricked. So a leg you know is suspended must carry isAvailable: false: until this was carried through, that false was dropped on the way into the betslip and the leg was then released, making the slip placeable on a selection nobody claimed was live.

Empty / loading / error states ​

  • No bet: the widget renders nothing (no card) until bet is fed. This applies to prop, rpc and subscribe mode; the widget never shows a placeholder box while waiting for a host to assign bet. That is deliberate in all three: the host owns the rail and decides when, or whether, a card exists, so a placeholder for a card that may never be fed would be exactly the card-shaped hole those modes are designed to avoid.
  • recsys loading skeleton: only in recsys mode, while the widget's own fetch → shortlist → resolve → subscribe pipeline is still working, it shows a whole-card skeleton (rank/header bars, a handful of pulsing leg rows, and placeholder Bet / Total Odds / Winnings + CTA bands) instead of leaving a gap in the rail. It occupies the same footprint as the real card: honouring cardHeight the same way the card does, so nothing reflows once the combo resolves. recsys is the one mode where this makes sense: it is the only one where the widget itself, not the host, owns the timeline of when a card shows up.
  • recsys empty state: only in recsys mode, once no combo can be assembled (no recsysBaseUrl configured, the fetch failed, fewer than recsysMinLegs legs priced within the resolve window, or the pool simply came back empty), the widget shows a dedicated empty-state message instead of no card at all: unlike the other three modes, recsys has an actual empty-state design because it is the one mode where the widget, not the host, decides whether there is anything to show.
  • Unresolved leg (rpc/subscribe): a leg whose event/offer hasn't arrived from the feed yet, and that has no host-fed label to show in the meantime, renders a pulsing "resolving" skeleton instead of its rows.
  • Total Odds and Winnings placeholders (rpc/subscribe/recsys): in these modes, Total Odds and Winnings come only from the provider's answer. Before the first answer arrives, when the call fails, or when there is no socket, both figures show the hyphen (-) placeholder. The card never calculates them locally. While a newer answer is on its way after a price change, the previous answer stays on screen. The card asks only once every leg has a price, so a leg still waiting for its odds keeps the previous answer (or the placeholder) on screen. With no stake (neither bet.stake nor the element's stake), Winnings shows the placeholder. After a stake change, Winnings shows the placeholder until the answer for the new stake arrives; Total Odds stays. When the socket connects after the card first had none, it asks again. When the provider refuses the combo (no price), both figures show the placeholder; with debug on, the card logs the refusal and its reason. In recsys mode a refusal for too many selections drops a leg instead (step 5 above); in rpc/subscribe the host chose the legs, so the card keeps them.
  • Render error: a Vue error caught by the widget's error boundary shows an inline role="alert" banner with the error message and a Retry button (clears the banner and emits goma:retry); it also auto-clears after 8 seconds. No goma:error is dispatched for it: the boundary catches it before it could reach that handler. The exception is an error raised in SuggestedBetApp itself: the boundary structurally cannot see its own component's errors, so those skip the banner and emit goma:error (see Events), as does a connection-layer failure.

Theming ​

All colours map to existing --goma-* tokens: no new tokens. The card paints:

TokenDefaultPaints
--goma-backgroundPrimary#03061bCard background
--goma-backgroundSecondary#161732Leg background
--goma-highlightPrimary#ff6600Rank badge background + highlighted pick
--goma-highlightPrimaryContrast#ffffffRank badge text
--goma-buttonBackgroundPrimary#ff6600CTA background
--goma-buttonTextPrimary#ffffffCTA text
--goma-textPrimary#ffffffMarket + summary text
--goma-textSecondary#939dffCompetition / fixture / selection count
--goma-iconPrimary#ffffffSport icon glyph
--goma-separatorLine#2a2d60Leg + summary dividers, scrollbar track
--goma-separatorLineSecondary#393d83Scrollbar thumb
--goma-alertError#f9554dRender-error banner background
--goma-cardBorderLineGradient3#b25633Card-border gradient: accent end (top-right)
--goma-cardBorderLineGradient2#7c4b57Card-border gradient: mid stop
--goma-cardBorderLineGradient1#393d83Card-border gradient: far end

Card width ​

The card caps itself at 360px: the design's card width. Its sibling promo cards, <goma-highlight-card> and <goma-pros-choice>, have no such ceiling and fill whatever the host gives them, so in a column wider than 360px this one stops short while they fill and the leftover space reads as padding around it.

TokenDefaultControls
--goma-suggestedBetMaxWidth360pxThe card's width ceiling
css
/* Fill the container, like the sibling cards do. */
goma-suggested-bet { --goma-suggestedBetMaxWidth: none; }

It takes any max-width value, so none, 420px and 100% all work. Unset, the card renders exactly as before.

See the theming guide for the full token reference.

Appearance (styling object) ​

appearance carries structure only. Each key is a single value (identical in light and dark) that maps to one --goma-* CSS custom property on the host. Set it via el.appearance = {...} or the appearance attribute (a JSON string); unset keys keep the default, so an empty {} (the default) reproduces the current look. Colours stay in theme.

KeyCSS variableDefaultAffects
panelPaddingTop--goma-suggestedBetPanelPaddingTop20px (32px in compact)Space between the card's top edge and its first row (the selections header, or the first leg in compact). The rank badge hangs over this space.
rankLineHeight--goma-suggestedBetRankLineHeight1 (16px, a 24px badge)Rank badge line height; the badge is this plus 4px top and bottom.
summaryLabelLineHeight--goma-suggestedBetSummaryLabelLineHeight1 (14px)Summary labels (Bet / Total Odds / Winnings).
summaryValueLineHeight--goma-suggestedBetSummaryValueLineHeight1 (18px)Summary figures.
ctaLineHeight--goma-suggestedBetCtaLineHeight1 (16px, a 32px button)"Bet the same" line height; the button is this plus 8px top and bottom.
js
// A 30px badge 16px above the header, 19/25px summary lines and a 38px CTA.
el.appearance = {
  panelPaddingTop: '45px',
  rankLineHeight: '22px',
  summaryLabelLineHeight: '19px',
  summaryValueLineHeight: '25px',
  ctaLineHeight: '22px',
}

Taller lines make the card taller. With a fixed cardHeight the legs list absorbs the difference, so raise cardHeight by the same amount to keep the same number of legs in view.

i18n / message overrides ​

KeyDefault (en)Used for
selections"Selections"Selections-count header label
bet"Bet"Summary column label
total_odds"Total Odds"Summary column label
winnings"Winnings"Summary column label
bet_the_same"Bet the same"CTA button label
retry"Retry"Error-banner retry button
no_suggested_bet"No suggested bet available right now"recsys mode's empty-state message

Embedding examples ​

Vanilla / CDN ​

html
<goma-suggested-bet id="sb"></goma-suggested-bet>
<script type="module">
  import '@gomagaming/suggested-bet'

  const el = document.getElementById('sb')
  el.config = { locale: 'en' }
  el.bet = { rank: 1, stake: 10, totalOdds: 8, selections: [ /* … */ ] }
  el.addEventListener('goma:bet-the-same', (e) => console.log(e.detail))
</script>

React ​

jsx
import '@gomagaming/suggested-bet'

export function SuggestedBet({ bet }) {
  return (
    <goma-suggested-bet
      ref={(el) => {
        if (!el) return
        el.config = { locale: 'en' }
        el.bet = bet
      }}
    />
  )
}

Vue ​

vue
<script setup>
import '@gomagaming/suggested-bet'
import { ref, watchEffect } from 'vue'

const elRef = ref(null)
const props = defineProps({ bet: Object })
watchEffect(() => {
  if (elRef.value) {
    elRef.value.config = { locale: 'en' }
    elRef.value.bet = props.bet
  }
})
</script>

<template>
  <goma-suggested-bet ref="elRef" />
</template>

Accessibility ​

  • The legs list is marked up as role="list", with each SuggestedBetLeg carrying role="listitem".
  • Decorative dividers, the resolving-skeleton block, the whole-card recsys loading skeleton, and fallback glyphs (globe/ball emoji) are aria-hidden="true".
  • The render-error banner is role="alert"; its Retry button has a visible focus-visible ring.
  • The CTA button is disabled (disabled, dimmed) when there are no legs to bet on.

Integration Caveats ​

  • SSR/webview: importing this package outside a browser previously threw immediately; fixed with a guard on the shared element factory.

  • Initialization: fixed four leaked WAMP subscriptions, each on a transition a live host actually makes on a mounted widget. Switching dataSource away from rpc/subscribe never released the open topic (only unmount, or a full rpc pricing cycle, did). Replacing the bet with one whose legs carry no eventId left the previous bet's topics streaming. Because subscriptions are keyed per sport, moving the legs to a different sport opened the new sport's topics while the old ones stayed open: a bet moved from football to tennis ran both feeds. And in 'rpc', the one-shot release fired only once per widget, so a bet swapped after the first snapshot re-resolved and then streamed until the element unmounted. All four are released now, each with a regression test.

  • Initialization: the widget no longer re-opens feeds it already holds. Each resync used to re-register every sport in the set, and re-registering pulls a fresh initial dump over the socket the whole page shares, so an unrelated change to a leg re-downloaded both feeds. Only sports that are new open now, plus every sport when eventLimit changes (it is baked into the topic) or when the socket reconnects (a new session holds none of the previous registrations, so the widget re-opens its feeds and keeps ticking). A sport dropping out releases just that sport instead of everything.

  • Tokens/Theming: themeTokens.js declared an unused --goma-iconSecondary and omitted the actually-used --goma-iconPrimary; corrected.

  • Initialization: this widget shares @gomagaming/core's i18n singleton with every other widget on the page; mounting two widgets with different locale values on one page is not currently isolated per instance (a known constraint, not fixed in this pass).

  • Configuring before the definition loads. Assigning config (or any property) while the element is still an unknown element is safe: the value is replayed through the widget when it upgrades. That matters when loading from the CDN, whose loader imports widget modules asynchronously, so your script usually runs first. On release sets at 0.34.0 and earlier the value was silently discarded and the widget mounted with no configuration and a clean console: there, await window.__gomaCdn.ready first, or configure through attributes. See Setting a property before the widget's definition has loaded.

See Integration Caveats for the cross-widget picture.

Changelog ​

Current release: 0.37.11. Every GomaPulse package shares one version number and they are published together, so a release that changes nothing here still bumps this widget. Only the releases that changed it are listed below.

Every release that changed this widget, newest first. The latest is open; the rest expand.

0.37.9
  • The event lists and the competition lists now wait for a slow or failing provider before they show an empty state. An event list keeps its loading state for about 55 s, long enough for the connection layer to ask for the data again, and the watchdog reports a failure only for a topic that was subscribed. The competition, top competition and region calls make up to 3 tries in 'rpc' mode (top competitions also in its 'subscribe' fallback), with a wait of 2 s before the second and 5 s before the third. network.rpcTimeoutMs bounds each try. A provider error now counts as a failed try: getCompetitions, getTopCompetitions and getAllLocations reject instead of resolving with nothing. A late answer of an earlier try that fills the list ends the tries. When every try has failed, the widget shows its empty state (the region filter drops its skeleton) and emits the new informational event goma:feed-load-failed ({ source, sportId?, topic? }), so a host can show its own message or reload. A sport change during the wait cancels the old tries, and the region call now ignores an answer for a sport the player has already left. The connection layer only gains a comment that links its retry timings to the event-list wait.

    The event list waits 55 s only after a request for data was sent, and 15 s when nothing could be requested. Load more now waits the same 55 s before its spinner clears, and the match page (game details) waits for the retries before it shows its not-found or empty state.

    The suggested bet card (dataSource: 'recsys') waits up to 55 s for the first answer once it has subscribed to the recommended events, instead of showing its empty state after 15 s. After an answer that still leaves no priced combo, or when no subscribe could be sent, it gives up after 15 s.

0.37.7
  • Faster pages with suggested-bet and promo cards on a real operator, with less data downloaded.

    • @gomagaming/sports-domain: mapping a market dump no longer slows down as the store grows. Outcomes find their offer, and markets find their outcomes, through a lookup built once per dump instead of a scan per item. The results are the same.
    • @gomagaming/sports-domain: subscribeToMatchOddsAdditive and bannerEvents.subscribeForMatchOdds accept bettingTypeId and eventPartId. With both, they open that one market's feed, {matchId}/match-odds/{bettingTypeId}-{eventPartId}, instead of every market of the match. subscribeForMatchOdds no longer re-opens a topic that is already open on the same session.
    • @gomagaming/network: a topic stays registered for 3 s after its last listener leaves, so a widget that closes and reopens it at once does not download its initial dump again. A listener that joins a topic whose initial dump failed, or has had no answer for 10 s, requests the dump again. unregisterGraceMs sets the window, and 0 restores the immediate release.
    • @gomagaming/suggested-bet: in recsys mode each leg opens only its recommended market's feed.
    • @gomagaming/pros-choice: a card that names its market as {bettingTypeId}_{eventPartId} opens only that market's feed. In the auto modes the card shows the first three priced outcomes in feed order, and leaves out outcomes the provider never prices instead of showing a price that never arrives.
    • @gomagaming/highlight-card and @gomagaming/pros-choice: a card whose named market is not on its event logs one console warning after 5 s, listing the markets the event does offer.
0.37.5
  • <goma-suggested-bet> in recsys mode no longer shows "-" for Total Odds and Winnings when the provider refuses the combo for having too many selections. An operator can limit the selections per slip by sport (for example, at most 3 when one leg is ice hockey), and the default recsysLegCount of 4 can go over that limit. The card now drops its lowest-ranked leg and asks for a price again, until the provider prices the combo. If fewer than recsysMinLegs legs would be left, it shows its empty state. "Bet the same" then loads a slip the provider accepts. With debug on, the card logs every refusal.

    @gomagaming/sports-domain: getBettingOptions answers carry rejectionReason, a provider-neutral reason for a refused quote (QUOTE_REJECTION.TOO_MANY_SELECTIONS or QUOTE_REJECTION.TOO_FEW_SELECTIONS, otherwise null). The EveryMatrix module maps its own error codes to it, so widgets never read a provider code. QUOTE_REJECTION is exported.

0.37.4
  • An event that a feed delivers again now keeps its markets. Before, when a favourite competition was collapsed and opened again, or its time window or sport changed, the event card in <goma-favorites> showed "Suspended" instead of its odds, because the markets were already stored and the event was built without them. The same fix applies to the banner, suggested bet, highlight and pros choice cards, which load their events the same way. An event only receives the stored markets that belong to it, and those markets take the state of the new data: a market that is open again is no longer shown as suspended, and an offer that the provider withdrew while the section was closed is no longer shown at its old price.

  • A card or banner that names an eventId now finds its event by id. Before, the widget subscribed to the whole feed of the event's sport and looked for the event inside it, so an event outside that feed (a far-future match, for example) never resolved and its card stayed empty. Now the widget asks the provider for the events it needs, live or prelive, so any event the provider knows resolves, and its score, clock, odds and live status keep updating. The events feed carries no main-market ranking, so for each sport shown the widget makes one small extra request for it, to keep a banner's headline market stable when a market is suspended and offered again.

    <goma-multi-banners>, <goma-highlight-card>, <goma-pros-choice> and <goma-suggested-bet> no longer need a sportId to resolve an event. The sportEventLimit property and the sport-event-limit attribute are deprecated and now do nothing. They are still accepted, so no existing page breaks.

    On the favourites page, live score, clock and status now update after the page loads. In the suggested bet recommendation mode the events also receive live match updates now; the card does not show a score, so nothing changes on it.

    Sport banners in <goma-multi-banners> can show the market name in a pill under the team names. Set showMarketName on the widget, or on one banner entry, to turn it on.

    • <goma-suggested-bet>: custom-elements.json now lists the defaults for the icon-base-url attribute ('') and the flagBaseUrl property (the built-in flag CDN).
    • Playground theme-token lists (./playground export) now match what each widget paints: <goma-hero-card> now also lists --goma-favorites (the favourite star), alongside --goma-backgroundOdds (the odds buttons, painted by the shared odds button); <goma-top-competitions> drops --goma-iconSecondary, which it never paints (the sport glyph uses --goma-iconPrimary); <goma-highlight-card> and <goma-pros-choice> add --goma-backgroundBorder, the colour of their loading skeleton bars.
    • <goma-live-chat>: on a browser that cannot format compact numbers, the participant count now reads like the normal compact output (25K, 2M, and 1M for 999,999) instead of keeping a trailing .0 (25.0K).
  • <goma-suggested-bet>: a card with no stake, no winnings or unresolved Total Odds now shows a plain hyphen (-) for that figure instead of an em dash, matching the empty placeholder the other widgets use. A host-fed bet with winnings: null (or '') now shows the hyphen placeholder too, instead of 0.00€.

0.37.0
  • Adds an appearance object for the card's geometry: panelPaddingTop, rankLineHeight, summaryLabelLineHeight, summaryValueLineHeight and ctaLineHeight. Each key maps to a --goma-suggestedBet* variable whose fallback is today's value, so a card without appearance renders exactly as before.
0.36.1
  • Playground manifests (@gomagaming/<widget>/playground) now offer a control for every setting each widget really has. Settings that cannot be a form field (raw provider data, read-only state mirrors, props a widget never reads) are recorded with a reason in the repo instead of being silently missing.

    Controls that never reached the widget are fixed:

    • The betslips' placeBetHeadersJson / placeBetBodyJson / quickAddAmountsJson are now placeBetHeaders / placeBetBody / quickAddAmounts.
    • The time-filter and filters-settings ticks editors set ticks.
    • live-chat's layout control writes position, the config key the element reads.
    • The sports-navigation widgets drop the unread sportsJson.
    • JSON-typed defaults are real objects and arrays instead of serialized strings. The top-competitions highlightedImages string default was indexed character by character and requested bad image URLs on every load. The missions-hub emptyStateImages illustrations now show.

    No widget runtime behaviour changes.

0.36.0
  • Total odds and possible winnings in the betslip and the suggested-bet card now come from the provider's bettingOptionsV2 answer instead of being multiplied on the client. A leg's price or availability change asks for a new answer (debounced, and never held back more than one second while prices keep moving), and every call sends betSlipChangeType (ODDS_CHANGE for a price-only move, otherwise SELECTION_CHANGE). Single bets are priced per selection with that selection's stake, and the Single tab no longer shows a Total Odds figure. In @gomagaming/ui, the shared betslip summary drops the Total Odds row on the Single tab and shows the empty placeholder when winnings are null, each selection row reads its winnings from the per-selection answer, and the betslip panel asks for those per-selection answers. <goma-suggested-bet> gains a variant property ('default' | 'compact'): the compact layout drops the selections row on top and shows a two-column Total Odds / Selections summary at the same card height. With no answer yet, a failed call, or no socket, the figures show the empty placeholder, and after a stake change Winnings shows it until the answer for the new stake arrives (Total Odds stays); the betslip's Winnings, and the possibleWinnings of goma:bet-placed, are then empty (null) rather than zero. <goma-suggested-bet> can now switch between recsys and rpc/subscribe on a mounted element without a remount. @gomagaming/sports-domain exports winningsForStake and gains bannerEvents.unsubscribeSports(), which releases the per-sport topics and keeps the match-odds topics. The bonus-wallet lookup for a Multiple now sends the provider's price for the current selections and waits for it, instead of a price multiplied on the client.

  • <goma-suggested-bet> gains a recsys data source that fills the card from the recommendation service, with popular recommendations for a signed-out visitor and personalised ones for a signed-in player. In this mode the card also gets a loading skeleton and an empty state (see below). Multiple recsys cards on the same page now automatically divide up the recommendation pool instead of racing each other onto the same fixtures: each card claims the events its combo settles on, and a sibling card sharing the same {recsysBaseUrl, recsysOperatorId, sessionId} combination skips whatever is already claimed and fills its own combo from what's left, falling back to its empty state if too little is left over. recsysCandidatePoolSize now counts candidates a card can actually use, reaching as deep into the ranked list as it needs to after excluding a sibling's claims, never the service's literal first N, so a rail of several cards no longer starves the later ones while each card still opens at most recsysCandidatePoolSize batched-events subscriptions. No host configuration is involved. While the recsys pipeline (fetch → shortlist → resolve → subscribe) is still working, the card now shows a whole-card loading skeleton sized to match the finished card instead of leaving a bare gap in the rail; prop, rpc and subscribe are unchanged and still render nothing until the host feeds a bet, since in those modes the host (not the widget) owns whether a card exists at all. @gomagaming/sports-domain gains the REST client and the combo-assembly policy behind it, plus a generic events-by-id subscription (sports.eventsById) that both the new recsys driver and @gomagaming/favorites now share; favorites's own favourite-events feed was migrated onto it with no change in behaviour. @gomagaming/core gains the no_suggested_bet locale key the new empty state renders.

    <goma-suggested-bet> also gains two element properties, rank and stake, read as a fallback whenever the active bet does not already carry that field. A host-fed bet.rank / bet.stake always wins. This closes the gap the recsys mode above opens: the recommendation service supplies neither, so without a host-fed fallback a recsys card shows no rank badge and an empty placeholder for Bet and Winnings. The fallback is general rather than scoped to recsys: it equally patches a partial host-fed bet in prop, rpc or subscribe mode. The widget never derives rank itself; a host supplies its card's own position in the rail. stake stays display-only in every mode, exactly like a host-fed bet.stake: the "Bet the same" CTA still never writes it into the shared betslip. Leaving both unset reproduces today's behaviour exactly.

    The recsys combo's per-match odds subscription now tracks the legs actually displayed, not the whole candidate pool. Each {matchId}/match-odds topic carries the full market set for one match, so opening one per pool candidate to ever show recsysLegCount of them cost far more live-odds traffic than the card put on screen, and a rail of several cards made this add up fast. The driver now walks the pool in the service's own rank order and opens a per-match feed only for the candidates it is currently trying to display: a candidate whose market already resolves off the batched events feed needs no subscription at all, and a candidate that is live but unpriced gets exactly one fair shot before the driver moves on to the next unclaimed candidate and releases the one that didn't pay off. The search stays exactly as deep as recsysCandidatePoolSize allows; only the number of open per-match feeds shrank, down to recsysLegCount at most. No behaviour change to what a combo displays, and no new configuration.

0.35.0
  • The shared betslip moved to goma:betslip:v4, and the odds-change setting it carries is now a three-value policy.

    The envelope used to carry acceptOddsChange, a boolean that could express only two of the three settings the betting provider accepts, and that meant the opposite of what its name said: true was submitted as ACCEPT_HIGHER, which accepts a rise. Its replacement, oddsChangeBehavior, says which of accept-any, accept-higher and never-accept applies, and the last of those is a setting the boolean could not represent at all: a bet that is cancelled if the odds move in either direction.

    The storage version moved rather than the field changing meaning under the same key, so a page still running the previous release keeps reading the envelope it wrote.

    Nothing is required of an integrator. An existing betslip migrates on the first widget mount, carrying the slip, stake, tab and bonus selections across. Only the odds setting changes, and every existing betslip resolves to accept-higher:

    • A betslip with acceptOddsChange: true, the previous default, was already being submitted as ACCEPT_HIGHER. Nothing about those bets changes.
    • A betslip with acceptOddsChange: false was being submitted as ACCEPT_ANY, under a control reading "Approve higher odds", which is not what that value does: it also accepts a DROP in the odds. Those bets are now submitted as the label promised. A player who wants any change accepted can select it explicitly.

    An integrator reading the betslip out of storage should read oddsChangeBehavior, and address the key through the newly exported BETSLIP_STORAGE_KEY rather than a literal. Widget code that sets the old boolean keeps working: it is still available, derived from the policy.

  • A sport with no bundled icon now falls back to a real glyph instead of the browser's broken-image icon.

    SbIcon resolves a bundled SVG by name and otherwise falls through to an <img>. For a sport id the feed ships before its artwork exists (e-Volleyball, in the sports carousel and in the "Sports available" list) that <img> 404s, and the old error handler only faded it to opacity: 0.2, which leaves the broken-image glyph on screen at 20%. A failed <img> is now dropped from the render instead.

    What stands in is the sport's family glyph: every e-sports category falls back to the e-sports icon, which is a real icon rather than "no icon". The family is read from the sport list the widget was already fed (the parent whose childrenIds names the category) so no id list has to be kept in step with the provider, and a flat feed that names no parent simply falls back to the neutral noicon glyph as before. The new sportIconNames helper in @gomagaming/core is what the sport tiles, the picker's trigger pill and the settings modal's sport row all resolve through.

    A host serving its own sport artwork keeps priority, and keeps it whether or not it configured a base URL: ${iconBaseUrl}/icons/sports/dark_mode/{iconId}.svg when one is set, and the origin-relative /icons/sports/dark_mode/{iconId}.svg when it is not. iconBaseUrl has no value that means "my own root" ('' and '/' both read as unset), so a host serving icons from its root keeps working with no configuration at all. A stand-in is considered only once that request has failed.

    Every widget that draws a sport glyph through SbIcon inherits the change, which is why they are all listed here: the two sports pickers, the settings modal, both top-competitions widgets, the sport banners, the game-details header, the suggested-bet legs, the My Bets cards, the two betslips that embed them, the highlight card and the Pros' Choice card.

    The family is looked up across the whole sport list the feed sent rather than the slice on screen. In rpc/subscribe mode useSportsSource mirrors the betting store filtered to the current context (LIVE vs popular), and a parent category carries no events of its own, so it is routinely missing from that slice: every e-sports child would have fallen back to the neutral glyph. The unfiltered list is mirrored alongside it as state.sportsCatalog, and sportIconLookupList (new in @gomagaming/core) is what the tiles, the trigger pill and the settings row read. mapSport also refreshes childrenIds on an existing entry: the list was written only when the entry was first created, so a family relation that arrived on a later dump, or a child added to one. Never reached the widget.

0.34.0
  • Stop a region list fetched for the wrong sport from overwriting the right one, and parse numeric strings assigned to numeric widget properties instead of silently replacing them with the widget's default

    A <goma-region-filter> configured for any sport other than Football issued two location requests instead of one: its own, correctly scoped to the configured sport, and a second one (scoped to Football) that nothing had asked for. Both wrote the same place, so the pills the widget rendered were whichever response happened to come back last. The same race reached <goma-competitions-list>, which groups its competitions by the same region data.

    The unrequested request came from the per-widget boot routine, which fetched locations with no sport at all and so fell back to sport 1. Locations are scoped to a sport on the server, and boot has no sport to scope them by, so it no longer fetches them: the only readers of that data are the widgets above, and each of them already fetches it for the sport it is showing.

    That removes the collision at its source. As a second line of defence, a locations payload now records which sport it was fetched for, and a widget ignores one belonging to a sport it is not showing. This matters whenever two scoped fetches can overlap (switching sport while a request is still in flight, for instance) and it is deliberately narrow: a payload with no recorded sport is still rendered, so the guard can only ever suppress data it can positively identify as belonging to somewhere else.

    Separately, every numeric property on every widget (sportId, skeletonCount, pageSize, spaceBetween, autoplayInterval and the rest) used to accept a JavaScript number and nothing else. A numeric string was not parsed; it was discarded and quietly replaced by the property's default value. el.sportId = '8' therefore left the widget on sport 1 and fetched the wrong data, with nothing logged to say so. Three ordinary integration paths hand over a string: a router parameter, a value read out of a CMS or JSON configuration, and a framework re-render that stringifies its props. Numeric strings are now parsed. Values that are not a finite number and not a numeric string (null, undefined, '', booleans, objects, NaN, Infinity) still fall back to the default, as before. HTML attributes (sport-id="8") and the config object were never affected by this and are unchanged.

0.33.0
  • Suggested bet: "Bet the same" no longer injects the card's stake into the betslip

    The CTA appended every leg to the shared goma:betslip:v3 envelope and then wrote the card's bet.stake as the slip stake, so a player tapping a card whose host fed stake: 10 found 10 pre-filled in the betslip. No other door into the betslip sets a stake (<goma-events-horizontal>, <goma-hero-card>, <goma-highlight-card> and <goma-pros-choice> contribute selections only and leave the stake to the player) and the component's requirement only asks the CTA to load the selections.

    The CTA now loads selections only. bet.stake stays the card's display figure: it still renders as "Bet", still drives the Winnings fallback, and is still reported as goma:bet-the-same's detail.stake. With nothing slip-level left to write, the widget now mounts useSharedBetslipSync({ subscribeToState: false }) like the other selection sources, so it neither adopts nor overwrites the stake a player typed in a betslip. No config prop or event changes. a separate change.

    @gomagaming/sports-domain is deliberately not listed: its only change here is a comment in useSharedBetslipSync.js (a stale "injected by a Bet the same card" clause), with no behaviour to release. It is in the lockstep group, so it is versioned with this release regardless.

0.32.0
  • Widgets no longer dispatch a bare error event alongside goma:error.

    Every factory-built widget's app-level Vue error handler dispatched two events for the same failure: goma:error, and an un-namespaced error. The bare one is gone; goma:error is unchanged and still carries the same { message, code, component? } detail.

    Like every GomaPulse event it was composed and bubbling, so it did not stop at the widget. It crossed the shadow root, crossed the document, and arrived at whatever the host had on window.addEventListener('error', …): as a CustomEvent with message, error and filename all undefined, since those are ErrorEvent fields a CustomEvent does not have. A host with error reporting installed received a malformed error record it could neither read nor attribute.

    That had been written off as harmless on the grounds that the handler behind it could never fire. It can. Vue begins its errorCaptured walk at the throwing component's parent, so the onErrorCaptured boundary each widget installs in its own root component cannot see that component's own errors, and being the root, there is nothing above it to catch them either. Anything raised in a widget's root scope (its setup, its render, its lifecycle hooks, its watchers) skips the boundary and reaches the app-level handler every time. Only errors from child components are caught by the boundary, which is what it is for: those show the in-shadow banner and emit goma:retry.

    Hosts listening for goma:error need no change. A host listening for bare error on a widget element should move to goma:error; it was documented as unreachable, so in practice it delivered nothing until now.

  • suggested-bet: release the WAMP subscription when a bet's legs resolve to no sports, when they move to a different sport, and after a second rpc resolution, and stop re-opening feeds the widget already holds.

    Subscriptions are keyed per sport, and the driver only ever added. Replacing a mounted widget's bet with one whose legs resolve to a different sport opened the new sport's topics while the old ones stayed open; replacing it with a bet whose legs carry no eventId left the previous bet's topics streaming with nothing to resolve; and in rpc mode the one-shot release fired only once per widget, so a bet swapped after the first snapshot re-subscribed and never released. All three ran until the element unmounted.

    The driver now tracks the sport ids it holds and releases exactly the ones that drop out, and the rpc snapshot is treated as belonging to the subscription rather than to the widget. A widget that never subscribed still never fires a teardown.

    Separately, each resync used to re-register every sport in the set even when nothing about the set had changed, and every re-registration pulls a fresh initial dump over the socket the whole page shares. Only new sports open now, plus every sport when eventLimit changes (the limit is baked into the topic) or when the socket reconnects, since a new session holds none of the previous one's registrations. eventLimit is also watched, so raising it takes effect instead of waiting for an unrelated resync to carry it.

0.31.0
  • The card's 360px width ceiling is now configurable via --goma-suggestedBetMaxWidth, defaulting to the same 360px it has always used.

    The cap was hard-coded, and the sibling promo cards (<goma-highlight-card> and <goma-pros-choice>) have none, filling whatever width the host gives them. In a column wider than 360px this card therefore stopped short while they filled, and the leftover space read as padding around it. Set the token to none to let it fill like its siblings, or to any other max-width value to cap it somewhere else.

    Nothing changes for existing embeds.

0.30.2
  • Betting with suggested bets: fix the 401 and the unconfirmed legs.

    Place-bet no longer fires before the session can pay for it. canPlaceBet gated on state.sessionId being a non-empty string, but a raw sessionId is not a placeable identity: the REST probe resolves the userId and the CE exchange mints the sessionToken + username seconds later. In that window the button was already live, and the submit went out with X-SessionId alone (no X-SessionToken, no userId header, no username in the body) which the provider answers 401. <goma-suggested-bet>'s "Bet the same" is the fastest way in, because it fills the slip and sets a stake in one tap, so a slip can be placeable milliseconds after login. The gate now also requires the minted identity, scoped to the existing requireSession + placeBetMode: 'api' opt-in, and reports it as the new goma:place-bet-blocked reason session_pending: deliberately distinct from no_session, so a host shows a wait state instead of telling a signed-in player to sign in. A host that sets placeBetHeaders carries its own credentials and is never gated on this.

    The wait is bounded at 20 seconds, because the exchange it waits for is not guaranteed to happen: useCeLogin returns early and silently without ucsOperatorId, gameLaunchBaseUrl or a socket, and gives up for good after its three attempts. Blocking unconditionally would leave a cookie-authenticated host with Place Bet disabled forever. Past the deadline the gate stands down (back to the previous behaviour, a submit that may 401) and logs a warning naming the configuration the exchange needs. Twenty seconds is comfortably above the exchange times measured against a live stage environment (11.5s and 12.5s from the REST login).

    Suggested-bet legs now carry isAvailable. The CTA's field whitelist omitted it, so a host-fed isAvailable: false was silently dropped on the way into the betslip. An unruled leg does not stay unruled: the betslip releases unruled legs wherever no authority exists to ask (no socket, a failed provider round-trip, a socket still connecting), so a leg the host had explicitly marked suspended was released to true and the slip became placeable on a selection nobody claimed was live. The flag is now carried through from the host in prop mode, and in rpc / subscribe reconciled with the feed one-way: an explicit false from either authority suspends the leg, an explicit true from the feed releases it, and anything else defers to the host. Neither side may unlock what the other suspended: the feed's offers come from the matches-aggregator dump, which receives no availability deltas, so a stale true there cannot override a host's false; and an offer that carries no isAvailable key at all resolves to unknown, not unavailable, which the betslip's own release path then decides.

    The other half of what was reported (the injected stake of 10 surviving the slip) is fixed separately, by the selections empty => stake 0 invariant in useSharedBetslipSync.

0.29.0
  • Integration caveats: fixes from a cross-widget review of every shipped widget from an integrator's point of view.

    Cross-cutting fixes:

    • @gomagaming/core's createWidgetElement() factory, and live-chat's and messenger's own element classes, now guard against importing a widget package in a non-browser context (SSR): the HTMLElement subclass was previously defined at module top level in every widget package, so importing any of them under server-side rendering threw a ReferenceError immediately.

    Per-package fixes:

    • favorites: added src/themeTokens.js and a :host stylesheet (previously the package had neither, so it silently rendered core's base theme instead of its documented dark-navy/cyan/amber theme).
    • suggested-bet. Fixed a leaked WAMP subscription: switching a mounted widget's dataSource away from rpc/subscribe never released the open topic.
    • filters-settings: fixed a dead :host{display:inline-block} CSS rule (the shared element factory's inline display:block always won), so the widget always rendered full-width instead of the intended compact inline trigger.
    • game-details: added a min-height fallback for the inert/no-eventId state in an unsized container.
    • betslip-sidebar, my-bets, competition-filter, suggested-bet, events-horizontal, top-competitions, top-competitions-pills, competitions-list, multi-banners, casino-games, sports-navigation-horizontal. Corrected themeTokens.js registries that had drifted from actual token usage, added missing goma:retry Events-table rows, and other small doc/registry corrections.
    • region-filter: added a missing typeof window !== 'undefined' guard on a window listener.
    • live-chat: corrected custom-elements.json's members array (missing theme/config accessors, and an inaccurate description).
    • sports-domain: corrected a stale code comment that seeded a documentation error in two Missions widgets.

    Three fixes (favorites, game-details, filters-settings) change real rendering and are flagged in docs/guide/integration-caveats.md as needing human visual verification before release.

0.26.0
  • Stop publishing source maps and package source.

    dist/*.js.map was emitted by the shared Vite preset and swept into every tarball by files: ["dist/", "src/"], with the original source inlined in sourcesContent. src/ shipped the same source again, in the clear. Both are one URL away from a browser once a package is served from a CDN, so the preset now builds with sourcemap: false and files no longer carries src/.

    @gomagaming/core keeps the two paths its export map actually points at: src/styles/main.css and src/types/index.d.ts. Its tarball drops from 1741 files to 576, which also brings it back under the 900-file limit a per-request unpacker can handle.

    Deep imports of @gomagaming/<pkg>/src/* from an installed package no longer resolve. Nothing documented used them; imports inside the monorepo are unaffected.

0.23.1
  • Fix "Bet the same" producing unplaceable bets. The CTA built its betslip legs from a field whitelist that omitted bettingTypeId (and its pair eventPartId), so every leg reached the place-bet payload with a null market identity and the provider rejected the whole slip.

    Both modes were affected: in rpc / subscribe the fields are now resolved off the feed's market (falling back to the betting offer), and in prop a host-fed value is no longer stripped. The failure was silent: the CTA writes directly to the betslip store, bypassing both the missing-field warning on the goma:outcome-select path and the shared-envelope identity backfill.

0.16.0
  • GomaPulse UX review (round 3):

    • Sport icons: dedicated glyphs for the eSports titles (FIFA, NBA2k, Counter-Strike, DOTA 2, Overwatch) plus a shared outline-controller default for every other eSports category, replacing the legacy generic gamepad; corrected Beach Volleyball and Hurling glyphs.
    • Casino card: nav-arrow disc uses backgroundBorder; restyled Play pill (8px radius, 14px, weight 600, buttonTextPrimary, outline play icon); title gains a soft white glow on hover; added the design's top-edge line + radial top glow (driven by highlightPrimary); removed the hover lift.
    • Multi-banners: favourite star now matches the app's outline star.
    • Country flag ring (shared): uses the theme-fixed allWhite token so the ring stays white under any consumer theme.
    • tabPill pressed state: added the 80%-opacity pressed treatment on the My Bets and Game Details pills.
    • Sports selector: the widget host now carries a default 8px corner radius.
    • Suggested-bet leg: sport-icon colour uses iconPrimary.
0.15.2
  • GomaPulse UX-review round 2: visual/token fixes:

    • core: official flat sports-icon set (46 mainstream/virtual/eSports-category glyphs across dark + light modes, keyed by sport id). Inherited app-wide via SbIcon; new casino_games_play i18n key (en/fr/pt).
    • casino-games: official flame glyph, hover "Play" overlay on tiles, title→tiles / tiles→dots spacing.
    • multi-banners: header now self-resolves the sport glyph from the event's sport id, renders the country flag via the shared CountryFlag component (new flagBaseUrl option, defaults to the OM flag CDN), and gains a 1px card border.
    • sports-navigation-horizontal: the rounded container now clips its content so the 8px corners render.
    • suggested-bet: leg sport icons use the flat bundled icon set.
    • game-details / missions-hub / sports-domain (My Bets): tabPill hover + pressed states (violet ring); My Bets ticket icon sizing (12px sport + flag) and centered totals summary.
0.15.0
  • UI fixes from the a separate change design review (Sports Pages, items 1.1–1.19):

    • Suspended odds pill text uses inputTextTitleDisabled (legible on lightened themes) and game-card team/player names render at 16px.
    • Brand font: Open Sans is bundled and registered via the document.fonts API so widgets paint the intended typeface inside the shadow root.
    • Game-card market pill colours corrected (live separatorLine, prelive highlightPrimary); sports-navigation tile sizing + rounded container; multi-banners overlay gradient; casino-games flame colour + tile gradient border; filters-settings radio-row divider.
    • competition-filter: edge-trigger icon size, pill padding, and hover/pressed states.
    • competitions-list: inset row divider (original colour) + corrected accordion caret orientation.
    • game-details: header sport-icon/flag + rounded top corners; market-tab horizontal paging arrows with click-to-centre; long-market "See All" cap; corrected collapse-caret orientation.
    • top-competitions: white sport icon + featured-card neon divider moved to the card bottom and thickened.
    • suggested-bet: Bet / Total Odds / Winnings summary centre-aligned.
0.12.0
  • <goma-suggested-bet>: the card now paints the cyan card-border gradient (the same 224deg cardBorderLineGradient* frame as the events-horizontal featured pre-live card), the rank badge sits as a glowing tab hanging down into the card from its top-centre edge (square top flush with the card's top border, rounded bottom), and the "N Selections" header is right-aligned. Matching the design.

    Adds a cardHeight prop (card-height): when set (number → px, or a CSS length), the card is that fixed height and the legs list fills the middle and scrolls if it overflows, so every card in a carousel lines up regardless of leg count. cardHeight takes precedence over scrollAfter.

0.10.0
  • Add <goma-suggested-bet>: a featured "Bet the same" suggested-bet card. A single ranked card (rank badge, N-selections header, per-selection legs with competition + flag, highlighted pick, market and fixture, plus a Bet / Total Odds / Winnings summary) whose CTA appends every leg into the shared cross-widget betslip and injects the card's stake, emitting goma:bet-the-same. Above scrollAfter legs the list becomes a themed scrollable region. A dataSource prop ('prop' | 'rpc' | 'subscribe') switches between fully host-fed data and resolving each leg's teams / competition / live odds from the event feed via { eventId, bettingOfferId } (recomputing Total Odds + Winnings as odds tick; subscribe is live, rpc is a one-shot snapshot). All colours map to existing --goma-* tokens.