@devrongx/games 0.4.12 → 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.12",
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",
@@ -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;
@@ -290,6 +292,9 @@ export const PreMatchBetsPopup = ({ poolId, matchId: _matchId, match: matchProp
290
292
 
291
293
  await placeTDBets(poolId, betsToSubmit);
292
294
 
295
+ // Snapshot submitted state for change detection
296
+ setSubmittedBets({ ...bets });
297
+
293
298
  // Refresh data
294
299
  await Promise.all([refetchEntry(), refetchLB(), refetchPool()]);
295
300
  } catch (err: unknown) {
@@ -353,6 +358,7 @@ export const PreMatchBetsPopup = ({ poolId, matchId: _matchId, match: matchProp
353
358
  betSummary={betSummary}
354
359
  leaderboardRows={leaderboardRows}
355
360
  onSubmit={poolId ? handleSubmitBets : undefined}
361
+ submittedBets={poolId ? submittedBets : null}
356
362
  onViewLeaderboard={poolId ? () => setShowFullLeaderboard(true) : undefined}
357
363
  submitting={submitting}
358
364
  />
@@ -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>