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. 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:v3 envelope; the sidebar mirrors the floating variant's behaviour so the same selections appear inline instead of behind a trigger pill.
Install
bash
pnpm add @gomagaming/betslip-sidebar vue pinia vue-router vue-i18n @vueuse/coreElement
html
<goma-betslip-sidebar id="bs"></goma-betslip-sidebar>The widget renders an <aside> containing the panel inline. It sets :host { 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, it falls back to min-height: 360px.
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.
Selection source
The betslip widget itself doesn't capture selections. It reads a single cross-widget shared envelope at goma:betslip:v3 (backed by localStorage + BroadcastChannel):
{
selections: [{ outcomeId, bettingOfferId, eventId, eventName, marketId,
marketName, outcomeName, sportId, competitionId,
homeParticipantName, awayParticipantName, eventDate,
tournamentName, idfosport, venueId, venueName,
bettingTypeId, eventPartId }], // identity ONLY
activeBetslipId, stake, activeTab, acceptOddsChange,
}Persistence model. Each persisted selection carries identity fields PLUS the last-known live state (odd, isAvailable, isLive) so a re-mount or peer hydration shows a real value immediately. The betslip widget's own per-instance WAMP subscription (useBetslipSelectionsSubscription + useBetslipOddsSync) refreshes those fields on the next tick. Pure UI flash flags (priceUp, priceDown) are NEVER serialised — the component derives its own up/down indicator from a local watch on selection.odd. Selection-row flicker is prevented by updates.js no longer pushing stale isAvailable from the events-horizontal pipeline — leaving the betslip widget's WAMP feed as the single authoritative writer for live fields.
Role separation under one key. Events-horizontal calls useSharedBetslipSync({ subscribeToState: false }): it neither reads nor writes the slip-state fields (stake / activeTab / acceptOddsChange), 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 calls betslipStore.addSelection(…) which writes the new selection (identity only) into the envelope. This widget's useSharedBetslipSync applies the inbound change into its local Pinia and the panel re-renders. Live odds + availability for that selection arrive a moment later via this widget's own WAMP feed; until the first tick lands, peer-driven rows render the placeholder (-) for odds. The widget also listens on window for goma:outcome-select (composed events bubble out of any shadow root), so a host can populate the slip from a custom UI without <goma-events-horizontal> on the page.
A one-shot migration runs on first widget mount in any tab — older goma:betslip:v2 envelopes (full shape) and the short-lived goma:betslip-selections:v1 + goma:betslip-state:v1 split are merged into v3 (transient flash flags stripped on the way in; identity + last-known live state preserved) and the legacy entries deleted. Idempotent across tabs.
Live odds
The widget opens its own WAMP subscription per instance — independent of <goma-events-horizontal>. When config.socketUrl/config.socketRealm is set, it watches the active betslip's selections and registers a single topic keyed on the current set of bettingOfferIds. The default URL shape:
/<sportsNamespace>/<ucsOperatorId>/<lang>/bettingOffers/<id1>,<id2>,...The subscription re-keys whenever the slip's selection set changes. On dump and update the per-instance betting.bettingOffers store is patched; useBetslipOddsSync's deep watcher then rewrites each matching selection's odd/priceUp/priceDown/isAvailable. Without socketUrl the widget still mounts and placeBet still works against the snapshot prices captured at addSelection time.
Place-bet transport
Two modes, controlled by placeBetMode:
| Mode | Behaviour |
|---|---|
'event' (default) | Emits goma:place-bet-request with the payload, then resolves with a mock receipt after ~300 ms (slip cleared, goma:bet-placed fires). The host integrates its own backend by listening to goma:place-bet-request — the mock receipt keeps the UI honest while you wire the real call. |
'api' | POSTs the payload to placeBetEndpoint (absolute URL or path resolved against config.bettingApiBaseUrl). Adds Content-Type: application/json + Accept: application/json + Accept-Language: <locale> plus any keys from placeBetHeaders. Spreads placeBetBody into the request body so the host supplies identity (userId, username, currency, ucsOperatorId, …) without the widget knowing the provider's specifics. Treats either !response.ok or response.body.success === false as failure. |
The base request body (always included):
json
{
"type": "SINGLE" | "MULTIPLE",
"amount": 10,
"oddsValidationType": "ACCEPT_ANY" | "ACCEPT_HIGHER",
"terminalType": "DESKTOP",
"freeBet": null,
"ubsWalletId": null,
"selections": [
{
"bettingOfferId": "…",
"priceValue": 2.5,
"eventId": "…",
"marketId": "…",
"bettingTypeId": "…",
"outcomeId": "…",
"betBuilderPriceValue": 3.2
}
],
"userId": "…",
"currency": "…",
"lang": "en",
"ucsOperatorId": "…",
"sessionId": "…",
"...placeBetBody": "spread last so host can override base fields"
}typeis uppercase (SINGLE/MULTIPLE/ futureSYSTEM) — matches the EMplace-bet/v2/betscontract.marketIdis a single string (provider-confirmed shape — not amarketIdsarray as some older EM samples show).bettingTypeIdis populated when the selection carries one (it travels through the cross-widget storage envelope as part of the identity fields);nullotherwise.betBuilderPriceValueonly appears on legs that the provider'sbettingOptionsV2response flagged as part of a BetBuilder group; it carries the group's combined odds so the provider can score the combo correctly.- Auto-filled by the widget once
useSession+useUserBalancepopulate the user store:userId,currency,lang,ucsOperatorId. Omitted when unpopulated so the payload doesn't lie about an unauthenticated state. - Host-supplied via
placeBetBody(fields the widget can't derive from the session probe):username,userCountry, and any free-bet / wallet routing fields (freeBet,ubsWalletId). - Wire conversion: in api mode,
amount(and per-legamountin single-bet mode) is serialised as a 2-decimal string ("10.00") to match the EM contract. The in-processgoma:place-bet-requestevent payload keepsamountas a number for host ergonomics.
Request headers (api mode)
In addition to Content-Type, Accept, and Accept-Language, the widget auto-adds:
X-SessionId: <sessionId>— when thesessionIdprop is set.userId: <userId>— whenuseSessionhas populated the user store.X-OperatorId: <ucsOperatorId>— whenconfig.ucsOperatorIdis set.
Explicit entries in placeBetHeaders win over these auto-set values — use that to add X-SessionToken (format {userId}_{ucsOperatorId}_{random}) when your EM deployment requires it. X-SessionToken isn't returned by /v1/player/session/player so the widget can't auto-derive it.
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 (see sportsbook-frontend-demo/src/api/everymatrix/modules/betting.js); other backends can either follow the same shape or remap on the host side via goma:place-bet-request.
Authenticated submissions
Set the sessionId prop with the user's session token to authenticate the request without having to wire it through placeBetHeaders / placeBetBody by hand:
js
el.placeBetMode = 'api'
el.placeBetEndpoint = '/place-bet/2838/v2/bets'
el.sessionId = currentUser.sessionIdIn 'api' mode the widget adds X-SessionId: <sessionId> to the request headers automatically. In 'event' mode the same token appears on the goma:place-bet-request payload as sessionId, so a host listener can forward it to its own backend:
js
el.addEventListener('goma:place-bet-request', async (e) => {
await fetch('/my/internal/bets', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-SessionId': e.detail.sessionId },
body: JSON.stringify(e.detail),
})
})If the host needs a different header or body key (e.g. Authorization: Bearer …, or a backend that calls the field token), use placeBetHeaders / placeBetBody — explicit values still win over the sessionId shortcut.
Authenticated session surface
Setting sessionId does more than thread the token onto the place-bet request. When the prop is populated AND config.playerApiBaseUrl is configured, the widget:
- Probes the session —
GET {playerApiBaseUrl}/v1/player/session/playerwith headerX-SessionId. The response shape{ IsAuthenticated: true, UserID }populates a per-instance user store. Any failure orIsAuthenticated: falseclears the session and gates everything below back off. - Fetches the user's balance —
GET {playerApiBaseUrl}/v2/player/{userId}/balancewithX-Session-Type: others+X-SessionId. The response (a{ [currency]: amount }map undertotalAmount) is normalised onto the user store asbalance/currency. - Opens an SSE balance stream —
GET {playerApiBaseUrl}/v2/player/{userId}/information/updates. Messages oftype: 'BALANCE_UPDATE_V2'trigger a balance re-fetch; messages oftype: 'SESSION_EXPIRATION_V2'emitgoma:session-expiredand clear the session. The stream uses a fetch-based reader (not nativeEventSource) so the auth headers travel with the request; it reconnects with exponential backoff up to 5 attempts (1 s → 30 s). - Loads MyBets — once
ucsOperatorIdis set (viaconfig.ucsOperatorId) andbetsApiBaseUrlis configured,GET {betsApiBaseUrl}/bets-api/v1/{ucsOperatorId}/open-bets(or/settled-betsfor the won / cashout / settled tabs) populates the user's bet history on the betting store. Active tab is settable viauseMyBets().setActiveTab('won' | 'cashout' | 'settled' | 'open'). Auto-reloads on login and after every successful place-bet. Cursor paging: the first request usesplacedBefore=now()&limit=20; subsequent pages fire viauseMyBets().loadMore()and sendplacedBefore=<oldest placedDate seen>. A short page (response shorter thanlimit) marks the tab as exhausted (hasMore=false) so further requests no-op. - Resolves BetBuilder grouping — when 2+ selections share an
eventId, the widget calls the provider'sbettingOptionsV2RPC to fetch combined odds + combinability metadata. Selections whosebettingOfferIdis not in the combinable set getisCombinable: falseand the place-bet button is gated withplaceBetBlockedReason: 'place_bet_blocked_incompatible'. ThebetBuilderPriceValueper leg is appended to the place-bet payload so the provider scores the combo correctly. Falls back gracefully when the resolved provider adapter doesn't exposegetBettingOptions— combinability stays at its default (true) and grouping degrades to "the same event isn't combined server-side".
Reactive state (balance, currency, MyBets list, BetBuilder groups, combinability) lands on the user / betting stores. The MyBets UI is shipped — see My Bets tab below. The accompanying balance chip + BetBuilder card surfaces consume these same stores in a follow-up.
My Bets tab
When the tab is shown, the panel header grows a second tab — My bets — alongside the existing Betslip tab. Tapping it swaps the body to a list of the user's placed bets (powered by the same useMyBets feed described above), with a sub-pill row to switch between Open / Closed / Won.
Visibility is controlled by the myBetsTab prop: 'auto' (default) shows the tab only once a sessionId is set, so anonymous embeds keep the v1 single-tab shape; 'always' shows it even without a session (it renders the empty "no open bets yet" state until the user signs in); 'never' hides it entirely.
The cards list infinite-scrolls: when the user scrolls past ~70% of the list, the panel fires useMyBets().loadMore() to fetch the next 20 older bets (cursor-paged via placedBefore) and a small spinner row renders at the bottom of the list until the page resolves. Switching tabs resets the scroll position to the top so the new tab doesn't auto-page off the previous tab's cursor.
Each bet card shows the bet meta (Multiple/Single + status, placed-at timestamp, bet ID), one card per leg (sport icon, tournament, kick-off, teams + fixture score at place-bet time, market + outcome, odds), and a footer triple (Total Odds | Bet Amount | Possible Winnings / Returned). Terminal statuses (Won / Lost / Cashed Out) get a colour-coded top strip and an appropriate payout label and tint.
Sport + flag glyphs resolve off iconBaseUrl (path icons/sports/dark_mode/{sportId}.svg) and flagBaseUrl (path {venueId}.png) — same convention as <goma-sports-navigation-horizontal> and <goma-region-filter>. Both are optional; the glyphs are omitted entirely when unset.
Required configuration
js
el.config = {
apiBaseUrl: 'https://sports-api.example.com', // existing
bettingApiBaseUrl: 'https://sports-api.example.com', // existing — place-bet
playerApiBaseUrl: 'https://player-api.example.com', // NEW — session + balance
betsApiBaseUrl: 'https://bets-api.example.com', // NEW — MyBets
ucsOperatorId: '2838', // already required for place-bet path
locale: 'en', // existing — also used for X-language
}If playerApiBaseUrl is omitted, the widget falls back to apiBaseUrl. Same for betsApiBaseUrl → bettingApiBaseUrl → apiBaseUrl. CORS is the host's responsibility — the widget makes vanilla cross-origin fetch calls; the host needs to ensure the relevant Access-Control-Allow-* headers are in place (or front the requests with a same-origin reverse proxy).
Props (HTML attributes + JS properties)
| Property | Attribute | Default | Description |
|---|---|---|---|
placeBetMode | place-bet-mode | 'event' | 'event' (default) or 'api'. See above. |
placeBetEndpoint | place-bet-endpoint | '' | Absolute URL or path (resolved against config.bettingApiBaseUrl). Required when placeBetMode='api'. |
placeBetHeaders | place-bet-headers | {} | JSON-encoded extra request headers (auth tokens, X-OperatorId). Merged on top of the default Content-Type / Accept / Accept-Language. For X-SessionId prefer the dedicated sessionId prop. |
placeBetBody | place-bet-body | {} | JSON-encoded extra body fields (e.g. { userId, username, currency, ucsOperatorId, lang }). Spread last so the host can override base fields. |
sessionId | session-id | '' | Authenticated-session token. When set, the widget auto-adds it as the X-SessionId request header (api mode) and includes it as sessionId on the request payload (so goma:place-bet-request listeners in event mode can forward it to their own backend). Explicit placeBetHeaders['X-SessionId'] / placeBetBody.sessionId still win — sessionId is the convenience surface; the existing maps are the override hatch. |
myBetsTab | my-bets-tab | 'auto' | Controls the My bets header tab. 'auto' shows it only once sessionId is set; 'always' shows it even when anonymous (renders the empty "no open bets yet" state until a session is wired); 'never' hides it. |
requireSession | require-session | false | When true, the Place Bet button stays disabled until a sessionId is set (with a "Sign in to place a bet" hint), so anonymous users can't submit. Only enforced in api mode — a no-op in event mode, where the host submits through its own backend and handles auth itself. |
quickAddAmounts | quick-add-amounts | [10, 20, 50] | Quick-add chip amounts rendered next to each selection's stake input in single-bet mode. JSON-encoded array on the attribute. Each value is added to the current stake on click. Empty array hides the chips entirely. |
iconBaseUrl | icon-base-url | '' | Sport-icon CDN base for MyBets cards. Resolves to {iconBaseUrl}/icons/sports/dark_mode/{sportId}.svg. Omit to render cards without sport glyphs. |
flagBaseUrl | flag-base-url | '' | Country-flag CDN base for MyBets cards. Resolves to {flagBaseUrl}{venueId}.png. Omit to render cards without flag glyphs. |
emptyStateImage | empty-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. |
emptyStateImageAlt | empty-state-image-alt | '' | Alt text for emptyStateImage. |
The widget also inherits the standard goma surface from createWidgetElement: locale, theme, messages, debug, config.
Behaviour
Single vs Multiple
- Multiple (default) — one combo bet, stake × product of all odds. The shared
stakefield (envelope root + slip-level Pinia) feeds the payout. - Single — N independent bets, payout
Σ stake_i × odd_i. Each selection carries its ownstakeon the envelope; the slip-levelstakeis ignored. Quick-add chips next to the input increment the per-selection stake.
Header chrome
The panel header contains: a settings cog (left, opens a popover with the Accept odds change toggle — visible on both tabs), a clear-all trash icon (right — 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 is a v1 placeholder, disabled). The trash icon and bet-type pill row collapse on the My bets tab so its sub-pills (Open / Closed / Won) own the header below the tab strip; the settings cog stays in place so the odds-change preference is reachable from either tab.
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.
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.
Events
| Status | Event | Detail | Emitted when |
|---|---|---|---|
| Canonical | ready | {} | Widget mounted, initial render done. |
| Canonical | goma:outcome-deselect | { bettingOfferId } | User removed a selection from the panel via the ✕ button. Pairs with goma:outcome-select from <goma-events-horizontal>. |
| Canonical | goma:bet-removed | { outcomeId } | User removed a selection from the panel. Same trigger as goma:outcome-deselect, dispatched in parallel for hosts that only listen on the betslip surface. |
| Canonical | goma:betslip-cleared | {} | User cleared the entire slip via the "Clear" action. |
| Canonical | goma:stake-change | { stake } | User changed the multiple-mode stake input (footer). Storage-driven sync from a peer betslip widget does not re-emit. |
| Canonical | goma:selection-stake-change | { outcomeId, stake } | User changed a per-selection stake (single-bet mode) — typing in the input or clicking a quick-add chip. Storage-driven sync from a peer betslip widget does not re-emit. |
| Canonical | goma:place-bet-request | { type, amount, oddsValidationType, terminalType, 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. |
| Canonical | goma:bet-placed | { success, betId, type, amount, odds, possibleWinnings, selections[], placedAt, raw? } | Bet placed successfully (api response or event-mode mock). Slip cleared, receipt stored on the panel. |
| Canonical | goma:bet-failed | { message, payload, status?, body? } | Place-bet failed (HTTP error, provider success: false, or thrown). Slip preserved so the user can retry. |
| Canonical | goma:session-expired | { reason } | The balance SSE stream received a SESSION_EXPIRATION / SESSION_EXPIRATION_V2 event. Widget tore down the stream and cleared the user session. Host listens to flush its own session state (cookie / store) and prompt for re-login. |
| Canonical | goma:retry | {} | User clicked the retry button on the error banner. |
| Canonical | goma:error | { message, code, component? } | Render or runtime error caught by the boundary. |
| Alias | error | Same as goma:error | Dispatched in parallel. |
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.
All events bubble + composed, so they cross the Shadow DOM boundary and are observable from window.addEventListener(…).
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',
ucsOperatorId: '2838',
lang: 'en',
}
el.addEventListener('goma:bet-placed', (e) => console.log('receipt', e.detail))
el.addEventListener('goma:bet-failed', (e) => console.error(e.detail.message))
</script>React
jsx
import '@gomagaming/betslip-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>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 + per-selection odds chip + quick-add chip
backgroundTertiary: '#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',
// Live indicator chip on a selection row + alert pills
alertSuccess: '#21ba45',
alertError: '#ed4f63',
}Tokens used by this widget
| Token | Element |
|---|---|
--goma-backgroundPrimary | Header bar (cog/trash + tabs) and footer (summary + place-bet) |
--goma-backgroundSecondary | Panel shell surface (the <aside> wrapper) |
--goma-backgroundBetslip | Selections body surface (the scrollable selection rows) |
--goma-backgroundTertiary | Bet-type pill row background, per-selection odds chip, quick-add chips |
--goma-separatorLine | Divider inside the selection card + underline of inactive header tabs |
--goma-highlightPrimary | Active bet-type pill background + Place-bet button background (cyan in the design) |
--goma-highlightPrimaryContrast | Text on the active bet-type pill and on the Place-bet button (dark over cyan) |
--goma-highlightSecondary | Underline under the active Betslip header tab (violet in the design) |
--goma-textPrimary | Body text — outcome name, market name, place-bet label |
--goma-textSecondary | Secondary labels — match title, return / total-odds / possible-winnings labels |
--goma-iconSecondary | Settings cog, trash icon, close × on each selection row |
--goma-alertSuccess / --goma-alertError | Success receipt badge + error banner |
--goma-liveTag | Live indicator chip on a selection row |
i18n / Message overrides
| Key | Default (en) | Used in |
|---|---|---|
betslip | Betslip | <aside> aria-label, header tab label |
my_bets | My Bets | Header tab (disabled placeholder for the future my-bets widget) |
betslip_settings | Betslip Settings | Settings cog aria-label + popover label |
clear_betslip | Clear Betslip | Trash-icon aria-label |
single / multiple / system | Single / Multiple / System | Bet-type pill labels (System is a disabled placeholder) |
total_odds | Total odds | Summary label |
possible_winnings | Possible winnings | Summary label |
stake | Stake | Stake input label (multiple-mode footer; per-selection in single mode) |
return_text | Return | Per-selection return-amount label (single-bet mode) |
accept_odds_strict | Do not accept any changes in odds | Settings popover — strict radio (sends oddsValidationType: 'ACCEPT_HIGHER') |
accept_odds_higher | Approve higher odds | Settings popover — loose radio (sends oddsValidationType: 'ACCEPT_ANY') |
place_bet | Place Bet | Primary action button |
loading_betslip | Loading betslip | Place-bet button while in flight |
place_bet_success_title | Bet Succesfully placed | Success badge title |
empty_betslip_info_title | You don't have any selections yet. | Empty state title |
empty_betslip_info_subtitle | Here are your suggested bets! | Empty state subtitle |
live | Live | Live-event chip on a selection row |
market | Market | Fallback when a selection has no marketName |
remove | Remove | ✕ button aria-label per selection |
close | Close | Dialog ✕ aria-label (floating widget only) |
retry | Retry | Error banner button |
Override with config.messages:
js
el.config = {
/* … */
messages: {
en: { place_bet: 'Submit bet' },
fr: { place_bet: 'Valider le pari' },
pt: { place_bet: 'Apostar' },
},
}Accessibility
| Element | Role | Attributes |
|---|---|---|
| Sidebar shell | aside (implicit) | aria-label (localised "Betslip") |
| Header tab strip | tablist | each tab has role="tab", aria-selected; My bets carries aria-disabled="true" and tabindex="-1" |
| Bet-type pills | tablist | each pill has role="tab", aria-selected; System carries aria-disabled="true" |
| Settings cog | button | aria-expanded reflects popover state, aria-controls points at the popover, aria-label="Betslip Settings" |
| Settings popover | dialog (non-modal) | aria-label="Betslip Settings", Escape closes; click-outside (composedPath-aware so Shadow DOM is honoured) closes |
| Clear (trash) | button | aria-label="Clear Betslip" |
| Selection ✕ | button | aria-label="Remove" |
| Place Bet | button | aria-disabled reflects gating; stays focusable; aria-describedby points at the "why disabled" hint when 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 chips | button | label is +10 / +20 / +50; configurable via quickAddAmounts |
| Accept odds change | <input type="checkbox"> | inside the settings popover, wrapped in a <label> |
| Error retry | button | localised text, focus-visible:ring |
Source layout
| Layer | File |
|---|---|
| Element class | packages/betslip-sidebar/src/BetslipSidebarElement.js |
| App wrapper | packages/betslip-sidebar/src/BetslipSidebarApp.vue |
| Per-widget CSS (host sizing) | packages/betslip-sidebar/src/styles/sidebar.css |
| Entry / registration | packages/betslip-sidebar/src/index.js |
| Manifest | packages/betslip-sidebar/custom-elements.json |
| Tests | packages/betslip-sidebar/tests/BetslipSidebar.test.js |
Reused from @gomagaming/sports-domain:
| Layer | File |
|---|---|
| Betslip Pinia store | packages/sports-domain/src/stores/betslip.js |
| Cross-widget sync | packages/sports-domain/src/shared-state/useSharedBetslipSync.js |
| Storage schema + transient-strip helper | packages/sports-domain/src/shared-state/betslipStorageSchema.js |
| Storage migration (v2 / v1-split → v3) | packages/sports-domain/src/shared-state/migrateBetslipStorage.js |
| Live odds sync | packages/sports-domain/src/composables/betting/useBetslipOddsSync.js |
| Place-bet logic | packages/sports-domain/src/composables/betting/useBetslipLogic.js |
| Panel composite | packages/sports-domain/src/components/Betslip/BetslipPanel.vue |
| Selection item | packages/sports-domain/src/components/Betslip/BetslipSelectionItem.vue |
| Summary block | packages/sports-domain/src/components/Betslip/BetslipSummary.vue |
| Empty state | packages/sports-domain/src/components/Betslip/BetslipEmpty.vue |
Reused from @gomagaming/core:
| Layer | File |
|---|---|
| Element factory | packages/core/src/createWidgetElement.js |