@devrongx/games 0.4.11 → 0.4.13

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.11",
3
+ "version": "0.4.13",
4
4
  "description": "Game UI components for sports prediction markets",
5
5
  "license": "MIT",
6
6
  "main": "./src/index.ts",
@@ -16,7 +16,6 @@ import { GamePopupShell } from "../../core/GamePopupShell";
16
16
  import { PreMatchIntro } from "./PreMatchIntro";
17
17
  import { PreMatchQuestions } from "./PreMatchQuestions";
18
18
  import { PreMatchGame } from "./PreMatchGame";
19
- import { PreMatchSubmitted } from "./PreMatchSubmitted";
20
19
  import { PreMatchLive } from "./PreMatchLive";
21
20
  import { PreMatchResults } from "./PreMatchResults";
22
21
  import { FullLeaderboard } from "./FullLeaderboard";
@@ -34,7 +33,7 @@ const GAME_ID = "pre-match";
34
33
  // ─── View logic ────────────────────────────────────────────────────────────────
35
34
 
36
35
  type UserFlowState = "intro" | "questions" | "game";
37
- type ActiveView = UserFlowState | "submitted" | "live" | "results";
36
+ type ActiveView = UserFlowState | "live" | "results";
38
37
 
39
38
  function getActiveView(
40
39
  poolStatus: number | undefined,
@@ -44,8 +43,7 @@ function getActiveView(
44
43
  if (poolStatus === undefined) return userFlowState;
45
44
  if (poolStatus === TDPoolStatus.COMPLETE || poolStatus === TDPoolStatus.CANCELLED) return "results";
46
45
  if (poolStatus === TDPoolStatus.CLOSED || poolStatus === TDPoolStatus.RESOLVING) return hasEntry ? "live" : "results";
47
- // Pool is open
48
- if (hasEntry) return "submitted";
46
+ // Pool is open — user can keep editing regardless
49
47
  return userFlowState;
50
48
  }
51
49
 
@@ -122,6 +120,33 @@ export const PreMatchBetsPopup = ({ poolId, matchId: _matchId, match: matchProp
122
120
  const [showFullLeaderboard, setShowFullLeaderboard] = useState(false);
123
121
  const [submitting, setSubmitting] = useState(false);
124
122
  const [submitError, setSubmitError] = useState<string | null>(null);
123
+ const [submittedBets, setSubmittedBets] = useState<IUserBets | null>(null);
124
+
125
+ // ── Real bets restore: when user has submitted bets, load them into game state ─
126
+ const realBetsRestoredRef = useRef(false);
127
+ useEffect(() => {
128
+ if (!poolId || !config || !entryData || realBetsRestoredRef.current) return;
129
+ if (entryData.bets.length === 0) return;
130
+ const loaded: IUserBets = {};
131
+ const pickers: Record<number, number> = {};
132
+ for (const bet of entryData.bets) {
133
+ if (bet.parlay_id !== null) continue;
134
+ const mIdx = config.markets.findIndex((m) => m.backendChallengeId === bet.challenge_id);
135
+ if (mIdx < 0) continue;
136
+ const oIdx = config.markets[mIdx].options.findIndex(
137
+ (o) => o.label.toLowerCase().replace(/\s+/g, "_") === bet.selected_option,
138
+ );
139
+ if (oIdx < 0) continue;
140
+ loaded[mIdx] = { optionIdx: oIdx, amount: bet.coin_amount, parlaySlot: null };
141
+ pickers[mIdx] = oIdx;
142
+ }
143
+ if (Object.keys(loaded).length === 0) return;
144
+ setBets(loaded);
145
+ setSubmittedBets(loaded);
146
+ setExpandedPicker(pickers);
147
+ setUserFlowState("game");
148
+ realBetsRestoredRef.current = true;
149
+ }, [poolId, config, entryData]);
125
150
 
126
151
  // ── Draft restore: when entry + config load, seed bets from draft_selections ─
127
152
  const draftRestoredRef = useRef(false);
@@ -267,6 +292,9 @@ export const PreMatchBetsPopup = ({ poolId, matchId: _matchId, match: matchProp
267
292
 
268
293
  await placeTDBets(poolId, betsToSubmit);
269
294
 
295
+ // Snapshot submitted state for change detection
296
+ setSubmittedBets({ ...bets });
297
+
270
298
  // Refresh data
271
299
  await Promise.all([refetchEntry(), refetchLB(), refetchPool()]);
272
300
  } catch (err: unknown) {
@@ -276,24 +304,6 @@ export const PreMatchBetsPopup = ({ poolId, matchId: _matchId, match: matchProp
276
304
  }
277
305
  }, [poolId, config, bets, hasEntry, refetchEntry, refetchLB, refetchPool]);
278
306
 
279
- const handleAdjust = useCallback(async () => {
280
- // Load server bets back into local state
281
- if (entryData?.bets && config) {
282
- const loaded: IUserBets = {};
283
- for (const bet of entryData.bets) {
284
- if (bet.parlay_id !== null) continue; // skip parlays for now
285
- const mIdx = config.markets.findIndex((m) => m.backendChallengeId === bet.challenge_id);
286
- if (mIdx >= 0) {
287
- // Find option by matching the stored key to a label approximation
288
- // (we store selected_option as the label key from the pool option)
289
- const oIdx = config.markets[mIdx].options.findIndex((_, i) => i === 0); // fallback: first option
290
- loaded[mIdx] = { optionIdx: oIdx, amount: bet.coin_amount, parlaySlot: null };
291
- }
292
- }
293
- setBets(loaded);
294
- }
295
- setUserFlowState("game");
296
- }, [entryData, config]);
297
307
 
298
308
  // ── Loading ────────────────────────────────────────────────────────────────
299
309
  if (poolId && (poolLoading || (!config && !poolLoading))) {
@@ -348,22 +358,12 @@ export const PreMatchBetsPopup = ({ poolId, matchId: _matchId, match: matchProp
348
358
  betSummary={betSummary}
349
359
  leaderboardRows={leaderboardRows}
350
360
  onSubmit={poolId ? handleSubmitBets : undefined}
361
+ submittedBets={poolId ? submittedBets : null}
351
362
  onViewLeaderboard={poolId ? () => setShowFullLeaderboard(true) : undefined}
352
363
  submitting={submitting}
353
364
  />
354
365
  )}
355
366
 
356
- {activeView === "submitted" && (
357
- <PreMatchSubmitted
358
- config={config}
359
- poolId={poolId!}
360
- entryData={entryData}
361
- rankings={rankings}
362
- onAdjust={handleAdjust}
363
- onViewLeaderboard={() => setShowFullLeaderboard(true)}
364
- />
365
- )}
366
-
367
367
  {activeView === "live" && (
368
368
  <PreMatchLive
369
369
  config={config}
@@ -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>