@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
|
@@ -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
|
};
|