@devrongx/games 0.4.5 → 0.4.7

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.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "Game UI components for sports prediction markets",
5
5
  "license": "MIT",
6
6
  "main": "./src/index.ts",
@@ -1,7 +1,7 @@
1
1
  // @devrongx/games — games/prematch-bets/PreMatchBetsPopup.tsx
2
2
  "use client";
3
3
 
4
- import { useState, useCallback, useMemo } from "react";
4
+ import { useState, useCallback, useMemo, useEffect, useRef } from "react";
5
5
  import { Loader2 } from "lucide-react";
6
6
  import { useGamePopupStore } from "../../core/gamePopupStore";
7
7
  import {
@@ -24,6 +24,7 @@ import { useTDPool, useTDPoolEntry, useTDLeaderboard } from "../../pools/hooks";
24
24
  import { buildPMBConfig } from "../../pools/mapper";
25
25
  import type { ITDMatch } from "../../matches/types";
26
26
  import { joinTDPool, placeTDBets } from "../../pools/actions";
27
+ import { saveTDDraftBetsApi } from "../../pools/fetcher";
27
28
  import type { ITDLeaderboardEntry } from "../../pools/types";
28
29
  import { TDPoolStatus } from "../../pools/types";
29
30
  import { calcRankPayout } from "./config";
@@ -122,6 +123,60 @@ export const PreMatchBetsPopup = ({ poolId, matchId: _matchId, match: matchProp
122
123
  const [submitting, setSubmitting] = useState(false);
123
124
  const [submitError, setSubmitError] = useState<string | null>(null);
124
125
 
126
+ // ── Draft restore: when entry + config load, seed bets from draft_selections ─
127
+ const draftRestoredRef = useRef(false);
128
+ useEffect(() => {
129
+ if (!poolId || !config || !entryData || draftRestoredRef.current) return;
130
+ const draft = entryData.entry.draft_selections;
131
+ if (!draft || draft.length === 0) return;
132
+ if (entryData.bets.length > 0) return; // real bets already placed — don't overwrite
133
+ const restored: IUserBets = {};
134
+ for (const d of draft) {
135
+ const mIdx = config.markets.findIndex((m) => m.backendChallengeId === d.challenge_id);
136
+ if (mIdx < 0) continue;
137
+ const optionIdx = config.markets[mIdx].options.findIndex(
138
+ (o) => o.label.toLowerCase().replace(/\s+/g, "_") === d.selected_option,
139
+ );
140
+ if (optionIdx < 0) continue;
141
+ restored[mIdx] = { optionIdx, amount: d.coin_amount, parlaySlot: null };
142
+ }
143
+ if (Object.keys(restored).length > 0) {
144
+ setBets(restored);
145
+ setUserFlowState("game");
146
+ }
147
+ draftRestoredRef.current = true;
148
+ }, [poolId, config, entryData]);
149
+
150
+ // ── Debounced draft save: persists bets to backend 1s after last change ───
151
+ const saveDraftTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
152
+ useEffect(() => {
153
+ if (!poolId || Object.keys(bets).length === 0 || !config) return;
154
+ if (saveDraftTimerRef.current) clearTimeout(saveDraftTimerRef.current);
155
+ saveDraftTimerRef.current = setTimeout(async () => {
156
+ try {
157
+ const selections = config.markets
158
+ .map((m, mIdx) => {
159
+ const b = bets[mIdx];
160
+ if (!b || !m.backendChallengeId) return null;
161
+ const option = m.options[b.optionIdx];
162
+ if (!option) return null;
163
+ return {
164
+ challenge_id: m.backendChallengeId,
165
+ selected_option: option.label.toLowerCase().replace(/\s+/g, "_"),
166
+ coin_amount: b.amount,
167
+ };
168
+ })
169
+ .filter((b): b is NonNullable<typeof b> => b !== null);
170
+ if (selections.length > 0) {
171
+ await saveTDDraftBetsApi(poolId, selections);
172
+ }
173
+ } catch {
174
+ // Silent — draft save is best-effort
175
+ }
176
+ }, 1000);
177
+ return () => { if (saveDraftTimerRef.current) clearTimeout(saveDraftTimerRef.current); };
178
+ }, [bets, poolId, config]);
179
+
125
180
  const betSummary = useMemo(
126
181
  () => config ? calcBetSummary(bets, config) : { selectedCount: 0, compoundMultiplier: 0, totalEntry: 0, baseReward: 0, compoundedReward: 0, remainingBalance: 0, riskPercent: 0, potentialBalance: 0 },
127
182
  [bets, config],
@@ -152,6 +207,7 @@ export const PreMatchBetsPopup = ({ poolId, matchId: _matchId, match: matchProp
152
207
  pickers[Number(mIdxStr)] = entry.optionIdx;
153
208
  }
154
209
  setExpandedPicker(pickers);
210
+ setUserFlowState("game");
155
211
  }, []);
156
212
 
157
213
  const handleOptionClick = useCallback((mIdx: number, oIdx: number) => {
@@ -268,7 +324,7 @@ export const PreMatchBetsPopup = ({ poolId, matchId: _matchId, match: matchProp
268
324
  )}
269
325
 
270
326
  {activeView === "intro" && (
271
- <PreMatchIntro config={config} />
327
+ <PreMatchIntro config={config} onComplete={() => setUserFlowState("questions")} />
272
328
  )}
273
329
 
274
330
  {activeView === "questions" && (
@@ -1,16 +1,13 @@
1
1
  // @devrongx/games — games/prematch-bets/PreMatchIntro.tsx
2
2
  "use client";
3
3
 
4
- import { useState, useEffect, useCallback, useRef } from "react";
4
+ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
5
5
  import Image from "next/image";
6
6
  import { motion, AnimatePresence } from "framer-motion";
7
- import { Coins, Trophy, Play, Pause, Rewind, FastForward, RotateCcw } from "lucide-react";
8
- import { useGamePopupStore } from "../../core/gamePopupStore";
7
+ import { Coins, Trophy, Pause, Rewind, FastForward, RotateCcw } from "lucide-react";
9
8
  import { IChallengeConfig } from "./config";
10
9
 
11
10
  const OUTFIT = { fontFamily: "Outfit, sans-serif" };
12
- const TOTAL_SLIDES = 6;
13
- const SLIDE_DURATIONS = [5500, 5000, 5500, 7000, 5500, 5500];
14
11
 
15
12
  // Countdown hook for match start
16
13
  const useCountdown = (targetIso: string) => {
@@ -34,8 +31,8 @@ const useCountdown = (targetIso: string) => {
34
31
  return text;
35
32
  };
36
33
 
37
- // Slide props — all slides receive config
38
- interface SlideProps { config: IChallengeConfig }
34
+ // Slide props — all slides receive config + onComplete
35
+ interface SlideProps { config: IChallengeConfig; onComplete: () => void }
39
36
 
40
37
  // ── Slide 0: Logo reveal + match info + countdown ──
41
38
  const LogoSlide = ({ config }: SlideProps) => {
@@ -61,7 +58,7 @@ const LogoSlide = ({ config }: SlideProps) => {
61
58
  >
62
59
  <svg width="44" height="44" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
63
60
  <g clipPath="url(#clip_intro_logo)">
64
- <path d="M14.0024 12.5249C19.1884 12.5249 22.4314 13.6319 23.4234 14.4939C20.9354 15.1919 17.6354 15.6219 14.0024 15.6219C10.3694 15.6219 7.06937 15.1919 4.58137 14.4939C5.57337 13.6319 8.81637 12.5249 14.0024 12.5249ZM20.0574 3.43286C20.1079 3.17257 20.2598 2.94303 20.4796 2.79469C20.6994 2.64636 20.9691 2.59139 21.2294 2.64186L25.1564 3.40586C25.4125 3.46068 25.6368 3.61378 25.7813 3.83225C25.9257 4.05073 25.9786 4.31714 25.9287 4.57424C25.8788 4.83134 25.7301 5.0586 25.5144 5.20719C25.2987 5.35577 25.0334 5.41382 24.7754 5.36886L24.0024 5.21886V6.75386C23.3684 6.56786 22.7024 6.40086 22.0024 6.25686V4.82986L20.8484 4.60586C20.588 4.55506 20.3584 4.40298 20.2101 4.18304C20.0618 3.9631 20.0069 3.69328 20.0574 3.43286ZM11.0024 3.00586C11.0024 2.74064 11.1077 2.48629 11.2953 2.29875C11.4828 2.11122 11.7371 2.00586 12.0024 2.00586H16.0024C16.2676 2.00586 16.5219 2.11122 16.7095 2.29875C16.897 2.48629 17.0024 2.74064 17.0024 3.00586C17.0024 3.27108 16.897 3.52543 16.7095 3.71297C16.5219 3.9005 16.2676 4.00586 16.0024 4.00586H15.0024V5.52186C14.3358 5.50186 13.6689 5.50186 13.0024 5.52186V4.00586H12.0024C11.7371 4.00586 11.4828 3.9005 11.2953 3.71297C11.1077 3.52543 11.0024 3.27108 11.0024 3.00586ZM2.05737 4.57886C2.00687 4.31843 2.06182 4.04862 2.21014 3.82868C2.35845 3.60874 2.588 3.45666 2.84837 3.40586L6.77537 2.64186C6.90432 2.61684 7.03695 2.61747 7.16566 2.64371C7.29438 2.66995 7.41666 2.72128 7.52554 2.79478C7.63442 2.86827 7.72775 2.9625 7.80021 3.07207C7.87268 3.18163 7.92285 3.3044 7.94787 3.43336C7.97288 3.56232 7.97225 3.69494 7.94602 3.82366C7.91978 3.95237 7.86845 4.07466 7.79495 4.18353C7.72145 4.29241 7.62723 4.38575 7.51766 4.45821C7.40809 4.53067 7.28532 4.58084 7.15637 4.60586L6.00237 4.82986V6.25686C5.30237 6.39986 4.63637 6.56786 4.00237 6.75386V5.21886L3.23037 5.36886C3.10147 5.39401 2.96889 5.39352 2.84018 5.36743C2.71148 5.34134 2.58917 5.29016 2.48025 5.2168C2.37133 5.14344 2.27792 5.04935 2.20536 4.93989C2.1328 4.83043 2.08251 4.70775 2.05737 4.57886Z" fill="url(#paint0_intro_logo)" />
61
+ <path d="M14.0024 12.5249C19.1884 12.5249 22.4314 13.6319 23.4234 14.4939C20.9354 15.1919 17.6354 15.6219 14.0024 15.6219C10.3694 15.6219 7.06937 15.1919 4.58137 14.4939C5.57337 13.6319 8.81637 12.5249 14.0024 12.5249ZM20.0574 3.43286C20.1079 3.17257 20.2598 2.94303 20.4796 2.79469C20.6994 2.64636 20.9691 2.59139 21.2294 2.64186L25.1564 3.40586C25.4125 3.46068 25.6368 3.61378 25.7813 3.83225C25.9257 4.05073 25.9786 4.31714 25.9287 4.57424C25.8788 4.83134 25.7301 5.0586 25.5144 5.20719C25.2987 5.35577 25.0334 5.41382 24.7754 5.36886L24.0024 5.21886V6.75386C23.3684 6.56786 22.7024 6.40086 22.0024 6.25686V4.82986L20.8484 4.60586C20.588 4.55506 20.3584 4.40298 20.2101 4.18304C20.0618 3.9631 20.0069 3.69328 20.0574 3.43286ZM11.0024 3.00586C11.0024 2.74064 11.1077 2.48629 11.2953 2.29875C11.4828 2.11122 11.7371 2.00586 12.0024 2.00586H16.0024C16.2676 2.00586 16.5219 2.11122 16.7095 2.29875C16.897 2.48629 17.0024 2.74064 17.0024 3.00586C17.0024 3.27108 16.897 3.52543 16.7095 3.71297C16.5219 3.9005 16.2676 4.00586 16.0024 4.00586H15.0024V5.52186C14.3358 5.50186 13.6689 5.50186 13.0024 5.52186V4.00586H12.0024C11.7371 4.00586 11.4828 3.9005 11.2953 3.71297C11.1077 3.52543 11.0024 3.27108 11.0024 3.00586ZM2.05737 4.57886C2.00687 4.31843 2.06182 4.04862 2.21014 3.82868C2.35845 3.60874 2.588 3.45666 2.84837 3.40586L6.77537 2.64186C6.90432 2.61684 7.03695 2.61747 7.16566 2.64371C7.29438 2.66995 7.41666 2.72128 7.52554 2.79478C7.63442 2.86827 7.72775 2.9625 7.80021 3.07207C7.92285 3.18163 7.92285 3.3044 7.94787 3.43336C7.97288 3.56232 7.97225 3.69494 7.94602 3.82366C7.91978 3.95237 7.86845 4.07466 7.79495 4.18353C7.72145 4.29241 7.62723 4.38575 7.51766 4.45821C7.40809 4.53067 7.28532 4.58084 7.15637 4.60586L6.00237 4.82986V6.25686C5.30237 6.39986 4.63637 6.56786 4.00237 6.75386V5.21886L3.23037 5.36886C3.10147 5.39401 2.96889 5.39352 2.84018 5.36743C2.71148 5.34134 2.58917 5.29016 2.48025 5.2168C2.37133 5.14344 2.27792 5.04935 2.20536 4.93989C2.1328 4.83043 2.08251 4.70775 2.05737 4.57886Z" fill="url(#paint0_intro_logo)" />
65
62
  <path d="M28 11.3139C28 12.3309 26.849 13.2629 24.934 13.9999C23.684 12.0439 18.821 11.0249 14 11.0249C9.179 11.0249 4.316 12.0439 3.066 13.9999C1.151 13.2629 0 12.3309 0 11.3139C0 8.93486 6.268 7.00586 14 7.00586C21.732 7.00586 28 8.93486 28 11.3139Z" fill="url(#paint1_intro_logo)" />
66
63
  <path d="M14 17.122C8.484 17.122 2.667 16.126 0 13.998V21.699C0 23.761 4.712 25.482 11.001 25.905V21.006C11.001 20.7408 11.1064 20.4865 11.2939 20.2989C11.4814 20.1114 11.7358 20.006 12.001 20.006H16.002C16.2672 20.006 16.5216 20.1114 16.7091 20.2989C16.8966 20.4865 17.002 20.7408 17.002 21.006V25.904C23.29 25.481 28 23.76 28 21.699V13.998C25.333 16.126 19.516 17.122 14 17.122Z" fill="url(#paint2_intro_logo)" />
67
64
  </g>
@@ -119,30 +116,11 @@ const LogoSlide = ({ config }: SlideProps) => {
119
116
  {countdown}
120
117
  </span>
121
118
  </motion.div>
122
- {/* Title — appears last, with cyan fill sweep */}
123
- <motion.p
124
- initial={{ opacity: 0, y: 20 }}
125
- animate={{ opacity: 1, y: 0 }}
126
- transition={{ duration: 0.5, delay: 2.8 }}
127
- className="text-[18px] font-bold tracking-wider uppercase mt-4"
128
- style={{
129
- ...OUTFIT,
130
- background: "linear-gradient(90deg, #22E3E8 0%, #22E3E8 50%, rgba(255,255,255,0.35) 50.1%, rgba(255,255,255,0.35) 100%)",
131
- backgroundSize: "200% 100%",
132
- backgroundPosition: "100% 0",
133
- WebkitBackgroundClip: "text",
134
- WebkitTextFillColor: "transparent",
135
- backgroundClip: "text",
136
- animation: "textFillSweep 2s ease-out 3.3s forwards",
137
- }}
138
- >
139
- {config.title}
140
- </motion.p>
141
119
  </motion.div>
142
120
  );
143
121
  };
144
122
 
145
- // ── Slide 1: Buy in with a match pass ──
123
+ // ── Slide 1: Buy in with a match pass (only shown when entryFee > 0) ──
146
124
  const EntrySlide = ({ config }: SlideProps) => (
147
125
  <motion.div
148
126
  className="flex flex-col items-center gap-4"
@@ -150,7 +128,6 @@ const EntrySlide = ({ config }: SlideProps) => (
150
128
  animate="animate"
151
129
  exit="exit"
152
130
  >
153
- {/* Headline — appears first */}
154
131
  <motion.p
155
132
  initial={{ opacity: 0, y: 20 }}
156
133
  animate={{ opacity: 1, y: 0 }}
@@ -160,7 +137,6 @@ const EntrySlide = ({ config }: SlideProps) => (
160
137
  >
161
138
  Buy in with a match pass
162
139
  </motion.p>
163
- {/* Price + USDC — appears after reading the headline */}
164
140
  <motion.div
165
141
  initial={{ opacity: 0, y: 20 }}
166
142
  animate={{ opacity: 1, y: 0 }}
@@ -170,7 +146,6 @@ const EntrySlide = ({ config }: SlideProps) => (
170
146
  <span className="text-[52px] font-bold text-white leading-none" style={OUTFIT}>${config.entryFee}</span>
171
147
  <Image src="/icons/ic_usdc_hd.png" alt="USDC" width={52} height={52} className="rounded-full" />
172
148
  </motion.div>
173
- {/* Subtitle — appears last */}
174
149
  <motion.p
175
150
  initial={{ opacity: 0, y: 16 }}
176
151
  animate={{ opacity: 1, y: 0 }}
@@ -183,7 +158,7 @@ const EntrySlide = ({ config }: SlideProps) => (
183
158
  </motion.div>
184
159
  );
185
160
 
186
- // ── Slide 2: Get 1,000 points ──
161
+ // ── Slide 2: Get prediction points ──
187
162
  const PointsSlide = ({ config }: SlideProps) => (
188
163
  <motion.div
189
164
  className="flex flex-col items-center"
@@ -191,7 +166,6 @@ const PointsSlide = ({ config }: SlideProps) => (
191
166
  animate="animate"
192
167
  exit="exit"
193
168
  >
194
- {/* "You get" — appears first */}
195
169
  <motion.p
196
170
  initial={{ opacity: 0, y: 20 }}
197
171
  animate={{ opacity: 1, y: 0 }}
@@ -201,7 +175,6 @@ const PointsSlide = ({ config }: SlideProps) => (
201
175
  >
202
176
  You get
203
177
  </motion.p>
204
- {/* Big number + logo — appears after "You get" is read */}
205
178
  <motion.div
206
179
  initial={{ opacity: 0, y: 20 }}
207
180
  animate={{ opacity: 1, y: 0 }}
@@ -222,7 +195,6 @@ const PointsSlide = ({ config }: SlideProps) => (
222
195
  {config.startingBalance.toLocaleString()}
223
196
  </span>
224
197
  </motion.div>
225
- {/* "prediction points" — appears after the number lands */}
226
198
  <motion.p
227
199
  initial={{ opacity: 0, y: 16 }}
228
200
  animate={{ opacity: 1, y: 0 }}
@@ -232,7 +204,6 @@ const PointsSlide = ({ config }: SlideProps) => (
232
204
  >
233
205
  prediction points to bet with
234
206
  </motion.p>
235
- {/* Delayed tip — appears last */}
236
207
  <motion.p
237
208
  initial={{ opacity: 0, y: 10 }}
238
209
  animate={{ opacity: 1, y: 0 }}
@@ -251,8 +222,8 @@ const MarketsSlide = ({ config }: SlideProps) => {
251
222
 
252
223
  useEffect(() => {
253
224
  const timers = [
254
- setTimeout(() => setDemoStep(1), 2800), // option highlights — after card appears
255
- setTimeout(() => setDemoStep(2), 4200), // chip selected
225
+ setTimeout(() => setDemoStep(1), 2800),
226
+ setTimeout(() => setDemoStep(2), 4200),
256
227
  ];
257
228
  return () => timers.forEach(clearTimeout);
258
229
  }, []);
@@ -264,7 +235,6 @@ const MarketsSlide = ({ config }: SlideProps) => {
264
235
  animate="animate"
265
236
  exit="exit"
266
237
  >
267
- {/* Headline — appears first */}
268
238
  <motion.p
269
239
  initial={{ opacity: 0, y: 20 }}
270
240
  animate={{ opacity: 1, y: 0 }}
@@ -275,7 +245,6 @@ const MarketsSlide = ({ config }: SlideProps) => {
275
245
  {config.markets.length} markets to predict
276
246
  </motion.p>
277
247
 
278
- {/* Floating market card — appears after headline is read */}
279
248
  <motion.div
280
249
  initial={{ opacity: 0, y: 20 }}
281
250
  animate={{ opacity: 1, y: 0 }}
@@ -286,28 +255,19 @@ const MarketsSlide = ({ config }: SlideProps) => {
286
255
  border: "1px solid rgba(255,255,255,0.08)",
287
256
  }}
288
257
  >
289
- {/* Market question */}
290
258
  <div className="flex items-center gap-2 mb-2.5">
291
259
  <Coins size={14} className="text-[#22E3E8]/50 flex-shrink-0" />
292
260
  <span className="text-[13px] text-white font-semibold" style={OUTFIT}>
293
261
  Who wins the toss?
294
262
  </span>
295
263
  </div>
296
- {/* Options */}
297
264
  <div className="grid grid-cols-2 gap-2">
298
- {/* CSK option — gets selected */}
299
265
  <div
300
266
  className="flex items-center justify-between px-2.5 py-2 rounded-sm transition-all duration-500"
301
267
  style={{
302
- background: demoStep >= 1
303
- ? "linear-gradient(135deg, #22E3E8, #9945FF)"
304
- : "transparent",
305
- borderLeft: demoStep >= 1
306
- ? "1px solid transparent"
307
- : "1px solid rgba(255,255,255,0.12)",
308
- borderBottom: demoStep >= 1
309
- ? "1px solid transparent"
310
- : "1px solid rgba(255,255,255,0.06)",
268
+ background: demoStep >= 1 ? "linear-gradient(135deg, #22E3E8, #9945FF)" : "transparent",
269
+ borderLeft: demoStep >= 1 ? "1px solid transparent" : "1px solid rgba(255,255,255,0.12)",
270
+ borderBottom: demoStep >= 1 ? "1px solid transparent" : "1px solid rgba(255,255,255,0.06)",
311
271
  borderTop: "1px solid transparent",
312
272
  borderRight: "1px solid transparent",
313
273
  }}
@@ -330,7 +290,6 @@ const MarketsSlide = ({ config }: SlideProps) => {
330
290
  1.4x
331
291
  </span>
332
292
  </div>
333
- {/* RCB option — stays default */}
334
293
  <div
335
294
  className="flex items-center justify-between px-2.5 py-2 rounded-sm"
336
295
  style={{
@@ -344,7 +303,6 @@ const MarketsSlide = ({ config }: SlideProps) => {
344
303
  <span className="text-[10px] font-bold text-[#22E3E8]" style={OUTFIT}>1.4x</span>
345
304
  </div>
346
305
  </div>
347
- {/* Amount picker — appears at step 2 */}
348
306
  <AnimatePresence>
349
307
  {demoStep >= 2 && (
350
308
  <motion.div
@@ -369,15 +327,6 @@ const MarketsSlide = ({ config }: SlideProps) => {
369
327
  border: "1px solid rgba(34,227,232,0.2)",
370
328
  }}
371
329
  >
372
- {amt === 100 && (
373
- <div
374
- className="absolute inset-0 pointer-events-none animate-shine"
375
- style={{
376
- background: "linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.3) 50%, transparent 100%)",
377
- backgroundSize: "200% 100%",
378
- }}
379
- />
380
- )}
381
330
  <Image src="/iamgame_square_logo.jpg" alt="" width={7} height={7} className="rounded-[1px] relative z-[1]" />
382
331
  <span
383
332
  className={`text-[9px] font-bold relative z-[1] ${amt === 100 ? "text-black" : "text-[#22E3E8]"}`}
@@ -392,7 +341,6 @@ const MarketsSlide = ({ config }: SlideProps) => {
392
341
  </AnimatePresence>
393
342
  </motion.div>
394
343
 
395
- {/* Subtitle — appears after demo plays */}
396
344
  <motion.p
397
345
  initial={{ opacity: 0, y: 16 }}
398
346
  animate={{ opacity: 1, y: 0 }}
@@ -414,7 +362,6 @@ const MultiplierSlide = ({ config }: SlideProps) => (
414
362
  animate="animate"
415
363
  exit="exit"
416
364
  >
417
- {/* First line — appears immediately */}
418
365
  <motion.p
419
366
  initial={{ opacity: 0, y: 20 }}
420
367
  animate={{ opacity: 1, y: 0 }}
@@ -424,7 +371,6 @@ const MultiplierSlide = ({ config }: SlideProps) => (
424
371
  >
425
372
  Stack predictions.
426
373
  </motion.p>
427
- {/* Second line — after first is read */}
428
374
  <motion.p
429
375
  initial={{ opacity: 0, y: 20 }}
430
376
  animate={{ opacity: 1, y: 0 }}
@@ -434,7 +380,6 @@ const MultiplierSlide = ({ config }: SlideProps) => (
434
380
  >
435
381
  Multiply your odds.
436
382
  </motion.p>
437
- {/* Big number — the reveal moment */}
438
383
  <motion.div
439
384
  initial={{ opacity: 0, scale: 0.8 }}
440
385
  animate={{ opacity: 1, scale: 1 }}
@@ -453,7 +398,6 @@ const MultiplierSlide = ({ config }: SlideProps) => (
453
398
  {config.compoundMultipliers[config.compoundMultipliers.length - 1]}x
454
399
  </span>
455
400
  </motion.div>
456
- {/* Label — after number lands */}
457
401
  <motion.p
458
402
  initial={{ opacity: 0, y: 16 }}
459
403
  animate={{ opacity: 1, y: 0 }}
@@ -466,25 +410,33 @@ const MultiplierSlide = ({ config }: SlideProps) => (
466
410
  </motion.div>
467
411
  );
468
412
 
413
+ // Free leaderboard payouts: ranks 1-10, $25 down to $2.5 in $2.5 steps
414
+ const FREE_PAYOUTS = Array.from({ length: 10 }, (_, i) => ({
415
+ rank: i + 1,
416
+ usdc: 25 - i * 2.5,
417
+ }));
418
+
469
419
  // ── Slide 5: Win USDC ──
470
- const WinSlide = ({ config: _config }: SlideProps) => {
471
- const goTo = useGamePopupStore(s => s.goTo);
420
+ const WinSlide = ({ config, onComplete }: SlideProps) => {
421
+ const isFree = config.entryFee === 0;
422
+
472
423
  return (
473
424
  <motion.div
474
- className="flex flex-col items-center gap-4"
425
+ className="flex flex-col items-center gap-3 w-full max-w-[280px]"
475
426
  initial="initial"
476
427
  animate="animate"
477
428
  exit="exit"
478
429
  >
479
- {/* Trophy — appears first */}
430
+ {/* Trophy */}
480
431
  <motion.div
481
432
  initial={{ opacity: 0, y: 20 }}
482
433
  animate={{ opacity: 1, y: 0 }}
483
434
  transition={{ duration: 0.5, delay: 0.2 }}
484
435
  >
485
- <Trophy size={36} className="text-[#FFD700]" />
436
+ <Trophy size={32} className="text-[#FFD700]" />
486
437
  </motion.div>
487
- {/* Headline — after trophy */}
438
+
439
+ {/* Headline */}
488
440
  <motion.p
489
441
  initial={{ opacity: 0, y: 20 }}
490
442
  animate={{ opacity: 1, y: 0 }}
@@ -494,34 +446,80 @@ const WinSlide = ({ config: _config }: SlideProps) => {
494
446
  >
495
447
  Top the leaderboard.
496
448
  </motion.p>
497
- {/* Win real USDC — after headline is read */}
498
- <motion.div
499
- initial={{ opacity: 0, y: 20 }}
500
- animate={{ opacity: 1, y: 0 }}
501
- transition={{ duration: 0.6, delay: 1.8 }}
502
- className="flex items-center gap-2"
503
- >
504
- <span className="text-[18px] text-white/70 font-medium" style={OUTFIT}>Win real</span>
505
- <Image src="/icons/ic_usdc_hd.png" alt="USDC" width={22} height={22} className="rounded-full" />
506
- <span className="text-[18px] text-[#2775CA] font-bold" style={OUTFIT}>USDC</span>
507
- </motion.div>
508
- {/* Tagline after USDC line */}
509
- <motion.p
510
- initial={{ opacity: 0, y: 16 }}
511
- animate={{ opacity: 1, y: 0 }}
512
- transition={{ duration: 0.5, delay: 2.8 }}
513
- className="text-[13px] text-white/40 font-medium"
514
- style={OUTFIT}
515
- >
516
- Take the money home.
517
- </motion.p>
518
- {/* CTA button — appears last */}
449
+
450
+ {isFree ? (
451
+ /* Free game: rank/reward table */
452
+ <motion.div
453
+ initial={{ opacity: 0, y: 20 }}
454
+ animate={{ opacity: 1, y: 0 }}
455
+ transition={{ duration: 0.6, delay: 1.4 }}
456
+ className="w-full rounded-xl overflow-hidden"
457
+ style={{ border: "1px solid rgba(255,255,255,0.08)", background: "rgba(255,255,255,0.02)" }}
458
+ >
459
+ {/* Table header */}
460
+ <div className="flex items-center justify-between px-3 py-1.5" style={{ borderBottom: "1px solid rgba(255,255,255,0.06)", background: "rgba(255,255,255,0.03)" }}>
461
+ <span className="text-[9px] font-bold text-white/50 uppercase tracking-widest" style={OUTFIT}>Rank</span>
462
+ <div className="flex items-center gap-1">
463
+ <Image src="/icons/ic_usdc_hd.png" alt="USDC" width={11} height={11} className="rounded-full" />
464
+ <span className="text-[9px] font-bold text-white/50 uppercase tracking-widest" style={OUTFIT}>Reward</span>
465
+ </div>
466
+ </div>
467
+ {/* Rows */}
468
+ {FREE_PAYOUTS.map((row, idx) => (
469
+ <motion.div
470
+ key={row.rank}
471
+ initial={{ opacity: 0, x: -8 }}
472
+ animate={{ opacity: 1, x: 0 }}
473
+ transition={{ delay: 1.6 + idx * 0.06, duration: 0.2 }}
474
+ className="flex items-center justify-between px-3 py-1.5"
475
+ style={{
476
+ borderBottom: idx < FREE_PAYOUTS.length - 1 ? "1px solid rgba(255,255,255,0.04)" : "none",
477
+ background: idx === 0 ? "rgba(255,215,0,0.04)" : idx < 3 ? "rgba(255,255,255,0.015)" : "transparent",
478
+ }}
479
+ >
480
+ <div className="flex items-center gap-2">
481
+ {idx === 0 && <span style={{ fontSize: 11 }}>🥇</span>}
482
+ {idx === 1 && <span style={{ fontSize: 11 }}>🥈</span>}
483
+ {idx === 2 && <span style={{ fontSize: 11 }}>🥉</span>}
484
+ {idx >= 3 && (
485
+ <span className="text-[10px] text-white/40 font-semibold w-[14px] text-center" style={OUTFIT}>#{row.rank}</span>
486
+ )}
487
+ {idx < 3 && (
488
+ <span className="text-[10px] font-semibold" style={{ ...OUTFIT, color: idx === 0 ? "#FFD700" : idx === 1 ? "#C0C0C0" : "#CD7F32" }}>
489
+ #{row.rank}
490
+ </span>
491
+ )}
492
+ </div>
493
+ <span
494
+ className="text-[11px] font-bold"
495
+ style={{ ...OUTFIT, color: idx === 0 ? "#FFD700" : idx < 3 ? "rgba(255,255,255,0.85)" : "rgba(255,255,255,0.6)" }}
496
+ >
497
+ ${row.usdc.toFixed(2)}
498
+ </span>
499
+ </motion.div>
500
+ ))}
501
+ </motion.div>
502
+ ) : (
503
+ /* Paid game: "Win real USDC" */
504
+ <motion.div
505
+ initial={{ opacity: 0, y: 20 }}
506
+ animate={{ opacity: 1, y: 0 }}
507
+ transition={{ duration: 0.6, delay: 1.8 }}
508
+ className="flex items-center gap-2"
509
+ >
510
+ <span className="text-[18px] text-white/70 font-medium" style={OUTFIT}>Win real</span>
511
+ <Image src="/icons/ic_usdc_hd.png" alt="USDC" width={22} height={22} className="rounded-full" />
512
+ <span className="text-[18px] text-[#2775CA] font-bold" style={OUTFIT}>USDC</span>
513
+ </motion.div>
514
+ )}
515
+
516
+ {/* CTA button */}
519
517
  <motion.button
520
518
  initial={{ opacity: 0, y: 16 }}
521
519
  animate={{ opacity: 1, y: 0 }}
522
- transition={{ duration: 0.5, delay: 3.5 }}
523
- onClick={() => goTo("questions")}
524
- className="mt-4 px-6 py-2.5 rounded-full cursor-pointer"
520
+ transition={{ duration: 0.5, delay: isFree ? 3.4 : 3.5 }}
521
+ onClick={onComplete}
522
+ className="mt-2 px-6 py-2.5 rounded-full cursor-pointer"
525
523
  style={{ background: "linear-gradient(135deg, #22E3E8, #9945FF)" }}
526
524
  >
527
525
  <span className="text-[14px] font-bold text-black" style={OUTFIT}>Build Your Bets</span>
@@ -531,30 +529,43 @@ const WinSlide = ({ config: _config }: SlideProps) => {
531
529
  };
532
530
 
533
531
 
534
- export const PreMatchIntro = ({ config }: { config: IChallengeConfig }) => {
535
- const goTo = useGamePopupStore(s => s.goTo);
532
+ export const PreMatchIntro = ({ config, onComplete }: { config: IChallengeConfig; onComplete: () => void }) => {
536
533
  const [slideIdx, setSlideIdx] = useState(0);
537
534
  const [direction, setDirection] = useState(1);
538
535
  const [paused, setPaused] = useState(false);
539
536
  const [slideFill, setSlideFill] = useState(0);
540
537
  const [finished, setFinished] = useState(false);
541
538
 
539
+ // Dynamic slide list — skip EntrySlide when entry fee is 0
540
+ const slides = useMemo(() => {
541
+ type SlideEntry = { component: React.ComponentType<SlideProps>; duration: number };
542
+ const all: SlideEntry[] = [
543
+ { component: LogoSlide, duration: 5500 },
544
+ { component: EntrySlide, duration: 5000 },
545
+ { component: PointsSlide, duration: 5500 },
546
+ { component: MarketsSlide, duration: 7000 },
547
+ { component: MultiplierSlide, duration: 5500 },
548
+ { component: WinSlide, duration: 5500 },
549
+ ];
550
+ return config.entryFee === 0 ? all.filter((_, i) => i !== 1) : all;
551
+ }, [config.entryFee]);
552
+
553
+ const totalSlides = slides.length;
554
+
542
555
  // Refs for timing
543
556
  const slideStartRef = useRef(Date.now());
544
- const pausedElapsedRef = useRef(0); // how much time had elapsed when paused
557
+ const pausedElapsedRef = useRef(0);
545
558
  const rafRef = useRef<number>(0);
546
559
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
547
560
 
548
- // Clear auto-advance timer
549
561
  const clearAutoTimer = useCallback(() => {
550
562
  if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; }
551
563
  }, []);
552
564
 
553
- // Start auto-advance timer for remaining duration
554
565
  const startAutoTimer = useCallback((remainingMs: number) => {
555
566
  clearAutoTimer();
556
567
  timerRef.current = setTimeout(() => {
557
- if (slideIdx < TOTAL_SLIDES - 1) {
568
+ if (slideIdx < totalSlides - 1) {
558
569
  setDirection(1);
559
570
  setSlideIdx(prev => prev + 1);
560
571
  } else {
@@ -562,23 +573,22 @@ export const PreMatchIntro = ({ config }: { config: IChallengeConfig }) => {
562
573
  setPaused(true);
563
574
  }
564
575
  }, remainingMs);
565
- }, [slideIdx, clearAutoTimer]);
576
+ }, [slideIdx, totalSlides, clearAutoTimer]);
566
577
 
567
- // Progress fill RAF — respects paused state on slide change
568
578
  useEffect(() => {
569
579
  setSlideFill(0);
570
580
  pausedElapsedRef.current = 0;
571
581
  slideStartRef.current = Date.now();
572
582
 
573
- if (paused) return; // stay paused at 0 fill on new slide
583
+ if (paused) return;
574
584
 
575
585
  const tick = () => {
576
586
  const elapsed = Date.now() - slideStartRef.current;
577
- setSlideFill(Math.min(elapsed / SLIDE_DURATIONS[slideIdx], 1));
587
+ setSlideFill(Math.min(elapsed / slides[slideIdx].duration, 1));
578
588
  rafRef.current = requestAnimationFrame(tick);
579
589
  };
580
590
  rafRef.current = requestAnimationFrame(tick);
581
- startAutoTimer(SLIDE_DURATIONS[slideIdx]);
591
+ startAutoTimer(slides[slideIdx].duration);
582
592
 
583
593
  return () => {
584
594
  cancelAnimationFrame(rafRef.current);
@@ -586,28 +596,25 @@ export const PreMatchIntro = ({ config }: { config: IChallengeConfig }) => {
586
596
  };
587
597
  }, [slideIdx]); // eslint-disable-line react-hooks/exhaustive-deps
588
598
 
589
- // Handle pause/resume
590
599
  const togglePause = useCallback(() => {
591
600
  if (paused) {
592
- // Resume: restart RAF and timer from where we left off
593
- const remaining = SLIDE_DURATIONS[slideIdx] - pausedElapsedRef.current;
601
+ const remaining = slides[slideIdx].duration - pausedElapsedRef.current;
594
602
  slideStartRef.current = Date.now() - pausedElapsedRef.current;
595
603
  const tick = () => {
596
604
  const elapsed = Date.now() - slideStartRef.current;
597
- setSlideFill(Math.min(elapsed / SLIDE_DURATIONS[slideIdx], 1));
605
+ setSlideFill(Math.min(elapsed / slides[slideIdx].duration, 1));
598
606
  rafRef.current = requestAnimationFrame(tick);
599
607
  };
600
608
  rafRef.current = requestAnimationFrame(tick);
601
609
  startAutoTimer(remaining);
602
610
  setPaused(false);
603
611
  } else {
604
- // Pause: freeze everything
605
612
  cancelAnimationFrame(rafRef.current);
606
613
  clearAutoTimer();
607
614
  pausedElapsedRef.current = Date.now() - slideStartRef.current;
608
615
  setPaused(true);
609
616
  }
610
- }, [paused, slideIdx, startAutoTimer, clearAutoTimer]);
617
+ }, [paused, slideIdx, slides, startAutoTimer, clearAutoTimer]);
611
618
 
612
619
  const replay = useCallback(() => {
613
620
  cancelAnimationFrame(rafRef.current);
@@ -621,14 +628,14 @@ export const PreMatchIntro = ({ config }: { config: IChallengeConfig }) => {
621
628
  const seekNext = useCallback(() => {
622
629
  cancelAnimationFrame(rafRef.current);
623
630
  clearAutoTimer();
624
- if (slideIdx < TOTAL_SLIDES - 1) {
631
+ if (slideIdx < totalSlides - 1) {
625
632
  setFinished(false);
626
633
  setDirection(1);
627
634
  setSlideIdx(prev => prev + 1);
628
635
  } else {
629
- goTo("questions");
636
+ onComplete();
630
637
  }
631
- }, [slideIdx, goTo, clearAutoTimer]);
638
+ }, [slideIdx, totalSlides, onComplete, clearAutoTimer]);
632
639
 
633
640
  const seekBack = useCallback(() => {
634
641
  cancelAnimationFrame(rafRef.current);
@@ -646,16 +653,16 @@ export const PreMatchIntro = ({ config }: { config: IChallengeConfig }) => {
646
653
  exit: (dir: number) => ({ opacity: 0, x: dir * -60 }),
647
654
  };
648
655
 
649
- const SLIDES = [LogoSlide, EntrySlide, PointsSlide, MarketsSlide, MultiplierSlide, WinSlide] as const;
650
- const SlideComponent = SLIDES[slideIdx] as React.ComponentType<SlideProps>;
656
+ const { component: SlideComponent } = slides[slideIdx];
657
+ const isLastSlide = slideIdx === totalSlides - 1;
651
658
 
652
659
  return (
653
660
  <div className="relative w-full h-full flex flex-col bg-black">
654
661
  {/* Skip button */}
655
662
  <div className="flex justify-end px-4 pt-3">
656
663
  <button
657
- onClick={() => goTo("questions")}
658
- className="text-[10px] text-white/25 uppercase tracking-wider font-semibold"
664
+ onClick={onComplete}
665
+ className="text-[10px] text-white/25 uppercase tracking-wider font-semibold cursor-pointer"
659
666
  style={OUTFIT}
660
667
  >
661
668
  Skip
@@ -663,7 +670,7 @@ export const PreMatchIntro = ({ config }: { config: IChallengeConfig }) => {
663
670
  </div>
664
671
 
665
672
  {/* Slide content — centered */}
666
- <div className="flex-1 flex items-center justify-center px-6">
673
+ <div className="flex-1 flex items-center justify-center px-6 overflow-y-auto">
667
674
  <AnimatePresence mode="wait" custom={direction}>
668
675
  <motion.div
669
676
  key={slideIdx}
@@ -673,9 +680,9 @@ export const PreMatchIntro = ({ config }: { config: IChallengeConfig }) => {
673
680
  animate="center"
674
681
  exit="exit"
675
682
  transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
676
- className="w-full flex justify-center"
683
+ className="w-full flex justify-center py-4"
677
684
  >
678
- <SlideComponent config={config} />
685
+ <SlideComponent config={config} onComplete={onComplete} />
679
686
  </motion.div>
680
687
  </AnimatePresence>
681
688
  </div>
@@ -687,7 +694,7 @@ export const PreMatchIntro = ({ config }: { config: IChallengeConfig }) => {
687
694
  {finished ? (
688
695
  <button
689
696
  onClick={replay}
690
- className="flex items-center gap-1.5 p-1.5"
697
+ className="flex items-center gap-1.5 p-1.5 cursor-pointer"
691
698
  style={{ opacity: 0.5 }}
692
699
  >
693
700
  <RotateCcw size={16} className="text-white" />
@@ -698,23 +705,27 @@ export const PreMatchIntro = ({ config }: { config: IChallengeConfig }) => {
698
705
  <button
699
706
  onClick={seekBack}
700
707
  disabled={slideIdx === 0}
701
- className="p-1.5"
708
+ className="p-1.5 cursor-pointer"
702
709
  style={{ opacity: slideIdx === 0 ? 0.15 : 0.5 }}
703
710
  >
704
711
  <Rewind size={18} className="text-white" />
705
712
  </button>
706
- <button
707
- onClick={togglePause}
708
- className="p-1.5"
709
- >
710
- {paused
711
- ? <Play size={18} className="text-white" style={{ marginLeft: 2 }} />
712
- : <Pause size={18} className="text-white" />
713
- }
714
- </button>
713
+ {/* Hide play/pause on last slide — user should use Build Your Bets button */}
714
+ {!isLastSlide && (
715
+ <button
716
+ onClick={togglePause}
717
+ className="p-1.5 cursor-pointer"
718
+ >
719
+ {paused
720
+ ? <svg width="18" height="18" viewBox="0 0 18 18" fill="none"><polygon points="5,3 15,9 5,15" fill="white" style={{ marginLeft: 2 }} /></svg>
721
+ : <Pause size={18} className="text-white" />
722
+ }
723
+ </button>
724
+ )}
725
+ {isLastSlide && <div style={{ width: 18 + 12 }} />}
715
726
  <button
716
727
  onClick={seekNext}
717
- className="p-1.5"
728
+ className="p-1.5 cursor-pointer"
718
729
  style={{ opacity: 0.5 }}
719
730
  >
720
731
  <FastForward size={18} className="text-white" />
@@ -723,14 +734,14 @@ export const PreMatchIntro = ({ config }: { config: IChallengeConfig }) => {
723
734
  )}
724
735
  </div>
725
736
 
726
- {/* Segmented progress bar with checkpoint circles */}
737
+ {/* Segmented progress bar */}
727
738
  <div style={{ display: "flex", alignItems: "center", width: "100%", gap: 6 }}>
728
- {Array.from({ length: TOTAL_SLIDES }).map((_, i) => {
739
+ {Array.from({ length: totalSlides }).map((_, i) => {
729
740
  const filled = i < slideIdx;
730
741
  const active = i === slideIdx;
731
742
  const passed = i <= slideIdx;
732
743
  return (
733
- <div key={i} style={{ display: "flex", alignItems: "center", flex: i < TOTAL_SLIDES - 1 ? 1 : "none", gap: 6 }}>
744
+ <div key={i} style={{ display: "flex", alignItems: "center", flex: i < totalSlides - 1 ? 1 : "none", gap: 6 }}>
734
745
  <div
735
746
  style={{
736
747
  width: 7, height: 7, borderRadius: "50%", flexShrink: 0,
@@ -738,7 +749,7 @@ export const PreMatchIntro = ({ config }: { config: IChallengeConfig }) => {
738
749
  boxShadow: passed ? "0 0 6px rgba(34,227,232,0.4)" : "none",
739
750
  }}
740
751
  />
741
- {i < TOTAL_SLIDES - 1 && (
752
+ {i < totalSlides - 1 && (
742
753
  <div style={{ flex: 1, height: 3, borderRadius: 2, background: "rgba(255,255,255,0.08)", overflow: "hidden" }}>
743
754
  <div
744
755
  style={{
@@ -9,6 +9,7 @@ import type {
9
9
  ITDMyEntryResponse,
10
10
  ITDLeaderboardResponse,
11
11
  ITDBetInput,
12
+ ITDDraftSelection,
12
13
  } from "./types";
13
14
 
14
15
  // Shared fetch helper — adds auth header if token exists
@@ -78,6 +79,17 @@ export async function joinTDPoolApi(poolId: number): Promise<{ id: number }> {
78
79
  return tdFetch<{ id: number }>(`/api/pools/${poolId}/join`, { method: "POST", body: "{}" });
79
80
  }
80
81
 
82
+ // PUT /api/pools/:id/draft-bets
83
+ export async function saveTDDraftBetsApi(
84
+ poolId: number,
85
+ selections: ITDDraftSelection[],
86
+ ): Promise<void> {
87
+ await tdFetch<unknown>(`/api/pools/${poolId}/draft-bets`, {
88
+ method: "PUT",
89
+ body: JSON.stringify({ selections }),
90
+ });
91
+ }
92
+
81
93
  // POST /api/pools/:id/bets
82
94
  export async function placeTDBetsApi(
83
95
  poolId: number,
@@ -163,6 +163,12 @@ export interface ITDLeaderboardResponse {
163
163
 
164
164
  // ─── Pool entry (GET /api/pools/:id/my-entry) ─────────────────────────────────
165
165
 
166
+ export interface ITDDraftSelection {
167
+ challenge_id: number;
168
+ selected_option: string;
169
+ coin_amount: number;
170
+ }
171
+
166
172
  export interface ITDPoolEntryRecord {
167
173
  id: number;
168
174
  pool_id: number;
@@ -178,6 +184,7 @@ export interface ITDPoolEntryRecord {
178
184
  questions_left: number;
179
185
  questions_total: number;
180
186
  active_parlays: number;
187
+ draft_selections: ITDDraftSelection[] | null;
181
188
  created_at: string;
182
189
  }
183
190