@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.
- package/package.json +1 -1
- package/src/games/prematch-bets/FullLeaderboard.tsx +149 -0
- package/src/games/prematch-bets/PreMatchBetsPopup.tsx +307 -76
- package/src/games/prematch-bets/PreMatchGame.tsx +55 -7
- package/src/games/prematch-bets/PreMatchLive.tsx +193 -0
- package/src/games/prematch-bets/PreMatchResults.tsx +212 -0
- package/src/games/prematch-bets/PreMatchSubmitted.tsx +183 -0
- package/src/games/prematch-bets/config.ts +1 -0
- package/src/index.ts +16 -0
- package/src/matches/MatchCalendar.tsx +104 -50
- package/src/pools/actions.ts +19 -0
- package/src/pools/fetcher.ts +90 -0
- package/src/pools/hooks.ts +173 -0
- package/src/pools/index.ts +44 -0
- package/src/pools/mapper.ts +85 -0
- package/src/pools/types.ts +229 -0
|
@@ -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 —
|
|
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
|
-
<
|
|
656
|
-
|
|
657
|
-
|
|
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
|
+
}
|