@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.
- package/package.json +33 -0
- package/src/core/GamePopupShell.tsx +67 -0
- package/src/core/gamePopupStore.ts +30 -0
- package/src/core/types.ts +20 -0
- package/src/games/prematch-bets/LeaderboardRow.tsx +67 -0
- package/src/games/prematch-bets/PreMatchBetsPopup.tsx +97 -0
- package/src/games/prematch-bets/PreMatchGame.tsx +829 -0
- package/src/games/prematch-bets/PreMatchIntro.tsx +759 -0
- package/src/games/prematch-bets/PreMatchQuestions.tsx +491 -0
- package/src/games/prematch-bets/config.ts +604 -0
- package/src/games/prematch-bets/constants.tsx +33 -0
- package/src/games/prematch-bets/index.ts +4 -0
- package/src/index.ts +10 -0
- package/src/styles/animations.css +16 -0
|
@@ -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'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
|
+
};
|