goma-game-details
Full details for a single sporting event: header (teams, score, clock), market-category tabs, and a lazy-loaded list of collapsible market cards. Page-shaped — fills its parent. The host owns presentation context (their own route slot, drawer, modal, or sized column); <goma-game-details> owns contents.
Drop a <goma-events-horizontal> and a <goma-game-details> on the same page and card taps drive the details widget out of the box — goma:event-navigate is already emitted by every card today.
Live demo
Open in the playground → — toggle Game details in the widget rail and pick the mock source in the inspector to render a self-contained game with drifting odds and a live clock. Mount Events horizontal alongside it to verify the goma:event-navigate hand-off — tapping a card swaps the details view.
Install
bash
pnpm add @gomagaming/game-details vue pinia vue-router vue-i18n @vueuse/coreElement
html
<goma-game-details id="gd" event-id="12345"></goma-game-details>The widget fills its parent (:host { display: block; width: 100% }). Give it a sized container (route slot, drawer, sidebar column, modal body — your call).
Configuration
js
const el = document.getElementById('gd')
el.config = {
// WAMP socket for live match + market subscriptions
socketUrl: 'wss://sports-api.example.com/v2',
socketRealm: 'www.example.com',
// REST base URL for the sports API
apiBaseUrl: 'https://sports-api.example.com',
// The event to render. Equivalent to setting el.eventId directly.
eventId: '12345',
// Optional — locale propagates to bundled i18n
locale: 'en',
// Cross-widget cooperation: when true (default), listen on the window
// for sibling card widgets' `goma:event-navigate` events and patch
// eventId. Drop-in cooperation with <goma-events-horizontal>.
followEventNavigate: true,
// Batching for the market list — IntersectionObserver-driven
initialBatchSize: 5, // markets rendered before first scroll
batchSize: 10, // markets added per sentinel hit
intersectionRootMargin: '200px',
// Fixed-height scroll viewport for the markets list. Any valid CSS
// length: '600px', '70vh', 'calc(100vh - 200px)', etc.
marketsListHeight: '480px',
// Surface toggles
showBackButton: true,
showCompetition: true,
showLiveClock: true,
}For the live WAMP path, set socketUrl (and socketRealm); apiBaseUrl covers the REST surface. Alternatively, drive the widget entirely host-side via the direct-data properties (el.game, el.marketGroups, el.markets, el.outcomes, el.bettingOffers) with no socket open — this is how the playground mock fixture renders. Either way the widget is inert until eventId is set (directly or via the watcher).
How the widget loads data
Three subscriptions, each owned by the component it serves:
| Subscription | Owner | Fires on |
|---|---|---|
subscribeToMatch(eventId) | GameDetailsHeader | eventId change |
subscribeToMarketGroups(eventId) | GameDetailsHeader | eventId change |
subscribeToMarketsByMarketGroupKey({ matchId, groupKey }) | GameDetailsMarketsList | (eventId, activeTab) change |
Each composable cleans up its own subscription via onScopeDispose. Switching eventId tears down the previous subscriptions before registering new ones. Switching the active market-category tab swaps only the markets-by-group subscription — the match + market-groups subscriptions stay attached.
A Close all control sits above the market list; tapping it collapses every market card at once (each card opens expanded by default). It uses the close_all message key.
Default tab auto-selection runs once per eventId when the market groups arrive: the group with the lowest position (the leftmost tab, same sort the tab strip uses for visual order) is selected — not by isDefault. If the user (or a prior interaction) has moved the tab off 'allMarkets', the auto-select respects the current choice.
Trigger contract — goma:event-navigate
When followEventNavigate: true (default), the widget listens on the window for goma:event-navigate events with payload { eventId, homeTeam, awayTeam, competitionName } and patches its own eventId. All four <goma-events-horizontal> card components and the horizontal wrapper emit this event today on card tap.
To opt out:
js
el.config = { ...el.config, followEventNavigate: false }Hosts that want to mediate (e.g., route through their own URL before switching) can also event.stopPropagation() on goma:event-navigate at a higher DOM level.
Props (HTML attributes + JS properties)
| HTML attribute | JS property | Type | Default | Notes |
|---|---|---|---|---|
event-id | eventId | string | null | null | Primary contract. Changing it tears down the previous subscriptions and re-registers against the new id (no element re-mount). |
locale | locale | string | 'en' | Mirrored. |
| — | config | GameDetailsConfig | {} | JS-only; full config object. |
| — | theme | Record<string, string> | {} | Standard --goma-* token bag. |
follow-event-navigate | followEventNavigate | boolean | true | Toggle the navigate watcher. |
initial-batch-size | initialBatchSize | number | 5 | Markets in the first batch. |
batch-size | batchSize | number | 10 | Markets added per sentinel hit. |
intersection-root-margin | intersectionRootMargin | string | '200px' | Passed to IntersectionObserver. |
markets-list-height | marketsListHeight | string | '480px' | CSS length for the markets-list scroll viewport. |
show-back-button | showBackButton | boolean | true | Toggle the header back affordance. |
show-competition | showCompetition | boolean | true | Toggle the competition row. |
show-live-clock | showLiveClock | boolean | true | Toggle the live clock (effective only for live events). |
game | game | object | null | null | Direct-data mode — host-supplied match object (patched into the betting store). |
market-groups | marketGroups | array | [] | Direct-data mode — host-supplied market-group tabs. |
markets | markets | array | [] | Direct-data mode — host-supplied markets. |
outcomes | outcomes | array | [] | Direct-data mode — host-supplied outcomes. |
betting-offers | bettingOffers | array | [] | Direct-data mode — host-supplied betting offers (prices). |
The HTML-attribute forms of the direct-data props (game, market-groups, markets, outcomes, betting-offers) are parsed with JSON.parse; in practice you set these as JS properties.
Events
All events use { bubbles: true, composed: true }.
| Event | Detail | When |
|---|---|---|
goma:back | { eventId: string } | User taps the back affordance in the header. Host decides what "back" means in its routing. |
goma:tab-select | { eventId, groupKey, groupName } | User changes the active market-group tab — or the auto-select default fires. |
goma:event-not-found | { eventId: string } | Match subscription resolves and the event is missing or empty. |
goma:outcome-select | { bettingOfferId, outcomeId, outcomeName, odds, eventId, marketId } | User taps an outcome to add to the betslip (reused from the shared RegularOdds button). |
goma:outcome-deselect | { bettingOfferId, outcomeId } | User taps an active outcome to remove. |
goma:retry | {} | User taps the retry button on the error boundary. |
The widget listens for goma:event-navigate (it does not emit it) — see the trigger contract above. Selecting/deselecting outcomes also syncs across the shared betslip envelope (goma:betslip:v3), so any <goma-betslip-*> widget on the page picks up the change.
Empty / loading / error states
- Inert — no
eventIdset: nothing renders. - Loading —
eventIdset, subscription in flight: skeleton (header outline + 3 placeholder market cards). - Not found — subscription resolved, no event in the store: empty state with
empty_state_match_not_available_*strings, plus agoma:event-not-foundemit. - No markets — match loaded but the active tab has no markets: empty state with
empty_state_match_no_markets_*strings. - Render error — caught by the in-shadow error boundary: red banner with a retry button that emits
goma:retry.
Embedding examples
Vanilla HTML
html
<div style="height: 100vh; display: grid; grid-template-rows: auto 1fr;">
<goma-events-horizontal id="evts"></goma-events-horizontal>
<goma-game-details id="gd"></goma-game-details>
</div>
<script type="module">
import '@gomagaming/events-horizontal'
import '@gomagaming/game-details'
const cfg = {
socketUrl: 'wss://sports-api.example.com/v2',
socketRealm: 'www.example.com',
apiBaseUrl: 'https://sports-api.example.com',
locale: 'en',
}
document.getElementById('evts').config = { ...cfg, topic: 'live/1' }
document.getElementById('gd').config = cfg
// No glue needed — tapping a card emits goma:event-navigate
// and game-details patches its eventId via the watcher.
</script>React
jsx
import '@gomagaming/game-details'
function MatchPage({ eventId, apiBaseUrl }) {
return (
<goma-game-details
ref={(el) => {
if (!el) return
el.config = { apiBaseUrl, locale: 'en' }
el.eventId = eventId
}}
/>
)
}Vue
vue
<script setup>
import '@gomagaming/game-details'
import { ref, onMounted, watch } from 'vue'
const props = defineProps({ eventId: String })
const el = ref(null)
const config = {
socketUrl: 'wss://sports-api.example.com/v2',
socketRealm: 'www.example.com',
apiBaseUrl: 'https://sports-api.example.com',
locale: 'en',
}
onMounted(() => {
el.value.config = config
el.value.eventId = props.eventId
})
watch(() => props.eventId, (id) => { if (el.value) el.value.eventId = id })
</script>
<template>
<goma-game-details ref="el" @goma:back="$emit('back')" />
</template>Theming
Most surfaces map to the shared --goma-* token set, plus five widget-specific tokens for the market-category tab pills and the market-card header strip. The notable surfaces:
- Backgrounds — page on
--goma-backgroundPrimary, header on--goma-backgroundSecondary, market-card body on--goma-backgroundCards. - Market-category tab pills — inactive pills use
--goma-tabPillBackground/--goma-tabPillText; the active pill uses--goma-tabPillBackgroundActive/--goma-tabPillTextActive. - Market-card header strip — sits on
--goma-marketCardHeaderBackground. - Live — header LIVE badge + clock use
--goma-liveTagand the--goma-liveBorder1/2/3gradient. - Highlights / buttons — accent and buttons use
--goma-highlightPrimary,--goma-buttonBackgroundPrimary,--goma-buttonTextPrimary.
The widget-specific tokens (see packages/game-details/src/themeTokens.js):
| Token | Surface | Default |
|---|---|---|
--goma-tabPillBackground | Tab pill — inactive | #1f2147 |
--goma-tabPillBackgroundActive | Tab pill — active | #ff6600 |
--goma-tabPillText | Tab pill text — inactive | #939dff |
--goma-tabPillTextActive | Tab pill text — active | #ffffff |
--goma-marketCardHeaderBackground | Market card header strip | #1f2147 |
js
el.theme = {
backgroundSecondary: '#161732',
tabPillBackgroundActive: '#ff6600',
tabPillTextActive: '#ffffff',
marketCardHeaderBackground: '#1f2147',
highlightPrimary: '#ff6600',
}i18n / Message overrides
The widget bundles its strings via @gomagaming/core's shared messages. Pass config.messages to override per-locale phrases:
js
el.config = {
apiBaseUrl: '…',
messages: {
en: {
empty_state_match_not_available_title: 'Match unavailable',
empty_state_match_no_markets_title: 'No markets yet',
market_categories: 'Categories',
},
},
}Keys used by the widget:
back,live,expand,collapse,retryclose_all(the collapse-every-market-card control)market_categories(the ARIA label of the tablist)market_group_<key>(per-category labels — fall back to the server-providedtranslatedNamewhen present)empty_state_match_not_available_title/empty_state_match_not_available_descriptionempty_state_match_no_markets_title/empty_state_match_no_markets_description
Accessibility
- The market-tab strip is an ARIA
tablist; each pill is atabwitharia-selected; the markets list is the correspondingtabpanelwitharia-labelledbypointing to the active tab. - The back affordance is a
<button>(not an anchor) — the widget doesn't know the host's route. - Each market card's collapse toggle exposes
aria-expandedandaria-controls. - Outcome buttons (shared
RegularOddscomponent) carryaria-pressedfor the selected state andaria-disabledwhen suspended.
Source layout
| Layer | File |
|---|---|
| Element class | packages/game-details/src/GameDetailsElement.js |
| App wrapper | packages/game-details/src/GameDetailsApp.vue |
| Per-instance state | packages/game-details/src/composables/useGameDetails.js |
| Header | packages/game-details/src/components/GameDetailsHeader/GameDetailsHeader.vue |
| Header subscription | packages/game-details/src/components/GameDetailsHeader/GameDetailsHeader.subscription.js |
| Market tabs | packages/game-details/src/components/GameDetailsMarketTabs/GameDetailsMarketTabs.vue |
| Markets list | packages/game-details/src/components/GameDetailsMarketsList/GameDetailsMarketsList.vue |
| Markets list subscription | packages/game-details/src/components/GameDetailsMarketsList/GameDetailsMarketsList.subscription.js |
| Market card | packages/game-details/src/components/GameDetailsMarketsList/GameDetailsMarketCard.vue |
| Empty state | packages/game-details/src/components/EmptyState/EmptyState.vue |
| Shared score machinery | packages/sports-domain/src/composables/games/gamecards/{useGameCardsTallOdds,useGameScoresTallOdds,useGamePartsAndTimesTallOdds}.js |
| WAMP actions | packages/sports-domain/src/api/everymatrix/actions.js (createMatchDetailsActions) |
What's deferred
These are recognised next-version surfaces, not omissions:
- BetBoost tab — a
bet_boostsynthetic tab that wraps the existingBetBoostListwidget concern. Source ships it but the gomawt port lands separately. - Share button +
goma:event-shareevent contract — the source sportsbook ships a header share affordance; gomawt has no share contract yet. - Favorite toggle in the header.
- Stats / lineups / H2H / commentary — these aren't implemented in the source
MatchDetailspage either (dead-code references inuseMatchDetailsonly). <goma-game-details-dialog>twin — a modal variant for mobile-overlay UX patterns. Will land if a real product need shows up.