Appearance
goma-betslip-floating
Floating-action-button (FAB) betslip: a fixed-position pill with a selection-count badge that, on tap, opens a full-screen native <dialog> containing the shared betslip panel (selections list, stake input, single/multiple summary, place-bet button). Sister widget to goma-betslip-sidebar — same shared-storage selection sync, same place-bet contract, same theming. Use this one in mobile-first layouts where a permanent sidebar would steal too much real estate.
The dialog renders as a centred modal on desktop and as a bottom-sheet below the configured mobileBreakpoint. The iOS-safe native-<dialog> recipe from <goma-sports-navigation-dialog> is reused: top-layer showModal(), manual useScrollLock, dim ::backdrop with allow-discrete transitions.
Live demo
Open in the playground → — toggle Betslip (floating) alongside Events horizontal in the widget rail. Tap odds in the event cards to populate the shared goma:betslip:v3 envelope, then tap the floating pill to open the modal and inspect the selections / stake / place-bet flow.
Install
bash
pnpm add @gomagaming/betslip-floating vue pinia vue-router vue-i18n @vueuse/coreElement
html
<goma-betslip-floating id="bf"></goma-betslip-floating>The widget renders only the FAB in its closed state — the host element is positionally fixed and does not occupy normal flow. Tapping the FAB opens the modal; the modal closes on Place Bet success, the close button, backdrop click, or Escape.
Configuration
js
const el = document.getElementById('bf')
el.config = {
// Optional — only needed when placeBetMode is 'api' and the endpoint is a
// relative path. Resolves placeBetEndpoint against this base.
bettingApiBaseUrl: 'https://sports-api.example.com',
locale: 'en',
// Optional host-supplied formatters for multi-currency / locale-specific
// display. Receive the raw number; return a string. Default behaviour
// when omitted: payouts use `Number#toFixed(2)`, odds use the truncate-
// not-round convention from `formatDisplayOdds`.
formatStake: (n) => new Intl.NumberFormat('pt-PT', { style: 'currency', currency: 'EUR' }).format(n),
formatOdds: (n) => n.toFixed(2),
}config is the runtime configuration object. None of the keys are required for placeBetMode='event'; only bettingApiBaseUrl matters in 'api' mode (and only if placeBetEndpoint is a path rather than an absolute URL). Locale propagates to Accept-Language on the API request and to the bundled i18n.
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.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)
| Property | Attribute | Default | Description |
|---|---|---|---|
open | open | false | Programmatic open/close. Two-way: tapping the FAB flips it true; placing or closing flips it false. |
fabPosition | fab-position | 'bottom-right' | FAB anchor on the viewport. 'bottom-right' / 'bottom-left' / 'bottom-center'. |
mobileBreakpoint | mobile-breakpoint | 'sm' | Viewport width below which the dialog renders as a bottom-sheet (full-width, anchored to bottom, slide-up animation) instead of a centred modal. Tailwind token ('sm' 640 / 'md' 768 / 'lg' 1024 / 'xl' 1280 / '2xl' 1536) or a raw pixel value (number 480 or string '480px'). |
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, X-SessionId). Merged on top of the default Content-Type / Accept / Accept-Language. |
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. |
Bottom-sheet vs centred modal
Below mobileBreakpoint the panel renders as a bottom-sheet: full-width, anchored to the viewport bottom, rounded only at the top, with a small drag-handle pill above the header (purely visual — the sheet isn't draggable yet) and a slide-up entry/exit. At or above the breakpoint, the panel is a right-anchored modal capped at 460 px / 90 dvh with a scale + fade entry/exit. The transition is live — resizing across the breakpoint while open swaps the layout without re-mounting.
Behaviour
Open / close
- Tapping the FAB sets
el.open = trueand opens the modal. - A successful Place Bet (api or event mode) auto-closes the modal.
- Tapping the Close button, the backdrop, or pressing Escape closes the modal.
- Hosts can drive open/close programmatically:
el.open = true/el.open = false. Each transition emitsgoma:dialog-open/goma:dialog-close.
Selection count badge
The FAB renders a circular badge with the current selection count from shared storage. Counts above 99 render as 99+. The badge hides at zero.
Single vs Multiple
Same semantics as <goma-betslip-sidebar>:
- 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.
Events
| Status | Event | Detail | Emitted when |
|---|---|---|---|
| Canonical | ready | {} | Widget mounted, initial render done. |
| Canonical | goma:dialog-open | {} | state.open transitioned false → true. |
| Canonical | goma:dialog-close | {} | state.open transitioned true → false. |
| Canonical | goma:outcome-deselect | { bettingOfferId } | 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 stake input. 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:error | { message, code, component? } | Render or runtime error caught by the boundary. |
| Alias | error | Same as goma:error | Dispatched in parallel. |
All events bubble + composed, so they cross the Shadow DOM boundary and are observable from window.addEventListener(…).
The widget also listens for goma:outcome-select on window (composed events from any source bubble up). A host that emits goma:outcome-select directly — without <goma-events-horizontal> on the page — populates the betslip the same way. betslip.addSelection dedups on outcomeId, so events from events-horizontal aren't double-counted.
Embedding examples
Vanilla HTML — event mode (consumer integrates own backend)
html
<goma-betslip-floating id="bf"></goma-betslip-floating>
<script type="module">
import '@gomagaming/betslip-floating'
const el = document.getElementById('bf')
el.placeBetMode = 'event'
el.addEventListener('goma:place-bet-request', async (e) => {
await fetch('/my/internal/bets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(e.detail),
})
})
el.addEventListener('goma:bet-placed', (e) => console.log('receipt', e.detail))
</script>Vanilla HTML — api mode (Everymatrix-shaped backend)
html
<goma-betslip-floating id="bf" place-bet-mode="api"></goma-betslip-floating>
<script type="module">
import '@gomagaming/betslip-floating'
const el = document.getElementById('bf')
el.config = { bettingApiBaseUrl: 'https://sports-api.example.com', locale: 'en' }
el.placeBetEndpoint = '/place-bet/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-floating'
import { useEffect, useRef } from 'react'
function FloatingBetslip() {
const ref = useRef(null)
useEffect(() => {
if (!ref.current) return
ref.current.placeBetMode = 'api'
ref.current.placeBetEndpoint = '/place-bet/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 <goma-betslip-floating ref={ref} fab-position="bottom-right" />
}Vue 3
vue
<script setup>
import '@gomagaming/betslip-floating'
import { ref, onMounted } from 'vue'
const el = ref(null)
const props = defineProps(['session'])
onMounted(() => {
el.value.placeBetMode = 'api'
el.value.placeBetEndpoint = '/place-bet/4313/v2/bets'
el.value.placeBetHeaders = { 'X-SessionId': props.session.id }
el.value.config = { bettingApiBaseUrl: 'https://sports-api.example.com' }
})
</script>
<template>
<goma-betslip-floating
ref="el"
fab-position="bottom-right"
@goma:bet-placed="(e) => console.log(e.detail)"
/>
</template>Theming
Override CSS variables via el.theme = { … } — the prefix --goma- is added automatically.
js
el.theme = {
// FAB background + active text colour
highlightPrimary: '#ff6600',
highlightPrimaryContrast: '#ffffff',
// Modal panel background — decoupled from backgroundPrimary so the host
// (FAB area) can be transparent while the modal stays opaque.
dialogBackground: '#03061b',
// Panel/border + textual contrast
textPrimary: '#ffffff',
textSecondary: '#939dff',
separatorLine: '#2a2d60',
// 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
| Token | Element |
|---|---|
--goma-highlightPrimary | FAB background, active tab background, place-bet button |
--goma-highlightPrimaryContrast | FAB icon + label, active tab text, place-bet text |
--goma-dialogBackground | Modal panel background (decoupled from backgroundPrimary) |
--goma-backgroundCards | Selection rows, tab strip background, summary inputs |
--goma-backgroundTertiary | Summary block background |
--goma-backgroundOdds / --goma-textOdds | Odds chip on each selection row |
--goma-textPrimary / --goma-textSecondary | Title, body, secondary labels |
--goma-separatorLine | Panel border + selection-row borders |
--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 | FAB label, panel header |
open_betslip | Open Betslip | FAB aria-label (with selection count appended) |
close | Close | Close button aria-label |
clear_betslip | Clear Betslip | Header clear-link text |
single / multiple | Single / Multiple | Tab labels |
selections | Selections | Summary label (single mode) |
total_odds | Total odds | Summary label (multiple mode) |
possible_winnings | Possible winnings | Summary label |
stake | Stake | Stake input label |
accept_odds_change | Accept odds change | Summary toggle |
place_bet | Place Bet | Primary action button |
loading_betslip | Loading betslip | Place-bet button while in flight |
place_bet_success_title | Bet Successfully 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 |
retry | Retry | Error banner button |
Override with config.messages:
js
el.config = {
/* … */
messages: {
en: { open_betslip: 'View bets' },
fr: { open_betslip: 'Voir les paris' },
pt: { open_betslip: 'Ver apostas' },
},
}Accessibility
| Element | Role | Attributes |
|---|---|---|
| FAB | button | aria-haspopup="dialog", aria-expanded, aria-label (announces selection count) |
| Modal panel | dialog (native) | aria-labelledby → hidden title id |
| Tab strip | tablist | each tab has role="tab", aria-selected |
| Selection ✕ | button | aria-label="Remove" |
| Place Bet | button | disabled when canPlaceBet is false |
| Stake input | <input type="number"> | label via wrapping <label>, inputmode="decimal", font-size: 16px (iOS auto-zoom suppression) |
| Accept odds change | <input type="checkbox"> | label via wrapping <label> |
Keyboard: Tab cycles focus inside the modal; Shift+Tab cycles backward; Escape closes; Enter/Space activates the focused control. Focus trap is browser-managed (native <dialog> opened via showModal() traps Tab automatically).
iOS Safari notes
The widget reuses the same iOS-safe recipe as <goma-sports-navigation-dialog>:
| Symptom on iOS | Solution |
|---|---|
| Inputs under 16 px trigger an auto-zoom | Stake input has inline font-size: 16px. |
Native <dialog> focus-trap leaks across the shadow boundary | showModal() puts the dialog into the browser's top layer — the trap is browser-managed and works because the dialog tree is no longer anchored to the host's stacking context. |
showModal() doesn't scroll-lock the page on iOS | useScrollLock from @gomagaming/core applied on every open. |
| Shadow-rooted z-index doesn't always cover sibling widgets | Native <dialog> top layer escapes every ancestor stacking context. |
| Layout flex/grid quirks on the dialog/panel root | The dialog wrapper uses display: grid; place-items: center to stage the panel; the visible panel itself uses flex flex-col. |
Source layout
| Layer | File |
|---|---|
| Element class | packages/betslip-floating/src/BetslipFloatingElement.js |
| App wrapper | packages/betslip-floating/src/BetslipFloatingApp.vue |
| FAB trigger | packages/betslip-floating/src/components/BetslipFloatingTrigger.vue |
| Dialog panel | packages/betslip-floating/src/components/BetslipFloatingDialog.vue |
| Per-widget CSS (FAB position + dialog motion) | packages/betslip-floating/src/styles/dialog.css |
| Entry / registration | packages/betslip-floating/src/index.js |
| Manifest | packages/betslip-floating/custom-elements.json |
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 |
| Scroll-lock composable | packages/core/src/composables/useScrollLock.js |