@devrongx/games 0.1.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.
@@ -0,0 +1,491 @@
1
+ // @devrongx/games — games/prematch-bets/PreMatchQuestions.tsx
2
+ "use client";
3
+
4
+ import { useState, useCallback, useEffect } from "react";
5
+ import { motion, AnimatePresence } from "framer-motion";
6
+ import { Check, Rewind, FastForward, ArrowRight } from "lucide-react";
7
+ import { IChallengeConfig, IChallengeMarket, IUserBets } from "./config";
8
+ import { OUTFIT, MARKET_ICONS, PointsIcon } from "./constants";
9
+ import { useGamePopupStore } from "../../core/gamePopupStore";
10
+
11
+ interface PreMatchQuestionsProps {
12
+ config: IChallengeConfig;
13
+ onComplete: (selections: IUserBets) => void;
14
+ }
15
+
16
+ // ── Sassy typewriter — each word has its own exponential speed curve ──
17
+ // Words alternate between snappy bursts and dramatic slow reveals.
18
+ // Within each word, character delays follow an exponential curve.
19
+ const useSassyTypewriter = (text: string, delay = 0) => {
20
+ const [displayed, setDisplayed] = useState("");
21
+ const [done, setDone] = useState(false);
22
+
23
+ useEffect(() => {
24
+ setDisplayed("");
25
+ setDone(false);
26
+
27
+ // Build per-character delay schedule
28
+ const words = text.split(" ");
29
+ const charDelays: number[] = [];
30
+
31
+ // Alternating word styles for rhythm
32
+ // "burst" = types fast then decelerates at end
33
+ // "drag" = starts slow, accelerates to finish
34
+ // "snap" = entire word appears almost instantly
35
+ const wordStyles = ["drag", "snap", "burst", "drag", "snap", "burst"];
36
+
37
+ words.forEach((word, wIdx) => {
38
+ const style = wordStyles[wIdx % wordStyles.length];
39
+ const len = word.length;
40
+
41
+ for (let c = 0; c < len; c++) {
42
+ const t = len > 1 ? c / (len - 1) : 0; // 0..1 progress through word
43
+
44
+ let ms: number;
45
+ if (style === "burst") {
46
+ // Starts fast (15ms), exponentially decelerates to ~80ms
47
+ ms = 15 + 65 * Math.pow(t, 2.5);
48
+ } else if (style === "drag") {
49
+ // Starts slow (90ms), exponentially accelerates to ~12ms
50
+ ms = 90 - 78 * Math.pow(t, 2.2);
51
+ } else {
52
+ // snap — near instant, 8ms per char
53
+ ms = 8;
54
+ }
55
+ charDelays.push(Math.round(ms));
56
+ }
57
+ // Pause between words — dramatic breath
58
+ charDelays.push(wIdx < words.length - 1 ? 120 : 0);
59
+ });
60
+
61
+ const timers: ReturnType<typeof setTimeout>[] = [];
62
+ let cumulative = delay;
63
+ let charIdx = 0;
64
+
65
+ // Schedule each character reveal
66
+ for (let i = 0; i < charDelays.length; i++) {
67
+ cumulative += charDelays[i];
68
+ const idx = charIdx;
69
+ // The space between words is at charDelays entries where we push 120ms pause
70
+ // but we need to track actual characters in text
71
+ if (i < charDelays.length && idx <= text.length) {
72
+ timers.push(setTimeout(() => {
73
+ setDisplayed(text.slice(0, idx + 1));
74
+ if (idx + 1 >= text.length) setDone(true);
75
+ }, cumulative));
76
+ }
77
+ // Only increment charIdx for actual characters (not the inter-word pauses)
78
+ // Words joined by spaces: after each word's chars, next char is space
79
+ charIdx++;
80
+ }
81
+
82
+ return () => timers.forEach(clearTimeout);
83
+ }, [text, delay]);
84
+
85
+ return { displayed, done };
86
+ };
87
+
88
+ // ── Start slide ──
89
+ const StartSlide = ({ onStart }: { onStart: () => void }) => {
90
+ const line1 = useSassyTypewriter("Choose your answers", 300);
91
+ const line2 = useSassyTypewriter(
92
+ "Pick your predictions first. We'll set up bet amounts after.",
93
+ line1.done ? 0 : 1800,
94
+ );
95
+
96
+ return (
97
+ <div className="flex flex-col items-center justify-center h-full px-8 gap-8">
98
+ <div className="flex flex-col items-center gap-3">
99
+ <p className="text-[20px] font-bold text-white text-center leading-tight min-h-[28px]" style={OUTFIT}>
100
+ {line1.displayed}
101
+ {!line1.done && <span className="inline-block w-[2px] h-[18px] bg-[#22E3E8] ml-[2px] align-middle animate-pulse" />}
102
+ </p>
103
+ <p className="text-[14px] text-white/50 font-medium text-center leading-relaxed max-w-[260px] min-h-[44px]" style={OUTFIT}>
104
+ {line2.displayed}
105
+ {line1.done && !line2.done && <span className="inline-block w-[2px] h-[14px] bg-white/30 ml-[2px] align-middle animate-pulse" />}
106
+ </p>
107
+ </div>
108
+
109
+ {/* Let's Go — simple cyan text link with arrow */}
110
+ {line2.done && (
111
+ <motion.button
112
+ initial={{ opacity: 0 }}
113
+ animate={{ opacity: 1 }}
114
+ transition={{ duration: 0.4 }}
115
+ onClick={onStart}
116
+ className="flex items-center gap-1.5 bg-transparent border-none cursor-pointer p-0"
117
+ >
118
+ <span
119
+ className="text-[14px] font-semibold"
120
+ style={{
121
+ ...OUTFIT,
122
+ color: "#22E3E8",
123
+ borderBottom: "1px solid rgba(34,227,232,0.4)",
124
+ }}
125
+ >
126
+ Let&apos;s Go
127
+ </span>
128
+ <ArrowRight size={14} style={{ color: "#22E3E8" }} />
129
+ </motion.button>
130
+ )}
131
+ </div>
132
+ );
133
+ };
134
+
135
+ // ── Single question slide ──
136
+ const QuestionSlide = ({
137
+ market,
138
+ selectedOption,
139
+ onSelect,
140
+ startingBalance,
141
+ stakeIncrements,
142
+ }: {
143
+ market: IChallengeMarket;
144
+ selectedOption: number | null;
145
+ onSelect: (optionIdx: number) => void;
146
+ startingBalance: number;
147
+ stakeIncrements: number;
148
+ }) => {
149
+ const chipCount = Math.floor(startingBalance / stakeIncrements);
150
+ const IconComponent = MARKET_ICONS[market.icon];
151
+
152
+ return (
153
+ <div className="flex flex-col items-center h-full px-5 pt-10">
154
+ {/* Icon — no bg/border */}
155
+ <motion.div
156
+ initial={{ opacity: 0, scale: 0.8 }}
157
+ animate={{ opacity: 1, scale: 1 }}
158
+ transition={{ duration: 0.4, delay: 0.1 }}
159
+ className="mb-5"
160
+ >
161
+ {IconComponent && (
162
+ <IconComponent size={28} style={{ color: market.accent }} />
163
+ )}
164
+ </motion.div>
165
+
166
+ {/* Question text */}
167
+ <motion.p
168
+ initial={{ opacity: 0, y: 16 }}
169
+ animate={{ opacity: 1, y: 0 }}
170
+ transition={{ duration: 0.5, delay: 0.2 }}
171
+ className="text-[18px] font-bold text-white text-center leading-snug mb-8 max-w-[280px]"
172
+ style={OUTFIT}
173
+ >
174
+ {market.question}
175
+ </motion.p>
176
+
177
+ {/* Options */}
178
+ <motion.div
179
+ initial={{ opacity: 0, y: 20 }}
180
+ animate={{ opacity: 1, y: 0 }}
181
+ transition={{ duration: 0.5, delay: 0.4 }}
182
+ className="w-full max-w-[320px] flex flex-col gap-2.5"
183
+ >
184
+ {market.options.map((opt, j) => {
185
+ const isSelected = selectedOption === j;
186
+
187
+ return (
188
+ <button
189
+ key={j}
190
+ onClick={() => onSelect(j)}
191
+ className="w-full flex items-center justify-between px-4 py-3.5 rounded-xl cursor-pointer"
192
+ style={{ background: "transparent" }}
193
+ >
194
+ <div className="flex items-center gap-2.5">
195
+ {/* Selection indicator — green circle with white tick */}
196
+ <motion.div
197
+ className="w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0"
198
+ animate={{
199
+ background: isSelected ? "#22C55E" : "rgba(0,0,0,0)",
200
+ border: isSelected ? "1.5px solid #22C55E" : "1.5px solid rgba(255,255,255,0.2)",
201
+ boxShadow: isSelected ? "0 0 12px rgba(34,197,94,0.5), 0 0 4px rgba(34,197,94,0.3)" : "none",
202
+ scale: isSelected ? [1, 1.3, 1] : 1,
203
+ }}
204
+ transition={{
205
+ background: { duration: 0.15 },
206
+ border: { duration: 0.15 },
207
+ boxShadow: { duration: 0.3 },
208
+ scale: { duration: 0.35, ease: [0.34, 1.56, 0.64, 1] },
209
+ }}
210
+ >
211
+ <AnimatePresence>
212
+ {isSelected && (
213
+ <motion.div
214
+ initial={{ scale: 0, rotate: -45 }}
215
+ animate={{ scale: 1, rotate: 0 }}
216
+ exit={{ scale: 0, rotate: 45 }}
217
+ transition={{ type: "spring", stiffness: 600, damping: 15, delay: 0.08 }}
218
+ >
219
+ <Check size={13} strokeWidth={3.5} style={{ color: "white" }} />
220
+ </motion.div>
221
+ )}
222
+ </AnimatePresence>
223
+ </motion.div>
224
+ <span
225
+ className="text-[14px] font-semibold text-white"
226
+ style={OUTFIT}
227
+ >
228
+ {opt.label}
229
+ </span>
230
+ </div>
231
+ <div className="flex items-center gap-1.5">
232
+ <span
233
+ className="text-[12px] font-bold"
234
+ style={{ ...OUTFIT, color: "#22E3E8" }}
235
+ >
236
+ {opt.odds}x
237
+ </span>
238
+ <PointsIcon size={12} />
239
+ </div>
240
+ </button>
241
+ );
242
+ })}
243
+ </motion.div>
244
+
245
+ {/* Hint text + locked points carousel */}
246
+ <motion.div
247
+ initial={{ opacity: 0, y: 14 }}
248
+ animate={{ opacity: 1, y: 0 }}
249
+ transition={{ duration: 0.5, delay: 0.6 }}
250
+ className="w-full mt-8 flex flex-col gap-2.5 -mx-5"
251
+ style={{ width: "calc(100% + 40px)", paddingLeft: 20 }}
252
+ >
253
+ <p className="text-[11px] text-white font-medium leading-relaxed" style={OUTFIT}>
254
+ Pick using your cricket knowledge — the{" "}
255
+ <span className="text-[#22E3E8] font-bold">{market.options[0].odds}x</span>
256
+ {" "}
257
+ <PointsIcon size={11} className="inline-block align-text-bottom" />
258
+ {" "}multiplier next to each option is how much your points grow if you{"'"}re right.
259
+ </p>
260
+ <p className="text-[11px] text-white/70 font-medium" style={OUTFIT}>
261
+ How many points to bet? You{"'"}ll decide that next.
262
+ </p>
263
+
264
+ {/* Locked chips carousel — extends past right edge.
265
+ Chips have reduced opacity directly so there's no flash before blur loads. */}
266
+ <div className="relative mt-0.5 overflow-hidden" style={{ marginRight: -20 }}>
267
+ <div className="flex items-center gap-1.5 py-1 opacity-40">
268
+ {Array.from({ length: chipCount }, (_, i) => (i + 1) * stakeIncrements).map(amt => (
269
+ <div
270
+ key={amt}
271
+ className="flex-shrink-0 flex items-center gap-0.5 px-2.5 py-[4px] rounded"
272
+ style={{
273
+ background: "rgba(34,227,232,0.08)",
274
+ border: "1px solid rgba(34,227,232,0.15)",
275
+ }}
276
+ >
277
+ <PointsIcon size={8} />
278
+ <span className="text-[9px] font-bold text-[#22E3E8]" style={OUTFIT}>{amt}</span>
279
+ </div>
280
+ ))}
281
+ </div>
282
+ {/* Right fade — vanishes into the edge */}
283
+ <div
284
+ className="absolute inset-y-0 right-0 w-20 pointer-events-none"
285
+ style={{ background: "linear-gradient(90deg, transparent 0%, rgba(0,0,0,0.85) 70%, black 100%)" }}
286
+ />
287
+ </div>
288
+ </motion.div>
289
+ </div>
290
+ );
291
+ };
292
+
293
+ // ── Main component ──
294
+ export const PreMatchQuestions = ({ config, onComplete }: PreMatchQuestionsProps) => {
295
+ const goTo = useGamePopupStore(s => s.goTo);
296
+
297
+ // -1 = start slide, 0..N-1 = question slides
298
+ const [step, setStep] = useState(-1);
299
+ const [direction, setDirection] = useState(1);
300
+ const [selections, setSelections] = useState<IUserBets>({});
301
+
302
+ const totalQuestions = config.markets.length;
303
+ // Total steps for progress bar: start + questions
304
+ const totalSteps = totalQuestions + 1;
305
+ // Current progress step (0-based): start=0, question 0=1, etc.
306
+ const currentStep = step + 1;
307
+
308
+ const handleStart = useCallback(() => {
309
+ setDirection(1);
310
+ setStep(0);
311
+ }, []);
312
+
313
+ const handleOptionSelect = useCallback((mIdx: number, optionIdx: number) => {
314
+ setSelections(prev => {
315
+ // Deselect if same option tapped again
316
+ if (prev[mIdx]?.optionIdx === optionIdx) {
317
+ const next = { ...prev };
318
+ delete next[mIdx];
319
+ return next;
320
+ }
321
+ // Only record the option pick — no points spent yet. User sets bet amounts on game screen.
322
+ return { ...prev, [mIdx]: { optionIdx, amount: 0, parlaySlot: null } };
323
+ });
324
+
325
+ // Auto-advance after a brief pause (only if selecting, not deselecting)
326
+ const isDeselect = selections[mIdx]?.optionIdx === optionIdx;
327
+ if (!isDeselect) {
328
+ setTimeout(() => {
329
+ if (mIdx < totalQuestions - 1) {
330
+ setDirection(1);
331
+ setStep(mIdx + 1);
332
+ } else {
333
+ const final = {
334
+ ...selections,
335
+ [mIdx]: { optionIdx, amount: 0, parlaySlot: null },
336
+ };
337
+ onComplete(final);
338
+ goTo("game");
339
+ }
340
+ }, 500);
341
+ }
342
+ }, [config.markets, selections, totalQuestions, onComplete, goTo]);
343
+
344
+ const seekNext = useCallback(() => {
345
+ if (step === -1) {
346
+ handleStart();
347
+ return;
348
+ }
349
+ if (step < totalQuestions - 1) {
350
+ setDirection(1);
351
+ setStep(prev => prev + 1);
352
+ } else {
353
+ // All done — go to game
354
+ onComplete(selections);
355
+ goTo("game");
356
+ }
357
+ }, [step, totalQuestions, selections, onComplete, goTo, handleStart]);
358
+
359
+ const seekBack = useCallback(() => {
360
+ if (step > -1) {
361
+ setDirection(-1);
362
+ setStep(prev => prev - 1);
363
+ }
364
+ }, [step]);
365
+
366
+ const slideVariants = {
367
+ enter: (dir: number) => ({ opacity: 0, x: dir * 60 }),
368
+ center: { opacity: 1, x: 0 },
369
+ exit: (dir: number) => ({ opacity: 0, x: dir * -60 }),
370
+ };
371
+
372
+ return (
373
+ <div className="relative w-full h-full flex flex-col bg-black">
374
+ {/* Skip button */}
375
+ <div className="flex justify-end px-4 pt-3">
376
+ <button
377
+ onClick={() => {
378
+ onComplete(selections);
379
+ goTo("game");
380
+ }}
381
+ className="text-[10px] text-white/25 uppercase tracking-wider font-semibold"
382
+ style={OUTFIT}
383
+ >
384
+ Skip
385
+ </button>
386
+ </div>
387
+
388
+ {/* Slide content */}
389
+ <div className="flex-1 overflow-hidden">
390
+ <AnimatePresence mode="wait" custom={direction}>
391
+ <motion.div
392
+ key={step}
393
+ custom={direction}
394
+ variants={slideVariants}
395
+ initial="enter"
396
+ animate="center"
397
+ exit="exit"
398
+ transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
399
+ className="w-full h-full"
400
+ >
401
+ {step === -1 ? (
402
+ <StartSlide onStart={handleStart} />
403
+ ) : (
404
+ <QuestionSlide
405
+ key={step}
406
+ market={config.markets[step]}
407
+ selectedOption={selections[step]?.optionIdx ?? null}
408
+ onSelect={(optionIdx) => handleOptionSelect(step, optionIdx)}
409
+ startingBalance={config.startingBalance}
410
+ stakeIncrements={config.parlayConfig.stakeIncrements}
411
+ />
412
+ )}
413
+ </motion.div>
414
+ </AnimatePresence>
415
+ </div>
416
+
417
+ {/* Bottom controls */}
418
+ <div className="px-5 pb-24 flex flex-col gap-3">
419
+ {/* Navigation controls — Rewind / counter / FastForward */}
420
+ <div className="flex items-center justify-center gap-6">
421
+ <button onClick={seekBack} className="p-1.5" style={{ opacity: 0.5 }}>
422
+ <Rewind size={18} className="text-white/90" />
423
+ </button>
424
+ <span className="text-[11px] text-white/90 font-semibold min-w-[36px] text-center" style={OUTFIT}>
425
+ {step >= 0 ? `${step + 1}/${totalQuestions}` : ""}
426
+ </span>
427
+ <button onClick={seekNext} className="p-1.5" style={{ opacity: 0.5 }}>
428
+ <FastForward size={18} className="text-white/90" />
429
+ </button>
430
+ </div>
431
+
432
+ {/* Segmented progress bar — market icons as checkpoints */}
433
+ <svg width="0" height="0" style={{ position: "absolute" }}>
434
+ <defs>
435
+ <linearGradient id="controlsGradient" x1="0%" y1="0%" x2="100%" y2="100%">
436
+ <stop offset="0%" stopColor="#22E3E8" />
437
+ <stop offset="100%" stopColor="#9945FF" />
438
+ </linearGradient>
439
+ </defs>
440
+ </svg>
441
+ <div style={{ display: "flex", alignItems: "center", width: "100%", gap: 4 }}>
442
+ {Array.from({ length: totalSteps }).map((_, i) => {
443
+ const filled = i < currentStep;
444
+ const passed = i <= currentStep;
445
+ // Step 0 = start slide (ArrowRight), step 1..N = market icons
446
+ const IconForStep = i === 0
447
+ ? ArrowRight
448
+ : MARKET_ICONS[config.markets[i - 1]?.icon] ?? MARKET_ICONS.coin;
449
+ return (
450
+ <div key={i} style={{ display: "flex", alignItems: "center", flex: i < totalSteps - 1 ? 1 : "none", gap: 4 }}>
451
+ <div
452
+ style={{
453
+ flexShrink: 0,
454
+ lineHeight: 0,
455
+ background: passed ? "linear-gradient(135deg, #22E3E8, #9945FF)" : "none",
456
+ WebkitBackgroundClip: passed ? "text" : undefined,
457
+ WebkitTextFillColor: passed ? "transparent" : undefined,
458
+ filter: passed ? "drop-shadow(0 0 4px rgba(34,227,232,0.4))" : "none",
459
+ transition: "filter 0.3s ease",
460
+ }}
461
+ >
462
+ <IconForStep
463
+ size={10}
464
+ strokeWidth={2.5}
465
+ style={{
466
+ color: passed ? "transparent" : "rgba(255,255,255,0.15)",
467
+ stroke: passed ? "url(#controlsGradient)" : "rgba(255,255,255,0.15)",
468
+ transition: "stroke 0.3s ease",
469
+ }}
470
+ />
471
+ </div>
472
+ {i < totalSteps - 1 && (
473
+ <div style={{ flex: 1, height: 3, borderRadius: 2, background: "rgba(255,255,255,0.08)", overflow: "hidden" }}>
474
+ <div
475
+ style={{
476
+ height: "100%", borderRadius: 2,
477
+ width: filled ? "100%" : "0%",
478
+ background: "linear-gradient(90deg, #22E3E8, #9945FF)",
479
+ transition: "width 0.4s ease",
480
+ }}
481
+ />
482
+ </div>
483
+ )}
484
+ </div>
485
+ );
486
+ })}
487
+ </div>
488
+ </div>
489
+ </div>
490
+ );
491
+ };