<goma-betslip-floating>
Floating-action-button (FAB) betslip: a fixed-position circular, icon-only button with a top-right selection-count badge that, on tap, opens a full-screen native <dialog> containing the shared betslip panel (selections list, stake input, single/multiple summary, place-bet button). Sister widget to goma-betslip-sidebar: same shared-storage selection sync, same place-bet contract, same theming. Use this one in mobile-first layouts where a permanent sidebar would steal too much real estate.
The dialog renders as a centred modal on desktop and as a bottom-sheet below the configured mobileBreakpoint. The iOS-safe native-<dialog> recipe from <goma-sports-navigation-dialog> is reused: top-layer showModal(), manual useScrollLock, dim ::backdrop with allow-discrete transitions.
Live demo
Open in the playground →: toggle Betslip (floating) alongside Events horizontal in the widget rail. Tap odds in the event cards to populate the shared goma:betslip:v4 envelope, then tap the floating pill to open the modal and inspect the selections / stake / place-bet flow.
Element tag
html
<goma-betslip-floating id="bf"></goma-betslip-floating>The widget renders only the FAB in its closed state: a circular, icon-only cyan button (a built-in betslip glyph by default, overridable via triggerIcon) with a top-right count badge. The host element is positionally fixed and does not occupy normal flow. Tapping the FAB opens the modal; the modal closes on the close button, backdrop click, or Escape. A successful Place Bet does not auto-close the modal: the receipt surfaces inline at the top of the panel so the user can confirm it and (optionally) switch to the My Bets tab.
Installation
bash
pnpm add @gomagaming/betslip-floating vue pinia vue-router vue-i18n @vueuse/core autobahn-browserConfiguration
js
const el = document.getElementById('bf')
el.config = {
// Optional: only needed when placeBetMode is 'api' and the endpoint is a
// relative path. Resolves placeBetEndpoint against this base.
bettingApiBaseUrl: 'https://sports-api.example.com',
locale: 'en',
// Optional host-supplied formatters for multi-currency / locale-specific
// display. Receive the raw number; return a string. Default behaviour
// when omitted: payouts use `Number#toFixed(2)`, odds use the truncate-
// not-round convention from `formatDisplayOdds`.
formatStake: (n) => new Intl.NumberFormat('pt-PT', { style: 'currency', currency: 'EUR' }).format(n),
formatOdds: (n) => n.toFixed(2),
}config is the runtime configuration object. None of the keys are required for placeBetMode='event'; only bettingApiBaseUrl matters in 'api' mode (and only if placeBetEndpoint is a path rather than an absolute URL). Locale propagates to Accept-Language on the API request and to the bundled i18n.
When the widget resolves a session (a raw sessionId fed for the CE-session exchange), three optional keys tune the cross-widget shared session (goma:session:v1, reused across every @gomagaming/* widget on the origin): sessionMaxAgeMs (number, default 1800000, max age before a proactive re-exchange), sessionRetryBackoffMs (number, default 500, base backoff for the exchange retry; 0 disables the wait) and sessionStepTimeoutMs (number, default 5000, how long one exchange step, the StartInfo request or the loginWithCeSession call, may stay unanswered; a StartInfo timeout is retried, a loginWithCeSession timeout replaces the shared socket; 0 disables the deadline). A 401/403 on place-bet carrying the provider's InvalidSession marker flags the shared session dead, which signs the player out across every widget on the page and emits goma:session-expired for the host to act on: a bare 401/403 with no such marker only fails that one bet.
Data sources
The betslip widget itself doesn't capture selections. It reads a single cross-widget shared envelope at goma:betslip:v4 (backed by localStorage + BroadcastChannel):
{
selections: [{ outcomeId, bettingOfferId, eventId, eventName, marketId,
marketName, outcomeName, sportId, competitionId,
homeParticipantName, awayParticipantName, eventDate,
tournamentName, idfosport, venueId, venueName,
bettingTypeId, eventPartId, // identity
odd, isAvailable, isLive, // last-known live state
stake }], // user-typed, single mode only
activeBetslipId, stake, activeTab, oddsChangeBehavior, systemBetTypeId,
}Persistence model. Each persisted selection carries identity fields PLUS the last-known live state (odd, isAvailable, isLive) so a re-mount or peer hydration shows a real value immediately. The betslip widget's own per-instance WAMP subscription (useBetslipSelectionsSubscription + useBetslipOddsSync) refreshes those fields on the next tick. Pure UI flash flags (priceUp, priceDown) are NEVER serialised: the component derives its own up/down indicator from a local watch on selection.odd. Selection-row flicker is prevented by updates.js no longer pushing stale isAvailable from the events-horizontal pipeline: leaving the betslip widget's WAMP feed as the single authoritative writer for live fields.
Upgrading from goma:betslip:v3. The envelope's acceptOddsChange boolean was replaced by oddsChangeBehavior, and the key moved with it. Nothing is required of a host: a v3 envelope migrates on the first widget mount, carrying the slip, stake, tab and bonus picks across. A host that read acceptOddsChange out of storage should read oddsChangeBehavior instead: 'accept-any' | 'accept-higher' | 'never-accept', where the boolean could only express the first two and meant the opposite of what it said. Address the key through the exported BETSLIP_STORAGE_KEY rather than a literal. The full migration table is in Shared state.
Role separation under one key. Events-horizontal calls useSharedBetslipSync({ subscribeToState: false }): it neither reads nor writes the slip-state fields (stake / activeTab / oddsChangeBehavior / systemBetTypeId), and its selections-out write spreads ...shared.value so peer-written state survives. The betslip widgets read AND write the full envelope.
systemBetTypeId is the player's chosen system type (e.g. "Trebles (3/5)"), synced across betslip widgets on the page like the other state fields. The system-type catalog it's chosen from is deliberately not shared: it's derived from the current selections, so each widget fetches its own from the provider rather than trusting a peer's copy that could go stale the moment the leg set differs.
Tapping an odd in events-horizontal calls betslipStore.addSelection(…) which writes the new selection (identity only) into the envelope. This widget's useSharedBetslipSync applies the inbound change into its local Pinia and the panel re-renders. Live odds + availability for that selection arrive a moment later via this widget's own WAMP feed; until the first tick lands, peer-driven rows render the placeholder (-) for odds. The widget also listens on window for goma:outcome-select (composed events bubble out of any shadow root), so a host can populate the slip from a custom UI without <goma-events-horizontal> on the page.
A one-shot migration runs on first widget mount in any tab: older goma:betslip:v2 envelopes (full shape) and the short-lived goma:betslip-selections:v1 + goma:betslip-state:v1 split are merged into v3 (transient flash flags stripped on the way in; identity + last-known live state preserved) and the legacy entries deleted. Idempotent across tabs.
Live odds
The widget opens its own WAMP subscription per instance: independent of <goma-events-horizontal>. When config.socketUrl/config.socketRealm is set, it watches the active betslip's selections and registers a single topic keyed on the current set of bettingOfferIds. The default URL shape:
/<sportsNamespace>/<ucsOperatorId>/<lang>/bettingOffers/<id1>,<id2>,...The subscription re-keys whenever the slip's selection set changes. On dump and update the per-instance betting.bettingOffers store is patched; useBetslipOddsSync's deep watcher then rewrites each matching selection's odd/priceUp/priceDown/isAvailable. Without socketUrl the widget still mounts and placeBet still works against the snapshot prices captured at addSelection time.
Place-bet transport
Two modes, controlled by placeBetMode:
| Mode | Behaviour |
|---|---|
'event' (default) | Emits goma:place-bet-request with the payload, then resolves with a mock receipt after ~300 ms (slip cleared, goma:bet-placed fires). The host integrates its own backend by listening to goma:place-bet-request: the mock receipt keeps the UI honest while you wire the real call. |
'api' | POSTs the payload to placeBetEndpoint (absolute URL or path resolved against config.bettingApiBaseUrl). Adds Content-Type: application/json + Accept: application/json + Accept-Language: <locale> plus any keys from placeBetHeaders. Spreads placeBetBody into the request body so the host supplies identity (userId, username, currency, ucsOperatorId, …) without the widget knowing the provider's specifics. Treats either !response.ok or response.body.success === false as failure. |
Single-bet mode places one bet per selection
The single tab submits each staked leg as its own bet. One request (or one goma:place-bet-request) per selection, typed SINGLE, carrying a one-element selections[] and that leg's own stake as amount. Legs with no stake are skipped. Legs are settled independently: each success removes its leg from the slip, each failure leaves its leg in place to retry.
Multiple mode is unchanged: one combined request for the whole slip, typed MULTIPLE, with the slip-level stake as amount.
The base request body (always included):
json
{
"uniqueId": "MS0xNzAwMDAwMDAwMDAw",
"batchId": "batch-1-1700000000000",
"type": "SINGLE" | "MULTIPLE" | "SYSTEM",
"amount": 10,
"ucsOperatorId": "2838",
"oddsValidationType": "ACCEPT_ANY" | "ACCEPT_HIGHER" | "WITHIN_THRESHOLD",
"terminalType": "DESKTOP",
"freeBet": false,
"systemBetType": "3",
"selections": [
{
"bettingOfferId": "…",
"priceValue": 2.5,
"eventId": "…",
"marketIds": ["…"],
"bettingTypeId": "…",
"outcomeId": "…",
"betBuilderPriceValue": 3.2
}
],
"userId": "123",
"username": "…",
"currency": "…",
"lang": "en",
"userCountry": "PT",
"sessionId": "…",
"...placeBetBody": "spread last so host can override base fields"
}uniqueIdis the provider idempotency key (base64 of{counter}-{millis}) that dedupes retried submits (double-clicks, network retries).batchIdcorrelates one press of Place Bet acrossgoma:place-bet-start,goma:place-bet-request,goma:bet-placedandgoma:bet-failed: see Events.typeis uppercase (SINGLE/MULTIPLE/SYSTEM): matches the EMplace-bet/v2/betscontract.systemBetTypeappears only on a system bet: the chosen catalog id (from the provider's system-type list, see System bets) as a string.amountabove stays the slip's per-combination stake; the provider multiplies it out by the combination count itself.marketIdsis an array of strings per leg (even for a single market): the EMplace-bet/v2/betscontract for the reference operator expects this shape.bettingTypeIdis populated when the selection carries one (it travels through the cross-widget storage envelope as part of the identity fields);nullotherwise.betBuilderPriceValueonly appears on legs that the provider'sbettingOptionsV2response flagged as part of a BetBuilder group; it carries the group's combined odds so the provider can score the combo correctly.freeBetis always a boolean, nevernull. It istrueonly when theubsWalletIdsent on that same payload belongs to a bonus of typefreeBet; the provider books a free bet only when the flag rides with the wallet id, and books a cash stake otherwise. Odds boosts, stake-back, cashback and plain bets sendfalse. In single-bet mode each leg's payload is flagged by that leg's own wallet. With a bonus picked in the betslip the widget writesubsWalletIdandfreeBettogether, so a hostplaceBetBody.freeBetcannot disagree with the wallet sent. With no betslip pick, aplaceBetBodyvalue stays as the host sent it: a host that sends a free-bet wallet throughplaceBetBody.ubsWalletIdmust also sendfreeBet: true.ucsOperatorIdis not included in the body: it already travels in the URL path (place-bet/{ucsOperatorId}/v2/bets) and theX-OperatorIdheader (api mode).- Auto-filled by the widget once
useSession+useUserBalancepopulate the user store:userId(numeric),username,currency,lang(from the active locale). Omitted when unpopulated so the payload doesn't lie about an unauthenticated state. - Host-supplied via
placeBetBody(fields the widget can't derive from the session probe):userCountry, and wallet routing (ubsWalletId; the widget derivesfreeBetfrom the wallet itself, see above). Spread last so the host can override anything the widget auto-filled: exceptubsWalletIdandfreeBetwhen a bonus is selected in the betslip; see Payload precedence below. amountstays a plain JS number end-to-end: in single-bet mode each per-leg request'samountis that leg's own staked amount, not the whole slip's stake.
Payload precedence
A bonus selected in the widget is sent as
ubsWalletId, with the matchingfreeBetboolean, and takes precedence over aubsWalletIdorfreeBetsupplied throughplaceBetBody. When no bonus is selected, theplaceBetBodyvalues are sent unchanged. These are the only fields where a widget value overridesplaceBetBody; it exists so that a configured default cannot silently disable the player's own choice.
In single-bet mode the wallet resolved is that leg's own pick, not a slip-level one: each single-mode payload already carries only one selection. The rule only applies while bonusEnabled is set; with the feature off, a host-supplied ubsWalletId always passes through untouched, so a betslip that hasn't opted in behaves exactly as before.
Request headers (api mode)
In addition to Content-Type, Accept, and Accept-Language, the widget auto-adds:
X-SessionId: <sessionId>: when thesessionIdprop is set.userId: <userId>: whenuseSessionhas populated the user store.X-OperatorId: <ucsOperatorId>: whenconfig.ucsOperatorIdis set.
Explicit entries in placeBetHeaders win over these auto-set values. X-SessionToken (format {userId}_{ucsOperatorId}_{random}) is auto-derived when config.gameLaunchBaseUrl, config.ucsOperatorId and a WAMP socket are configured: the widget exchanges the REST sessionId for a CE session and binds it via loginWithCeSession. Override it through placeBetHeaders only if your deployment mints the token elsewhere.
sessionId is only present when the sessionId prop is set; otherwise the key is omitted.
The shape mirrors the Everymatrix place-bet/{ucsOperatorId}/v2/bets contract; other backends can either follow the same shape or remap on the host side via goma:place-bet-request.
Authenticated submissions
Set the sessionId prop with the user's session token to authenticate the request without having to wire it through placeBetHeaders / placeBetBody by hand:
js
el.placeBetMode = 'api'
el.placeBetEndpoint = '/place-bet/2838/v2/bets'
el.sessionId = currentUser.sessionIdIn 'api' mode the widget adds X-SessionId: <sessionId> to the request headers automatically. In 'event' mode the same token appears on the goma:place-bet-request payload as sessionId, so a host listener can forward it to its own backend:
js
el.addEventListener('goma:place-bet-request', async (e) => {
await fetch('/my/internal/bets', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-SessionId': e.detail.sessionId },
body: JSON.stringify(e.detail),
})
})If the host needs a different header or body key (e.g. Authorization: Bearer …, or a backend that calls the field token), use placeBetHeaders / placeBetBody: explicit values still win over the sessionId shortcut.
Authenticated session surface
Setting sessionId does more than thread the token onto the place-bet request. When the prop is populated AND config.playerApiBaseUrl is configured, the widget:
- Probes the session:
GET {playerApiBaseUrl}/v1/player/session/playerwith headerX-SessionId. The response shape{ IsAuthenticated: true, UserID }populates a per-instance user store. Any failure orIsAuthenticated: falseclears the session and gates everything below back off. - Fetches the user's balance:
GET {playerApiBaseUrl}/v2/player/{userId}/balancewithX-Session-Type: others+X-SessionId. The response (a{ [currency]: amount }map undertotalAmount) is normalised onto the user store asbalance/currency. - Opens an SSE balance stream:
GET {playerApiBaseUrl}/v2/player/{userId}/information/updates. Messages oftype: 'BALANCE_UPDATE_V2'trigger a balance re-fetch; messages oftype: 'SESSION_EXPIRATION'or'SESSION_EXPIRATION_V2'emitgoma:session-expiredand clear the session. The stream uses a fetch-based reader (not nativeEventSource) so the auth headers travel with the request; it reconnects with exponential backoff up to 5 attempts (1 s → 30 s). - My bets is delegated, not owned.
<goma-betslip-floating>no longer fetches bet history itself: the panel's My bets tab embeds a real<goma-my-bets>custom element (its own widget), fed this widget'sconfig,theme,locale,iconBaseUrl,sessionId, themyBetsEmptyGuestImage/myBetsEmptyAuthedImageprops,myBetsShowLiveClock(as the embedded element'sshowLiveClock, also re-sent when it changes),myBetsCashoutEnabled(as itscashoutEnabled),myBetsSubTab(as itssubTab, also re-sent when it changes) and the three cash out images (the images are re-sent when they change, only when the new URL is not empty). After a cash out succeeds, the betslip asks for a new balance at once and again after 2.5 seconds.goma:cashout-successandgoma:cashout-errorbubble out of the betslip to the page. Closing the betslip also closes a cash out dialog that is open inside it (seegoma-my-bets). On a page that loads two copies of@gomagaming/core, the embedded element can come from the other copy and mount standalone instead of hosted; the betslip then also calls itsreload()after each placement (seegoma-my-bets's Integrates with). That embedded widget owns the bets-api fetch, cursor paging, and live open-bet scores: see<goma-my-bets>for the exact REST contract. The embedded widget stays mounted once shown and is told toreload()every time the user switches back to the My bets tab. - Resolves BetBuilder grouping: when 2+ selections share an
eventId, the widget calls the provider'sbettingOptionsV2RPC to fetch combined odds + combinability metadata. Selections whosebettingOfferIdis not in the combinable set getisCombinable: falseand the place-bet button is gated withplaceBetBlockedReason: 'incompatible'(place_bet_blocked_incompatibleis only the i18n key a future in-widget render would derive from it, the emitted value is the bare reason). ThebetBuilderPriceValueper leg is appended to the place-bet payload so the provider scores the combo correctly. Falls back gracefully when the resolved provider adapter doesn't exposegetBettingOptions: combinability stays at its default (true) and grouping degrades to "the same event isn't combined server-side".
Reactive state (balance, currency, MyBets list, BetBuilder groups, combinability) lands on the user / betting stores. The MyBets UI is shipped: see My Bets tab below. The accompanying balance chip + BetBuilder card surfaces consume these same stores in a follow-up.
Session expiry: the host owns recovery
GomaPulse cannot re-authenticate. The REST sessionId is minted by the host's own login and the provider exposes no refresh path, so once a credential stops working the only way back is the host issuing a new sessionId. Two consequences to design for:
1. The widget signs the player out and tells you: it shows nothing itself. When the provider rejects a place-bet with 401 / 403, the panel renders no error text and no warning banner. The raw transport string ("HTTP 401", providers send no body on a gateway rejection, and HTTP/2 has no status text) means nothing to a player, and an earlier "your session is no longer valid" banner was worse still: it left the UI presenting them as signed in while nothing they could do would work.
Instead the widget treats a dead session as a logout. The token every widget was about to re-send is dropped, the identity is cleared so balance, My Bets and favourites all fall back to their guest states, and goma:session-expired goes out to you. goma:bet-failed still fires alongside it, carrying the full status / body for your telemetry.
Listening is therefore mandatory in api mode. A host that ignores goma:session-expired leaves the player tapping a Place Bet button that silently does nothing.
js
let lastHandled = null
el.addEventListener('goma:session-expired', async (e) => {
const { reason, invalidationId } = e.detail
if (invalidationId === lastHandled) return // a sibling widget already reported this one
lastHandled = invalidationId
console.warn('session died:', reason)
el.sessionId = await myApp.reauthenticate() // a fresh token restarts everything
})detail.invalidationId is a monotonic counter: every mounted session-aware widget reports the same id for one death, and a retry reports a higher one. Dedupe on it and your handler runs exactly once per actual death while a second failure still re-prompts, which a "have I already logged out?" guard would swallow.
reason is one of place-bet-401, place-bet-403, favorites-401, favorites-403, session-probe-401, session-probe-403, session-probe-rejected, ce-login-rejected, SESSION_EXPIRATION, SESSION_EXPIRATION_V2. It fires once per invalidation per mounted session-aware widget, and again if the player retries: make the handler idempotent.
A transient failure is deliberately not reported and changes nothing: a network blip, a 5xx, or a missing playerApiBaseUrl tells us nothing about whether the token is still good, so the session is left exactly as it was rather than signing the player out over a flaky request. A real rejection settles it.
2. A reload re-verifies rather than trusts. The resolved session is cached in localStorage (goma:session:v1) and shared across widgets and tabs, but that record only says what GomaPulse last resolved: never that the provider still honours it. A session killed server-side leaves it untouched. So the first sessionId of every page load pays for a fresh session probe and a fresh CE-session exchange regardless of cache age (the cached token was bound to the previous socket), after which the normal reuse window resumes. Both are de-duped page-wide, so this costs one probe + one exchange per load however many session-aware widgets you mount. Tune the reuse window with config.sessionMaxAgeMs.
My Bets tab
When the tab is shown, the panel header grows a second tab (My bets) alongside the existing Betslip tab. Tapping it swaps the body to a list of the user's placed bets (powered by the same useMyBets feed described above), with a sub-pill row to switch between Open / Closed / Won.
Visibility is controlled by the myBetsTab prop: 'auto' (default) shows the tab only once a sessionId is set, so anonymous embeds keep the v1 single-tab shape; 'always' shows it even without a session (it renders the guest empty state, "Offside! / Log in to view your bets.", until the user signs in); 'never' hides it entirely.
The empty state is auth-aware: a guest (no sessionId) sees the guest illustration + "Offside!", while a signed-in user with no bets sees the authenticated illustration + "Empty stands…". The Open / Closed / Won sub-tab pills are shown in both cases. Feed the two illustrations with myBetsEmptyGuestImage / myBetsEmptyAuthedImage.
The card list, infinite scroll, per-card layout, and the sport/flag glyph resolution (iconBaseUrl / flagBaseUrl) are all implemented inside <goma-my-bets>: see its widget page for the exact behaviour. iconBaseUrl reaches the embedded widget in two ways (the reactive-bridge factory copies locale / iconBaseUrl / theme into a hosted goma-* child, and the betslip also sets them on the embedded element, which covers a standalone one from another copy of @gomagaming/core); flagBaseUrl set on <goma-betslip-floating> is not currently forwarded to the embedded <goma-my-bets> instance, so a host that needs flag glyphs on MyBets cards should confirm they render as expected before relying on it.
Required configuration
js
el.config = {
apiBaseUrl: 'https://sports-api.example.com', // existing
bettingApiBaseUrl: 'https://sports-api.example.com', // existing: place-bet
playerApiBaseUrl: 'https://player-api.example.com', // session + balance
betsApiBaseUrl: 'https://bets-api.example.com', // read by the embedded <goma-my-bets>
ucsOperatorId: '2838', // already required for place-bet path
locale: 'en', // existing: also used for X-language
}The whole config object is forwarded to the embedded <goma-my-bets> widget, so betsApiBaseUrl (and its fallback chain) still belongs in this widget's config even though the fetch itself happens inside the child. If playerApiBaseUrl is omitted, the widget falls back to apiBaseUrl. CORS is the host's responsibility: the widget makes vanilla cross-origin fetch calls; the host needs to ensure the relevant Access-Control-Allow-* headers are in place (or front the requests with a same-origin reverse proxy).
Props / attributes
| Property | Attribute | Default | Description |
|---|---|---|---|
open | open | false | Programmatic open/close. Two-way: tapping the FAB flips it true; placing or closing flips it false. |
fabPosition | fab-position | 'bottom-right' | FAB anchor on the viewport. 'bottom-right' / 'bottom-left' / 'bottom-center'. |
triggerIcon | trigger-icon | '' | Raw inline-SVG string used as the FAB icon. The SVG should use fill="currentColor" / stroke="currentColor" so it inherits the FAB's contrast colour; the widget sizes it to fill the icon slot. Empty (default) renders a built-in betslip glyph. |
mobileBreakpoint | mobile-breakpoint | 'sm' | Viewport width below which the dialog renders as a bottom-sheet (full-width, anchored to bottom, slide-up animation) instead of a centred modal. Tailwind token ('sm' 640 / 'md' 768 / 'lg' 1024 / 'xl' 1280 / '2xl' 1536) or a raw pixel value (number 480 or string '480px'). |
panelHeight | panel-height | '' | Height of the dialog panel, on both the desktop modal and the mobile bottom sheet. A percentage is a share of the viewport height ('80%', applied as 80dvh); a number or a string of digits is pixels (600, '600', '600px'). Empty, 0, over 100% or anything else keeps the built-in height (85 dvh desktop, 88 dvh mobile). The panel is never taller than the viewport: a larger pixel value is capped at 100dvh. See Panel height. |
placeBetMode | place-bet-mode | 'event' | 'event' (default) or 'api'. See above. |
placeBetEndpoint | place-bet-endpoint | '' | Absolute URL or path (resolved against config.bettingApiBaseUrl). Required when placeBetMode='api'. |
placeBetHeaders | place-bet-headers | {} | JSON-encoded extra request headers (auth tokens, X-OperatorId). Merged on top of the default Content-Type / Accept / Accept-Language. For X-SessionId prefer the dedicated sessionId prop. |
placeBetBody | place-bet-body | {} | JSON-encoded extra body fields (e.g. { userId, username, currency, ucsOperatorId, lang }). Spread last so the host can override base fields. |
sessionId | session-id | '' | Authenticated-session token. When set, the widget auto-adds it as the X-SessionId request header (api mode) and includes it as sessionId on the request payload (so goma:place-bet-request listeners in event mode can forward it to their own backend). Explicit placeBetHeaders['X-SessionId'] / placeBetBody.sessionId still win: sessionId is the convenience surface; the existing maps are the override hatch. |
myBetsTab | my-bets-tab | 'auto' | Controls the My bets header tab. 'auto' shows it only once sessionId is set; 'always' shows it even when anonymous (renders the empty "no open bets yet" state until a session is wired); 'never' hides it. |
panelTab | panel-tab | 'betslip' | Active panel tab: 'betslip' or 'myBets'. Sets the tab the panel opens on, and can be flipped live via the remote-control events. Requesting 'myBets' is ignored while the My bets tab isn't visible (see myBetsTab). |
openOnSelect | open-on-select | false | When true, the dialog auto-opens on the Betslip tab. "Bet the same" (goma:bet-the-same) opens it on any viewport; an odds tap (goma:outcome-select) opens it only on a desktop viewport (at/above mobileBreakpoint): on mobile, odds taps select the Betslip tab without popping the dialog. See Auto-open on selection. |
requireSession | require-session | false | When true, the Place Bet button stays disabled until a usable session is present, so anonymous users can't submit. That means both a non-empty sessionId and no authoritative rejection recorded against it: a stale id restored from a cookie satisfies the first but not the second, and submitting on one can only ever return 401. Since the widget can't clear the host-owned sessionId prop, this is also what stops a player re-tapping into the same failure while the host gets around to signing them off. The widget renders no explanatory text of its own: the button is simply disabled; a pressed-anyway tap reports reason: 'no_session' on goma:place-bet-blocked (see Events) for the host to word. Only enforced in api mode: a no-op in event mode, where the host submits through its own backend and handles auth itself. |
quickAddAmounts | quick-add-amounts | [10, 20, 50] | Quick-add chip amounts rendered next to each selection's stake input in single-bet mode. JSON-encoded array on the attribute. Each value is added to the current stake on click. Empty array hides the chips entirely. |
emptyStateImage | empty-state-image | '' | URL of a host-supplied illustration rendered above the empty-state label when the slip is empty. Omit to fall back to a small emoji glyph. |
emptyStateImageAlt | empty-state-image-alt | '' | Alt text for emptyStateImage. |
myBetsEmptyGuestImage | my-bets-empty-guest-image | '' | URL of the guest My Bets empty-state illustration: shown with "Offside!" / "Log in to view your bets." when there's no sessionId. Omit to fall back to a flag emoji glyph. |
myBetsEmptyAuthedImage | my-bets-empty-authed-image | '' | URL of the authenticated My Bets empty-state illustration: shown with "Empty stands…" / "Place a bet and light up the game." when signed in with no bets. Omit to fall back to a stadium emoji glyph. |
myBetsShowLiveClock | my-bets-show-live-clock | false | Shows the match clock (e.g. 33' 1st Half) in front of the LIVE pill on a live leg in the My Bets tab. Forwarded to the embedded <goma-my-bets> as its showLiveClock, at mount and whenever it changes. The LIVE pill shows either way. |
myBetsCashoutEnabled | my-bets-cashout-enabled | true | Turns on cash out in the My Bets tab. Forwarded to the embedded <goma-my-bets> as its cashoutEnabled, at mount and whenever it changes. Set it to false when the host has no cash out backend. The property also takes the string 'false' as off. |
myBetsSubTab | my-bets-sub-tab | 'open' | The sub-tab the My Bets tab opens on: 'open', 'settled', 'won' or 'cashout' ('closed' also works). Forwarded to the embedded <goma-my-bets> as its subTab, at mount and whenever it changes; the player can still click any tab. Setting the same value again moves the tab back. Only the sub-tab inside My Bets: the Betslip / My Bets header tab is panelTab. See subTab. |
myBetsCashoutWarningImage | my-bets-cashout-warning-image | '' | URL of the illustration in the cash out confirmation dialog. Forwarded to the embedded <goma-my-bets>. Nothing renders when unset. |
myBetsCashoutSuccessImage | my-bets-cashout-success-image | '' | URL of the illustration shown after a successful cash out. Forwarded to the embedded <goma-my-bets>. Nothing renders when unset. |
myBetsCashoutErrorImage | my-bets-cashout-error-image | '' | URL of the illustration shown when a cash out fails. Forwarded to the embedded <goma-my-bets>. Nothing renders when unset. |
iconBaseUrl | icon-base-url | '' | Sport-icon CDN base, forwarded to the embedded <goma-my-bets> widget's cards. |
flagBaseUrl | flag-base-url | '' | Country-flag CDN base intended for the embedded MyBets cards: see the My Bets tab caveat above. |
bonusEnabled | bonus-enabled | false | Turns the bonus selector on. Off by default: an embed that doesn't opt in renders exactly as before. In api mode it also needs a sessionId. See Bonus mechanism. |
bonusMode | bonus-mode | 'api' | 'api' has the widget fetch the player's eligible bonus wallets itself; 'prop' treats bonuses as an already-built list and makes no request: offline demos, docs examples, tests. |
bonusEndpoint | bonus-endpoint | '/v1/bonus/wallets/sports' | Absolute URL, or a path resolved against config.playerApiBaseUrl (falling back to config.apiBaseUrl). Sent as a PUT. Only read in bonusMode: 'api'. |
bonusHeaders | bonus-headers | {} | JSON-encoded extra request headers for the bonus-wallets fetch. Spread last, so they override the widget's auto-derived session headers. |
bonusBody | bonus-body | {} | JSON-encoded extra fields merged into the bonus-wallets request body. |
bonuses | - | [] | Host-supplied bonus rows. Read only when bonusMode is 'prop'; no matching attribute: set it as a JS property. |
Custom FAB icon
Swap the built-in betslip glyph for your own by passing a raw inline-SVG string. Use currentColor so the icon inherits the FAB's contrast colour:
js
el.triggerIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 4h12l-1.5 14.5a2 2 0 0 1-2 1.8H9.5a2 2 0 0 1-2-1.8L6 4z"/></svg>'Moving the FAB out of the way
The FAB is position: fixed and anchors to the viewport, so it can collide with your own bottom-anchored furniture: a chat dock, a cookie banner, a bottom nav. Two custom properties, set on the element, move it:
| Property | Default | Effect |
|---|---|---|
--goma-bf-bottom | calc(20px + env(safe-area-inset-bottom)) | Distance from the viewport bottom |
--goma-bf-side | 20px | Distance from the left/right edge (per fabPosition) |
Both defaults already honour the iPhone home-indicator safe area: keep env(safe-area-inset-bottom) in your own value if you replace it:
html
<!-- lift the FAB above a 64px-tall docked chat bar -->
<goma-betslip-floating
style="--goma-bf-bottom: calc(76px + env(safe-area-inset-bottom))"
></goma-betslip-floating>jsx
// React
<goma-betslip-floating style={{ '--goma-bf-bottom': 'calc(76px + env(safe-area-inset-bottom))' }} />Set them conditionally if the obstruction only exists on some routes: there is no need to reserve the space where nothing overlaps.
Bottom-sheet vs centred modal
Below mobileBreakpoint the panel renders as a bottom-sheet: full-width, anchored to the viewport bottom, rounded only at the top, with a small drag-handle pill above the header (purely visual, the sheet isn't draggable yet) and a slide-up entry/exit. At or above the breakpoint, the panel is a centred modal capped at 460 px / 85 dvh with a scale + fade entry/exit. The transition is live: resizing across the breakpoint while open swaps the layout without re-mounting.
Panel height
By default the panel is 85 dvh tall on desktop and 88 dvh on the mobile bottom sheet. The height is fixed, not a maximum, so the panel does not resize when the user switches between the Betslip and My Bets tabs. The panel content scrolls inside it.
Set panelHeight to choose the height for the page the widget sits on, for example when a fixed host banner or footer takes part of a short screen. The value applies to both layouts:
html
<!-- 80% of the viewport height -->
<goma-betslip-floating panel-height="80%"></goma-betslip-floating>
<!-- 600 px -->
<goma-betslip-floating panel-height="600px"></goma-betslip-floating>The panel is never taller than the viewport, so a pixel value larger than the screen is capped and the tabs and Close stay on screen. The host owns this value. On a short screen a System bet shows its combination list, its summary and the stake row under the selections, and a panel that is too short leaves little room for the selections. Pick a height that leaves the selections visible on the screens the page targets.
Appearance (styling object)
appearance carries structure only: sizes, spacing and border widths of the stake row, the header tabs, the bet-type switch, the selections list and the footer. Each key is a single value (identical in light and dark) that maps to one --goma-* CSS custom property written on the host. Set it via el.appearance = {...} or the appearance attribute (a JSON string). Unset keys leave the default untouched, so an empty {} (the default) reproduces the current geometry exactly. Colours stay in theme.
| Key | CSS variable | Default | Affects |
|---|---|---|---|
stakeRowHeight | --goma-stakeRowHeight | 2.5rem (40px) | Height of the footer stake input and the quick-add buttons (Multiple / System). |
stakeRowGap | --goma-stakeRowGap | 10px | Gap between the stake input and the quick-add buttons. |
quickAddButtonMinWidth | --goma-quickAddButtonMinWidth | auto | Minimum width of each quick-add button. It is only a minimum: the button still grows with its label. |
quickAddButtonPaddingX | --goma-quickAddButtonPaddingX | 11px | Side padding of each quick-add button. |
quickAddFontSize | --goma-quickAddFontSize | 1rem (16px) | Quick-add button label size (the line height stays 1.5rem). |
stakeInputBorderWidth | --goma-stakeInputBorderWidth | 1px | Border width of every stake input (footer and single-tab). |
panelTabPaddingY | --goma-panelTabPaddingY | 5px | Top and bottom padding of the Betslip / My bets tab labels. |
panelTabLineHeight | --goma-panelTabLineHeight | 1.5rem (24px) | Line height of the Betslip / My bets tab labels. With the 4px gap and 6px underline, the tab row is 2 × panelTabPaddingY + panelTabLineHeight + 10px tall (44px by default). |
betTypeSwitchHeight | --goma-betTypeSwitchHeight | 31px | Height of each Single / Multiple / System pill. |
betTypeSwitchPadding | --goma-betTypeSwitchPadding | 2px | Padding of the switch track around the pills. |
betTypeSwitchInsetX | --goma-betTypeSwitchInsetX | 1rem (16px) | Distance from the switch to the left and right panel edges. |
selectionsPaddingTop | --goma-selectionsPaddingTop | 0.75rem (12px) | Space between the header and the first selection. |
footerPaddingX | --goma-footerPaddingX | 1rem (16px) | Left and right padding of the footer (stake row, summary, place-bet button). The footer stake input is panel width − 2 × footerPaddingX − quick-add buttons − gaps wide. |
The stake inputs' fill and border colour read --goma-stakeInputBackground / --goma-stakeInputBorder (fall back to --goma-backgroundPrimary / --goma-separatorLine), the inactive panel-tab labels read --goma-betslipTabText (falls back to --goma-textSecondary), and the settings radios read --goma-betslipRadioBorder (falls back to --goma-textSecondary at 60%) and --goma-betslipRadioDot (falls back to --goma-textPrimary). All five are optional theme tokens: unset, the default look is unchanged, except for the ring fix below.
One change to the default look, a bug fix: the unselected radio rings (settings and bonus picker) used border-textSecondary/60, which never compiled (opacity on a var() colour), so they rendered in Tailwind's #e5e7eb on every host. They now read --goma-betslipRadioBorder, whose fallback is the intended textSecondary at 60%.
Behavior
Open / close
- Tapping the FAB sets
el.open = trueand opens the modal. - A successful Place Bet (api or event mode) does not auto-close the modal: the receipt renders inline at the top of the panel and the user dismisses the modal explicitly. Hosts that want auto-close can listen for
goma:bet-placedand callel.open = false. - Tapping the Close button, the backdrop, or pressing Escape closes the modal.
- Hosts can drive open/close programmatically:
el.open = true/el.open = false. Each transition emitsgoma:dialog-open/goma:dialog-close.
Remote control
Beyond the el.open property, the widget listens on window for three composed remote-control events, so any control on the page (a header cart button, a nav link, another widget) can drive the betslip without holding an element reference:
| Event | detail | Effect |
|---|---|---|
goma:betslip-open | { target? } | Opens the modal. When target is given (and reachable), also selects that tab. |
goma:betslip-close | - | Closes the modal. |
goma:betslip-toggle | { target? } | Flips the modal open/closed. When opening with a target, also selects that tab. |
target picks the panel tab and is normalised case/whitespace/dash-insensitively: 'betslip' / 'BetSlip' / 'slip' map to the Betslip tab; 'myBets' / 'My Bets' / 'my-bets' / 'bets' map to the My bets tab. Requesting myBets is ignored while that tab isn't visible (see myBetsTab), so the event can never strand the panel on a tab with no way back. Every mounted betslip on the page reacts (there's no per-instance targeting yet).
js
// Open the slip straight to My bets from a header button:
document.querySelector('#my-bets-link').addEventListener('click', () => {
window.dispatchEvent(new CustomEvent('goma:betslip-open', { detail: { target: 'myBets' } }))
})Auto-open on selection
Set openOnSelect (attribute open-on-select) and the dialog opens on the Betslip tab when the user adds a bet:
html
<goma-events-horizontal></goma-events-horizontal>
<goma-suggested-bet></goma-suggested-bet>
<goma-betslip-floating open-on-select></goma-betslip-floating>Two triggers, both composed window events the betslip listens for:
- "Bet the same" (suggested-bet →
goma:bet-the-same) opens the dialog on any viewport: it's an explicit "show me this bet" action. - An odds tap (any odds widget →
goma:outcome-select, add-only) opens the dialog only on a desktop viewport (width at/abovemobileBreakpoint, default 640px). On mobile, tapping odds doesn't pop the dialog: you keep browsing and open it from the FAB when ready.
The Betslip tab is selected on every viewport, including mobile where the dialog stays shut. The active tab is sticky state that survives the dialog closing, so without this a user who had been reading My Bets would reopen the slip onto that tab with the pick they just added hidden behind it.
Both the prop and the viewport are checked live, so runtime flips and resizes take effect immediately.
Selection count badge
The FAB renders a circular badge with the current selection count from shared storage. Counts above 99 render as 99+. The badge hides at zero.
Stake input
Every stake field (the footer field in Multiple / System, and the field on each leg in Single) reads the typed text itself, not through the browser's number parsing, so the same keys give the same stake on iOS, Android and desktop.
| Input | Field shows | Stake |
|---|---|---|
2,50 or 2.50 | 2.50 | 2.5 |
2. (still typing) | 2. | 2 |
2.555 | 2.55 | 2.55 |
A pasted grouped amount, 1,000.50 or 1.000,50 | 1.00 | 1 |
- The field accepts digits and one separator (
,or.), keeps at most 2 decimals and 12 characters, and always shows a dot. - In a pasted grouped amount the first separator is the decimal point, so an amount is pasted without a thousands separator.
stakeingoma:stake-changeandgoma:selection-stake-change, in the shared betslip storage and in the place-betamountis always a number rounded to 2 decimals.
Single vs Multiple
Same semantics as <goma-betslip-sidebar>:
- Multiple: one combo bet. Total Odds and Winnings come from the provider's
bettingOptionsV2answer, not a product of the leg odds. A leg's price or availability change asks for a new answer (debounced); every other change to the slip asks too. With no answer yet, a failed call, or no socket, both figures show the empty placeholder, and there is no local calculation to fall back on. After a stake edit, Winnings shows the empty placeholder until the answer for the new stake arrives, so it never shows a figure priced for the previous stake; Total Odds stays, because the price does not depend on the stake. The sharedstakefield is the stake sent with each call. It belongs to the slip it was typed for: emptying the slip leg by leg (the row's X, single-mode placement removing each placed leg, or a peer widget deselecting the last card) zeroes it, on any page and whether or not a betslip widget is mounted at that moment, and an envelope holding a stake but no selections is hydrated withstake: 0. Dropping to one leg keeps it: the Multiple stake returns with the next leg. - Single: N independent bets. Each selection is priced on its own call, with its own stake, and its row shows that selection's own Winnings from the provider's answer; quick-add chips (
+10/+20/+50, configurable viaquickAddAmounts) increment the per-selection stake. The summary shows the sum of those Winnings only once every selection has an answer; a selection still waiting shows the empty placeholder for the total instead of a partial sum. After a selection's stake changes, its Winnings (and so the total) shows the placeholder until the answer for the new stake arrives. The Single tab shows no Total Odds figure. The slip-levelstakeis unused in single mode.
The bet type is gated on the selection count: a multiple needs at least two legs. With 0 or 1 selection the slip is a Single and the Multiple and System pills are disabled. From 2 selections both pills go live, though System still needs a third leg to produce any combinations: see System bets below.
Crossing back up to 2+ selections returns to Multiple, discarding any earlier Single choice. This is a crossing rule, not a preference: a choice made while the slip is already combinable survives further adds, but a slip that drops to one leg (or is emptied) and grows again comes back as a Multiple. A slip restored from storage starts from the default too, so a Single choice never outlives the slip that produced it. The choice is shared between betslip widgets on the same page, so a pill tapped in one is reflected in the other.
System bets
The System pill covers every N-fold combination of the slip's legs: Trebles (3/5) over five selections is ten separate 3-leg bets. A system needs at least three selections (MIN_SELECTIONS in useBetslipSystem.js): below that the provider has nothing to offer, so the widget never calls the catalog RPC (betting.getSystemBetTypes) and the tab shows the system_min_selection_total_error message ("Select at least 3 bets.") instead of any pills.
At three or more legs the widget fetches the provider's system-type catalog for the current leg set (debounced) and renders each type as a pill (e.g. "3/5"); tapping one selects it and triggers a fresh quote (betting.getBettingOptions typed SYSTEM). Until that answer arrives, and after a stake edit until the answer for the new stake arrives, Winnings shows the empty placeholder.
The stake you type is per combination. A stake of 10 on a 10-combination system spends 100. The panel states this explicitly via the Combinations / Amount for combination / Total amount summary, because the wire format does not: the place-bet payload's amount carries the per-combination figure and the provider derives the total itself.
goma:place-bet-start reports betCount: 1 for a system (one request, one result) alongside combinations and systemBetType, and a totalStake of stake × combinations, which is what actually leaves the player's wallet (the other two bet types report the slip-level amount directly).
When the slip has 3+ legs but the provider's catalog comes back empty (or the call fails), goma:place-bet-blocked fires with reason: 'no_system_type' if the player presses Place Bet anyway.
The system-type catalog is per-widget local (never written to the shared envelope); only the chosen systemBetTypeId is synced: see Data sources above.
Bet Builder (same-game combos)
In Multiple mode, when the slip holds two or more selections from the same match they collapse into a single Bet Builder card instead of separate rows: a purple hammer icon + Betbuilder label, the fixture (Home x Away), then one row per leg (a purple dot with a dashed connector between consecutive legs, the pick name, and its market), a per-leg remove ×, and a remove-group × in the header. Removing a leg that drops the group back to a single selection reverts it to a normal selection card automatically.
Grouping is driven by the provider's bettingOptionsV2 round-trip (already wired via useBetslipBettingOptions): the combined price shows as an odds chip in the card header (a dash placeholder until the server prices the correlated combo) and also feeds the footer totals and the place-bet payload (betBuilderPriceValue per leg). If the provider flags any leg as non-combinable, the panel renders no banner of its own: Place bet stays blocked until the offending leg is removed, and the widget reports the rejection to the host via goma:combination-rejected (see Events) so the host can render its own message. Same-game combos are grouped only in Multiple mode: Single mode keeps each leg as its own ticket.
An optional per-leg cashback badge renders only when the host marks a selection as cashback-eligible (sets cashbackImage on the selection); it is hidden by default.
Header chrome
The panel header contains: a settings cog (left, opens a popover with the Accept odds change toggle, visible on both tabs), a clear-all trash icon (right, alongside the dialog ✕ close, betslip tab only), an underline Betslip / My bets tab strip (the My bets tab's visibility follows the myBetsTab prop, by default it renders once sessionId is set), and a segmented pill row for the bet-type tabs (Single / Multiple / System, System follows the same 2-selection gate as Multiple; see System bets). The trash icon and bet-type pill row collapse on the My bets tab so its sub-pills (Open / Closed / Won) own the header below the tab strip; the settings cog stays in place so the odds-change preference is reachable from either tab.
On mobile (below mobileBreakpoint, where the panel is a bottom-sheet dialog) the settings cog and the ✕ close icon are hidden, and a flat text Close link in the highlightPrimary colour is shown top-right instead. The desktop modal and the sidebar variant keep the icon controls.
Live odds tick into the panel via useBetslipOddsSync; the displayed odd truncates rather than rounds (1.8695 → "1.86", never "1.87") via the shared formatDisplayOdds() helper, matching the cards.
Selection availability
A leg is placeable only once an authority has confirmed it. Two can:
- the widget's own live offers feed (
useBetslipSelectionsSubscription→updateBettingOffer), and - the provider's
bettingOptionsV2round-trip (useBetslipBettingOptions), whoserejectedSelectionsis the definitive "this can no longer be bet".
The place-bet gate is a whitelist: every leg must be explicitly available. A leg nobody has ruled on yet is blocked, not allowed.
That matters because a finished event is invisible to the live feed. When an event ends the provider deletes its offers, and a delete removes the offer from the store without touching the selection that referenced it, so no tick ever arrives to invalidate the leg. Treating "no news" as "still good" is what let a finished match stay bettable in the slip. The round-trip is the only thing that surfaces it, so the widget re-validates when:
- the selection set changes,
- the stake changes (mandatory before placing, so every route to the button re-checks),
- any leg's availability changes,
- the socket reconnects,
- and every 30 s while the slip is non-empty: the case where the user adds a leg and simply waits.
Deployments with no authority. With neither a socket nor getBettingOptions (mock data, an event-mode host using its own backend, the playground), nothing can ever confirm a leg, so the widget releases unconfirmed legs rather than blocking the button forever. An explicit "no" from a feed is never overridden this way.
Bonus mechanism
The player can apply one bonus from their bonus wallets to a bet. The feature is off by default.
Turning it on
| Setting | Effect |
|---|---|
bonusEnabled not set | No bonus control. The widget renders exactly as before. |
bonusEnabled + bonusMode: 'api' (default) | The control shows once a sessionId is set. A signed-out visitor has no bonus wallets, so there is nothing to offer. |
bonusEnabled + bonusMode: 'prop' | The control shows at once. Bonuses come from the host's bonuses property, so no session is needed. |
How the player picks a bonus
- Tap the gift icon. A dialog lists the bonuses available for the current slip, plus a None row.
- Tap a row (or None). This only stages the choice.
- Tap Apply to commit it.
- Apply stays disabled until the staged choice differs from the applied one. Tapping the row that is already applied, or None when nothing is applied, does nothing.
- At most one bonus applies at a time.
Where the control sits
| Mode | Control | Scope |
|---|---|---|
| Multiple, System | One gift icon in the panel header, next to the settings cog | The whole slip |
| Single | A gift icon on each selection row | That selection only. A bonus applied to one selection shows as "Already selected" (disabled) on the others, because a bonus is used once. |
After a pick:
- A bonus that replaces the stake (today only a free bet) swaps the stake row for a chip. The chip's × removes the bonus.
- Every other type leaves the stake row alone. The gift icon stays and shows an active ring. Open it again to change or clear the pick. Odds boost and stakeback show their own surfaces instead, see below.
- A cashback pick keeps the stake. On a Single leg a chip sits above the stake row, which stays editable, and its × removes the pick. In Multiple and System mode a Cashback row shows instead, see below.
- Switching from a stake-replacing bonus to any other pick clears the amount the free bet wrote into the stake row first, the same as removing it outright would. The player types a stake of their own for the new pick; it is never placed with the free bet's leftover amount as real cash.
- A free bet shows its winnings without the stake. The provider quote counts the stake as part of the winnings, but a free bet's stake is not given back. A Single selection carrying a free bet shows its Return as the quote minus that selection's stake, a Multiple slip carrying one shows the same in the summary, and the Single summary adds up the net and gross legs. A System slip shows the quote as given. The pick stays free-bet flagged on the request even if a refresh of the wallets response leaves the wallet out of that selection's list, so its stake is never placed as cash.
Where the list comes from
bonusMode: 'api' (default): the widget fetches the player's eligible bonus wallets itself.
- The request is scoped to the current selections, stake and combined odds. It is sent again (debounced) when any of these change.
- For a Multiple, the combined odds are the provider's
bettingOptionsV2price for exactly those selections at their current prices. The request waits until that price has landed and is sent again when it changes. No price is multiplied on the client. - It uses the same session headers as place-bet:
X-SessionId,X-SessionToken,X-OperatorId.
| Property | Default | Purpose |
|---|---|---|
bonusEndpoint | /v1/bonus/wallets/sports | Sent as a PUT. A path resolves against config.playerApiBaseUrl, then config.apiBaseUrl. |
bonusHeaders | none | Extra request headers |
bonusBody | none | Extra request body fields |
TIP
Bonus wallets belong to the player, not to the betting feed. If your deployment serves them from a different host, set playerApiBaseUrl to that host.
bonusMode: 'prop': no request. The widget reads the list from the host's bonuses property. Use it for offline demos and tests.
If a bonus stops being eligible
When a refetch drops the applied bonus from the list, the widget:
- removes it from the slip,
- shows the player an inline notice,
- emits
goma:bonus-unavailableto the host (see Events).
Sent with the bet
The applied bonus travels on the place-bet payload as ubsWalletId: per selection in Single mode, for the whole slip otherwise. See Payload precedence.
Odds boost
A bonus of type oddsBoost adds a percentage of the potential winnings. The percentage grows in tiers with the number of qualifying selections.
When the player applies one, the slip shows two extra surfaces:
- a boosted winnings row under the base figure in the summary,
- a progression indicator: the tier now paying, and how many more qualifying selections the next tier needs. If the bonus has a minimum price per selection, the indicator shows it, for example "(Min 1.30)". The minimum is hidden at the top tier.
When they show:
- Only after the boost is applied. A slip that only qualifies shows neither: no empty box, no zero row.
- Not in Single mode, which has no slip-level bonus. A boost applied in Multiple or System comes back when the player switches back.
On a Single bet: the gift icon on a selection row lists an odds boost when the wallets response for that selection returns one and the boost has reached a stair, so it has a rate and a cap to carry. A boost that has not reached its first stair is not listed on a single selection, because the bet would go out without its terms and the provider refuses it. (The Multiple and System slip still lists it, with the progression indicator.) With host-fed wallets (bonusMode: 'prop') the same rule applies, together with the eligibleEventID check against that selection's event. Picking or dropping the boost leaves the stake the player typed untouched, because a boost raises the winnings of the player's own stake. The bet for that selection carries the wallet id and the boost terms (oddsBoostPercent, capAmount), read from that selection's own response and never from the slip's. The row shows no boosted-winnings figure or progression indicator: the Winnings figure is the provider's unboosted quote, and the provider applies the boost when the bet settles. A stake-back wallet is not offered on a single selection.
The figure: min(potential winnings × tier percentage, tier cap). The cap is per currency, read in the bonus wallet's currency. The figure is for information only. The bet carries the wallet id, and the betting provider applies the boost when the bet settles.
Eligible events: an odds boost can name the events it applies to. The wallets response only names events for the legs that were sent with the request, so a leg the player adds is checked when the response for the new slip arrives, not before. Until then the boost stays applied. With host-fed wallets (bonusMode: 'prop') the check runs at once. If a selection outside those events joins the slip:
- the boost is removed from the slip,
- the player sees the inline notice, and the host receives
goma:bonus-unavailable, - the bet carries no boost.
Removing that selection does not apply the boost again. The player picks it again in the bonus selector.
Stakeback
A bonus of type stakeBack returns a share of the stake when the bet loses. The share grows in tiers with the number of selections.
When the player applies one, the slip shows:
- a progression indicator with the tier now paying, or how many selections activate it,
- a Stakeback row under the winnings, with the refund and the condition, for example "Paid if exactly 1 selection loses". The row has its own × to remove the bonus.
| Topic | Behaviour |
|---|---|
| Shown | Only once the bonus is applied. Not in Single mode, and not offered on single selections. |
| Figure | min(stake × tier %, tier cap), cap in the wallet's currency. A tier with no cap for that currency is not capped. In System mode the stake is the total of all combinations. For information only: the provider pays the refund at settlement. |
| Condition | From the tier's lost-selection range (minLostSelectionNumber, maxLostSelectionNumber). A bet that loses more selections than the maximum gets nothing. |
| Winnings | Unchanged. The refund is never added to the winnings figure. |
| Sent with the bet | ubsWalletId only |
Cashback
A bonus of type cashBack tracks the player's net losses. At fixed checkpoints it pays out, and that payout can be a free bet (shown in the bonus selector like any other free bet), casino free spins, or a percentage. The cashback wallet itself has no funds of its own.
Not yet verified against a live cashback wallet
The cashback handling below is built on a cashback bonus recorded on a staging environment. It has not yet been checked against a live cashback wallet in the betslip.
| Topic | Behaviour |
|---|---|
| Stake | The player's own stake. Picking cashback never changes it. |
| Amount | Never shown. The wallet's amount field is not money the player can spend; the loss counter is the separate remainingAmount field. |
| Name fallback | A wallet with no presentation name shows the translated Cashback label instead of the raw type key. |
| Multiple / System | A Cashback row under the winnings shows the label, the wallet name (only when it differs from the label), and "Your net losses count towards your cashback rewards." Its × removes the pick. No shared chip. |
| Single | A chip on the leg, above its stake input, shows the same label plus the name when it differs, and the explanation line underneath. Its × removes the pick and leaves the stake untouched. |
| Sent with the bet | ubsWalletId only |
Events
The widget renders no result messages
The betslip does not display success confirmations, provider error text, or combination warnings. It reports what happened and the host decides how to say it. A host that listens to none of the events below shows the player nothing when a bet fails: the slip simply stays put.
Two things are still shown in-widget, because they are attached to a specific row rather than being a message: a selection the provider has marked unavailable is dimmed and struck through with an inline notice, and the Place Bet button renders visibly disabled while the gate refuses.
| Status | Event | Detail | Emitted when |
|---|---|---|---|
| Canonical | ready | {} | Widget mounted, initial render done. |
| Canonical | goma:dialog-open | {} | state.open transitioned false → true. |
| Canonical | goma:dialog-close | {} | state.open transitioned true → false. |
| Canonical | goma:outcome-deselect | { bettingOfferId, outcomeId } | User removed a selection from the panel via the ✕ button. bettingOfferId is null (the panel keys its rows by outcome id) so dedupe on outcomeId. Pairs with goma:outcome-select from <goma-events-horizontal>. |
| Canonical | goma:bet-removed | { outcomeId } | User removed a selection from the panel. Same trigger as goma:outcome-deselect, dispatched in parallel for hosts that only listen on the betslip surface. |
| Canonical | goma:betslip-cleared | {} | User cleared the entire slip via the "Clear" action. |
| Canonical | goma:stake-change | { stake } | User changed the multiple-mode stake input (footer). Storage-driven sync from a peer betslip widget does not re-emit. stake is a number rounded to 2 decimals. |
| Canonical | goma:selection-stake-change | { outcomeId, stake } | User changed a per-selection stake (single-bet mode): typing in the input or clicking a quick-add chip. Storage-driven sync from a peer betslip widget does not re-emit. stake is a number rounded to 2 decimals. |
| Canonical | goma:bonus-select | { walletId, type, name, amount, currency, selectionId, previousWalletId } | User applied a bonus: staged a bonus row in the dialog and hit Apply while that row differed from the current pick. selectionId is null on the slip-level pick (multiple/system); it's the selection's outcomeId in single mode. previousWalletId is the wallet this pick replaced, or null on a first pick. amount is null for types whose wallet amount is not money (stakeback, cashback) or when none is set. Only fires while bonusEnabled is set. |
| Canonical | goma:bonus-remove | { walletId, type, name, amount, currency, selectionId } | User cleared an applied bonus: tapping a chip's ×, the odds boost row's ×, or the cashback row's ×, or staging the None row and hitting Apply while a bonus was previously applied. Not fired by re-picking the already-applied row, or by picking None with nothing applied, since both leave Apply disabled and the staged pick never changes. |
| Canonical | goma:bonus-unavailable | { walletId, type, name, amount, currency, selectionId, reason: 'ineligible' } | The applied bonus stopped being eligible and was dropped automatically. This happens on a refetch (odds moved, a leg changed, the wallet expired), and also, with host-fed wallets (bonusMode: 'prop'), with no refetch when a selection outside an odds boost's eligible events joins the slip. With the wallets API, a newly added leg is checked when the response for the new slip arrives. The player sees an inline notice; this is the host-facing signal of the same drop. |
| Canonical | goma:place-bet-request | { batchId, uniqueId, type, amount, oddsValidationType, terminalType, freeBet, selections[] } | Fires before the place-bet transport runs. In 'event' mode this is the host's integration hook; in 'api' mode it's an observability tap for telemetry. Single-bet mode emits one event per staked selection (each with type: 'SINGLE', a one-element selections[] and that leg's own amount); multiple mode emits one event for the whole slip. |
| Canonical | goma:bet-placed | { batchId, success, betId, type, amount, odds, possibleWinnings, selections[], placedAt, raw? } | Bet placed successfully (api response or event-mode mock). The slip is cleared; the widget shows no confirmation of its own. One event per placed leg in single-bet mode; each placed leg is removed from the slip as it succeeds. Multiple mode emits once and clears the whole slip. For a system bet amount is the stake PER COMBINATION, not the total. It mirrors the figure sent on the wire, which is what the provider expects and multiplies out itself. A host rendering "you staked X" off this receipt must multiply by the combination count (combinations on goma:place-bet-start, whose totalStake already carries that total). possibleWinnings is number | null: it is the provider's figure for the bet, and it is null when the provider had not priced the bet yet when it was placed (no answer, a failed call, or no socket). For a free bet it is the gross figure, stake included, as the provider quotes it; the betslip itself shows the net figure. |
| Canonical | goma:bet-failed | { batchId, message, payload, status?, body?, unavailableSelections?: string[], movedLineSelections?: string[] } | Place-bet failed (HTTP error, provider success: false, or thrown). Slip preserved so the user can retry. unavailableSelections names any leg the widget already knows is no longer bettable, so a host can tell the user which row to remove: it is carried on the multiple-mode emission only. movedLineSelections names the legs blocked for a different reason: the market's line moved after the leg was tapped (a live handicap redrawn from 12.5 to 10.5), so the match is still on but the bet the row names no longer exists; word those two cases differently to the player. In single-bet mode one event per failed leg: legs are settled independently, so a partial failure leaves exactly the failed legs in the slip, and each event carries only that leg's payload. |
| Canonical | goma:place-bet-start | { batchId, betType, betCount, selectionCount, totalStake, combinations?, systemBetType? } | Fires once per press of Place Bet, before any transport runs. betCount is how many goma:bet-placed / goma:bet-failed events will follow: single mode submits one bet per staked leg, so it can be lower than selectionCount; a system bet is one request producing one result, so betCount is 1 regardless of how many combinations it covers, which means the batch is complete when placed + failed === betCount. combinations and systemBetType are only present for a system bet, and totalStake is stake × combinations in that case: the actual amount leaving the player's wallet. Correlate the whole submission via batchId, stamped on goma:place-bet-request, goma:bet-placed and goma:bet-failed. |
| Canonical | goma:place-bet-blocked | { reason: 'no_session' | 'session_pending' | 'no_system_type' | 'unavailable' | 'incompatible' | 'bonus_pending' | 'no_stake' } | The user pressed Place Bet while the gate refused. Fires on the press, not when the reason changes, so repeated presses emit repeatedly; debounce host-side if you surface it as a toast. The widget renders no explanation of its own, so this is how a host tells the player why the button did nothing. Reasons are listed in the order they take priority. bonus_pending means an odds boost is applied and a selection was added after the bonus wallets were last fetched: the boost's terms have not been checked for the new selection yet. It clears by itself when the wallets for the new slip arrive, normally within a second; if they cannot be fetched, the boost is removed with the usual notice and the bet can be placed without it. session_pending means the player IS signed in but the session exchange hasn't minted a placeable identity yet (it lands seconds after the host sets sessionId): show "one moment", never a sign-in prompt. It is bounded: 20s after sign-in the gate stands down and lets the submit through rather than blocking forever, and logs a warning naming the config it was waiting on (ucsOperatorId, gameLaunchBaseUrl, socketUrl). The stand-down itself is announced on goma:session-unverified (below), so a host does not have to read the console to notice it. A host that sets placeBetHeaders carries its own credentials and is never gated on this. |
| Canonical | goma:session-unverified | { reason: 'ce-exchange-incomplete', graceMs: number } | The session exchange never minted a placeable identity within graceMs of sign-in, so the place-bet gate has stood down. From here on every bet is submitted without X-SessionToken and the provider answers 401, which looks like a rejected bet unless the host knows better, so this is the signal that tells it apart. Distinct from goma:session-expired: that one means a session lived and then died; this one means one never became usable, so show "we couldn't verify your session" and offer a re-login rather than an expiry notice. Fires once per stand-down. Only in api mode with requireSession set, and never for a host that supplies its own placeBetHeaders (those count as a placeable identity). |
| Canonical | goma:combination-rejected | { message, errorCode, selections: [{ outcomeId, eventId, name, outcomeName, marketName }] } | The provider's quote refused to combine the slip. Two flavours: an explicit refusal carries the provider's message and errorCode (e.g. cr100 "Sports cannot be combined"); a Bet Builder or forbidden-combination refusal on an otherwise successful quote carries message: null and errorCode: null, because that refusal exists only as per-leg combinability. Prefer errorCode for localisation: message arrives in the provider's own language. Legs of one Bet Builder group share a fixture, so use outcomeName / marketName rather than name to tell them apart. Fires on entering the rejected state only; a repeat quote with the same verdict does not re-emit. |
| Canonical | goma:combination-resolved | {} | The slip left the rejected state: a later quote succeeded, the offending leg was removed, the slip was emptied, or the user switched to single mode. Pairs with goma:combination-rejected so a host can dismiss its own banner. |
| Canonical | goma:session-ready | { userId, username } | The session finished resolving into an identity that can actually place a bet: the CE-login exchange completed and the payload now carries the provider-side username. You only know when you ISSUED a sessionId; this is seconds later. A host that reveals its betting UI on its own login response hands the player a Place Bet button whose first submission is guaranteed to fail. The shared goma:session:v1 envelope cannot be watched from the same tab (storage events fire only in other tabs), so this event is the only same-tab signal. Fires once per resolved token per mounted session-aware widget, and again whenever a new token is minted (max-age refresh, WAMP reconnect): handlers must be idempotent. |
| Canonical | goma:session-expired | { reason, invalidationId } | The player's session is no longer usable and only the host can fix it: GomaPulse cannot mint a replacement token. Fires when the provider rejects the credential (place-bet 401/403, favourites 401/403, the session probe, the CE-login exchange) and when the balance SSE stream reports SESSION_EXPIRATION / SESSION_EXPIRATION_V2. Host listens to flush its own session state and re-authenticate: see Session expiry. Fires once per invalidation per mounted session-aware widget, and again on a retry: dedupe on detail.invalidationId, which is identical across widgets for one death and higher on a retry. |
| Canonical | goma:retry | {} | User clicked Retry on the error-boundary banner. |
| Canonical | goma:error | { message, code, component? } | Two paths. Connection layer: the WAMP adapter (used for live odds sync) 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 bubble + composed, so they cross the Shadow DOM boundary and are observable from window.addEventListener(…).
The widget also listens for goma:outcome-select on window (composed events from any source bubble up). A host that emits goma:outcome-select directly (without <goma-events-horizontal> on the page) populates the betslip the same way. betslip.addSelection dedups on outcomeId, so events from events-horizontal aren't double-counted.
The same rule applies to a bet tailed out of either chat widget: see goma-messenger.
Include bettingTypeId when you emit it yourself
This listener builds the whole selection from the event detail. It runs synchronously, before any shared-storage snapshot from a sibling widget can arrive. A detail without bettingTypeId therefore reaches place-bet without one, and the provider rejects the slip ("Invalid or missing Betting Type Id on placed bet"). Emit the full documented detail: bettingOfferId, outcomeId, outcomeName, odds, eventId, marketId, bettingTypeId, eventPartId. Selections that also arrive over goma:betslip:v4 are repaired automatically: the inbound sync backfills identity fields the local copy is missing (absent, undefined and null all count as missing). When nothing can repair the row (a host driving the slip from its own UI) the widget logs a console.warn naming the outcome, once per outcome, so you find out before the provider's HTTP 409 does.
It additionally listens for the goma:betslip-open / goma:betslip-close / goma:betslip-toggle remote-control events: see Remote control above.
Integrates with
Listens for:
goma:outcome-select(any source, onwindow): populates the shared slip; see the warning above. The same event, add-only, also gates auto-open on selection viaopenOnSelect(defaultfalse), but only on a desktop viewport.goma:bet-the-same(typically from<goma-suggested-bet>): surfaces the dialog on the Betslip tab on any viewport, gated byopenOnSelect(defaultfalse).goma:betslip-open/goma:betslip-close/goma:betslip-toggle: remote-control events with no gating prop; every mounted betslip on the page reacts. See Remote control.
Commonly paired with <goma-events-horizontal> and <goma-hero-card> upstream (both emit goma:outcome-select), <goma-suggested-bet> upstream (goma:bet-the-same), and <goma-betslip-sidebar> as its sister widget: same shared-storage sync, same place-bet contract, so a page can run either or both without extra wiring. The My bets tab embeds a real <goma-my-bets> instance (see My Bets tab).
Shared state: reads and writes the full goma:betslip:v4 envelope: identity fields plus the slip-level state fields (stake, activeTab, oddsChangeBehavior, activeBetslipId, systemBetTypeId, selectedBonusWalletId, selectedBonusWalletIdBySelection) that <goma-events-horizontal> deliberately opts out of. The system-type catalog and the fetched bonus catalog are both derived data and never written to the envelope: only the player's picks cross the wire, so each widget fetches its own off the current selections (see System bets and Bonus mechanism). Also participates in the page-wide goma:session:v1 envelope once a sessionId resolves. See Cross-widget wiring and Shared state for the full contracts.
Empty / loading / error states
- Closed FAB: with zero selections the badge is hidden entirely; it reappears (and renders
99+above 99) the moment a selection lands. - Empty slip. The panel's Betslip tab renders
BetslipEmpty: a host-suppliedemptyStateImage(or a small emoji glyph when unset) above theadd_bet_to_startheading. - My bets empty states: owned by the embedded
<goma-my-bets>widget; see its docs for the guest-vs-signed-in split. - Placing: the Place Bet button's label switches to
loading_betslipwhileplacingBetis true; the button also renders visually disabled (aria-disabled,canPlaceBetfalse) per the reasons in Selection availability and Behaviour. - Render error: a caught Vue error renders an inline
role="alert"banner (message + Retry) at the top of the widget root; it self-clears after 8 seconds, or immediately when the user clicks Retry (emitsgoma:retry) or dismisses it. - Place-bet failure: the widget renders no message of its own; see the
goma:bet-failed/goma:place-bet-blocked/goma:combination-rejectedevents.
Theming
Override CSS variables via el.theme = { … }: the prefix --goma- is added automatically.
js
el.theme = {
// Footer surface (summary + place-bet button)
backgroundPrimary: '#1f2435',
// Panel header surface (cog/trash + tabs + bet-type pills)
backgroundSecondary: '#252a3e',
// Selections body surface (the scrollable selection rows)
backgroundBetslip: '#1c2030',
// Bet-type pill row background + per-selection odds chip + quick-add chip
backgroundTertiary: '#1b1d2b',
// Modal panel background: decoupled from backgroundPrimary so the host
// (FAB area) can be transparent while the modal stays opaque.
dialogBackground: '#03061b',
// Divider line (between selection header and body, between tab and pills)
separatorLine: '#3d425a',
// FAB background + active bet-type pill background + Place-bet button background
highlightPrimary: '#29f2ff',
highlightPrimaryContrast: '#062225',
// Underline under the active "Betslip" tab in the header
highlightSecondary: '#8750ff',
// Text + muted text + icon (cog / trash / × close icons)
textPrimary: '#ffffff',
textSecondary: '#8c919c',
iconSecondary: '#8691a8',
// Success receipt badge + error banner pills
alertSuccess: '#21ba45',
alertError: '#ed4f63',
}Tokens used by this widget
| Token | Element |
|---|---|
--goma-highlightPrimary | FAB background + active bet-type pill background + Place-bet button background (cyan in the design) |
--goma-highlightPrimaryContrast | FAB icon + label + Place-bet text on cyan; LIVE pill text in the My Bets tab |
--goma-liveTag | LIVE pill on a live leg in the My Bets tab |
--goma-highlightSecondary | Underline under the active Betslip header tab (violet in the design) |
--goma-dialogBackground | Modal panel background (decoupled from backgroundPrimary) |
--goma-backgroundPrimary | Footer surface (summary + place-bet) |
--goma-backgroundSecondary | Panel header surface (cog/trash + tabs + bet-type pills) |
--goma-backgroundBetslip | Selections body surface (the scrollable selection rows) |
--goma-backgroundTertiary | Bet-type pill row background, per-selection odds chip, quick-add chips |
--goma-separatorLine | Divider inside the selection card + underline of inactive header tabs |
--goma-textPrimary | Body text: outcome name, market name, place-bet label |
--goma-textSecondary | Secondary labels: match title, return / total-odds / possible-winnings labels |
--goma-iconSecondary | Settings cog, trash icon, close × on each selection row |
--goma-alertSuccess / --goma-alertError | Success receipt badge + error banner |
i18n / message overrides
| Key | Default (en) | Used in |
|---|---|---|
betslip | Betslip | Dialog accessible name, header tab label |
my_bets | My Bets | My bets header tab label (visibility follows the myBetsTab prop) |
betslip_settings | Betslip Settings | Settings cog aria-label + popover label |
settings | Settings | Mobile "Settings" text link (replaces the cog icon on the bottom-sheet) |
open_betslip | Open Betslip | FAB aria-label (with selection count appended) |
close | Close | Dialog close ✕ aria-label, the mobile "Close" text link, and the bonus notice's own dismiss × |
clear_betslip | Clear Betslip | Trash-icon aria-label |
delete_all | Delete all | Mobile "Delete all" text link (replaces the trash icon on the bottom-sheet) |
single / multiple / system | Single / Multiple / System | Bet-type pill labels |
system_min_selection_total_error | Select at least 3 bets. | System tab empty state: fewer than 3 selections, or the provider's catalog came back empty |
betslip_system_combinations | Combinations | System summary label: how many bets the chosen type covers |
betslip_system_amount_for_combination | Amount for combination | System summary label: the per-combination stake the player typed |
betslip_system_total_amount | Total amount | System summary label: stake × combinations, what actually leaves the wallet |
betbuilder | Betbuilder | Bet Builder card label |
total_odds | Total Odds | Summary label |
possible_winnings | Possible Winnings | Summary label |
stake | Stake | Stake input label (multiple-mode footer; per-selection in single mode) |
return_text | Return | Per-selection return-amount label (single-bet mode) |
accept_odds_any | Accept any change in odds | Settings popover: sends oddsValidationType: 'ACCEPT_ANY' |
accept_odds_higher | Approve higher odds | Settings popover: sends oddsValidationType: 'ACCEPT_HIGHER' (default) |
accept_odds_strict | Do not accept any changes in odds | Settings popover: sends oddsValidationType: 'WITHIN_THRESHOLD' (refuses any change when the account threshold is 0) |
place_bet | Place Bet | Primary action button |
loading_betslip | Loading betslip | Place-bet button while in flight |
add_bet_to_start | Add a bet to Start | Empty-state heading (also the emptyStateImageAlt fallback) |
odds_up / odds_down | Odds up / Odds down | aria-label on the price-change indicator icon next to a selection's odds |
selection_unavailable | Selection unavailable | Inline notice on a selection the provider marked unavailable |
market | Market | Fallback when a selection has no marketName |
remove | Remove | ✕ button aria-label per selection, and on the applied-bonus chip |
retry | Retry | Error banner button |
betslip_bonus_title | Your bonuses | Gift-icon aria-label, bonus dialog title |
betslip_bonus_choose | You can use {count} on this bet. | Bonus dialog subtitle, {count} interpolated from betslip_bonus_choose_count |
betslip_bonus_choose_count | 1 bonus | Interpolated into betslip_bonus_choose |
betslip_bonus_none | None | Explicit no-bonus radio row |
betslip_bonus_expires | Expires | Expiry-date label prefix on a bonus row |
betslip_bonus_already_selected | Already selected | Label on a bonus row already applied to another selection (single mode) |
betslip_bonus_already_selected_hint | This bonus is already applied to another bet on your slip. | Hint text under the above |
betslip_bonus_empty_body | You have no bonuses available for this bet right now. | Shown once the fetch has landed with zero eligible bonuses |
betslip_bonus_apply | Apply | Bonus dialog primary button |
betslip_bonus_selected | Selected | Applied-bonus chip label |
betslip_bonus_unavailable | Your selected bonus is no longer available for this bet. | Notice shown when the applied bonus is dropped as ineligible |
betslip_bonus_applied | Bonus applied | Confirmation notice on applying a bonus (suffixed with the bonus name, e.g. "Bonus applied: Free Bet"), auto-dismissed after 3s |
betslip_cashback_label | Cashback | Cashback row heading in Multiple/System mode, the Single-leg chip label, and the fallback name shown everywhere a bonus name is displayed for a cashback wallet with no presentation name |
betslip_cashback_explanation | Your net losses count towards your cashback rewards. | Cashback row body text in Multiple/System mode, and the line under the Single-leg chip |
betslip_cashback_remove | Remove cashback | Cashback row's remove-control aria-label in Multiple/System mode |
cancel | Cancel | Bonus dialog header: closes without applying the pending pick |
betslip_stakeback_refund | Stakeback | Stakeback row label; also the progression indicator's fallback title when no tier is named |
betslip_stakeback_activated | {percentage}% Stakeback Activated! | Progression indicator title once a tier is paying |
betslip_stakeback_pre_activation | {percentage}% Stakeback | Progression indicator title naming the next tier, before any tier is active |
betslip_stakeback_max_reached | Max stakeback reached | Progression indicator message at the top tier |
betslip_stakeback_add_selections | Add {count} more selection(s) for {percentage}% | Progression indicator message when a tier is active and more selections reach the next one |
betslip_stakeback_add_selections_pre | Add {count} selection(s) to activate | Progression indicator message when no tier is active yet |
betslip_stakeback_refund_exact_one | Paid if exactly 1 selection loses | Stakeback row condition when the tier's lost-selection range is exactly one |
betslip_stakeback_refund_exact | Paid if exactly {count} selections lose | Stakeback row condition when the tier's lost-selection range is an exact count above one |
betslip_stakeback_refund_range | Paid if {min} to {max} selections lose | Stakeback row condition when the tier's lost-selection range has both a minimum and a maximum |
betslip_stakeback_refund_up_to | Paid if up to {max} selections lose | Stakeback row condition when the tier's lost-selection range has only a maximum |
The My bets tab body is rendered by the embedded <goma-my-bets> widget, which carries its own my_bets_empty_guest_title / my_bets_empty_authed_title (and subtitle) keys: see <goma-my-bets> for those.
Override with config.messages:
js
el.config = {
/* … */
messages: {
en: { open_betslip: 'View bets' },
fr: { open_betslip: 'Voir les paris' },
pt: { open_betslip: 'Ver apostas' },
},
}Embedding examples
Vanilla HTML: event mode (consumer integrates own backend)
html
<goma-betslip-floating id="bf"></goma-betslip-floating>
<script type="module">
import '@gomagaming/betslip-floating'
const el = document.getElementById('bf')
el.placeBetMode = 'event'
el.addEventListener('goma:place-bet-request', async (e) => {
await fetch('/my/internal/bets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(e.detail),
})
})
el.addEventListener('goma:bet-placed', (e) => console.log('receipt', e.detail))
</script>Vanilla HTML: api mode (Everymatrix-shaped backend)
html
<goma-betslip-floating id="bf" place-bet-mode="api"></goma-betslip-floating>
<script type="module">
import '@gomagaming/betslip-floating'
const el = document.getElementById('bf')
el.config = { bettingApiBaseUrl: 'https://sports-api.example.com', locale: 'en' }
el.placeBetEndpoint = '/place-bet/2838/v2/bets'
el.sessionId = sessionId
el.placeBetHeaders = { 'X-OperatorId': '2838' }
el.placeBetBody = {
userId: '123',
username: 'alice',
currency: 'EUR',
ucsOperatorId: '2838',
lang: 'en',
}
el.addEventListener('goma:bet-placed', (e) => console.log('receipt', e.detail))
el.addEventListener('goma:bet-failed', (e) => console.error(e.detail.message))
</script>React
jsx
import '@gomagaming/betslip-floating'
import { useEffect, useRef } from 'react'
function FloatingBetslip() {
const ref = useRef(null)
useEffect(() => {
if (!ref.current) return
ref.current.placeBetMode = 'api'
ref.current.placeBetEndpoint = '/place-bet/2838/v2/bets'
ref.current.sessionId = sessionId
ref.current.placeBetBody = { userId: '123', username: 'alice', currency: 'EUR' }
ref.current.config = { bettingApiBaseUrl: 'https://sports-api.example.com' }
}, [])
return <goma-betslip-floating ref={ref} fab-position="bottom-right" />
}Vue 3
vue
<script setup>
import '@gomagaming/betslip-floating'
import { ref, onMounted } from 'vue'
const el = ref(null)
const props = defineProps(['session'])
onMounted(() => {
el.value.placeBetMode = 'api'
el.value.placeBetEndpoint = '/place-bet/2838/v2/bets'
el.value.sessionId = props.session.id
el.value.config = { bettingApiBaseUrl: 'https://sports-api.example.com' }
})
</script>
<template>
<goma-betslip-floating
ref="el"
fab-position="bottom-right"
@goma:bet-placed="(e) => console.log(e.detail)"
/>
</template>Accessibility
| Element | Role | Attributes |
|---|---|---|
| FAB | button | aria-haspopup="dialog", aria-expanded, aria-label (announces selection count) |
| Modal panel | dialog (native) | aria-labelledby → hidden title id |
| Tab strip | tablist | each tab has role="tab", aria-selected |
| Selection ✕ | button | aria-label="Remove" |
| Place Bet | button | aria-disabled="true" (not the native disabled attribute) when canPlaceBet is false: the button stays clickable so a press-while-blocked still fires goma:place-bet-blocked |
| Stake input | <input type="number"> | label via wrapping <label>, inputmode="decimal", font-size: 16px (iOS auto-zoom suppression) |
| Accept odds change | two <input type="radio"> (strict / higher) | label via wrapping <label> per option |
| Gift icon (bonus) | button | aria-pressed, aria-label (announces the eligible-bonus count when unapplied) |
| Bonus dialog | dialog (native) | Same recipe as the modal panel: aria-labelledby → hidden title id |
| Bonus row list | radiogroup | each row role="radio", aria-checked; a row already applied to another selection also carries aria-disabled + disabled |
| Bonus chip ✕ | button | aria-label="Remove" |
Keyboard: Tab cycles focus inside the modal; Shift+Tab cycles backward; Escape closes; Enter/Space activates the focused control. Focus trap is browser-managed (native <dialog> opened via showModal() traps Tab automatically). The bonus dialog is a second, independently-opened instance of the same native-<dialog> recipe, so it traps focus the same way.
iOS Safari notes
The widget reuses the same iOS-safe recipe as <goma-sports-navigation-dialog>:
| Symptom on iOS | Solution |
|---|---|
| Inputs under 16 px trigger an auto-zoom | Stake input has inline font-size: 16px. |
Native <dialog> focus-trap leaks across the shadow boundary | showModal() puts the dialog into the browser's top layer: the trap is browser-managed and works because the dialog tree is no longer anchored to the host's stacking context. |
showModal() doesn't scroll-lock the page on iOS | useScrollLock from @gomagaming/core applied on every open. |
| Shadow-rooted z-index doesn't always cover sibling widgets | Native <dialog> top layer escapes every ancestor stacking context. |
| Layout flex/grid quirks on the dialog/panel root | The dialog wrapper uses display: grid; place-items: center to stage the panel; the visible panel itself uses flex flex-col. |
What's deferred
The bonus mechanism is type-agnostic by design, but only four bonus types ship with dedicated behaviour today: a free bet, whose amount replaces the stake it's applied to; odds boost and stakeback, each with its own progression indicator and summary row; and cashback, shown by name only (never as money) with its own summary row. Any other type a provider returns (a stair-based bonus outside those two shapes, …) renders generically (listed, selectable, sent as ubsWalletId) until it registers its own type-specific handling (a different stake behaviour, an automatic first-eligible default, its own summary row). Nothing in that gap blocks using the feature; it means a new bonus type shows up plainly rather than with the presentation its type deserves, until that entry is added.
Integration Caveats
SSR/webview: importing this package outside a browser previously threw immediately; fixed with a guard on the shared element factory.
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.11
- A session-aware widget that is mounted again later in the same page load (for example favourites after the player opens an event and comes back) now keeps the valid session. Before, when the host set
sessionIdafter the mount, the widget skipped the session check and stayed signed out, and favourites showed "Log in" after 15 s.
0.37.9
- My Bets: the sub-tab pill labels (Open, Closed, Won, Cash Out) are now 12px instead of 14px. On a 320px panel, in English and in longer languages such as Lithuanian, at least part of the Cash Out pill now shows, so the player can see there is more to scroll to. This also applies to the My Bets tab inside both betslip widgets.
0.37.6
<goma-my-bets>now offers cash out on open bets. A player can cash out the whole bet or only part of the stake, and sees the cash out value update live. Cash out is on by default. SetcashoutEnabled(attributecashout-enabled) tofalseto turn it off. The widget emits two new events:goma:cashout-success(withbetId,cashoutType,cashoutAmount,pendingand, for a partial cash out,partialCashoutStake) andgoma:cashout-error(withbetId,cashoutType, areasonofnot-signed-in,invalid-stake,network,http-error,rejectedorunknown, and, when known, the HTTPstatusand the provider'smessage). Three new image properties,myBetsCashoutWarningImage,myBetsCashoutSuccessImageandmyBetsCashoutErrorImage, set the pictures on the confirm, success and error steps. When one is not set, that step shows no picture.A cash out counts as done only when the provider answers with
success: true.cashoutAmountis the amount the provider paid, or the quoted value when it sends none. When the provider accepts a cash out but holds it for approval,pendingistrue, the bet stays in the Open list and the list is fetched again.<goma-betslip-sidebar>and<goma-betslip-floating>forward the newmyBetsCashoutEnabledattribute (my-bets-cashout-enabled) and the three image properties to the My Bets tab. After a successful cash out, the betslip asks for a new balance at once and again after 2.5 seconds. In<goma-betslip-floating>, the cash out dialog can open over the betslip dialog, and closing it leaves the betslip open and does not move the page behind.<goma-live-chat>and<goma-messenger>turn cash out off in the My Bets list of their ticket share screens.@gomagaming/network:openSseaccepts amethodoption and abodyoption, so a stream can be opened with a POST request.openSsealso callsonClosewhen its retries run out after an error, so a consumer knows the stream is finished.@gomagaming/my-betsholds the cash out dialog logic and now depends on@gomagaming/ui.<goma-my-bets>has a fourth sub-tab, Cash Out, that lists fully cashed out bets only (a partly cashed out bet stays in Open). It shows only while cash out is on, a full cash out that is not pending moves the widget to it when the success step closes (a partial or pending one shows Open), a page that holds only hidden bets loads the next page by itself, andgoma:my-bet-selectcan now reporttab: 'cashout'. A typed partial amount is rounded down to cents, and Try again shows the current value before the player confirms. After a partial cash out, the card shows a Cashed Out tag next to the title and a box with the amount paid out and the stake still in play; the Bet Amount keeps the stake as placed. A fully cashed out card keeps the neutral look, shows no leg result and a box with the total paid out. Possible Winnings is always the provider's figure (the winnings without the stake on a free bet). The cash out dialog is centred on every screen size, up to 480px wide, the amount field shows the currency, and the ? opens a tooltip.@gomagaming/sports-domainhas the cash out API calls and the live value stream. The stream keeps the last values while it retries and opens again when the browser goes back online or the tab becomes visible. It exports two new composables,useMyBetsCashoutValuesanduseMyBetsCashout, and a newrequestBalanceRefreshaction on the user store.@gomagaming/coreand@gomagaming/uicarry the shared dialog and slider styles.GomaDialogin@gomagaming/uitakes an optionalpanelClasswith extra classes for the panel; when set, it also replaces the default border colour. In@gomagaming/core,useScrollLocknow does nothing for a lock that is taken while another lock is active.<goma-betslip-floating>now closes the dialogs that are open inside it (the bonus picker too) before it closes itself.<goma-my-bets>has a newsubTabproperty (attributesub-tab). The host sets which sub-tab opens,'open','settled'(or'closed'),'won'or'cashout', and can change it later. The player can still click any tab.<goma-betslip-sidebar>and<goma-betslip-floating>have a newmyBetsSubTabproperty (attributemy-bets-sub-tab) that sets the sub-tab inside the My Bets tab.cashoutEnabledon<goma-my-bets>andmyBetsCashoutEnabledon both betslips turn cash out off when the property is set to the string'false', the same as the attribute.Playground defaults now target the EveryMatrix demo prod operator (2879): the betslip place-bet endpoint and the My Bets operator ID. The published widgets do not change.
0.37.5
<goma-betslip-floating>gains apanelHeightproperty (panel-heightattribute) that sets the height of the dialog panel on both the desktop modal and the mobile bottom sheet. A percentage is a share of the viewport height ('80%'), and a number or a string of digits is pixels (600,'600px'). Empty or unreadable values keep the built-in 85dvh desktop / 88dvh mobile height, so an embed that does not set it is unchanged.
0.37.4
My Bets now shows the score and the live state of each game on a bet, in
<goma-my-bets>and in the My Bets tab of<goma-betslip-sidebar>and<goma-betslip-floating>.- A live game on an open bet shows its live score. Before, every live leg in a betslip's My Bets tab showed
-, because the embedded<goma-my-bets>never opened its live-score feed when it mounted inside a betslip. - A live leg now shows a LIVE pill (the live event card's tag) in place of its kick-off date. A per-event status feed decides when the game is in play, so a bet placed before kick-off also gets the pill and the score once the game starts, even while the list stays open.
- New opt-in prop to show the match clock (for example
33' 1st Half) in front of the LIVE pill:showLiveClock/show-live-clockon<goma-my-bets>, andmyBetsShowLiveClock/my-bets-show-live-clockon both betslips, which forward it. Off by default. - A settled leg whose bet record has no result now gets its final score: the widget asks for it once over the socket. A leg that already has a result is not changed.
@gomagaming/sports-domainaddsbetting.getEventPartScore(eventId)(a one-time score request),betting.subscribeToEventStatus({ eventId, onUpdate })(the per-event status feed) andmyBetsLiveStatus(event).
- A live game on an open bet shows its live score. Before, every live leg in a betslip's My Bets tab showed
<goma-betslip-floating>: a decimal stake can now be typed with a comma or a dot on every platform. On iOS,2,50no longer becomes 250, and a half-typed2.no longer reads as 0. Thestakeingoma:stake-changeandgoma:selection-stake-changeis a number rounded to 2 decimals, and so is the place-betamount.<goma-betslip-sidebar>: a decimal stake can now be typed with a comma or a dot on every platform. On iOS,2,50no longer becomes 250, and a half-typed2.no longer reads as 0. Thestakeingoma:stake-changeandgoma:selection-stake-changeis a number rounded to 2 decimals, and so is the place-betamount.@gomagaming/ui: the betslip stake fields are text fields that keep at most 2 decimals and at most 12 characters.@gomagaming/sports-domain: newnormalizeStakeTextandtoStakeAmounthelpers. A stake read from the shared betslip storage is cleaned the same way.
0.37.3
Fix bet placement with the "Do not accept any changes in odds" setting. The betslip sent
oddsValidationType: 'ACCEPT_NONE', which is not a value the provider accepts, so every bet failed with error 147 "Invalid Odds Validation Type". It now sendsWITHIN_THRESHOLD, one of the three values the provider accepts. The provider then refuses any odds change when the account threshold is 0.Hosts in
placeBetMode: 'event'see the change too: thegoma:place-bet-requestdetail now carriesoddsValidationType: 'WITHIN_THRESHOLD'for this setting, where it carried'ACCEPT_NONE'before. A host that forwards the payload to its own backend, or checks this value, must acceptWITHIN_THRESHOLD. Thecustom-elements.jsontype of that field now lists it.Two more place-bet changes ride in the same release, and one display fix.
The place-bet payload now sends
freeBetas a boolean:trueonly when theubsWalletIdon that payload belongs to a free-bet wallet (so the provider books a free bet and not a cash stake),falseotherwise, where it sentnullbefore; thegoma:place-bet-requestdetail type changes fromnull|objecttoboolean. A betslip pick now setsubsWalletIdandfreeBettogether and wins over a hostplaceBetBody.freeBet; with no betslip pick a host that sends a free-bet wallet throughplaceBetBody.ubsWalletIdmust also sendfreeBet: true. The picked wallet's bonus type is now stored with the pick (and shared like the wallet id) as{ walletId, type }inselectedBonusTypeandselectedBonusTypeBySelectionof thegoma:betslip:v4envelope, and is used only for that same wallet id. A free bet pick on a Single selection stays flagged as a free bet, and is no longer cleared, when a refresh of that selection's wallets response leaves the wallet out. The two fields are additive and optional, so the envelope version does not change. In the same event detail type,ubsWalletIdis now absent instead ofnullwhen no bonus is picked, andoddsBoostPercentandcapAmountare listed.A Single bet can now be placed with an odds boost: the selection picker lists a boost that has reached a stair (it has a rate and a cap), leaves the typed stake alone, and the selection's payload carries the wallet id with the boost terms read from that selection's own wallets response. A boost with no usable stair is not offered on a Single selection, for fetched and host-fed wallets alike.
The betslip, My Bets and the chat ticket share now show a free bet's winnings without the stake. My Bets reads
potentialNetReturns(or stake times odds minus one) for every status except lost (0) and cashed out (unchanged), where it showed the stake as part of the winnings. The betslip shows the quote minus the stake for a Single selection or a Multiple slip carrying a free bet. ThepossibleWinningsofgoma:bet-placedkeeps the provider's figure, stake included. The chat ticket (possible_winnings) follows the same rule for a free bet.For hosts: in
goma:place-bet-requestand in the place-bet body,freeBetis now a boolean, and a betslip pick sets it. A host that used to put a free-bet token object inplaceBetBody.freeBetmust stop doing that. SendfreeBet: trueonly when you send your own free-bet wallet id and the betslip has no pick.@gomagaming/sports-domainhas two new exports:isFreeBetWallet(whether a wallet id belongs to a free-bet wallet) andfreeBetNetWinnings(a free bet's winnings without the stake).Fix
<goma-my-bets>crashing with "Cannot read properties of undefined (reading '_s')" inside the betslip's My Bets tab when a page loads two copies of@gomagaming/core, for example the betslip from the CDN and a chat widget bundled from npm. A widget nested in another widget only mounts hosted (in the parent's Vue app and Pinia) when both come from the same copy of core. A child from another copy now mounts standalone with its own Pinia.Such a child keeps its lifecycle events (
ready,goma:connection,goma:connected,goma:session-ready,goma:session-expired, andgoma:errorwith codeAPI_INIT_ERROR) inside the parent's shadow root, so the page does not see them twice. Its other events still reach the page, as from a hosted child.The betslip now also forwards
locale(at mount and on change) andiconBaseUrlto the embedded<goma-my-bets>, and calls itsreload()after a placement when it mounted standalone, because it cannot see the betslip's store.
0.37.0
- Both betslip elements'
appearanceobject gains an optionalfooterPaddingXkey: the left and right padding of the footer (stake row, summary, place-bet button), which sets the footer stake input's width. Unset, it keeps the current 1rem, so the default look is unchanged.@gomagaming/coreis bumped for the new utility in its compiled sheet. - Both betslip elements'
appearanceobject gains six optional keys for the panel's header and list:panelTabPaddingYandpanelTabLineHeight(Betslip / My bets tab labels),betTypeSwitchHeight,betTypeSwitchPaddingandbetTypeSwitchInsetX(Single / Multiple / System switch), andselectionsPaddingTop(space above the first selection). Unset, each keeps the current value (5px, 1.5rem, 31px, 2px, 1rem, 0.75rem), so the default look is unchanged.@gomagaming/coreis bumped for the new utilities in its compiled sheet.
0.36.1
Odds boost: adding an eligible selection while a boost is applied no longer drops the boost with "Your selected bonus is no longer available". The wallets response only names the events of the legs that were sent with the request, so a newly added leg is now checked when the response for the new slip arrives, instead of against the previous response. The boost tracker follows the same rule. A leg the new response leaves out still drops the boost, as before. Host-fed wallets (
bonusMode: 'prop') are unchanged. While the response for the new slip is on its way, Place Bet holds with the newgoma:place-bet-blockedreasonbonus_pending, because the boost's terms have not been checked for the added leg yet. If the price quote for the new slip fails, so no response can come, the boost is removed with the usual notice and the bet can be placed without it.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.Betslip styling options, all optional: with none of them set the betslip looks as before, except for the radio-ring fix below:
- Theme tokens:
--goma-stakeInputBackground/--goma-stakeInputBorderfor every stake input (fall back tobackgroundPrimary/separatorLine),--goma-betslipTabTextfor the inactive panel-tab labels (falls back totextSecondary),--goma-betslipRadioDotfor the selected settings-radio centre (falls back totextPrimary) and--goma-betslipRadioBorderfor the unselected radio rings (settings and bonus picker). appearanceobject (new on both betslip elements, structure only):stakeRowHeight,stakeRowGap,quickAddButtonMinWidth,quickAddButtonPaddingX,quickAddFontSizeandstakeInputBorderWidth, each defaulting to the current value.
Fix (changes the default look): the unselected radio rings in the settings panel and the bonus picker used
border-textSecondary/60, which never compiled (opacity on avar()colour), so they rendered in Tailwind's#e5e7ebin every theme. Both now read--goma-betslipRadioBorder, which falls back to the intendedtextSecondaryat 60%.quickAddButtonMinWidthonly sets a minimum: the buttons still grow with their label.@gomagaming/coreis bumped for the new utilities in its compiled sheet.- Theme tokens:
Cashback bonus: the betslip now explains a picked cashback wallet instead of showing it as money. Not yet verified against a live cashback wallet from the provider. The picker and chips show its name only (the wallet's
amountfield is not money the player can spend). Multiple and system mode show a Cashback row with the label, the wallet name (only when it differs from the label), and "Your net losses count towards your cashback rewards.", with its own remove control; single mode shows a chip on the leg above its stake input, with the same label/name pair and the explanation line underneath. The player's stake is never changed, and the bet still carries onlyubsWalletId. A cashBack wallet with no presentation name now shows the translated Cashback label instead of the raw type key on the betslip (the picker row, the chips, the applied/unavailable notice, and the cashback row's own name); event payloads keep the rawnamethe provider sent. For a cashBack pick,goma:bonus-select,goma:bonus-removeandgoma:bonus-unavailablenow carryamount: null(it was a number before). The applied-bonus notice now separates the bonus name with a colon instead of a dash (e.g. "Bonus applied: Free Bet"). New copy in en, fr and pt. Switching from a stake-replacing bonus (a free bet) to one that keeps the player's own stake (cashback, an odds boost, or any future type) now clears the stake the previous pick wrote, so it is never placed as real cash. A blank localized name (present but empty or whitespace-only) no longer hides a real name sitting in a later locale key.A My Bets card's footer now reads as one aligned block.
The rule under the Total Odds / Bet Amount / Possible Winnings headings sat inside a narrow column that capped those figures well short of the card, so it read as a short underline beneath the headings rather than as a divider. It now runs from one card edge to the other, and the figures spread to the width of the card instead of staying bunched in the middle.
The win-boost row is aligned to the same three columns: its label sits under Total Odds and its amount under the winnings figure it replaces, in the same size and typeface as the other figures rather than a smaller one pushed to the far edge.
A bet placed with an odds boost now shows it in My Bets.
Until now a boosted ticket looked identical to any other: the winnings cell showed the boosted figure with nothing marking it as such. While the bet is open, the card now strikes through the pre-boost winnings figure and adds a "WIN BOOST (x%)" row beneath it carrying the boosted amount. A settled bet shows its settlement with no strikethrough: 0.00 when lost, the cashout amount when cashed out, the payout when won. The row stays on a won bet with that payout, and is hidden on a lost or cashed-out bet, where the boosted figure is money the player never received.
The bets feed ships a different shape from the place-bet response:
oddsBoostis a plain boolean rather than the boosted total, and the pre-boost winnings figure isn't sent at all. It's derived from the stake and the ticket's combined odds.The bet feed's mapper now carries the boost rate through to the card. It previously dropped the field, so the information never reached the card at all.
A boosted ticket whose rate is missing from the feed now reads as the plain boost name rather than a truncated "WIN BOOST" with an empty bracket.
The betslip now shows what an odds-boost bonus is worth.
Applying a bonus that boosts the winnings by a tiered percentage adds two things to the betslip: the boosted winnings beneath the ordinary figure, and an indicator showing which tier is paying and how many more qualifying selections the next tier needs. Both appear only once the bonus is applied: a betslip that merely qualifies for one shows neither, rather than an empty panel.
The boosted figure is shown for information. The bet carries the bonus wallet, and the betting provider applies the boost when the bet settles, so the amount paid is always theirs rather than a figure calculated here.
Win-boost copy is now complete in French and Portuguese, and two strings that could never display their numbers have been repaired.
Placing a bet with an odds boost applied now works. The provider was rejecting these bets outright: it checks a boost claim against the terms it holds for that wallet, and the request carried only the wallet id. It now also carries the resolved cap and the tiered rate for the wallet's currency, both read from the same terms the tracker already shows the player. A stakeBack bet is unaffected: it never carried these fields and still does not.
An odds boost is now removed from the betslip when a selection outside the events it applies to is added. Before, both boost surfaces disappeared but the bet still claimed the boost, so the betslip said no boost applied while the request asserted one. The player now sees the same notice as for any bonus that stops being eligible, and the host receives
goma:bonus-unavailable.Bonus rates are shown as whole percentages again.
A bonus paying a fractional rate was labelled with its exact figure, which differed from the figure the betting provider's own clients show for the same bonus. Rates now round to a whole percent everywhere they appear, so the betslip, the bonus list and the bet card agree with each other and with the provider.
The one exception is a rate under half a percent, which would otherwise round to "0%" and read as a bonus that pays nothing. It keeps one significant figure instead, so 0.4% reads as "0.4%".
This affects the label only. Every amount is calculated from the exact rate, so no figure a player is paid or shown as a total changes.
Stakeback bonus: the betslip now shows a progression indicator and a separate Stakeback row with the refund (
min(stake x tier %, cap)) and the condition it is paid under, for example "Paid if exactly 1 selection loses". The refund is never added to the winnings. Stakeback is no longer offered in Single mode, where its tiers can never be reached, and no longer shows the shared bonus chip. My Bets shows the refund the provider paid on a lost ticket (betStakeBack). New copy in en, fr and pt.Fixes: with two wallets of the same bonus type offered on a slip, the tracker now prices the wallet the player actually applied instead of always the first one of that type. The My Bets refund sentence no longer states the exact rate arithmetic when the reported refund is below stake x rate (a capped refund), falling back to the plain "delivered to your account balance" sentence instead. A stakeback tier with no cap for the wallet currency is no longer read as a zero cap: the refund shows the full stake x rate.
The betslip's embedded My Bets tab now follows a runtime theme change.
<goma-my-bets>paints from its own--goma-*variables and only received the theme when it mounted, so a host switching dark <-> light left the whole My Bets tab (cards, legs, borders, filter pills) on the palette it started with. The betslip now forwards everythemechange to it; config and session are not re-sent, so the bets feed is not reloaded.Moved every shared UI component out of
@gomagaming/sports-domaininto a new@gomagaming/uipackage, which depends on it:The game cards:
RegularOddsand its live/prelive/outright/market variants,SkeletonCardandCountryFlag. Used by every widget that lists events.The betslip family:
BetslipPaneland its selection, summary and bonus pieces, and theGomaDialogmodal shell. Used by the betslip floating and sidebar widgets.The missions family: the missions cards, level rows, tags, empty state and mission-detail panels. Used by the missions hub and mission details widgets.
The promo-card family: the promo card shell, hero, competition row, kickoff time, CTA link and skeleton. Used by the highlight card and pros' choice widgets.
MyBetsPanelandMyBetsCardmoved into@gomagaming/my-bets, their only consumer. The betslip widgets keep showing them through<goma-my-bets>.No widget's public surface, markup or behaviour changes, and each widget renders the same components as before.
@gomagaming/uiis installed automatically with the widgets that use it.@gomagaming/sports-domainno longer exports any component. It keeps the stores, API clients, composables and the promo-card auto-mode layer, and additionally exportsuseBetslipSystem,useBonusSessionGate,bonusRowTitle,convertOdd,formatRatePercent,formattedDate,formattedTimeandresolveDateLocale. A host importing one of the moved components from@gomagaming/sports-domaindirectly should import it from@gomagaming/ui(or, for the MyBets pieces, use<goma-my-bets>) instead.
0.35.0
Removing a selection no longer locks up the bonus it was using.
In single-bet mode each selection carries its own bonus, and a bonus already applied to one selection is offered as unavailable on the others: it can only be spent once. Removing that selection left the record of it behind, so the bonus stayed unavailable on every remaining selection while nothing was using it. Only clearing the whole betslip recovered it, and because the record is shared between betslip widgets, it survived a reload and reached the other widgets on the page too. A selection now releases its bonus as it leaves.
Clearing the betslip also re-applies the default bonus again, rather than waiting for the next time the list of bonuses is refreshed.
A betslip with the bonus feature switched off no longer clears a bonus chosen in a betslip that has it switched on. The two share the player's choice through cross-widget storage, and a widget showing no bonus controls at all could still decide the choice was no longer valid and remove it.
Bonus rows now show the right figure, in the right currency.
A stake-back or odds-boost bonus was listed with a money amount it does not have. The provider reuses the wallet's amount field to carry the bonus's RATE on these types, so a bonus paying 5% back arrived as
0.05and the picker offered it as "0,05€": five cents, for a bonus worth up to 1000. These rows now read the rate from the bonus's own terms and show it as a percentage, prefixed to the bonus name only when the name does not already carry it, since operators commonly name them that way already.Amounts that ARE money are now formatted in the currency the bonus states rather than always in euros. A player can hold a bonus in a currency their balance is not in, and the old behaviour labelled those incorrectly. Hosts supplying their own
formatStakeare unaffected (that still takes precedence) and a bonus that states no currency is shown as a plain number rather than being assigned one.A fractional rate is no longer rounded to a whole number. A bonus paying 12.5% was shown as "13%", a figure that appears nowhere in the terms the player agreed to. The same helper now formats the rate wherever it appears, so the betslip and the bet card cannot disagree about it.
A bonus whose amount is zero is shown by name alone. The provider sends the amount as a number on some wallets and a string on others, and the string form was rendering as a confident "0,00 €".
Betslip widgets can now surface and apply a bonus to a bet. A gift control in the betslip opens a selector listing the bonuses available for the current slip, with an explicit "None" option; at most one bonus applies at a time, and the applied bonus is shown with a control to remove it. In single-bet mode each selection carries its own bonus, and a bonus already used on one selection is shown as unavailable on the others.
The selected bonus is sent with the bet as
ubsWalletId, taking precedence over a value supplied throughplaceBetBody. Selecting, removing, and losing a bonus each dispatch an event the host can listen for. The feature is off untilbonusEnabledis set, so existing embeds are unaffected. When bonuses are fetched (the defaultapimode), the control additionally requires a session, so a signed-out visitor is never offered a selector that could not be filled.The place-bet request now matches the sportsbook it was extracted from, field for field.
The odds-change setting now does what it says. The betslip offers three choices instead of two: accept any change, approve higher odds (the default), and do not accept any changes. The last one previously told the provider to accept a higher price anyway, so a player who asked for no changes could still have their bet placed at different odds: the setting had no effect. Each choice now sends the value that enforces it. The middle option is new: until now the betslip could only ask for "any change" or "higher only", and the labels were attached to the wrong values.
A betslip that already carried a saved preference keeps behaving as it did: the stored value maps onto "approve higher odds". Two betslips on the same page now agree on this setting, as they already did for the stake and the bet type.
Other payload corrections.
userIdis sent as a string andfreeBetas null, both matching the provider contract.ucsOperatorIdis included in the body as well as the URL and header. The player's registered country is sent, and the language always is, defaulting to English.bettingTypeIdis forwarded exactly as received, and each selection's outcome id is re-resolved from the live offer when one is loaded.Currency is now taken from the account rather than inferred. It was read from the first entry of the balance response, which is ambiguous for a player holding more than one wallet (exactly what a bonus creates) and the provider rejects a bet whose currency does not match. The account's registered currency is read once per session and used instead; if that request does not resolve, the previous behaviour applies unchanged.
The betslip now tells the host when a signed-in player never got a usable identity. When
requireSessionis set, the Place Bet button waits for the session exchange to complete and then releases after 20 seconds so a host whose exchange cannot finish is not left with a button that never works. Until now that release was recorded only as a console warning, and every bet after it was rejected with401, which reads as a rejected bet rather than an unverified player. It is now also announced asgoma:session-unverified, carrying the reason and the grace period that elapsed.This is deliberately separate from
goma:session-expired: that one means a session was established and later ended, while this one means one was never established, so the host can ask the player to sign in again instead of telling them their session expired.The bonus selector now also hides itself when the session it would spend against has been rejected. Previously only an empty session id hid it, so an expired session kept offering bonuses that could no longer be applied.
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 bet placed with a stakeback bonus now says so in My Bets.
Until now a player could choose a bonus, have it applied and place the bet, and then find nothing on the bet card indicating the bonus was attached. The card now carries a tag reading the rate the bonus pays back, for example "5% Stakeback".
The tag is shown in every settlement state, not only once a bet is settled: the bonus is a property of the bet rather than of its outcome, and a player needs to see it is attached while the bet is still running. Where a bet qualifies for several tiers, the highest is shown.
The bet feed's mapper now carries the bonus terms through to the card. It previously dropped them, so the information never reached the card at all.
Betslip selections name themselves in full. The three labels that identify a line (outcome name, market name and fixture) were ellipsed to one line, which at phone width cut exactly the part that tells one selection from the next ("Over/Under 188.5, Whole Match" arriving as "Over/Under 188.5, Whole…", a handicap or player market losing its number). They now wrap. The same applies to the BetBuilder card's legs.
Affects
<goma-betslip-floating>and<goma-betslip-sidebar>, which both render the shared betslip panel. A long name now grows its row's height; the odds chip and the remove button are unchanged and never get pushed over.Match dates read day-first in English, so one match is no longer written two different ways across the suite.
An event card writes the kick-off as a compact
11/10;<goma-game-details>'s header,<goma-hero-card>,<goma-multi-banners>and<goma-search>'s result rows write it in words throughtoLocaleDateString. Withlocale: 'en'(the bare tag, whichIntlresolves to American ordering) that long form came out asOctober 11next to the card's11/10. English was the only shipped locale that disagreed with its own cards (frandptare already day-first, and the shared time formatter has always been pinned toen-GB), so the long form now resolves a bareentoen-GB. A host that explicitly configuresen-USkeeps American ordering.Two surfaces build their own
Intl.DateTimeFormatrather than going through the shared formatters, and both showed the same inversion for the same match: the My Bets card's leg date (which also printed07 Octoberwhere the cards print7 October, and sat directly under a hand-builtdd/mm/yyyy"placed" line) and the live-chat bet modal's per-leg date. Both now resolve the locale the same way.Every package listed here renders a date that changes, which is why the note reaches all of their pages rather than only the two whose source moved: including the two betslips and
<goma-messenger>, which render the same leg date through the<goma-my-bets>widget they embed.Not covered, and deliberately: the missions surfaces format challenge dates with the browser's locale rather than the widget's, and, in the same file, render the same instant as a deliberately locale-independent
DD/MM/YYtwo functions below, so one view contradicts itself regardless of locale. That is a different defect from this one and wants its own ticket.Secondary market cards no longer swap their two sides mid-game, and a betslip row can no longer become a different bet without saying so.
The card. A live market that suspends and re-offers arrives as DELETE+CREATE, so the re-created outcome lands on the tail of
market.outcomesand the market reaches the card with its two sides the other way round.sortOutcomesonly ranks outcomes carrying aheaderNameKey, which a handicap outcome does not, so every pair compared equal andArray#sort's stability handed the layout to arrival order. For a two-sided market the comparator now falls through to the provider's outcomecode: numbered codes (#1/#2) compare numerically, so#10does not sort before#2, and the provider's named pairs (#Y/#N,#O/#U,#Odd/#Even) are ranked the waygetOutcomeOrderalready ranks them. Yes before no, over before under, odd before even. A pair of codes the comparator does not recognise keeps the order the feed sent, and a market with more than two outcomes is never reordered.sortOutcomesreturns a copy, so the call sites that hand it a store-backed market's own array no longer reorder the store from inside a computed.<goma-events-horizontal>,<goma-favorites>and<goma-competitions-list>render the shared secondary card and inherit the fix.<goma-game-details>,<goma-hero-card>,<goma-highlight-card>,<goma-multi-banners>and<goma-pros-choice>callsortOutcomeson their own market lists and render the shared odds button, so the new ordering and the newlineongoma:outcome-selectreach them too.The betslip.
RegularOddsrecords the market's line (paramFloat1) on the selection at tap time, and it now survives both cross-widget bridges: thegoma:betslip:v4envelope carries it as an identity field, andgoma:outcome-selectcarries it in its detail. Without that it never reached the widget that reads it.useBetslipOddsSynccompares it against the market on every pass, in the game-details store slots as well as the list ones: when the line has moved the row is re-captioned and marked unavailable, anduseBetslipLogicrefuses to place a slip holding an unavailable leg, so the player picks the new line deliberately instead of having it accepted for them by the odds-change gate. The tapped line is never overwritten, so the verdict is re-asserted on every tick rather than being undone by the next price update, and a line that moves with no price change is caught too: a market-only rewrite never woke the odds watcher before. A wording-only change still just corrects the caption, and a selection saved before the line was recorded is never invalidated by the upgrade.Saying why. A blocked leg now carries
lineMoved: truebesideisAvailable: false, so the two reasons a leg cannot be placed stay apart. The betslip row reads "Line changed" (selection_line_moved, added to the en/fr/pt bundles) instead of "Selection unavailable" (the match is still on, it is the line that is gone) andgoma:bet-failedlists those legs under a newmovedLineSelectionskey rather than folding them intounavailableSelections, so a host does not word a live match as dead.placeBetBlockedReasongains'line_moved', ahead of'unavailable', so a host keying its wording off that token can say that the line moved rather than that the match is gone; the widget's own copy carries the newplace_bet_blocked_line_movedkey in all three locales. The flag itself is now written in both directions: a line that moves and comes back clears it, so a later genuine withdrawal reports as unavailable and not as a moved line.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
Signing in (
<goma-betslip-sidebar>,<goma-betslip-floating>,<goma-favorites>) no longer stalls on a WAMP socket the provider never answers.The EveryMatrix gateway sometimes hands out a socket that completes the handshake and then answers no call at all, not the feeds, not
/sports#loginWithCeSession. The CE-session exchange only retried once a step settled, so it stalled for good: the host's "session ready" wait ran out, and every bet went out without ausernameand came back rejected until the page was reloaded. Around a quarter of sign-ins hit it on stage.Each exchange step (the StartInfo request and the
loginWithCeSessioncall) now has a 5s deadline, tunable with the newsessionStepTimeoutMskey (0disables it). A timed-out StartInfo request is aborted and retried. A timed-outloginWithCeSessiondrops the shared connection instead of retrying into it (every widget reconnects and the exchange re-runs on the fresh socket) at most twice per session; a successful exchange restores that allowance. If no new connection follows a replacement, a console warning says the exchange is not running.<goma-multi-banners>,<goma-highlight-card>and<goma-pros-choice>now re-open their live topics after a reconnect. They skipped every sport they had already subscribed to, so after any socket loss, and now after a replaced login socket, their odds stopped updating until the element was unmounted.
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.
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.30.0
- The floating betslip button draws the ticket glyph from the design instead of a receipt outline, with the two rules that were a hair apart (2.16 against 2.17325) evened up: that mismatch was what made the icon look distorted at small sizes. The game-details market list takes 16px of inline padding on mobile, where the widget is laid out edge-to-edge and the cards were butting against the screen.
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.24.0
- betslip-floating: make
--goma-bf-bottomand--goma-bf-sidework as host levers. Both were declared on.goma-bf-triggerinside the shadow DOM, which shadows any value inherited from the host, so a host that set them saw no effect. The defaults now live in thevar()fallbacks instead, so a host-supplied value wins. Values and rendering are unchanged when nothing is set. Hosts need this to lift the FAB clear of their own bottom-anchored furniture (a chat dock, a cookie bar, a bottom nav): previously the only escape was overriding the widget's CSS from outside the shadow boundary, which isn't possible.
0.23.0
Betslip: the System tab works
The third bet-type pill was rendered disabled with a "not supported in v1" comment. It now covers every N-fold combination of the slip's legs, with the system types the provider offers for the current selections.
The entered stake is per combination. A stake of 10 on a 10-combination system spends 100, and nothing on the wire says so: the place-bet payload carries the per-combination figure and the provider derives the total. The panel states the real spend explicitly.
goma:place-bet-startgainscombinationsandsystemBetTypefor a system bet, and reportstotalStakeasstake × combinations.betCountstays1: a system is one request producing one result, however many combinations it covers.goma:place-bet-blockedgains the reasonno_system_type.The cross-widget envelope (
goma:betslip:v3) now carriesactiveTab: 'system'andsystemBetTypeId, so the chosen system follows the player between the sidebar and floating betslips. An older widget reading a newer envelope falls back to Multiple.betslip: adding a selection now selects the Betslip tab on every viewport
The panel's active tab is sticky state that survives the floating dialog closing. With
openOnSelecton, only a desktop-width odds tap retargeted it, so a mobile user who had been reading My Bets reopened the slip onto that tab with the pick they had just added hidden behind it.The viewport now gates only the auto-open of the floating variant: the tab retarget applies everywhere. Mobile odds taps still never pop the dialog.
0.22.0
Betslip: place-bet results are reported as events instead of rendered as text in the widget.
Removed: the success receipt badge, the place-bet error row (which surfaced raw provider strings and bare HTTP codes), the dead-leg name list under it, and the combination warning banner. A host that listens to none of the events below now shows the player nothing when a bet fails, so listeners are required. The per-selection "unavailable" treatment and the disabled Place Bet button stay: they are attached to a row, not messages.
Added
goma:place-bet-start: once per press of Place Bet, withbetCountso a host knows how many results to expect.goma:place-bet-blocked: the user pressed a refused button, with the reason.goma:combination-rejected/goma:combination-resolved: the provider's verdict on combining the slip, including the Bet Builder case where a successful quote still refuses and carries no message.batchIdongoma:place-bet-request,goma:bet-placedandgoma:bet-failed, so the N results of a single-mode submission can be correlated back to the one press that caused them.
Fixed: the EveryMatrix place-bet response is nested under
response, but both parsers read the top level. A 409 explaining itself as "The odds of your selections has changed." surfaced as"HTTP 409 Conflict",errorCodenever reached the host, and every successful bet carried a fabricatedbet-<timestamp>id instead of the provider's real one.Session: new
goma:session-readyevent. The host can finally tell when a session is usableA host knows when it issued a
sessionId. It has had no way to know when that id became something the player can bet with, and those are seconds apart: the REST probe resolves theuserId, then the CE-login exchange fetches aceSessionand binds the WAMP socket, and only then does the place-bet payload carry theusernamethe provider rejects a bet without.A host that reveals its betting UI on its own login response therefore hands the player a Place Bet button whose first submission is guaranteed to fail: tap once, get an error, tap again, it works.
goma:session-readycloses that gap, as the counterpart to the existinggoma:session-expired:jswindow.addEventListener("goma:session-ready", ({ detail }) => { // detail: { userId, username } closeLoginModal(); });Emitted by every session-aware widget (both betslips, favourites, my-bets) once per resolved token, and again whenever a new token is minted (max-age refresh, WAMP reconnect), so handlers must be idempotent.
There is no host-side alternative: the shared
goma:session:v1envelope is public, butstorageevents fire only in other tabs, never the writer's, so a host could only have polled it.Session: a player who signs in without reloading can bet again
Signing in during a page's lifetime left the player half-authenticated: the place-bet payload went out with no
usernameand the provider refused it, and only a page reload fixed it. Two defects, each of which was hiding the other.A refused CE-login was committed as a session. A successful
loginWithCeSessionanswers{ username, sessionToken, message }; a refused one answers a single-key payload, which is still truthy, so the guard let it through. That wrotestatus: 'valid'with a nullusernameand a nullsessionToken: an envelope every widget reads as resolved, that can never place a bet, and that nothing re-triggers. A response missing either credential is now treated as a failed attempt and retried.An unresolved session was mistaken for a logout. While the exchange had committed nothing, the shared envelope stayed
idle, and the mirror read that as "the player signed out" and wiped theuserIdthe REST probe had already established, so the bet went out with no identity at all and came back401.idlenow only tears the identity down when the host has actually dropped itssessionId, which is what a real logout does.Fixing either alone made things worse, because the first defect's bogus commit was what kept the second from firing.
0.20.1
Fix: a 401 no longer signs the player out of the whole page
0.20.0 made a dead session sign the player out, but treated any
401/403from place-bet or favourites as proof the session had died, and applied the logout page-wide regardless of which credential actually failed. In practice that made a signed-in session unusable: place a bet, get signed out.A bare 401 is no longer read as a dead session. It means "this request wasn't authorised", which equally covers a gateway rejection, a misrouted call, or a request issued with a credential the host has already replaced. Place-bet and favourites now require the provider's explicit
InvalidSessionmarker before condemning the session, matching the production sportsbook'shandleSessionExpiration. Anything else fails that one request and leaves the session alone. The session probe (GET /v1/player/session/player) and the balance stream'sSESSION_EXPIRATIONevent remain the authorities on whether the player is still signed in.A rejection is scoped to the credential it names.
markInvalid()now records whichsessionIdfailed, and the logout +goma:session-expiredonly apply to a widget still running that one. Previously the death was stamped onto whatever the envelope currently held, which, after a re-login, is the new token, so a late401from the old one killed the session that had just replaced it. Requests can't be recalled: a/balancecall, a favourites poll, or an SSE stream replaying its captured headers on backoff can all outlive the token they were issued with.commit()no longer blanks fields it wasn't given.sessionIdused??, so an empty string overwrote a real id: hosts feeding a pre-resolved identity (userId+sessionToken, no rawsessionId) wiped the id every other widget had recorded, after whichisValidForcould never match and the CE-session exchange re-ran indefinitely.usernamehad the same flaw: only the CE-login exchange learns it (the REST probe never returns one), so any other committer nulled it, andusernamethen silently vanished from every subsequent place-bet payload. Both are preserved now; a real logout still clears them viaclear().A spent rejection left over from a previous
sessionIdis deliberately left in the envelope rather than cleared:clear()resets it to'idle', whose mirror branch wipes the identity the probe just established. Trading one logout bug for another. It is inert, since every consumer scopes on the id it names.
0.20.0
A dead session now signs the player out and notifies the host
A place-bet rejected with
401used to paint the literal stringHTTP 401into the betslip panel: the transport fallback, because the provider sends no body on a gateway rejection and HTTP/2 carries nostatusText. Above it sat a "your session is no longer valid" banner, and underneath, nothing recovered: the invalidation cleared the shared token but no resolver watched for it, the stale token stayed on the user store, and Place Bet stayed enabled. Every retry re-failed identically until a full page reload.gomawt cannot re-authenticate (the REST
sessionIdis minted by the host's login and the provider exposes no refresh path) so the fix hands the problem to the party that can solve it.A dead session is now a logout, not a warning. The identity is cleared, so balance, My Bets and favourites all fall back to their guest states. The warning banner and its
session_invalid_warningmessage key are removed: leaving the UI presenting a signed-in player who couldn't do anything was the worst of both. A place-bet401/403also renders no error text.goma:bet-failedstill carries the fullstatus/bodyfor telemetry.goma:session-expiredis the session-death contract. Every authoritative rejection funnels throughuseSharedSession().markInvalid(reason)and reaches the host: place-bet401/403, favourites401/403, the session probe, the CE-login exchange, and the SSE expiry that already fired it.detail.reasonnames the source. It fires once per invalidation per mounted session-aware widget, and again on a retry, so handlers must be idempotent.Migrating an existing host
This changes what the widget shows, so a host upgrading from an earlier version should check both of these:
- The session-invalid banner is gone. If you relied on the widget painting "Your session is no longer valid" as the only signal to the player, there is now no widget-side surface at all: a dead session silently signs them out. Listen for
goma:session-expiredand show your own message. - Place-bet errors no longer include session failures. A
401/403writes nothing to the panel. If you were reading the rendered error text, switch to thegoma:bet-failedevent, which still carriesstatusandbody.
jslet lastHandled = null; el.addEventListener("goma:session-expired", async (e) => { const { reason, invalidationId } = e.detail; if (invalidationId === lastHandled) return; // a sibling widget already reported it lastHandled = invalidationId; el.sessionId = await myApp.reauthenticate(); });Hosts using
placeBetMode: 'event'are unaffected: they submit through their own backend and already own auth.detail.invalidationIdis a monotonic counter: every mounted session-aware widget reports the same id for one death, a retry reports a higher one. Dedupe on it rather than on your own logged-in flag, which would swallow the retry.A page load re-verifies the session instead of trusting the cache. The
goma:session:v1envelope records what gomawt last resolved, never that the provider still honours it, so a session killed server-side stayed "valid" across reloads for the whole max-age window. The firstsessionIdof each page load now always runs the REST probe and always re-runs the CE-session exchange (the cached token was bound to the previous socket). Both are de-duped page-wide (one probe plus one exchange per load however many session-aware widgets are mounted) after which the normalsessionMaxAgeMsreuse window resumes.requireSessionnow gates on a usable session, not just a non-empty string. A stalesessionIdrestored from a host cookie satisfied the old check, so Place Bet stayed enabled and the submit went out to a guaranteed401. The widget can't clear the host-ownedsessionIdprop, so this is also what stops a player re-tapping into the same failure while the host gets around to signing them off. The gate is scoped to the id the rejection was recorded against, so a freshly-issued token isn't blocked by the previous one's death.Transient failures change nothing. A network blip, a 5xx, or a missing
playerApiBaseUrlleaves the session exactly as it was (no logout, no page-wide invalidation, no event) so a flaky connection can't sign the player out. Only an authoritative rejection does.- The session-invalid banner is gone. If you relied on the widget painting "Your session is no longer valid" as the only signal to the player, there is now no widget-side surface at all: a dead session silently signs them out. Listen for
0.19.0
Block finished events in the betslip
A game that had already finished stayed in the betslip as a placeable selection. The betslip had a working "unavailable" state, but nothing ever put a finished event into it.
A finished event is invisible to the live odds feed: when an event ends the provider deletes its offers, and a delete removes the offer from the store without touching the selection that referenced it, so no tick ever arrives to invalidate the leg, and the optimistic
isAvailableit was added with was never challenged. The provider'sbettingOptionsV2response reports the dead leg inrejectedSelections, and that field was fetched, parsed, returned by the API module, and then ignored by its consumer.Brings the availability model in line with the reference sportsbook:
rejectedSelectionsnow drives per-leg availability; legs the provider does not reject are confirmed available.- The slip re-validates on stake change (mandatory before placing, so every route to the place-bet button re-checks), on availability changes, and every 30 s while non-empty: the "add a leg and wait" case, which produces no user input and no odds tick.
- The place-bet gate is now a whitelist: a leg must be explicitly confirmed available. Previously only an explicit
falseblocked, so a leg nobody had ruled on was treated as bettable. goma:bet-failedcarriesunavailableSelections, and the panel names the offending fixtures instead of showing a bare provider error.
Whenever there is no authority to ask, unconfirmed legs are released rather than blocking the button forever, whether the adapter exposes no
getBettingOptionsat all (mock data, event-mode hosts, the playground) or it exposes one that cannot answer (nosocketUrl, a socket still connecting, a transient failure). An explicit "no" from a feed is never overridden this way, so a leg the provider already rejected stays rejected across a failed round-trip.Stop a refused combination from blanking the whole betslip
Adding a second selection could mark every leg "no longer available" and shut the place-bet button, on a slip where each leg was perfectly bettable.
bettingOptionsV2returnsrejectedSelectionson failure envelopes, and the same-looking payload means two opposite things depending onerrorCode:107: "At least one of your selections is currently not available". A genuine per-leg verdict, and the only way a finished event reaches us: the provider deletes ended offers, so the live feed never invalidates the leg.cr100: "Sports cannot be combined". A combination restriction that lists every leg, though each is individually placeable.
The adapter never checked
body.success, so it mapped both error envelopes as if they were quotes and funnelled them into per-leg availability. A one-leg slip is typedSINGLE, always succeeds, and hides the whole thing, which is why it only showed from the second selection onwards.getBettingOptionsnow surfacessuccess/errorCode/errorMessage, and the composable routes a failed quote by cause:107still drives availability, while a combination restriction releases the legs and marks them non-combinable instead. Where the existing single/multiple scoping already applies, so single mode stays placeable. Unknown codes are read as combination restrictions: an over-permissive answer is caught by the provider at submit, whereas the pessimistic one leaves the user no way out.The provider's own message is now shown in the betslip warning banner in place of the generic string: it knows which rule was hit and we do not. It is not translated, arriving already localised via the RPC's
langargument.Also fixes a related mis-attribution: the response was applied to a live reference to the selections array, so a leg added while the request was in flight could inherit a verdict the provider was never asked for. The list is snapshotted before the round-trip.
Fix the betslip when selections come from the same event
Picking two options from one event left the betslip unable to place a bet the provider would happily have accepted, and (where it did place one) priced it wrongly. Three fixes, all bringing the widget back in step with the source sportsbook.
Combinability now only applies to a combination. The provider's
forbiddenCombinationssays those legs may not be bet together; it says nothing about betting each on its own. It was being applied in single mode too, so two options from one event disabled Place Bet even though each was individually placeable. The source issues the same request whatever the tab (it is typedMULTIPLEwhenever there is more than one selection) but only applies the result while the slip is a multiple. Switching bet type now re-evaluates, mirroring its per-tab recalculation hook: entering single releases the flag, returning to multiple re-applies the provider's verdict.Single mode submits one bet per selection, each carrying that leg's own stake. It previously sent a single request containing every selection with a slip-level
amountset to the sum of their stakes, and since the wire format has no per-leg stake field, the provider would price one bet at the total of the whole slip. Legs with no stake are skipped. Multiple mode is unchanged: still one combined request. Hosts listening forgoma:place-bet-request/goma:bet-placed/goma:bet-failednow receive one event per leg in single mode. Legs are settled independently (successful ones leave the slip, failed ones stay) so a partial failure leaves exactly the retryable legs behind.A flagged leg no longer latches. Once the provider flags a leg, only a later successful
bettingOptionsV2response could lift it, and the place-bet gate blocks while any leg carries it. Two paths held it permanently:- A response that could not be obtained (no session behind the adapter, a socket still connecting, a transient failure) returned early and kept the previous verdict, so a user who removed the offending leg and added a valid one kept the old block. Non-answers now release the flag, matching the source, whose handler falls through to marking every selection combinable when the result is empty.
- A
forbiddenCombinationspayload whose shape could not be read marked the entire slip non-combinable, so one unrecognised payload could block every leg indefinitely. An unreadable shape is now logged and treated as nothing forbidden; the provider still rejects a genuinely illegal combination at submit, which is recoverable, whereas a bricked button is not.
0.17.0
- Missions Finished-tab history card fixes: the forfeited outcome now uses the shared
alertErrortoken consistently. The Forfeit pill, the per-level ✕ circle, and the "Forfeited" status text all paint--goma-alertError(replacing a maroonmyTicketsLostpill and a hardcoded#be170fred). The token default is corrected to its design-system value#f9554d. The "X/Y levels" count now counts only levels the player actually completed, so a fully-forfeited challenge reads0/4instead of4/4.
0.16.0
Add
<goma-my-bets>standalone My Bets widget (@gomagaming/my-bets): renders the user's bets (Open / Closed / Won) with live match scores on open bets over WAMP, and emitsgoma:my-bet-selecton card tap. Sharedsports-domainadditions:subscribeToEventPartScores+parseEventPartScore(per-event live-score feed),useMyBetsScoresLive, areloadTokenforce-reload hook inuseMyBets, aneventResultfield on mapped legs, and a card-tapselectemit onMyBetsCard/MyBetsPanel.The betslip widgets (
@gomagaming/betslip-sidebar,@gomagaming/betslip-floating) now reuse the standalone<goma-my-bets>widget in their MyBets tab instead of embedding the panel directly: one MyBets implementation, shared. They take@gomagaming/my-betsas a dependency and no longer run their ownuseMyBets()feed (the widget owns it).
0.15.0
Betslip remote-control events: the page can now drive the betslip via composed window events.
goma:betslip-open/goma:betslip-close/goma:betslip-toggleopen, close, or toggle the floating dialog; adetail.targetof'betslip'or'myBets'(case/whitespace-insensitive) selects which tab the panel lands on. The always-visible sidebar reacts to the same events by switching tabs. Adds apanelTabprop /panel-tabattribute (initial tab) to both betslip widgets.New
openOnSelectprop (open-on-selectattribute) on both betslip widgets: when enabled, the betslip surfaces itself (floating dialog opens; both snap to the Betslip tab) on two triggers: suggested-bet's "Bet the same" (goma:bet-the-same) on any viewport, and an odds tap (goma:outcome-select) only on a desktop viewport (at/abovemobileBreakpoint). On mobile, odds taps don't open the slip.The betslip bet type is now gated on the number of selections: a multiple needs at least two legs, so with 0 or 1 selection the slip is a single and the Multiple/System pills are disabled; adding a second selection enables Multiple and restores it as the default. The user's last explicit choice is preserved across drops back to a single leg.
Betslip "My Bets" fixes.
Ticket card: the sport icon and country flag now render without any host configuration (bundled
SbIcon+ the shared flag CDN, matching the game cards); the card gains a 1px border; the kick-off time is emphasised (10px semibold, primary text) over the date; the footer summary keeps its divider aligned when "Possible Winnings" wraps at narrow widths (labels row + single separator + values row); and the per-leg Won/Lost badge sits flush to the right edge with flat right corners.My Bets panel: the panel background uses the
backgroundBetsliptoken; the Open/Closed/Won sub-tab pills use thepillsfill with a 2pxhighlightPrimaryborder on the active pill and subtle hover/pressed feedback; and the ticket list gains uniform padding. The top Betslip / My Bets tab strip spans the full panel width with its underline flush to the content below and the inactive-tab separator aligned to the active underline.Mobile modal header: inside the mobile bottom-sheet dialog the header now hides the settings cog and the ✕ close icon and shows a flat text Close link in the
highlightPrimarycolour. Desktop and the sidebar keep the icon controls.My Bets empty state: the empty pane now reflects the user's auth state. A guest (no
sessionId) sees a flag illustration with "Offside!" / "Log in to view your bets.", and a signed-in user with no bets sees a stadium illustration with "Empty stands…" / "Place a bet and light up the game." Both illustrations are host-injectable via the newmyBetsEmptyGuestImage/myBetsEmptyAuthedImageproperties (emoji glyph fallback when unset). The Open/Closed/Won sub-tab pills are now always shown, including for guests.
0.9.2
- The floating-betslip FAB is now a circular, icon-only button (previously a labelled pill). New
triggerIconprop /trigger-iconattribute: a raw inline-SVG string used as the FAB icon (usescurrentColorso it inherits the FAB colour; empty = built-in default icon).
0.9.1
Support host-driven sessions and richer hero/betslip presentation, surfaced while building the GomaPulse client POC:
core
- The injected
sportsbookConfigis now reactive and stays in sync whenel.configis assigned after mount, so post-mount connection keys that aren't mirrored into_state(playerApiBaseUrl,betsApiBaseUrl,gameLaunchBaseUrl,apiBaseUrl) now reachinject('sportsbookConfig')consumers (useSession/useCeLogin/useMyBets). Previously the session/login flow only worked when config was set before the element connected. - The Vue mount wrapper gets
height: 100%so a host that sizes the element (definite height / flex item) propagates down to the app root: fixes the sidebar betslip's internal selections scroll.
sports-domain / betslip-sidebar / betslip-floating
- New
myBetsTabprop ('auto'default |'always'|'never') controls the MyBets header tab independent ofsessionId. The MyBets empty pane now reuses the hostemptyStateImageillustration, and its Open/Closed/Won sub-tabs are hidden for anonymous embeds. - New
requireSessionprop (defaultfalse): whentrue, the Place Bet button stays disabled until asessionIdis set (with a "Sign in to place a bet" hint). Only enforced inapimode; a no-op in event mode.
sports-domain
- Fixed selected-odds text colour: the resting
text-textOddsis no longer a static class, so the selectedbuttonTextPrimaryactually wins. Previously a selected odds button kept its resting (light) text on a lightbuttonBackgroundPrimary, leaving the label unreadable. Affects every widget that rendersRegularOdds. - Fixed swapped live-card backgrounds: the main live card now uses
backgroundDropand its secondary market cards usebackgroundSecondary(previously reversed). Affects every widget that renders live game cards.
multi-banners
- New
centeredSlidesandcenteredSlidesBoundsprops (first banner pins left, last pins right, middle centres); pagination dots now render one-per-banner driven by the snap index; and the carousel stays on the first banner until the user interacts. - Sport banner odds row now adapts its column count to the number of outcomes (1/2/3) so a 2-way market fills the card width instead of leaving an empty third column.
- The injected
0.9.0
New widget
<goma-competitions-list>: vertical list of competitions grouped by region, scoped per sport.Each region renders as a collapsible card (flag + region name + caret in the header, competition rows with separator lines in the expanded body), matching the design. Multi-open accordion; the first region card is expanded by default, controlled via
initialOpenRegionIds.- Reuses
useCompetitionsSource+useLocationsSource; joins them client-side onvenueIdto build the per-region groups. - Three data sources via
dataSource:'prop'(host-fed viael.competitions/el.regions),'rpc'(one-shotgetCompetitions+getLocationson connect; default),'subscribe'(placeholder, falls back to RPC). sportIdis mandatory (defaults to 1 = Football). Pair with the sports picker viafollowSportPickerto re-fetch on sport change.- Emits
goma:competition-selectwith the same payload as<goma-competition-filter>: downstream<goma-events-horizontal>withfollowCompetitionPicker: truecooperates with no changes. - Also emits
goma:region-toggle(informational) when a region accordion card is opened or closed.
Released alongside the rest of the lockstep family at a minor bump.
- Reuses
New widget
<goma-multi-banners>. A swipeable carousel of injectable banner cards, three types in one widget: sport events, casino game promos, and info banners.All data is host-fed via the
bannersarray (no CMS). For a sport banner, settingeventId(live or prelive) makes the widget resolve teams, start time, score and odds itself; without it the host supplies those fields inline. The background image is always host-fed.- Sport cards adapt to event state: live cards show the running clock/part and score (
home - away), prelive cards show the kickoff date + time. - Carousel chrome: prev/next arrows, pagination dots, swipe/drag, opt-in autoplay (off by default). The track is clipped + isolated so nothing bleeds over neighbouring widgets.
- Emits
goma:banner-select(every banner),goma:event-navigate(sport-card body tap → game-details), andgoma:outcome-select(sport odds tap → betslip, same shape as events-horizontal). - No new theme tokens; all colours map to existing
--goma-*tokens.
@gomagaming/sports-domainadditions (additive, no change to existing paths):- New action group
sports.bannerEvents(createBannerEventsActions): a multi-event subscription manager that accumulates events intobettingStore.events(skipStoreCleanup) so several event cards keyed by id coexist with live odds, unlike the singular-gamematchDetailspath. - Public exports for
useGameScoresTallOddsanduseGamePartsAndTimesTallOddsso other widgets can render live scores/clocks from a store event.
- Sport cards adapt to event state: live cards show the running clock/part and score (
0.8.0
New widget
<goma-top-competitions>: sidebar "Top Competitions" list.A vertical list of competition rows (sport icon + region flag + name) with a section title above. Selecting a row emits
goma:competition-selectwith the full payload (competitionId, competitionName, sportSportId, competitionVenueId, previousCompetitionId).- Three data sources via
dataSource:'prop'(host-fed),'rpc'(one-shotpopularTournamentscall on connect; default),'subscribe'(placeholder, falls back to RPC). - Sport scope set once via
config.sportId; no picker-chain wiring. - Sport icons resolved off
iconBaseUrl(/icons/sports/dark_mode/{sportId}.svg), flags offflagBaseUrl({venueId}.png). Same conventions as the rest of the filter family. - New composable
useTopCompetitionsSourcein@gomagaming/sports-domainwraps the existingoddsApi.betting.getTopCompetitionsRPC.
Released alongside the rest of the lockstep family at a minor bump.
- Three data sources via
0.7.0
Betslip widgets: authenticated session surface (balance, MyBets, BetBuilder)
Setting
sessionIdon<goma-betslip-floating>/<goma-betslip-sidebar>now wires a full authenticated-user surface in the underlying composables. Business logic only: the UI lands in a follow-up task; all reactive state ships on the existing user / betting Pinia stores so the future UI can consume it without further plumbing.Session validation + invalid-session warning
New
useSessioncomposable. OnsessionIdchange, callsGET {playerApiBaseUrl}/v1/player/session/playerwithX-SessionId. On{ IsAuthenticated: true, UserID }, populatesuseUser()withuserId+isAuthenticated: true. Any failure flipsuserStore.sessionStatusto'invalid'and the betslip panel renders an in-panel warning row (alert-styled, role=alert, i18n keysession_invalid_warning). Tri-state status:'idle'(no sessionId yet) /'validating'(probe in flight) /'valid'/'invalid'. Only the'invalid'state surfaces the warning.Balance + SSE
New
useUserBalancecomposable. Gated onisAuthenticated:- Fetches
GET {playerApiBaseUrl}/v2/player/{userId}/balancewithX-Session-Type: others+X-SessionId. Storesbalance+currencyon the user store (currency derived from the first key oftotalAmount). - Opens a fetch-based SSE stream on
/v2/player/{userId}/information/updates.BALANCE_UPDATE_V2triggers a refetch;SESSION_EXPIRATION_V2emitsgoma:session-expiredand tears the stream down. Reconnects with exponential backoff (1 s → 30 s, up to 5 attempts).
MyBets
New
useMyBetscomposable. Four tabs (open/settled/won/cashout); maps toGET {betsApiBaseUrl}/bets-api/v1/{ucsOperatorId}/open-betsor…/settled-bets(withbetStatus=WON|CASHED_OUTper tab). Headers:X-language,X-session-id,X-operator-id,X-user-id. Auto-loads on login; force-reloads aftergoma:bet-placed. Bet arrays live onbettingStore.myBets; tab state on the user store.Place-bet payload + headers aligned with EM
place-bet/v2/betscontractThe payload + headers built by
useBetslipLogicnow match the EMplace-bet/v2/betson-the-wire format:Payload:
typeis uppercase ('SINGLE'/'MULTIPLE') instead of lowercase. Receipttypealso flips. Breaking change for hosts that switched onevent.detail.type === 'multiple': update to uppercase.- Each
selections[]entry carriesmarketId: string|null(singular, not array) +bettingTypeId: string|null.outcomeNamedropped: redundant (server resolves fromoutcomeId). betBuilderPriceValueper leg is unchanged (only on legs in a BetBuilder group; carries the group's combined odds).- Top-level
freeBet: nullandubsWalletId: nulldefaults; host promotes viaplaceBetBodywhen applicable. - Auto-filled when populated:
userId,currency,lang,ucsOperatorId. Omitted otherwise so the payload doesn't lie about an unauthenticated state. - Wire-time conversion: in api mode,
amount(top-level + per-leg single-mode) is serialised as a 2-decimal string ("10.00"). The emittedgoma:place-bet-requestevent payload keepsamountas a number for host ergonomics: only the HTTP body diverges.
Headers (api mode):
- Auto-adds
userIdfrom the authenticated user store. - Auto-adds
X-OperatorIdfromconfig.ucsOperatorId. - Existing
X-SessionIdauto-add from thesessionIdprop is unchanged. - Explicit
placeBetHeadersstill win: use that to addX-SessionToken(format{userId}_{ucsOperatorId}_{random}) when the EM deployment requires it. That token isn't returned by/v1/player/session/player, so the widget can't auto-derive it.
Host identity (
username,userCountry, free-bet tokens, …) is still spread on top viaplaceBetBody. CEM event-detail descriptions on both betslip widgets are updated to the new shape.BetBuilder
New
useBetslipBettingOptionscomposable wires the provider'sbettingOptionsV2RPC to the betslip:- 2+ selections sharing an
eventIdform a BetBuilder group; the provider's response populatesmultiplePossibleWinnings.betBuilder[]with per-group combined odds. - Selections whose
bettingOfferIdis not in the combinable set getisCombinable: false.useBetslipLogic.canPlaceBetrequiresisCombinable !== falseon every leg;placeBetBlockedReasongains'place_bet_blocked_incompatible'. - The place-bet payload appends
betBuilderPriceValueper leg in BetBuilder groups so the provider scores the combo correctly. useBetslipLogicexposesselectionsGroupedByEvent(eventId-grouped view + per-group combined odds) andhasIncompatibleSelectionfor the future UI.- Falls back gracefully when the resolved provider adapter doesn't expose
getBettingOptions: combinability stays at its default (true).
New config keys (sportsbookConfig)
playerApiBaseUrl: base URL for the player REST API (session, balance, SSE updates). Falls back toapiBaseUrl.betsApiBaseUrl: base URL for the bets REST API. Falls back tobettingApiBaseUrl→apiBaseUrl.
Both registered on
@gomagaming/core'sknownTopLevelKeysso the advisoryvalidateConfigwarning stays quiet.New event
goma:session-expired: fires when the balance SSE stream receives aSESSION_EXPIRATION_V2event. Composed + bubbles past Shadow DOM.
New public exports (
@gomagaming/sports-domain)useSession,useUserBalance,useMyBets,useBetslipBettingOptions
Provider adapter contract
EM adapter's
bettingmodule gainsgetBettingOptions({ selections, stakeAmount?, type? })wrapping/sports#bettingOptionsV2. Returns{ odds, possibleWinnings, selections, betBuilder, forbiddenCombinations, rejectedSelections }. Other adapters can omit the method without breaking the betslip: the composable detects the absence and degrades to default-true combinability.- Fetches
Betslip widgets: layout / design polish (header chrome, per-selection stake, quick-add chips)
Both
<goma-betslip-floating>and<goma-betslip-sidebar>(which share theBetslipPanelcomposite from@gomagaming/sports-domain) gain a new header chrome and selection-card layout:Header chrome: settings cog (opens a popover with the existing
Accept odds changetoggle) on the left, a clear-all trash icon on the right, aBetslip / My betsunderline tab strip, and a segmented pill row for the bet-type tabs (Single / Multiple / System).My betsandSystemare visual placeholders (rendered disabled) with no functional surface yet.Per-selection stake (single-bet mode): each selection now carries its own
stakefield. In single mode the per-selection stake feeds the payout (Σ stake_i × odd_i) and the slip-levelstakeis unused; multiple mode is unchanged. Stakes persist on the sharedgoma:betslip:v3envelope so two betslip widgets (or two tabs) see the same values.Quick-add chips: a configurable row of chips (
+10 / +20 / +50by default) sits next to each selection's stake input in single mode. Hosts override via the newquickAddAmountsconfig (JSON array via thequick-add-amountsHTML attribute). Each chip adds its value to the current stake.New event:
goma:selection-stake-change({ outcomeId, stake }) fires whenever the user changes a per-selection stake (typing in the input or clicking a quick-add chip). Existinggoma:stake-changecontinues to fire for the multiple-mode footer stake.Theming: the betslip surface now reads
--goma-highlightPrimary--goma-highlightPrimaryContrastfor the active bet-type pill and Place-bet button (cyan over dark, by default), and--goma-highlightSecondaryfor the underline beneath the activeBetsliptab (violet, by default). Per-widget defaults are wired so the new layout renders correctly out of the box; hosts can override any token viael.theme = {...}as before.
Betslip widgets: My Bets tab (gated on sessionId)
The placeholder "My bets" tab in the betslip panel chrome is now interactive. When the host wires a non-empty
sessionIdon<goma-betslip-floating>/<goma-betslip-sidebar>, the tab renders alongside "Betslip" and swaps the panel body to a list of the user's placed bets. The data plumbing (useMyBetscomposable,mapMyBetmapper, REST endpoints) was already shipped in the authenticated- session-surface change; this wires the UI on top of it.Tab gating
- No
sessionId→ only the "Betslip" tab renders (panel matches v1 shape). sessionIdset → "My bets" tab renders alongside; clicking it swaps the body to<MyBetsPanel>. The settings cog, clear-all trash, and bet-type pill row hide on the My bets tab so its sub-pills own the header below the tab strip.- Host later clears
sessionIdwhile the user is on the My bets tab → the tab disappears and the panel snaps back to the Betslip tab.
MyBetsPanel: body
Three sub-pills below the tab strip: Open / Closed / Won. Map to the existing
useMyBetstabsopen/settled/won(the composable'scashouttab stays available to programmatic callers but is not exposed in the UI). Body renders one of:- Loading state (skeleton cards) when
useMyBets().loadingis true. - Empty state ("You have no open/closed/won bets yet") when the active tab returns no bets.
- A scrollable list of
<MyBetsCard>instances otherwise.
MyBetsCard: placed-bet detail card
- Header:
{Multiple|Single} Bet - {status}+ placed-at + bet ID. - One leg per
selection: sport icon + tournament + kick-off date/time, team rows with the fixture score at place-bet time, market label, and odds. - Footer: Total Odds | Bet Amount | Possible Winnings (renamed "Returned" for terminal won / cashed-out bets, with success / error text tint).
- Terminal statuses (Won / Lost / Cashed Out) paint a coloured top strip (
alertSuccess/alertError/alertWarningtokens).
New widget config keys
Both betslip widgets gain two optional config keys used by MyBetsCard:
iconBaseUrl: sport-icon CDN base. Resolves to{iconBaseUrl}/icons/sports/dark_mode/{sportId}.svg(matches the<goma-sports-navigation-horizontal>convention).flagBaseUrl: country-flag CDN base. Resolves to{flagBaseUrl}{venueId}.png(matches the<goma-region-filter>convention).
Both are optional; the glyphs are omitted entirely when unset.
Playground
betsApiBaseUrlin the EveryMatrix demo preset corrected to the sports-api-stage host that actually serves the bets-api endpoints (https://sports-api-stage.everymatrix.com). The playground manifests for both betslip widgets surface the newiconBaseUrl/flagBaseUrlfields with the OM static asset CDN pre-filled.Cursor paging (infinite scroll)
useMyBetsnow supports cursor-based paging on top of the existing single-page fetch:loadMore()action: fetches the next page usingplacedBefore=<oldest placedDate seen>(EM bets-api's cursor pattern). Appends tobettingStore.myBets[tab]viaappendMyBetsrather than replacing.hasMorecomputed: flips false when a short page (response shorter thanlimit) returns, so further requests no-op.loadingMorecomputed. Distinguishes a first-page spinner from an infinite-scroll-tail spinner.- Per-tab
myBetsCursor+myBetsNoMoreon the user store track state independently acrossopen/settled/won/cashout. - Tab switches reset cursor + noMore for the new tab so the first fetch starts fresh from
now()rather than resuming a partial scroll.
MyBetsPanel wires the scroll handler:
loadMore()fires at ~70% of the body scrollHeight (matches the source app's threshold). A small spinner row renders at the bottom of the list while a page is in flight, hidden oncehasMoreflips false.New public exports (
@gomagaming/sports-domain)MyBetsPanelMyBetsCard
New i18n keys (
@gomagaming/core)closed: "Closed" (My bets sub-pill).my_bets_empty_open/my_bets_empty_closed/my_bets_empty_won: empty-state titles per tab.
Layout: stable height across tabs
<goma-betslip-floating>'s modal panel now uses a strictheight(85dvhdesktop /88dvhmobile) instead ofmax-height, so the panel stays the same size when the user switches between the Betslip and My Bets tabs: the body region of each tab scrolls independently rather than the panel itself growing / shrinking with content.<goma-betslip-sidebar>consumers should give the host element a strictheight(notmin-height) for the same reason; the demo page's sidebar container was updated accordingly.
Behaviour change:
<goma-betslip-floating>no longer auto-closes on place-bet successThe modal previously closed automatically when a Place Bet succeeded. Now the receipt surfaces inline at the top of the panel and the user dismisses the modal explicitly (close button, backdrop, or Escape). Hosts that prefer the old auto-close UX can listen for
goma:bet-placedand toggleel.open = false.- No
Betslip widgets accept a
sessionIdfor authenticated bet submissionBoth
<goma-betslip-floating>and<goma-betslip-sidebar>now expose a top-levelsessionIdJS property (andsession-idHTML attribute). When set, the widget threads the value into the place-bet flow on both transports:placeBetMode: 'api': automatically addsX-SessionId: <sessionId>to the request headers, alongside the existingContent-Type/Accept/Accept-Languagedefaults.placeBetMode: 'event': includessessionIdon thegoma:place-bet-requestevent payload so a host listener can forward it to its own backend.
Explicit
placeBetHeaders['X-SessionId']andplaceBetBody.sessionIdstill win:sessionIdis the convenience surface; the existing maps remain the override hatch for hosts whose backend uses a different header or body key.Surfaced in the playground inspector (
Session IDfield in the basic section of both betslip widgets).Playground scenario presets, live theme-token editor, and family-wide picker-coupling defaults.
Widget runtime defaults
- events-horizontal:
followSportPicker,followRegionPicker,followCompetitionPicker,followTimePickerall flip fromfalsetotruein the element-class defaults and the playground manifest. Drop a sibling picker or filter on the page and the events feed auto-couples without any explicit config. Consumers that don't want the coupling set the flag tofalse(or the matching HTML attributefollow-sport-picker="false", etc.). - region-filter:
followSportPickerdefault flips totruein the element class + manifest. The widget will re-fetch the region list whenever a sibling sports picker emitsgoma:sport-select. - competition-filter:
followSportPickerandfollowRegionPickerdefaults both flip totrue. Drops the manual wiring step from the picker-chain composition. - region-filter / competition-filter: playground manifest's
sourcedefault flips from'mock'to'rpc'. Aligns the manifest with the element class's owndataSource: 'rpc'default; consumers of the published widgets are unaffected (mock was a playground-only convenience).
Breaking-ish note: a host page that mounts multiple of these widgets side-by-side without setting
follow*Pickerexplicitly will now see them auto-react to each other's selection events. The old isolated behaviour is restored withfollow*Picker: false.Playground SPA additions (not published; bundled into the docs site at
/playground)- Scenario presets: a named scenario is an ordered set of widgets
- per-instance configs + theme + token overrides + locale. Three built-ins ship:
full-betting(filters row + sports nav + events + game details + sidebar betslip),mobile-layout(dialog picker + events + floating betslip), anddark-neon-blue-showcase. TopBar gains a Scenario<select>that replaces widgets / configs / theme in one mutation; the dropdown auto-flips to "Custom" the moment the user diverges from the preset.
- per-instance configs + theme + token overrides + locale. Three built-ins ship:
- Live theme-token editor: new Inspector "Theme" tab grouped by prefix (Backgrounds, Text, Icons, Highlights, …). Edits write through
store.setThemeToken('--goma-X', '#hex')to the global override layer (sparseRecord<--goma-key, hex>), which Stage's fast-path watcher pushes viael.theme = …to every mounted element._applyTheme()writeshost.style.setProperty(--goma-X)reactively, so token edits repaint with no Vue remount. - Share URL: v3 envelope additively carries
themeTokensandactiveScenario. Default values are dropped from the encoded payload (size optimisation, hydrate re-applies defaults). Foreign payloads with poisonedthemeTokens(non-object, arrays, non-string values) are normalised back to{}inhydrate(). dark-minttheme renamed todark-neon-blue: palette pulled verbatim from the new Figma tokens export (Uniclub Dark Neon Blue). Highlights / live tags / button-primary flip from mint green (#5cffc0,#00ffbc) to neon cyan (#29f2ff); secondary accent flips from indigo#5b56ffto purple#8750ff. Backgrounds, text, icons, separators, dialog background are unchanged. New tokens added: inputs, alerts, betslip, game header, hero card, tickets, stats, shadows. Persisted state or share URLs that pintheme: 'dark-mint'fall back toTHEMES.defaultafter hydrate.
Tests
- New
tests/playground-scenarios.test.js: 19 vitest cases coveringapplyScenario,setThemeToken+activeScenarioCustom-flip,resetThemeTokens, share URL round-trip with the new envelope fields, hydrate normalisation of malformed payloads. - New
tests/integration/playground-scenarios.spec.ts. 4 Playwright cases: scenario → DOM mount, dark-neon-blue palette assertion (backgroundPrimary+highlightPrimary), live token edit without remount (element identity preserved), share URL round-trip across pages. tests/integration/playground-inspector.spec.ts: tab list extended with the new "Theme" tab.
- events-horizontal:
0.6.0
New widget:
<goma-competition-filter>. Horizontal pill row of competitions (tournaments, leagues) with a hardcoded "All" pill on the leftmost slot, single-selection state, and optional competition icons resolved officonBaseUrl(same convention as sport-navigation,competitions/{id}.svg). Sister widget to<goma-region-filter>; same data-source surface, same picker-chain pattern, plus an additional client-side filter wired to the region picker.What's new
- New package
@gomagaming/competition-filter. Element:<goma-competition-filter>. Reference docs:docs/widgets/competition-filter.md. - Hardcoded "All" pill emits
{ competitionId: 'all', competitionName: <localised> }viagoma:competition-select: maps straight to the matches-aggregator topic's literalallno-filter tournamentId segment. - Opt-in
followSportPicker(re-fetch on sport-select) andfollowRegionPicker(client-side filter onvenueIdvia the picked regionId;'all'disables). Both resetselectedCompetitionIdto'all'and emitgoma:competition-selecton context change so downstream listeners clear their tournamentId filter in the same tick.
@gomagaming/events-horizontaladditions- New
followCompetitionPicker+competitionPickerSelectoropt-in props. Third in the picker chain: listens forgoma:competition-selectat the window and rewrites segment index 2 (tournamentId) of the matches-aggregator topic. The existingrewriteTopicSegment(topic, idx, value)helper does the lifting: adapter-resolved prefix when available, canonical'custom-matches-aggregator/'fallback otherwise (works in mock mode + pre-WAMP-connect). - Playground manifest gains a
followCompetitionPickertoggle under "advanced".
@gomagaming/sports-domainadditionsuseCompetitionsSource(state): public composable. Watchesstate.sportId+state.regionId+state.dataSource; callsoddsApi.betting.getCompetitions({ sportId })on connect and mirrors avenueId-filtered slice ofbettingStore.competitionsintostate.competitions. The region filter runs client-side without requiring a re-fetch.subscribeToCompetitions({ sportId, afterInitialDump })stub on the everymatrix betting module (warn-once + RPC fallback, same shape assubscribeToLocations). Backend topic for live competition updates is deferred.
@gomagaming/coreadditions- Four new bundled i18n keys per locale (en/fr/pt):
competition_filter_all_label,competition_filter_aria,competition_filter_empty,competition_filter_live_events_count(vue-i18n 3-form plural for the live-count badge).
gomawt playground
competition-filterjoins the registry, typesWidgetIdunion, storeALL_WIDGETS, VitePress theme side-effect import, Stage event-log allow-list (goma:competition-select), and EventLogPanel category classifier.- demo entry-file imports added to playground/isolation/stress/ soak entries; workspace dep +
optimizeDeps.excludeentry indemo/.
Three-layer chain ready out of the box: drop a sports navigation picker, a region filter, a competition filter, and an events-horizontal feed on the same page; turn on the relevant
follow*Pickerflags and the topic chain{sportId}/{locationId}/{tournamentId}updates segment-by-segment with no host glue and no WAMP reconnect.- New package
Drop the unused
operatorIdfield from the public widget config surface. The field was declared in@gomagaming/coredefaults + TypeScript types but never read anywhere on the data path:ucsOperatorIdis the only operator identifier the EveryMatrix adapter (anduseBetslipLogic) consume. Settingconfig.operatorIdon a widget element now logs an "unknown config key" warning indebug: truemode instead of being silently accepted.Migration: consumers using
config.operatorIdshould rename toconfig.ucsOperatorId. No behavioural change for consumers who already only setucsOperatorId(which the docs have always recommended).@gomagaming/corechanges:createWidgetElement.js:COMMON_DEFAULTSno longer seedsoperatorId; theknownTopLevelKeysvalidator set drops it.types/index.d.ts: removedoperatorId?: stringfrom theGomaWidgetConfiginterface.
@gomagaming/betslip-sidebardocstring tweak:custom-elements.jsonexample endpoint placeholder changed from/place-bet/4313/v2/betsto/place-bet/2838/v2/bets(matches the staging operator id used by the playground EM demo preset). No behavioural change.
gomawt playground:
- The "Connection" panel drops the
operatorIdinput row; share-URL envelope, persistence, types, and preset filler all scrubbed in lockstep so older share URLs that carriedoperatorIdare silently ignored on hydrate (the field falls out ofSTATE_DEFAULTSand the validator'sstringFieldslist). - New mobile UI: ≤760 px viewport gets a bottom tab bar (Widgets / Stage / Inspect) routing the body panels; the topbar folds Mode/Theme/Locale/API URL behind a
⋮disclosure; Connection panel grid collapses to a single column; tab targets meet 44 px touch-target guidance and inputs use 16 px font to suppress iOS focus-zoom. - EM demo preset refreshed to the NDW staging endpoint (
wss://api-ndwstage.everymatrix.com/v2, realmwww.demo1-stage.everymatrix.com, ucsOperatorId2838).
VitePress docs site:
- Locked to dark mode (
appearance: 'force-dark'): the appearance toggle is hidden from the nav. The playground's dark--bg-*tokens no longer two-tone against a light VitePress chrome. - Homepage, introduction, publishing, and widget reference docs refreshed to current state: nine widgets shipped, eleven publishable packages in the lockstep group, example URLs scrubbed of the previous client-specific staging hostnames (now use generic
api.example.com/sports-api.example.complaceholders), exampleucsOperatorIdliteral updated to2838. Three new widget docs (region/competition/time-filter) added to the homepage feature sections.
New widget:
<goma-region-filter>. Horizontal pill row of regions (countries) with a hardcoded "All" pill on the leftmost slot, single-selection state, and an optional flag glyph per pill. Sources its region list from a one-shot WAMP RPC call on connect, with a placeholdersubscribemode that currently falls back to RPC until the backend exposes a live-update topic for locations.What's new
- New package
@gomagaming/region-filterpublished in lockstep with the existing widgets. Element:<goma-region-filter>. Reference docs:docs/widgets/region-filter.md. - Hardcoded "All" pill emits
{ regionId: 'all', regionName: 'All' }viagoma:region-select(canonical "no filter" sentinel, matches the matches-aggregator topic format wherelocationId='all'). - Flag glyphs rendered from a configurable CDN: default
flagBaseUrlis the upstream Glastcoper flag pack, resolved as${flagBaseUrl}{regionId}.png. Falls back to a globe glyph when a flag fails to load. - Opt-in
followSportPickerautomation (mirrors<goma-events-horizontal>): when enabled, the widget listens at the window forgoma:sport-selectevents emitted by sibling sports-navigation widgets and rewrites itssportIdso the region list refetches automatically. OptionalsportPickerSelectorscopes the listener to a specific picker for multi-instance disambiguation.
@gomagaming/events-horizontaladditions- New
followRegionPicker+regionPickerSelectoropt-in props. Mirror the existingfollowSportPickerplumbing but listen forgoma:region-selectand rewrite the locationId segment (index 1) of the matches-aggregator topic. The region-filter's hardcoded "All" pill emitsregionId: 'all'which lines up exactly with the matches-aggregator topic's literalallno-filter segment: no special-casing needed. Drop a<goma-region-filter>alongside an events-horizontal feed and setfollow-region-pickeron the latter; selections flow through with no host glue and no WAMP reconnect (the existinguseTopicSubscriptionwatcher rebinds the topic in place).
@gomagaming/sports-domainadditionsuseLocationsSource(state): public composable that drivesstate.regionsfrom the WAMP API. Watchesstate.dataSource,state.sportId, and the apiContainer's connection; on connect, callsoddsApi.betting.getLocations({ sportId })and mirrorsbettingStore.locationsinto the widget's reactive state.subscribeToLocations({ sportId, afterInitialDump }): new stub method on the Everymatrix betting module. Logs a one-time warning and falls back to one-shotgetAllLocationsuntil the live topic is wired backend-side. Returns a no-op unregister so the composable's cleanup contract stays uniform with the other subscribe methods.
@gomagaming/coreadditions- Three new bundled i18n keys across
en.json,fr.json, andpt.json:region_filter_aria,region_filter_empty.
gomawt playground
region-filterjoins the playground registry, storeALL_WIDGETS, typesWidgetIdunion, and VitePress theme side-effect imports. The embedded/playgroundroute can mount it alongside the other six widgets.
- New package
New widget:
<goma-time-filter>. Horizontal pill row of time-interval filters with a hardcoded "All" pill on the leftmost slot. Pure host-fed; no WAMP transport, no sport-scoping, no API container.Lighter than the rest of the filter family: no
setupAppplumbing on the element, no shared composable in@gomagaming/sports-domain. Just anintervalsprop, aselectedTimeValueselection state, and agoma:time-selectevent.What's new
- New package
@gomagaming/time-filter. Element:<goma-time-filter>. Reference docs:docs/widgets/time-filter.md. - The host feeds
el.intervals = [{ label, value }, …].labelis rendered verbatim (hosts pre-localise, only the hardcoded "All" pill is locale-driven, via the bundledtime_filter_all_labelkey).valuematches the matches-aggregator topic's hoursInterval segment ('0-1','0-24','24-48', etc.). Malformed entries drop silently; duplicates byvaluekeep the first occurrence. - The hardcoded "All" pill emits
{ timeValue: 'all', timeLabel: <localised>, previousTimeValue }viagoma:time-select: maps straight to the matches-aggregator topic's literalallno-filter hoursInterval segment.
@gomagaming/events-horizontaladditions- New
followTimePicker+timePickerSelectoropt-in props. Fourth in the picker chain. Listens forgoma:time-selectat the window and rewrites segment index 3 (hoursInterval) of the matches-aggregator topic withdetail.timeValue. Reuses the existingrewriteTopicSegment(topic, idx, value)helper (with its canonical-prefix fallback so mock-mode + pre-WAMP-connect rewrites still land). - Playground manifest gains a
followTimePickertoggle under "advanced".
@gomagaming/coreadditions- Three new bundled i18n keys per locale (en/fr/pt):
time_filter_all_label,time_filter_aria,time_filter_empty.
gomawt playground
time-filterjoins the registry, typesWidgetIdunion, storeALL_WIDGETS, VitePress theme side-effect import, Stage event-log allow-list (goma:time-select), and EventLogPanel category classifier.- demo entry-file imports added across playground/isolation/stress/ soak; workspace dep +
optimizeDeps.excludeentry indemo/.
Four-segment chain ready out of the box: drop a sports navigation picker, a region filter, a competition filter, a time filter, and an events-horizontal feed on the same page; turn on the relevant
follow*Pickerflags and the topic chain{sportId}/{locationId}/{tournamentId}/{hoursInterval}updates segment-by-segment with no host glue and no WAMP reconnect.- New package
0.5.2
- packages: wire up the now-live
gomawt.gomagaming.comdocs subdomain across npm-facing surfaces. Adds ahomepagefield to each of the eight publishablepackage.jsonfiles so the npm sidebar shows a "Homepage" link again (widget packages point at their per-widget docs page under/widgets/<name>;@gomagaming/coreand@gomagaming/sports-domainpoint at the site root). Replaces the two_coming soon_Documentation bullets in each README with real links togomawt.gomagaming.com/widgets/<name>(full reference) andgomawt.gomagaming.com/playground(live playground). No code change.
0.5.1
events-horizontal: flip
followSportPickerdefault totruein the playground manifest so integrators landing on the playground see the events feed auto-follow any sports picker they enable. The shipped widget's element-class default (followSportPicker: false) is unchanged: consumers who don't opt in viaconfig.followSportPickeror the HTML attribute still get the old behaviour.Playground + maintenance polish:
@gomagaming/events-horizontalnow exports its ownpackage.json("./package.json": "./package.json"in the exports map). Lets consumers read the package version without import-resolution errors; the embedded playground uses this to render its lockstep version badge.@gomagaming/sports-navigation-dialog's playground mock fixture now driftsliveCountevery 4 seconds, matching the horizontal sister. Previously itssubscribe()was a no-op; the dialog's badge UX now exercises while the dialog is open. Initial dataset unchanged.- Source comments scrubbed of stale internal references throughout the eight published packages. No behaviour change.
packages: scrub private-repo URLs from npm-facing metadata and rewrite client-facing READMEs. Removes
repository,homepage, andbugsfields from all eight publishablepackage.jsonfiles: the gomawt GitHub repo is private, so these fields were leaking a 404-on-anonymous link into the npm sidebar. Rewrites the six widget READMEs and the two internal-package READMEs (@gomagaming/core,@gomagaming/sports-domain) with a client-dev-targeted template: blockquote one-liner, framework-agnostic note, install snippet with.npmrc+NPM_TOKENauth blurb, widget-specific quick-start, and placeholder Documentation section (the canonical docs subdomain and public playground URL are pending DevOps; they'll fill in via find-and-replace on_coming soon_and a singlehomepagefield add perpackage.jsononce available). No code change.
0.5.0
New widget:
<goma-game-details>. Page-shaped Web Component that renders the full details for one sporting event. Header (teams, score, clock) + market-category tabs + fixed-height scrollable list of collapsible market cards with IntersectionObserver-driven batched rendering. Fills its parent; the host owns presentation context (route slot, drawer, modal, sized column, their choice). a separate change.Cross-widget cooperation works out of the box: drop a
<goma-events-horizontal>and a<goma-game-details>on the same page and card taps drive the details widget via the existinggoma:event-navigateevent (opt-out viaconfig.followEventNavigate: false).What's new
- New package
@gomagaming/game-detailspublished in lockstep alongside the existing widgets. Element:<goma-game-details>. Reference docs:docs/widgets/game-details.md. - New per-component subscription pattern (
.subscription.jsfiles) port the source post-a separate change architecture: each internal component (Header / MarketsList) owns its own WAMP subscription withonScopeDisposecleanup. Multi-instance safe: each<goma-game-details>mount gets its ownuseGameDetails()state andcreateMatchDetailsActions()closure. - Five new theme tokens with CSS-level fallbacks to existing tokens:
--goma-tabPillBackground,--goma-tabPillBackgroundActive,--goma-tabPillText,--goma-tabPillTextActive,--goma-marketCardHeaderBackground. Documented indocs/guide/theming.md → Game details. - New
marketsListHeightconfig knob (mirrored as themarkets-list-heightHTML attribute). Accepts any CSS length ('480px'default,'70vh','calc(100vh - 200px)', …): bounds the markets-list scroll viewport so the widget doesn't sprawl in hosts that don't constrain it.
@gomagaming/sports-domainadditionsactions.sports.matchDetails.*: six new WAMP actions lifted from the source repo's actions tree:subscribeToMatch/unSubscribeToMatch,subscribeToMarketGroups/unRegisterToMarketGroups,subscribeToMarketsByMarketGroupKey/unSubscribeToMarketsByMarketGroupKey. Per-instanceunregisterclosure viacreateMatchDetailsActions(): co-mounted widgets don't share WAMP handles.- New public exports:
useOddsApiActions,useActionsContainer,useLoader,useGameCardsTallOdds,RegularOdds,formattedUnixDate,formattedUnixTime,connectSportsApiReactive. These were already used internally; opening them up so external widgets (incl. this one) can compose without deep imports. adapter.createActions(router, apiContainer): adapter signature widened. Action factories close overapiContainerand readoddsApifromapiContainer.oddsApi.valuedirectly instead ofuseOddsApi()/inject(). Required because action methods are invoked from watcher callbacks that fire outside Vue's setup context (e.g., when a WAMPonInitialDumpcascades through a reactive store patch into a watcher), whereinject()returnsundefined. The closure path is reliable regardless of where the callback runs. Backwards-compatible: existing adapters that ignore the second arg keep working.useLoaderloader store gainsmatchDetailsMarketGroupMarketsflag (used by the markets-by-group action'safterInitialDumpcallback).
@gomagaming/coreadditions- Bundled i18n keys for game-details added across
en.json,fr.json, andpt.json:empty_state_match_not_available_*,empty_state_match_no_markets_*,market_categories,collapse. createWidgetElement'sCOMMON_DEFAULTSnow declare the provider / connection fields (provider,socketUrl,socketRealm,bettingApiBaseUrl,operatorId,ucsOperatorId) so every widget's reactive state bridge auto-tracks them. Setting them viael.config = { socketUrl: '…', … }propagates into the widget's_state: the newconnectSportsApiReactivehelper watches that and reconnects on runtime change.
Connection-reactive widgets (all five publishable widgets + game-details)
- Every shipped widget now uses
connectSportsApiReactiveinstead of the one-shotconnectSportsApi(...)call insetupApp. Hosts can changesocketUrl(and the other connection fields) at any time after mount and the widget tears down the old WAMP handle and re-establishes against the new params. Use case: the gomawt playground's "Apply" button for the Connection panel; any host that rotates credentials / switches environments without remounting the widget. - New public export from
@gomagaming/sports-domain:connectSportsApiReactive({ app, host, router, apiContainer, actionsContainer, name }).
gomawt playground
- New "Connection" disclosure in the TopBar with six inputs (
provider,socketUrl,socketRealm,bettingApiBaseUrl,operatorId,ucsOperatorId), Apply / Revert buttons, share-URL- localStorage persistence.
- Live connection-state badges on each widget card (driven by
goma:connectionevents). - Generic mock-fixture plumbing: fixtures can now declare arbitrary top-level keys (not just
events/sports); the Stage applies each as a JS property on the element. - Game-details added to the rail with a mock fixture (Arsenal vs Chelsea, four market groups, six markets, drifting odds + live clock).
- Events-horizontal's
subscribemode now auto-builds the WAMP topic from the manifest'ssportId+eventLimitwhen the user hasn't settopicexplicitly.
Follow-up
BetBoosttab, share button, favorite toggle, stats / lineups / H2H / commentary are explicit v2+ surfaces (recorded indocs/widgets/game-details.md → What's deferred).
- New package
Playground manifest schema additively extended (Phase 4 inspector parity with
demo/).@gomagaming/coreexports new control types (slider,textarea,json), two new tag values ('transport','mock'), an optional per-controldependsOn: { key, oneOf }field for conditional visibility, an optional top-levelthemeTokensper-widget token registry, and an optionaltopicBuilderfunction field for read-only WAMP topic previews. Phase 2 manifests continue to validate without modification (regression guard in the schema test suite).Every shipped widget's
playground.manifest.jswill be expanded in subsequent commits on this branch with the full per-widget configuration surface previously only exposed in the internaldemo/sandbox (transport block, mock-streaming knobs, full theme tokens, place-bet JSON fields where applicable). After this block the playground SPA can render the new control types and respectdependsOnfiltering; the per-widget manifests catch up in Block D.
0.4.0
Phase 2 retrofit for the betslip widgets:
@gomagaming/betslip-floatingand@gomagaming/betslip-sidebarnow ship the same./playgroundsub-export contract as the other widgets. Each has asrc/playground.manifest.jsvalidated byPlaygroundManifestSchemain@gomagaming/core, adist/playground.jsemitted alongside the main bundle (via theextraEntriesoption in@gomagaming/vite-preset), a./playgroundentry inpackage.jsonexports, and a per-package Vitest test asserting schema conformance. Unlike events-horizontal / sports-navigation-*, the betslip widgets ship nomock()fixture: they read selections from cross-widget shared storage (goma:betslip:v3) populated by events-horizontal, so the playground demos them by enabling events-horizontal alongside and tapping outcomes.The betslip widgets also join the fixed lockstep group in
.changeset/config.json, so all 7 publishable packages now version together. This fixes the version-skew introduced when betslip widgets were added at 0.2.0 in the a separate change PR without lockstep membership.The playground SPA gains all five widgets in its registry, store, Stage element map, and snippet PKG_NAMES; vite.config.ts'
optimizeDeps.includeis extended to pre-bundle all five main entries plus their./playgroundsub-exports.Betslip widgets
<goma-betslip-floating>+<goma-betslip-sidebar>ship at lockstep0.2.x, with live odds independent of<goma-events-horizontal>.@gomagaming/sports-domainNew
useBetslipSelectionsSubscriptioncomposable: each betslip widget watches its slip'sbettingOfferIds and opens its own WAMP topic subscription (/<sportsNamespace>/<ucsOperatorId>/<lang>/bettingOffers/<ids>), re-keying when the slip changes. Reconnect, locale-flip, and actions-container-ready paths all re-issue the subscribe.New
subscribeToBettingOffersin the source sportsbook's betting module: patches the per-instancebetting.bettingOfferson dump/update and per-offer-callsbetslipStore.updateBettingOfferso labels/odds refresh in the active language.New
bettingOffersTopicUrl/bettingOffersTopicSuffixhelpers intopics.js: single source of truth for the live-odds topic shape.useSharedBetslipSyncconsolidated to a singlegoma:betslip:v3envelope (replaces v2):{ selections: SelectionIdentity[], // identity-only: no odd/etc. activeBetslipId, stake, activeTab, acceptOddsChange }Live-volatile selection fields (
odd,priceUp,priceDown,isAvailable,isLive) are NEVER serialised: they live exclusively in each betslip widget's local Pinia, fed byuseBetslipOddsSync+useBetslipSelectionsSubscription. Excising volatile fields from the wire fixes intermittent betslip-row flicker when a peer widget (events-horizontal) re-broadcast a staleisAvailable/oddand fought the betslip widget's own WAMP feed.Role-gated apply/write under one key. Events-horizontal calls
useSharedBetslipSync({ subscribeToState: false }). In that mode it neither reads nor writesstake/activeTab/acceptOddsChange, and its selections-out write spreads...shared.valueso peer-written state fields survive (no lost-update of state when events-horizontal writes selections). Two echo guards (applyingSelectionsFromShared/applyingStateFromShared) keep an inbound state apply from gating concurrent local-selection writes (and vice versa).Migration. On first widget mount in any tab, runs an idempotent migration into
goma:betslip:v3from either of the older shapes:goma:betslip:v2(legacy full envelope, strips volatile on the way in) or the short-lived splitgoma:betslip-selections:v1+goma:betslip-state:v1. SeemigrateBetslipStorage.js. New schema constants andstripVolatile/stripVolatileListhelpers exported frombetslipStorageSchema.js.Deploy-window caveat: during a rollout, mixed-version tabs on the same origin do NOT share the slip: the old build keeps reading / writing its own key (v2 or v1-split) while the new build uses v3. Hard-reload after deploy to converge.
Lost-update note: simultaneous cross-tab writes to different fields of the same envelope can theoretically race (the second writer's spread is over a snapshot taken before the first's delivery). Same-tab is safe:
useSharedStoragekeeps refs in sync synchronously via BroadcastChannel +suppressBroadcast. Cross-tab the race window is sub-millisecond and the next user action re-converges. Documented, not engineered around.New shared betslip pieces:
BetslipPanel,BetslipSelectionItem,BetslipSummary,BetslipEmpty,useBetslipLogic,useBetslipOddsSync.BetslipSelectionItemrenders a dimmed disabled state whenselection.isAvailable === false.betslipPinia store getsupdateBettingOffer({ bettingOfferId, odd|odds, isAvailable, event }): splice-based for Pinia reactivity, computespriceUp/priceDown, refreshes locale-driven name fields wheneventis supplied.addSelection/upsertSelectionretained.actions.betslippromoted from a module-level singleton to a per-widget closure insidecreateActions(router): co-mounted betslip widgets no longer compete for the sameunregisterslot.actionsContainer.readyreactive ref added; the betslip composable bootstraps once both API and actions containers finish their async init.New
useBetslipSelectionsSubscriptionexport from@gomagaming/sports-domain.
@gomagaming/betslip-floating+@gomagaming/betslip-sidebar- New widgets that render the shared betslip panel: floating FAB + full-screen native
<dialog>for mobile-first layouts; always- visible<aside>for desktop layouts. Same place-bet contract (placeBetMode: 'event' | 'api'+placeBetEndpoint/placeBetHeaders/placeBetBody), same shared-storage selection sync, same per-instance WAMP subscription for live odds. - Outbound events:
goma:dialog-open/goma:dialog-close(floating only),goma:outcome-deselect,goma:bet-removed,goma:betslip-cleared,goma:stake-change,goma:place-bet-request,goma:bet-placed,goma:bet-failed,goma:retry. Listens onwindowforgoma:outcome-selectso a host can populate the slip without<goma-events-horizontal>. - Optional host-supplied formatters via
config.formatStake/config.formatOdds.
@gomagaming/events-horizontal- No longer calls
useBetslipOddsSync: its betting store feeds its own cards only; the betslip owns its own subscription. Selection ingress to the betslip stays viauseSharedBetslipSync({ subscribeToState: false }): events-horizontal writes/reads the identity-only selections channel and does NOT touch the slip-state channel (stake/tab/accept-odds-change).goma:outcome-selectevent contract unchanged.
@gomagaming/core- New i18n key
selection_unavailablein en/fr/pt for the dimmed betslip-item state.
0.2.0
Initial release. <goma-betslip-floating> ships at 0.2.0 lockstep with the rest of the @gomagaming/* widget suite (no prior 0.1.x publish).
Floating-action-button betslip: a fixed-position pill with a selection-count badge that, on tap, opens a full-screen native <dialog> hosting the shared BetslipPanel (selections list, stake input, single/multiple summary, place-bet button). Sister widget to <goma-betslip-sidebar>: same shared-storage selection sync (useSharedBetslipSync reads goma:betslip:v2 from localStorage + BroadcastChannel), same place-bet contract.
Place-bet ships in two transports controlled by placeBetMode:
'event'(default): emitsgoma:place-bet-requestwith the payload and resolves with a mock receipt (~300 ms). Host integrates its own backend by listening on the event.'api': POSTs the payload toplaceBetEndpoint(absolute URL, or path resolved againstconfig.bettingApiBaseUrl). Headers and extra body fields come fromplaceBetHeaders/placeBetBody. Mirrors the Everymatrixplace-bet/{ucsOperatorId}/v2/betscontract.
iOS Safari workarounds from the a separate change spike are internalised: top-layer <dialog>.showModal(), manual useScrollLock, ::backdrop with allow-discrete transitions, :host { width: 100%; height: 0; position: relative; z-index: 1000 } for FAB stacking in light DOM. Bottom-sheet below mobileBreakpoint, centred modal above.
Outbound events: ready, error, goma:dialog-open, goma:dialog-close, goma:outcome-deselect, goma:bet-removed, goma:betslip-cleared, goma:stake-change, goma:place-bet-request, goma:bet-placed, goma:bet-failed, goma:retry. The widget also listens on window for goma:outcome-select so a host can populate the slip without <goma-events-horizontal> on the page; betslip.addSelection dedups on outcomeId.
Optional host-supplied formatters via config.formatStake / config.formatOdds (multi-currency / locale-specific display); fall back to the bundled formatDisplayOdds / Number#toFixed(2) defaults when omitted.
All notable changes to this package will be documented in this file. Auto-generated by Changesets once a changeset entry lands; this initial entry was authored by hand.