@energy8platform/platform-core 0.26.1 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,237 +0,0 @@
1
- import type { GameShell } from '../GameShell';
2
- import { formatCurrency } from '../format';
3
- import { stepBet, nextTurbo } from '../state';
4
- import { effectiveAccent, contrastText } from '../colors';
5
- import { icon, type IconName } from './icons';
6
- import { twoLine } from './primitives';
7
-
8
- /** A floating labelled money readout (balance/win/bet). */
9
- function readout(ge: string, label: string, value: string): HTMLElement {
10
- const el = document.createElement('div');
11
- el.dataset.ge = ge;
12
- el.className = `ge-rd ge-${ge}`;
13
- el.innerHTML = `<span class="ge-lbl">${label}</span>`;
14
- el.append(document.createTextNode(value));
15
- return el;
16
- }
17
-
18
- // Resting icon is turbo1 (1 line, grey via .ge-iconbtn); engaging turbo adds the
19
- // .ge-active class which paints it white. Higher levels add more speed lines.
20
- // level: 0 → turbo1 (grey), 1 → turbo1 (white), 2 → turbo2, 3 → turbo3.
21
- function turboIcon(level: number): IconName {
22
- return (['turbo1', 'turbo1', 'turbo2', 'turbo3'] as const)[Math.max(0, Math.min(3, level))];
23
- }
24
-
25
- /** A borderless icon button. */
26
- function iconBtn(ge: string, name: IconName, onClick: () => void, active = false): HTMLButtonElement {
27
- const b = document.createElement('button');
28
- b.className = `ge-iconbtn${active ? ' ge-active' : ''}`;
29
- b.dataset.ge = ge;
30
- b.innerHTML = icon(name);
31
- b.addEventListener('click', () => { if (!b.disabled) onClick(); });
32
- return b;
33
- }
34
-
35
- export function renderBottomBar(shell: GameShell): HTMLElement {
36
- const { state, config } = shell;
37
- const fmt = (n: number) => formatCurrency(n, config.currency);
38
- const fmtWin = (n: number) => formatCurrency(n, config.currency, true); // win / total-win: variable decimals
39
- const mobile = shell.layout === 'mobile';
40
- const bar = document.createElement('div');
41
- bar.className = 'ge-shell-bottom';
42
- bar.dataset.geMode = state.mode;
43
-
44
- // menu icon button (always)
45
- const menu = iconBtn('menu', 'menu', () => shell.openMenu());
46
-
47
- // All three modes share the base plaque layout. FS/replay hide the controls that don't apply
48
- // and add Free Spins + Total Win blocks on the left; the per-spin WIN uses the base pill.
49
- const isBase = state.mode === 'base';
50
- const isFS = state.mode === 'freeSpins';
51
- // FS always shows the spins counter + accumulated Total Win (even €0); a replay shows them
52
- // only when it's a free-spins replay (freeSpins.total > 0).
53
- const showFsBlocks = isFS || (state.mode === 'replay' && state.freeSpins.total > 0);
54
-
55
- // Replay is a read-only historical round — there's no real balance to show, so hide it. Keyed on
56
- // the sticky `replay` flag (not `mode`) so it stays hidden through a replay's free-spins phase.
57
- const balance = state.replay
58
- ? null
59
- : readout('balance', shell.t('Balance'), fmt(state.balance));
60
- // With a feature active (e.g. Ante) the BET readout shows the effective stake, tinted with
61
- // the feature accent; the base state.bet is unchanged and returns once the feature is off.
62
- const feature = state.activeFeature;
63
- const betShown = feature ? state.bet * feature.priceMultiplier : state.bet;
64
- const betValue = readout('bet-value', shell.t('Bet'), fmt(betShown));
65
- if (feature) {
66
- const accent = effectiveAccent(feature);
67
- betValue.classList.add('ge-bet-feature');
68
- betValue.style.color = accent;
69
- // tint the "BET" label too (its .ge-lbl colour is set in CSS, so override inline)
70
- const lbl = betValue.querySelector('.ge-lbl') as HTMLElement | null;
71
- if (lbl) lbl.style.color = accent;
72
- }
73
- const turbo = config.features.turbo > 0
74
- ? iconBtn('turbo', turboIcon(state.turbo), () => onTurbo(shell), state.turbo > 0) : null;
75
-
76
- // interactive controls — base mode only
77
- let betDown: HTMLElement | null = null, betUp: HTMLElement | null = null;
78
- let spin: HTMLElement | null = null, auto: HTMLElement | null = null, buy: HTMLElement | null = null;
79
- if (isBase) {
80
- betDown = iconBtn('bet-down', 'minus', () => onBet(shell, -1));
81
- betUp = iconBtn('bet-up', 'plus', () => onBet(shell, 1));
82
- betValue.classList.add('ge-betbtn'); // tap the stake → bet picker
83
- betValue.addEventListener('click', () => { if (!betLocked(shell)) shell.openBetPicker(); });
84
- spin = spinButton(shell);
85
- auto = config.features.autoplay ? autoButton(shell) : null;
86
- buy = (config.features.buyBonus !== false || config.onBonusBuy) ? buyBtn(shell) : null;
87
- }
88
-
89
- const winEl = state.win > 0 ? readout('win', shell.t('Win'), fmtWin(state.win)) : null;
90
- // FS/replay left blocks: spins counter + accumulated Total Win (shown even at €0).
91
- // current = number → "current / total"; current = null/undefined → just the (game-driven) total.
92
- const fs = state.freeSpins;
93
- const fsText = fs.current == null ? `${fs.total}` : `${fs.current} / ${fs.total}`;
94
- const fsCounter = showFsBlocks ? readout('fs-counter', shell.t('Free spins'), fsText) : null;
95
- const fsTotalWin = showFsBlocks ? readout('fs-totalwin', shell.t('Total win'), fmtWin(fs.totalWin)) : null;
96
-
97
- if (mobile) {
98
- // rows: [balance · win] · [menu · auto · spin · FS counter · Total Win · turbo · buy] · [− bet +]
99
- // FS counter + Total Win live in the controls row (alongside menu/turbo), not the top readouts.
100
- bar.appendChild(plaque('ge-m-top ge-pl ge-pl-glass', compact([balance, winEl])));
101
- const center = isBase ? spin : null;
102
- bar.appendChild(plaque('ge-m-controls ge-pl-dark', compact([menu, auto, center, fsCounter, fsTotalWin, turbo, buy])));
103
- bar.appendChild(plaque('ge-m-bet ge-pl ge-pl-dark', compact([betDown, betValue, betUp])));
104
- } else {
105
- // LEFT: [menu] ⊐ BUY BONUS coin ⊏ [balance] · [Free Spins] · [Total Win]
106
- // (the last two only render in FS / a free-spins replay)
107
- const menuPlaque = plaque('ge-pl ge-pl-dark ge-pl-menu', [menu]);
108
- const balPlaque = balance ? plaque('ge-pl ge-pl-glass ge-pl-bal', [balance]) : null;
109
- const fsPlaque = fsCounter ? plaque('ge-pl ge-pl-glass ge-pl-fs', [fsCounter]) : null;
110
- const totalWinPlaque = fsTotalWin ? plaque('ge-pl ge-pl-glass ge-pl-totalwin', [fsTotalWin]) : null;
111
- const left = zone('ge-zone-left ge-zone-plaques', ...compact([menuPlaque, buy, balPlaque, fsPlaque, totalWinPlaque]));
112
-
113
- // RIGHT: [bet (+ step)] · |divider| · [auto · SPIN · turbo]
114
- const betKids: HTMLElement[] = [betValue];
115
- if (betUp && betDown) {
116
- const step = document.createElement('div'); step.className = 'ge-betstep'; step.append(betUp, betDown);
117
- betKids.push(step);
118
- }
119
- const betPlaque = plaque('ge-pl ge-pl-dark ge-pl-bet', betKids);
120
- const divider = document.createElement('div'); divider.className = 'ge-pl-divider';
121
- const spinWrap = document.createElement('div'); spinWrap.className = 'ge-spinwrap ge-pl-dark';
122
- spinWrap.append(...compact([auto, spin, turbo]));
123
- const right = zone('ge-zone-right ge-zone-plaques', betPlaque, divider, spinWrap);
124
-
125
- // MIDDLE: per-spin WIN pill in every mode — lifts above the bar on overflow.
126
- let middle: HTMLElement | null = null;
127
- if (winEl) { winEl.classList.add('ge-winpill'); middle = winEl; }
128
- bar.append(...compact([left, middle, right]));
129
- }
130
-
131
- applyBusy(shell, bar);
132
- return bar;
133
- }
134
-
135
- function zone(cls: string, ...children: HTMLElement[]): HTMLElement {
136
- const z = document.createElement('div');
137
- z.className = `ge-zone ${cls}`;
138
- z.append(...children);
139
- return z;
140
- }
141
- /** A rounded background panel ("plaque") grouping a set of controls. */
142
- function plaque(cls: string, children: HTMLElement[]): HTMLElement {
143
- const d = document.createElement('div');
144
- d.className = cls;
145
- d.append(...children);
146
- return d;
147
- }
148
- function compact(items: (HTMLElement | null)[]): HTMLElement[] { return items.filter((x): x is HTMLElement => x !== null); }
149
-
150
- function buyBtn(shell: GameShell): HTMLButtonElement {
151
- const buy = document.createElement('button');
152
- buy.className = 'ge-shell-buybonus'; buy.dataset.ge = 'buybonus';
153
- const feature = shell.state.activeFeature;
154
- if (feature) {
155
- // A feature is active → this button turns into DISABLE (tinted with the feature accent).
156
- const accent = effectiveAccent(feature);
157
- buy.classList.add('ge-disable');
158
- buy.innerHTML = `<span>${shell.t('DISABLE')}</span>`;
159
- buy.style.background = accent; buy.style.color = contrastText(accent);
160
- buy.addEventListener('click', () => { if (!buy.disabled) shell.deactivateFeature(); });
161
- } else {
162
- buy.innerHTML = `<span>${twoLine(shell.t('BUY BONUS'))}</span>`;
163
- buy.addEventListener('click', () => { if (!buy.disabled) shell.openBuyBonus(); });
164
- }
165
- return buy;
166
- }
167
-
168
- function onBet(shell: GameShell, dir: 1 | -1): void {
169
- if (shell.state.busy) return;
170
- const next = stepBet(shell.state, dir);
171
- if (next !== shell.state.bet) { shell.state.bet = next; shell.emit('betChange', next); shell.render(); }
172
- }
173
- function onTurbo(shell: GameShell): void {
174
- const next = nextTurbo(shell.state.turbo, shell.config.features.turbo);
175
- shell.state.turbo = next; shell.emit('turboChange', next); shell.render();
176
- }
177
- function betLocked(shell: GameShell): boolean {
178
- return shell.state.busy || shell.state.autoplay.active;
179
- }
180
-
181
- /** SPIN disc — rotates while busy; becomes a STOP + countdown while autoplay runs. */
182
- function spinButton(shell: GameShell): HTMLButtonElement {
183
- const { state } = shell;
184
- const sp = document.createElement('button');
185
- sp.className = 'ge-shell-spin'; sp.dataset.ge = 'spin';
186
- if (state.autoplay.active) {
187
- sp.classList.add('ge-stop');
188
- const rem = state.autoplay.remaining;
189
- const label = Number.isFinite(rem) ? String(rem) : '∞';
190
- sp.innerHTML = `<span class="ge-spin-stop">${icon('stop')}</span><span class="ge-spin-count">${label}</span>`;
191
- sp.addEventListener('click', () => { if (!sp.disabled) stopAutoplay(shell); });
192
- } else {
193
- sp.innerHTML = icon('spin');
194
- if (state.busy) sp.classList.add('ge-spinning');
195
- sp.addEventListener('click', () => { if (!sp.disabled) shell.emit('spin'); });
196
- }
197
- return sp;
198
- }
199
-
200
- /** Autoplay icon button — opens the count picker; glows accent while running. */
201
- function autoButton(shell: GameShell): HTMLButtonElement {
202
- const active = shell.state.autoplay.active;
203
- const b = iconBtn('autoplay', 'autoplay', () => onAutoplay(shell), active);
204
- if (active) b.classList.add('ge-glow');
205
- return b;
206
- }
207
-
208
- function onAutoplay(shell: GameShell): void {
209
- if (shell.state.autoplay.active) stopAutoplay(shell);
210
- else shell.openAutoplayPicker();
211
- }
212
- function stopAutoplay(shell: GameShell): void {
213
- shell.state.autoplay = { active: false, remaining: 0 };
214
- shell.emit('autoplayStop');
215
- shell.render();
216
- }
217
-
218
- function applyBusy(shell: GameShell, bar: HTMLElement): void {
219
- const { busy } = shell.state;
220
- const auto = shell.state.autoplay.active;
221
- const lockBet = busy || auto;
222
- const disable = (ge: string, off: boolean) => {
223
- const el = bar.querySelector(`[data-ge="${ge}"]`) as HTMLButtonElement | null;
224
- if (el) el.disabled = off;
225
- };
226
- // also disable the stepper that's already at the end of the bet range
227
- const i = shell.state.availableBets.indexOf(shell.state.bet);
228
- disable('bet-up', lockBet || i >= shell.state.availableBets.length - 1);
229
- disable('bet-down', lockBet || i <= 0);
230
- disable('spin', busy && !auto); // keep the STOP disc clickable through autoplay
231
- disable('autoplay', busy && !auto); // keep autoplay (stop) clickable through autoplay
232
- const betVal = bar.querySelector('[data-ge="bet-value"]') as HTMLElement | null;
233
- if (betVal) betVal.classList.toggle('ge-disabled', lockBet);
234
- const buy = bar.querySelector('[data-ge="buybonus"]') as HTMLButtonElement | null;
235
- // disabled for the whole autoplay run (not just per-spin busy) so it doesn't flicker/pulse
236
- if (buy) buy.disabled = busy || auto || !shell.state.buyBonusEnabled;
237
- }
@@ -1,329 +0,0 @@
1
- import type { GameShell } from '../GameShell';
2
- import type { BonusOption } from '../types';
3
- import { formatCurrency } from '../format';
4
- import { stepBet } from '../state';
5
- import { betDir } from '../keyboard';
6
- import { effectiveAccent, contrastText } from '../colors';
7
- import { createOverlay, createCardModal } from './primitives';
8
- import { icon, type IconName } from './icons';
9
-
10
- /** Mutable state shared between the overlay DOM and the onKey handler. */
11
- interface OverlayState {
12
- /** Index into the affordable-card subset; -1 = none (no affordable cards). */
13
- focusIndex: number;
14
- /** The bonus whose confirm dialog is currently open, or undefined. */
15
- confirmBonus: BonusOption | undefined;
16
- }
17
-
18
- /** Buy-bonus overlay — a grid of art-forward cards, one per option.
19
- * Returns the overlay element + a keyboard handler for the shell's `showModal`. */
20
- export function openBuyBonusOverlay(shell: GameShell): { root: HTMLElement; onKey: (e: KeyboardEvent) => boolean } | null {
21
- const bonuses = shell.config.features.buyBonus;
22
- if (bonuses === false || bonuses.length === 0) return null;
23
-
24
- const st: OverlayState = { focusIndex: -1, confirmBonus: undefined };
25
-
26
- const { root, body } = createOverlay({ title: shell.t('Buy bonus'), onClose: () => shell.closeModal() });
27
- root.dataset.ge = 'buybonus-overlay';
28
-
29
- // Re-render the grid whenever the bet changes so every card's price stays live.
30
- const renderGrid = (): void => {
31
- body.innerHTML = '';
32
- const grid = document.createElement('div'); grid.className = 'ge-bb-grid';
33
- // Card count drives the width-fit clamp in CSS (each card is 18em; N cards must fit the frame
34
- // width), so the row scales to the available width instead of overflowing into an X-scroll.
35
- grid.style.setProperty('--ge-bb-n', String(bonuses.length));
36
- const affordable: BonusOption[] = [];
37
- for (const bonus of bonuses) {
38
- const card = buildCard(shell, bonus, root, st);
39
- grid.appendChild(card);
40
- if (isAffordable(shell, bonus)) affordable.push(bonus);
41
- }
42
- body.appendChild(grid);
43
- // Initialize or restore focus index
44
- if (affordable.length > 0) {
45
- if (st.focusIndex < 0) st.focusIndex = 0;
46
- else st.focusIndex = Math.min(st.focusIndex, affordable.length - 1);
47
- applyFocusClass(root, bonuses, affordable, st.focusIndex);
48
- } else {
49
- st.focusIndex = -1;
50
- }
51
- };
52
-
53
- renderGrid();
54
- root.appendChild(buildBetBar(shell, renderGrid)); // thin bottom footer, only as tall as the pill
55
-
56
- /** Step the bet by `dir` and re-render the grid (live prices + affordability) when it changed.
57
- * Shared by the keyboard bet keys (the footer ± buttons keep their own copy). */
58
- const stepBetBy = (dir: 1 | -1): void => {
59
- const next = stepBet(shell.state, dir);
60
- if (next === shell.state.bet) return;
61
- shell.state.bet = next; shell.emit('betChange', next); shell.render();
62
- renderGrid();
63
- };
64
-
65
- /** Keyboard handler for both browse and confirm phases. */
66
- const onKey = (e: KeyboardEvent): boolean => {
67
- const affordable = bonuses.filter((b) => isAffordable(shell, b));
68
-
69
- // ── Confirm phase ──
70
- if (st.confirmBonus) {
71
- switch (e.code) {
72
- case 'Enter':
73
- case 'Space': {
74
- const bonus = st.confirmBonus;
75
- if (!isAffordable(shell, bonus)) return true;
76
- if (bonus.type === 'feature') shell.activateFeature(bonus);
77
- else shell.emit('buyBonusSelect', { id: bonus.id });
78
- shell.closeModal();
79
- return true;
80
- }
81
- case 'Escape':
82
- // Remove the confirm dialog, return to browse
83
- closeConfirm(root, st);
84
- return true;
85
- default:
86
- return false;
87
- }
88
- }
89
-
90
- // ── Browse phase ──
91
- const last = affordable.length - 1;
92
- const mobile = shell.layout === 'mobile';
93
-
94
- // Bet stepping mirrors the bar's keys (Shift+↑/↓, Shift+=/-, Numpad ±). Checked BEFORE arrow
95
- // navigation so a bare arrow still moves card focus while a Shift+arrow changes the bet.
96
- const bet = betDir(e);
97
- if (bet !== null) { stepBetBy(bet); return true; }
98
-
99
- // Determine navigation direction from key code + layout (mobile uses vertical arrows)
100
- const fwdKey = e.code === 'ArrowRight' || (mobile && e.code === 'ArrowDown');
101
- const bwdKey = e.code === 'ArrowLeft' || (mobile && e.code === 'ArrowUp');
102
-
103
- if (fwdKey) {
104
- if (last < 0) return true;
105
- if (st.focusIndex < last) {
106
- st.focusIndex++;
107
- applyFocusClass(root, bonuses, affordable, st.focusIndex);
108
- }
109
- return true;
110
- }
111
- if (bwdKey) {
112
- if (last < 0) return true;
113
- if (st.focusIndex > 0) {
114
- st.focusIndex--;
115
- applyFocusClass(root, bonuses, affordable, st.focusIndex);
116
- }
117
- return true;
118
- }
119
-
120
- switch (e.code) {
121
- case 'Enter':
122
- case 'Space':
123
- if (last < 0 || st.focusIndex < 0) return true;
124
- {
125
- const bonus = affordable[st.focusIndex];
126
- openConfirm(shell, bonus, root, st);
127
- }
128
- return true;
129
- // Bare =/- also step the bet (the Shift+=/- and Numpad variants are handled by betDir above).
130
- case 'Equal':
131
- stepBetBy(1);
132
- return true;
133
- case 'Minus':
134
- stepBetBy(-1);
135
- return true;
136
- case 'Escape':
137
- shell.closeModal();
138
- return true;
139
- default:
140
- return false;
141
- }
142
- };
143
-
144
- return { root, onKey };
145
- }
146
-
147
- /** Apply a CSS keyboard-focus class to the currently focused affordable card. */
148
- function applyFocusClass(overlay: HTMLElement, bonuses: BonusOption[], affordable: BonusOption[], focusIndex: number): void {
149
- for (const b of bonuses) {
150
- const card = overlay.querySelector(`[data-ge="bonus-card-${b.id}"]`) as HTMLElement | null;
151
- if (!card) continue;
152
- card.classList.remove('ge-bonus-card--kbd-focus');
153
- }
154
- const focused = affordable[focusIndex];
155
- if (!focused) return;
156
- const card = overlay.querySelector(`[data-ge="bonus-card-${focused.id}"]`) as HTMLElement | null;
157
- if (card) card.classList.add('ge-bonus-card--kbd-focus');
158
- }
159
-
160
- /** Open the confirm dialog for the given bonus and track it in overlay state. */
161
- function openConfirm(shell: GameShell, bonus: BonusOption, overlay: HTMLElement, st: OverlayState): void {
162
- closeConfirm(overlay, st); // remove any existing confirm
163
- st.confirmBonus = bonus;
164
- overlay.appendChild(buildConfirm(shell, bonus, overlay, st));
165
- shell.fitModals();
166
- }
167
-
168
- /** Remove the confirm dialog and clear the overlay state. */
169
- function closeConfirm(overlay: HTMLElement, st: OverlayState): void {
170
- // The confirm dialog is a .ge-sheet with data-ge="bonus-confirm" appended directly to overlay.
171
- const sheet = overlay.querySelector('[data-ge="bonus-confirm"]') as HTMLElement | null;
172
- if (sheet) sheet.remove();
173
- st.confirmBonus = undefined;
174
- }
175
-
176
- /** Bet control — a compact −/+ pill around the live stake, in a thin footer at the screen bottom.
177
- * Stepping repaints the value, re-prices the cards, and updates the control bar. */
178
- function buildBetBar(shell: GameShell, onChange: () => void): HTMLElement {
179
- const bar = document.createElement('div'); bar.className = 'ge-bb-betbar';
180
- const pill = document.createElement('div'); pill.className = 'ge-bb-betpill';
181
- const val = document.createElement('div'); val.className = 'ge-bb-betval';
182
- const down = stepButton('bb-bet-down', 'minus');
183
- const up = stepButton('bb-bet-up', 'plus');
184
- // Mirror the control bar: disable a stepper at the end of the bet range, and lock both
185
- // while busy — so changing the stake behaves identically here and on the bottom bar.
186
- const paint = () => {
187
- val.innerHTML = `<span>${shell.t('Bet')}</span><b>${formatCurrency(shell.state.bet, shell.config.currency)}</b>`;
188
- const i = shell.state.availableBets.indexOf(shell.state.bet);
189
- down.disabled = shell.state.busy || i <= 0;
190
- up.disabled = shell.state.busy || i >= shell.state.availableBets.length - 1;
191
- };
192
- const step = (dir: 1 | -1) => () => {
193
- const next = stepBet(shell.state, dir);
194
- if (next === shell.state.bet) return;
195
- shell.state.bet = next; shell.emit('betChange', next); shell.render();
196
- paint(); onChange();
197
- };
198
- down.addEventListener('click', step(-1));
199
- up.addEventListener('click', step(1));
200
- paint();
201
- pill.append(down, val, up);
202
- bar.appendChild(pill);
203
- return bar;
204
- }
205
-
206
- function stepButton(ge: string, name: IconName): HTMLButtonElement {
207
- const b = document.createElement('button');
208
- b.className = 'ge-bb-betstep'; b.dataset.ge = ge; b.innerHTML = icon(name);
209
- return b;
210
- }
211
-
212
- /** A grid card: title → thumbnail → description → volatility → price → full-bleed CTA.
213
- * Clicking (when affordable) opens the confirmation modal. */
214
- function buildCard(shell: GameShell, bonus: BonusOption, overlay: HTMLElement, st: OverlayState): HTMLElement {
215
- const accent = effectiveAccent(bonus);
216
- const card = document.createElement('div');
217
- card.className = 'ge-bonus-card'; card.dataset.ge = `bonus-card-${bonus.id}`;
218
- card.style.setProperty('--card-acc', accent);
219
- card.style.setProperty('--card-ink', contrastText(accent));
220
-
221
- const enabled = isAffordable(shell, bonus);
222
- // Stack the confirm on top of the overlay grid (cancel returns to the grid). Re-checks
223
- // affordability at click time, so it's a safe no-op when the option can't be bought.
224
- const select = (): void => {
225
- if (!isAffordable(shell, bonus)) return;
226
- openConfirm(shell, bonus, overlay, st);
227
- };
228
-
229
- // Game-supplied card UI: the shell keeps the wrapper (grid sizing + accent vars) and runs the
230
- // buy flow when the game calls ctx.select(); the game owns everything inside.
231
- if (bonus.custom) {
232
- card.classList.add('ge-bonus-card--custom');
233
- const price = bonus.priceMultiplier * shell.state.bet;
234
- card.appendChild(bonus.custom({
235
- bonus, bet: shell.state.bet, price,
236
- priceText: formatCurrency(price, shell.config.currency),
237
- disabled: !enabled, accent, select,
238
- }));
239
- return card;
240
- }
241
-
242
- card.appendChild(cardBody(shell, bonus));
243
- const cta = document.createElement('button');
244
- cta.className = 'ge-bonus-cta'; cta.dataset.ge = `bonus-cta-${bonus.id}`;
245
- cta.textContent = shell.t(actionLabel(bonus));
246
- card.appendChild(cta);
247
-
248
- if (!enabled) {
249
- card.classList.add('ge-bonus-off');
250
- cta.disabled = true;
251
- } else {
252
- card.addEventListener('click', select);
253
- }
254
- return card;
255
- }
256
-
257
- /** The shared card interior (everything above the action area), reused by the confirm modal. */
258
- function cardBody(shell: GameShell, bonus: BonusOption): HTMLElement {
259
- const price = bonus.priceMultiplier * shell.state.bet;
260
- const wrap = document.createElement('div'); wrap.className = 'ge-bonus-body';
261
- wrap.innerHTML =
262
- `<div class="ge-bonus-title">${bonus.title}</div>` +
263
- `<div class="ge-bonus-thumb">${thumb(bonus)}</div>` +
264
- `<div class="ge-bonus-desc">${bonus.description}</div>` +
265
- `<div class="ge-bonus-spacer"></div>` +
266
- (bonus.volatility ? `<div class="ge-bonus-vol">${volatility(bonus.volatility)}</div>` : '') +
267
- `<div class="ge-bonus-price">${formatCurrency(price, shell.config.currency)}</div>`;
268
- return wrap;
269
- }
270
-
271
- /** Confirmation modal — the shared card chrome (accent title heading, no ✕) with a bonus
272
- * preview body and a full-bleed Cancel + action footer. */
273
- function buildConfirm(shell: GameShell, bonus: BonusOption, overlay: HTMLElement, st: OverlayState): HTMLElement {
274
- const accent = effectiveAccent(bonus);
275
- const ui = createCardModal({ ge: 'bonus-confirm', title: bonus.title, accent, onClose: () => { closeConfirm(overlay, st); } });
276
-
277
- const price = bonus.priceMultiplier * shell.state.bet;
278
- const preview = document.createElement('div'); preview.className = 'ge-confirm-preview';
279
- preview.innerHTML =
280
- `<div class="ge-bonus-thumb">${thumb(bonus)}</div>` +
281
- `<div class="ge-bonus-desc">${bonus.description}</div>` +
282
- (bonus.volatility ? `<div class="ge-bonus-vol">${volatility(bonus.volatility)}</div>` : '') +
283
- `<div class="ge-bonus-price">${formatCurrency(price, shell.config.currency)}</div>`;
284
- ui.body.appendChild(preview);
285
-
286
- const actions = document.createElement('div'); actions.className = 'ge-modal-actions';
287
- const cancel = document.createElement('button');
288
- cancel.className = 'ge-modal-btn ge-modal-btn--ghost'; cancel.dataset.ge = 'bonus-confirm-cancel';
289
- cancel.textContent = shell.t('Cancel');
290
- cancel.addEventListener('click', () => closeConfirm(overlay, st));
291
- const buy = document.createElement('button');
292
- buy.className = 'ge-modal-btn ge-modal-btn--accent'; buy.dataset.ge = 'bonus-confirm-buy';
293
- buy.textContent = shell.t(actionLabel(bonus));
294
- buy.style.color = contrastText(accent); // bg comes from --card-acc on the card
295
- buy.addEventListener('click', () => {
296
- // Re-check at click time: the confirm modal stays open across state changes, so a spin
297
- // starting (busy), buy-bonus being disabled, or the balance dropping must block the purchase.
298
- if (!isAffordable(shell, bonus)) return;
299
- if (bonus.type === 'feature') shell.activateFeature(bonus);
300
- else shell.emit('buyBonusSelect', { id: bonus.id });
301
- shell.closeModal();
302
- });
303
- actions.append(cancel, buy);
304
- ui.card.appendChild(actions);
305
-
306
- return ui.root;
307
- }
308
-
309
- function thumb(bonus: BonusOption): string {
310
- if (bonus.thumbnail) return `<img src="${bonus.thumbnail}" alt="${bonus.title}">`;
311
- return `<span class="ge-bonus-thumb-ph">${icon('gift')}</span>`;
312
- }
313
-
314
- /** Volatility as five lightning bolts (the supplied SVG); `level` lit in the accent, rest dimmed. */
315
- function volatility(level: number): string {
316
- const n = Math.max(0, Math.min(5, level));
317
- const bolt = icon('lightning');
318
- return `<span class="ge-bonus-vol-on">${bolt.repeat(n)}</span>` +
319
- `<span class="ge-bonus-vol-off">${bolt.repeat(5 - n)}</span>`;
320
- }
321
-
322
- function actionLabel(bonus: BonusOption): string {
323
- return bonus.type === 'feature' ? 'Activate' : 'Buy';
324
- }
325
-
326
- function isAffordable(shell: GameShell, bonus: BonusOption): boolean {
327
- if (shell.state.busy || !shell.state.buyBonusEnabled) return false;
328
- return bonus.priceMultiplier * shell.state.bet <= shell.state.balance;
329
- }