@devrongx/games 0.1.0

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.
@@ -0,0 +1,829 @@
1
+ // @devrongx/games — games/prematch-bets/PreMatchGame.tsx
2
+ "use client";
3
+
4
+ import { useState, useEffect, useMemo, useCallback } from "react";
5
+ import Image from "next/image";
6
+ import { motion, AnimatePresence, useSpring, useTransform, useMotionValue } from "framer-motion";
7
+ import { ChevronDown, Info, X, Play } from "lucide-react";
8
+ import { IBetSummary, ILeaderboardEntry, IChallengeConfig, IUserBets, deriveParlayGroups, deriveMarketToParlay, calcDisplayReward, optionReward, calcParlayMultiplier } from "./config";
9
+ import { OUTFIT, MARKET_ICONS, PointsIcon, SelectedCheck } from "./constants";
10
+ import { useGamePopupStore } from "../../core/gamePopupStore";
11
+ import { LeaderboardRow } from "./LeaderboardRow";
12
+
13
+ /** Animated number that counts up/down to the target value */
14
+ const AnimatedNumber = ({ value, className, style }: { value: number; className?: string; style?: React.CSSProperties }) => {
15
+ const motionVal = useMotionValue(value);
16
+ const spring = useSpring(motionVal, { stiffness: 120, damping: 20 });
17
+ const display = useTransform(spring, (v) => Math.max(0, Math.round(v)).toLocaleString());
18
+ const [text, setText] = useState(value.toLocaleString());
19
+
20
+ useEffect(() => { motionVal.set(value); }, [value, motionVal]);
21
+ useEffect(() => {
22
+ const unsub = display.on("change", (v) => setText(v));
23
+ return unsub;
24
+ }, [display]);
25
+
26
+ return <span className={className} style={style}>{text}</span>;
27
+ };
28
+
29
+ interface PreMatchGameProps {
30
+ config: IChallengeConfig;
31
+ bets: IUserBets;
32
+ onBetsChange?: (bets: IUserBets) => void;
33
+ expandedPicker: Record<number, number>; // mIdx → oIdx for open pickers
34
+ onOptionClick: (mIdx: number, oIdx: number) => void;
35
+ onAmountSelect: (mIdx: number, oIdx: number, amount: number) => void;
36
+ betSummary: IBetSummary;
37
+ leaderboardRows: ILeaderboardEntry[];
38
+ /** Render only the markets section (no header/balance/leaderboard) — used for card preview */
39
+ marketsOnly?: boolean;
40
+ }
41
+
42
+
43
+ export const PreMatchGame = ({ config, bets, onBetsChange, expandedPicker, onOptionClick, onAmountSelect, betSummary, leaderboardRows, marketsOnly }: PreMatchGameProps) => {
44
+ const { selectedCount, compoundMultiplier, totalEntry, compoundedReward, remainingBalance, riskPercent } = betSummary;
45
+
46
+ const goTo = useGamePopupStore(s => s.goTo);
47
+
48
+ // Onboarding — visible whenever no points have been bet yet
49
+ const showOnboarding = !marketsOnly && totalEntry === 0;
50
+ const onboardingWords = useMemo(() =>
51
+ `Select how many points you want to bet on each question. Spend all ${config.startingBalance.toLocaleString()} points across ${config.markets.length} questions.`.split(" "),
52
+ [config.startingBalance, config.markets.length]
53
+ );
54
+
55
+ // Collapsed/expanded state per market
56
+ const [collapsedMarkets, setCollapsedMarkets] = useState<Set<number>>(new Set());
57
+ const toggleCollapse = useCallback((mIdx: number) => {
58
+ setCollapsedMarkets(prev => {
59
+ const next = new Set(prev);
60
+ if (next.has(mIdx)) next.delete(mIdx);
61
+ else next.add(mIdx);
62
+ return next;
63
+ });
64
+ }, []);
65
+
66
+ // Parlay edit mode: which slot is being edited (null = none)
67
+ const [editingParlay, setEditingParlay] = useState<number | null>(null);
68
+ // Parlay info popup: which slot's breakdown is shown (null = none)
69
+ const [parlayInfo, setParlayInfo] = useState<number | null>(null);
70
+
71
+ const parlayColors = config.parlayConfig.colors;
72
+
73
+ // Derived from unified state
74
+ const parlayGroups = useMemo(() => deriveParlayGroups(bets), [bets]);
75
+ const marketToParlay = useMemo(() => deriveMarketToParlay(bets), [bets]);
76
+
77
+ // Toggle a market in/out of the currently editing parlay.
78
+ // A market already in a DIFFERENT parlay cannot be toggled — UI prevents this.
79
+ const toggleMarketInParlay = useCallback((mIdx: number) => {
80
+ if (editingParlay === null || !onBetsChange) return;
81
+ const slot = editingParlay;
82
+ const entry = bets[mIdx];
83
+ if (!entry) return;
84
+
85
+ // Block if market belongs to a different parlay
86
+ if (entry.parlaySlot !== null && entry.parlaySlot !== slot) return;
87
+
88
+ const next = { ...bets };
89
+ if (entry.parlaySlot === slot) {
90
+ // Remove from this parlay → individual
91
+ next[mIdx] = { ...entry, parlaySlot: null };
92
+ } else {
93
+ // Add to this parlay
94
+ next[mIdx] = { ...entry, parlaySlot: slot };
95
+ }
96
+ onBetsChange(next);
97
+ }, [editingParlay, bets, onBetsChange]);
98
+
99
+ // Clear a parlay slot — set all its markets back to individual
100
+ const clearParlay = useCallback((slot: number) => {
101
+ if (!onBetsChange) return;
102
+ const next = { ...bets };
103
+ for (const [mIdxStr, entry] of Object.entries(next)) {
104
+ if (entry.parlaySlot === slot) {
105
+ next[Number(mIdxStr)] = { ...entry, parlaySlot: null };
106
+ }
107
+ }
108
+ onBetsChange(next);
109
+ if (editingParlay === slot) setEditingParlay(null);
110
+ }, [bets, onBetsChange, editingParlay]);
111
+
112
+ // Pre-compute which market indices start a new category section
113
+ const categoryFirstIndices = useMemo(() => {
114
+ const set = new Set<number>();
115
+ let prev = "";
116
+ config.markets.forEach((m, i) => {
117
+ if (m.category !== prev) { set.add(i); prev = m.category; }
118
+ });
119
+ return set;
120
+ }, [config.markets]);
121
+
122
+ return (
123
+ <div className={`w-full flex flex-col gap-3 px-4${marketsOnly ? "" : " relative"}`}>
124
+ {!marketsOnly && (
125
+ <>
126
+ {/* Match header — edge-to-edge, black bg */}
127
+ <div className="-mx-4 -mt-4 flex flex-col items-center justify-center pt-10 pb-8 bg-black border-b border-white/10">
128
+ <div className="flex items-center gap-2 mb-1">
129
+ <span className="text-[14px] text-white font-bold tracking-wide" style={OUTFIT}>{config.teamB.shortName}</span>
130
+ <span className="text-[10px] text-white/25 font-semibold uppercase" style={OUTFIT}>vs</span>
131
+ <span className="text-[14px] text-white font-bold tracking-wide" style={OUTFIT}>{config.teamA.shortName}</span>
132
+ </div>
133
+ <span className="text-[9px] text-white/20 font-medium tracking-wider uppercase" style={OUTFIT}>{config.venue}</span>
134
+ </div>
135
+
136
+ {/* How to Play */}
137
+ <div className="pt-2">
138
+ <button
139
+ onClick={() => goTo("intro")}
140
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-[#22E3E8]/20 bg-[#22E3E8]/[0.06]"
141
+ >
142
+ <Play size={12} fill="#22E3E8" strokeWidth={0} className="text-[#22E3E8]" />
143
+ <span className="text-[10px] text-[#22E3E8] font-semibold tracking-wide" style={OUTFIT}>How to Play</span>
144
+ </button>
145
+ </div>
146
+ </>
147
+ )}
148
+
149
+ {/* Markets — grouped by category */}
150
+ <div className="w-full">
151
+ <div className="flex flex-col gap-3">
152
+ {config.markets.map((market, mIdx) => {
153
+ const selection = bets[mIdx];
154
+ const isPickerOpenForMarket = mIdx in expandedPicker;
155
+ const showCategoryHeader = categoryFirstIndices.has(mIdx);
156
+
157
+ // Available balance for picker: remaining + current bet on this market (it would be freed)
158
+ const pickerAvailable = remainingBalance + (selection?.amount ?? 0);
159
+ const chipCount = Math.floor(pickerAvailable / config.parlayConfig.stakeIncrements);
160
+
161
+ const IconComponent = MARKET_ICONS[market.icon];
162
+
163
+ return (
164
+ <div key={market.id}>
165
+ {/* Category header */}
166
+ {showCategoryHeader && (
167
+ <div className="px-1 pt-1 pb-1.5">
168
+ <span className="text-[8px] text-white/40 uppercase tracking-widest font-bold" style={OUTFIT}>
169
+ {config.categoryLabels[market.category] ?? market.category}
170
+ </span>
171
+ </div>
172
+ )}
173
+
174
+ {/* Question header + collapse toggle */}
175
+ {(() => {
176
+ const isCollapsed = collapsedMarkets.has(mIdx);
177
+ const selectedOpt = selection ? market.options[selection.optionIdx] : null;
178
+ const betAmount = selection?.amount ?? 0;
179
+ const reward = selectedOpt && betAmount > 0 ? Math.round(betAmount * selectedOpt.odds) : 0;
180
+
181
+ return (
182
+ <>
183
+ <div className="px-1 py-1.5 flex items-center gap-2 cursor-pointer" onClick={() => toggleCollapse(mIdx)}>
184
+ {IconComponent && <IconComponent size={14} className="text-white/30 flex-shrink-0" />}
185
+ <span className="text-[13px] text-white font-semibold leading-tight flex-1" style={OUTFIT}>{market.question}</span>
186
+ {/* Parlay color dot */}
187
+ {mIdx in marketToParlay && (
188
+ <div
189
+ className="w-[8px] h-[8px] rounded-full flex-shrink-0"
190
+ style={{ background: parlayColors[marketToParlay[mIdx]] }}
191
+ />
192
+ )}
193
+ {/* Edit mode: parlay toggle */}
194
+ {editingParlay !== null && selection && (() => {
195
+ const inCurrentParlay = bets[mIdx]?.parlaySlot === editingParlay;
196
+ const inOtherParlay = mIdx in marketToParlay && marketToParlay[mIdx] !== editingParlay;
197
+ if (inOtherParlay) return (
198
+ <div
199
+ className="flex-shrink-0 w-[20px] h-[20px] rounded-full flex items-center justify-center opacity-40"
200
+ style={{ border: `1px dashed ${parlayColors[marketToParlay[mIdx]]}` }}
201
+ >
202
+ <div className="w-[6px] h-[6px] rounded-full" style={{ background: parlayColors[marketToParlay[mIdx]] }} />
203
+ </div>
204
+ );
205
+ return (
206
+ <button
207
+ onClick={(e) => { e.stopPropagation(); toggleMarketInParlay(mIdx); }}
208
+ className="flex-shrink-0 w-[20px] h-[20px] rounded-full border flex items-center justify-center transition-all"
209
+ style={{
210
+ borderColor: parlayColors[editingParlay],
211
+ background: inCurrentParlay ? parlayColors[editingParlay] : "transparent",
212
+ }}
213
+ >
214
+ {inCurrentParlay && (
215
+ <svg width="10" height="10" viewBox="0 0 16 16" fill="none">
216
+ <path d="M5 8l2 2 4-4" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"/>
217
+ </svg>
218
+ )}
219
+ </button>
220
+ );
221
+ })()}
222
+ {/* Collapse/expand chevron */}
223
+ <motion.div
224
+ animate={{ rotate: isCollapsed ? 0 : 180 }}
225
+ transition={{ duration: 0.25 }}
226
+ className="flex-shrink-0"
227
+ >
228
+ <ChevronDown size={14} className="text-white/40" />
229
+ </motion.div>
230
+ </div>
231
+
232
+ {/* Collapsed summary row */}
233
+ <AnimatePresence initial={false}>
234
+ {isCollapsed && (
235
+ <motion.div
236
+ className="overflow-hidden"
237
+ initial={{ height: 0, opacity: 0 }}
238
+ animate={{ height: "auto", opacity: 1 }}
239
+ exit={{ height: 0, opacity: 0 }}
240
+ transition={{ duration: 0.25, ease: "easeInOut" }}
241
+ >
242
+ <div className="px-2 py-1.5 flex items-center justify-between">
243
+ {/* Left — selected option */}
244
+ <div className="flex items-center gap-1.5">
245
+ {selectedOpt ? (
246
+ <>
247
+ <SelectedCheck />
248
+ <span className="text-[10px] text-white font-semibold" style={OUTFIT}>{selectedOpt.label}</span>
249
+ </>
250
+ ) : (
251
+ <span className="text-[10px] text-white/30 font-medium" style={OUTFIT}>No selection</span>
252
+ )}
253
+ </div>
254
+ {/* Right — bet summary */}
255
+ {selectedOpt && (
256
+ <div className="flex items-center gap-[3px]">
257
+ {betAmount > 0 ? (
258
+ <>
259
+ <PointsIcon size={7} />
260
+ <span className="text-[9px] text-white font-semibold" style={OUTFIT}>{betAmount}</span>
261
+ <span className="text-[8px] text-white/40" style={OUTFIT}>×</span>
262
+ <span className="text-[9px] text-white/60 font-semibold" style={OUTFIT}>{selectedOpt.odds}</span>
263
+ <span className="text-[8px] text-white/40" style={OUTFIT}>=</span>
264
+ <PointsIcon size={7} />
265
+ <span className="text-[9px] text-[#22E3E8] font-bold" style={OUTFIT}>{reward}</span>
266
+ </>
267
+ ) : (
268
+ <span className="text-[9px] text-white/40 font-semibold" style={OUTFIT}>{selectedOpt.odds}x</span>
269
+ )}
270
+ </div>
271
+ )}
272
+ </div>
273
+ </motion.div>
274
+ )}
275
+ </AnimatePresence>
276
+ </>
277
+ );
278
+ })()}
279
+
280
+ {/* Expanded view — options grid + picker */}
281
+ <AnimatePresence initial={false}>
282
+ {!collapsedMarkets.has(mIdx) && (
283
+ <motion.div
284
+ className="overflow-hidden"
285
+ initial={{ height: 0, opacity: 0 }}
286
+ animate={{ height: "auto", opacity: 1 }}
287
+ exit={{ height: 0, opacity: 0 }}
288
+ transition={{ duration: 0.25, ease: "easeInOut" }}
289
+ >
290
+
291
+ {/* Options grid */}
292
+ <div className="grid grid-cols-2 gap-1.5 px-1 pt-0.5 pb-1">
293
+ {market.options.map((opt, j) => {
294
+ const isSelected = selection?.optionIdx === j;
295
+ const isPickerOpen = isPickerOpenForMarket && expandedPicker[mIdx] === j;
296
+ const showPickerRow = isPickerOpenForMarket;
297
+ const displayReward = calcDisplayReward(opt, compoundMultiplier, isSelected, selectedCount, isSelected ? selection.amount : undefined);
298
+ const baseReward = optionReward(opt);
299
+ return (
300
+ <div key={j} className="col-span-1 flex flex-col">
301
+ <div
302
+ onClick={() => onOptionClick(mIdx, j)}
303
+ className="flex items-center justify-between px-2 py-1.5 rounded-sm transition-all duration-300 cursor-pointer"
304
+ style={{
305
+ background: isSelected
306
+ ? "linear-gradient(135deg, #22E3E8, #9945FF)"
307
+ : isPickerOpen
308
+ ? "rgba(34,227,232,0.12)"
309
+ : "transparent",
310
+ borderLeft: isSelected
311
+ ? "1px solid transparent"
312
+ : isPickerOpen
313
+ ? "1px solid rgba(34,227,232,0.3)"
314
+ : "1px solid rgba(255,255,255,0.12)",
315
+ borderBottom: isSelected
316
+ ? "1px solid transparent"
317
+ : isPickerOpen
318
+ ? "1px solid rgba(34,227,232,0.3)"
319
+ : "1px solid rgba(255,255,255,0.06)",
320
+ borderTop: "1px solid transparent",
321
+ borderRight: "1px solid transparent",
322
+ }}
323
+ >
324
+ <div className="flex items-center gap-1.5 min-w-0 flex-shrink">
325
+ {isSelected && (
326
+ <SelectedCheck />
327
+ )}
328
+ <span className="text-[10px] text-white font-semibold truncate" style={OUTFIT}>{opt.label}</span>
329
+ </div>
330
+ {/* When picker is open: only show multiplier in cyan */}
331
+ {showPickerRow ? (
332
+ <span className="text-[10px] font-bold text-[#22E3E8]" style={OUTFIT}>
333
+ {opt.odds}x
334
+ </span>
335
+ ) : (
336
+ <div className="flex items-center gap-[3px] flex-shrink-0">
337
+ <span className="text-[8px] font-semibold" style={{ ...OUTFIT, color: isSelected ? "white" : "rgba(255,255,255,0.4)" }}>
338
+ {opt.odds}x
339
+ </span>
340
+ <span className="text-[7px] text-white/25" style={OUTFIT}>(</span>
341
+ <PointsIcon size={7} />
342
+ <span className="text-[8px] font-medium" style={{ ...OUTFIT, color: isSelected ? "white" : "rgba(255,255,255,0.35)" }}>
343
+ {isSelected ? selection.amount : opt.entry}
344
+ </span>
345
+ <span className="text-[8px]" style={{ ...OUTFIT, color: isSelected ? "white" : "rgba(255,255,255,0.2)" }}>{"\u2192"}</span>
346
+ <PointsIcon size={7} />
347
+ <span className="text-[8px] font-semibold" style={{ ...OUTFIT, color: isSelected ? "white" : "#22E3E8" }}>
348
+ {isSelected ? displayReward : baseReward}
349
+ </span>
350
+ <span className="text-[7px] text-white/25" style={OUTFIT}>)</span>
351
+ </div>
352
+ )}
353
+ </div>
354
+ </div>
355
+ );
356
+ })}
357
+
358
+ {/* Amount picker — inline chip carousel (always reserves height when picker is open) */}
359
+ {isPickerOpenForMarket && (
360
+ <div className="col-span-2 mt-0.5 relative">
361
+ {/* Onboarding shine overlay on first market */}
362
+ {mIdx === 0 && showOnboarding && (
363
+ <div className="absolute inset-0 z-10 pointer-events-none rounded overflow-hidden">
364
+ <motion.div
365
+ className="absolute inset-0"
366
+ style={{
367
+ background: "linear-gradient(90deg, transparent 0%, rgba(34,227,232,0.25) 40%, rgba(255,255,255,0.35) 50%, rgba(34,227,232,0.25) 60%, transparent 100%)",
368
+ }}
369
+ animate={{ x: ["-100%", "100%"] }}
370
+ transition={{ duration: 1.5, ease: "easeInOut", repeat: Infinity, repeatDelay: 1.5 }}
371
+ />
372
+ </div>
373
+ )}
374
+ {chipCount > 0 ? (
375
+ <div className="flex items-center gap-1.5 overflow-x-auto scrollbar-hide py-1 px-0.5">
376
+ <span className="text-[8px] text-white/60 font-semibold flex-shrink-0" style={OUTFIT}>Bet:</span>
377
+ {Array.from({ length: chipCount }, (_, i) => (i + 1) * config.parlayConfig.stakeIncrements).map(amt => {
378
+ const isChipSelected = selection?.optionIdx === expandedPicker[mIdx] && selection?.amount === amt;
379
+ return (
380
+ <div
381
+ key={amt}
382
+ onClick={() => onAmountSelect(mIdx, expandedPicker[mIdx], amt)}
383
+ className="flex-shrink-0 flex items-center gap-0.5 px-2 py-[3px] rounded cursor-pointer transition-colors relative overflow-hidden"
384
+ style={isChipSelected ? {
385
+ background: "linear-gradient(135deg, #22E3E8, #9945FF, #f83cc5)",
386
+ } : {
387
+ background: "rgba(34,227,232,0.08)",
388
+ border: "1px solid rgba(34,227,232,0.2)",
389
+ }}
390
+ >
391
+ {isChipSelected && (
392
+ <div
393
+ className="absolute inset-0 pointer-events-none animate-shine"
394
+ style={{
395
+ background: "linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.3) 50%, transparent 100%)",
396
+ backgroundSize: "200% 100%",
397
+ }}
398
+ />
399
+ )}
400
+ <Image src="/iamgame_square_logo.jpg" alt="" width={7} height={7} className="rounded-[1px] relative z-[1]" />
401
+ <span
402
+ className={`text-[9px] font-bold relative z-[1] ${isChipSelected ? "text-black" : "text-[#22E3E8]"}`}
403
+ style={OUTFIT}
404
+ >
405
+ {amt}
406
+ </span>
407
+ </div>
408
+ );
409
+ })}
410
+ </div>
411
+ ) : (
412
+ /* All points spent — blurred placeholder chips with overlay message */
413
+ <div className="relative py-1 px-0.5">
414
+ <div className="flex items-center gap-1.5 overflow-hidden" style={{ filter: "blur(3px)" }}>
415
+ <span className="text-[8px] text-white/60 font-semibold flex-shrink-0" style={OUTFIT}>Bet:</span>
416
+ {[50, 100, 150, 200, 250, 300, 350].map(amt => (
417
+ <div
418
+ key={amt}
419
+ className="flex-shrink-0 flex items-center gap-0.5 px-2 py-[3px] rounded"
420
+ style={{ background: "rgba(34,227,232,0.08)", border: "1px solid rgba(34,227,232,0.2)" }}
421
+ >
422
+ <PointsIcon size={7} />
423
+ <span className="text-[9px] font-bold text-[#22E3E8]" style={OUTFIT}>{amt}</span>
424
+ </div>
425
+ ))}
426
+ </div>
427
+ <div className="absolute inset-0 flex items-center justify-center">
428
+ <p className="text-[9px] text-[#22E3E8] font-semibold flex items-center gap-1" style={OUTFIT}>
429
+ All {config.startingBalance.toLocaleString()}
430
+ <PointsIcon size={9} />
431
+ points used. Reduce other bets to bet here.
432
+ </p>
433
+ </div>
434
+ </div>
435
+ )}
436
+ </div>
437
+ )}
438
+
439
+ {/* Onboarding dialogue box — first market only, stays until user bets */}
440
+ <AnimatePresence>
441
+ {mIdx === 0 && showOnboarding && (
442
+ <motion.div
443
+ className="col-span-2 mt-1 mb-0.5"
444
+ initial={{ opacity: 0, height: 0 }}
445
+ animate={{ opacity: 1, height: "auto" }}
446
+ exit={{ opacity: 0, height: 0 }}
447
+ transition={{ type: "spring", stiffness: 300, damping: 28 }}
448
+ >
449
+ <div
450
+ className="rounded-md px-2.5 py-1.5"
451
+ style={{ background: "#22E3E8" }}
452
+ >
453
+ <p className="text-[10px] text-black font-semibold leading-relaxed" style={OUTFIT}>
454
+ {onboardingWords.map((word, i) => (
455
+ <motion.span
456
+ key={i}
457
+ className="inline"
458
+ initial={{ opacity: 0, y: 6, filter: "blur(4px)" }}
459
+ animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
460
+ transition={{ duration: 0.35, delay: 0.4 + i * 0.045, ease: "easeOut" }}
461
+ >
462
+ {word}{" "}
463
+ </motion.span>
464
+ ))}
465
+ </p>
466
+ </div>
467
+ </motion.div>
468
+ )}
469
+ </AnimatePresence>
470
+ </div>
471
+
472
+ </motion.div>
473
+ )}
474
+ </AnimatePresence>
475
+ </div>
476
+ );
477
+ })}
478
+ </div>
479
+ </div>
480
+
481
+ {!marketsOnly && (
482
+ <>
483
+ {/* Sticky bottom summary bar */}
484
+ <div
485
+ className="sticky bottom-0 z-10 -mx-4 px-4 pt-3 pb-5"
486
+ style={{ background: "linear-gradient(180deg, transparent 0%, #0a0a12 12%, #0a0a12 100%)" }}
487
+ >
488
+ <div className="flex items-stretch justify-between gap-3">
489
+ {/* Left — two text lines stacked */}
490
+ <div className="flex flex-col gap-2 flex-1 min-w-0">
491
+ <motion.div
492
+ className="flex items-baseline gap-1"
493
+ initial={{ scale: 1.15, opacity: 0 }}
494
+ animate={{ scale: 1, opacity: 1 }}
495
+ transition={{ type: "spring", stiffness: 300, damping: 18, delay: 0.2 }}
496
+ >
497
+ <Image src="/iamgame_square_logo.jpg" alt="" width={totalEntry > 0 ? 14 : 20} height={totalEntry > 0 ? 14 : 20} className="rounded-[3px] transition-all duration-500" />
498
+ <AnimatedNumber value={remainingBalance} className="text-white font-bold leading-none transition-all duration-500" style={{ ...OUTFIT, fontSize: totalEntry > 0 ? 16 : 24 }} />
499
+ <span className="text-white font-bold leading-none transition-all duration-500" style={{ ...OUTFIT, fontSize: totalEntry > 0 ? 16 : 24 }}>/{config.startingBalance.toLocaleString()}</span>
500
+ <span className="text-white font-medium leading-none transition-all duration-500" style={{ ...OUTFIT, fontSize: totalEntry > 0 ? 9 : 11 }}>
501
+ points left to bet
502
+ </span>
503
+ </motion.div>
504
+ <AnimatePresence>
505
+ {totalEntry > 0 && (
506
+ <motion.div
507
+ className="overflow-hidden"
508
+ initial={{ height: 0, opacity: 0 }}
509
+ animate={{ height: "auto", opacity: 1 }}
510
+ exit={{ height: 0, opacity: 0 }}
511
+ transition={{ height: { duration: 0.35, ease: "easeOut" }, opacity: { duration: 0.3, delay: 0.1 } }}
512
+ >
513
+ <div className="flex items-baseline gap-1 flex-nowrap">
514
+ <span className="text-[13px] text-white font-medium leading-none flex-shrink-0" style={OUTFIT}>Max outcome</span>
515
+ <Image src="/iamgame_square_logo.jpg" alt="" width={18} height={18} className="rounded-[2px] flex-shrink-0" />
516
+ <AnimatedNumber value={totalEntry} className="text-[22px] text-white font-bold leading-none" style={OUTFIT} />
517
+ <span className="text-[22px] text-white font-bold leading-none flex-shrink-0" style={OUTFIT}>→</span>
518
+ <Image src="/iamgame_square_logo.jpg" alt="" width={18} height={18} className="rounded-[2px] flex-shrink-0" />
519
+ <span className="relative inline-flex items-baseline overflow-visible">
520
+ {/* Shine sweep — clipped to text via mix-blend */}
521
+ <span className="absolute inset-0 pointer-events-none overflow-hidden" style={{ WebkitMaskImage: "linear-gradient(90deg, transparent 0%, black 10%, black 90%, transparent 100%)" }} aria-hidden>
522
+ <motion.span
523
+ className="absolute inset-y-0 w-[40%]"
524
+ style={{ background: "linear-gradient(90deg, transparent, rgba(255,255,255,0.25), transparent)" }}
525
+ animate={{ left: ["-40%", "140%"] }}
526
+ transition={{ duration: 2, ease: "easeInOut", repeat: Infinity, repeatDelay: 3.5 }}
527
+ />
528
+ </span>
529
+ {/* Floating particles — tiny, spread all over */}
530
+ {Array.from({ length: 12 }, (_, p) => {
531
+ const size = 1 + (p % 3) * 0.4;
532
+ const left = (p / 12) * 100 + (p % 3) * 3;
533
+ const top = 10 + (p * 7) % 80;
534
+ return (
535
+ <motion.span
536
+ key={p}
537
+ className="absolute rounded-full pointer-events-none"
538
+ style={{
539
+ width: size,
540
+ height: size,
541
+ background: p % 3 === 0 ? "rgba(255,255,255,0.9)" : "#22E3E8",
542
+ left: `${left}%`,
543
+ top: `${top}%`,
544
+ }}
545
+ animate={{
546
+ y: [0, -4 - (p % 3) * 1.5, 0],
547
+ x: [0, (p % 2 === 0 ? 1.5 : -1.5), 0],
548
+ opacity: [0, 0.4, 1, 0.4, 0],
549
+ }}
550
+ transition={{
551
+ duration: 4 + (p % 4) * 0.8,
552
+ repeat: Infinity,
553
+ delay: (p % 8) * 0.7,
554
+ ease: "easeInOut",
555
+ }}
556
+ />
557
+ );
558
+ })}
559
+ <AnimatedNumber value={compoundedReward} className="text-[22px] text-[#22E3E8] font-bold leading-none relative" style={OUTFIT} />
560
+ </span>
561
+ </div>
562
+ </motion.div>
563
+ )}
564
+ </AnimatePresence>
565
+ </div>
566
+ {/* Right — play button stretches to match both lines, hidden until points are bet */}
567
+ <AnimatePresence>
568
+ {totalEntry > 0 && (() => {
569
+ const spentPercent = totalEntry / config.startingBalance;
570
+ const fillPercent = 5 + spentPercent * 95;
571
+ return (
572
+ <motion.div
573
+ className="relative w-[65px] rounded-xl overflow-hidden flex-shrink-0 flex items-center justify-center"
574
+ style={{ background: "linear-gradient(135deg, rgba(34,227,232,0.2), rgba(153,69,255,0.2), rgba(248,60,197,0.2))" }}
575
+ initial={{ opacity: 0, width: 0 }}
576
+ animate={{ opacity: 1, width: 65 }}
577
+ exit={{ opacity: 0, width: 0 }}
578
+ transition={{ width: { duration: 0.4, ease: "easeOut", delay: 0.8 }, opacity: { duration: 0.3, delay: 1 } }}
579
+ >
580
+ {/* Fill bar — spring-animated left to right */}
581
+ <motion.div
582
+ className="absolute inset-y-0 left-0"
583
+ animate={{ width: `${fillPercent}%` }}
584
+ transition={{ type: "spring", stiffness: 120, damping: 20 }}
585
+ style={{ background: "linear-gradient(135deg, #22E3E8, #9945FF, #f83cc5)" }}
586
+ />
587
+ <Play size={26} fill="white" strokeWidth={0} className="relative z-10" />
588
+ </motion.div>
589
+ );
590
+ })()}
591
+ </AnimatePresence>
592
+ </div>
593
+
594
+ {/* Risk bar + tip — hidden until risk > 0, staggered entrance */}
595
+ <AnimatePresence>
596
+ {riskPercent > 0 && (
597
+ <motion.div
598
+ className="overflow-hidden"
599
+ initial={{ height: 0 }}
600
+ animate={{ height: "auto" }}
601
+ exit={{ height: 0 }}
602
+ transition={{ height: { duration: 0.4, ease: "easeOut", delay: 1.8 } }}
603
+ >
604
+ <div className="flex flex-col gap-2 mt-3">
605
+ {/* Risk factor bar */}
606
+ <motion.div
607
+ className="flex items-center gap-2"
608
+ initial={{ opacity: 0 }}
609
+ animate={{ opacity: 1 }}
610
+ transition={{ duration: 0.4, delay: 2.2 }}
611
+ >
612
+ <span className="text-[8px] text-white font-semibold flex-shrink-0" style={OUTFIT}>Risk</span>
613
+ <div className="flex-1 h-[5px] rounded-full bg-white/[0.06] overflow-hidden">
614
+ <div
615
+ className="h-full rounded-full"
616
+ style={{
617
+ width: `${riskPercent}%`,
618
+ background: "linear-gradient(90deg, #f59e0b, #ef4444)",
619
+ transition: "width 0.4s ease",
620
+ }}
621
+ />
622
+ </div>
623
+ <span className="text-[8px] text-white font-bold flex-shrink-0" style={OUTFIT}>{riskPercent}%</span>
624
+ </motion.div>
625
+ {/* Tip */}
626
+ <motion.p
627
+ className="text-[8px] text-white font-medium text-center mt-0.5"
628
+ style={OUTFIT}
629
+ initial={{ opacity: 0 }}
630
+ animate={{ opacity: 1 }}
631
+ transition={{ duration: 0.4, delay: 3 }}
632
+ >
633
+ {riskPercent > 60
634
+ ? "High risk — spread your bets to lower risk while keeping outcome high"
635
+ : riskPercent > 30
636
+ ? "Balanced — adjust your bets to find the sweet spot"
637
+ : "Low risk — you can afford to be bolder for a bigger outcome"
638
+ }
639
+ </motion.p>
640
+ </div>
641
+ </motion.div>
642
+ )}
643
+ </AnimatePresence>
644
+
645
+ {/* Combined Bets (Parlays) */}
646
+ <div className="mt-4">
647
+ <p className="text-[9px] text-white/60 font-semibold uppercase tracking-wider mb-1" style={OUTFIT}>
648
+ Combine for crazy multipliers
649
+ </p>
650
+ <p className="text-[7px] text-white/30 font-medium mb-2.5" style={OUTFIT}>
651
+ All must hit to win. You lose all if any one goes wrong.
652
+ </p>
653
+
654
+ {/* Parlay slots — concentric circles */}
655
+ <div className="flex items-end gap-3">
656
+ {Array.from({ length: config.parlayConfig.maxSlots }, (_, slot) => {
657
+ const legs = parlayGroups[slot] ?? [];
658
+ const legCount = legs.length;
659
+ const isEditing = editingParlay === slot;
660
+ const color = parlayColors[slot];
661
+ const { multiplier, totalStake, breakdown } = legCount > 0
662
+ ? calcParlayMultiplier(slot, bets, config)
663
+ : { multiplier: 0, totalStake: 0, breakdown: [] };
664
+
665
+ return (
666
+ <div key={slot} className="flex flex-col items-center gap-1">
667
+ {/* Concentric circles */}
668
+ <button
669
+ onClick={() => setEditingParlay(isEditing ? null : slot)}
670
+ className="relative flex items-center justify-center transition-all"
671
+ style={{
672
+ width: 36,
673
+ height: 36,
674
+ }}
675
+ >
676
+ {legCount === 0 ? (
677
+ /* Empty slot — dashed outline */
678
+ <div
679
+ className="w-[28px] h-[28px] rounded-full border border-dashed transition-all"
680
+ style={{
681
+ borderColor: isEditing ? color : "rgba(255,255,255,0.15)",
682
+ boxShadow: isEditing ? `0 0 8px ${color}40` : "none",
683
+ }}
684
+ />
685
+ ) : (
686
+ /* Filled concentric rings */
687
+ <>
688
+ {Array.from({ length: Math.min(legCount, 6) }, (_, ring) => {
689
+ const size = 12 + ring * 5;
690
+ const opacity = 1 - ring * 0.12;
691
+ return (
692
+ <motion.div
693
+ key={ring}
694
+ initial={{ scale: 0 }}
695
+ animate={{ scale: 1 }}
696
+ transition={{ type: "spring", stiffness: 400, damping: 20, delay: ring * 0.05 }}
697
+ className="absolute rounded-full"
698
+ style={{
699
+ width: size,
700
+ height: size,
701
+ border: `2px solid ${color}`,
702
+ opacity,
703
+ background: ring === 0 ? `${color}30` : "transparent",
704
+ boxShadow: isEditing ? `0 0 8px ${color}40` : "none",
705
+ }}
706
+ />
707
+ );
708
+ })}
709
+ </>
710
+ )}
711
+ </button>
712
+
713
+ {/* Multiplier + info */}
714
+ {legCount >= 2 ? (
715
+ <div className="flex items-center gap-0.5">
716
+ <span className="text-[8px] font-bold text-white" style={OUTFIT}>
717
+ {multiplier}x
718
+ </span>
719
+ <button onClick={() => setParlayInfo(parlayInfo === slot ? null : slot)}>
720
+ <Info size={8} className="text-white/40" />
721
+ </button>
722
+ </div>
723
+ ) : legCount === 1 ? (
724
+ <span className="text-[7px] text-white/30 font-medium" style={OUTFIT}>+1 more</span>
725
+ ) : null}
726
+
727
+ {/* Info popup */}
728
+ <AnimatePresence>
729
+ {parlayInfo === slot && legCount >= 2 && (
730
+ <motion.div
731
+ initial={{ opacity: 0, y: 4 }}
732
+ animate={{ opacity: 1, y: 0 }}
733
+ exit={{ opacity: 0, y: 4 }}
734
+ className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 z-20 w-[180px] rounded-lg p-2.5"
735
+ style={{ background: "#1a1a2e", border: `1px solid ${color}30` }}
736
+ >
737
+ <div className="flex items-center justify-between mb-1.5">
738
+ <span className="text-[8px] text-white font-bold" style={OUTFIT}>Combined Bet</span>
739
+ <button onClick={() => setParlayInfo(null)}>
740
+ <X size={10} className="text-white/40" />
741
+ </button>
742
+ </div>
743
+ {breakdown.map((leg, i) => (
744
+ <div key={i} className="flex items-center justify-between py-0.5">
745
+ <span className="text-[7px] text-white/60 font-medium truncate flex-1 pr-1" style={OUTFIT}>{leg.question}</span>
746
+ <span className="text-[8px] text-white font-semibold flex-shrink-0" style={OUTFIT}>×{leg.odds}</span>
747
+ </div>
748
+ ))}
749
+ <div className="h-px bg-white/10 my-1" />
750
+ <div className="flex items-center justify-between">
751
+ <span className="text-[7px] text-white/40 font-medium" style={OUTFIT}>Combined</span>
752
+ <span className="text-[9px] font-bold" style={{ ...OUTFIT, color }}>{multiplier}x</span>
753
+ </div>
754
+ {totalStake > 0 && (
755
+ <div className="flex items-center justify-between mt-0.5">
756
+ <span className="text-[7px] text-white/40 font-medium" style={OUTFIT}>Stake → Win</span>
757
+ <span className="text-[8px] text-white font-semibold" style={OUTFIT}>
758
+ {totalStake} → {Math.round(totalStake * multiplier).toLocaleString()}
759
+ </span>
760
+ </div>
761
+ )}
762
+ <p className="text-[6px] text-white/25 mt-1" style={OUTFIT}>All must hit to win</p>
763
+ {/* Clear parlay */}
764
+ <button
765
+ onClick={() => { clearParlay(slot); setParlayInfo(null); }}
766
+ className="mt-1.5 text-[7px] text-red-400/60 font-medium"
767
+ style={OUTFIT}
768
+ >
769
+ Remove combined bet
770
+ </button>
771
+ </motion.div>
772
+ )}
773
+ </AnimatePresence>
774
+ </div>
775
+ );
776
+ })}
777
+ </div>
778
+
779
+ {/* Edit mode hint */}
780
+ {editingParlay !== null && (
781
+ <div className="flex items-center justify-between mt-2">
782
+ <p className="text-[8px] font-medium" style={{ ...OUTFIT, color: parlayColors[editingParlay] }}>
783
+ Tap questions above to add to this combined bet
784
+ </p>
785
+ <button
786
+ onClick={() => setEditingParlay(null)}
787
+ className="text-[8px] text-white/50 font-semibold px-2 py-0.5 rounded"
788
+ style={{ ...OUTFIT, background: "rgba(255,255,255,0.06)" }}
789
+ >
790
+ Done
791
+ </button>
792
+ </div>
793
+ )}
794
+ </div>
795
+ </div>
796
+
797
+ {/* Leaderboard & Potential Payouts */}
798
+ <div className="pt-1">
799
+ <p className="text-[10px] text-white uppercase tracking-wide mb-2 font-semibold" style={OUTFIT}>Leaderboard & Potential Payouts</p>
800
+ <div className="flex items-center gap-2 px-3 mb-1">
801
+ <span className="w-[46px] flex-shrink-0 text-right pr-1 text-[9px] text-white/50 uppercase tracking-wide" style={OUTFIT}>#</span>
802
+ <span className="flex-1 text-[9px] text-white/50 uppercase tracking-wide" style={OUTFIT}>Player</span>
803
+ <span className="w-[66px] flex-shrink-0 text-right text-[9px] text-white/50 uppercase tracking-wide" style={OUTFIT}>Points</span>
804
+ <span className="w-[72px] flex-shrink-0 text-right text-[9px] text-white/50 uppercase tracking-wide" style={OUTFIT}>Payout</span>
805
+ </div>
806
+ <div className="h-px bg-white/5 mb-1" />
807
+ <div className="flex flex-col">
808
+ {leaderboardRows.map((entry, i, arr) => (
809
+ <div key={entry.wallet + (entry.rank ?? i)}>
810
+ {entry.gapAbove && (
811
+ <div className="flex items-center justify-center py-0.5">
812
+ <span className="text-[8px] text-white/20 font-semibold tracking-widest" style={OUTFIT}>&middot;&middot;&middot;</span>
813
+ </div>
814
+ )}
815
+ <LeaderboardRow
816
+ entry={entry}
817
+ rank={entry.rank ?? i + 1}
818
+ isLast={i === arr.length - 1}
819
+ />
820
+ </div>
821
+ ))}
822
+ </div>
823
+ </div>
824
+
825
+ </>
826
+ )}
827
+ </div>
828
+ );
829
+ };