@devrongx/games 0.4.32 → 0.4.34

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.34",
4
4
  "description": "Game UI components for sports prediction markets",
5
5
  "license": "MIT",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,265 @@
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: "35%", boxShadow: `0 0 ${size * 3}px ${color}` }}
20
+ initial={{ x: 0, y: 0, opacity: 0, scale: 0 }}
21
+ animate={{ x, y: y - 30, opacity: [0, 1, 0.8, 0], scale: [0, 1.8, 0.8, 0] }}
22
+ transition={{ duration: 1.6, 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.div
31
+ initial={{ scale: 0 }}
32
+ animate={{ scale: 1 }}
33
+ transition={{ type: "spring", stiffness: 260, damping: 14, delay }}
34
+ >
35
+ <motion.div
36
+ className="rounded-full flex items-center justify-center"
37
+ style={{ width: size, height: size, boxShadow: "0 0 40px rgba(34,227,232,0.3), 0 0 80px rgba(34,227,232,0.1)" }}
38
+ initial={{ opacity: 0 }}
39
+ animate={{ opacity: 1 }}
40
+ transition={{ delay, duration: 0.3 }}
41
+ >
42
+ <svg width={size} height={size} viewBox="0 0 56 56" fill="none">
43
+ <motion.circle
44
+ cx="28" cy="28" r="26" stroke="#22E3E8" strokeWidth="1.5"
45
+ fill="none"
46
+ initial={{ pathLength: 0, opacity: 0 }}
47
+ animate={{ pathLength: 1, opacity: 0.6 }}
48
+ transition={{ duration: 0.7, delay: delay + 0.1, ease: "easeOut" }}
49
+ />
50
+ <motion.path
51
+ d="M17 28l8 8 14-14" stroke="#22E3E8" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
52
+ fill="none"
53
+ initial={{ pathLength: 0 }}
54
+ animate={{ pathLength: 1 }}
55
+ transition={{ duration: 0.4, delay: delay + 0.55, ease: "easeOut" }}
56
+ />
57
+ </svg>
58
+ </motion.div>
59
+ </motion.div>
60
+ );
61
+
62
+ // ─── Props ───────────────────────────────────────────────────────────────────
63
+
64
+ interface BetCelebrationProps {
65
+ config: IChallengeConfig;
66
+ bets: IUserBets;
67
+ betSummary: IBetSummary;
68
+ onClose: () => void;
69
+ onViewLeaderboard?: () => void;
70
+ isEdit?: boolean;
71
+ editedMarketIdx?: number;
72
+ }
73
+
74
+ // ─── Component ───────────────────────────────────────────────────────────────
75
+
76
+ export const BetCelebration = ({
77
+ config, bets, betSummary, onClose, onViewLeaderboard, isEdit = false, editedMarketIdx,
78
+ }: BetCelebrationProps) => {
79
+ const { totalEntry, compoundedReward } = betSummary;
80
+
81
+ const displayBets = useMemo(() => {
82
+ const entries: { mIdx: number; question: string; optionLabel: string; amount: number; odds: number; reward: number }[] = [];
83
+ if (isEdit && editedMarketIdx !== undefined) {
84
+ const bet = bets[editedMarketIdx];
85
+ if (bet && bet.amount > 0) {
86
+ const market = config.markets[editedMarketIdx];
87
+ const opt = market.options[bet.optionIdx];
88
+ entries.push({ mIdx: editedMarketIdx, question: market.question, optionLabel: opt.label, amount: bet.amount, odds: opt.odds, reward: Math.round(bet.amount * opt.odds) });
89
+ }
90
+ } else {
91
+ for (const [mIdxStr, bet] of Object.entries(bets)) {
92
+ if (bet.amount <= 0) continue;
93
+ const mIdx = Number(mIdxStr);
94
+ const market = config.markets[mIdx];
95
+ if (!market) continue;
96
+ const opt = market.options[bet.optionIdx];
97
+ if (!opt) continue;
98
+ entries.push({ mIdx, question: market.question, optionLabel: opt.label, amount: bet.amount, odds: opt.odds, reward: Math.round(bet.amount * opt.odds) });
99
+ }
100
+ }
101
+ return entries;
102
+ }, [bets, config, isEdit, editedMarketIdx]);
103
+
104
+ // Timing
105
+ const particleCount = isEdit ? 8 : 24;
106
+ const checkDelay = 0.1;
107
+ const titleDelay = checkDelay + 0.5;
108
+ const betStartDelay = isEdit ? 0.5 : 0.9;
109
+ const betStagger = isEdit ? 0.08 : 0.12;
110
+ const outcomeDelay = betStartDelay + displayBets.length * betStagger + 0.3;
111
+ const lineDelay = isEdit ? betStartDelay + displayBets.length * betStagger + 0.2 : outcomeDelay + 0.4;
112
+ const messageDelay = lineDelay + 0.6;
113
+ const buttonDelay = messageDelay + 0.15;
114
+
115
+ return (
116
+ <motion.div
117
+ className="fixed inset-0 z-50 flex items-center justify-center px-6"
118
+ initial={{ opacity: 0 }}
119
+ animate={{ opacity: 1 }}
120
+ exit={{ opacity: 0 }}
121
+ transition={{ duration: 0.3 }}
122
+ >
123
+ {/* Blurred backdrop */}
124
+ <motion.div
125
+ className="absolute inset-0"
126
+ style={{ backdropFilter: "blur(16px)", WebkitBackdropFilter: "blur(16px)", background: "rgba(0,0,0,0.55)" }}
127
+ onClick={onClose}
128
+ />
129
+
130
+ {/* Firework particles */}
131
+ <div className="absolute inset-0 pointer-events-none overflow-hidden">
132
+ {Array.from({ length: particleCount }, (_, i) => {
133
+ const angle = (360 / particleCount) * i + (i % 3) * 5;
134
+ const distance = 50 + (i % 5) * 30;
135
+ const size = 2 + (i % 3) * 1.5;
136
+ const colors = ["#22E3E8", "#22E3E8", "#22E3E8", "#22E3E8", "rgba(255,255,255,0.8)"];
137
+ return <Particle key={i} delay={checkDelay + 0.4 + (i % 6) * 0.04} angle={angle} distance={distance} size={size} color={colors[i % colors.length]} />;
138
+ })}
139
+ {!isEdit && Array.from({ length: 16 }, (_, i) => {
140
+ const angle = (360 / 16) * i + 11;
141
+ const distance = 90 + (i % 4) * 30;
142
+ const size = 1.5 + (i % 3);
143
+ return <Particle key={`b${i}`} delay={outcomeDelay + (i % 5) * 0.04} angle={angle} distance={distance} size={size} color="#22E3E8" />;
144
+ })}
145
+ </div>
146
+
147
+ {/* Floating content — no card, no border */}
148
+ <motion.div
149
+ className="relative z-10 w-full max-w-[320px] max-h-[80vh] overflow-y-auto flex flex-col items-center gap-4 py-4"
150
+ initial={{ scale: 0.9, opacity: 0, y: 16 }}
151
+ animate={{ scale: 1, opacity: 1, y: 0 }}
152
+ transition={{ type: "spring", stiffness: 250, damping: 20, delay: 0.05 }}
153
+ onClick={(e) => e.stopPropagation()}
154
+ >
155
+ {/* Checkmark */}
156
+ <AnimatedCheck delay={checkDelay} size={isEdit ? 40 : 52} />
157
+
158
+ {/* Title */}
159
+ <motion.p
160
+ className="font-bold text-white text-center"
161
+ style={{ ...OUTFIT, fontSize: isEdit ? 15 : 18, letterSpacing: "0.02em" }}
162
+ initial={{ opacity: 0, y: 6 }}
163
+ animate={{ opacity: 1, y: 0 }}
164
+ transition={{ delay: titleDelay, duration: 0.3 }}
165
+ >
166
+ {isEdit ? "Bet Updated" : "Bets Placed"}
167
+ </motion.p>
168
+
169
+ {/* Bet items — floating, no borders */}
170
+ <div className="w-full flex flex-col gap-2 mt-1">
171
+ {displayBets.map((bet, i) => (
172
+ <motion.div
173
+ key={bet.mIdx}
174
+ className="w-full px-3 py-2"
175
+ initial={{ opacity: 0, x: -12 }}
176
+ animate={{ opacity: 1, x: 0 }}
177
+ transition={{ delay: betStartDelay + i * betStagger, duration: 0.3, ease: "easeOut" }}
178
+ >
179
+ <p className="text-[9px] text-white/30 font-medium truncate mb-1" style={OUTFIT}>{bet.question}</p>
180
+ <div className="flex items-center justify-between">
181
+ <div className="flex items-center gap-1.5">
182
+ <SelectedCheck size={8} />
183
+ <span className="text-[11px] text-white font-semibold" style={OUTFIT}>{bet.optionLabel}</span>
184
+ </div>
185
+ <div className="flex items-center gap-[3px]">
186
+ <PointsIcon size={7} />
187
+ <span className="text-[9px] text-white font-semibold" style={OUTFIT}>{bet.amount}</span>
188
+ <span className="text-[8px] text-white/30" style={OUTFIT}>×</span>
189
+ <span className="text-[9px] text-white/50 font-semibold" style={OUTFIT}>{bet.odds}</span>
190
+ <span className="text-[8px] text-white/30" style={OUTFIT}>=</span>
191
+ <PointsIcon size={7} />
192
+ <span className="text-[9px] text-[#22E3E8] font-bold" style={OUTFIT}>{bet.reward}</span>
193
+ </div>
194
+ </div>
195
+ </motion.div>
196
+ ))}
197
+ </div>
198
+
199
+ {/* Max outcome */}
200
+ {!isEdit && totalEntry > 0 && (
201
+ <motion.div
202
+ className="flex items-center justify-center gap-2 pt-1"
203
+ initial={{ opacity: 0, scale: 0.95 }}
204
+ animate={{ opacity: 1, scale: 1 }}
205
+ transition={{ delay: outcomeDelay, type: "spring", stiffness: 200, damping: 18 }}
206
+ >
207
+ <span className="text-[10px] text-white/40 font-medium" style={OUTFIT}>Max outcome</span>
208
+ <div className="flex items-center gap-1">
209
+ <Image src="/iamgame_square_logo.jpg" alt="" width={12} height={12} className="rounded-[2px]" />
210
+ <span className="text-[16px] text-white font-bold" style={OUTFIT}>{totalEntry.toLocaleString()}</span>
211
+ </div>
212
+ <span className="text-[16px] text-white/20 font-bold" style={OUTFIT}>→</span>
213
+ <div className="flex items-center gap-1 relative">
214
+ <Image src="/iamgame_square_logo.jpg" alt="" width={12} height={12} className="rounded-[2px]" />
215
+ <span className="text-[16px] text-[#22E3E8] font-bold" style={{ ...OUTFIT, textShadow: "0 0 12px rgba(34,227,232,0.4)" }}>{compoundedReward.toLocaleString()}</span>
216
+ </div>
217
+ </motion.div>
218
+ )}
219
+
220
+ {/* Line growth */}
221
+ <motion.div
222
+ className="w-3/4 h-px rounded-full overflow-hidden"
223
+ style={{ background: "rgba(255,255,255,0.06)" }}
224
+ initial={{ opacity: 0 }}
225
+ animate={{ opacity: 1 }}
226
+ transition={{ delay: lineDelay }}
227
+ >
228
+ <motion.div
229
+ className="h-full rounded-full"
230
+ style={{ background: "linear-gradient(90deg, transparent, #22E3E8, transparent)" }}
231
+ initial={{ width: "0%" }}
232
+ animate={{ width: "100%" }}
233
+ transition={{ delay: lineDelay + 0.1, duration: 0.8, ease: "easeOut" }}
234
+ />
235
+ </motion.div>
236
+
237
+ {/* Message */}
238
+ <motion.p
239
+ className="text-[11px] text-white/35 text-center font-medium leading-relaxed"
240
+ style={OUTFIT}
241
+ initial={{ opacity: 0 }}
242
+ animate={{ opacity: 1 }}
243
+ transition={{ delay: messageDelay, duration: 0.4 }}
244
+ >
245
+ Track the leaderboard to see your position{"\n"}updating as bets resolve with the game
246
+ </motion.p>
247
+
248
+ {/* Leaderboard button */}
249
+ {onViewLeaderboard && (
250
+ <motion.button
251
+ onClick={onViewLeaderboard}
252
+ className="px-8 py-2 rounded-full text-[12px] font-semibold"
253
+ style={{ ...OUTFIT, background: "rgba(34,227,232,0.12)", color: "#22E3E8", border: "1px solid rgba(34,227,232,0.25)" }}
254
+ whileTap={{ scale: 0.96 }}
255
+ initial={{ opacity: 0, y: 6 }}
256
+ animate={{ opacity: 1, y: 0 }}
257
+ transition={{ delay: buttonDelay, type: "spring", stiffness: 200, damping: 18 }}
258
+ >
259
+ View Leaderboard
260
+ </motion.button>
261
+ )}
262
+ </motion.div>
263
+ </motion.div>
264
+ );
265
+ };
@@ -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
  };