Skip to content

<goma-betslip-sidebar> ​

Always-visible vertical betslip: renders the shared betslip panel (selections list, stake input, single/multiple summary, place-bet button) inline in the page as an <aside>. Sister widget to goma-betslip-floating: same shared-storage selection sync, same place-bet contract, same theming. Use this one in desktop layouts where you have a permanent column for the betslip; the floating variant covers mobile-first layouts.

Live demo ​

Open in the playground →: toggle Betslip (sidebar) alongside Events horizontal in the widget rail. Tap odds in the event cards to populate the shared goma:betslip:v4 envelope; the sidebar mirrors the floating variant's behaviour so the same selections appear inline instead of behind a trigger pill.

Element tag ​

html
<goma-betslip-sidebar></goma-betslip-sidebar>

The widget renders an <aside> containing the panel inline. It sets :host { display: block; width: 100%; height: 100% } so it fills its parent's height: give it a sized container (definite height or a flex column) for best results. If no height is available, both the root wrapper and the <aside> shell fall back to min-height: 360px.

Installation ​

bash
pnpm add @gomagaming/betslip-sidebar vue pinia vue-router vue-i18n @vueuse/core autobahn-browser

See Installation for the one-time .npmrc token setup. This package also declares @gomagaming/my-bets as a regular dependency: it registers <goma-my-bets> as a side effect so the panel's My Bets tab has something to mount (see Data sources below); you don't install it separately.

Configuration ​

js
const el = document.getElementById('bs')
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.

The full config surface this widget reads across its own place-bet/session/balance path (apiBaseUrl, bettingApiBaseUrl, playerApiBaseUrl, ucsOperatorId, gameLaunchBaseUrl, socketUrl/socketRealm, locale) is covered in Authenticated session surface below. betsApiBaseUrl is not read by this widget at all: it's forwarded, along with the rest of config, straight through to the embedded <goma-my-bets> widget (see Data sources).

Data sources ​

Selections: shared cross-widget envelope ​

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,
}

activeTab is 'single' | 'multiple' | 'system'. systemBetTypeId is the player's chosen system-bet type (see System bets). It crosses the wire because it's a choice, but the catalog of types it was chosen from does not: each widget derives its own catalog from the selections it holds rather than syncing a value that can go stale.

Persistence model. Each persisted selection carries its identity fields PLUS its last-known live state (odd, isAvailable, isLive) and, in single mode, its own per-selection stake, so a re-mount or peer hydration shows a real value immediately. The betslip widget's own per-instance WAMP subscription (useBetslipSelectionsSubscription + useBetslipOddsSync) refreshes the live 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), and its selections-out write spreads ...shared.value so peer-written state survives. The betslip widgets read AND write the full envelope.

Tapping an odd in events-horizontal 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: own WAMP subscription ​

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.

My Bets: embedded <goma-my-bets> widget ​

The My bets tab is not implemented by this widget's own code. BetslipPanel (the shared component both betslip widgets render) mounts the standalone <goma-my-bets> custom element and feeds it this widget's own config, theme, locale, iconBaseUrl, sessionId, myBetsEmptyGuestImage, myBetsEmptyAuthedImage, myBetsShowLiveClock (as its showLiveClock), myBetsCashoutEnabled (as its cashoutEnabled), myBetsSubTab (as its subTab) and the three cash out images whenever the session changes, and re-sends theme, locale, myBetsShowLiveClock, myBetsCashoutEnabled and myBetsSubTab when they change. The three cash out images are also re-sent when they change, but only when the new URL is not empty. The embedded widget offers cash out on open bets. After a cash out succeeds, the betslip asks for a new balance at once and again after 2.5 seconds. goma:cashout-success and goma:cashout-error bubble out of the betslip to the page; see Cross-widget wiring. 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 its reload() after each placement. See goma-my-bets's Integrates with. <goma-my-bets> then resolves its own authenticated identity and loads its own bets-api feed exactly as it does standalone: see goma-my-bets's Data sources / Auth flow for the session probe, cursor paging and live open-bet scores. The nested widget is kept mounted (v-show, not v-if) once the tab becomes visible, so it boots its shadow DOM once; switching back to the My bets tab calls its reload() method rather than re-mounting it.

Because <goma-my-bets> runs an independent useSession(), it reads and writes the same shared goma:session:v1 envelope this widget's own session probe uses: both resolve against the same sessionId and converge on the same identity, they just do it as two separate composable instances.

config.betsApiBaseUrl (falls back to bettingApiBaseUrl → apiBaseUrl inside <goma-my-bets> itself) only matters for this nested widget; it is not read anywhere in @gomagaming/betslip-sidebar's own source.

Place-bet transport ​

Two modes, controlled by placeBetMode:

ModeBehaviour
'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": "MS0xNzMy...",
  "batchId": "batch-1-1732...",
  "type": "SINGLE" | "MULTIPLE",
  "amount": 10,
  "ucsOperatorId": "2838",
  "oddsValidationType": "ACCEPT_ANY" | "ACCEPT_HIGHER" | "WITHIN_THRESHOLD",
  "terminalType": "DESKTOP",
  "freeBet": false,
  "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"
}
  • type is uppercase (SINGLE / MULTIPLE / SYSTEM): matches the EM place-bet/v2/bets contract.
  • A SYSTEM payload additionally carries systemBetType (the chosen catalog entry's id, as a string). amount stays the slip-level figure the player typed, which on the System tab is the stake per combination, not the total; the provider multiplies it out by the combination count itself. See System bets.
  • marketIds is an array holding the leg's single marketId as a string ([] when the selection carries none): the EM place-bet/v2/bets contract for this operator expects an array here even for a single market.
  • bettingTypeId is populated when the selection carries one (it travels through the cross-widget storage envelope as part of the identity fields); null otherwise.
  • betBuilderPriceValue only appears on legs that the provider's bettingOptionsV2 response flagged as part of a BetBuilder group; it carries the group's combined odds so the provider can score the combo correctly.
  • uniqueId is a per-request idempotency key the provider uses to dedupe retried submits (rapid double-clicks, network retries), not host-configurable. batchId correlates this request with the goma:place-bet-start that announced the batch and the goma:bet-placed / goma:bet-failed that settle it.
  • freeBet is always a boolean, never null: true only when the ubsWalletId sent on that same payload belongs to a bonus of type freeBet (the provider books a free bet only when the flag rides with the wallet id, and books a cash stake otherwise), false for odds boosts, stake-back, cashback and plain bets. In single-bet mode each leg's payload is flagged by that leg's own wallet. There is no ubsWalletId field on the base payload unless a bonus is picked; a host can add one via placeBetBody when the deployment needs it. With no betslip pick a placeBetBody value stays as the host sent it, so a host that sends a free-bet wallet through placeBetBody.ubsWalletId must also send freeBet: true. Except: a bonus selected in the betslip overrides a host-supplied ubsWalletId and freeBet, which the widget then writes together; see Payload precedence below.
  • ucsOperatorId is sent in the body as well as in the URL path (/place-bet/{ucsOperatorId}/v2/bets) and the X-OperatorId header, so a gateway that routes on the body field finds it there.
  • Auto-filled by the widget once useSession + useUserBalance populate the user store: userId (a string, matching the userId request header), username, currency, userCountry. userId, username and currency are omitted while unpopulated, so the payload does not imply an authenticated state it does not have. lang and userCountry are always present: lang falls back to "en", and userCountry is sent as an explicit null when unknown.
  • currency prefers the account's registered wallet currency from /v1/player/{userId}/details, falling back to the one derived from the balance response. The derived value reads the first key of the balance totalAmount map, which is ambiguous for a player holding more than one wallet (the situation a bonus wallet creates) and a mismatched currency is rejected by the provider. The details call is non-fatal: if it does not resolve, the derived currency is used exactly as before.
  • bettingTypeId is forwarded exactly as the feed delivered it, and outcomeId is re-resolved from the live betting offer when one is loaded, falling back to the stored value otherwise.

Payload precedence ​

A bonus selected in the widget is sent as ubsWalletId, with the matching freeBet boolean, and takes precedence over a ubsWalletId or freeBet supplied through placeBetBody. When no bonus is selected, the placeBetBody values are sent unchanged. These are the only fields where a widget value overrides placeBetBody; 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.

  • Host-supplied via placeBetBody (fields the widget can't derive from the session probe): userCountry, and wallet routing (ubsWalletId; the widget derives freeBet from the wallet itself, see above). Spread last so the host can override anything the widget auto-filled: including username, if a deployment needs to. A bonus picked in the betslip still wins for ubsWalletId and freeBet.

Request headers (api mode) ​

In addition to Content-Type, Accept, and Accept-Language, the widget auto-adds:

  • X-SessionId: <sessionId>: when the sessionId prop is set.
  • userId: <userId>: when useSession has populated the user store.
  • X-OperatorId: <ucsOperatorId>: when config.ucsOperatorId is set.
  • X-SessionToken: <sessionToken>: when useCeLogin has completed the two-step exchange (REST sessionId → WAMP ceSession via gameLaunchBaseUrl, then ceSession → sessionToken via /sports#loginWithCeSession). Requires config.gameLaunchBaseUrl, config.ucsOperatorId and an active WAMP socket. Override it through placeBetHeaders only if your deployment mints the token elsewhere.

Explicit entries in placeBetHeaders win over these auto-set values. 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.sessionId

In '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:

  1. Probes the session: GET {playerApiBaseUrl}/v1/player/session/player with header X-SessionId. The response shape { IsAuthenticated: true, UserID } populates a per-instance user store. Any failure or IsAuthenticated: false clears the session and gates everything below back off.
  2. Fetches the user's balance: GET {playerApiBaseUrl}/v2/player/{userId}/balance with X-Session-Type: others + X-SessionId. The response (a { [currency]: amount } map under totalAmount) is normalised onto the user store as balance / currency. Nothing in this widget's own UI renders the balance yet: see What's deferred.
  3. Opens an SSE balance stream: GET {playerApiBaseUrl}/v2/player/{userId}/information/updates. Messages of type: 'BALANCE_UPDATE_V2' trigger a balance re-fetch; messages of type: 'SESSION_EXPIRATION_V2' (or 'SESSION_EXPIRATION') invalidate the shared session and emit goma:session-expired. The stream uses a fetch-based reader (not native EventSource) so the auth headers travel with the request; it reconnects with exponential backoff up to 5 attempts (1 s → 30 s cap).
  4. Exchanges the session for a WAMP identity: the two-step useCeLogin bridge described in Request headers above. This is what produces the username the provider requires on a placed bet, and the X-SessionToken header.
  5. Resolves BetBuilder grouping: when 2+ selections share an eventId, the widget calls the provider's bettingOptionsV2 RPC to fetch combined odds + combinability metadata. Selections whose bettingOfferId is not in the combinable set get isCombinable: false and the place-bet button is gated with placeBetBlockedReason: 'incompatible'. The betBuilderPriceValue per 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 expose getBettingOptions: combinability stays at its default (true) and grouping degrades to "the same event isn't combined server-side".

The My Bets feed itself is not loaded by this list (see My Bets) embedded <goma-my-bets> widget above.

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 and the response body carries the provider's InvalidSession marker, the panel renders no error text and no warning banner. A bare 401/403 with no such marker fails that bet (goma:bet-failed) without touching the session: a stray unauthorised response is not by itself proof the credential is dead.

Instead the widget treats a confirmed 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, session-probe-401, session-probe-403, session-probe-rejected, ce-login-rejected, SESSION_EXPIRATION, SESSION_EXPIRATION_V2 from this widget's own code paths, plus whatever reason a sibling session-aware widget on the page recorded (e.g. favorites-401 from a mounted <goma-favorites>): the shared session is one envelope for the whole page. 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.

Props / attributes ​

PropertyAttributeDefaultDescription
placeBetModeplace-bet-mode'event''event' (default) or 'api'. See Place-bet transport.
placeBetEndpointplace-bet-endpoint''Absolute URL or path (resolved against config.bettingApiBaseUrl). Required when placeBetMode='api'.
placeBetHeadersplace-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.
placeBetBodyplace-bet-body{}JSON-encoded extra body fields (e.g. { userId, username, currency, ubsWalletId }). Spread last so the host can override base fields.
sessionIdsession-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. Also forwarded to the embedded <goma-my-bets> widget.
myBetsTabmy-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 guest empty state ("Offside!") until a session is wired); 'never' hides it.
panelTabpanel-tab'betslip'Active panel tab: 'betslip' or 'myBets'. Sets the tab the panel shows, and can be flipped live via the remote-control events. Requesting 'myBets' is ignored while the My bets tab isn't visible (see myBetsTab).
openOnSelectopen-on-selectfalseWhen true, the panel snaps to the Betslip tab. "Bet the same" (goma:bet-the-same) switches it on any viewport; an odds tap (goma:outcome-select) also switches it on any viewport. See Auto-select the Betslip tab on selection.
requireSessionrequire-sessionfalseWhen true, the Place Bet button stays disabled until a usable session is present (with a "Sign in to place a bet" hint), 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. Only enforced in api mode: a no-op in event mode, where the host submits through its own backend and handles auth itself.
quickAddAmountsquick-add-amounts[10, 20, 50]Quick-add chip amounts rendered next to each selection's stake input in single-bet mode, and next to the slip-level stake input in multiple mode. JSON-encoded array on the attribute. Each value is added to the current stake on click. Empty array hides the chips entirely.
emptyStateImageempty-state-image''URL of a host-supplied illustration rendered (~250px wide) above the empty-state label when the slip is empty. Omit to fall back to a small emoji glyph.
emptyStateImageAltempty-state-image-alt''Alt text for emptyStateImage.
myBetsEmptyGuestImagemy-bets-empty-guest-image''URL of the guest My Bets empty-state illustration, forwarded to the embedded <goma-my-bets> widget: shown with "Offside!" / "Log in to view your bets." when there's no sessionId. Omit to fall back to a flag emoji glyph.
myBetsEmptyAuthedImagemy-bets-empty-authed-image''URL of the authenticated My Bets empty-state illustration, forwarded to the embedded <goma-my-bets> widget: 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.
myBetsShowLiveClockmy-bets-show-live-clockfalseShows 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.
myBetsCashoutEnabledmy-bets-cashout-enabledtrueTurns 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.
myBetsSubTabmy-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.
myBetsCashoutWarningImagemy-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.
myBetsCashoutSuccessImagemy-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.
myBetsCashoutErrorImagemy-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.
iconBaseUrlicon-base-url''Sport-icon CDN base for the embedded <goma-my-bets> widget's cards: this widget's own components render no icons themselves. Reaches <goma-my-bets> in two ways: the element factory copies locale / iconBaseUrl / theme down to 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. Resolves to {iconBaseUrl}/icons/sports/dark_mode/{sportId}.svg for any sport outside the bundled icon set.
flagBaseUrlflag-base-url''Country-flag CDN base. Currently has no observable effect on My Bets cards: the flag glyph resolves from a module-level constant in the shared icon helper, and nothing forwards this value into it. Do not rely on it until that is wired up.
bonusEnabledbonus-enabledfalseTurns 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.
bonusModebonus-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.
bonusEndpointbonus-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'.
bonusHeadersbonus-headers{}JSON-encoded extra request headers for the bonus-wallets fetch. Spread last, so they override the widget's auto-derived session headers.
bonusBodybonus-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.

The widget also inherits the standard goma surface from createWidgetElement: locale, theme, appearance (see Appearance), messages, debug, config.

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.

KeyCSS variableDefaultAffects
stakeRowHeight--goma-stakeRowHeight2.5rem (40px)Height of the footer stake input and the quick-add buttons (Multiple / System).
stakeRowGap--goma-stakeRowGap10pxGap between the stake input and the quick-add buttons.
quickAddButtonMinWidth--goma-quickAddButtonMinWidthautoMinimum width of each quick-add button. It is only a minimum: the button still grows with its label.
quickAddButtonPaddingX--goma-quickAddButtonPaddingX11pxSide padding of each quick-add button.
quickAddFontSize--goma-quickAddFontSize1rem (16px)Quick-add button label size (the line height stays 1.5rem).
stakeInputBorderWidth--goma-stakeInputBorderWidth1pxBorder width of every stake input (footer and single-tab).
panelTabPaddingY--goma-panelTabPaddingY5pxTop and bottom padding of the Betslip / My bets tab labels.
panelTabLineHeight--goma-panelTabLineHeight1.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-betTypeSwitchHeight31pxHeight of each Single / Multiple / System pill.
betTypeSwitchPadding--goma-betTypeSwitchPadding2pxPadding of the switch track around the pills.
betTypeSwitchInsetX--goma-betTypeSwitchInsetX1rem (16px)Distance from the switch to the left and right panel edges.
selectionsPaddingTop--goma-selectionsPaddingTop0.75rem (12px)Space between the header and the first selection.
footerPaddingX--goma-footerPaddingX1rem (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 ​

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.

InputField showsStake
2,50 or 2.502.502.5
2. (still typing)2.2
2.5552.552.55
A pasted grouped amount, 1,000.50 or 1.000,501.001
  • 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.
  • stake in goma:stake-change and goma:selection-stake-change, in the shared betslip storage and in the place-bet amount is always a number rounded to 2 decimals.

Single vs Multiple ​

  • Multiple: one combo bet. Total Odds and Winnings come from the provider's bettingOptionsV2 answer, 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 shared stake field (envelope root + slip-level Pinia) 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 with stake: 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. 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. Quick-add chips next to the input increment the per-selection stake; the slip-level stake is ignored.

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: a Trebles (3/5) pill over a 5-selection slip is ten separate 3-leg bets. System and Multiple share the same 2-selection gate, but a system needs a third leg (MIN_SELECTIONS) before the provider has anything to offer. Below that the pill row renders "Select at least 3 bets." instead of disabling the tab, so tapping it still teaches the rule rather than sitting inert.

From 3 legs, the widget calls the provider's getSystemBetTypes RPC with the slip's bettingOfferIds and renders the returned types as pills (e.g. "3/5" for Trebles on a 5-fold slip); named systems without fold notation in their label ("Super Yankee") render their full name. Tapping a pill requests a live quote via getBettingOptions, typed SYSTEM for that specific type. Until that answer arrives, and after a stake edit until the answer for the new stake arrives, Winnings shows the empty placeholder. The chosen systemBetTypeId is synced across betslip widgets through the shared goma:betslip:v4 envelope. The catalog it was chosen from is not: each mounted widget derives its own catalog from the selections it currently holds rather than syncing a value that can go stale the moment a leg is added or removed.

The stake you type is per combination. The System tab reuses the same slip-level stake input as Multiple (BetslipSummary), but on this tab the figure is what the provider bills for a single combination: a stake of 10 on a 10-combination system spends 100. Because the wire format doesn't say so (the payload carries only the per-combination figure; the provider derives the total itself), the panel states it explicitly with a dedicated summary block: Combinations / Amount for combination / Total amount (stake × combinations).

goma:place-bet-start reports betCount: 1 for a system bet (one request, one result, however many combinations it covers) alongside combinations and systemBetType, and a totalStake of stake × combinations, which is the figure that actually leaves the player's wallet.

Below the 3-leg minimum, while the catalog is still loading, or when the provider offers no system for this slip, Place Bet is blocked with goma:place-bet-blocked reason: 'no_system_type'.

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). Same-game combos are grouped only in Multiple mode: Single mode keeps each leg as its own ticket. If the provider flags any leg as non-combinable, that leg is shown struck through inline and the place-bet button is gated (goma:place-bet-blocked reason 'incompatible') rather than banner-ed: see Events.

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 (opens a popover with the Accept odds change toggle, visible on both tabs), a clear-all trash icon (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 own chrome owns the header below the tab strip; the settings cog stays in place so the odds-change preference is reachable from either tab. Unlike <goma-betslip-floating>'s mobile bottom-sheet layout, the sidebar never switches to the flat-text mobile chrome (Settings / Delete all links, a text "Close" link): it always renders the desktop icon row, and it never renders a close control at all (the shared panel's close button is opt-in and this widget never opts in).

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. An up/down flash arrow (fading after 3s) renders next to a selection's odds chip and next to the multiple-mode total odds figure on the tick that changed it.

Layout ​

Renders an <aside> with flex flex-col h-full p-4. The selections list scrolls internally if it overflows the panel's height. The footer (summary + place-bet button) stays at the bottom of the aside.

Remote control ​

The sidebar listens on window for the same composed remote-control events as <goma-betslip-floating>, so any control on the page can switch which tab it shows:

EventdetailEffect
goma:betslip-open{ target? }Selects the target tab. The sidebar is always visible, so there's nothing to "open".
goma:betslip-toggle{ target? }Selects the target tab.
goma:betslip-close-No-op: the sidebar has no open/closed state to close.

target picks the panel tab and is normalised case/whitespace/dash-insensitively: 'betslip' / 'BetSlip' / 'slip' → the Betslip tab; 'myBets' / 'My Bets' / 'my-bets' / 'bets' → the My bets tab. Requesting myBets is ignored while that tab isn't visible (see myBetsTab). Every mounted betslip on the page reacts (no per-instance targeting yet).

js
// Jump the sidebar to My bets from a header link:
window.dispatchEvent(new CustomEvent('goma:betslip-open', { detail: { target: 'myBets' } }))

Auto-select the Betslip tab on selection ​

Set openOnSelect (attribute open-on-select) and the panel snaps to the Betslip tab when the user adds a bet, on two triggers:

html
<goma-events-horizontal></goma-events-horizontal>
<goma-betslip-sidebar open-on-select></goma-betslip-sidebar>
  • "Bet the same" (suggested-bet → goma:bet-the-same) switches to the Betslip tab on any viewport.
  • An odds tap (any odds widget → goma:outcome-select, add-only) switches it on any viewport: the pick that just landed has to be what the user sees when they next look at the slip, and the active tab is sticky state that outlives a panel being scrolled away or (on the floating variant) closed.

Both the prop and the viewport are checked live.

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 bettingOptionsV2 round-trip (useBetslipBettingOptions), whose rejectedSelections is the definitive "this can no longer be bet".

On the System tab the round-trip is typed SYSTEM for the player's chosen type (once one is chosen) rather than MULTIPLE, so a leg is re-validated against the bet actually being built rather than a combo nobody is placing: this is a deliberate divergence from the source app, which never re-validates a system's legs at all.

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 ​

SettingEffect
bonusEnabled not setNo 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 ​

  1. Tap the gift icon. A dialog lists the bonuses available for the current slip, plus a None row.
  2. Tap a row (or None). This only stages the choice.
  3. 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 ​

ModeControlScope
Multiple, SystemOne gift icon in the panel header, next to the settings cogThe whole slip
SingleA gift icon on each selection rowThat 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 bettingOptionsV2 price 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.
PropertyDefaultPurpose
bonusEndpoint/v1/bonus/wallets/sportsSent as a PUT. A path resolves against config.playerApiBaseUrl, then config.apiBaseUrl.
bonusHeadersnoneExtra request headers
bonusBodynoneExtra 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:

  1. removes it from the slip,
  2. shows the player an inline notice,
  3. emits goma:bonus-unavailable to 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:

  1. the boost is removed from the slip,
  2. the player sees the inline notice, and the host receives goma:bonus-unavailable,
  3. 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.
TopicBehaviour
ShownOnly once the bonus is applied. Not in Single mode, and not offered on single selections.
Figuremin(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.
ConditionFrom the tier's lost-selection range (minLostSelectionNumber, maxLostSelectionNumber). A bet that loses more selections than the maximum gets nothing.
WinningsUnchanged. The refund is never added to the winnings figure.
Sent with the betubsWalletId 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.

TopicBehaviour
StakeThe player's own stake. Picking cashback never changes it.
AmountNever shown. The wallet's amount field is not money the player can spend; the loss counter is the separate remainingAmount field.
Name fallbackA wallet with no presentation name shows the translated Cashback label instead of the raw type key.
Multiple / SystemA 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.
SingleA 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 betubsWalletId 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.

StatusEventDetailEmitted when
Canonicalready{}Widget mounted, initial render done.
Canonicalgoma: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>.
Canonicalgoma: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.
Canonicalgoma:betslip-cleared{}User cleared the entire slip via the "Clear" action.
Canonicalgoma: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.
Canonicalgoma: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.
Canonicalgoma: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.
Canonicalgoma: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.
Canonicalgoma: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.
Canonicalgoma: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.
Canonicalgoma: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.
Canonicalgoma: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.
Canonicalgoma: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 present only 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.
Canonicalgoma:place-bet-blocked{ reason: 'no_session' | 'session_pending' | 'no_system_type' | 'line_moved' | '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: line_moved means a handicap or total line moved off the one the player tapped (the match is alive; remove and re-pick), and it wins over unavailable, which is for legs the provider withdrew. 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. no_system_type fires only on the System tab, below the 3-leg minimum or with no usable catalog entry. 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.
Canonicalgoma: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).
Canonicalgoma: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.
Canonicalgoma: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.
Canonicalgoma: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.
Canonicalgoma: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 with its InvalidSession marker (place-bet 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.
Canonicalgoma:retry{}User clicked the retry button on the error banner.
Canonicalgoma: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.

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.

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-toggle / goma:betslip-close remote-control events: see Remote control above.

All events bubble + composed, so they cross the Shadow DOM boundary and are observable from window.addEventListener(…).

Integrates with ​

Listens for:

  • goma:outcome-select on window: always on, no gating prop. Populates the shared slip from any emitter (<goma-events-horizontal>, <goma-hero-card>, <goma-game-details>, or a host-driven dispatch).
  • goma:betslip-open / goma:betslip-toggle / goma:betslip-close: always on. open/toggle switch the panel's active tab; close is a no-op for this always-visible widget (see Remote control).
  • goma:bet-the-same and goma:outcome-select, both on any viewport: gated by openOnSelect (default false); switches the panel to the Betslip tab (see Auto-select the Betslip tab on selection). The viewport gate that exists in the shared remote-control logic only governs auto-opening a dialog, which this always-visible widget has none of.

Commonly pairs with <goma-events-horizontal>, <goma-hero-card> and <goma-game-details> upstream (odds taps feed the shared envelope), <goma-suggested-bet> upstream ("Bet the same" appends a full multi-leg bet), and <goma-betslip-floating> as its sister widget: both read and write the same envelope, so a page can run either or both without extra wiring (desktop sidebar + mobile floating dialog is the usual split). It also embeds <goma-my-bets> directly (not a peer pairing, a bundled dependency) for its My Bets tab; see Data sources.

Shared state: reads and writes the full goma:betslip:v4 envelope (selections and slip-state fields stake / activeTab / oddsChangeBehavior / activeBetslipId / systemBetTypeId / selectedBonusWalletId / selectedBonusWalletIdBySelection, unlike <goma-events-horizontal>, which calls useSharedBetslipSync({ subscribeToState: false }) and only contributes identity fields). The fetched bonus catalog itself is never written to the envelope: only the player's pick crosses the wire, so each widget fetches its own off the current selections (see Bonus mechanism). Also participates in the shared goma:session:v1 session envelope (own useSession/useCeLogin, plus the embedded <goma-my-bets>'s independent copy of the same). See Cross-widget wiring and Shared state for the full contracts.

Empty / loading / error states ​

  • Mount: the panel renders synchronously off the shared localStorage envelope; there is no network round-trip on the critical path for the Betslip tab itself, so there's no betslip-level loading spinner.
  • Empty slip. BetslipEmpty renders: the host's emptyStateImage (250px wide) or a small 🧾 emoji glyph, and the title add_bet_to_start ("Add a bet to Start"). No subtitle.
  • Place-bet in flight: the Place Bet button label swaps to loading_betslip ("Loading betslip…") while placingBet is true.
  • My bets tab: states (loading / empty guest / empty authenticated / error) belong to the embedded <goma-my-bets> widget; see its Empty / loading / error states rather than this page.
  • Render error: an onErrorCaptured boundary in BetslipSidebarApp catches a Vue render/runtime error, renders a red inline banner (ERROR: <message>) with a Retry button (emits goma:retry) and a dismiss ×, and auto-clears the banner after 8000 ms even if the user doesn't interact with it.

Theming ​

Override CSS variables via el.theme = { … }: the prefix --goma- is added automatically.

js
el.theme = {
  // Header bar (cog/trash + tabs) and footer (summary + place-bet button)
  backgroundPrimary: '#1f2435',
  // Panel shell surface (the <aside> wrapper)
  backgroundSecondary: '#252a3e',
  // Selections body surface (the scrollable selection rows)
  backgroundBetslip: '#1c2030',
  // Bet-type pill row background
  backgroundTertiary: '#1b1d2b',
  // Quick-add chip background (per-selection and multiple-mode footer)
  addBetslip: '#1b1d2b',
  // Divider line (between selection header and body, between tab and pills)
  separatorLine: '#3d425a',
  // 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',
  // Odds chip on an unavailable selection
  backgroundDisabledOdds: '#2a2f42',
  textDisabledOdds: '#6b7080',
  // Odds-chip border flash on a live tick, up vs down
  oddsUpdateUpBorder: '#21ba45',
  oddsUpdateDownBorder: '#ed4f63',
  // Error text (remove-button hover, unavailable-selection notice) + up/down flash arrows
  alertSuccess: '#21ba45',
  alertError: '#ed4f63',
}

Tokens used by this widget ​

TokenElement
--goma-backgroundPrimaryHeader bar (cog/trash + tabs) and footer (summary + place-bet)
--goma-backgroundSecondaryPanel shell surface (the <aside> wrapper)
--goma-backgroundBetslipSelections body surface (the scrollable selection rows)
--goma-backgroundTertiaryBet-type pill row background
--goma-addBetslipQuick-add stake chips (per-selection and multiple-mode footer)
--goma-separatorLineDivider inside the selection card + underline of inactive header tabs
--goma-highlightPrimaryActive bet-type pill background + Place-bet button background (cyan in the design)
--goma-highlightPrimaryContrastText on the active bet-type pill and on the Place-bet button (dark over cyan); LIVE pill text in the My Bets tab
--goma-liveTagLIVE pill on a live leg in the My Bets tab
--goma-highlightSecondaryUnderline under the active Betslip header tab + Bet Builder hammer icon + connector dots (violet in the design)
--goma-backgroundCards / --goma-backgroundBorderUnselected System-type pill background / border
--goma-textPrimaryBody text: outcome name, market name, place-bet label
--goma-textSecondarySecondary labels: match title, return / total-odds / possible-winnings labels
--goma-iconSecondarySettings cog, trash icon, close × on each selection row
--goma-backgroundDisabledOdds / --goma-textDisabledOddsOdds chip on a selection the provider has marked unavailable
--goma-oddsUpdateUpBorder / --goma-oddsUpdateDownBorderOdds-chip border flash on a live tick
--goma-alertSuccess / --goma-alertErrorUp/down flash arrows (▲/▼) + remove-button hover + unavailable-selection notice text

i18n / message overrides ​

KeyDefault (en)Used in
betslipBetslip<aside> aria-label, header tab label
my_betsMy BetsHeader tab label
betslip_settingsBetslip SettingsSettings cog aria-label + popover label
clear_betslipClear BetslipTrash-icon aria-label
single / multiple / systemSingle / Multiple / SystemBet-type pill labels
system_min_selection_total_errorSelect at least 3 bets.System tab empty state: 2 selections, below the 3-leg provider minimum
betslip_system_combinationsCombinationsSystem summary label
betslip_system_amount_for_combinationAmount for combinationSystem summary label: the per-combination stake
betslip_system_total_amountTotal amountSystem summary label: stake × combinations
betbuilderBetbuilderBet Builder card header label
total_oddsTotal OddsSummary label
possible_winningsPossible WinningsSummary label
stakeStakeStake input label (multiple-mode footer; per-selection in single mode)
return_textReturnPer-selection return-amount label (single-bet mode)
odds_up / odds_downOdds up / Odds downaria-label on the ▲/▼ flash-arrow icon (selection row + summary total)
selection_unavailableSelection unavailableInline notice under a selection the provider marked unavailable
accept_odds_anyAccept any change in oddsSettings popover: sends oddsValidationType: 'ACCEPT_ANY'
accept_odds_higherApprove higher oddsSettings popover: sends oddsValidationType: 'ACCEPT_HIGHER' (default)
accept_odds_strictDo not accept any changes in oddsSettings popover: sends oddsValidationType: 'WITHIN_THRESHOLD' (refuses any change when the account threshold is 0)
place_betPlace BetPrimary action button
loading_betslipLoading betslipPlace-bet button while in flight
add_bet_to_startAdd a bet to StartEmpty-state title
liveLiveBundled, but never rendered by this widget: the live-event dot/badge was deliberately removed from the selection row
marketMarketFallback when a selection has no marketName
removeRemove✕ button aria-label per selection / Bet Builder leg / Bet Builder group
closeCloseDialog ✕ aria-label: reachable in the shared panel component, but this widget never opts into showing a close button; also the bonus notice's own dismiss ×
retryRetryError banner button
betslip_bonus_titleYour bonusesGift-icon aria-label, bonus dialog title
betslip_bonus_chooseYou can use {count} on this bet.Bonus dialog subtitle, {count} interpolated from betslip_bonus_choose_count
betslip_bonus_choose_count1 bonusInterpolated into betslip_bonus_choose
betslip_bonus_noneNoneExplicit no-bonus radio row
betslip_bonus_expiresExpiresExpiry-date label prefix on a bonus row
betslip_bonus_already_selectedAlready selectedLabel on a bonus row already applied to another selection (single mode)
betslip_bonus_already_selected_hintThis bonus is already applied to another bet on your slip.Hint text under the above
betslip_bonus_empty_bodyYou have no bonuses available for this bet right now.Shown once the fetch has landed with zero eligible bonuses
betslip_bonus_applyApplyBonus dialog primary button
betslip_bonus_selectedSelectedApplied-bonus chip label
betslip_bonus_unavailableYour selected bonus is no longer available for this bet.Notice shown when the applied bonus is dropped as ineligible
betslip_bonus_appliedBonus appliedConfirmation notice on applying a bonus (suffixed with the bonus name, e.g. "Bonus applied: Free Bet"), auto-dismissed after 3s
betslip_cashback_labelCashbackCashback 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_explanationYour 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_removeRemove cashbackCashback row's remove-control aria-label in Multiple/System mode
cancelCancelBonus dialog header: closes without applying the pending pick
betslip_stakeback_refundStakebackStakeback 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}% StakebackProgression indicator title naming the next tier, before any tier is active
betslip_stakeback_max_reachedMax stakeback reachedProgression indicator message at the top tier
betslip_stakeback_add_selectionsAdd {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_preAdd {count} selection(s) to activateProgression indicator message when no tier is active yet
betslip_stakeback_refund_exact_onePaid if exactly 1 selection losesStakeback row condition when the tier's lost-selection range is exactly one
betslip_stakeback_refund_exactPaid if exactly {count} selections loseStakeback row condition when the tier's lost-selection range is an exact count above one
betslip_stakeback_refund_rangePaid if {min} to {max} selections loseStakeback row condition when the tier's lost-selection range has both a minimum and a maximum
betslip_stakeback_refund_up_toPaid if up to {max} selections loseStakeback row condition when the tier's lost-selection range has only a maximum

Override with config.messages:

js
el.config = {
  /* … */
  messages: {
    en: { place_bet: 'Submit bet' },
    fr: { place_bet: 'Valider le pari' },
    pt: { place_bet: 'Apostar' },
  },
}

Embedding examples ​

Vanilla HTML: event mode (consumer integrates own backend) ​

html
<div style="height: 600px; max-width: 380px;">
  <goma-betslip-sidebar id="bs"></goma-betslip-sidebar>
</div>
<script type="module">
  import '@gomagaming/betslip-sidebar'

  const el = document.getElementById('bs')
  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
<div style="height: 600px; max-width: 380px;">
  <goma-betslip-sidebar id="bs" place-bet-mode="api"></goma-betslip-sidebar>
</div>
<script type="module">
  import '@gomagaming/betslip-sidebar'

  const el = document.getElementById('bs')
  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',
  }
  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-sidebar'
import { useEffect, useRef } from 'react'

function SidebarBetslip() {
  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 (
    <div style={{ height: 600, maxWidth: 380 }}>
      <goma-betslip-sidebar ref={ref} />
    </div>
  )
}

Vue 3 ​

vue
<script setup>
import '@gomagaming/betslip-sidebar'
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>
  <div style="height: 600px; max-width: 380px;">
    <goma-betslip-sidebar
      ref="el"
      @goma:bet-placed="(e) => console.log(e.detail)"
    />
  </div>
</template>

Accessibility ​

ElementRoleAttributes
Sidebar shellaside (implicit)aria-labelledby points at an sr-only heading (localised "Betslip")
Header tab striptablisteach tab has role="tab", aria-selected; My bets only renders once visible (see myBetsTab)
Bet-type pillstablisteach pill has role="tab", aria-selected, aria-disabled; System follows the same gating as Multiple: both carry aria-disabled="true" below 2 selections
Settings cogbuttonaria-expanded reflects popover state, aria-controls points at the popover, aria-label="Betslip Settings"
Settings popoverrole="radiogroup" (non-modal)aria-label="Betslip Settings", Escape closes; click-outside (composedPath-aware so Shadow DOM is honoured) closes
Clear (trash)buttonaria-label="Clear Betslip"
Selection ✕ / Bet Builder leg ✕ / Bet Builder group ✕buttonaria-label="Remove"
Place Betbuttonaria-disabled reflects gating; stays focusable (not the disabled attribute) so a blocked press still fires goma:place-bet-blocked
Per-selection stake input<input type="number">wrapped in a <label> with sr-only text, inputmode="decimal", font-size: 16px (iOS auto-zoom suppression)
Multiple-mode stake input (footer)<input type="number">wrapped in a <label>, same iOS-safe size constraints
Quick-add chipsbuttonlabel is +10 / +20 / +50; configurable via quickAddAmounts
Odds up/down flash arrowrole="img"aria-label="Odds up" / "Odds down"
Accept odds change<input type="radio">two mutually-exclusive radios inside the settings popover, each wrapped in a <label>
Error retrybuttonlocalised text, focus-visible:ring
Unavailable selection-aria-disabled="true" on the selection row; the inline notice text is not itself a live region
Gift icon (bonus)buttonaria-pressed, aria-label (announces the eligible-bonus count when unapplied)
Bonus dialogdialog (native)Same top-layer showModal() recipe as <goma-betslip-floating>'s modal: aria-labelledby → hidden title id
Bonus row listradiogroupeach row role="radio", aria-checked; a row already applied to another selection also carries aria-disabled + disabled
Bonus chip ✕buttonaria-label="Remove"

What's deferred ​

  • No in-widget balance surface. useUserBalance populates balance / currency on the per-instance user store, but nothing in this widget's own component tree renders a balance chip yet.
  • Only four bonus types carry dedicated behaviour. The bonus mechanism is type-agnostic by design, but today only 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) have type-specific handling. 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: the docs' install command omitted the required autobahn-browser peer dependency (needed by the widget's own live-odds subscription); added.

  • Tokens/Theming: themeTokens.js was missing 7 tokens this widget's own docs page lists as used; added.

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

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

See Integration Caveats for the cross-widget picture.

Changelog ​

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

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

0.37.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 sessionId after 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. Set cashoutEnabled (attribute cashout-enabled) to false to turn it off. The widget emits two new events: goma:cashout-success (with betId, cashoutType, cashoutAmount, pending and, for a partial cash out, partialCashoutStake) and goma:cashout-error (with betId, cashoutType, a reason of not-signed-in, invalid-stake, network, http-error, rejected or unknown, and, when known, the HTTP status and the provider's message). Three new image properties, myBetsCashoutWarningImage, myBetsCashoutSuccessImage and myBetsCashoutErrorImage, 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. cashoutAmount is 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, pending is true, the bet stays in the Open list and the list is fetched again.

    <goma-betslip-sidebar> and <goma-betslip-floating> forward the new myBetsCashoutEnabled attribute (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: openSse accepts a method option and a body option, so a stream can be opened with a POST request. openSse also calls onClose when its retries run out after an error, so a consumer knows the stream is finished.

    @gomagaming/my-bets holds 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, and goma:my-bet-select can now report tab: '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-domain has 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, useMyBetsCashoutValues and useMyBetsCashout, and a new requestBalanceRefresh action on the user store.

    @gomagaming/core and @gomagaming/ui carry the shared dialog and slider styles. GomaDialog in @gomagaming/ui takes an optional panelClass with extra classes for the panel; when set, it also replaces the default border colour. In @gomagaming/core, useScrollLock now 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 new subTab property (attribute sub-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 new myBetsSubTab property (attribute my-bets-sub-tab) that sets the sub-tab inside the My Bets tab.

    cashoutEnabled on <goma-my-bets> and myBetsCashoutEnabled on 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.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-clock on <goma-my-bets>, and myBetsShowLiveClock / my-bets-show-live-clock on 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-domain adds betting.getEventPartScore(eventId) (a one-time score request), betting.subscribeToEventStatus({ eventId, onUpdate }) (the per-event status feed) and myBetsLiveStatus(event).
    • <goma-betslip-floating>: a decimal stake can now be typed with a comma or a dot on every platform. On iOS, 2,50 no longer becomes 250, and a half-typed 2. no longer reads as 0. The stake in goma:stake-change and goma:selection-stake-change is a number rounded to 2 decimals, and so is the place-bet amount.
    • <goma-betslip-sidebar>: a decimal stake can now be typed with a comma or a dot on every platform. On iOS, 2,50 no longer becomes 250, and a half-typed 2. no longer reads as 0. The stake in goma:stake-change and goma:selection-stake-change is a number rounded to 2 decimals, and so is the place-bet amount.
    • @gomagaming/ui: the betslip stake fields are text fields that keep at most 2 decimals and at most 12 characters.
    • @gomagaming/sports-domain: new normalizeStakeText and toStakeAmount helpers. 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 sends WITHIN_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: the goma:place-bet-request detail now carries oddsValidationType: '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 accept WITHIN_THRESHOLD. The custom-elements.json type 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 freeBet as a boolean: true only when the ubsWalletId on that payload belongs to a free-bet wallet (so the provider books a free bet and not a cash stake), false otherwise, where it sent null before; the goma:place-bet-request detail type changes from null|object to boolean. A betslip pick now sets ubsWalletId and freeBet together and wins over a host placeBetBody.freeBet; with no betslip pick a host that sends a free-bet wallet through placeBetBody.ubsWalletId must also send freeBet: true. The picked wallet's bonus type is now stored with the pick (and shared like the wallet id) as { walletId, type } in selectedBonusType and selectedBonusTypeBySelection of the goma:betslip:v4 envelope, 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, ubsWalletId is now absent instead of null when no bonus is picked, and oddsBoostPercent and capAmount are 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. The possibleWinnings of goma:bet-placed keeps 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-request and in the place-bet body, freeBet is now a boolean, and a betslip pick sets it. A host that used to put a free-bet token object in placeBetBody.freeBet must stop doing that. Send freeBet: true only when you send your own free-bet wallet id and the betslip has no pick.

    @gomagaming/sports-domain has two new exports: isFreeBetWallet (whether a wallet id belongs to a free-bet wallet) and freeBetNetWinnings (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, and goma:error with code API_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) and iconBaseUrl to the embedded <goma-my-bets>, and calls its reload() after a placement when it mounted standalone, because it cannot see the betslip's store.

0.37.0
  • Both betslip elements' appearance object gains an optional footerPaddingX key: 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/core is bumped for the new utility in its compiled sheet.
  • Both betslip elements' appearance object gains six optional keys for the panel's header and list: panelTabPaddingY and panelTabLineHeight (Betslip / My bets tab labels), betTypeSwitchHeight, betTypeSwitchPadding and betTypeSwitchInsetX (Single / Multiple / System switch), and selectionsPaddingTop (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/core is 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 new goma:place-bet-blocked reason bonus_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 / quickAddAmountsJson are now placeBetHeaders / placeBetBody / quickAddAmounts.
    • The time-filter and filters-settings ticks editors set ticks.
    • live-chat's layout control writes position, the config key the element reads.
    • The sports-navigation widgets drop the unread sportsJson.
    • JSON-typed defaults are real objects and arrays instead of serialized strings. The top-competitions highlightedImages string default was indexed character by character and requested bad image URLs on every load. The missions-hub emptyStateImages illustrations now show.

    No widget runtime behaviour changes.

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

  • 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-stakeInputBorder for every stake input (fall back to backgroundPrimary / separatorLine), --goma-betslipTabText for the inactive panel-tab labels (falls back to textSecondary), --goma-betslipRadioDot for the selected settings-radio centre (falls back to textPrimary) and --goma-betslipRadioBorder for the unselected radio rings (settings and bonus picker).
    • appearance object (new on both betslip elements, structure only): stakeRowHeight, stakeRowGap, quickAddButtonMinWidth, quickAddButtonPaddingX, quickAddFontSize and stakeInputBorderWidth, 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 a var() colour), so they rendered in Tailwind's #e5e7eb in every theme. Both now read --goma-betslipRadioBorder, which falls back to the intended textSecondary at 60%.

    quickAddButtonMinWidth only sets a minimum: the buttons still grow with their label.

    @gomagaming/core is bumped for the new utilities in its compiled sheet.

  • 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 amount field 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 only ubsWalletId. 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 raw name the provider sent. For a cashBack pick, goma:bonus-select, goma:bonus-remove and goma:bonus-unavailable now carry amount: 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: oddsBoost is 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 every theme change 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-domain into a new @gomagaming/ui package, which depends on it:

    • The game cards: RegularOdds and its live/prelive/outright/market variants, SkeletonCard and CountryFlag. Used by every widget that lists events.

    • The betslip family: BetslipPanel and its selection, summary and bonus pieces, and the GomaDialog modal 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.

    MyBetsPanel and MyBetsCard moved 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/ui is installed automatically with the widgets that use it.

    @gomagaming/sports-domain no longer exports any component. It keeps the stores, API clients, composables and the promo-card auto-mode layer, and additionally exports useBetslipSystem, useBonusSessionGate, bonusRowTitle, convertOdd, formatRatePercent, formattedDate, formattedTime and resolveDateLocale. A host importing one of the moved components from @gomagaming/sports-domain directly 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.05 and 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 formatStake are 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 through placeBetBody. Selecting, removing, and losing a bonus each dispatch an event the host can listen for. The feature is off until bonusEnabled is set, so existing embeds are unaffected. When bonuses are fetched (the default api mode), 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. userId is sent as a string and freeBet as null, both matching the provider contract. ucsOperatorId is 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. bettingTypeId is 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 requireSession is 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 with 401, which reads as a rejected bet rather than an unverified player. It is now also announced as goma: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: true was submitted as ACCEPT_HIGHER, which accepts a rise. Its replacement, oddsChangeBehavior, says which of accept-any, accept-higher and never-accept applies, and the last of those is a setting the boolean could not represent at all: a bet that is cancelled if the odds move in either direction.

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

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

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

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

  • A 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 through toLocaleDateString. With locale: 'en' (the bare tag, which Intl resolves to American ordering) that long form came out as October 11 next to the card's 11/10. English was the only shipped locale that disagreed with its own cards (fr and pt are already day-first, and the shared time formatter has always been pinned to en-GB), so the long form now resolves a bare en to en-GB. A host that explicitly configures en-US keeps American ordering.

    Two surfaces build their own Intl.DateTimeFormat rather 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 printed 07 October where the cards print 7 October, and sat directly under a hand-built dd/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/YY two 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.outcomes and the market reaches the card with its two sides the other way round. sortOutcomes only ranks outcomes carrying a headerNameKey, which a handicap outcome does not, so every pair compared equal and Array#sort's stability handed the layout to arrival order. For a two-sided market the comparator now falls through to the provider's outcome code: numbered codes (#1 / #2) compare numerically, so #10 does not sort before #2, and the provider's named pairs (#Y/#N, #O/#U, #Odd/#Even) are ranked the way getOutcomeOrder already 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. sortOutcomes returns 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> call sortOutcomes on their own market lists and render the shared odds button, so the new ordering and the new line on goma:outcome-select reach them too.

    The betslip. RegularOdds records the market's line (paramFloat1) on the selection at tap time, and it now survives both cross-widget bridges: the goma:betslip:v4 envelope carries it as an identity field, and goma:outcome-select carries it in its detail. Without that it never reached the widget that reads it. useBetslipOddsSync compares 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, and useBetslipLogic refuses 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: true beside isAvailable: 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) and goma:bet-failed lists those legs under a new movedLineSelections key rather than folding them into unavailableSelections, so a host does not word a live match as dead.

    placeBetBlockedReason gains '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 new place_bet_blocked_line_moved key 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.

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

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

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

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

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

0.34.0
  • 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 a username and 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 loginWithCeSession call) now has a 5s deadline, tunable with the new sessionStepTimeoutMs key (0 disables it). A timed-out StartInfo request is aborted and retried. A timed-out loginWithCeSession drops 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 error event alongside goma:error.

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

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

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

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

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

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

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

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

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

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

    Cross-cutting fixes:

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

    Per-package fixes:

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

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

0.26.0
  • Stop publishing source maps and package source.

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

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

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

0.23.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-start gains combinations and systemBetType for a system bet, and reports totalStake as stake × combinations. betCount stays 1: a system is one request producing one result, however many combinations it covers. goma:place-bet-blocked gains the reason no_system_type.

    The cross-widget envelope (goma:betslip:v3) now carries activeTab: 'system' and systemBetTypeId, 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 openOnSelect on, 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, with betCount so 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.
    • batchId on goma:place-bet-request, goma:bet-placed and goma: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", errorCode never reached the host, and every successful bet carried a fabricated bet-<timestamp> id instead of the provider's real one.

  • Session: new goma:session-ready event. The host can finally tell when a session is usable

    A 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 the userId, then the CE-login exchange fetches a ceSession and binds the WAMP socket, and only then does the place-bet payload carry the username the 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-ready closes that gap, as the counterpart to the existing goma:session-expired:

    js
    window.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:v1 envelope is public, but storage events 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 username and 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 loginWithCeSession answers { username, sessionToken, message }; a refused one answers a single-key payload, which is still truthy, so the guard let it through. That wrote status: 'valid' with a null username and a null sessionToken: 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 the userId the REST probe had already established, so the bet went out with no identity at all and came back 401. idle now only tears the identity down when the host has actually dropped its sessionId, 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/403 from 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 InvalidSession marker before condemning the session, matching the production sportsbook's handleSessionExpiration. Anything else fails that one request and leaves the session alone. The session probe (GET /v1/player/session/player) and the balance stream's SESSION_EXPIRATION event remain the authorities on whether the player is still signed in.

    A rejection is scoped to the credential it names. markInvalid() now records which sessionId failed, and the logout + goma:session-expired only 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 late 401 from the old one killed the session that had just replaced it. Requests can't be recalled: a /balance call, 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. sessionId used ??, so an empty string overwrote a real id: hosts feeding a pre-resolved identity (userId + sessionToken, no raw sessionId) wiped the id every other widget had recorded, after which isValidFor could never match and the CE-session exchange re-ran indefinitely. username had the same flaw: only the CE-login exchange learns it (the REST probe never returns one), so any other committer nulled it, and username then silently vanished from every subsequent place-bet payload. Both are preserved now; a real logout still clears them via clear().

    A spent rejection left over from a previous sessionId is 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 401 used to paint the literal string HTTP 401 into the betslip panel: the transport fallback, because the provider sends no body on a gateway rejection and HTTP/2 carries no statusText. 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 sessionId is 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_warning message key are removed: leaving the UI presenting a signed-in player who couldn't do anything was the worst of both. A place-bet 401/403 also renders no error text. goma:bet-failed still carries the full status/body for telemetry.

    goma:session-expired is the session-death contract. Every authoritative rejection funnels through useSharedSession().markInvalid(reason) and reaches the host: place-bet 401/403, favourites 401/403, the session probe, the CE-login exchange, and the SSE expiry that already fired it. detail.reason names 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:

    1. 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-expired and show your own message.
    2. Place-bet errors no longer include session failures. A 401/403 writes nothing to the panel. If you were reading the rendered error text, switch to the goma:bet-failed event, which still carries status and body.
    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 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.invalidationId is 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:v1 envelope 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 first sessionId of 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 normal sessionMaxAgeMs reuse window resumes.

    requireSession now gates on a usable session, not just a non-empty string. A stale sessionId restored from a host cookie satisfied the old check, so Place Bet stayed enabled and the submit went out to a guaranteed 401. The widget can't clear the host-owned sessionId prop, 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 playerApiBaseUrl leaves 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.

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 isAvailable it was added with was never challenged. The provider's bettingOptionsV2 response reports the dead leg in rejectedSelections, 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:

    • rejectedSelections now 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 false blocked, so a leg nobody had ruled on was treated as bettable.
    • goma:bet-failed carries unavailableSelections, 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 getBettingOptions at all (mock data, event-mode hosts, the playground) or it exposes one that cannot answer (no socketUrl, 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.

    bettingOptionsV2 returns rejectedSelections on failure envelopes, and the same-looking payload means two opposite things depending on errorCode:

    • 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 typed SINGLE, always succeeds, and hides the whole thing, which is why it only showed from the second selection onwards.

    getBettingOptions now surfaces success / errorCode / errorMessage, and the composable routes a failed quote by cause: 107 still 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 lang argument.

    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 forbiddenCombinations says 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 typed MULTIPLE whenever 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 amount set 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 for goma:place-bet-request / goma:bet-placed / goma:bet-failed now 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 bettingOptionsV2 response 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 forbiddenCombinations payload 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 alertError token consistently. The Forfeit pill, the per-level ✕ circle, and the "Forfeited" status text all paint --goma-alertError (replacing a maroon myTicketsLost pill and a hardcoded #be170f red). 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 reads 0/4 instead of 4/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 emits goma:my-bet-select on card tap. Shared sports-domain additions: subscribeToEventPartScores + parseEventPartScore (per-event live-score feed), useMyBetsScoresLive, a reloadToken force-reload hook in useMyBets, an eventResult field on mapped legs, and a card-tap select emit on MyBetsCard / 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-bets as a dependency and no longer run their own useMyBets() 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-toggle open, close, or toggle the floating dialog; a detail.target of '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 a panelTab prop / panel-tab attribute (initial tab) to both betslip widgets.

    New openOnSelect prop (open-on-select attribute) 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/above mobileBreakpoint). 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 backgroundBetslip token; the Open/Closed/Won sub-tab pills use the pills fill with a 2px highlightPrimary border 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 highlightPrimary colour. 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 new myBetsEmptyGuestImage / myBetsEmptyAuthedImage properties (emoji glyph fallback when unset). The Open/Closed/Won sub-tab pills are now always shown, including for guests.

0.9.1
  • Support host-driven sessions and richer hero/betslip presentation, surfaced while building the GomaPulse client POC:

    core

    • The injected sportsbookConfig is now reactive and stays in sync when el.config is assigned after mount, so post-mount connection keys that aren't mirrored into _state (playerApiBaseUrl, betsApiBaseUrl, gameLaunchBaseUrl, apiBaseUrl) now reach inject('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 myBetsTab prop ('auto' default | 'always' | 'never') controls the MyBets header tab independent of sessionId. The MyBets empty pane now reuses the host emptyStateImage illustration, and its Open/Closed/Won sub-tabs are hidden for anonymous embeds.
    • New requireSession prop (default false): when true, the Place Bet button stays disabled until a sessionId is set (with a "Sign in to place a bet" hint). Only enforced in api mode; a no-op in event mode.

    sports-domain

    • Fixed selected-odds text colour: the resting text-textOdds is no longer a static class, so the selected buttonTextPrimary actually wins. Previously a selected odds button kept its resting (light) text on a light buttonBackgroundPrimary, leaving the label unreadable. Affects every widget that renders RegularOdds.
    • Fixed swapped live-card backgrounds: the main live card now uses backgroundDrop and its secondary market cards use backgroundSecondary (previously reversed). Affects every widget that renders live game cards.

    multi-banners

    • New centeredSlides and centeredSlidesBounds props (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.
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 on venueId to build the per-region groups.
    • Three data sources via dataSource: 'prop' (host-fed via el.competitions / el.regions), 'rpc' (one-shot getCompetitions + getLocations on connect; default), 'subscribe' (placeholder, falls back to RPC).
    • sportId is mandatory (defaults to 1 = Football). Pair with the sports picker via followSportPicker to re-fetch on sport change.
    • Emits goma:competition-select with the same payload as <goma-competition-filter>: downstream <goma-events-horizontal> with followCompetitionPicker: true cooperates 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.

  • 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 banners array (no CMS). For a sport banner, setting eventId (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), and goma:outcome-select (sport odds tap → betslip, same shape as events-horizontal).
    • No new theme tokens; all colours map to existing --goma-* tokens.

    @gomagaming/sports-domain additions (additive, no change to existing paths):

    • New action group sports.bannerEvents (createBannerEventsActions): a multi-event subscription manager that accumulates events into bettingStore.events (skipStoreCleanup) so several event cards keyed by id coexist with live odds, unlike the singular-game matchDetails path.
    • Public exports for useGameScoresTallOdds and useGamePartsAndTimesTallOdds so other widgets can render live scores/clocks from a store event.
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-select with the full payload (competitionId, competitionName, sportSportId, competitionVenueId, previousCompetitionId).

    • Three data sources via dataSource: 'prop' (host-fed), 'rpc' (one-shot popularTournaments call 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 off flagBaseUrl ({venueId}.png). Same conventions as the rest of the filter family.
    • New composable useTopCompetitionsSource in @gomagaming/sports-domain wraps the existing oddsApi.betting.getTopCompetitions RPC.

    Released alongside the rest of the lockstep family at a minor bump.

0.7.0
  • Betslip widgets: authenticated session surface (balance, MyBets, BetBuilder)

    Setting sessionId on <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 useSession composable. On sessionId change, calls GET {playerApiBaseUrl}/v1/player/session/player with X-SessionId. On { IsAuthenticated: true, UserID }, populates useUser() with userId + isAuthenticated: true. Any failure flips userStore.sessionStatus to 'invalid' and the betslip panel renders an in-panel warning row (alert-styled, role=alert, i18n key session_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 useUserBalance composable. Gated on isAuthenticated:

    • Fetches GET {playerApiBaseUrl}/v2/player/{userId}/balance with X-Session-Type: others + X-SessionId. Stores balance + currency on the user store (currency derived from the first key of totalAmount).
    • Opens a fetch-based SSE stream on /v2/player/{userId}/information/updates. BALANCE_UPDATE_V2 triggers a refetch; SESSION_EXPIRATION_V2 emits goma:session-expired and tears the stream down. Reconnects with exponential backoff (1 s → 30 s, up to 5 attempts).

    MyBets

    New useMyBets composable. Four tabs (open / settled / won / cashout); maps to GET {betsApiBaseUrl}/bets-api/v1/{ucsOperatorId}/open-bets or …/settled-bets (with betStatus=WON|CASHED_OUT per tab). Headers: X-language, X-session-id, X-operator-id, X-user-id. Auto-loads on login; force-reloads after goma:bet-placed. Bet arrays live on bettingStore.myBets; tab state on the user store.

    Place-bet payload + headers aligned with EM place-bet/v2/bets contract

    The payload + headers built by useBetslipLogic now match the EM place-bet/v2/bets on-the-wire format:

    Payload:

    • type is uppercase ('SINGLE' / 'MULTIPLE') instead of lowercase. Receipt type also flips. Breaking change for hosts that switched on event.detail.type === 'multiple': update to uppercase.
    • Each selections[] entry carries marketId: string|null (singular, not array) + bettingTypeId: string|null. outcomeName dropped: redundant (server resolves from outcomeId).
    • betBuilderPriceValue per leg is unchanged (only on legs in a BetBuilder group; carries the group's combined odds).
    • Top-level freeBet: null and ubsWalletId: null defaults; host promotes via placeBetBody when 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 emitted goma:place-bet-request event payload keeps amount as a number for host ergonomics: only the HTTP body diverges.

    Headers (api mode):

    • Auto-adds userId from the authenticated user store.
    • Auto-adds X-OperatorId from config.ucsOperatorId.
    • Existing X-SessionId auto-add from the sessionId prop is unchanged.
    • Explicit placeBetHeaders still win: use that to add X-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 via placeBetBody. CEM event-detail descriptions on both betslip widgets are updated to the new shape.

    BetBuilder

    New useBetslipBettingOptions composable wires the provider's bettingOptionsV2 RPC to the betslip:

    • 2+ selections sharing an eventId form a BetBuilder group; the provider's response populates multiplePossibleWinnings.betBuilder[] with per-group combined odds.
    • Selections whose bettingOfferId is not in the combinable set get isCombinable: false. useBetslipLogic.canPlaceBet requires isCombinable !== false on every leg; placeBetBlockedReason gains 'place_bet_blocked_incompatible'.
    • The place-bet payload appends betBuilderPriceValue per leg in BetBuilder groups so the provider scores the combo correctly.
    • useBetslipLogic exposes selectionsGroupedByEvent (eventId-grouped view + per-group combined odds) and hasIncompatibleSelection for 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 to apiBaseUrl.
    • betsApiBaseUrl: base URL for the bets REST API. Falls back to bettingApiBaseUrl → apiBaseUrl.

    Both registered on @gomagaming/core's knownTopLevelKeys so the advisory validateConfig warning stays quiet.

    New event

    • goma:session-expired: fires when the balance SSE stream receives a SESSION_EXPIRATION_V2 event. Composed + bubbles past Shadow DOM.

    New public exports (@gomagaming/sports-domain)

    • useSession, useUserBalance, useMyBets, useBetslipBettingOptions

    Provider adapter contract

    EM adapter's betting module gains getBettingOptions({ 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.

  • Betslip widgets: layout / design polish (header chrome, per-selection stake, quick-add chips)

    Both <goma-betslip-floating> and <goma-betslip-sidebar> (which share the BetslipPanel composite 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 change toggle) on the left, a clear-all trash icon on the right, a Betslip / My bets underline tab strip, and a segmented pill row for the bet-type tabs (Single / Multiple / System). My bets and System are visual placeholders (rendered disabled) with no functional surface yet.

    • Per-selection stake (single-bet mode): each selection now carries its own stake field. In single mode the per-selection stake feeds the payout (Σ stake_i × odd_i) and the slip-level stake is unused; multiple mode is unchanged. Stakes persist on the shared goma:betslip:v3 envelope so two betslip widgets (or two tabs) see the same values.

    • Quick-add chips: a configurable row of chips (+10 / +20 / +50 by default) sits next to each selection's stake input in single mode. Hosts override via the new quickAddAmounts config (JSON array via the quick-add-amounts HTML 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). Existing goma:stake-change continues to fire for the multiple-mode footer stake.

    • Theming: the betslip surface now reads --goma-highlightPrimary

      • --goma-highlightPrimaryContrast for the active bet-type pill and Place-bet button (cyan over dark, by default), and --goma-highlightSecondary for the underline beneath the active Betslip tab (violet, by default). Per-widget defaults are wired so the new layout renders correctly out of the box; hosts can override any token via el.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 sessionId on <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 (useMyBets composable, mapMyBet mapper, 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).
    • sessionId set → "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 sessionId while 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 useMyBets tabs open / settled / won (the composable's cashout tab stays available to programmatic callers but is not exposed in the UI). Body renders one of:

    • Loading state (skeleton cards) when useMyBets().loading is 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 / alertWarning tokens).

    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

    betsApiBaseUrl in 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 new iconBaseUrl / flagBaseUrl fields with the OM static asset CDN pre-filled.

    Cursor paging (infinite scroll)

    useMyBets now supports cursor-based paging on top of the existing single-page fetch:

    • loadMore() action: fetches the next page using placedBefore=<oldest placedDate seen> (EM bets-api's cursor pattern). Appends to bettingStore.myBets[tab] via appendMyBets rather than replacing.
    • hasMore computed: flips false when a short page (response shorter than limit) returns, so further requests no-op.
    • loadingMore computed. Distinguishes a first-page spinner from an infinite-scroll-tail spinner.
    • Per-tab myBetsCursor + myBetsNoMore on the user store track state independently across open / 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 once hasMore flips false.

    New public exports (@gomagaming/sports-domain)

    • MyBetsPanel
    • MyBetsCard

    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 strict height (85dvh desktop / 88dvh mobile) instead of max-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 strict height (not min-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 success

    The 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-placed and toggle el.open = false.

  • Betslip widgets accept a sessionId for authenticated bet submission

    Both <goma-betslip-floating> and <goma-betslip-sidebar> now expose a top-level sessionId JS property (and session-id HTML attribute). When set, the widget threads the value into the place-bet flow on both transports:

    • placeBetMode: 'api': automatically adds X-SessionId: <sessionId> to the request headers, alongside the existing Content-Type / Accept / Accept-Language defaults.
    • placeBetMode: 'event': includes sessionId on the goma:place-bet-request event payload so a host listener can forward it to its own backend.

    Explicit placeBetHeaders['X-SessionId'] and placeBetBody.sessionId still win: sessionId is 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 ID field 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, followTimePicker all flip from false to true in 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 to false (or the matching HTML attribute follow-sport-picker="false", etc.).
    • region-filter: followSportPicker default flips to true in the element class + manifest. The widget will re-fetch the region list whenever a sibling sports picker emits goma:sport-select.
    • competition-filter: followSportPicker and followRegionPicker defaults both flip to true. Drops the manual wiring step from the picker-chain composition.
    • region-filter / competition-filter: playground manifest's source default flips from 'mock' to 'rpc'. Aligns the manifest with the element class's own dataSource: '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*Picker explicitly will now see them auto-react to each other's selection events. The old isolated behaviour is restored with follow*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), and dark-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.
    • 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 (sparse Record<--goma-key, hex>), which Stage's fast-path watcher pushes via el.theme = … to every mounted element. _applyTheme() writes host.style.setProperty(--goma-X) reactively, so token edits repaint with no Vue remount.
    • Share URL: v3 envelope additively carries themeTokens and activeScenario. Default values are dropped from the encoded payload (size optimisation, hydrate re-applies defaults). Foreign payloads with poisoned themeTokens (non-object, arrays, non-string values) are normalised back to {} in hydrate().
    • dark-mint theme renamed to dark-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 #5b56ff to 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 pin theme: 'dark-mint' fall back to THEMES.default after hydrate.

    Tests

    • New tests/playground-scenarios.test.js: 19 vitest cases covering applyScenario, setThemeToken + activeScenario Custom-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.
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 off iconBaseUrl (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> } via goma:competition-select: maps straight to the matches-aggregator topic's literal all no-filter tournamentId segment.
    • Opt-in followSportPicker (re-fetch on sport-select) and followRegionPicker (client-side filter on venueId via the picked regionId; 'all' disables). Both reset selectedCompetitionId to 'all' and emit goma:competition-select on context change so downstream listeners clear their tournamentId filter in the same tick.

    @gomagaming/events-horizontal additions

    • New followCompetitionPicker + competitionPickerSelector opt-in props. Third in the picker chain: listens for goma:competition-select at the window and rewrites segment index 2 (tournamentId) of the matches-aggregator topic. The existing rewriteTopicSegment(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 followCompetitionPicker toggle under "advanced".

    @gomagaming/sports-domain additions

    • useCompetitionsSource(state): public composable. Watches state.sportId + state.regionId + state.dataSource; calls oddsApi.betting.getCompetitions({ sportId }) on connect and mirrors a venueId-filtered slice of bettingStore.competitions into state.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 as subscribeToLocations). Backend topic for live competition updates is deferred.

    @gomagaming/core additions

    • 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-filter joins the registry, types WidgetId union, store ALL_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.exclude entry in demo/.

    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*Picker flags and the topic chain {sportId}/{locationId}/{tournamentId} updates segment-by-segment with no host glue and no WAMP reconnect.

  • Drop the unused operatorId field from the public widget config surface. The field was declared in @gomagaming/core defaults + TypeScript types but never read anywhere on the data path: ucsOperatorId is the only operator identifier the EveryMatrix adapter (and useBetslipLogic) consume. Setting config.operatorId on a widget element now logs an "unknown config key" warning in debug: true mode instead of being silently accepted.

    Migration: consumers using config.operatorId should rename to config.ucsOperatorId. No behavioural change for consumers who already only set ucsOperatorId (which the docs have always recommended).

    @gomagaming/core changes:

    • createWidgetElement.js: COMMON_DEFAULTS no longer seeds operatorId; the knownTopLevelKeys validator set drops it.
    • types/index.d.ts: removed operatorId?: string from the GomaWidgetConfig interface.

    @gomagaming/betslip-sidebar docstring tweak:

    • custom-elements.json example endpoint placeholder changed from /place-bet/4313/v2/bets to /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 operatorId input row; share-URL envelope, persistence, types, and preset filler all scrubbed in lockstep so older share URLs that carried operatorId are silently ignored on hydrate (the field falls out of STATE_DEFAULTS and the validator's stringFields list).
    • 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, realm www.demo1-stage.everymatrix.com, ucsOperatorId 2838).

    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.com placeholders), example ucsOperatorId literal updated to 2838. 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 placeholder subscribe mode that currently falls back to RPC until the backend exposes a live-update topic for locations.

    What's new

    • New package @gomagaming/region-filter published 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' } via goma:region-select (canonical "no filter" sentinel, matches the matches-aggregator topic format where locationId='all').
    • Flag glyphs rendered from a configurable CDN: default flagBaseUrl is the upstream Glastcoper flag pack, resolved as ${flagBaseUrl}{regionId}.png. Falls back to a globe glyph when a flag fails to load.
    • Opt-in followSportPicker automation (mirrors <goma-events-horizontal>): when enabled, the widget listens at the window for goma:sport-select events emitted by sibling sports-navigation widgets and rewrites its sportId so the region list refetches automatically. Optional sportPickerSelector scopes the listener to a specific picker for multi-instance disambiguation.

    @gomagaming/events-horizontal additions

    • New followRegionPicker + regionPickerSelector opt-in props. Mirror the existing followSportPicker plumbing but listen for goma:region-select and rewrite the locationId segment (index 1) of the matches-aggregator topic. The region-filter's hardcoded "All" pill emits regionId: 'all' which lines up exactly with the matches-aggregator topic's literal all no-filter segment: no special-casing needed. Drop a <goma-region-filter> alongside an events-horizontal feed and set follow-region-picker on the latter; selections flow through with no host glue and no WAMP reconnect (the existing useTopicSubscription watcher rebinds the topic in place).

    @gomagaming/sports-domain additions

    • useLocationsSource(state): public composable that drives state.regions from the WAMP API. Watches state.dataSource, state.sportId, and the apiContainer's connection; on connect, calls oddsApi.betting.getLocations({ sportId }) and mirrors bettingStore.locations into 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-shot getAllLocations until 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/core additions

    • Three new bundled i18n keys across en.json, fr.json, and pt.json: region_filter_aria, region_filter_empty.

    gomawt playground

    • region-filter joins the playground registry, store ALL_WIDGETS, types WidgetId union, and VitePress theme side-effect imports. The embedded /playground route can mount it alongside the other six widgets.
  • 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 setupApp plumbing on the element, no shared composable in @gomagaming/sports-domain. Just an intervals prop, a selectedTimeValue selection state, and a goma:time-select event.

    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 }, …]. label is rendered verbatim (hosts pre-localise, only the hardcoded "All" pill is locale-driven, via the bundled time_filter_all_label key). value matches the matches-aggregator topic's hoursInterval segment ('0-1', '0-24', '24-48', etc.). Malformed entries drop silently; duplicates by value keep the first occurrence.
    • The hardcoded "All" pill emits { timeValue: 'all', timeLabel: <localised>, previousTimeValue } via goma:time-select: maps straight to the matches-aggregator topic's literal all no-filter hoursInterval segment.

    @gomagaming/events-horizontal additions

    • New followTimePicker + timePickerSelector opt-in props. Fourth in the picker chain. Listens for goma:time-select at the window and rewrites segment index 3 (hoursInterval) of the matches-aggregator topic with detail.timeValue. Reuses the existing rewriteTopicSegment(topic, idx, value) helper (with its canonical-prefix fallback so mock-mode + pre-WAMP-connect rewrites still land).
    • Playground manifest gains a followTimePicker toggle under "advanced".

    @gomagaming/core additions

    • Three new bundled i18n keys per locale (en/fr/pt): time_filter_all_label, time_filter_aria, time_filter_empty.

    gomawt playground

    • time-filter joins the registry, types WidgetId union, store ALL_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.exclude entry in demo/.

    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*Picker flags and the topic chain {sportId}/{locationId}/{tournamentId}/{hoursInterval} updates segment-by-segment with no host glue and no WAMP reconnect.

0.5.2
  • packages: wire up the now-live gomawt.gomagaming.com docs subdomain across npm-facing surfaces. Adds a homepage field to each of the eight publishable package.json files so the npm sidebar shows a "Homepage" link again (widget packages point at their per-widget docs page under /widgets/<name>; @gomagaming/core and @gomagaming/sports-domain point at the site root). Replaces the two _coming soon_ Documentation bullets in each README with real links to gomawt.gomagaming.com/widgets/<name> (full reference) and gomawt.gomagaming.com/playground (live playground). No code change.
0.5.1
  • events-horizontal: flip followSportPicker default to true in 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 via config.followSportPicker or the HTML attribute still get the old behaviour.

  • Playground + maintenance polish:

    • @gomagaming/events-horizontal now exports its own package.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 drifts liveCount every 4 seconds, matching the horizontal sister. Previously its subscribe() 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, and bugs fields from all eight publishable package.json files: 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_TOKEN auth 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 single homepage field add per package.json once 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 existing goma:event-navigate event (opt-out via config.followEventNavigate: false).

    What's new

    • New package @gomagaming/game-details published in lockstep alongside the existing widgets. Element: <goma-game-details>. Reference docs: docs/widgets/game-details.md.
    • New per-component subscription pattern (.subscription.js files) port the source post-a separate change architecture: each internal component (Header / MarketsList) owns its own WAMP subscription with onScopeDispose cleanup. Multi-instance safe: each <goma-game-details> mount gets its own useGameDetails() state and createMatchDetailsActions() closure.
    • Five new theme tokens with CSS-level fallbacks to existing tokens: --goma-tabPillBackground, --goma-tabPillBackgroundActive, --goma-tabPillText, --goma-tabPillTextActive, --goma-marketCardHeaderBackground. Documented in docs/guide/theming.md → Game details.
    • New marketsListHeight config knob (mirrored as the markets-list-height HTML 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-domain additions

    • actions.sports.matchDetails.*: six new WAMP actions lifted from the source repo's actions tree: subscribeToMatch / unSubscribeToMatch, subscribeToMarketGroups / unRegisterToMarketGroups, subscribeToMarketsByMarketGroupKey / unSubscribeToMarketsByMarketGroupKey. Per-instance unregister closure via createMatchDetailsActions(): 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 over apiContainer and read oddsApi from apiContainer.oddsApi.value directly instead of useOddsApi() / inject(). Required because action methods are invoked from watcher callbacks that fire outside Vue's setup context (e.g., when a WAMP onInitialDump cascades through a reactive store patch into a watcher), where inject() returns undefined. The closure path is reliable regardless of where the callback runs. Backwards-compatible: existing adapters that ignore the second arg keep working.
    • useLoader loader store gains matchDetailsMarketGroupMarkets flag (used by the markets-by-group action's afterInitialDump callback).

    @gomagaming/core additions

    • Bundled i18n keys for game-details added across en.json, fr.json, and pt.json: empty_state_match_not_available_*, empty_state_match_no_markets_*, market_categories, collapse.
    • createWidgetElement's COMMON_DEFAULTS now declare the provider / connection fields (provider, socketUrl, socketRealm, bettingApiBaseUrl, operatorId, ucsOperatorId) so every widget's reactive state bridge auto-tracks them. Setting them via el.config = { socketUrl: '…', … } propagates into the widget's _state: the new connectSportsApiReactive helper watches that and reconnects on runtime change.

    Connection-reactive widgets (all five publishable widgets + game-details)

    • Every shipped widget now uses connectSportsApiReactive instead of the one-shot connectSportsApi(...) call in setupApp. Hosts can change socketUrl (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:connection events).
    • 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 subscribe mode now auto-builds the WAMP topic from the manifest's sportId + eventLimit when the user hasn't set topic explicitly.

    Follow-up

    • BetBoost tab, share button, favorite toggle, stats / lineups / H2H / commentary are explicit v2+ surfaces (recorded in docs/widgets/game-details.md → What's deferred).
  • Playground manifest schema additively extended (Phase 4 inspector parity with demo/). @gomagaming/core exports new control types (slider, textarea, json), two new tag values ('transport', 'mock'), an optional per-control dependsOn: { key, oneOf } field for conditional visibility, an optional top-level themeTokens per-widget token registry, and an optional topicBuilder function 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.js will be expanded in subsequent commits on this branch with the full per-widget configuration surface previously only exposed in the internal demo/ 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 respect dependsOn filtering; the per-widget manifests catch up in Block D.

0.4.0
  • Phase 2 retrofit for the betslip widgets: @gomagaming/betslip-floating and @gomagaming/betslip-sidebar now ship the same ./playground sub-export contract as the other widgets. Each has a src/playground.manifest.js validated by PlaygroundManifestSchema in @gomagaming/core, a dist/playground.js emitted alongside the main bundle (via the extraEntries option in @gomagaming/vite-preset), a ./playground entry in package.json exports, and a per-package Vitest test asserting schema conformance. Unlike events-horizontal / sports-navigation-*, the betslip widgets ship no mock() 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.include is extended to pre-bundle all five main entries plus their ./playground sub-exports.

  • Betslip widgets <goma-betslip-floating> + <goma-betslip-sidebar> ship at lockstep 0.2.x, with live odds independent of <goma-events-horizontal>.

    @gomagaming/sports-domain

    • New useBetslipSelectionsSubscription composable: each betslip widget watches its slip's bettingOfferIds 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 subscribeToBettingOffers in the source sportsbook's betting module: patches the per-instance betting.bettingOffers on dump/update and per-offer-calls betslipStore.updateBettingOffer so labels/odds refresh in the active language.

    • New bettingOffersTopicUrl / bettingOffersTopicSuffix helpers in topics.js: single source of truth for the live-odds topic shape.

    • useSharedBetslipSync consolidated to a single goma:betslip:v3 envelope (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 by useBetslipOddsSync + useBetslipSelectionsSubscription. Excising volatile fields from the wire fixes intermittent betslip-row flicker when a peer widget (events-horizontal) re-broadcast a stale isAvailable / odd and 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 writes stake / activeTab / acceptOddsChange, and its selections-out write spreads ...shared.value so 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:v3 from either of the older shapes: goma:betslip:v2 (legacy full envelope, strips volatile on the way in) or the short-lived split goma:betslip-selections:v1 + goma:betslip-state:v1. See migrateBetslipStorage.js. New schema constants and stripVolatile/stripVolatileList helpers exported from betslipStorageSchema.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: useSharedStorage keeps 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. BetslipSelectionItem renders a dimmed disabled state when selection.isAvailable === false.

    • betslip Pinia store gets updateBettingOffer({ bettingOfferId, odd|odds, isAvailable, event }): splice-based for Pinia reactivity, computes priceUp/priceDown, refreshes locale-driven name fields when event is supplied. addSelection / upsertSelection retained.

    • actions.betslip promoted from a module-level singleton to a per-widget closure inside createActions(router): co-mounted betslip widgets no longer compete for the same unregister slot.

    • actionsContainer.ready reactive ref added; the betslip composable bootstraps once both API and actions containers finish their async init.

    • New useBetslipSelectionsSubscription export 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 on window for goma:outcome-select so 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 via useSharedBetslipSync({ 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-select event contract unchanged.

    @gomagaming/core

    • New i18n key selection_unavailable in en/fr/pt for the dimmed betslip-item state.
0.2.0

Initial release. <goma-betslip-sidebar> ships at 0.2.0 lockstep with the rest of the @gomagaming/* widget suite (no prior 0.1.x publish).

Always-visible vertical betslip: renders the shared BetslipPanel (selections list, stake input, single/multiple summary, place-bet button) inline as an <aside>. Sister widget to <goma-betslip-floating>: same shared-storage selection sync (useSharedBetslipSync reads goma:betslip:v2 from localStorage + BroadcastChannel), same place-bet contract. Use this one in desktop layouts where you have a permanent column for the betslip.

Place-bet ships in two transports controlled by placeBetMode:

  • 'event' (default): emits goma:place-bet-request with the payload and resolves with a mock receipt (~300 ms). Host integrates its own backend by listening on the event.
  • 'api': POSTs the payload to placeBetEndpoint (absolute URL, or path resolved against config.bettingApiBaseUrl). Headers and extra body fields come from placeBetHeaders / placeBetBody. Mirrors the Everymatrix place-bet/{ucsOperatorId}/v2/bets contract.

Wants a sized parent (definite height or flex column); falls back to min-height: 360px when no height is available. :host { display: block; width: 100%; height: 100% }.

Outbound events: ready, error, 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.