@devrongx/games 0.4.34 → 0.4.36
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 +1 -1
- package/src/games/prematch-bets/FullLeaderboard.tsx +289 -132
- package/src/games/prematch-bets/LeaderboardRow.tsx +28 -25
- package/src/games/prematch-bets/PreMatchBetsPopup.tsx +9 -6
- package/src/games/prematch-bets/PreMatchGame.tsx +4 -5
- package/src/games/prematch-bets/PreMatchLive.tsx +3 -3
- package/src/pools/types.ts +1 -0
package/package.json
CHANGED
|
@@ -1,149 +1,306 @@
|
|
|
1
1
|
// @devrongx/games — games/prematch-bets/FullLeaderboard.tsx
|
|
2
|
-
// Full paginated leaderboard
|
|
2
|
+
// Full paginated leaderboard — immersive dark design matching game screen.
|
|
3
3
|
"use client";
|
|
4
4
|
|
|
5
|
-
import { useState } from "react";
|
|
6
|
-
import {
|
|
5
|
+
import { useState, useMemo } from "react";
|
|
6
|
+
import { motion, AnimatePresence } from "framer-motion";
|
|
7
|
+
import { ArrowLeft, ChevronDown, ChevronUp, Loader2, Trophy, TrendingUp } from "lucide-react";
|
|
7
8
|
import { IChallengeConfig } from "./config";
|
|
8
|
-
import {
|
|
9
|
-
import { OUTFIT } from "./constants";
|
|
9
|
+
import { OUTFIT, PointsIcon } from "./constants";
|
|
10
10
|
import { useTDLeaderboard } from "../../pools/hooks";
|
|
11
|
+
import type { ITDLeaderboardEntry } from "../../pools/types";
|
|
11
12
|
|
|
12
13
|
const PAGE_SIZE = 50;
|
|
13
14
|
|
|
14
15
|
const SORT_TABS = [
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
{ key: "max_possible", label: "Max" },
|
|
18
|
-
{ key: "questions_left", label: "Left" },
|
|
16
|
+
{ key: "max_possible", label: "Score", icon: Trophy },
|
|
17
|
+
{ key: "pending_risk", label: "Risk", icon: TrendingUp },
|
|
19
18
|
] as const;
|
|
20
19
|
|
|
20
|
+
const EXPLAINER: Record<string, string> = {
|
|
21
|
+
max_possible:
|
|
22
|
+
"Each unresolved bet counts at its max potential payout. As results come in, won bets keep their value, lost bets drop to zero. Your score converges to your actual outcome as the match progresses.",
|
|
23
|
+
pending_risk:
|
|
24
|
+
"Total coins at stake in unresolved bets. As each question is answered, that bet\u2019s risk drops to zero \u2014 win or lose. Risk reaches zero when all bets settle.",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const RANK_COLORS = [
|
|
28
|
+
{ bg: "rgba(255,215,0,0.12)", border: "rgba(255,215,0,0.3)", text: "#FFD700", glow: "0 0 12px rgba(255,215,0,0.2)" },
|
|
29
|
+
{ bg: "rgba(192,192,192,0.10)", border: "rgba(192,192,192,0.25)", text: "#C0C0C0", glow: "0 0 10px rgba(192,192,192,0.15)" },
|
|
30
|
+
{ bg: "rgba(205,127,50,0.10)", border: "rgba(205,127,50,0.25)", text: "#CD7F32", glow: "0 0 10px rgba(205,127,50,0.15)" },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function getMetric(entry: ITDLeaderboardEntry, sortKey: string, startingBalance: number): number {
|
|
34
|
+
switch (sortKey) {
|
|
35
|
+
case "pending_risk": return entry.pending_risk;
|
|
36
|
+
default: return entry.max_possible - startingBalance;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
21
40
|
interface FullLeaderboardProps {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
41
|
+
poolId: number;
|
|
42
|
+
config: IChallengeConfig;
|
|
43
|
+
onBack: () => void;
|
|
44
|
+
userEntryId?: number | null;
|
|
25
45
|
}
|
|
26
46
|
|
|
27
|
-
export function FullLeaderboard({ poolId, config, onBack }: FullLeaderboardProps) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}}
|
|
64
|
-
>
|
|
65
|
-
{tab.label}
|
|
66
|
-
</button>
|
|
67
|
-
))}
|
|
68
|
-
</div>
|
|
69
|
-
|
|
70
|
-
{/* Column headers */}
|
|
71
|
-
<div className="flex items-center gap-2 px-4 py-1.5" style={{ borderBottom: "1px solid rgba(255,255,255,0.04)" }}>
|
|
72
|
-
<span className="w-[28px] text-right text-[8px] text-white/25 uppercase tracking-wide" style={OUTFIT}>#</span>
|
|
73
|
-
<span className="flex-1 text-[8px] text-white/25 uppercase tracking-wide" style={OUTFIT}>Player</span>
|
|
74
|
-
<span className="w-[52px] text-right text-[8px] text-white/25 uppercase tracking-wide" style={OUTFIT}>Score</span>
|
|
75
|
-
<span className="w-[52px] text-right text-[8px] text-white/25 uppercase tracking-wide" style={OUTFIT}>Payout</span>
|
|
76
|
-
</div>
|
|
77
|
-
|
|
78
|
-
{/* Rows */}
|
|
79
|
-
{loading ? (
|
|
80
|
-
<div className="flex items-center justify-center py-10">
|
|
81
|
-
<Loader2 size={18} className="animate-spin" style={{ color: "rgba(255,255,255,0.3)" }} />
|
|
82
|
-
</div>
|
|
83
|
-
) : (
|
|
84
|
-
<div className="flex flex-col overflow-y-auto" style={{ maxHeight: "55vh" }}>
|
|
85
|
-
{rankings.map((r) => (
|
|
86
|
-
<LeaderboardRow
|
|
87
|
-
key={r.entry_id}
|
|
88
|
-
entry={{
|
|
89
|
-
wallet: r.partner_ext_id ?? `User #${r.user_id}`,
|
|
90
|
-
pts: r.current_coins,
|
|
91
|
-
payout: 0,
|
|
92
|
-
isYou: false,
|
|
93
|
-
rank: r.rank,
|
|
94
|
-
}}
|
|
95
|
-
rank={r.rank}
|
|
96
|
-
isLast={false}
|
|
97
|
-
/>
|
|
98
|
-
))}
|
|
99
|
-
{!loading && rankings.length === 0 && (
|
|
100
|
-
<div className="flex items-center justify-center py-10">
|
|
101
|
-
<p className="text-[11px] text-white/30" style={OUTFIT}>No entries yet</p>
|
|
47
|
+
export function FullLeaderboard({ poolId, config, onBack, userEntryId }: FullLeaderboardProps) {
|
|
48
|
+
const [sortBy, setSortBy] = useState<string>("max_possible");
|
|
49
|
+
const [page, setPage] = useState(0);
|
|
50
|
+
const [showExplainer, setShowExplainer] = useState(false);
|
|
51
|
+
|
|
52
|
+
const { rankings, total, loading } = useTDLeaderboard(poolId, {
|
|
53
|
+
sort: sortBy,
|
|
54
|
+
limit: PAGE_SIZE,
|
|
55
|
+
offset: page * PAGE_SIZE,
|
|
56
|
+
pollMs: 15_000,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const startingBalance = config.startingBalance;
|
|
60
|
+
|
|
61
|
+
const maxMetric = useMemo(() => {
|
|
62
|
+
if (rankings.length === 0) return 1;
|
|
63
|
+
return Math.max(...rankings.map((r) => getMetric(r, sortBy, startingBalance)), 1);
|
|
64
|
+
}, [rankings, sortBy, startingBalance]);
|
|
65
|
+
|
|
66
|
+
const activeTab = SORT_TABS.find((t) => t.key === sortBy) ?? SORT_TABS[0];
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className="w-full flex flex-col">
|
|
70
|
+
{/* Header */}
|
|
71
|
+
<div className="flex items-center gap-3 px-4 pt-4 pb-3">
|
|
72
|
+
<button onClick={onBack}
|
|
73
|
+
className="flex items-center justify-center w-8 h-8 rounded-xl"
|
|
74
|
+
style={{ background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.08)" }}>
|
|
75
|
+
<ArrowLeft size={14} className="text-white/60" />
|
|
76
|
+
</button>
|
|
77
|
+
<div className="flex-1">
|
|
78
|
+
<p className="text-[15px] font-bold text-white tracking-wide" style={OUTFIT}>Leaderboard</p>
|
|
79
|
+
<p className="text-[10px] text-white/35 font-medium" style={OUTFIT}>
|
|
80
|
+
{total.toLocaleString()} player{total !== 1 ? "s" : ""}
|
|
81
|
+
</p>
|
|
82
|
+
</div>
|
|
102
83
|
</div>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
84
|
+
|
|
85
|
+
{/* Sort tabs */}
|
|
86
|
+
<div className="flex gap-1.5 px-4 py-2">
|
|
87
|
+
{SORT_TABS.map((tab) => {
|
|
88
|
+
const active = sortBy === tab.key;
|
|
89
|
+
const TabIcon = tab.icon;
|
|
90
|
+
return (
|
|
91
|
+
<button
|
|
92
|
+
key={tab.key}
|
|
93
|
+
onClick={() => { setSortBy(tab.key); setPage(0); }}
|
|
94
|
+
className="flex-1 flex items-center justify-center gap-1 py-1.5 rounded-full text-[10px] font-semibold transition-all"
|
|
95
|
+
style={{
|
|
96
|
+
...OUTFIT,
|
|
97
|
+
border: `1px solid ${active ? "#22E3E8" : "rgba(34,227,232,0.15)"}`,
|
|
98
|
+
background: active ? "rgba(34,227,232,0.12)" : "transparent",
|
|
99
|
+
color: active ? "#22E3E8" : "rgba(255,255,255,0.4)",
|
|
100
|
+
boxShadow: active ? "0 0 12px rgba(34,227,232,0.1)" : "none",
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
<TabIcon size={10} />
|
|
104
|
+
{tab.label}
|
|
105
|
+
</button>
|
|
106
|
+
);
|
|
107
|
+
})}
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
{/* Explainer toggle */}
|
|
111
|
+
<button
|
|
112
|
+
onClick={() => setShowExplainer((v) => !v)}
|
|
113
|
+
className="flex items-center gap-1 px-4 py-1 text-[9px] font-medium"
|
|
114
|
+
style={{ ...OUTFIT, color: "rgba(34,227,232,0.5)" }}
|
|
115
|
+
>
|
|
116
|
+
How does this work?
|
|
117
|
+
{showExplainer ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
|
|
118
|
+
</button>
|
|
119
|
+
<AnimatePresence>
|
|
120
|
+
{showExplainer && (
|
|
121
|
+
<motion.div
|
|
122
|
+
initial={{ height: 0, opacity: 0 }}
|
|
123
|
+
animate={{ height: "auto", opacity: 1 }}
|
|
124
|
+
exit={{ height: 0, opacity: 0 }}
|
|
125
|
+
transition={{ duration: 0.2 }}
|
|
126
|
+
className="overflow-hidden"
|
|
127
|
+
>
|
|
128
|
+
<p className="text-[9px] leading-relaxed px-4 pb-2 font-medium"
|
|
129
|
+
style={{ ...OUTFIT, color: "rgba(255,255,255,0.3)" }}>
|
|
130
|
+
{EXPLAINER[sortBy]}
|
|
131
|
+
</p>
|
|
132
|
+
</motion.div>
|
|
133
|
+
)}
|
|
134
|
+
</AnimatePresence>
|
|
135
|
+
|
|
136
|
+
{/* Column guide */}
|
|
137
|
+
<div className="flex items-center px-4 py-1.5 mt-1" style={{ borderBottom: "1px solid rgba(255,255,255,0.04)" }}>
|
|
138
|
+
<span className="w-[32px] text-[7px] text-white/20 uppercase tracking-widest font-bold text-center" style={OUTFIT}>Rank</span>
|
|
139
|
+
<span className="flex-1 text-[7px] text-white/20 uppercase tracking-widest font-bold pl-2" style={OUTFIT}>Player</span>
|
|
140
|
+
<span className="w-[80px] text-[7px] text-white/20 uppercase tracking-widest font-bold text-right" style={OUTFIT}>{activeTab.label}</span>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
{/* Rows */}
|
|
144
|
+
{loading ? (
|
|
145
|
+
<div className="flex items-center justify-center py-16">
|
|
146
|
+
<Loader2 size={18} className="animate-spin" style={{ color: "rgba(34,227,232,0.4)" }} />
|
|
147
|
+
</div>
|
|
148
|
+
) : (
|
|
149
|
+
<div className="flex flex-col overflow-y-auto" style={{ maxHeight: "55vh" }}>
|
|
150
|
+
{rankings.map((r, i) => {
|
|
151
|
+
const isUser = r.entry_id === userEntryId;
|
|
152
|
+
const rankIdx = r.rank - 1;
|
|
153
|
+
const isTop3 = rankIdx < 3;
|
|
154
|
+
const metric = getMetric(r, sortBy, startingBalance);
|
|
155
|
+
const barWidth = maxMetric > 0 ? (metric / maxMetric) * 100 : 0;
|
|
156
|
+
const rankStyle = isTop3 ? RANK_COLORS[rankIdx] : null;
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<motion.div
|
|
160
|
+
key={r.entry_id}
|
|
161
|
+
initial={{ opacity: 0, x: -8 }}
|
|
162
|
+
animate={{ opacity: 1, x: 0 }}
|
|
163
|
+
transition={{ delay: i * 0.03, duration: 0.25 }}
|
|
164
|
+
className="relative px-4 py-2"
|
|
165
|
+
style={{
|
|
166
|
+
background: isUser ? "rgba(34,227,232,0.06)" : "transparent",
|
|
167
|
+
borderBottom: "1px solid rgba(255,255,255,0.03)",
|
|
168
|
+
}}
|
|
169
|
+
>
|
|
170
|
+
{/* Cyan left accent for user */}
|
|
171
|
+
{isUser && (
|
|
172
|
+
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[2px] h-[60%] rounded-full"
|
|
173
|
+
style={{ background: "#22E3E8", boxShadow: "0 0 8px rgba(34,227,232,0.4)" }} />
|
|
174
|
+
)}
|
|
175
|
+
|
|
176
|
+
<div className="flex items-center">
|
|
177
|
+
{/* Rank badge */}
|
|
178
|
+
<div className="w-[32px] flex-shrink-0 flex items-center justify-center">
|
|
179
|
+
{isTop3 && rankStyle ? (
|
|
180
|
+
<span
|
|
181
|
+
className="inline-flex items-center justify-center w-[22px] h-[22px] rounded-lg text-[10px] font-bold"
|
|
182
|
+
style={{ background: rankStyle.bg, border: `1px solid ${rankStyle.border}`, color: rankStyle.text, boxShadow: rankStyle.glow, ...OUTFIT }}>
|
|
183
|
+
{r.rank}
|
|
184
|
+
</span>
|
|
185
|
+
) : (
|
|
186
|
+
<span className="text-[11px] text-white/40 font-semibold" style={OUTFIT}>{r.rank}</span>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
{/* Player */}
|
|
191
|
+
<div className="flex-1 flex items-center gap-1.5 pl-2 min-w-0">
|
|
192
|
+
<span className={`text-[11px] font-semibold truncate ${isUser ? "text-[#22E3E8]" : isTop3 ? "text-white" : "text-white/70"}`} style={OUTFIT}>
|
|
193
|
+
{r.partner_ext_id ?? `User #${r.user_id}`}
|
|
194
|
+
</span>
|
|
195
|
+
{isUser && (
|
|
196
|
+
<span className="text-[7px] px-1.5 py-[1px] rounded-full font-bold uppercase flex-shrink-0"
|
|
197
|
+
style={{ background: "rgba(34,227,232,0.15)", color: "#22E3E8", border: "1px solid rgba(34,227,232,0.2)", ...OUTFIT }}>
|
|
198
|
+
You
|
|
199
|
+
</span>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
{/* Primary metric */}
|
|
204
|
+
<div className="w-[80px] flex-shrink-0 flex items-center justify-end gap-1">
|
|
205
|
+
<PointsIcon size={9} />
|
|
206
|
+
<span
|
|
207
|
+
className={`text-[12px] font-bold ${isUser ? "text-[#22E3E8]" : isTop3 ? "text-white" : "text-white/80"}`}
|
|
208
|
+
style={{ ...OUTFIT, ...(isUser ? { textShadow: "0 0 8px rgba(34,227,232,0.3)" } : {}) }}>
|
|
209
|
+
{metric.toLocaleString()}
|
|
210
|
+
</span>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
{/* Secondary info + progress bar */}
|
|
215
|
+
<div className="flex items-center mt-1 pl-[34px]">
|
|
216
|
+
<div className="flex-1 flex items-center gap-2">
|
|
217
|
+
<span className="text-[8px] text-white/25 font-medium" style={OUTFIT}>
|
|
218
|
+
{r.bets_placed} bet{r.bets_placed !== 1 ? "s" : ""}
|
|
219
|
+
</span>
|
|
220
|
+
{sortBy !== "pending_risk" && r.total_risked > 0 && (
|
|
221
|
+
<span className="text-[8px] text-white/20 font-medium" style={OUTFIT}>
|
|
222
|
+
· {r.total_risked.toLocaleString()} risked
|
|
223
|
+
</span>
|
|
224
|
+
)}
|
|
225
|
+
{sortBy !== "max_possible" && r.max_possible > 0 && (
|
|
226
|
+
<span className="text-[8px] text-white/20 font-medium" style={OUTFIT}>
|
|
227
|
+
· {(r.max_possible - startingBalance).toLocaleString()} score
|
|
228
|
+
</span>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
{/* Thin progress bar */}
|
|
234
|
+
<div className="mt-1 ml-[34px] h-[2px] rounded-full overflow-hidden" style={{ background: "rgba(255,255,255,0.04)" }}>
|
|
235
|
+
<motion.div
|
|
236
|
+
className="h-full rounded-full"
|
|
237
|
+
initial={{ width: 0 }}
|
|
238
|
+
animate={{ width: `${barWidth}%` }}
|
|
239
|
+
transition={{ delay: i * 0.03 + 0.15, duration: 0.5, ease: "easeOut" }}
|
|
240
|
+
style={{
|
|
241
|
+
background: isUser
|
|
242
|
+
? "linear-gradient(90deg, rgba(34,227,232,0.6), rgba(34,227,232,0.2))"
|
|
243
|
+
: isTop3 && rankStyle
|
|
244
|
+
? `linear-gradient(90deg, ${rankStyle.text}80, ${rankStyle.text}20)`
|
|
245
|
+
: "linear-gradient(90deg, rgba(255,255,255,0.2), rgba(255,255,255,0.05))",
|
|
246
|
+
}}
|
|
247
|
+
/>
|
|
248
|
+
</div>
|
|
249
|
+
</motion.div>
|
|
250
|
+
);
|
|
251
|
+
})}
|
|
252
|
+
{!loading && rankings.length === 0 && (
|
|
253
|
+
<div className="flex items-center justify-center py-16">
|
|
254
|
+
<p className="text-[11px] text-white/30 font-medium" style={OUTFIT}>No entries yet</p>
|
|
255
|
+
</div>
|
|
256
|
+
)}
|
|
257
|
+
</div>
|
|
258
|
+
)}
|
|
259
|
+
|
|
260
|
+
{/* Pagination — only when needed */}
|
|
261
|
+
{total > PAGE_SIZE && (
|
|
262
|
+
<div className="flex items-center justify-center gap-3 px-4 py-3" style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }}>
|
|
263
|
+
<button
|
|
264
|
+
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
265
|
+
disabled={page === 0}
|
|
266
|
+
className="text-[10px] font-semibold px-4 py-1.5 rounded-full disabled:opacity-25 transition-all"
|
|
267
|
+
style={{ ...OUTFIT, background: "rgba(255,255,255,0.06)", color: "rgba(255,255,255,0.6)", border: "1px solid rgba(255,255,255,0.08)" }}>
|
|
268
|
+
Prev
|
|
269
|
+
</button>
|
|
270
|
+
<span className="text-[10px] text-white/35 font-medium" style={OUTFIT}>
|
|
271
|
+
{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total}
|
|
272
|
+
</span>
|
|
273
|
+
<button
|
|
274
|
+
onClick={() => setPage((p) => p + 1)}
|
|
275
|
+
disabled={(page + 1) * PAGE_SIZE >= total}
|
|
276
|
+
className="text-[10px] font-semibold px-4 py-1.5 rounded-full disabled:opacity-25 transition-all"
|
|
277
|
+
style={{ ...OUTFIT, background: "rgba(255,255,255,0.06)", color: "rgba(255,255,255,0.6)", border: "1px solid rgba(255,255,255,0.08)" }}>
|
|
278
|
+
Next
|
|
279
|
+
</button>
|
|
280
|
+
</div>
|
|
281
|
+
)}
|
|
282
|
+
|
|
283
|
+
{/* Prize brackets */}
|
|
284
|
+
{config.rankBrackets.length > 0 && (
|
|
285
|
+
<div className="px-4 pb-4 pt-2">
|
|
286
|
+
<p className="text-[8px] text-white/25 font-bold uppercase tracking-widest mb-2" style={OUTFIT}>Prize Breakdown</p>
|
|
287
|
+
<div className="flex flex-wrap gap-1.5">
|
|
288
|
+
{config.rankBrackets.map((b, i) => (
|
|
289
|
+
<span
|
|
290
|
+
key={i}
|
|
291
|
+
className="text-[8px] font-semibold px-2.5 py-1 rounded-full"
|
|
292
|
+
style={{
|
|
293
|
+
background: i === 0 ? "rgba(255,215,0,0.08)" : "rgba(255,255,255,0.04)",
|
|
294
|
+
color: i === 0 ? "#FFD700" : "rgba(255,255,255,0.35)",
|
|
295
|
+
border: `1px solid ${i === 0 ? "rgba(255,215,0,0.2)" : "rgba(255,255,255,0.06)"}`,
|
|
296
|
+
...OUTFIT,
|
|
297
|
+
}}>
|
|
298
|
+
#{b.from}{b.to > b.from ? `–${b.to}` : ""}: {(b.poolPercent * 100).toFixed(0)}%
|
|
299
|
+
</span>
|
|
300
|
+
))}
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
)}
|
|
145
304
|
</div>
|
|
146
|
-
|
|
147
|
-
</div>
|
|
148
|
-
);
|
|
305
|
+
);
|
|
149
306
|
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// @devrongx/games — games/prematch-bets/LeaderboardRow.tsx
|
|
2
2
|
"use client";
|
|
3
3
|
|
|
4
|
-
import Image from "next/image";
|
|
5
4
|
import { ILeaderboardEntry } from "./config";
|
|
5
|
+
import { OUTFIT, PointsIcon } from "./constants";
|
|
6
6
|
|
|
7
|
-
const
|
|
8
|
-
{
|
|
9
|
-
{
|
|
10
|
-
{
|
|
7
|
+
const RANK_COLORS = [
|
|
8
|
+
{ bg: "rgba(255,215,0,0.12)", border: "rgba(255,215,0,0.3)", text: "#FFD700" },
|
|
9
|
+
{ bg: "rgba(192,192,192,0.10)", border: "rgba(192,192,192,0.25)", text: "#C0C0C0" },
|
|
10
|
+
{ bg: "rgba(205,127,50,0.10)", border: "rgba(205,127,50,0.25)", text: "#CD7F32" },
|
|
11
11
|
];
|
|
12
12
|
|
|
13
13
|
interface LeaderboardRowProps {
|
|
@@ -18,50 +18,53 @@ interface LeaderboardRowProps {
|
|
|
18
18
|
|
|
19
19
|
export const LeaderboardRow = ({ entry, rank, isLast }: LeaderboardRowProps) => {
|
|
20
20
|
const rankIdx = rank - 1;
|
|
21
|
+
const isTop3 = rankIdx < 3;
|
|
22
|
+
const rankStyle = isTop3 ? RANK_COLORS[rankIdx] : null;
|
|
21
23
|
|
|
22
24
|
return (
|
|
23
25
|
<div
|
|
24
|
-
className=
|
|
26
|
+
className="relative flex items-center gap-2 px-3 py-[6px] transition-all duration-300"
|
|
25
27
|
style={{
|
|
26
28
|
borderBottom: isLast ? "none" : "1px solid rgba(255,255,255,0.03)",
|
|
27
|
-
background: entry.isYou ? "rgba(34,227,232,0.
|
|
29
|
+
background: entry.isYou ? "rgba(34,227,232,0.06)" : "transparent",
|
|
28
30
|
}}
|
|
29
31
|
>
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
{entry.isYou && (
|
|
33
|
+
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[2px] h-[60%] rounded-full"
|
|
34
|
+
style={{ background: "#22E3E8", boxShadow: "0 0 6px rgba(34,227,232,0.3)" }} />
|
|
35
|
+
)}
|
|
36
|
+
<div className="w-[32px] flex-shrink-0 flex items-center justify-center">
|
|
37
|
+
{isTop3 && rankStyle ? (
|
|
32
38
|
<span
|
|
33
|
-
className="inline-flex items-center justify-center w-[
|
|
34
|
-
style={{ background:
|
|
35
|
-
>
|
|
39
|
+
className="inline-flex items-center justify-center w-[20px] h-[20px] rounded-lg text-[9px] font-bold"
|
|
40
|
+
style={{ background: rankStyle.bg, border: `1px solid ${rankStyle.border}`, color: rankStyle.text, ...OUTFIT }}>
|
|
36
41
|
{rank}
|
|
37
42
|
</span>
|
|
38
43
|
) : (
|
|
39
|
-
<span className="text-[10px] text-white/
|
|
44
|
+
<span className="text-[10px] text-white/40 font-semibold" style={OUTFIT}>{rank.toLocaleString()}</span>
|
|
40
45
|
)}
|
|
41
46
|
</div>
|
|
42
47
|
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
|
43
48
|
<span
|
|
44
|
-
className={`text-[11px] truncate ${entry.isYou ? "text-[#22E3E8]
|
|
45
|
-
style={
|
|
46
|
-
>
|
|
49
|
+
className={`text-[11px] truncate font-semibold ${entry.isYou ? "text-[#22E3E8]" : isTop3 ? "text-white" : "text-white/60"}`}
|
|
50
|
+
style={OUTFIT}>
|
|
47
51
|
{entry.wallet}
|
|
48
52
|
</span>
|
|
49
53
|
{entry.isYou && (
|
|
50
|
-
<span className="text-[7px] px-1 py-[1px] rounded
|
|
54
|
+
<span className="text-[7px] px-1.5 py-[1px] rounded-full font-bold uppercase flex-shrink-0"
|
|
55
|
+
style={{ background: "rgba(34,227,232,0.15)", color: "#22E3E8", border: "1px solid rgba(34,227,232,0.2)", ...OUTFIT }}>
|
|
56
|
+
You
|
|
57
|
+
</span>
|
|
51
58
|
)}
|
|
52
59
|
</div>
|
|
53
60
|
<div className="w-[66px] flex-shrink-0 flex items-center justify-end gap-[3px]">
|
|
54
|
-
<
|
|
55
|
-
<span
|
|
61
|
+
<PointsIcon size={9} />
|
|
62
|
+
<span
|
|
63
|
+
className={`text-[11px] font-bold ${entry.isYou ? "text-[#22E3E8]" : isTop3 ? "text-white" : "text-white/70"}`}
|
|
64
|
+
style={{ ...OUTFIT, ...(entry.isYou ? { textShadow: "0 0 6px rgba(34,227,232,0.3)" } : {}) }}>
|
|
56
65
|
{entry.pts.toLocaleString()}
|
|
57
66
|
</span>
|
|
58
67
|
</div>
|
|
59
|
-
<div className="w-[72px] flex-shrink-0 flex items-center justify-end gap-[2px]">
|
|
60
|
-
<span className={`text-[10px] font-semibold ${entry.isYou ? "text-[#22E3E8]" : "text-green-400"}`}>
|
|
61
|
-
+${entry.payout.toFixed(1)}
|
|
62
|
-
</span>
|
|
63
|
-
<Image src="/icons/ic_usdc.png" alt="" width={12} height={12} className="rounded-full" />
|
|
64
|
-
</div>
|
|
65
68
|
</div>
|
|
66
69
|
);
|
|
67
70
|
};
|
|
@@ -54,13 +54,15 @@ function getActiveView(
|
|
|
54
54
|
function buildMiniLeaderboard(
|
|
55
55
|
rankings: ITDLeaderboardEntry[],
|
|
56
56
|
userEntryId: number | null,
|
|
57
|
+
startingBalance: number,
|
|
57
58
|
): ILeaderboardEntry[] {
|
|
59
|
+
const score = (r: ITDLeaderboardEntry) => r.max_possible - startingBalance;
|
|
58
60
|
const rows: ILeaderboardEntry[] = [];
|
|
59
61
|
const top = rankings.slice(0, 3);
|
|
60
62
|
top.forEach((r) => {
|
|
61
63
|
rows.push({
|
|
62
64
|
wallet: r.partner_ext_id ?? `User #${r.user_id}`,
|
|
63
|
-
pts: r
|
|
65
|
+
pts: score(r),
|
|
64
66
|
payout: 0,
|
|
65
67
|
isYou: r.entry_id === userEntryId,
|
|
66
68
|
rank: r.rank,
|
|
@@ -72,13 +74,13 @@ function buildMiniLeaderboard(
|
|
|
72
74
|
rows.push({ wallet: "", pts: 0, payout: 0, isYou: false, gapAbove: true });
|
|
73
75
|
if (userIdx > 3) {
|
|
74
76
|
const above = rankings[userIdx - 1];
|
|
75
|
-
rows.push({ wallet: above.partner_ext_id ?? `User #${above.user_id}`, pts: above
|
|
77
|
+
rows.push({ wallet: above.partner_ext_id ?? `User #${above.user_id}`, pts: score(above), payout: 0, isYou: false, rank: userIdx });
|
|
76
78
|
}
|
|
77
79
|
const me = rankings[userIdx];
|
|
78
|
-
rows.push({ wallet: me.partner_ext_id ?? "You", pts: me
|
|
80
|
+
rows.push({ wallet: me.partner_ext_id ?? "You", pts: score(me), payout: 0, isYou: true, rank: userIdx + 1 });
|
|
79
81
|
if (userIdx < rankings.length - 1) {
|
|
80
82
|
const below = rankings[userIdx + 1];
|
|
81
|
-
rows.push({ wallet: below.partner_ext_id ?? `User #${below.user_id}`, pts: below
|
|
83
|
+
rows.push({ wallet: below.partner_ext_id ?? `User #${below.user_id}`, pts: score(below), payout: 0, isYou: false, rank: userIdx + 2 });
|
|
82
84
|
}
|
|
83
85
|
}
|
|
84
86
|
}
|
|
@@ -103,7 +105,7 @@ export const PreMatchBetsPopup = ({ poolId, matchId: _matchId, match: matchProp
|
|
|
103
105
|
// ── Real API data (only when poolId provided) ────────────────────────────
|
|
104
106
|
const { pool, loading: poolLoading, refetch: refetchPool } = useTDPool(poolId ?? 0);
|
|
105
107
|
const { data: entryData, refetch: refetchEntry } = useTDPoolEntry(poolId ?? 0);
|
|
106
|
-
const { rankings, refetch: refetchLB } = useTDLeaderboard(poolId ?? 0, { pollMs: poolId ? 30_000 : 0 });
|
|
108
|
+
const { rankings, refetch: refetchLB } = useTDLeaderboard(poolId ?? 0, { sort: "max_possible", pollMs: poolId ? 30_000 : 0 });
|
|
107
109
|
|
|
108
110
|
// ── Config: real or fallback ─────────────────────────────────────────────
|
|
109
111
|
// matchProp is passed directly from MatchCalendar's onPoolPress — no extra fetch needed
|
|
@@ -228,7 +230,7 @@ export const PreMatchBetsPopup = ({ poolId, matchId: _matchId, match: matchProp
|
|
|
228
230
|
|
|
229
231
|
const leaderboardRows = useMemo(() => {
|
|
230
232
|
if (poolId && rankings.length > 0) {
|
|
231
|
-
return buildMiniLeaderboard(rankings, entryData?.entry.id ?? null);
|
|
233
|
+
return buildMiniLeaderboard(rankings, entryData?.entry.id ?? null, config?.startingBalance ?? 0);
|
|
232
234
|
}
|
|
233
235
|
if (fallbackPool && config) {
|
|
234
236
|
return buildPoolLeaderboard(fallbackPool, betSummary.potentialBalance, config).rows;
|
|
@@ -429,6 +431,7 @@ export const PreMatchBetsPopup = ({ poolId, matchId: _matchId, match: matchProp
|
|
|
429
431
|
poolId={poolId ?? 0}
|
|
430
432
|
config={config}
|
|
431
433
|
onBack={() => setShowFullLeaderboard(false)}
|
|
434
|
+
userEntryId={entryData?.entry.id ?? null}
|
|
432
435
|
/>
|
|
433
436
|
</GamePopupShell>
|
|
434
437
|
);
|
|
@@ -840,12 +840,11 @@ export const PreMatchGame = ({
|
|
|
840
840
|
|
|
841
841
|
{/* Leaderboard & Potential Payouts */}
|
|
842
842
|
<div className="pt-1">
|
|
843
|
-
<p className="text-[10px] text-white uppercase tracking-wide mb-2 font-semibold" style={OUTFIT}>Leaderboard
|
|
843
|
+
<p className="text-[10px] text-white uppercase tracking-wide mb-2 font-semibold" style={OUTFIT}>Leaderboard</p>
|
|
844
844
|
<div className="flex items-center gap-2 px-3 mb-1">
|
|
845
|
-
<span className="w-[
|
|
846
|
-
<span className="flex-1 text-[
|
|
847
|
-
<span className="w-[66px] flex-shrink-0 text-right text-[
|
|
848
|
-
<span className="w-[72px] flex-shrink-0 text-right text-[9px] text-white/50 uppercase tracking-wide" style={OUTFIT}>Payout</span>
|
|
845
|
+
<span className="w-[32px] flex-shrink-0 text-center text-[8px] text-white/30 uppercase tracking-widest font-bold" style={OUTFIT}>#</span>
|
|
846
|
+
<span className="flex-1 text-[8px] text-white/30 uppercase tracking-widest font-bold pl-2" style={OUTFIT}>Player</span>
|
|
847
|
+
<span className="w-[66px] flex-shrink-0 text-right text-[8px] text-white/30 uppercase tracking-widest font-bold" style={OUTFIT}>Score</span>
|
|
849
848
|
</div>
|
|
850
849
|
<div className="h-px bg-white/5 mb-1" />
|
|
851
850
|
<div className="flex flex-col">
|
|
@@ -76,8 +76,8 @@ export function PreMatchLive({
|
|
|
76
76
|
{entry && (
|
|
77
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
78
|
<div className="flex-1">
|
|
79
|
-
<p className="text-[9px] text-white/40 uppercase tracking-wide" style={OUTFIT}>
|
|
80
|
-
<p className="text-[18px] font-bold text-white" style={OUTFIT}>{entry.
|
|
79
|
+
<p className="text-[9px] text-white/40 uppercase tracking-wide" style={OUTFIT}>Score</p>
|
|
80
|
+
<p className="text-[18px] font-bold text-white" style={OUTFIT}>{(entry.max_possible - config.startingBalance).toLocaleString()} pts</p>
|
|
81
81
|
</div>
|
|
82
82
|
{myRank !== null && myRank > 0 && (
|
|
83
83
|
<div className="text-right">
|
|
@@ -169,7 +169,7 @@ export function PreMatchLive({
|
|
|
169
169
|
key={r.entry_id}
|
|
170
170
|
entry={{
|
|
171
171
|
wallet: r.partner_ext_id ?? `User #${r.user_id}`,
|
|
172
|
-
pts: r.
|
|
172
|
+
pts: r.max_possible - config.startingBalance,
|
|
173
173
|
payout: 0,
|
|
174
174
|
isYou: r.entry_id === entry?.id,
|
|
175
175
|
rank: r.rank,
|