<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-browserThe 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' },
}| Key | Type | Required | Description |
|---|---|---|---|
socketUrl | string | Only for rpc/subscribe/recsys | WAMP socket URL the widget itself connects to resolve legs (from { eventId, bettingOfferId } in rpc/subscribe, or from the shortlisted recommendations in recsys). |
socketRealm | string | Only for rpc/subscribe/recsys | WAMP realm. |
ucsOperatorId | string | Only for rpc/subscribe/recsys | Operator ID for the WAMP connection. Distinct from the recsysOperatorId property below, which is a query parameter on the recommendation-service REST call. |
locale | string | No | i18n locale. Defaults to 'en'. |
theme | object | No | CSS variable overrides: see Theming. |
debug | boolean | No | Verbose 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:
dataSource | How 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(andodd) labels inline as a fallback, or usepropmode. - 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:
- Fetch the ranked recommendation list (cached per
{recsysBaseUrl, recsysOperatorId, sessionId}forrecsysCacheTtlMs, several cards on the same page sharing that combination make one request, not one each). - Shortlist candidates: drop anything starting more than
recsysMaxStartsInHoursfrom now and keep only the first (best-ranked) recommendation per event, in the service's own rank order, however deep its list runs.recsysCandidatePoolSizeis NOT applied here: see the next point. - 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/subscriberesolution 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. - Take the first
recsysLegCountpriced 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. BelowrecsysMinLegspriced 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. - 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
recsysMinLegslegs 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:
| Row | Field | Fallback |
|---|---|---|
| Competition | competitionLabel | "{competitionName} - {countryName}", then tournamentName |
| Pick (highlighted) | pick | outcomeName |
| Market | market | marketName |
| Fixture | fixture | "{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'.
variant | Top row | Summary row |
|---|---|---|
'default' (default) | An N-selections count, right-aligned | Bet / Total Odds / Winnings, three columns |
'compact' | None | Total 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 betslipThey 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
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
bet | bet | object | null | null | The host-fed featured bet (see shape above). Ignored in recsys mode: the driver overwrites it. |
dataSource | data-source | 'prop' | 'rpc' | 'subscribe' | 'recsys' | 'prop' | How legs resolve (see Data sources). rpc/subscribe/recsys need socket config. |
sportEventLimit | sport-event-limit | number | 50 | Deprecated, no effect. Legs are resolved by event id, so there is no sport feed to cap. Still accepted so existing pages do not break. |
sessionId | session-id | string | '' | Player session id, forwarded to the recsys fetch. Personalised recommendations only fire when this and recsysOperatorId are both set. |
recsysBaseUrl | recsys-base-url | string | '' | Base URL of the recommendation service. Unset means no request is made and the widget shows its empty state. |
recsysOperatorId | recsys-operator-id | string | null | null | Operator id sent to the personalised endpoint. Required (with sessionId) for personalised recommendations; ignored by the guest endpoint. |
recsysLegCount | recsys-leg-count | number | 4 | Hard 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). |
recsysMinLegs | recsys-min-legs | number | 2 | Floor below which the widget shows its empty state instead of a combo. |
recsysCandidatePoolSize | recsys-candidate-pool-size | number | 12 | Hard 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). |
recsysMaxStartsInHours | recsys-max-starts-in-hours | number | 48 | Recommendations starting further out than this are dropped before any subscription opens. |
recsysCacheTtlMs | recsys-cache-ttl-ms | number | 300000 | How long an identical {recsysBaseUrl, recsysOperatorId, sessionId} fetch is shared across cards, in milliseconds. 0 disables the cache. |
currency | currency | string | '€' | Currency symbol for the Bet / Winnings figures. |
currencyPosition | currency-position | 'suffix' | 'prefix' | 'suffix' | 'suffix' → 10,00€; 'prefix' → $10.00. The numeric part is formatted with the active locale. |
showRank | show-rank | boolean | true | Show the rank badge. |
variant | variant | 'default' | 'compact' | 'default' | Card layout, see Card layout (variant). Any other value renders as 'default'. |
rank | rank | number | null | null | Fallback 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. |
stake | stake | number | null | null | Fallback "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. |
scrollAfter | scroll-after | number | 3 | Legs above this count scroll; 0 disables scrolling. Ignored when cardHeight is set. |
cardHeight | card-height | number | string | null | null | Fixed card height (number → px, or a CSS length). All cards line up and the legs list scrolls if it overflows. |
iconBaseUrl | icon-base-url | string | '' | ${iconBaseUrl}/icons/sports/dark_mode/{sportId}.svg. Per-leg sportIconUrl overrides it. |
flagBaseUrl | flag-base-url | string | glastcoper flag CDN | ${flagBaseUrl}{venueId}.png. Per-leg flagUrl overrides it. |
locale | locale | string | 'en' | i18n locale; also drives currency number formatting. |
theme | theme | object | {} | --goma-* overrides. |
Events
| Event | Detail | When |
|---|---|---|
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 says | You said | Written |
|---|---|---|
false | anything | false: the provider has suspended it |
| anything | false | false: you have suspended it |
true | not false | true |
| nothing | anything | your 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) untilbetis fed. This applies toprop,rpcandsubscribemode; the widget never shows a placeholder box while waiting for a host to assignbet. 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. recsysloading skeleton: only inrecsysmode, 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: honouringcardHeightthe same way the card does, so nothing reflows once the combo resolves.recsysis 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.recsysempty state: only inrecsysmode, once no combo can be assembled (norecsysBaseUrlconfigured, the fetch failed, fewer thanrecsysMinLegslegs 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,recsyshas 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 (neitherbet.stakenor the element'sstake), 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; withdebugon, the card logs the refusal and its reason. Inrecsysmode a refusal for too many selections drops a leg instead (step 5 above); inrpc/subscribethe 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 emitsgoma:retry); it also auto-clears after 8 seconds. Nogoma:erroris dispatched for it: the boundary catches it before it could reach that handler. The exception is an error raised inSuggestedBetAppitself: the boundary structurally cannot see its own component's errors, so those skip the banner and emitgoma:error(see Events), as does a connection-layer failure.
Theming
All colours map to existing --goma-* tokens: no new tokens. The card paints:
| Token | Default | Paints |
|---|---|---|
--goma-backgroundPrimary | #03061b | Card background |
--goma-backgroundSecondary | #161732 | Leg background |
--goma-highlightPrimary | #ff6600 | Rank badge background + highlighted pick |
--goma-highlightPrimaryContrast | #ffffff | Rank badge text |
--goma-buttonBackgroundPrimary | #ff6600 | CTA background |
--goma-buttonTextPrimary | #ffffff | CTA text |
--goma-textPrimary | #ffffff | Market + summary text |
--goma-textSecondary | #939dff | Competition / fixture / selection count |
--goma-iconPrimary | #ffffff | Sport icon glyph |
--goma-separatorLine | #2a2d60 | Leg + summary dividers, scrollbar track |
--goma-separatorLineSecondary | #393d83 | Scrollbar thumb |
--goma-alertError | #f9554d | Render-error banner background |
--goma-cardBorderLineGradient3 | #b25633 | Card-border gradient: accent end (top-right) |
--goma-cardBorderLineGradient2 | #7c4b57 | Card-border gradient: mid stop |
--goma-cardBorderLineGradient1 | #393d83 | Card-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.
| Token | Default | Controls |
|---|---|---|
--goma-suggestedBetMaxWidth | 360px | The 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.
| Key | CSS variable | Default | Affects |
|---|---|---|---|
panelPaddingTop | --goma-suggestedBetPanelPaddingTop | 20px (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-suggestedBetRankLineHeight | 1 (16px, a 24px badge) | Rank badge line height; the badge is this plus 4px top and bottom. |
summaryLabelLineHeight | --goma-suggestedBetSummaryLabelLineHeight | 1 (14px) | Summary labels (Bet / Total Odds / Winnings). |
summaryValueLineHeight | --goma-suggestedBetSummaryValueLineHeight | 1 (18px) | Summary figures. |
ctaLineHeight | --goma-suggestedBetCtaLineHeight | 1 (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
| Key | Default (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 eachSuggestedBetLegcarryingrole="listitem". - Decorative dividers, the resolving-skeleton block, the whole-card
recsysloading skeleton, and fallback glyphs (globe/ball emoji) arearia-hidden="true". - The render-error banner is
role="alert"; its Retry button has a visiblefocus-visiblering. - 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
dataSourceaway fromrpc/subscribenever released the open topic (only unmount, or a full rpc pricing cycle, did). Replacing thebetwith one whose legs carry noeventIdleft 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
eventLimitchanges (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.jsdeclared an unused--goma-iconSecondaryand 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 differentlocalevalues 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, awaitwindow.__gomaCdn.readyfirst, 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.rpcTimeoutMsbounds each try. A provider error now counts as a failed try:getCompetitions,getTopCompetitionsandgetAllLocationsreject 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 eventgoma: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:subscribeToMatchOddsAdditiveandbannerEvents.subscribeForMatchOddsacceptbettingTypeIdandeventPartId. With both, they open that one market's feed,{matchId}/match-odds/{bettingTypeId}-{eventPartId}, instead of every market of the match.subscribeForMatchOddsno 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.unregisterGraceMssets the window, and0restores the immediate release.@gomagaming/suggested-bet: inrecsysmode 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-cardand@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>inrecsysmode 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 defaultrecsysLegCountof 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 thanrecsysMinLegslegs would be left, it shows its empty state. "Bet the same" then loads a slip the provider accepts. Withdebugon, the card logs every refusal.@gomagaming/sports-domain:getBettingOptionsanswers carryrejectionReason, a provider-neutral reason for a refused quote (QUOTE_REJECTION.TOO_MANY_SELECTIONSorQUOTE_REJECTION.TOO_FEW_SELECTIONS, otherwisenull). The EveryMatrix module maps its own error codes to it, so widgets never read a provider code.QUOTE_REJECTIONis 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
eventIdnow 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 asportIdto resolve an event. ThesportEventLimitproperty and thesport-event-limitattribute 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. SetshowMarketNameon the widget, or on one banner entry, to turn it on.<goma-suggested-bet>:custom-elements.jsonnow lists the defaults for theicon-base-urlattribute ('') and theflagBaseUrlproperty (the built-in flag CDN).- Playground theme-token lists (
./playgroundexport) 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, and1Mfor 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-fedbetwithwinnings: null(or'') now shows the hyphen placeholder too, instead of0.00€.
0.37.0
- Adds an
appearanceobject for the card's geometry:panelPaddingTop,rankLineHeight,summaryLabelLineHeight,summaryValueLineHeightandctaLineHeight. Each key maps to a--goma-suggestedBet*variable whose fallback is today's value, so a card withoutappearancerenders 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/quickAddAmountsJsonare nowplaceBetHeaders/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
highlightedImagesstring default was indexed character by character and requested bad image URLs on every load. The missions-hubemptyStateImagesillustrations now show.
No widget runtime behaviour changes.
- The betslips'
0.36.0
Total odds and possible winnings in the betslip and the suggested-bet card now come from the provider's
bettingOptionsV2answer 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 sendsbetSlipChangeType(ODDS_CHANGEfor a price-only move, otherwiseSELECTION_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 arenull, 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 avariantproperty ('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 thepossibleWinningsofgoma:bet-placed, are then empty (null) rather than zero.<goma-suggested-bet>can now switch betweenrecsysandrpc/subscribeon a mounted element without a remount.@gomagaming/sports-domainexportswinningsForStakeand gainsbannerEvents.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 arecsysdata 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). Multiplerecsyscards 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.recsysCandidatePoolSizenow 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 mostrecsysCandidatePoolSizebatched-events subscriptions. No host configuration is involved. While therecsyspipeline (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,rpcandsubscribeare unchanged and still render nothing until the host feeds abet, since in those modes the host (not the widget) owns whether a card exists at all.@gomagaming/sports-domaingains 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/favoritesnow share;favorites's own favourite-events feed was migrated onto it with no change in behaviour.@gomagaming/coregains theno_suggested_betlocale key the new empty state renders.<goma-suggested-bet>also gains two element properties,rankandstake, read as a fallback whenever the active bet does not already carry that field. A host-fedbet.rank/bet.stakealways wins. This closes the gap therecsysmode above opens: the recommendation service supplies neither, so without a host-fed fallback arecsyscard shows no rank badge and an empty placeholder for Bet and Winnings. The fallback is general rather than scoped torecsys: it equally patches a partial host-fed bet inprop,rpcorsubscribemode. The widget never derivesrankitself; a host supplies its card's own position in the rail.stakestays display-only in every mode, exactly like a host-fedbet.stake: the "Bet the same" CTA still never writes it into the shared betslip. Leaving both unset reproduces today's behaviour exactly.The
recsyscombo's per-match odds subscription now tracks the legs actually displayed, not the whole candidate pool. Each{matchId}/match-oddstopic carries the full market set for one match, so opening one per pool candidate to ever showrecsysLegCountof 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 asrecsysCandidatePoolSizeallows; only the number of open per-match feeds shrank, down torecsysLegCountat 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:truewas submitted asACCEPT_HIGHER, which accepts a rise. Its replacement,oddsChangeBehavior, says which ofaccept-any,accept-higherandnever-acceptapplies, 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 asACCEPT_HIGHER. Nothing about those bets changes. - A betslip with
acceptOddsChange: falsewas being submitted asACCEPT_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 exportedBETSLIP_STORAGE_KEYrather than a literal. Widget code that sets the old boolean keeps working: it is still available, derived from the policy.- A betslip with
A sport with no bundled icon now falls back to a real glyph instead of the browser's broken-image icon.
SbIconresolves 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 toopacity: 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
childrenIdsnames 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 neutralnoiconglyph as before. The newsportIconNameshelper in@gomagaming/coreis 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}.svgwhen one is set, and the origin-relative/icons/sports/dark_mode/{iconId}.svgwhen it is not.iconBaseUrlhas 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
SbIconinherits 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/subscribemodeuseSportsSourcemirrors 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 asstate.sportsCatalog, andsportIconLookupList(new in@gomagaming/core) is what the tiles, the trigger pill and the settings row read.mapSportalso refresheschildrenIdson 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,autoplayIntervaland 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 sport1and 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 theconfigobject 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:v3envelope and then wrote the card'sbet.stakeas the slip stake, so a player tapping a card whose host fedstake: 10found 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.stakestays the card's display figure: it still renders as "Bet", still drives the Winnings fallback, and is still reported asgoma:bet-the-same'sdetail.stake. With nothing slip-level left to write, the widget now mountsuseSharedBetslipSync({ 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-domainis deliberately not listed: its only change here is a comment inuseSharedBetslipSync.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
errorevent alongsidegoma:error.Every factory-built widget's app-level Vue error handler dispatched two events for the same failure:
goma:error, and an un-namespacederror. The bare one is gone;goma:erroris unchanged and still carries the same{ message, code, component? }detail.Like every GomaPulse event it was
composedand bubbling, so it did not stop at the widget. It crossed the shadow root, crossed the document, and arrived at whatever the host had onwindow.addEventListener('error', …): as aCustomEventwithmessage,errorandfilenameallundefined, since those areErrorEventfields aCustomEventdoes 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
errorCapturedwalk at the throwing component's parent, so theonErrorCapturedboundary 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 emitgoma:retry.Hosts listening for
goma:errorneed no change. A host listening for bareerroron a widget element should move togoma: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
rpcresolution, 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
betwith 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 noeventIdleft the previous bet's topics streaming with nothing to resolve; and inrpcmode 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
rpcsnapshot 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
eventLimitchanges (the limit is baked into the topic) or when the socket reconnects, since a new session holds none of the previous one's registrations.eventLimitis 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 tononeto let it fill like its siblings, or to any othermax-widthvalue 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.
canPlaceBetgated onstate.sessionIdbeing a non-empty string, but a raw sessionId is not a placeable identity: the REST probe resolves the userId and the CE exchange mints thesessionToken+ username seconds later. In that window the button was already live, and the submit went out withX-SessionIdalone (noX-SessionToken, nouserIdheader, nousernamein 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 existingrequireSession+placeBetMode: 'api'opt-in, and reports it as the newgoma:place-bet-blockedreasonsession_pending: deliberately distinct fromno_session, so a host shows a wait state instead of telling a signed-in player to sign in. A host that setsplaceBetHeaderscarries 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:
useCeLoginreturns early and silently withoutucsOperatorId,gameLaunchBaseUrlor 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-fedisAvailable: falsewas 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 totrueand the slip became placeable on a selection nobody claimed was live. The flag is now carried through from the host inpropmode, and inrpc/subscribereconciled with the feed one-way: an explicitfalsefrom either authority suspends the leg, an explicittruefrom 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 staletruethere cannot override a host'sfalse; and an offer that carries noisAvailablekey 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 0invariant inuseSharedBetslipSync.
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'screateWidgetElement()factory, andlive-chat's andmessenger's own element classes, now guard against importing a widget package in a non-browser context (SSR): theHTMLElementsubclass was previously defined at module top level in every widget package, so importing any of them under server-side rendering threw aReferenceErrorimmediately.
Per-package fixes:
favorites: addedsrc/themeTokens.jsand a:hoststylesheet (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'sdataSourceaway fromrpc/subscribenever released the open topic.filters-settings: fixed a dead:host{display:inline-block}CSS rule (the shared element factory's inlinedisplay:blockalways won), so the widget always rendered full-width instead of the intended compact inline trigger.game-details: added amin-heightfallback for the inert/no-eventIdstate 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. CorrectedthemeTokens.jsregistries that had drifted from actual token usage, added missinggoma:retryEvents-table rows, and other small doc/registry corrections.region-filter: added a missingtypeof window !== 'undefined'guard on a window listener.live-chat: correctedcustom-elements.json'smembersarray (missingtheme/configaccessors, 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 indocs/guide/integration-caveats.mdas needing human visual verification before release.
0.26.0
Stop publishing source maps and package source.
dist/*.js.mapwas emitted by the shared Vite preset and swept into every tarball byfiles: ["dist/", "src/"], with the original source inlined insourcesContent.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 withsourcemap: falseandfilesno longer carriessrc/.@gomagaming/corekeeps the two paths its export map actually points at:src/styles/main.cssandsrc/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 paireventPartId), 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/subscribethe fields are now resolved off the feed's market (falling back to the betting offer), and inpropa 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 thegoma:outcome-selectpath 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 byhighlightPrimary); removed the hover lift. - Multi-banners: favourite star now matches the app's outline star.
- Country flag ring (shared): uses the theme-fixed
allWhitetoken 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; newcasino_games_playi18n 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
CountryFlagcomponent (newflagBaseUrloption, 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.
- core: official flat sports-icon set (46 mainstream/virtual/eSports-category glyphs across dark + light modes, keyed by sport id). Inherited app-wide via
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.fontsAPI so widgets paint the intended typeface inside the shadow root. - Game-card market pill colours corrected (live
separatorLine, prelivehighlightPrimary); 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.
- Suspended odds pill text uses
0.12.0
<goma-suggested-bet>: the card now paints the cyan card-border gradient (the same 224degcardBorderLineGradient*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
cardHeightprop (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.cardHeighttakes precedence overscrollAfter.
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, emittinggoma:bet-the-same. AbovescrollAfterlegs the list becomes a themed scrollable region. AdataSourceprop ('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;subscribeis live,rpcis a one-shot snapshot). All colours map to existing--goma-*tokens.