@devrongx/games 0.4.6 → 0.4.8

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devrongx/games",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "Game UI components for sports prediction markets",
5
5
  "license": "MIT",
6
6
  "main": "./src/index.ts",
@@ -1,7 +1,7 @@
1
1
  // @devrongx/games — games/prematch-bets/PreMatchBetsPopup.tsx
2
2
  "use client";
3
3
 
4
- import { useState, useCallback, useMemo } from "react";
4
+ import { useState, useCallback, useMemo, useEffect, useRef } from "react";
5
5
  import { Loader2 } from "lucide-react";
6
6
  import { useGamePopupStore } from "../../core/gamePopupStore";
7
7
  import {
@@ -24,6 +24,7 @@ import { useTDPool, useTDPoolEntry, useTDLeaderboard } from "../../pools/hooks";
24
24
  import { buildPMBConfig } from "../../pools/mapper";
25
25
  import type { ITDMatch } from "../../matches/types";
26
26
  import { joinTDPool, placeTDBets } from "../../pools/actions";
27
+ import { saveTDDraftBetsApi } from "../../pools/fetcher";
27
28
  import type { ITDLeaderboardEntry } from "../../pools/types";
28
29
  import { TDPoolStatus } from "../../pools/types";
29
30
  import { calcRankPayout } from "./config";
@@ -122,6 +123,84 @@ export const PreMatchBetsPopup = ({ poolId, matchId: _matchId, match: matchProp
122
123
  const [submitting, setSubmitting] = useState(false);
123
124
  const [submitError, setSubmitError] = useState<string | null>(null);
124
125
 
126
+ // ── Draft restore: when entry + config load, seed bets from draft_selections ─
127
+ const draftRestoredRef = useRef(false);
128
+ useEffect(() => {
129
+ if (!poolId || !config || !entryData || draftRestoredRef.current) return;
130
+ const draft = entryData.entry.draft_selections;
131
+ if (!draft || draft.length === 0) return;
132
+ if (entryData.bets.length > 0) return; // real bets already placed — don't overwrite
133
+ const restored: IUserBets = {};
134
+ for (const d of draft) {
135
+ const mIdx = config.markets.findIndex((m) => m.backendChallengeId === d.challenge_id);
136
+ if (mIdx < 0) continue;
137
+ const optionIdx = config.markets[mIdx].options.findIndex(
138
+ (o) => o.label.toLowerCase().replace(/\s+/g, "_") === d.selected_option,
139
+ );
140
+ if (optionIdx < 0) continue;
141
+ restored[mIdx] = { optionIdx, amount: d.coin_amount, parlaySlot: null };
142
+ }
143
+ if (Object.keys(restored).length > 0) {
144
+ setBets(restored);
145
+ setUserFlowState("game");
146
+ }
147
+ draftRestoredRef.current = true;
148
+ }, [poolId, config, entryData]);
149
+
150
+ // ── Debounced draft save: persists bets to backend 1s after last change ───
151
+ const saveDraftTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
152
+ useEffect(() => {
153
+ if (!poolId || Object.keys(bets).length === 0 || !config) return;
154
+ if (saveDraftTimerRef.current) clearTimeout(saveDraftTimerRef.current);
155
+ saveDraftTimerRef.current = setTimeout(async () => {
156
+ try {
157
+ const selections = config.markets
158
+ .map((m, mIdx) => {
159
+ const b = bets[mIdx];
160
+ if (!b || !m.backendChallengeId) return null;
161
+ const option = m.options[b.optionIdx];
162
+ if (!option) return null;
163
+ return {
164
+ challenge_id: m.backendChallengeId,
165
+ selected_option: option.label.toLowerCase().replace(/\s+/g, "_"),
166
+ coin_amount: b.amount,
167
+ };
168
+ })
169
+ .filter((b): b is NonNullable<typeof b> => b !== null);
170
+ if (selections.length > 0) {
171
+ await saveTDDraftBetsApi(poolId, selections);
172
+ }
173
+ } catch {
174
+ // Silent — draft save is best-effort
175
+ }
176
+ }, 1000);
177
+ return () => { if (saveDraftTimerRef.current) clearTimeout(saveDraftTimerRef.current); };
178
+ }, [bets, poolId, config]);
179
+
180
+ // Debounced save triggered from PreMatchQuestions on each option pick
181
+ const handleQuestionSelectionChange = useCallback((selections: IUserBets) => {
182
+ if (!poolId || !config) return;
183
+ if (saveDraftTimerRef.current) clearTimeout(saveDraftTimerRef.current);
184
+ saveDraftTimerRef.current = setTimeout(async () => {
185
+ try {
186
+ const draft = config.markets
187
+ .map((m, mIdx) => {
188
+ const b = selections[mIdx];
189
+ if (!b || !m.backendChallengeId) return null;
190
+ const option = m.options[b.optionIdx];
191
+ if (!option) return null;
192
+ return {
193
+ challenge_id: m.backendChallengeId,
194
+ selected_option: option.label.toLowerCase().replace(/\s+/g, "_"),
195
+ coin_amount: 0,
196
+ };
197
+ })
198
+ .filter((b): b is NonNullable<typeof b> => b !== null);
199
+ if (draft.length > 0) await saveTDDraftBetsApi(poolId, draft);
200
+ } catch { /* best-effort */ }
201
+ }, 1000);
202
+ }, [poolId, config]);
203
+
125
204
  const betSummary = useMemo(
126
205
  () => config ? calcBetSummary(bets, config) : { selectedCount: 0, compoundMultiplier: 0, totalEntry: 0, baseReward: 0, compoundedReward: 0, remainingBalance: 0, riskPercent: 0, potentialBalance: 0 },
127
206
  [bets, config],
@@ -152,6 +231,7 @@ export const PreMatchBetsPopup = ({ poolId, matchId: _matchId, match: matchProp
152
231
  pickers[Number(mIdxStr)] = entry.optionIdx;
153
232
  }
154
233
  setExpandedPicker(pickers);
234
+ setUserFlowState("game");
155
235
  }, []);
156
236
 
157
237
  const handleOptionClick = useCallback((mIdx: number, oIdx: number) => {
@@ -272,7 +352,7 @@ export const PreMatchBetsPopup = ({ poolId, matchId: _matchId, match: matchProp
272
352
  )}
273
353
 
274
354
  {activeView === "questions" && (
275
- <PreMatchQuestions config={config} onComplete={handleQuestionsComplete} />
355
+ <PreMatchQuestions config={config} onComplete={handleQuestionsComplete} onSelectionChange={handleQuestionSelectionChange} />
276
356
  )}
277
357
 
278
358
  {activeView === "game" && (
@@ -11,6 +11,7 @@ import { useGamePopupStore } from "../../core/gamePopupStore";
11
11
  interface PreMatchQuestionsProps {
12
12
  config: IChallengeConfig;
13
13
  onComplete: (selections: IUserBets) => void;
14
+ onSelectionChange?: (selections: IUserBets) => void;
14
15
  }
15
16
 
16
17
  // ── Smooth typewriter — constant speed, clean and simple ──
@@ -263,7 +264,7 @@ const QuestionSlide = ({
263
264
  };
264
265
 
265
266
  // ── Main component ──
266
- export const PreMatchQuestions = ({ config, onComplete }: PreMatchQuestionsProps) => {
267
+ export const PreMatchQuestions = ({ config, onComplete, onSelectionChange }: PreMatchQuestionsProps) => {
267
268
  const goTo = useGamePopupStore(s => s.goTo);
268
269
 
269
270
  // -1 = start slide, 0..N-1 = question slides
@@ -283,19 +284,21 @@ export const PreMatchQuestions = ({ config, onComplete }: PreMatchQuestionsProps
283
284
  }, []);
284
285
 
285
286
  const handleOptionSelect = useCallback((mIdx: number, optionIdx: number) => {
287
+ const isDeselect = selections[mIdx]?.optionIdx === optionIdx;
288
+
286
289
  setSelections(prev => {
287
- // Deselect if same option tapped again
288
- if (prev[mIdx]?.optionIdx === optionIdx) {
290
+ if (isDeselect) {
289
291
  const next = { ...prev };
290
292
  delete next[mIdx];
293
+ onSelectionChange?.(next);
291
294
  return next;
292
295
  }
293
- // Only record the option pick no points spent yet. User sets bet amounts on game screen.
294
- return { ...prev, [mIdx]: { optionIdx, amount: 0, parlaySlot: null } };
296
+ const next = { ...prev, [mIdx]: { optionIdx, amount: 0, parlaySlot: null } };
297
+ onSelectionChange?.(next);
298
+ return next;
295
299
  });
296
300
 
297
301
  // Auto-advance after a brief pause (only if selecting, not deselecting)
298
- const isDeselect = selections[mIdx]?.optionIdx === optionIdx;
299
302
  if (!isDeselect) {
300
303
  setTimeout(() => {
301
304
  if (mIdx < totalQuestions - 1) {
@@ -311,7 +314,7 @@ export const PreMatchQuestions = ({ config, onComplete }: PreMatchQuestionsProps
311
314
  }
312
315
  }, 500);
313
316
  }
314
- }, [config.markets, selections, totalQuestions, onComplete, goTo]);
317
+ }, [config.markets, selections, totalQuestions, onComplete, onSelectionChange, goTo]);
315
318
 
316
319
  const seekNext = useCallback(() => {
317
320
  if (step === -1) {
@@ -9,6 +9,7 @@ import type {
9
9
  ITDMyEntryResponse,
10
10
  ITDLeaderboardResponse,
11
11
  ITDBetInput,
12
+ ITDDraftSelection,
12
13
  } from "./types";
13
14
 
14
15
  // Shared fetch helper — adds auth header if token exists
@@ -78,6 +79,17 @@ export async function joinTDPoolApi(poolId: number): Promise<{ id: number }> {
78
79
  return tdFetch<{ id: number }>(`/api/pools/${poolId}/join`, { method: "POST", body: "{}" });
79
80
  }
80
81
 
82
+ // PUT /api/pools/:id/draft-bets
83
+ export async function saveTDDraftBetsApi(
84
+ poolId: number,
85
+ selections: ITDDraftSelection[],
86
+ ): Promise<void> {
87
+ await tdFetch<unknown>(`/api/pools/${poolId}/draft-bets`, {
88
+ method: "PUT",
89
+ body: JSON.stringify({ selections }),
90
+ });
91
+ }
92
+
81
93
  // POST /api/pools/:id/bets
82
94
  export async function placeTDBetsApi(
83
95
  poolId: number,
@@ -163,6 +163,12 @@ export interface ITDLeaderboardResponse {
163
163
 
164
164
  // ─── Pool entry (GET /api/pools/:id/my-entry) ─────────────────────────────────
165
165
 
166
+ export interface ITDDraftSelection {
167
+ challenge_id: number;
168
+ selected_option: string;
169
+ coin_amount: number;
170
+ }
171
+
166
172
  export interface ITDPoolEntryRecord {
167
173
  id: number;
168
174
  pool_id: number;
@@ -178,6 +184,7 @@ export interface ITDPoolEntryRecord {
178
184
  questions_left: number;
179
185
  questions_total: number;
180
186
  active_parlays: number;
187
+ draft_selections: ITDDraftSelection[] | null;
181
188
  created_at: string;
182
189
  }
183
190