@devrongx/games 0.4.38 → 0.4.40

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.38",
3
+ "version": "0.4.40",
4
4
  "description": "Game UI components for sports prediction markets",
5
5
  "license": "MIT",
6
6
  "main": "./src/index.ts",
@@ -3,226 +3,139 @@
3
3
 
4
4
  import { useRef, useMemo, useEffect } from "react";
5
5
  import { motion } from "framer-motion";
6
- import { Calendar, MapPin, Loader2, Trophy, Zap, Target, Users } from "lucide-react";
6
+ import { Calendar, MapPin, Loader2, Trophy, Target, Users, Zap, Check } from "lucide-react";
7
7
  import { useTDMatches } from "./useTDMatches";
8
8
  import { useTDPools } from "../pools/hooks";
9
9
  import type { ITDPool } from "../pools/types";
10
10
  import { TDGameType, TDPoolStatus } from "../pools/types";
11
11
  import type { ITDMatch } from "./types";
12
12
 
13
- // TD match status enum values
14
- const MATCH_STATUS = {
15
- UPCOMING: 1,
16
- LIVE: 2,
17
- COMPLETED: 5,
18
- } as const;
13
+ const MS = { UPCOMING: 1, LIVE: 2, COMPLETED: 5 } as const;
14
+ const O = { fontFamily: "Outfit, sans-serif" };
19
15
 
20
- const OUTFIT = { fontFamily: "Outfit, sans-serif" };
16
+ /* ── helpers ── */
21
17
 
22
- /* ── Date helpers ── */
18
+ function toLocal(iso: string) { return new Date(iso); }
19
+ function fmtDay(d: Date) { return `${d.getDate()} ${d.toLocaleString("en-US", { month: "short" }).toUpperCase()}`; }
20
+ function fmtWeekday(d: Date) { return d.toLocaleString("en-US", { weekday: "short" }).toUpperCase(); }
21
+ function fmtTime(d: Date) { return d.toLocaleString("en-US", { hour: "numeric", minute: "2-digit", hour12: true }); }
22
+ function sameDay(a: Date, b: Date) { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); }
23
+ function today(d: Date) { return sameDay(d, new Date()); }
24
+ function tomorrow(d: Date) { const t = new Date(); t.setDate(t.getDate() + 1); return sameDay(d, t); }
25
+ function dayLabel(d: Date) { return today(d) ? "TODAY" : tomorrow(d) ? "TOMORROW" : fmtWeekday(d); }
26
+ function teamShort(m: ITDMatch, t: "a" | "b") { const x = t === "a" ? m.team_a : m.team_b; return x.short_name || x.name.slice(0, 3).toUpperCase(); }
27
+ function shineOp(p: number) { return p <= 0 ? 0 : Math.min(p / 10, 1); }
28
+ function hCount(n: number) { return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); }
29
+ function isFree(p: ITDPool) { const d = (p.display_price || "").toLowerCase().trim(); return d === "" || d === "free"; }
23
30
 
24
- function toLocalDate(iso: string): Date {
25
- return new Date(iso);
26
- }
27
-
28
- function formatDayMonth(d: Date): string {
29
- return `${d.getDate()} ${d.toLocaleString("en-US", { month: "short" }).toUpperCase()}`;
30
- }
31
-
32
- function formatWeekday(d: Date): string {
33
- return d.toLocaleString("en-US", { weekday: "short" }).toUpperCase();
34
- }
35
-
36
- function formatTime(d: Date): string {
37
- return d.toLocaleString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
38
- }
39
-
40
- function isSameDay(a: Date, b: Date): boolean {
41
- return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
42
- }
43
-
44
- function isToday(d: Date): boolean {
45
- return isSameDay(d, new Date());
46
- }
47
-
48
- function isTomorrow(d: Date): boolean {
49
- const t = new Date();
50
- t.setDate(t.getDate() + 1);
51
- return isSameDay(d, t);
52
- }
53
-
54
- function getDayLabel(d: Date): string {
55
- if (isToday(d)) return "TODAY";
56
- if (isTomorrow(d)) return "TOMORROW";
57
- return formatWeekday(d);
58
- }
59
-
60
- function getTeamShortName(match: ITDMatch, team: "a" | "b"): string {
61
- const t = team === "a" ? match.team_a : match.team_b;
62
- return t.short_name || t.name.slice(0, 3).toUpperCase();
63
- }
64
-
65
- /** Map popularity 0-10 to border shine opacity (0 = no shine, 10 = full intensity) */
66
- function getShineOpacity(popularity: number): number {
67
- if (popularity <= 0) return 0;
68
- return Math.min(popularity / 10, 1);
69
- }
70
-
71
- function humanizeCount(n: number): string {
72
- if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
73
- return String(n);
74
- }
75
-
76
- /** Determine if a pool is free based on display_price and entry_fee */
77
- function isFreePool(pool: ITDPool): boolean {
78
- const dp = (pool.display_price || "").toLowerCase().trim();
79
- return dp === "" || dp === "free" || (pool.entry_fee === 0 && dp === "");
80
- }
81
-
82
- /* ── Grouping: by date ── */
83
-
84
- interface IMatchDay {
85
- dateKey: string;
86
- date: Date;
87
- label: string;
88
- dayMonth: string;
89
- matches: ITDMatch[];
90
- }
31
+ interface IMatchDay { dateKey: string; date: Date; label: string; dayMonth: string; matches: ITDMatch[]; }
91
32
 
92
33
  function groupByDate(matches: ITDMatch[]): IMatchDay[] {
93
34
  const map = new Map<string, { date: Date; matches: ITDMatch[] }>();
94
-
95
35
  for (const m of matches) {
96
36
  if (!m.scheduled_start_at) continue;
97
- const d = toLocalDate(m.scheduled_start_at);
98
- const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
99
- const existing = map.get(key);
100
- if (existing) {
101
- existing.matches.push(m);
102
- } else {
103
- map.set(key, { date: d, matches: [m] });
104
- }
37
+ const d = toLocal(m.scheduled_start_at);
38
+ const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
39
+ const e = map.get(k);
40
+ if (e) e.matches.push(m); else map.set(k, { date: d, matches: [m] });
105
41
  }
106
-
107
42
  return Array.from(map.entries())
108
43
  .sort(([a], [b]) => a.localeCompare(b))
109
- .map(([dateKey, { date, matches: dayMatches }]) => ({
110
- dateKey,
111
- date,
112
- label: getDayLabel(date),
113
- dayMonth: formatDayMonth(date),
114
- matches: dayMatches,
115
- }));
44
+ .map(([dateKey, { date, matches }]) => ({ dateKey, date, label: dayLabel(date), dayMonth: fmtDay(date), matches }));
116
45
  }
117
46
 
118
- /* ── Pool row item ── */
47
+ /* ── game type config ── */
119
48
 
120
- const GAME_TYPE_STYLE: Record<number, { icon: typeof Target; color: string; bg: string; label: string }> = {
121
- [TDGameType.PRE_MATCH_BETS]: { icon: Target, color: "#22E3E8", bg: "rgba(34,227,232,0.06)", label: "Pre-Match Bets" },
122
- [TDGameType.FANTASY_11]: { icon: Users, color: "#9945FF", bg: "rgba(153,69,255,0.06)", label: "Fantasy 11" },
123
- [TDGameType.BALL_SEQUENCE]: { icon: Zap, color: "#FF9945", bg: "rgba(255,153,69,0.06)", label: "Ball Sequence" },
49
+ const GT: Record<number, { icon: typeof Target; color: string; label: string }> = {
50
+ [TDGameType.PRE_MATCH_BETS]: { icon: Target, color: "#22E3E8", label: "Pre-Match Bets" },
51
+ [TDGameType.FANTASY_11]: { icon: Users, color: "#9945FF", label: "Fantasy 11" },
52
+ [TDGameType.BALL_SEQUENCE]: { icon: Zap, color: "#FF9945", label: "Ball Sequence" },
124
53
  };
125
54
 
126
- function PoolRow({ pool, onPress }: { pool: ITDPool; onPress: (pool: ITDPool) => void }) {
127
- const style = GAME_TYPE_STYLE[pool.game_type] ?? { icon: Target, color: "#22E3E8", bg: "rgba(34,227,232,0.06)", label: "Play" };
128
- const isClosed = pool.status === TDPoolStatus.CLOSED || pool.status === TDPoolStatus.RESOLVING || pool.status === TDPoolStatus.COMPLETE;
129
- const isOpen = pool.status === TDPoolStatus.OPEN;
130
- const free = isFreePool(pool);
55
+ /* ── pool chip ── */
56
+
57
+ function PoolChip({ pool, color, onPress }: { pool: ITDPool; color: string; onPress: (p: ITDPool) => void }) {
58
+ const closed = pool.status === TDPoolStatus.CLOSED || pool.status === TDPoolStatus.RESOLVING || pool.status === TDPoolStatus.COMPLETE;
59
+ const open = pool.status === TDPoolStatus.OPEN;
60
+ const free = isFree(pool);
61
+ const joined = pool.has_entry === true;
131
62
 
132
63
  return (
133
64
  <motion.button
134
65
  onClick={() => onPress(pool)}
135
- whileTap={{ scale: 0.96 }}
136
- className="flex items-center gap-1.5 w-full px-2 py-1.5 rounded-lg"
66
+ whileTap={{ scale: 0.92 }}
67
+ className="relative flex items-center gap-1 shrink-0 rounded-md overflow-hidden"
137
68
  style={{
138
- background: isOpen ? style.bg.replace("0.06", "0.08") : style.bg,
139
- border: `1px solid ${style.color}${isOpen ? "30" : "18"}`,
140
- opacity: isClosed ? 0.45 : 1,
69
+ padding: "4px 8px",
70
+ background: joined ? `${color}22` : `${color}0a`,
71
+ border: `1px solid ${joined ? `${color}60` : `${color}${open ? "30" : "18"}`}`,
72
+ opacity: closed ? 0.4 : 1,
141
73
  }}
142
74
  >
143
- {/* Live dot */}
144
- {isOpen && (
145
- <span className="relative flex h-1.5 w-1.5 shrink-0">
146
- <span className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-60" style={{ backgroundColor: style.color }} />
147
- <span className="relative inline-flex rounded-full h-1.5 w-1.5" style={{ backgroundColor: style.color }} />
75
+ {/* Joined checkmark */}
76
+ {joined && (
77
+ <Check size={8} strokeWidth={3} style={{ color }} className="shrink-0" />
78
+ )}
79
+
80
+ {/* Live dot (only if open and not joined) */}
81
+ {open && !joined && (
82
+ <span className="relative flex h-1 w-1 shrink-0">
83
+ <span className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-50" style={{ backgroundColor: color }} />
84
+ <span className="relative inline-flex rounded-full h-1 w-1" style={{ backgroundColor: color }} />
148
85
  </span>
149
86
  )}
150
87
 
151
- {/* Label: "Play for Free" or cost */}
152
- <span className="flex-1 text-left text-[9px] font-bold tracking-wide" style={{ ...OUTFIT, color: free ? "rgba(255,255,255,0.9)" : "#fff" }}>
153
- {free ? "Play for Free" : pool.display_price}
88
+ {/* Price label */}
89
+ <span className="text-[9px] font-semibold leading-none" style={{ ...O, color: free ? "rgba(255,255,255,0.7)" : "#fff" }}>
90
+ {free ? "Free" : pool.display_price}
154
91
  </span>
155
92
 
156
- {/* Entry count for non-free pools, or always if there are entries */}
93
+ {/* Entry count */}
157
94
  {pool.entry_count > 0 && (
158
- <span className="text-[8px] font-medium shrink-0" style={{ ...OUTFIT, color: `${style.color}99` }}>
159
- {humanizeCount(pool.entry_count)} in
95
+ <span className="text-[7px] leading-none" style={{ ...O, color: `${color}80` }}>
96
+ {hCount(pool.entry_count)}
160
97
  </span>
161
98
  )}
162
99
  </motion.button>
163
100
  );
164
101
  }
165
102
 
166
- function MatchGameSection({
167
- matchId,
168
- isCompleted,
169
- onPoolPress,
170
- partnerSource,
171
- }: {
172
- matchId: number;
173
- isCompleted: boolean;
174
- onPoolPress?: (pool: ITDPool) => void;
175
- partnerSource?: string;
103
+ /* ── pools section inside a match card ── */
104
+
105
+ function MatchPools({ matchId, isCompleted, onPoolPress, partnerSource }: {
106
+ matchId: number; isCompleted: boolean; onPoolPress?: (p: ITDPool) => void; partnerSource?: string;
176
107
  }) {
177
108
  const { pools, loading } = useTDPools(matchId, partnerSource);
178
-
179
109
  if (isCompleted || loading || pools.length === 0) return null;
180
110
 
181
- // Group by game_type (integer)
111
+ // Group by game_type
182
112
  const grouped: Record<number, ITDPool[]> = {};
183
- for (const p of pools) {
184
- (grouped[p.game_type] ??= []).push(p);
185
- }
113
+ for (const p of pools) (grouped[p.game_type] ??= []).push(p);
186
114
 
187
- const gameTypes = (Object.keys(grouped) as unknown as number[])
188
- .map(Number)
189
- .filter((gt) => gt in GAME_TYPE_STYLE);
190
- if (gameTypes.length === 0) return null;
115
+ const types = (Object.keys(grouped) as unknown as number[]).map(Number).filter(t => t in GT);
116
+ if (types.length === 0) return null;
191
117
 
192
118
  return (
193
- <div className="flex flex-col gap-2 mt-1.5 pt-2" style={{ borderTop: "1px solid rgba(255,255,255,0.08)" }}>
194
- {gameTypes.map((gt) => {
195
- const style = GAME_TYPE_STYLE[gt];
196
- const Icon = style.icon;
197
- // Sort: free pools first, then by display_price ascending
198
- const sorted = [...grouped[gt]].sort((a, b) => {
199
- const aFree = isFreePool(a) ? 0 : 1;
200
- const bFree = isFreePool(b) ? 0 : 1;
201
- if (aFree !== bFree) return aFree - bFree;
202
- return a.entry_fee - b.entry_fee;
119
+ <div className="flex flex-col gap-1.5 mt-1 pt-1.5" style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }}>
120
+ {types.map(t => {
121
+ const s = GT[t]; const Icon = s.icon;
122
+ // Deduplicate: one free, then paid by price. Backend already sorts correctly.
123
+ const seen = new Set<string>();
124
+ const deduped = grouped[t].filter(p => {
125
+ const k = isFree(p) ? "free" : p.display_price;
126
+ if (seen.has(k)) return false;
127
+ seen.add(k);
128
+ return true;
203
129
  });
204
130
 
205
131
  return (
206
- <div key={gt} className="flex flex-col gap-1">
207
- {/* Game type pill header */}
208
- <div className="flex items-center gap-1 px-0.5 mb-0.5">
209
- <Icon size={9} style={{ color: style.color }} />
210
- <span className="text-[8px] barlowcondensedBold tracking-wide" style={{ color: style.color }}>
211
- {style.label}
212
- </span>
132
+ <div key={t}>
133
+ <div className="flex items-center gap-1 mb-1">
134
+ <Icon size={8} style={{ color: s.color }} />
135
+ <span className="text-[7px] font-bold tracking-wider uppercase" style={{ ...O, color: `${s.color}bb` }}>{s.label}</span>
213
136
  </div>
214
- {/* Pool rows */}
215
- <div className="flex flex-col gap-1">
216
- {sorted.map((pool, idx) => (
217
- <motion.div
218
- key={pool.id}
219
- initial={{ opacity: 0, y: 4 }}
220
- animate={{ opacity: 1, y: 0 }}
221
- transition={{ delay: idx * 0.03, duration: 0.2, ease: "easeOut" }}
222
- >
223
- <PoolRow pool={pool} onPress={onPoolPress ?? (() => {})} />
224
- </motion.div>
225
- ))}
137
+ <div className="flex gap-1 flex-wrap">
138
+ {deduped.map(p => <PoolChip key={p.id} pool={p} color={s.color} onPress={onPoolPress ?? (() => {})} />)}
226
139
  </div>
227
140
  </div>
228
141
  );
@@ -231,269 +144,147 @@ function MatchGameSection({
231
144
  );
232
145
  }
233
146
 
234
- /* ── Match card with border shine animation ── */
147
+ /* ── match card ── */
235
148
 
236
- function MatchCard({ match, onPoolPress, partnerSource }: { match: ITDMatch; onPoolPress?: (pool: ITDPool, match: ITDMatch) => void; partnerSource?: string }) {
237
- const isLive = match.status === MATCH_STATUS.LIVE;
238
- const isCompleted = match.status === MATCH_STATUS.COMPLETED;
239
- const time = match.scheduled_start_at ? formatTime(toLocalDate(match.scheduled_start_at)) : "";
149
+ function MatchCard({ match, onPoolPress, partnerSource }: { match: ITDMatch; onPoolPress?: (p: ITDPool, m: ITDMatch) => void; partnerSource?: string }) {
150
+ const live = match.status === MS.LIVE;
151
+ const done = match.status === MS.COMPLETED;
152
+ const time = match.scheduled_start_at ? fmtTime(toLocal(match.scheduled_start_at)) : "";
240
153
  const venue = match.venue?.city?.trim() || match.venue?.name;
241
- const shineOpacity = getShineOpacity(match.rating_popularity);
242
- const showShine = shineOpacity > 0 && !isCompleted;
154
+ const so = shineOp(match.rating_popularity);
155
+ const shine = so > 0 && !done;
243
156
 
244
157
  return (
245
158
  <div className="relative w-full rounded-xl p-[1px] overflow-hidden">
246
- {/* Spinning border shine intensity based on popularity */}
247
- {showShine && (
248
- <div
249
- className="absolute inset-[-50%] pointer-events-none"
250
- style={{
251
- background: isLive
252
- ? `conic-gradient(from 0deg, transparent 0%, transparent 65%, rgba(248,60,197,${shineOpacity * 0.6}) 75%, rgba(255,255,255,${shineOpacity * 0.7}) 80%, rgba(248,60,197,${shineOpacity * 0.6}) 85%, transparent 95%, transparent 100%)`
253
- : `conic-gradient(from 0deg, transparent 0%, transparent 70%, rgba(34,227,232,${shineOpacity * 0.5}) 78%, rgba(255,255,255,${shineOpacity * 0.6}) 80%, rgba(34,227,232,${shineOpacity * 0.5}) 82%, transparent 90%, transparent 100%)`,
254
- animation: `borderShine ${6 - shineOpacity * 2}s linear infinite`,
255
- }}
256
- />
159
+ {shine && (
160
+ <div className="absolute inset-[-50%] pointer-events-none" style={{
161
+ background: live
162
+ ? `conic-gradient(from 0deg, transparent 0%, transparent 65%, rgba(248,60,197,${so * 0.6}) 75%, rgba(255,255,255,${so * 0.7}) 80%, rgba(248,60,197,${so * 0.6}) 85%, transparent 95%, transparent 100%)`
163
+ : `conic-gradient(from 0deg, transparent 0%, transparent 70%, rgba(34,227,232,${so * 0.5}) 78%, rgba(255,255,255,${so * 0.6}) 80%, rgba(34,227,232,${so * 0.5}) 82%, transparent 90%, transparent 100%)`,
164
+ animation: `borderShine ${6 - so * 2}s linear infinite`,
165
+ }} />
257
166
  )}
167
+ {!shine && <div className="absolute inset-0 rounded-xl" style={{ border: done ? "1px solid rgba(255,255,255,0.05)" : "1px solid rgba(255,255,255,0.08)" }} />}
258
168
 
259
- {/* Static border for no-shine / completed */}
260
- {!showShine && (
261
- <div
262
- className="absolute inset-0 rounded-xl"
263
- style={{
264
- border: isCompleted
265
- ? "1px solid rgba(255,255,255,0.06)"
266
- : "1px solid rgba(255,255,255,0.08)",
267
- }}
268
- />
269
- )}
270
-
271
- <motion.div
272
- className="relative flex flex-col gap-1.5 rounded-xl px-3 py-2.5"
273
- style={{
274
- background: isLive
275
- ? "linear-gradient(160deg, rgba(14,10,24,0.98) 0%, rgba(10,8,20,0.98) 100%)"
276
- : "rgba(10,10,18,0.98)",
277
- }}
278
- whileHover={{ backgroundColor: "rgba(18,18,28,0.98)" }}
279
- transition={{ duration: 0.2 }}
169
+ <div
170
+ className="relative flex flex-col gap-1 rounded-xl px-2.5 py-2"
171
+ style={{ background: live ? "linear-gradient(160deg, rgba(14,10,24,0.98), rgba(10,8,20,0.98))" : "rgba(10,10,18,0.98)" }}
280
172
  >
281
- {/* Time + live indicator */}
282
- <div className="flex items-center justify-between gap-1">
283
- <span
284
- className="text-[10px] font-semibold"
285
- style={{
286
- ...OUTFIT,
287
- color: isLive ? "#f83cc5" : isCompleted ? "rgba(255,255,255,0.45)" : "rgba(255,255,255,0.7)",
288
- }}
289
- >
290
- {time}
291
- </span>
292
- {isLive && (
173
+ {/* time */}
174
+ <div className="flex items-center justify-between">
175
+ <span className="text-[9px] font-medium" style={{ ...O, color: live ? "#f83cc5" : done ? "rgba(255,255,255,0.35)" : "rgba(255,255,255,0.55)" }}>{time}</span>
176
+ {live && (
293
177
  <div className="flex items-center gap-1">
294
- <span className="relative flex h-1.5 w-1.5">
295
- <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#f83cc5] opacity-75" />
296
- <span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-[#f83cc5]" />
297
- </span>
298
- <span className="text-[8px] barlowcondensedBold tracking-widest" style={{ color: "#f83cc5" }}>
299
- LIVE
300
- </span>
178
+ <span className="relative flex h-1.5 w-1.5"><span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#f83cc5] opacity-75" /><span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-[#f83cc5]" /></span>
179
+ <span className="text-[7px] font-bold tracking-widest" style={{ ...O, color: "#f83cc5" }}>LIVE</span>
301
180
  </div>
302
181
  )}
303
- {isCompleted && match.winner && (
304
- <span className="text-[8px]" style={{ ...OUTFIT, color: "rgba(255,255,255,0.5)" }}>
305
- {match.winner.short_name || match.winner.name} won
306
- </span>
307
- )}
182
+ {done && match.winner && <span className="text-[7px]" style={{ ...O, color: "rgba(255,255,255,0.4)" }}>{match.winner.short_name || match.winner.name} won</span>}
308
183
  </div>
309
184
 
310
- {/* Teams */}
311
- <div className="flex items-center gap-2 justify-center">
312
- <span
313
- className="barlowcondensedBold text-[20px] leading-none tracking-wide"
314
- style={{ color: isCompleted ? "rgba(255,255,255,0.4)" : "#fff" }}
315
- >
316
- {getTeamShortName(match, "a")}
317
- </span>
318
- <span className="text-[9px] font-semibold" style={{ ...OUTFIT, color: "rgba(255,255,255,0.45)" }}>
319
- vs
320
- </span>
321
- <span
322
- className="barlowcondensedBold text-[20px] leading-none tracking-wide"
323
- style={{ color: isCompleted ? "rgba(255,255,255,0.4)" : "#fff" }}
324
- >
325
- {getTeamShortName(match, "b")}
326
- </span>
185
+ {/* teams */}
186
+ <div className="flex items-center gap-1.5 justify-center py-0.5">
187
+ <span className="barlowcondensedBold text-[18px] leading-none tracking-wide" style={{ color: done ? "rgba(255,255,255,0.35)" : "#fff" }}>{teamShort(match, "a")}</span>
188
+ <span className="text-[8px] font-medium" style={{ ...O, color: "rgba(255,255,255,0.35)" }}>vs</span>
189
+ <span className="barlowcondensedBold text-[18px] leading-none tracking-wide" style={{ color: done ? "rgba(255,255,255,0.35)" : "#fff" }}>{teamShort(match, "b")}</span>
327
190
  </div>
328
191
 
329
- {/* Venue */}
192
+ {/* venue */}
330
193
  {venue && (
331
- <div className="flex items-center gap-1 justify-center">
332
- <MapPin size={8} style={{ color: "rgba(255,255,255,0.45)" }} />
333
- <span
334
- className="text-[8px] truncate max-w-[120px]"
335
- style={{ ...OUTFIT, color: "rgba(255,255,255,0.6)" }}
336
- >
337
- {venue}
338
- </span>
194
+ <div className="flex items-center gap-0.5 justify-center">
195
+ <MapPin size={7} style={{ color: "rgba(255,255,255,0.35)" }} />
196
+ <span className="text-[7px] truncate max-w-[110px]" style={{ ...O, color: "rgba(255,255,255,0.5)" }}>{venue}</span>
339
197
  </div>
340
198
  )}
341
199
 
342
- {/* Game modes — driven by real pools */}
343
- <MatchGameSection
344
- matchId={match.id}
345
- isCompleted={isCompleted}
346
- onPoolPress={onPoolPress ? (pool) => onPoolPress(pool, match) : undefined}
347
- partnerSource={partnerSource}
348
- />
349
- </motion.div>
200
+ {/* pools */}
201
+ <MatchPools matchId={match.id} isCompleted={done} onPoolPress={onPoolPress ? p => onPoolPress(p, match) : undefined} partnerSource={partnerSource} />
202
+ </div>
350
203
  </div>
351
204
  );
352
205
  }
353
206
 
354
- /* ── Day column ── */
207
+ /* ── day column ── */
355
208
 
356
- function DayColumn({ day, index, onPoolPress, partnerSource }: { day: IMatchDay; index: number; onPoolPress?: (pool: ITDPool, match: ITDMatch) => void; partnerSource?: string }) {
357
- const hasToday = day.label === "TODAY";
358
- const hasLive = day.matches.some((m) => m.status === MATCH_STATUS.LIVE);
359
- const allCompleted = day.matches.every((m) => m.status === MATCH_STATUS.COMPLETED);
209
+ function DayCol({ day, idx, onPoolPress, ps }: { day: IMatchDay; idx: number; onPoolPress?: (p: ITDPool, m: ITDMatch) => void; ps?: string }) {
210
+ const isToday = day.label === "TODAY";
211
+ const hasLive = day.matches.some(m => m.status === MS.LIVE);
212
+ const allDone = day.matches.every(m => m.status === MS.COMPLETED);
360
213
 
361
214
  return (
362
215
  <motion.div
363
- initial={{ opacity: 0, y: 10 }}
216
+ initial={{ opacity: 0, y: 8 }}
364
217
  animate={{ opacity: 1, y: 0 }}
365
- transition={{ duration: 0.35, delay: index * 0.04, ease: [0.25, 0.46, 0.45, 0.94] }}
366
- className="shrink-0 flex flex-col gap-2"
367
- style={{ scrollSnapAlign: "start", width: "160px" }}
218
+ transition={{ duration: 0.3, delay: idx * 0.03 }}
219
+ className="shrink-0 flex flex-col gap-1.5"
220
+ style={{ scrollSnapAlign: "start", width: "150px" }}
368
221
  >
369
- {/* Day header */}
370
- <div className="flex flex-col items-center gap-0.5 pb-1">
371
- <span
372
- className="text-[10px] barlowcondensedBold tracking-widest"
373
- style={{
374
- color: hasLive ? "#f83cc5" : hasToday ? "#22E3E8" : allCompleted ? "rgba(255,255,255,0.35)" : "rgba(255,255,255,0.9)",
375
- }}
376
- >
377
- {day.label}
378
- </span>
379
- <span
380
- className="text-[12px] font-semibold"
381
- style={{
382
- ...OUTFIT,
383
- color: hasLive ? "#f83cc5" : hasToday ? "#22E3E8" : allCompleted ? "rgba(255,255,255,0.35)" : "#fff",
384
- }}
385
- >
386
- {day.dayMonth}
387
- </span>
388
- {(hasToday || hasLive) && (
389
- <div
390
- className="w-5 h-[2px] rounded-full mt-0.5"
391
- style={{ background: hasLive ? "#f83cc5" : "#22E3E8" }}
392
- />
393
- )}
222
+ <div className="flex flex-col items-center pb-1">
223
+ <span className="text-[9px] font-bold tracking-widest" style={{ ...O, color: hasLive ? "#f83cc5" : isToday ? "#22E3E8" : allDone ? "rgba(255,255,255,0.25)" : "rgba(255,255,255,0.8)" }}>{day.label}</span>
224
+ <span className="text-[11px] font-semibold" style={{ ...O, color: hasLive ? "#f83cc5" : isToday ? "#22E3E8" : allDone ? "rgba(255,255,255,0.25)" : "#fff" }}>{day.dayMonth}</span>
225
+ {(isToday || hasLive) && <div className="w-4 h-[1.5px] rounded-full mt-0.5" style={{ background: hasLive ? "#f83cc5" : "#22E3E8" }} />}
394
226
  </div>
395
-
396
- {/* Match cards for the day */}
397
- {day.matches.map((match) => (
398
- <MatchCard key={match.id} match={match} onPoolPress={onPoolPress} partnerSource={partnerSource} />
399
- ))}
227
+ {day.matches.map(m => <MatchCard key={m.id} match={m} onPoolPress={onPoolPress} partnerSource={ps} />)}
400
228
  </motion.div>
401
229
  );
402
230
  }
403
231
 
404
- /* ── Main calendar component ── */
232
+ /* ── main ── */
405
233
 
406
234
  interface MatchCalendarProps {
407
235
  tournamentId?: number;
408
- /** Called when user taps a pool pill. Receives both the pool and its parent match. */
409
236
  onPoolPress?: (pool: ITDPool, match: ITDMatch) => void;
410
- /** Filter pools to a specific partner source (e.g. "iamgame") */
411
237
  partnerSource?: string;
412
238
  }
413
239
 
414
240
  export function MatchCalendar({ tournamentId, onPoolPress, partnerSource }: MatchCalendarProps) {
415
- const scrollRef = useRef<HTMLDivElement>(null);
416
- const { matches, isLoading, error } = useTDMatches({
417
- tournamentId,
418
- limit: 100,
419
- });
420
-
241
+ const ref = useRef<HTMLDivElement>(null);
242
+ const { matches, isLoading, error } = useTDMatches({ tournamentId, limit: 100 });
421
243
  const days = useMemo(() => groupByDate(matches), [matches]);
422
244
 
423
- // Auto-scroll to today or first upcoming day
424
- const scrollTargetIndex = useMemo(() => {
425
- const tIdx = days.findIndex((d) => isToday(d.date));
426
- if (tIdx >= 0) return tIdx;
245
+ const scrollIdx = useMemo(() => {
246
+ const ti = days.findIndex(d => today(d.date));
247
+ if (ti >= 0) return ti;
427
248
  const now = new Date();
428
- const uIdx = days.findIndex((d) => d.date >= now);
429
- if (uIdx >= 0) return Math.max(0, uIdx);
430
- return 0;
249
+ const ui = days.findIndex(d => d.date >= now);
250
+ return ui >= 0 ? Math.max(0, ui) : 0;
431
251
  }, [days]);
432
252
 
433
253
  useEffect(() => {
434
- if (scrollRef.current && scrollTargetIndex > 0) {
435
- const container = scrollRef.current;
436
- const target = container.children[scrollTargetIndex] as HTMLElement;
437
- if (target) {
438
- container.scrollTo({ left: target.offsetLeft - 20, behavior: "smooth" });
439
- }
254
+ if (ref.current && scrollIdx > 0) {
255
+ const t = ref.current.children[scrollIdx] as HTMLElement;
256
+ if (t) ref.current.scrollTo({ left: t.offsetLeft - 16, behavior: "smooth" });
440
257
  }
441
- }, [scrollTargetIndex, days]);
258
+ }, [scrollIdx, days]);
442
259
 
443
- const tournament = matches[0]?.tournament;
444
-
445
- if (isLoading) {
446
- return (
447
- <div className="flex items-center justify-center py-6">
448
- <Loader2 size={16} className="animate-spin" style={{ color: "rgba(255,255,255,0.3)" }} />
449
- </div>
450
- );
451
- }
260
+ const tourney = matches[0]?.tournament;
452
261
 
262
+ if (isLoading) return <div className="flex items-center justify-center py-6"><Loader2 size={16} className="animate-spin" style={{ color: "rgba(255,255,255,0.3)" }} /></div>;
453
263
  if (error || matches.length === 0) return null;
454
264
 
455
265
  return (
456
- <div className="relative z-30 w-full mt-6 mb-4">
457
- {/* Tournament info header */}
458
- {tournament && (
459
- <div className="px-5 mb-3">
460
- <div className="flex items-center gap-2 mb-1">
461
- <Trophy size={14} style={{ color: "#22E3E8" }} />
462
- <span className="barlowcondensedBold text-[16px] tracking-wide text-white">
463
- {tournament.name}
464
- </span>
266
+ <div className="relative z-30 w-full mt-5 mb-3">
267
+ {tourney && (
268
+ <div className="px-5 mb-2.5">
269
+ <div className="flex items-center gap-2 mb-0.5">
270
+ <Trophy size={13} style={{ color: "#22E3E8" }} />
271
+ <span className="barlowcondensedBold text-[15px] tracking-wide text-white">{tourney.name}</span>
465
272
  </div>
466
273
  <div className="flex items-center gap-3">
467
- {tournament.start_date && tournament.end_date && (
468
- <span className="text-[11px] text-white" style={OUTFIT}>
469
- {formatDayMonth(toLocalDate(tournament.start_date))} – {formatDayMonth(toLocalDate(tournament.end_date))}
470
- </span>
471
- )}
472
- <span className="text-[11px] text-white" style={OUTFIT}>
473
- {matches.length} matches announced
474
- </span>
274
+ {tourney.start_date && tourney.end_date && <span className="text-[10px] text-white/50" style={O}>{fmtDay(toLocal(tourney.start_date))} – {fmtDay(toLocal(tourney.end_date))}</span>}
275
+ <span className="text-[10px] text-white/50" style={O}>{matches.length} matches</span>
475
276
  </div>
476
277
  </div>
477
278
  )}
478
279
 
479
- {/* Section header */}
480
- <div className="flex items-center gap-2 px-5 mb-3">
481
- <Calendar size={13} style={{ color: "#22E3E8" }} />
482
- <span className="barlowCondensedSemiBold text-[14px] tracking-wider text-white">
483
- SCHEDULE
484
- </span>
485
- <div className="flex-1 h-px" style={{ background: "rgba(255,255,255,0.08)" }} />
280
+ <div className="flex items-center gap-2 px-5 mb-2.5">
281
+ <Calendar size={12} style={{ color: "#22E3E8" }} />
282
+ <span className="barlowCondensedSemiBold text-[13px] tracking-wider text-white">SCHEDULE</span>
283
+ <div className="flex-1 h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
486
284
  </div>
487
285
 
488
- {/* Scrollable day columns */}
489
- <div
490
- ref={scrollRef}
491
- className="flex gap-3 overflow-x-auto px-5 pb-4 scrollbar-hide"
492
- style={{ scrollSnapType: "x mandatory", WebkitOverflowScrolling: "touch" }}
493
- >
494
- {days.map((day, i) => (
495
- <DayColumn key={day.dateKey} day={day} index={i} onPoolPress={onPoolPress} partnerSource={partnerSource} />
496
- ))}
286
+ <div ref={ref} className="flex gap-2.5 overflow-x-auto px-5 pb-3 scrollbar-hide" style={{ scrollSnapType: "x mandatory", WebkitOverflowScrolling: "touch" }}>
287
+ {days.map((d, i) => <DayCol key={d.dateKey} day={d} idx={i} onPoolPress={onPoolPress} ps={partnerSource} />)}
497
288
  </div>
498
289
  </div>
499
290
  );
@@ -60,6 +60,7 @@ export interface ITDPool {
60
60
  entry_count: number;
61
61
  challenge_count: number;
62
62
  currency: ITDCurrency;
63
+ has_entry?: boolean;
63
64
  }
64
65
 
65
66
  // ─── Challenge option (nested in pool detail) ────────────────────────────────