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. 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/core

Element

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:

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.

The base request body (always included):

json
{
  "type": "single" | "multiple",
  "amount": 10.0,
  "oddsValidationType": "ACCEPT_ANY" | "ACCEPT_HIGHER",
  "terminalType": "DESKTOP",
  "selections": [
    { "bettingOfferId": "…", "priceValue": 2.5, "eventId": "…", "marketId": "…", "outcomeId": "…", "outcomeName": "…" }
  ],
  "...placeBetBody": "spread last so host can override base fields"
}

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.

Props (HTML attributes + JS properties)

PropertyAttributeDefaultDescription
placeBetModeplace-bet-mode'event''event' (default) or 'api'. See above.
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, X-SessionId). Merged on top of the default Content-Type / Accept / Accept-Language.
placeBetBodyplace-bet-body{}JSON-encoded extra body fields (e.g. { userId, username, currency, ucsOperatorId, lang }). Spread last so the host can override base fields.

The widget also inherits the standard goma surface from createWidgetElement: locale, theme, iconBaseUrl, messages, debug, config.

Behaviour

Single vs Multiple

  • Multiple (default) — one combo bet, stake × product of all odds.
  • Single — N independent bets, payout Σ stake × odd_i.

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

StatusEventDetailEmitted when
Canonicalready{}Widget mounted, initial render done.
Canonicalgoma:outcome-deselect{ bettingOfferId }User removed a selection from the panel via the ✕ button. 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 stake input. Storage-driven sync from a peer betslip widget does not re-emit.
Canonicalgoma: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.
Canonicalgoma: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.
Canonicalgoma:bet-failed{ message, payload, status?, body? }Place-bet failed (HTTP error, provider success: false, or thrown). Slip preserved so the user can retry.
Canonicalgoma:retry{}User clicked the retry button on the error banner.
Canonicalgoma:error{ message, code, component? }Render or runtime error caught by the boundary.
AliaserrorSame as goma:errorDispatched 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/4313/v2/bets'
  el.placeBetHeaders = { 'X-OperatorId': '4313', 'X-SessionId': sessionId }
  el.placeBetBody = {
    userId: '123',
    username: 'alice',
    currency: 'EUR',
    ucsOperatorId: '4313',
    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/4313/v2/bets'
    ref.current.placeBetHeaders = { 'X-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/4313/v2/bets'
  el.value.placeBetHeaders = { 'X-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 = {
  // Sidebar shell background + border
  backgroundPrimary: '#03061b',
  separatorLine: '#2a2d60',
  // Active tab background, place-bet button, accent text
  highlightPrimary: '#ff6600',
  highlightPrimaryContrast: '#ffffff',
  // Panel/border + textual contrast
  textPrimary: '#ffffff',
  textSecondary: '#939dff',
  // Selection items + summary
  backgroundCards: '#1f2147',
  backgroundTertiary: '#1f2147',
  backgroundOdds: '#434799',
  textOdds: '#ffffff',
  // Place-bet success badge / errors
  alertSuccess: '#21ba45',
  alertError: '#ed4f63',
}

Tokens used by this widget

TokenElement
--goma-backgroundPrimarySidebar shell background
--goma-separatorLineSidebar shell border + selection-row borders
--goma-highlightPrimaryActive tab background, place-bet button, accent text
--goma-highlightPrimaryContrastActive tab text, place-bet text
--goma-backgroundCardsSelection rows, tab strip background, summary inputs
--goma-backgroundTertiarySummary block background
--goma-backgroundOdds / --goma-textOddsOdds chip on each selection row
--goma-textPrimary / --goma-textSecondaryTitle, body, secondary labels
--goma-alertSuccess / --goma-alertErrorSuccess receipt badge + error banner
--goma-liveTagLive indicator chip on a selection row

i18n / Message overrides

KeyDefault (en)Used in
betslipBetslip<aside> aria-label, panel header
clear_betslipClear BetslipHeader clear-link text
single / multipleSingle / MultipleTab labels
selectionsSelectionsSummary label (single mode)
total_oddsTotal oddsSummary label (multiple mode)
possible_winningsPossible winningsSummary label
stakeStakeStake input label
accept_odds_changeAccept odds changeSummary toggle
place_betPlace BetPrimary action button
loading_betslipLoading betslipPlace-bet button while in flight
place_bet_success_titleBet Successfully placedSuccess badge title
empty_betslip_info_titleYou don't have any selections yet.Empty state title
empty_betslip_info_subtitleHere are your suggested bets!Empty state subtitle
liveLiveLive-event chip on a selection row
marketMarketFallback when a selection has no marketName
removeRemove✕ button aria-label per selection
retryRetryError 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

ElementRoleAttributes
Sidebar shellaside (implicit)aria-label (localised "Betslip")
Tab striptablisteach tab has role="tab", aria-selected
Selection ✕buttonaria-label="Remove"
Place Betbuttondisabled when canPlaceBet is false
Stake input<input type="number">wrapped in a <label>, inputmode="decimal", font-size: 16px (iOS auto-zoom suppression)
Accept odds change<input type="checkbox">wrapped in a <label>
Error retrybuttonlocalised text, focus-visible:ring

Source layout

LayerFile
Element classpackages/betslip-sidebar/src/BetslipSidebarElement.js
App wrapperpackages/betslip-sidebar/src/BetslipSidebarApp.vue
Per-widget CSS (host sizing)packages/betslip-sidebar/src/styles/sidebar.css
Entry / registrationpackages/betslip-sidebar/src/index.js
Manifestpackages/betslip-sidebar/custom-elements.json
Testspackages/betslip-sidebar/tests/BetslipSidebar.test.js

Reused from @gomagaming/sports-domain:

LayerFile
Betslip Pinia storepackages/sports-domain/src/stores/betslip.js
Cross-widget syncpackages/sports-domain/src/shared-state/useSharedBetslipSync.js
Storage schema + transient-strip helperpackages/sports-domain/src/shared-state/betslipStorageSchema.js
Storage migration (v2 / v1-split → v3)packages/sports-domain/src/shared-state/migrateBetslipStorage.js
Live odds syncpackages/sports-domain/src/composables/betting/useBetslipOddsSync.js
Place-bet logicpackages/sports-domain/src/composables/betting/useBetslipLogic.js
Panel compositepackages/sports-domain/src/components/Betslip/BetslipPanel.vue
Selection itempackages/sports-domain/src/components/Betslip/BetslipSelectionItem.vue
Summary blockpackages/sports-domain/src/components/Betslip/BetslipSummary.vue
Empty statepackages/sports-domain/src/components/Betslip/BetslipEmpty.vue

Reused from @gomagaming/core:

LayerFile
Element factorypackages/core/src/createWidgetElement.js