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