@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devrongx/games",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "description": "Game UI components for sports prediction markets",
5
5
  "license": "MIT",
6
6
  "main": "./src/index.ts",
@@ -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 { CSK_VS_RCB_CHALLENGE, CSK_VS_RCB_POOL, IUserBets, calcBetSummary, buildPoolLeaderboard } from "./config";
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
- export const PreMatchBetsPopup = () => {
17
- const { activeView } = useGamePopupStore();
32
+ // ─── View logic ────────────────────────────────────────────────────────────────
18
33
 
19
- // Unified bet state
20
- const [bets, setBets] = useState<IUserBets>({});
34
+ type UserFlowState = "intro" | "questions" | "game";
35
+ type ActiveView = UserFlowState | "submitted" | "live" | "results";
21
36
 
22
- // Picker state: which market has its amount picker open, and for which option
23
- const [expandedPicker, setExpandedPicker] = useState<Record<number, number>>({});
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
- // Compute summary and leaderboard from bets
26
- const betSummary = useMemo(() => calcBetSummary(bets, config), [bets]);
27
- const leaderboard = useMemo(
28
- () => buildPoolLeaderboard(pool, betSummary.potentialBalance, config),
29
- [betSummary.potentialBalance],
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
- // Questions completion callback
33
- const handleQuestionsComplete = useCallback((selections: IUserBets) => {
34
- setBets(selections);
35
- // Open pickers for all selected markets
36
- const pickers: Record<number, number> = {};
37
- for (const [mIdxStr, entry] of Object.entries(selections)) {
38
- pickers[Number(mIdxStr)] = entry.optionIdx;
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
- setExpandedPicker(pickers);
41
- }, []);
42
-
43
- // Option click: toggle selection + open/close picker
44
- const handleOptionClick = useCallback((mIdx: number, oIdx: number) => {
45
- setBets(prev => {
46
- const existing = prev[mIdx];
47
- if (existing?.optionIdx === oIdx) {
48
- // Deselect
49
- const next = { ...prev };
50
- delete next[mIdx];
51
- setExpandedPicker(p => { const n = { ...p }; delete n[mIdx]; return n; });
52
- return next;
53
- }
54
- // Select new option — open picker
55
- setExpandedPicker(p => ({ ...p, [mIdx]: oIdx }));
56
- return {
57
- ...prev,
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
- <GamePopupShell
73
- gameId={GAME_ID}
74
- title="Pre-Match Bets"
75
- hideHeaderViews={["intro"]}
76
- >
77
- {activeView === "intro" && (
78
- <PreMatchIntro config={config} />
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 — play button stretches to match both lines, hidden until points are bet */}
582
+ {/* Right — action button, hidden until points are bet */}
575
583
  <AnimatePresence>
576
584
  {totalEntry > 0 && (() => {
577
585
  const spentPercent = totalEntry / config.startingBalance;
578
586
  const fillPercent = 5 + spentPercent * 95;
587
+
588
+ if (onSubmit) {
589
+ return (
590
+ <motion.button
591
+ onClick={onSubmit}
592
+ disabled={submitting}
593
+ className="relative rounded-xl overflow-hidden flex-shrink-0 flex items-center justify-center gap-1.5 px-3"
594
+ style={{ background: submitting ? "rgba(34,227,232,0.3)" : "linear-gradient(135deg, #22E3E8, #9945FF, #f83cc5)", minWidth: 90, height: "100%" }}
595
+ initial={{ opacity: 0, width: 0 }}
596
+ animate={{ opacity: 1, width: "auto" }}
597
+ exit={{ opacity: 0, width: 0 }}
598
+ transition={{ width: { duration: 0.4, ease: "easeOut", delay: 0.8 }, opacity: { duration: 0.3, delay: 1 } }}
599
+ >
600
+ {submitting ? (
601
+ <span className="text-[10px] font-bold text-white/70" style={OUTFIT}>Submitting…</span>
602
+ ) : (
603
+ <>
604
+ <Send size={14} className="text-white" />
605
+ <span className="text-[11px] font-bold text-white" style={OUTFIT}>Submit Bets</span>
606
+ </>
607
+ )}
608
+ </motion.button>
609
+ );
610
+ }
611
+
579
612
  return (
580
613
  <motion.div
581
614
  className="relative w-[65px] rounded-xl overflow-hidden flex-shrink-0 flex items-center justify-center"
@@ -585,7 +618,6 @@ export const PreMatchGame = ({ config, bets, onBetsChange, expandedPicker, onOpt
585
618
  exit={{ opacity: 0, width: 0 }}
586
619
  transition={{ width: { duration: 0.4, ease: "easeOut", delay: 0.8 }, opacity: { duration: 0.3, delay: 1 } }}
587
620
  >
588
- {/* Fill bar — spring-animated left to right */}
589
621
  <motion.div
590
622
  className="absolute inset-y-0 left-0"
591
623
  animate={{ width: `${fillPercent}%` }}
@@ -652,9 +684,16 @@ export const PreMatchGame = ({ config, bets, onBetsChange, expandedPicker, onOpt
652
684
 
653
685
  {/* Combined Bets (Parlays) */}
654
686
  <div className="mt-4">
655
- <p className="text-[9px] text-white/60 font-semibold uppercase tracking-wider mb-1" style={OUTFIT}>
656
- Combine for crazy multipliers
657
- </p>
687
+ <div className="flex items-center gap-2 mb-1">
688
+ <p className="text-[9px] text-white/60 font-semibold uppercase tracking-wider" style={OUTFIT}>
689
+ Combine for crazy multipliers
690
+ </p>
691
+ {!parlayEnabled && (
692
+ <span className="text-[7px] font-bold px-1.5 py-0.5 rounded-full" style={{ background: "rgba(153,69,255,0.15)", color: "#9945FF", border: "1px solid rgba(153,69,255,0.3)" }}>
693
+ Coming in v2
694
+ </span>
695
+ )}
696
+ </div>
658
697
  <p className="text-[7px] text-white/30 font-medium mb-2.5" style={OUTFIT}>
659
698
  All must hit to win. You lose all if any one goes wrong.
660
699
  </p>
@@ -828,6 +867,15 @@ export const PreMatchGame = ({ config, bets, onBetsChange, expandedPicker, onOpt
828
867
  </div>
829
868
  ))}
830
869
  </div>
870
+ {onViewLeaderboard && leaderboardRows.length > 0 && (
871
+ <button
872
+ onClick={onViewLeaderboard}
873
+ className="mt-2 w-full text-center text-[9px] font-semibold"
874
+ style={{ ...OUTFIT, color: "rgba(34,227,232,0.6)" }}
875
+ >
876
+ See Full Leaderboard →
877
+ </button>
878
+ )}
831
879
  </div>
832
880
 
833
881
  </>