@devrongx/games 0.4.32 → 0.4.33

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.32",
3
+ "version": "0.4.33",
4
4
  "description": "Game UI components for sports prediction markets",
5
5
  "license": "MIT",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,266 @@
1
+ // @devrongx/games — games/prematch-bets/BetCelebration.tsx
2
+ "use client";
3
+
4
+ import { useMemo } from "react";
5
+ import Image from "next/image";
6
+ import { motion } from "framer-motion";
7
+ import { IChallengeConfig, IUserBets, IBetSummary } from "./config";
8
+ import { OUTFIT, PointsIcon, SelectedCheck } from "./constants";
9
+
10
+ // ─── Firework particle ───────────────────────────────────────────────────────
11
+
12
+ const Particle = ({ delay, angle, distance, size, color }: { delay: number; angle: number; distance: number; size: number; color: string }) => {
13
+ const rad = (angle * Math.PI) / 180;
14
+ const x = Math.cos(rad) * distance;
15
+ const y = Math.sin(rad) * distance;
16
+ return (
17
+ <motion.div
18
+ className="absolute rounded-full"
19
+ style={{ width: size, height: size, background: color, left: "50%", top: "40%" }}
20
+ initial={{ x: 0, y: 0, opacity: 0, scale: 0 }}
21
+ animate={{ x, y: y - 20, opacity: [0, 1, 1, 0], scale: [0, 1.5, 1, 0] }}
22
+ transition={{ duration: 1.4, delay, ease: "easeOut" }}
23
+ />
24
+ );
25
+ };
26
+
27
+ // ─── Animated SVG checkmark ──────────────────────────────────────────────────
28
+
29
+ const AnimatedCheck = ({ delay = 0, size = 56 }: { delay?: number; size?: number }) => (
30
+ <motion.svg
31
+ width={size} height={size} viewBox="0 0 56 56" fill="none"
32
+ initial={{ scale: 0 }} animate={{ scale: 1 }}
33
+ transition={{ type: "spring", stiffness: 260, damping: 14, delay }}
34
+ >
35
+ <motion.circle
36
+ cx="28" cy="28" r="26" stroke="#22E3E8" strokeWidth="2"
37
+ fill="rgba(34,227,232,0.06)"
38
+ initial={{ pathLength: 0, opacity: 0 }}
39
+ animate={{ pathLength: 1, opacity: 1 }}
40
+ transition={{ duration: 0.6, delay: delay + 0.1, ease: "easeOut" }}
41
+ />
42
+ <motion.path
43
+ d="M17 28l8 8 14-14" stroke="#22E3E8" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"
44
+ fill="none"
45
+ initial={{ pathLength: 0 }}
46
+ animate={{ pathLength: 1 }}
47
+ transition={{ duration: 0.4, delay: delay + 0.5, ease: "easeOut" }}
48
+ />
49
+ </motion.svg>
50
+ );
51
+
52
+ // ─── Props ───────────────────────────────────────────────────────────────────
53
+
54
+ interface BetCelebrationProps {
55
+ config: IChallengeConfig;
56
+ bets: IUserBets;
57
+ betSummary: IBetSummary;
58
+ onClose: () => void;
59
+ onViewLeaderboard?: () => void;
60
+ isEdit?: boolean;
61
+ editedMarketIdx?: number;
62
+ }
63
+
64
+ // ─── Component ───────────────────────────────────────────────────────────────
65
+
66
+ export const BetCelebration = ({
67
+ config, bets, betSummary, onClose, onViewLeaderboard, isEdit = false, editedMarketIdx,
68
+ }: BetCelebrationProps) => {
69
+ const { totalEntry, compoundedReward } = betSummary;
70
+
71
+ // Build list of bets to display
72
+ const displayBets = useMemo(() => {
73
+ const entries: { mIdx: number; question: string; optionLabel: string; amount: number; odds: number; reward: number }[] = [];
74
+ if (isEdit && editedMarketIdx !== undefined) {
75
+ const bet = bets[editedMarketIdx];
76
+ if (bet && bet.amount > 0) {
77
+ const market = config.markets[editedMarketIdx];
78
+ const opt = market.options[bet.optionIdx];
79
+ entries.push({ mIdx: editedMarketIdx, question: market.question, optionLabel: opt.label, amount: bet.amount, odds: opt.odds, reward: Math.round(bet.amount * opt.odds) });
80
+ }
81
+ } else {
82
+ for (const [mIdxStr, bet] of Object.entries(bets)) {
83
+ if (bet.amount <= 0) continue;
84
+ const mIdx = Number(mIdxStr);
85
+ const market = config.markets[mIdx];
86
+ if (!market) continue;
87
+ const opt = market.options[bet.optionIdx];
88
+ if (!opt) continue;
89
+ entries.push({ mIdx, question: market.question, optionLabel: opt.label, amount: bet.amount, odds: opt.odds, reward: Math.round(bet.amount * opt.odds) });
90
+ }
91
+ }
92
+ return entries;
93
+ }, [bets, config, isEdit, editedMarketIdx]);
94
+
95
+ // Timing
96
+ const particleCount = isEdit ? 10 : 20;
97
+ const checkDelay = 0.15;
98
+ const titleDelay = checkDelay + 0.4;
99
+ const betStartDelay = isEdit ? 0.5 : 0.8;
100
+ const betStagger = isEdit ? 0.1 : 0.15;
101
+ const outcomeDelay = betStartDelay + displayBets.length * betStagger + 0.25;
102
+ const lineDelay = outcomeDelay + (isEdit ? 0.2 : 0.4);
103
+ const messageDelay = lineDelay + 0.5;
104
+ const buttonDelay = messageDelay + 0.2;
105
+
106
+ return (
107
+ <motion.div
108
+ className="fixed inset-0 z-50 flex items-center justify-center px-4"
109
+ initial={{ opacity: 0 }}
110
+ animate={{ opacity: 1 }}
111
+ exit={{ opacity: 0 }}
112
+ transition={{ duration: 0.25 }}
113
+ >
114
+ {/* Backdrop */}
115
+ <motion.div
116
+ className="absolute inset-0"
117
+ style={{ background: "rgba(0,0,0,0.70)" }}
118
+ onClick={onClose}
119
+ />
120
+
121
+ {/* Firework particles */}
122
+ <div className="absolute inset-0 pointer-events-none overflow-hidden">
123
+ {Array.from({ length: particleCount }, (_, i) => {
124
+ const angle = (360 / particleCount) * i + (i % 3) * 8;
125
+ const distance = 60 + (i % 4) * 35;
126
+ const size = 2 + (i % 3) * 2;
127
+ const colors = ["#22E3E8", "#22E3E8", "#22E3E8", "#9945FF", "rgba(255,255,255,0.9)"];
128
+ return <Particle key={i} delay={checkDelay + 0.3 + (i % 5) * 0.06} angle={angle} distance={distance} size={size} color={colors[i % colors.length]} />;
129
+ })}
130
+ {/* Second burst */}
131
+ {!isEdit && Array.from({ length: 12 }, (_, i) => {
132
+ const angle = (360 / 12) * i + 15;
133
+ const distance = 100 + (i % 3) * 40;
134
+ const size = 1.5 + (i % 2) * 1.5;
135
+ return <Particle key={`b${i}`} delay={outcomeDelay + (i % 4) * 0.05} angle={angle} distance={distance} size={size} color="#22E3E8" />;
136
+ })}
137
+ </div>
138
+
139
+ {/* Content card */}
140
+ <motion.div
141
+ className="relative z-10 w-full max-w-[340px] max-h-[80vh] overflow-y-auto rounded-2xl px-5 py-6 flex flex-col items-center gap-3"
142
+ style={{ background: "linear-gradient(180deg, #111122 0%, #0a0a12 100%)", border: "1px solid rgba(34,227,232,0.12)" }}
143
+ initial={{ scale: 0.85, opacity: 0, y: 20 }}
144
+ animate={{ scale: 1, opacity: 1, y: 0 }}
145
+ transition={{ type: "spring", stiffness: 280, damping: 20, delay: 0.05 }}
146
+ onClick={(e) => e.stopPropagation()}
147
+ >
148
+ {/* Checkmark */}
149
+ <AnimatedCheck delay={checkDelay} size={isEdit ? 44 : 56} />
150
+
151
+ {/* Title */}
152
+ <motion.p
153
+ className="font-bold text-white text-center"
154
+ style={{ ...OUTFIT, fontSize: isEdit ? 16 : 20 }}
155
+ initial={{ opacity: 0, y: 8 }}
156
+ animate={{ opacity: 1, y: 0 }}
157
+ transition={{ delay: titleDelay, duration: 0.3 }}
158
+ >
159
+ {isEdit ? "Bet Updated!" : "Bets Placed!"}
160
+ </motion.p>
161
+
162
+ {/* Bet items — one by one */}
163
+ <div className="w-full flex flex-col gap-1.5 mt-1">
164
+ {displayBets.map((bet, i) => (
165
+ <motion.div
166
+ key={bet.mIdx}
167
+ className="w-full px-3 py-2 rounded-lg"
168
+ style={{ background: "rgba(34,227,232,0.03)", border: "1px solid rgba(34,227,232,0.08)" }}
169
+ initial={{ opacity: 0, x: -16 }}
170
+ animate={{ opacity: 1, x: 0 }}
171
+ transition={{ delay: betStartDelay + i * betStagger, duration: 0.3, ease: "easeOut" }}
172
+ >
173
+ <p className="text-[10px] text-white/40 font-medium truncate mb-0.5" style={OUTFIT}>{bet.question}</p>
174
+ <div className="flex items-center justify-between">
175
+ <div className="flex items-center gap-1.5">
176
+ <SelectedCheck size={8} />
177
+ <span className="text-[11px] text-white font-semibold" style={OUTFIT}>{bet.optionLabel}</span>
178
+ </div>
179
+ <div className="flex items-center gap-[3px]">
180
+ <PointsIcon size={7} />
181
+ <span className="text-[9px] text-white font-semibold" style={OUTFIT}>{bet.amount}</span>
182
+ <span className="text-[8px] text-white/40" style={OUTFIT}>×</span>
183
+ <span className="text-[9px] text-white/60 font-semibold" style={OUTFIT}>{bet.odds}</span>
184
+ <span className="text-[8px] text-white/40" style={OUTFIT}>=</span>
185
+ <PointsIcon size={7} />
186
+ <span className="text-[9px] text-[#22E3E8] font-bold" style={OUTFIT}>{bet.reward}</span>
187
+ </div>
188
+ </div>
189
+ </motion.div>
190
+ ))}
191
+ </div>
192
+
193
+ {/* Max outcome */}
194
+ {!isEdit && totalEntry > 0 && (
195
+ <motion.div
196
+ className="w-full flex items-center justify-center gap-2 pt-2"
197
+ initial={{ opacity: 0, scale: 0.9 }}
198
+ animate={{ opacity: 1, scale: 1 }}
199
+ transition={{ delay: outcomeDelay, type: "spring", stiffness: 200, damping: 18 }}
200
+ >
201
+ <span className="text-[11px] text-white/50 font-medium" style={OUTFIT}>Max outcome</span>
202
+ <div className="flex items-center gap-1">
203
+ <Image src="/iamgame_square_logo.jpg" alt="" width={14} height={14} className="rounded-[2px]" />
204
+ <span className="text-[18px] text-white font-bold" style={OUTFIT}>{totalEntry.toLocaleString()}</span>
205
+ </div>
206
+ <span className="text-[18px] text-white/30 font-bold" style={OUTFIT}>→</span>
207
+ <div className="flex items-center gap-1 relative">
208
+ <Image src="/iamgame_square_logo.jpg" alt="" width={14} height={14} className="rounded-[2px]" />
209
+ <span className="text-[18px] text-[#22E3E8] font-bold" style={OUTFIT}>{compoundedReward.toLocaleString()}</span>
210
+ <motion.div className="absolute inset-0 pointer-events-none overflow-hidden rounded">
211
+ <motion.div
212
+ className="absolute inset-y-0 w-[50%]"
213
+ style={{ background: "linear-gradient(90deg, transparent, rgba(34,227,232,0.25), transparent)" }}
214
+ animate={{ left: ["-50%", "150%"] }}
215
+ transition={{ duration: 1.8, repeat: Infinity, repeatDelay: 2.5, ease: "easeInOut", delay: outcomeDelay + 0.3 }}
216
+ />
217
+ </motion.div>
218
+ </div>
219
+ </motion.div>
220
+ )}
221
+
222
+ {/* Line growth */}
223
+ <motion.div
224
+ className="w-full h-[2px] rounded-full overflow-hidden mt-1"
225
+ style={{ background: "rgba(34,227,232,0.08)" }}
226
+ initial={{ opacity: 0 }}
227
+ animate={{ opacity: 1 }}
228
+ transition={{ delay: lineDelay }}
229
+ >
230
+ <motion.div
231
+ className="h-full rounded-full"
232
+ style={{ background: "linear-gradient(90deg, #22E3E8, #9945FF)" }}
233
+ initial={{ width: "0%" }}
234
+ animate={{ width: "100%" }}
235
+ transition={{ delay: lineDelay + 0.1, duration: 0.7, ease: "easeOut" }}
236
+ />
237
+ </motion.div>
238
+
239
+ {/* Message */}
240
+ <motion.p
241
+ className="text-[11px] text-white/45 text-center font-medium leading-relaxed px-2"
242
+ style={OUTFIT}
243
+ initial={{ opacity: 0 }}
244
+ animate={{ opacity: 1 }}
245
+ transition={{ delay: messageDelay, duration: 0.4 }}
246
+ >
247
+ Track the leaderboard to see your position updating as bets resolve with the game
248
+ </motion.p>
249
+
250
+ {/* Leaderboard button */}
251
+ {onViewLeaderboard && (
252
+ <motion.button
253
+ onClick={onViewLeaderboard}
254
+ className="w-full py-2.5 rounded-xl text-[13px] font-bold"
255
+ style={{ ...OUTFIT, background: "linear-gradient(135deg, #22E3E8, #9945FF)", color: "#0a0a12" }}
256
+ initial={{ opacity: 0, y: 8 }}
257
+ animate={{ opacity: 1, y: 0 }}
258
+ transition={{ delay: buttonDelay, type: "spring", stiffness: 200, damping: 18 }}
259
+ >
260
+ View Leaderboard
261
+ </motion.button>
262
+ )}
263
+ </motion.div>
264
+ </motion.div>
265
+ );
266
+ };
@@ -28,6 +28,8 @@ import { saveTDDraftBetsApi } from "../../pools/fetcher";
28
28
  import type { ITDLeaderboardEntry } from "../../pools/types";
29
29
  import { TDPoolStatus } from "../../pools/types";
30
30
  import { calcRankPayout } from "./config";
31
+ import { BetCelebration } from "./BetCelebration";
32
+ import { AnimatePresence } from "framer-motion";
31
33
 
32
34
  const GAME_ID = "pre-match";
33
35
 
@@ -128,6 +130,9 @@ export const PreMatchBetsPopup = ({ poolId, matchId: _matchId, match: matchProp
128
130
  const [editingSnapshots, setEditingSnapshots] = useState<Record<number, IBetEntry | undefined>>({});
129
131
  const [editSubmitting, setEditSubmitting] = useState<number | null>(null);
130
132
 
133
+ // ── Celebration overlay state ─────────────────────────────────────────────
134
+ const [celebration, setCelebration] = useState<{ isEdit: boolean; marketIdx?: number } | null>(null);
135
+
131
136
  // ── Real bets restore: when user has submitted bets, load them into game state ─
132
137
  const realBetsRestoredRef = useRef(false);
133
138
  useEffect(() => {
@@ -300,6 +305,9 @@ export const PreMatchBetsPopup = ({ poolId, matchId: _matchId, match: matchProp
300
305
 
301
306
  // Refresh data
302
307
  await Promise.all([refetchEntry(), refetchLB(), refetchPool()]);
308
+
309
+ // Show celebration
310
+ setCelebration({ isEdit: false });
303
311
  } catch (err: unknown) {
304
312
  setSubmitError(err instanceof Error ? err.message : "Failed to submit bets");
305
313
  } finally {
@@ -348,6 +356,9 @@ export const PreMatchBetsPopup = ({ poolId, matchId: _matchId, match: matchProp
348
356
  setEditingSnapshots(prev => { const next = { ...prev }; delete next[mIdx]; return next; });
349
357
 
350
358
  await Promise.all([refetchEntry(), refetchLB(), refetchPool()]);
359
+
360
+ // Show celebration (edit)
361
+ setCelebration({ isEdit: true, marketIdx: mIdx });
351
362
  } catch (err: unknown) {
352
363
  setSubmitError(err instanceof Error ? err.message : "Failed to save changes");
353
364
  } finally {
@@ -488,6 +499,19 @@ export const PreMatchBetsPopup = ({ poolId, matchId: _matchId, match: matchProp
488
499
  onViewFullLeaderboard={() => setShowFullLeaderboard(true)}
489
500
  />
490
501
  )}
502
+ <AnimatePresence>
503
+ {celebration && config && (
504
+ <BetCelebration
505
+ config={config}
506
+ bets={bets}
507
+ betSummary={betSummary}
508
+ isEdit={celebration.isEdit}
509
+ editedMarketIdx={celebration.marketIdx}
510
+ onClose={() => setCelebration(null)}
511
+ onViewLeaderboard={poolId ? () => { setCelebration(null); setShowFullLeaderboard(true); } : undefined}
512
+ />
513
+ )}
514
+ </AnimatePresence>
491
515
  </GamePopupShell>
492
516
  );
493
517
  };