@devrongx/games 0.3.4 → 0.4.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.
- package/package.json +1 -1
- package/src/games/prematch-bets/FullLeaderboard.tsx +149 -0
- package/src/games/prematch-bets/PreMatchBetsPopup.tsx +306 -76
- package/src/games/prematch-bets/PreMatchGame.tsx +64 -8
- package/src/games/prematch-bets/PreMatchLive.tsx +192 -0
- package/src/games/prematch-bets/PreMatchQuestions.tsx +66 -42
- package/src/games/prematch-bets/PreMatchResults.tsx +211 -0
- package/src/games/prematch-bets/PreMatchSubmitted.tsx +183 -0
- package/src/games/prematch-bets/config.ts +1 -0
- package/src/games/prematch-bets/constants.tsx +39 -1
- package/src/index.ts +16 -0
- package/src/matches/MatchCalendar.tsx +100 -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 +194 -0
package/package.json
CHANGED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// @devrongx/games — games/prematch-bets/FullLeaderboard.tsx
|
|
2
|
+
// Full paginated leaderboard with sort tabs and pinned user rank.
|
|
3
|
+
"use client";
|
|
4
|
+
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import { ArrowLeft, Loader2 } from "lucide-react";
|
|
7
|
+
import { IChallengeConfig } from "./config";
|
|
8
|
+
import { LeaderboardRow } from "./LeaderboardRow";
|
|
9
|
+
import { OUTFIT } from "./constants";
|
|
10
|
+
import { useTDLeaderboard } from "../../pools/hooks";
|
|
11
|
+
|
|
12
|
+
const PAGE_SIZE = 50;
|
|
13
|
+
|
|
14
|
+
const SORT_TABS = [
|
|
15
|
+
{ key: "current_coins", label: "Score" },
|
|
16
|
+
{ key: "total_risked", label: "Risk" },
|
|
17
|
+
{ key: "max_possible", label: "Max" },
|
|
18
|
+
{ key: "questions_left", label: "Left" },
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
interface FullLeaderboardProps {
|
|
22
|
+
poolId: number;
|
|
23
|
+
config: IChallengeConfig;
|
|
24
|
+
onBack: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function FullLeaderboard({ poolId, config, onBack }: FullLeaderboardProps) {
|
|
28
|
+
const [sortBy, setSortBy] = useState<string>("current_coins");
|
|
29
|
+
const [page, setPage] = useState(0);
|
|
30
|
+
|
|
31
|
+
const { rankings, total, loading } = useTDLeaderboard(poolId, {
|
|
32
|
+
sort: sortBy,
|
|
33
|
+
limit: PAGE_SIZE,
|
|
34
|
+
offset: page * PAGE_SIZE,
|
|
35
|
+
pollMs: 15_000,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="w-full flex flex-col">
|
|
40
|
+
{/* Header */}
|
|
41
|
+
<div className="flex items-center gap-3 px-4 pt-4 pb-3" style={{ borderBottom: "1px solid rgba(255,255,255,0.06)" }}>
|
|
42
|
+
<button onClick={onBack} className="flex items-center justify-center w-7 h-7 rounded-lg" style={{ background: "rgba(255,255,255,0.06)" }}>
|
|
43
|
+
<ArrowLeft size={14} className="text-white/60" />
|
|
44
|
+
</button>
|
|
45
|
+
<div className="flex-1">
|
|
46
|
+
<p className="text-[14px] font-bold text-white" style={OUTFIT}>Leaderboard</p>
|
|
47
|
+
<p className="text-[10px] text-white/40" style={OUTFIT}>{total.toLocaleString()} players</p>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
{/* Sort tabs */}
|
|
52
|
+
<div className="flex gap-1 px-4 py-2" style={{ borderBottom: "1px solid rgba(255,255,255,0.06)" }}>
|
|
53
|
+
{SORT_TABS.map((tab) => (
|
|
54
|
+
<button
|
|
55
|
+
key={tab.key}
|
|
56
|
+
onClick={() => { setSortBy(tab.key); setPage(0); }}
|
|
57
|
+
className="flex-1 py-1.5 rounded-lg text-[10px] font-semibold transition-all"
|
|
58
|
+
style={{
|
|
59
|
+
...OUTFIT,
|
|
60
|
+
background: sortBy === tab.key ? "rgba(34,227,232,0.15)" : "rgba(255,255,255,0.04)",
|
|
61
|
+
color: sortBy === tab.key ? "#22E3E8" : "rgba(255,255,255,0.4)",
|
|
62
|
+
border: sortBy === tab.key ? "1px solid rgba(34,227,232,0.3)" : "1px solid transparent",
|
|
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>
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
{/* Pagination */}
|
|
108
|
+
<div className="flex items-center justify-center gap-3 px-4 py-3" style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }}>
|
|
109
|
+
<button
|
|
110
|
+
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
111
|
+
disabled={page === 0}
|
|
112
|
+
className="text-[10px] font-semibold px-3 py-1.5 rounded-lg disabled:opacity-30"
|
|
113
|
+
style={{ ...OUTFIT, background: "rgba(255,255,255,0.06)", color: "rgba(255,255,255,0.6)" }}
|
|
114
|
+
>
|
|
115
|
+
← Prev
|
|
116
|
+
</button>
|
|
117
|
+
<span className="text-[10px] text-white/40" style={OUTFIT}>
|
|
118
|
+
{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total}
|
|
119
|
+
</span>
|
|
120
|
+
<button
|
|
121
|
+
onClick={() => setPage((p) => p + 1)}
|
|
122
|
+
disabled={(page + 1) * PAGE_SIZE >= total}
|
|
123
|
+
className="text-[10px] font-semibold px-3 py-1.5 rounded-lg disabled:opacity-30"
|
|
124
|
+
style={{ ...OUTFIT, background: "rgba(255,255,255,0.06)", color: "rgba(255,255,255,0.6)" }}
|
|
125
|
+
>
|
|
126
|
+
Next →
|
|
127
|
+
</button>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{/* Payout brackets */}
|
|
131
|
+
{config.rankBrackets.length > 0 && (
|
|
132
|
+
<div className="px-4 pb-4">
|
|
133
|
+
<p className="text-[9px] text-white/30 font-semibold uppercase tracking-wider mb-1.5" style={OUTFIT}>Payout Brackets</p>
|
|
134
|
+
<div className="flex flex-wrap gap-1">
|
|
135
|
+
{config.rankBrackets.map((b, i) => (
|
|
136
|
+
<span
|
|
137
|
+
key={i}
|
|
138
|
+
className="text-[8px] font-medium px-2 py-0.5 rounded-full"
|
|
139
|
+
style={{ background: "rgba(255,255,255,0.04)", color: "rgba(255,255,255,0.4)", border: "1px solid rgba(255,255,255,0.08)" }}
|
|
140
|
+
>
|
|
141
|
+
#{b.from}{b.to > b.from ? `–${b.to}` : ""}: {(b.poolPercent * 100).toFixed(0)}%
|
|
142
|
+
</span>
|
|
143
|
+
))}
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -2,96 +2,326 @@
|
|
|
2
2
|
"use client";
|
|
3
3
|
|
|
4
4
|
import { useState, useCallback, useMemo } from "react";
|
|
5
|
+
import { Loader2 } from "lucide-react";
|
|
5
6
|
import { useGamePopupStore } from "../../core/gamePopupStore";
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
CSK_VS_RCB_CHALLENGE,
|
|
9
|
+
CSK_VS_RCB_POOL,
|
|
10
|
+
IUserBets,
|
|
11
|
+
calcBetSummary,
|
|
12
|
+
buildPoolLeaderboard,
|
|
13
|
+
ILeaderboardEntry,
|
|
14
|
+
} from "./config";
|
|
7
15
|
import { GamePopupShell } from "../../core/GamePopupShell";
|
|
8
16
|
import { PreMatchIntro } from "./PreMatchIntro";
|
|
9
17
|
import { PreMatchQuestions } from "./PreMatchQuestions";
|
|
10
18
|
import { PreMatchGame } from "./PreMatchGame";
|
|
19
|
+
import { PreMatchSubmitted } from "./PreMatchSubmitted";
|
|
20
|
+
import { PreMatchLive } from "./PreMatchLive";
|
|
21
|
+
import { PreMatchResults } from "./PreMatchResults";
|
|
22
|
+
import { FullLeaderboard } from "./FullLeaderboard";
|
|
23
|
+
import { useTDPool, useTDPoolEntry, useTDLeaderboard } from "../../pools/hooks";
|
|
24
|
+
import { useTDMatches } from "../../matches/useTDMatches";
|
|
25
|
+
import { buildPMBConfig } from "../../pools/mapper";
|
|
26
|
+
import { joinTDPool, placeTDBets } from "../../pools/actions";
|
|
27
|
+
import type { ITDLeaderboardEntry } from "../../pools/types";
|
|
28
|
+
import { calcRankPayout } from "./config";
|
|
11
29
|
|
|
12
30
|
const GAME_ID = "pre-match";
|
|
13
|
-
const config = CSK_VS_RCB_CHALLENGE;
|
|
14
|
-
const pool = CSK_VS_RCB_POOL;
|
|
15
31
|
|
|
16
|
-
|
|
17
|
-
const { activeView } = useGamePopupStore();
|
|
32
|
+
// ─── View logic ────────────────────────────────────────────────────────────────
|
|
18
33
|
|
|
19
|
-
|
|
20
|
-
|
|
34
|
+
type UserFlowState = "intro" | "questions" | "game";
|
|
35
|
+
type ActiveView = UserFlowState | "submitted" | "live" | "results";
|
|
21
36
|
|
|
22
|
-
|
|
23
|
-
|
|
37
|
+
function getActiveView(
|
|
38
|
+
poolStatus: string | undefined,
|
|
39
|
+
hasEntry: boolean,
|
|
40
|
+
userFlowState: UserFlowState,
|
|
41
|
+
): ActiveView {
|
|
42
|
+
if (!poolStatus) return userFlowState;
|
|
43
|
+
if (poolStatus === "complete" || poolStatus === "cancelled") return "results";
|
|
44
|
+
if (poolStatus === "closed" || poolStatus === "resolving") return hasEntry ? "live" : "results";
|
|
45
|
+
// Pool is open
|
|
46
|
+
if (hasEntry) return "submitted";
|
|
47
|
+
return userFlowState;
|
|
48
|
+
}
|
|
24
49
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
50
|
+
// Build a mini leaderboard from real API data
|
|
51
|
+
function buildMiniLeaderboard(
|
|
52
|
+
rankings: ITDLeaderboardEntry[],
|
|
53
|
+
userEntryId: number | null,
|
|
54
|
+
): ILeaderboardEntry[] {
|
|
55
|
+
const rows: ILeaderboardEntry[] = [];
|
|
56
|
+
const top = rankings.slice(0, 3);
|
|
57
|
+
top.forEach((r) => {
|
|
58
|
+
rows.push({
|
|
59
|
+
wallet: r.partner_ext_id ?? `User #${r.user_id}`,
|
|
60
|
+
pts: r.current_coins,
|
|
61
|
+
payout: 0,
|
|
62
|
+
isYou: r.entry_id === userEntryId,
|
|
63
|
+
rank: r.rank,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
if (userEntryId !== null) {
|
|
67
|
+
const userIdx = rankings.findIndex((r) => r.entry_id === userEntryId);
|
|
68
|
+
if (userIdx >= 3) {
|
|
69
|
+
rows.push({ wallet: "", pts: 0, payout: 0, isYou: false, gapAbove: true });
|
|
70
|
+
if (userIdx > 3) {
|
|
71
|
+
const above = rankings[userIdx - 1];
|
|
72
|
+
rows.push({ wallet: above.partner_ext_id ?? `User #${above.user_id}`, pts: above.current_coins, payout: 0, isYou: false, rank: userIdx });
|
|
73
|
+
}
|
|
74
|
+
const me = rankings[userIdx];
|
|
75
|
+
rows.push({ wallet: me.partner_ext_id ?? "You", pts: me.current_coins, payout: 0, isYou: true, rank: userIdx + 1 });
|
|
76
|
+
if (userIdx < rankings.length - 1) {
|
|
77
|
+
const below = rankings[userIdx + 1];
|
|
78
|
+
rows.push({ wallet: below.partner_ext_id ?? `User #${below.user_id}`, pts: below.current_coins, payout: 0, isYou: false, rank: userIdx + 2 });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return rows;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── Props ────────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
interface PreMatchBetsPopupProps {
|
|
88
|
+
/** When provided, fetches real data. When absent, uses hardcoded CSK_VS_RCB demo. */
|
|
89
|
+
poolId?: number;
|
|
90
|
+
matchId?: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── Component ───────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
export const PreMatchBetsPopup = ({ poolId, matchId }: PreMatchBetsPopupProps) => {
|
|
96
|
+
const { activeView: storeView } = useGamePopupStore();
|
|
97
|
+
|
|
98
|
+
// ── Real API data (only when poolId provided) ────────────────────────────
|
|
99
|
+
const { pool, loading: poolLoading, refetch: refetchPool } = useTDPool(poolId ?? 0);
|
|
100
|
+
const { data: entryData, refetch: refetchEntry } = useTDPoolEntry(poolId ?? 0);
|
|
101
|
+
const { rankings, refetch: refetchLB } = useTDLeaderboard(poolId ?? 0, { pollMs: poolId ? 30_000 : 0 });
|
|
102
|
+
const { matches } = useTDMatches(matchId !== undefined ? { tournamentId: undefined } : undefined);
|
|
103
|
+
const match = useMemo(() => matches.find((m) => m.id === matchId) ?? null, [matches, matchId]);
|
|
104
|
+
|
|
105
|
+
// ── Config: real or fallback ─────────────────────────────────────────────
|
|
106
|
+
const config = useMemo(() => {
|
|
107
|
+
if (poolId && pool && match) {
|
|
108
|
+
try { return buildPMBConfig(pool, match); } catch { /* fall through */ }
|
|
109
|
+
}
|
|
110
|
+
return poolId ? null : CSK_VS_RCB_CHALLENGE;
|
|
111
|
+
}, [poolId, pool, match]);
|
|
112
|
+
|
|
113
|
+
const fallbackPool = poolId ? null : CSK_VS_RCB_POOL;
|
|
114
|
+
|
|
115
|
+
// ── Bet state ─────────────────────────────────────────────────────────────
|
|
116
|
+
const [bets, setBets] = useState<IUserBets>({});
|
|
117
|
+
const [expandedPicker, setExpandedPicker] = useState<Record<number, number>>({});
|
|
118
|
+
const [userFlowState, setUserFlowState] = useState<UserFlowState>("intro");
|
|
119
|
+
const [showFullLeaderboard, setShowFullLeaderboard] = useState(false);
|
|
120
|
+
const [submitting, setSubmitting] = useState(false);
|
|
121
|
+
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
122
|
+
|
|
123
|
+
const betSummary = useMemo(
|
|
124
|
+
() => config ? calcBetSummary(bets, config) : { selectedCount: 0, compoundMultiplier: 0, totalEntry: 0, compoundedReward: 0, remainingBalance: 0, riskPercent: 0, potentialBalance: 0, prizes: [] },
|
|
125
|
+
[bets, config],
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const leaderboardRows = useMemo(() => {
|
|
129
|
+
if (poolId && rankings.length > 0) {
|
|
130
|
+
return buildMiniLeaderboard(rankings, entryData?.entry.id ?? null);
|
|
131
|
+
}
|
|
132
|
+
if (fallbackPool && config) {
|
|
133
|
+
return buildPoolLeaderboard(fallbackPool, betSummary.potentialBalance, config).rows;
|
|
134
|
+
}
|
|
135
|
+
return [];
|
|
136
|
+
}, [poolId, rankings, entryData, fallbackPool, config, betSummary.potentialBalance]);
|
|
137
|
+
|
|
138
|
+
// ── View routing ──────────────────────────────────────────────────────────
|
|
139
|
+
const hasEntry = !!entryData?.entry;
|
|
140
|
+
const poolStatus = poolId ? pool?.status : undefined;
|
|
141
|
+
const activeView: ActiveView = poolId
|
|
142
|
+
? getActiveView(poolStatus, hasEntry, userFlowState)
|
|
143
|
+
: (storeView as UserFlowState) || "intro";
|
|
144
|
+
|
|
145
|
+
// ── Handlers ─────────────────────────────────────────────────────────────
|
|
146
|
+
const handleQuestionsComplete = useCallback((selections: IUserBets) => {
|
|
147
|
+
setBets(selections);
|
|
148
|
+
const pickers: Record<number, number> = {};
|
|
149
|
+
for (const [mIdxStr, entry] of Object.entries(selections)) {
|
|
150
|
+
pickers[Number(mIdxStr)] = entry.optionIdx;
|
|
151
|
+
}
|
|
152
|
+
setExpandedPicker(pickers);
|
|
153
|
+
}, []);
|
|
154
|
+
|
|
155
|
+
const handleOptionClick = useCallback((mIdx: number, oIdx: number) => {
|
|
156
|
+
setBets((prev) => {
|
|
157
|
+
const existing = prev[mIdx];
|
|
158
|
+
if (existing?.optionIdx === oIdx) {
|
|
159
|
+
const next = { ...prev };
|
|
160
|
+
delete next[mIdx];
|
|
161
|
+
setExpandedPicker((p) => { const n = { ...p }; delete n[mIdx]; return n; });
|
|
162
|
+
return next;
|
|
163
|
+
}
|
|
164
|
+
setExpandedPicker((p) => ({ ...p, [mIdx]: oIdx }));
|
|
165
|
+
return {
|
|
166
|
+
...prev,
|
|
167
|
+
[mIdx]: { optionIdx: oIdx, amount: existing?.amount ?? 0, parlaySlot: existing?.parlaySlot ?? null },
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
}, []);
|
|
171
|
+
|
|
172
|
+
const handleAmountSelect = useCallback((mIdx: number, oIdx: number, amount: number) => {
|
|
173
|
+
setBets((prev) => ({
|
|
174
|
+
...prev,
|
|
175
|
+
[mIdx]: { optionIdx: oIdx, amount, parlaySlot: prev[mIdx]?.parlaySlot ?? null },
|
|
176
|
+
}));
|
|
177
|
+
}, []);
|
|
178
|
+
|
|
179
|
+
const handleSubmitBets = useCallback(async () => {
|
|
180
|
+
if (!poolId || !config) return;
|
|
181
|
+
setSubmitting(true);
|
|
182
|
+
setSubmitError(null);
|
|
183
|
+
try {
|
|
184
|
+
// Build bets payload — only individual bets with amount > 0
|
|
185
|
+
const betsToSubmit = Object.entries(bets)
|
|
186
|
+
.filter(([, b]) => b.amount > 0 && b.parlaySlot === null)
|
|
187
|
+
.map(([mIdxStr, b]) => {
|
|
188
|
+
const market = config.markets[Number(mIdxStr)];
|
|
189
|
+
return {
|
|
190
|
+
challenge_id: market.backendChallengeId!,
|
|
191
|
+
selected_option: market.options[b.optionIdx].label.toLowerCase().replace(/\s+/g, "_"),
|
|
192
|
+
coin_amount: b.amount,
|
|
193
|
+
};
|
|
194
|
+
})
|
|
195
|
+
.filter((b) => b.challenge_id !== undefined);
|
|
31
196
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
197
|
+
if (betsToSubmit.length === 0) return;
|
|
198
|
+
|
|
199
|
+
// Join pool if not already in
|
|
200
|
+
if (!hasEntry) {
|
|
201
|
+
await joinTDPool(poolId);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
await placeTDBets(poolId, betsToSubmit);
|
|
205
|
+
|
|
206
|
+
// Refresh data
|
|
207
|
+
await Promise.all([refetchEntry(), refetchLB(), refetchPool()]);
|
|
208
|
+
} catch (err: unknown) {
|
|
209
|
+
setSubmitError(err instanceof Error ? err.message : "Failed to submit bets");
|
|
210
|
+
} finally {
|
|
211
|
+
setSubmitting(false);
|
|
212
|
+
}
|
|
213
|
+
}, [poolId, config, bets, hasEntry, refetchEntry, refetchLB, refetchPool]);
|
|
214
|
+
|
|
215
|
+
const handleAdjust = useCallback(async () => {
|
|
216
|
+
// Load server bets back into local state
|
|
217
|
+
if (entryData?.bets && config) {
|
|
218
|
+
const loaded: IUserBets = {};
|
|
219
|
+
for (const bet of entryData.bets) {
|
|
220
|
+
if (bet.parlay_id !== null) continue; // skip parlays for now
|
|
221
|
+
const mIdx = config.markets.findIndex((m) => m.backendChallengeId === bet.challenge_id);
|
|
222
|
+
if (mIdx >= 0) {
|
|
223
|
+
// Find option by matching the stored key to a label approximation
|
|
224
|
+
// (we store selected_option as the label key from the pool option)
|
|
225
|
+
const oIdx = config.markets[mIdx].options.findIndex((_, i) => i === 0); // fallback: first option
|
|
226
|
+
loaded[mIdx] = { optionIdx: oIdx, amount: bet.coin_amount, parlaySlot: null };
|
|
39
227
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
[mIdx]: { optionIdx: oIdx, amount: existing?.amount ?? 0, parlaySlot: existing?.parlaySlot ?? null },
|
|
59
|
-
};
|
|
60
|
-
});
|
|
61
|
-
}, []);
|
|
62
|
-
|
|
63
|
-
// Amount select from chip picker
|
|
64
|
-
const handleAmountSelect = useCallback((mIdx: number, oIdx: number, amount: number) => {
|
|
65
|
-
setBets(prev => ({
|
|
66
|
-
...prev,
|
|
67
|
-
[mIdx]: { optionIdx: oIdx, amount, parlaySlot: prev[mIdx]?.parlaySlot ?? null },
|
|
68
|
-
}));
|
|
69
|
-
}, []);
|
|
228
|
+
}
|
|
229
|
+
setBets(loaded);
|
|
230
|
+
}
|
|
231
|
+
setUserFlowState("game");
|
|
232
|
+
}, [entryData, config]);
|
|
233
|
+
|
|
234
|
+
// ── Loading ────────────────────────────────────────────────────────────────
|
|
235
|
+
if (poolId && (poolLoading || (!config && !poolLoading))) {
|
|
236
|
+
return (
|
|
237
|
+
<GamePopupShell gameId={GAME_ID} title="Pre-Match Bets" hideHeaderViews={["intro"]}>
|
|
238
|
+
<div className="flex-1 flex items-center justify-center py-20">
|
|
239
|
+
<Loader2 size={24} className="animate-spin" style={{ color: "rgba(255,255,255,0.3)" }} />
|
|
240
|
+
</div>
|
|
241
|
+
</GamePopupShell>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!config) return null;
|
|
70
246
|
|
|
247
|
+
// ── Full leaderboard overlay ───────────────────────────────────────────────
|
|
248
|
+
if (showFullLeaderboard) {
|
|
71
249
|
return (
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
)}
|
|
80
|
-
{activeView === "questions" && (
|
|
81
|
-
<PreMatchQuestions config={config} onComplete={handleQuestionsComplete} />
|
|
82
|
-
)}
|
|
83
|
-
{activeView === "game" && (
|
|
84
|
-
<PreMatchGame
|
|
85
|
-
config={config}
|
|
86
|
-
bets={bets}
|
|
87
|
-
onBetsChange={setBets}
|
|
88
|
-
expandedPicker={expandedPicker}
|
|
89
|
-
onOptionClick={handleOptionClick}
|
|
90
|
-
onAmountSelect={handleAmountSelect}
|
|
91
|
-
betSummary={betSummary}
|
|
92
|
-
leaderboardRows={leaderboard.rows}
|
|
93
|
-
/>
|
|
94
|
-
)}
|
|
95
|
-
</GamePopupShell>
|
|
250
|
+
<GamePopupShell gameId={GAME_ID} title="Leaderboard" hideHeaderViews={[]} >
|
|
251
|
+
<FullLeaderboard
|
|
252
|
+
poolId={poolId ?? 0}
|
|
253
|
+
config={config}
|
|
254
|
+
onBack={() => setShowFullLeaderboard(false)}
|
|
255
|
+
/>
|
|
256
|
+
</GamePopupShell>
|
|
96
257
|
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<GamePopupShell gameId={GAME_ID} title="Pre-Match Bets" hideHeaderViews={["intro"]}>
|
|
262
|
+
{submitError && (
|
|
263
|
+
<div className="mx-4 mt-2 px-3 py-2 rounded-lg text-[11px] font-medium" style={{ background: "rgba(239,68,68,0.15)", color: "#f87171" }}>
|
|
264
|
+
{submitError}
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
|
|
268
|
+
{activeView === "intro" && (
|
|
269
|
+
<PreMatchIntro config={config} />
|
|
270
|
+
)}
|
|
271
|
+
|
|
272
|
+
{activeView === "questions" && (
|
|
273
|
+
<PreMatchQuestions config={config} onComplete={handleQuestionsComplete} />
|
|
274
|
+
)}
|
|
275
|
+
|
|
276
|
+
{activeView === "game" && (
|
|
277
|
+
<PreMatchGame
|
|
278
|
+
config={config}
|
|
279
|
+
bets={bets}
|
|
280
|
+
onBetsChange={setBets}
|
|
281
|
+
expandedPicker={expandedPicker}
|
|
282
|
+
onOptionClick={handleOptionClick}
|
|
283
|
+
onAmountSelect={handleAmountSelect}
|
|
284
|
+
betSummary={betSummary}
|
|
285
|
+
leaderboardRows={leaderboardRows}
|
|
286
|
+
onSubmit={poolId ? handleSubmitBets : undefined}
|
|
287
|
+
onViewLeaderboard={poolId ? () => setShowFullLeaderboard(true) : undefined}
|
|
288
|
+
submitting={submitting}
|
|
289
|
+
/>
|
|
290
|
+
)}
|
|
291
|
+
|
|
292
|
+
{activeView === "submitted" && (
|
|
293
|
+
<PreMatchSubmitted
|
|
294
|
+
config={config}
|
|
295
|
+
poolId={poolId!}
|
|
296
|
+
entryData={entryData}
|
|
297
|
+
rankings={rankings}
|
|
298
|
+
onAdjust={handleAdjust}
|
|
299
|
+
onViewLeaderboard={() => setShowFullLeaderboard(true)}
|
|
300
|
+
/>
|
|
301
|
+
)}
|
|
302
|
+
|
|
303
|
+
{activeView === "live" && (
|
|
304
|
+
<PreMatchLive
|
|
305
|
+
config={config}
|
|
306
|
+
poolId={poolId!}
|
|
307
|
+
pool={pool}
|
|
308
|
+
entryData={entryData}
|
|
309
|
+
rankings={rankings}
|
|
310
|
+
onRefresh={() => { refetchPool(); refetchEntry(); refetchLB(); }}
|
|
311
|
+
onViewFullLeaderboard={() => setShowFullLeaderboard(true)}
|
|
312
|
+
/>
|
|
313
|
+
)}
|
|
314
|
+
|
|
315
|
+
{activeView === "results" && (
|
|
316
|
+
<PreMatchResults
|
|
317
|
+
config={config}
|
|
318
|
+
poolId={poolId ?? 0}
|
|
319
|
+
pool={pool ?? undefined}
|
|
320
|
+
entryData={entryData}
|
|
321
|
+
rankings={rankings}
|
|
322
|
+
onViewFullLeaderboard={() => setShowFullLeaderboard(true)}
|
|
323
|
+
/>
|
|
324
|
+
)}
|
|
325
|
+
</GamePopupShell>
|
|
326
|
+
);
|
|
97
327
|
};
|