@devrongx/games 0.4.12 → 0.4.14

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.12",
3
+ "version": "0.4.14",
4
4
  "description": "Game UI components for sports prediction markets",
5
5
  "license": "MIT",
6
6
  "main": "./src/index.ts",
@@ -120,6 +120,7 @@ export const PreMatchBetsPopup = ({ poolId, matchId: _matchId, match: matchProp
120
120
  const [showFullLeaderboard, setShowFullLeaderboard] = useState(false);
121
121
  const [submitting, setSubmitting] = useState(false);
122
122
  const [submitError, setSubmitError] = useState<string | null>(null);
123
+ const [submittedBets, setSubmittedBets] = useState<IUserBets | null>(null);
123
124
 
124
125
  // ── Real bets restore: when user has submitted bets, load them into game state ─
125
126
  const realBetsRestoredRef = useRef(false);
@@ -141,6 +142,7 @@ export const PreMatchBetsPopup = ({ poolId, matchId: _matchId, match: matchProp
141
142
  }
142
143
  if (Object.keys(loaded).length === 0) return;
143
144
  setBets(loaded);
145
+ setSubmittedBets(loaded);
144
146
  setExpandedPicker(pickers);
145
147
  setUserFlowState("game");
146
148
  realBetsRestoredRef.current = true;
@@ -182,22 +184,22 @@ export const PreMatchBetsPopup = ({ poolId, matchId: _matchId, match: matchProp
182
184
 
183
185
  const saveDraftTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
184
186
 
185
- // Debounced save triggered from PreMatchQuestions on each option pick
186
- const handleQuestionSelectionChange = useCallback((selections: IUserBets) => {
187
+ // Unified debounced draft save call with current bets (includes amounts)
188
+ const saveDraftDebounced = useCallback((currentBets: IUserBets) => {
187
189
  if (!poolId || !config) return;
188
190
  if (saveDraftTimerRef.current) clearTimeout(saveDraftTimerRef.current);
189
191
  saveDraftTimerRef.current = setTimeout(async () => {
190
192
  try {
191
193
  const draft = config.markets
192
194
  .map((m, mIdx) => {
193
- const b = selections[mIdx];
195
+ const b = currentBets[mIdx];
194
196
  if (!b || !m.backendChallengeId) return null;
195
197
  const option = m.options[b.optionIdx];
196
198
  if (!option) return null;
197
199
  return {
198
200
  challenge_id: m.backendChallengeId,
199
201
  selected_option: option.label.toLowerCase().replace(/\s+/g, "_"),
200
- coin_amount: 0,
202
+ coin_amount: b.amount,
201
203
  };
202
204
  })
203
205
  .filter((b): b is NonNullable<typeof b> => b !== null);
@@ -206,6 +208,11 @@ export const PreMatchBetsPopup = ({ poolId, matchId: _matchId, match: matchProp
206
208
  }, 1000);
207
209
  }, [poolId, config]);
208
210
 
211
+ // Called from PreMatchQuestions on each option pick
212
+ const handleQuestionSelectionChange = useCallback((selections: IUserBets) => {
213
+ saveDraftDebounced(selections);
214
+ }, [saveDraftDebounced]);
215
+
209
216
  const betSummary = useMemo(
210
217
  () => config ? calcBetSummary(bets, config) : { selectedCount: 0, compoundMultiplier: 0, totalEntry: 0, baseReward: 0, compoundedReward: 0, remainingBalance: 0, riskPercent: 0, potentialBalance: 0 },
211
218
  [bets, config],
@@ -257,11 +264,12 @@ export const PreMatchBetsPopup = ({ poolId, matchId: _matchId, match: matchProp
257
264
  }, []);
258
265
 
259
266
  const handleAmountSelect = useCallback((mIdx: number, oIdx: number, amount: number) => {
260
- setBets((prev) => ({
261
- ...prev,
262
- [mIdx]: { optionIdx: oIdx, amount, parlaySlot: prev[mIdx]?.parlaySlot ?? null },
263
- }));
264
- }, []);
267
+ setBets((prev) => {
268
+ const next = { ...prev, [mIdx]: { optionIdx: oIdx, amount, parlaySlot: prev[mIdx]?.parlaySlot ?? null } };
269
+ saveDraftDebounced(next);
270
+ return next;
271
+ });
272
+ }, [saveDraftDebounced]);
265
273
 
266
274
  const handleSubmitBets = useCallback(async () => {
267
275
  if (!poolId || !config) return;
@@ -290,6 +298,9 @@ export const PreMatchBetsPopup = ({ poolId, matchId: _matchId, match: matchProp
290
298
 
291
299
  await placeTDBets(poolId, betsToSubmit);
292
300
 
301
+ // Snapshot submitted state for change detection
302
+ setSubmittedBets({ ...bets });
303
+
293
304
  // Refresh data
294
305
  await Promise.all([refetchEntry(), refetchLB(), refetchPool()]);
295
306
  } catch (err: unknown) {
@@ -353,6 +364,7 @@ export const PreMatchBetsPopup = ({ poolId, matchId: _matchId, match: matchProp
353
364
  betSummary={betSummary}
354
365
  leaderboardRows={leaderboardRows}
355
366
  onSubmit={poolId ? handleSubmitBets : undefined}
367
+ submittedBets={poolId ? submittedBets : null}
356
368
  onViewLeaderboard={poolId ? () => setShowFullLeaderboard(true) : undefined}
357
369
  submitting={submitting}
358
370
  />
@@ -4,7 +4,7 @@
4
4
  import { useState, useEffect, useMemo, useCallback } from "react";
5
5
  import Image from "next/image";
6
6
  import { motion, AnimatePresence, useSpring, useTransform, useMotionValue } from "framer-motion";
7
- import { ChevronDown, Info, X, Play, Send } from "lucide-react";
7
+ import { ChevronDown, Info, X, Play, Check, Loader2 } from "lucide-react";
8
8
  import { IBetSummary, ILeaderboardEntry, IChallengeConfig, IUserBets, deriveParlayGroups, deriveMarketToParlay, calcDisplayReward, optionReward, calcParlayMultiplier } from "./config";
9
9
  import { OUTFIT, MARKET_ICONS, PointsIcon, SelectedCheck, AiInsightButton } from "./constants";
10
10
  import { useGamePopupStore } from "../../core/gamePopupStore";
@@ -37,8 +37,10 @@ interface PreMatchGameProps {
37
37
  leaderboardRows: ILeaderboardEntry[];
38
38
  /** Render only the markets section (no header/balance/leaderboard) — used for card preview */
39
39
  marketsOnly?: boolean;
40
- /** Called when user taps Submit Bets. If provided, shows Submit button instead of Play. */
40
+ /** Called when user taps the action button to submit/re-submit bets. */
41
41
  onSubmit?: () => Promise<void> | void;
42
+ /** Snapshot of bets as last saved to the server — drives saved/changed button states. */
43
+ submittedBets?: IUserBets | null;
42
44
  /** Called when user taps "See Full Leaderboard" */
43
45
  onViewLeaderboard?: () => void;
44
46
  /** v1: parlays are cosmetic, shows "Coming in v2". Default = false (hides section) */
@@ -48,9 +50,23 @@ interface PreMatchGameProps {
48
50
  }
49
51
 
50
52
 
51
- export const PreMatchGame = ({ config, bets, onBetsChange, expandedPicker, onOptionClick, onAmountSelect, betSummary, leaderboardRows, marketsOnly, onSubmit, onViewLeaderboard, parlayEnabled = false, submitting = false }: PreMatchGameProps) => {
53
+ export const PreMatchGame = ({ config, bets, onBetsChange, expandedPicker, onOptionClick, onAmountSelect, betSummary, leaderboardRows, marketsOnly, onSubmit, submittedBets, onViewLeaderboard, parlayEnabled = false, submitting = false }: PreMatchGameProps) => {
52
54
  const { selectedCount, compoundMultiplier, totalEntry, compoundedReward, remainingBalance, riskPercent } = betSummary;
53
55
 
56
+ // Button state: fresh (no prior submit), saved (submitted, no changes), changed (submitted, user modified)
57
+ const buttonState = useMemo<"fresh" | "saved" | "changed">(() => {
58
+ if (!submittedBets) return "fresh";
59
+ const subKeys = Object.keys(submittedBets);
60
+ const curKeys = Object.keys(bets);
61
+ if (subKeys.length !== curKeys.length) return "changed";
62
+ for (const k of curKeys) {
63
+ const cur = bets[Number(k)];
64
+ const sub = submittedBets[Number(k)];
65
+ if (!sub || cur.optionIdx !== sub.optionIdx || cur.amount !== sub.amount) return "changed";
66
+ }
67
+ return "saved";
68
+ }, [bets, submittedBets]);
69
+
54
70
  const goTo = useGamePopupStore(s => s.goTo);
55
71
 
56
72
  // Onboarding — visible whenever no points have been bet yet
@@ -583,49 +599,48 @@ export const PreMatchGame = ({ config, bets, onBetsChange, expandedPicker, onOpt
583
599
  <AnimatePresence>
584
600
  {totalEntry > 0 && (() => {
585
601
  const spentPercent = totalEntry / config.startingBalance;
586
- const fillPercent = 5 + spentPercent * 95;
587
-
588
- if (onSubmit) {
589
- return (
590
- <motion.button
591
- onClick={onSubmit}
592
- disabled={submitting}
593
- className="relative rounded-xl overflow-hidden flex-shrink-0 flex items-center justify-center gap-1.5 px-3"
594
- style={{ background: submitting ? "rgba(34,227,232,0.3)" : "linear-gradient(135deg, #22E3E8, #9945FF, #f83cc5)", minWidth: 90, height: "100%" }}
595
- initial={{ opacity: 0, width: 0 }}
596
- animate={{ opacity: 1, width: "auto" }}
597
- exit={{ opacity: 0, width: 0 }}
598
- transition={{ width: { duration: 0.4, ease: "easeOut", delay: 0.8 }, opacity: { duration: 0.3, delay: 1 } }}
599
- >
600
- {submitting ? (
601
- <span className="text-[10px] font-bold text-white/70" style={OUTFIT}>Submitting…</span>
602
- ) : (
603
- <>
604
- <Send size={14} className="text-white" />
605
- <span className="text-[11px] font-bold text-white" style={OUTFIT}>Submit Bets</span>
606
- </>
607
- )}
608
- </motion.button>
609
- );
610
- }
602
+ const fillPercent = buttonState === "saved" ? 100 : 5 + spentPercent * 95;
603
+ const fillColor = buttonState === "saved"
604
+ ? "linear-gradient(135deg, #22c55e, #16a34a)"
605
+ : "linear-gradient(135deg, #22E3E8, #9945FF, #f83cc5)";
606
+ const bgColor = buttonState === "saved"
607
+ ? "linear-gradient(135deg, rgba(34,197,94,0.2), rgba(22,163,74,0.2))"
608
+ : "linear-gradient(135deg, rgba(34,227,232,0.2), rgba(153,69,255,0.2), rgba(248,60,197,0.2))";
611
609
 
612
610
  return (
613
- <motion.div
611
+ <motion.button
612
+ onClick={onSubmit && buttonState !== "saved" ? onSubmit : undefined}
613
+ disabled={submitting || buttonState === "saved"}
614
614
  className="relative w-[65px] rounded-xl overflow-hidden flex-shrink-0 flex items-center justify-center"
615
- style={{ background: "linear-gradient(135deg, rgba(34,227,232,0.2), rgba(153,69,255,0.2), rgba(248,60,197,0.2))" }}
615
+ style={{ background: bgColor, cursor: buttonState === "saved" ? "default" : "pointer" }}
616
616
  initial={{ opacity: 0, width: 0 }}
617
617
  animate={{ opacity: 1, width: 65 }}
618
618
  exit={{ opacity: 0, width: 0 }}
619
619
  transition={{ width: { duration: 0.4, ease: "easeOut", delay: 0.8 }, opacity: { duration: 0.3, delay: 1 } }}
620
620
  >
621
+ {/* Fill bar */}
621
622
  <motion.div
622
623
  className="absolute inset-y-0 left-0"
623
624
  animate={{ width: `${fillPercent}%` }}
624
625
  transition={{ type: "spring", stiffness: 120, damping: 20 }}
625
- style={{ background: "linear-gradient(135deg, #22E3E8, #9945FF, #f83cc5)" }}
626
+ style={{ background: fillColor }}
626
627
  />
627
- <Play size={26} fill="white" strokeWidth={0} className="relative z-10" />
628
- </motion.div>
628
+ {/* Pulsing ring when there are unsaved changes */}
629
+ {buttonState === "changed" && (
630
+ <motion.div
631
+ className="absolute inset-0 rounded-xl"
632
+ animate={{ boxShadow: ["0 0 0px rgba(34,227,232,0)", "0 0 10px rgba(34,227,232,0.7)", "0 0 0px rgba(34,227,232,0)"] }}
633
+ transition={{ duration: 1.4, repeat: Infinity, ease: "easeInOut" }}
634
+ />
635
+ )}
636
+ {/* Icon */}
637
+ {submitting
638
+ ? <Loader2 size={22} strokeWidth={2.5} className="relative z-10 animate-spin text-white/70" />
639
+ : buttonState === "saved"
640
+ ? <Check size={24} strokeWidth={2.5} className="relative z-10 text-white" />
641
+ : <Play size={26} fill="white" strokeWidth={0} className="relative z-10" />
642
+ }
643
+ </motion.button>
629
644
  );
630
645
  })()}
631
646
  </AnimatePresence>