@devrongx/games 0.3.5 → 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 +55 -7
- package/src/games/prematch-bets/PreMatchLive.tsx +192 -0
- 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/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
|
};
|
|
@@ -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
|
</>
|