@devrongx/games 0.3.5 → 0.4.1

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.
@@ -4,7 +4,7 @@
4
4
  import { useState, useEffect, useMemo, useCallback } from "react";
5
5
  import Image from "next/image";
6
6
  import { motion, AnimatePresence, useSpring, useTransform, useMotionValue } from "framer-motion";
7
- import { ChevronDown, Info, X, Play } from "lucide-react";
7
+ import { ChevronDown, Info, X, Play, Send } from "lucide-react";
8
8
  import { IBetSummary, ILeaderboardEntry, IChallengeConfig, IUserBets, deriveParlayGroups, deriveMarketToParlay, calcDisplayReward, optionReward, calcParlayMultiplier } from "./config";
9
9
  import { OUTFIT, MARKET_ICONS, PointsIcon, SelectedCheck, AiInsightButton } from "./constants";
10
10
  import { useGamePopupStore } from "../../core/gamePopupStore";
@@ -37,10 +37,18 @@ interface PreMatchGameProps {
37
37
  leaderboardRows: ILeaderboardEntry[];
38
38
  /** Render only the markets section (no header/balance/leaderboard) — used for card preview */
39
39
  marketsOnly?: boolean;
40
+ /** Called when user taps Submit Bets. If provided, shows Submit button instead of Play. */
41
+ onSubmit?: () => Promise<void> | void;
42
+ /** Called when user taps "See Full Leaderboard" */
43
+ onViewLeaderboard?: () => void;
44
+ /** v1: parlays are cosmetic, shows "Coming in v2". Default = false (hides section) */
45
+ parlayEnabled?: boolean;
46
+ /** Whether a submit is in progress */
47
+ submitting?: boolean;
40
48
  }
41
49
 
42
50
 
43
- export const PreMatchGame = ({ config, bets, onBetsChange, expandedPicker, onOptionClick, onAmountSelect, betSummary, leaderboardRows, marketsOnly }: PreMatchGameProps) => {
51
+ export const PreMatchGame = ({ config, bets, onBetsChange, expandedPicker, onOptionClick, onAmountSelect, betSummary, leaderboardRows, marketsOnly, onSubmit, onViewLeaderboard, parlayEnabled = false, submitting = false }: PreMatchGameProps) => {
44
52
  const { selectedCount, compoundMultiplier, totalEntry, compoundedReward, remainingBalance, riskPercent } = betSummary;
45
53
 
46
54
  const goTo = useGamePopupStore(s => s.goTo);
@@ -571,11 +579,36 @@ export const PreMatchGame = ({ config, bets, onBetsChange, expandedPicker, onOpt
571
579
  )}
572
580
  </AnimatePresence>
573
581
  </div>
574
- {/* Right — play button stretches to match both lines, hidden until points are bet */}
582
+ {/* Right — action button, hidden until points are bet */}
575
583
  <AnimatePresence>
576
584
  {totalEntry > 0 && (() => {
577
585
  const spentPercent = totalEntry / config.startingBalance;
578
586
  const fillPercent = 5 + spentPercent * 95;
587
+
588
+ if (onSubmit) {
589
+ return (
590
+ <motion.button
591
+ onClick={onSubmit}
592
+ disabled={submitting}
593
+ className="relative rounded-xl overflow-hidden flex-shrink-0 flex items-center justify-center gap-1.5 px-3"
594
+ style={{ background: submitting ? "rgba(34,227,232,0.3)" : "linear-gradient(135deg, #22E3E8, #9945FF, #f83cc5)", minWidth: 90, height: "100%" }}
595
+ initial={{ opacity: 0, width: 0 }}
596
+ animate={{ opacity: 1, width: "auto" }}
597
+ exit={{ opacity: 0, width: 0 }}
598
+ transition={{ width: { duration: 0.4, ease: "easeOut", delay: 0.8 }, opacity: { duration: 0.3, delay: 1 } }}
599
+ >
600
+ {submitting ? (
601
+ <span className="text-[10px] font-bold text-white/70" style={OUTFIT}>Submitting…</span>
602
+ ) : (
603
+ <>
604
+ <Send size={14} className="text-white" />
605
+ <span className="text-[11px] font-bold text-white" style={OUTFIT}>Submit Bets</span>
606
+ </>
607
+ )}
608
+ </motion.button>
609
+ );
610
+ }
611
+
579
612
  return (
580
613
  <motion.div
581
614
  className="relative w-[65px] rounded-xl overflow-hidden flex-shrink-0 flex items-center justify-center"
@@ -585,7 +618,6 @@ export const PreMatchGame = ({ config, bets, onBetsChange, expandedPicker, onOpt
585
618
  exit={{ opacity: 0, width: 0 }}
586
619
  transition={{ width: { duration: 0.4, ease: "easeOut", delay: 0.8 }, opacity: { duration: 0.3, delay: 1 } }}
587
620
  >
588
- {/* Fill bar — spring-animated left to right */}
589
621
  <motion.div
590
622
  className="absolute inset-y-0 left-0"
591
623
  animate={{ width: `${fillPercent}%` }}
@@ -652,9 +684,16 @@ export const PreMatchGame = ({ config, bets, onBetsChange, expandedPicker, onOpt
652
684
 
653
685
  {/* Combined Bets (Parlays) */}
654
686
  <div className="mt-4">
655
- <p className="text-[9px] text-white/60 font-semibold uppercase tracking-wider mb-1" style={OUTFIT}>
656
- Combine for crazy multipliers
657
- </p>
687
+ <div className="flex items-center gap-2 mb-1">
688
+ <p className="text-[9px] text-white/60 font-semibold uppercase tracking-wider" style={OUTFIT}>
689
+ Combine for crazy multipliers
690
+ </p>
691
+ {!parlayEnabled && (
692
+ <span className="text-[7px] font-bold px-1.5 py-0.5 rounded-full" style={{ background: "rgba(153,69,255,0.15)", color: "#9945FF", border: "1px solid rgba(153,69,255,0.3)" }}>
693
+ Coming in v2
694
+ </span>
695
+ )}
696
+ </div>
658
697
  <p className="text-[7px] text-white/30 font-medium mb-2.5" style={OUTFIT}>
659
698
  All must hit to win. You lose all if any one goes wrong.
660
699
  </p>
@@ -828,6 +867,15 @@ export const PreMatchGame = ({ config, bets, onBetsChange, expandedPicker, onOpt
828
867
  </div>
829
868
  ))}
830
869
  </div>
870
+ {onViewLeaderboard && leaderboardRows.length > 0 && (
871
+ <button
872
+ onClick={onViewLeaderboard}
873
+ className="mt-2 w-full text-center text-[9px] font-semibold"
874
+ style={{ ...OUTFIT, color: "rgba(34,227,232,0.6)" }}
875
+ >
876
+ See Full Leaderboard →
877
+ </button>
878
+ )}
831
879
  </div>
832
880
 
833
881
  </>
@@ -0,0 +1,193 @@
1
+ // @devrongx/games — games/prematch-bets/PreMatchLive.tsx
2
+ // Live match view: shows challenge resolution progress + live leaderboard.
3
+ // Pool status = CLOSED or RESOLVING.
4
+ "use client";
5
+
6
+ import { useEffect } from "react";
7
+ import { CheckCircle2, Clock, ArrowRight, Radio } from "lucide-react";
8
+ import { IChallengeConfig } from "./config";
9
+ import { LeaderboardRow } from "./LeaderboardRow";
10
+ import { OUTFIT, MARKET_ICONS } from "./constants";
11
+ import type { ITDPoolDetail, ITDMyEntryResponse, ITDLeaderboardEntry } from "../../pools/types";
12
+ import { TDChallengeStatus, TDBetStatus } from "../../pools/types";
13
+
14
+ interface PreMatchLiveProps {
15
+ config: IChallengeConfig;
16
+ poolId: number;
17
+ pool: ITDPoolDetail | null;
18
+ entryData: ITDMyEntryResponse | null;
19
+ rankings: ITDLeaderboardEntry[];
20
+ onRefresh: () => void;
21
+ onViewFullLeaderboard: () => void;
22
+ }
23
+
24
+ export function PreMatchLive({
25
+ config,
26
+ pool,
27
+ entryData,
28
+ rankings,
29
+ onRefresh,
30
+ onViewFullLeaderboard,
31
+ }: PreMatchLiveProps) {
32
+ // Poll every 20s during live phase
33
+ useEffect(() => {
34
+ const id = setInterval(onRefresh, 20_000);
35
+ return () => clearInterval(id);
36
+ }, [onRefresh]);
37
+
38
+ const entry = entryData?.entry;
39
+ const myBets = entryData?.bets ?? [];
40
+
41
+ const challenges = pool?.challenges ?? config.markets.map((m, i) => ({
42
+ id: m.backendChallengeId ?? i,
43
+ question: m.question,
44
+ icon: m.icon,
45
+ accent_color: m.accent,
46
+ status: "open",
47
+ correct_option: null as string | null,
48
+ options: m.options.map((o) => ({ key: o.label.toLowerCase().replace(/\s+/g, "_"), label: o.label, odds: o.odds, entry: o.entry, risk: o.risk })),
49
+ category: m.category,
50
+ sort_order: i,
51
+ resolved_at: null as string | null,
52
+ }));
53
+
54
+ const resolvedCount = challenges.filter((c) => c.status === TDChallengeStatus.RESOLVED).length;
55
+ const totalCount = challenges.length;
56
+ const progressPct = totalCount > 0 ? Math.round((resolvedCount / totalCount) * 100) : 0;
57
+
58
+ const myRank = entry ? rankings.findIndex((r) => r.entry_id === entry.id) + 1 : null;
59
+ const miniRankings = rankings.slice(0, 5);
60
+
61
+ return (
62
+ <div className="w-full flex flex-col gap-4 px-4 pb-8">
63
+ {/* Live header */}
64
+ <div className="flex items-center gap-2 pt-5">
65
+ <div className="flex items-center gap-1">
66
+ <span className="relative flex h-2 w-2">
67
+ <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#f83cc5] opacity-75" />
68
+ <span className="relative inline-flex rounded-full h-2 w-2 bg-[#f83cc5]" />
69
+ </span>
70
+ <span className="text-[11px] font-bold tracking-widest" style={{ color: "#f83cc5", fontFamily: "Barlow Condensed, sans-serif" }}>LIVE</span>
71
+ </div>
72
+ <span className="text-[14px] font-bold text-white" style={OUTFIT}>{config.matchTitle}</span>
73
+ </div>
74
+
75
+ {/* Balance + rank */}
76
+ {entry && (
77
+ <div className="flex items-center gap-3 px-3 py-2.5 rounded-xl" style={{ background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.08)" }}>
78
+ <div className="flex-1">
79
+ <p className="text-[9px] text-white/40 uppercase tracking-wide" style={OUTFIT}>Balance</p>
80
+ <p className="text-[18px] font-bold text-white" style={OUTFIT}>{entry.current_coins.toLocaleString()} pts</p>
81
+ </div>
82
+ {myRank !== null && myRank > 0 && (
83
+ <div className="text-right">
84
+ <p className="text-[9px] text-white/40 uppercase tracking-wide" style={OUTFIT}>Your Rank</p>
85
+ <p className="text-[18px] font-bold" style={{ ...OUTFIT, color: "#22E3E8" }}>#{myRank} <span className="text-[11px] text-white/40">of {rankings.length}</span></p>
86
+ </div>
87
+ )}
88
+ </div>
89
+ )}
90
+
91
+ <div className="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
92
+
93
+ {/* Challenge progress */}
94
+ <div>
95
+ <div className="flex items-center justify-between mb-2">
96
+ <p className="text-[10px] font-semibold uppercase tracking-wider text-white/50" style={OUTFIT}>
97
+ RESULTS ({resolvedCount}/{totalCount} resolved)
98
+ </p>
99
+ <span className="text-[9px] font-bold" style={{ ...OUTFIT, color: "#22E3E8" }}>{progressPct}%</span>
100
+ </div>
101
+ <div className="h-[5px] rounded-full mb-3" style={{ background: "rgba(255,255,255,0.06)" }}>
102
+ <div className="h-full rounded-full transition-all duration-700" style={{ width: `${progressPct}%`, background: "linear-gradient(90deg, #22E3E8, #9945FF)" }} />
103
+ </div>
104
+
105
+ <div className="flex flex-col gap-1.5">
106
+ {challenges.map((c) => {
107
+ const Icon = MARKET_ICONS[c.icon] ?? MARKET_ICONS.coin;
108
+ const myBet = myBets.find((b) => b.challenge_id === c.id);
109
+ const isResolved = c.status === TDChallengeStatus.RESOLVED;
110
+ const won = isResolved && myBet?.status === TDBetStatus.WON;
111
+ const lost = isResolved && myBet?.status === TDBetStatus.LOST;
112
+
113
+ return (
114
+ <div
115
+ key={c.id}
116
+ className="flex items-start gap-2 px-3 py-2 rounded-lg"
117
+ style={{
118
+ background: isResolved
119
+ ? won ? "rgba(34,197,94,0.06)" : lost ? "rgba(239,68,68,0.06)" : "rgba(255,255,255,0.03)"
120
+ : "rgba(255,255,255,0.03)",
121
+ border: `1px solid ${isResolved ? won ? "rgba(34,197,94,0.2)" : lost ? "rgba(239,68,68,0.2)" : "rgba(255,255,255,0.06)" : "rgba(255,255,255,0.05)"}`,
122
+ }}
123
+ >
124
+ {isResolved ? (
125
+ won
126
+ ? <CheckCircle2 size={12} style={{ color: "#22c55e", flexShrink: 0, marginTop: 1 }} />
127
+ : <span className="text-[10px] flex-shrink-0 mt-0.5" style={{ color: "#ef4444" }}>✗</span>
128
+ ) : (
129
+ <Clock size={12} style={{ color: "rgba(255,255,255,0.25)", flexShrink: 0, marginTop: 1 }} />
130
+ )}
131
+
132
+ <div className="flex-1 min-w-0">
133
+ <p className="text-[10px] text-white/60 font-medium leading-tight" style={OUTFIT}>{c.question}</p>
134
+ {myBet ? (
135
+ <p className="text-[9px] mt-0.5" style={{ ...OUTFIT, color: isResolved ? won ? "#22c55e" : "#ef4444" : "rgba(255,255,255,0.4)" }}>
136
+ {c.options.find((o) => o.key === myBet.selected_option)?.label ?? myBet.selected_option}
137
+ {" @ "}{myBet.odds_at_bet}x — {myBet.coin_amount} pts
138
+ {isResolved && won && ` → +${Math.round(myBet.actual_return ?? myBet.potential_return)} pts`}
139
+ {isResolved && lost && " → Lost"}
140
+ </p>
141
+ ) : (
142
+ <p className="text-[8px] text-white/25 mt-0.5" style={OUTFIT}>No bet placed</p>
143
+ )}
144
+ {isResolved && c.correct_option && (
145
+ <p className="text-[8px] text-white/35 mt-0.5" style={OUTFIT}>
146
+ Answer: {c.options.find((o) => o.key === c.correct_option)?.label ?? c.correct_option}
147
+ </p>
148
+ )}
149
+ {!isResolved && <p className="text-[8px] text-white/25 mt-0.5" style={OUTFIT}>Waiting for resolution…</p>}
150
+ </div>
151
+ </div>
152
+ );
153
+ })}
154
+ </div>
155
+ </div>
156
+
157
+ <div className="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
158
+
159
+ {/* Live leaderboard */}
160
+ {miniRankings.length > 0 && (
161
+ <div>
162
+ <div className="flex items-center gap-1.5 mb-2">
163
+ <Radio size={10} style={{ color: "#f83cc5" }} />
164
+ <p className="text-[10px] font-semibold uppercase tracking-wider text-white/50" style={OUTFIT}>LIVE LEADERBOARD</p>
165
+ </div>
166
+ <div className="flex flex-col">
167
+ {miniRankings.map((r) => (
168
+ <LeaderboardRow
169
+ key={r.entry_id}
170
+ entry={{
171
+ wallet: r.partner_ext_id ?? `User #${r.user_id}`,
172
+ pts: r.current_coins,
173
+ payout: 0,
174
+ isYou: r.entry_id === entry?.id,
175
+ rank: r.rank,
176
+ }}
177
+ rank={r.rank}
178
+ isLast={false}
179
+ />
180
+ ))}
181
+ </div>
182
+ <button
183
+ onClick={onViewFullLeaderboard}
184
+ className="mt-2 flex items-center justify-center gap-1 w-full text-[9px] font-semibold"
185
+ style={{ ...OUTFIT, color: "rgba(34,227,232,0.6)" }}
186
+ >
187
+ See Full Leaderboard <ArrowRight size={10} />
188
+ </button>
189
+ </div>
190
+ )}
191
+ </div>
192
+ );
193
+ }
@@ -0,0 +1,212 @@
1
+ // @devrongx/games — games/prematch-bets/PreMatchResults.tsx
2
+ // Final results view: rank, payout, bet breakdown, stats. Pool status = COMPLETE.
3
+ "use client";
4
+
5
+ import { useMemo } from "react";
6
+ import { Trophy, CheckCircle2, ArrowRight, TrendingUp } from "lucide-react";
7
+ import { IChallengeConfig } from "./config";
8
+ import { LeaderboardRow } from "./LeaderboardRow";
9
+ import { OUTFIT, MARKET_ICONS } from "./constants";
10
+ import type { ITDPoolDetail, ITDMyEntryResponse, ITDLeaderboardEntry } from "../../pools/types";
11
+ import { TDBetStatus } from "../../pools/types";
12
+
13
+ // USDC icon inline
14
+ const UsdcIcon = ({ size = 12 }: { size?: number }) => (
15
+ <svg width={size} height={size} viewBox="0 0 32 32" fill="none">
16
+ <circle cx="16" cy="16" r="16" fill="#2775CA" />
17
+ <path d="M20.5 18.2c0-2.1-1.3-2.8-3.8-3.1-1.8-.3-2.2-.7-2.2-1.5s.7-1.3 1.8-1.3c1 0 1.6.4 1.9 1.1.1.1.2.2.3.2h.7c.2 0 .3-.1.3-.3-.3-1-1-1.8-2.1-2v-1.2c0-.2-.1-.3-.3-.3h-.6c-.2 0-.3.1-.3.3v1.2c-1.5.2-2.5 1.2-2.5 2.4 0 2 1.2 2.7 3.7 3 1.7.3 2.3.8 2.3 1.6 0 1-.8 1.6-2 1.6-1.5 0-2-.6-2.2-1.4 0-.1-.2-.2-.3-.2h-.7c-.2 0-.3.1-.3.3.3 1.2 1 2 2.5 2.3v1.2c0 .2.1.3.3.3h.6c.2 0 .3-.1.3-.3v-1.2c1.6-.2 2.6-1.3 2.6-2.6z" fill="#fff" />
18
+ </svg>
19
+ );
20
+
21
+ interface PreMatchResultsProps {
22
+ config: IChallengeConfig;
23
+ poolId: number;
24
+ pool?: ITDPoolDetail;
25
+ entryData: ITDMyEntryResponse | null;
26
+ rankings: ITDLeaderboardEntry[];
27
+ onViewFullLeaderboard: () => void;
28
+ }
29
+
30
+ export function PreMatchResults({
31
+ config,
32
+ pool,
33
+ entryData,
34
+ rankings,
35
+ onViewFullLeaderboard,
36
+ }: PreMatchResultsProps) {
37
+ const entry = entryData?.entry;
38
+ const myBets = entryData?.bets ?? [];
39
+ const hasPartnerPayout = pool?.currency?.partner_payout ?? false;
40
+
41
+ const myRanking = rankings.find((r) => r.entry_id === entry?.id);
42
+ const totalEntrants = rankings.length;
43
+
44
+ const stats = useMemo(() => {
45
+ const individualBets = myBets.filter((b) => b.parlay_id === null);
46
+ const won = individualBets.filter((b) => b.status === TDBetStatus.WON).length;
47
+ const lost = individualBets.filter((b) => b.status === TDBetStatus.LOST).length;
48
+ const totalSpent = individualBets.reduce((s, b) => s + b.coin_amount, 0);
49
+ const totalEarned = entry?.final_coins ?? 0;
50
+ return {
51
+ betsPlaced: individualBets.length,
52
+ totalChallenges: pool?.challenge_count ?? config.markets.length,
53
+ won,
54
+ lost,
55
+ winRate: individualBets.length > 0 ? Math.round((won / individualBets.length) * 100) : 0,
56
+ totalSpent,
57
+ totalEarned,
58
+ roi: totalSpent > 0 ? Math.round(((totalEarned - config.startingBalance) / totalSpent) * 100) : 0,
59
+ };
60
+ }, [myBets, entry, pool, config]);
61
+
62
+ const miniRankings = useMemo(() => {
63
+ const top = rankings.slice(0, 3);
64
+ if (!myRanking || (myRanking.rank ?? 0) <= 3) return top;
65
+ return [...top, myRanking];
66
+ }, [rankings, myRanking]);
67
+
68
+ return (
69
+ <div className="w-full flex flex-col gap-4 px-4 pb-8">
70
+ {/* Header */}
71
+ <div className="flex items-center gap-2 pt-5">
72
+ <Trophy size={18} style={{ color: "#f59e0b" }} />
73
+ <span className="text-[18px] font-bold text-white" style={OUTFIT}>Results</span>
74
+ <span className="text-[12px] text-white/40" style={OUTFIT}>— {config.matchTitle}</span>
75
+ </div>
76
+
77
+ {/* Rank card */}
78
+ {myRanking && (
79
+ <div
80
+ className="relative overflow-hidden rounded-2xl px-4 py-5 flex flex-col items-center gap-2"
81
+ style={{ background: "linear-gradient(135deg, rgba(34,227,232,0.08), rgba(153,69,255,0.08))", border: "1px solid rgba(34,227,232,0.15)" }}
82
+ >
83
+ <p className="text-[11px] text-white/50 uppercase tracking-widest font-semibold" style={OUTFIT}>YOUR FINAL RANK</p>
84
+ <div className="flex items-baseline gap-1">
85
+ <span className="text-[52px] font-bold leading-none" style={{ ...OUTFIT, color: "#22E3E8" }}>#{myRanking.rank}</span>
86
+ <span className="text-[14px] text-white/40" style={OUTFIT}>of {totalEntrants.toLocaleString()}</span>
87
+ </div>
88
+ <p className="text-[13px] font-semibold text-white" style={OUTFIT}>
89
+ Final Score: {(myRanking.final_coins ?? myRanking.current_coins).toLocaleString()} pts
90
+ </p>
91
+ {hasPartnerPayout && myRanking.payout_amount !== null && myRanking.payout_amount !== undefined && (
92
+ <div
93
+ className="flex items-center gap-1.5 px-4 py-2 rounded-xl mt-1"
94
+ style={{ background: "rgba(39,117,202,0.2)", border: "1px solid rgba(39,117,202,0.35)" }}
95
+ >
96
+ <UsdcIcon size={14} />
97
+ <span className="text-[14px] font-bold text-white" style={OUTFIT}>PAYOUT: ${myRanking.payout_amount.toFixed(2)} USDC</span>
98
+ </div>
99
+ )}
100
+ {!hasPartnerPayout && (
101
+ <p className="text-[10px] text-white/30" style={OUTFIT}>
102
+ Earned {(myRanking.final_coins ?? myRanking.current_coins).toLocaleString()} {pool?.currency?.symbol ?? "pts"}
103
+ </p>
104
+ )}
105
+ </div>
106
+ )}
107
+
108
+ <div className="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
109
+
110
+ {/* Bet breakdown */}
111
+ {myBets.length > 0 && (
112
+ <div>
113
+ <p className="text-[10px] font-semibold uppercase tracking-wider text-white/50 mb-2" style={OUTFIT}>
114
+ YOUR BETS — {stats.won} Won, {stats.lost} Lost
115
+ </p>
116
+ <div className="flex flex-col gap-1">
117
+ {myBets.filter((b) => b.parlay_id === null).map((bet) => {
118
+ const market = config.markets.find((m) => m.backendChallengeId === bet.challenge_id);
119
+ const Icon = MARKET_ICONS[market?.icon ?? "coin"];
120
+ const won = bet.status === TDBetStatus.WON;
121
+ const lost = bet.status === TDBetStatus.LOST;
122
+
123
+ return (
124
+ <div
125
+ key={bet.id}
126
+ className="flex items-start gap-2 px-3 py-2 rounded-lg"
127
+ style={{
128
+ background: won ? "rgba(34,197,94,0.05)" : lost ? "rgba(239,68,68,0.05)" : "rgba(255,255,255,0.03)",
129
+ border: `1px solid ${won ? "rgba(34,197,94,0.15)" : lost ? "rgba(239,68,68,0.15)" : "rgba(255,255,255,0.05)"}`,
130
+ }}
131
+ >
132
+ {won
133
+ ? <CheckCircle2 size={11} style={{ color: "#22c55e", flexShrink: 0, marginTop: 1 }} />
134
+ : lost
135
+ ? <span className="text-[10px] flex-shrink-0 mt-0.5" style={{ color: "#ef4444" }}>✗</span>
136
+ : Icon && <Icon size={11} style={{ color: market?.accent ?? "#22E3E8", flexShrink: 0, marginTop: 1 }} />
137
+ }
138
+ <div className="flex-1 min-w-0">
139
+ <p className="text-[9px] text-white/60 font-medium leading-tight truncate" style={OUTFIT}>
140
+ {market?.question ?? `Challenge #${bet.challenge_id}`}
141
+ </p>
142
+ <p className="text-[9px] mt-0.5 font-semibold" style={{ ...OUTFIT, color: won ? "#22c55e" : lost ? "#ef4444" : "rgba(255,255,255,0.5)" }}>
143
+ {market?.options.find((o) => o.label.toLowerCase().replace(/\s+/g, "_") === bet.selected_option)?.label ?? bet.selected_option}
144
+ {" @ "}{bet.odds_at_bet}x — {bet.coin_amount} pts
145
+ {won && ` → +${Math.round(bet.actual_return ?? bet.potential_return)} pts`}
146
+ {lost && " → 0 pts"}
147
+ </p>
148
+ </div>
149
+ </div>
150
+ );
151
+ })}
152
+ </div>
153
+ </div>
154
+ )}
155
+
156
+ <div className="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
157
+
158
+ {/* Stats */}
159
+ <div>
160
+ <div className="flex items-center gap-1.5 mb-2">
161
+ <TrendingUp size={11} style={{ color: "#22E3E8" }} />
162
+ <p className="text-[10px] font-semibold uppercase tracking-wider text-white/50" style={OUTFIT}>STATS</p>
163
+ </div>
164
+ <div className="grid grid-cols-2 gap-x-4 gap-y-1">
165
+ {[
166
+ ["Bets placed", `${stats.betsPlaced}/${stats.totalChallenges}`],
167
+ ["Win rate", `${stats.winRate}%`],
168
+ ["Points spent", stats.totalSpent.toLocaleString()],
169
+ ["Final score", stats.totalEarned.toLocaleString()],
170
+ ].map(([label, value]) => (
171
+ <div key={label} className="flex items-center justify-between py-0.5">
172
+ <span className="text-[10px] text-white/40" style={OUTFIT}>{label}</span>
173
+ <span className="text-[10px] font-semibold text-white" style={OUTFIT}>{value}</span>
174
+ </div>
175
+ ))}
176
+ </div>
177
+ </div>
178
+
179
+ <div className="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
180
+
181
+ {/* Final leaderboard */}
182
+ {miniRankings.length > 0 && (
183
+ <div>
184
+ <p className="text-[10px] font-semibold uppercase tracking-wider text-white/50 mb-2" style={OUTFIT}>FINAL LEADERBOARD</p>
185
+ <div className="flex flex-col">
186
+ {miniRankings.map((r) => (
187
+ <LeaderboardRow
188
+ key={r.entry_id}
189
+ entry={{
190
+ wallet: r.partner_ext_id ?? `User #${r.user_id}`,
191
+ pts: r.final_coins ?? r.current_coins,
192
+ payout: 0,
193
+ isYou: r.entry_id === entry?.id,
194
+ rank: r.rank,
195
+ }}
196
+ rank={r.rank}
197
+ isLast={false}
198
+ />
199
+ ))}
200
+ </div>
201
+ <button
202
+ onClick={onViewFullLeaderboard}
203
+ className="mt-2 flex items-center justify-center gap-1 w-full text-[9px] font-semibold"
204
+ style={{ ...OUTFIT, color: "rgba(34,227,232,0.6)" }}
205
+ >
206
+ See Full Leaderboard <ArrowRight size={10} />
207
+ </button>
208
+ </div>
209
+ )}
210
+ </div>
211
+ );
212
+ }