@devrongx/games 0.4.39 → 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.39",
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,192 +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, Target, Users, Zap } 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
- const MATCH_STATUS = { UPCOMING: 1, LIVE: 2, COMPLETED: 5 } as const;
14
- const OUTFIT = { fontFamily: "Outfit, sans-serif" };
13
+ const MS = { UPCOMING: 1, LIVE: 2, COMPLETED: 5 } as const;
14
+ const O = { fontFamily: "Outfit, sans-serif" };
15
15
 
16
- /* ── Date helpers ── */
16
+ /* ── helpers ── */
17
17
 
18
- function toLocalDate(iso: string): Date { return new Date(iso); }
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"; }
19
30
 
20
- function formatDayMonth(d: Date): string {
21
- return `${d.getDate()} ${d.toLocaleString("en-US", { month: "short" }).toUpperCase()}`;
22
- }
23
-
24
- function formatWeekday(d: Date): string {
25
- return d.toLocaleString("en-US", { weekday: "short" }).toUpperCase();
26
- }
27
-
28
- function formatTime(d: Date): string {
29
- return d.toLocaleString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
30
- }
31
-
32
- function isSameDay(a: Date, b: Date): boolean {
33
- return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
34
- }
35
-
36
- function isToday(d: Date): boolean { return isSameDay(d, new Date()); }
37
-
38
- function isTomorrow(d: Date): boolean {
39
- const t = new Date();
40
- t.setDate(t.getDate() + 1);
41
- return isSameDay(d, t);
42
- }
43
-
44
- function getDayLabel(d: Date): string {
45
- if (isToday(d)) return "TODAY";
46
- if (isTomorrow(d)) return "TOMORROW";
47
- return formatWeekday(d);
48
- }
49
-
50
- function getTeamShortName(match: ITDMatch, team: "a" | "b"): string {
51
- const t = team === "a" ? match.team_a : match.team_b;
52
- return t.short_name || t.name.slice(0, 3).toUpperCase();
53
- }
54
-
55
- function getShineOpacity(popularity: number): number {
56
- if (popularity <= 0) return 0;
57
- return Math.min(popularity / 10, 1);
58
- }
59
-
60
- function humanizeCount(n: number): string {
61
- if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
62
- return String(n);
63
- }
64
-
65
- function isFreePool(pool: ITDPool): boolean {
66
- const dp = (pool.display_price || "").toLowerCase().trim();
67
- return dp === "" || dp === "free" || (pool.entry_fee === 0 && !dp);
68
- }
69
-
70
- /* ── Grouping: by date ── */
71
-
72
- interface IMatchDay {
73
- dateKey: string;
74
- date: Date;
75
- label: string;
76
- dayMonth: string;
77
- matches: ITDMatch[];
78
- }
31
+ interface IMatchDay { dateKey: string; date: Date; label: string; dayMonth: string; matches: ITDMatch[]; }
79
32
 
80
33
  function groupByDate(matches: ITDMatch[]): IMatchDay[] {
81
34
  const map = new Map<string, { date: Date; matches: ITDMatch[] }>();
82
35
  for (const m of matches) {
83
36
  if (!m.scheduled_start_at) continue;
84
- const d = toLocalDate(m.scheduled_start_at);
85
- const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
86
- const existing = map.get(key);
87
- if (existing) { existing.matches.push(m); }
88
- else { map.set(key, { date: d, matches: [m] }); }
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] });
89
41
  }
90
42
  return Array.from(map.entries())
91
43
  .sort(([a], [b]) => a.localeCompare(b))
92
- .map(([dateKey, { date, matches: dayMatches }]) => ({
93
- dateKey, date, label: getDayLabel(date), dayMonth: formatDayMonth(date), matches: dayMatches,
94
- }));
44
+ .map(([dateKey, { date, matches }]) => ({ dateKey, date, label: dayLabel(date), dayMonth: fmtDay(date), matches }));
95
45
  }
96
46
 
97
- /* ── Pool display ── */
47
+ /* ── game type config ── */
98
48
 
99
- const GAME_TYPE_STYLE: Record<number, { icon: typeof Target; color: string; label: string }> = {
49
+ const GT: Record<number, { icon: typeof Target; color: string; label: string }> = {
100
50
  [TDGameType.PRE_MATCH_BETS]: { icon: Target, color: "#22E3E8", label: "Pre-Match Bets" },
101
51
  [TDGameType.FANTASY_11]: { icon: Users, color: "#9945FF", label: "Fantasy 11" },
102
52
  [TDGameType.BALL_SEQUENCE]: { icon: Zap, color: "#FF9945", label: "Ball Sequence" },
103
53
  };
104
54
 
105
- /** Tiny inline pill for a single pool */
55
+ /* ── pool chip ── */
56
+
106
57
  function PoolChip({ pool, color, onPress }: { pool: ITDPool; color: string; onPress: (p: ITDPool) => void }) {
107
- const isClosed = pool.status === TDPoolStatus.CLOSED || pool.status === TDPoolStatus.RESOLVING || pool.status === TDPoolStatus.COMPLETE;
108
- const isOpen = pool.status === TDPoolStatus.OPEN;
109
- const free = isFreePool(pool);
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;
110
62
 
111
63
  return (
112
64
  <motion.button
113
65
  onClick={() => onPress(pool)}
114
66
  whileTap={{ scale: 0.92 }}
115
- className="flex items-center gap-1 px-2 py-1 rounded-md shrink-0"
67
+ className="relative flex items-center gap-1 shrink-0 rounded-md overflow-hidden"
116
68
  style={{
117
- background: `${color}${isOpen ? "14" : "0a"}`,
118
- border: `1px solid ${color}${isOpen ? "40" : "20"}`,
119
- opacity: isClosed ? 0.4 : 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,
120
73
  }}
121
74
  >
122
- {isOpen && (
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 && (
123
82
  <span className="relative flex h-1 w-1 shrink-0">
124
83
  <span className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-50" style={{ backgroundColor: color }} />
125
84
  <span className="relative inline-flex rounded-full h-1 w-1" style={{ backgroundColor: color }} />
126
85
  </span>
127
86
  )}
128
- <span className="text-[9px] font-bold" style={{ ...OUTFIT, color: free ? "rgba(255,255,255,0.8)" : "#fff" }}>
87
+
88
+ {/* Price label */}
89
+ <span className="text-[9px] font-semibold leading-none" style={{ ...O, color: free ? "rgba(255,255,255,0.7)" : "#fff" }}>
129
90
  {free ? "Free" : pool.display_price}
130
91
  </span>
92
+
93
+ {/* Entry count */}
131
94
  {pool.entry_count > 0 && (
132
- <span className="text-[7px]" style={{ ...OUTFIT, color: `${color}90` }}>
133
- {humanizeCount(pool.entry_count)}
95
+ <span className="text-[7px] leading-none" style={{ ...O, color: `${color}80` }}>
96
+ {hCount(pool.entry_count)}
134
97
  </span>
135
98
  )}
136
99
  </motion.button>
137
100
  );
138
101
  }
139
102
 
140
- function MatchGameSection({
141
- matchId, isCompleted, onPoolPress, partnerSource,
142
- }: {
143
- matchId: number;
144
- isCompleted: boolean;
145
- onPoolPress?: (pool: ITDPool) => void;
146
- 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;
147
107
  }) {
148
108
  const { pools, loading } = useTDPools(matchId, partnerSource);
149
109
  if (isCompleted || loading || pools.length === 0) return null;
150
110
 
111
+ // Group by game_type
151
112
  const grouped: Record<number, ITDPool[]> = {};
152
- for (const p of pools) { (grouped[p.game_type] ??= []).push(p); }
113
+ for (const p of pools) (grouped[p.game_type] ??= []).push(p);
153
114
 
154
- const gameTypes = (Object.keys(grouped) as unknown as number[])
155
- .map(Number)
156
- .filter((gt) => gt in GAME_TYPE_STYLE);
157
- 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;
158
117
 
159
118
  return (
160
- <div className="flex flex-col gap-1.5 mt-1.5 pt-1.5" style={{ borderTop: "1px solid rgba(255,255,255,0.07)" }}>
161
- {gameTypes.map((gt) => {
162
- const style = GAME_TYPE_STYLE[gt];
163
- const Icon = style.icon;
164
- // Sort: free first, then by display_price
165
- const sorted = [...grouped[gt]].sort((a, b) => {
166
- const af = isFreePool(a) ? 0 : 1;
167
- const bf = isFreePool(b) ? 0 : 1;
168
- if (af !== bf) return af - bf;
169
- return a.entry_fee - b.entry_fee;
170
- });
171
- // Deduplicate: keep only one free pool per game type
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.
172
123
  const seen = new Set<string>();
173
- const deduped = sorted.filter((p) => {
174
- const key = isFreePool(p) ? "free" : p.display_price;
175
- if (seen.has(key)) return false;
176
- seen.add(key);
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);
177
128
  return true;
178
129
  });
179
130
 
180
131
  return (
181
- <div key={gt} className="flex flex-col gap-1">
182
- <div className="flex items-center gap-1">
183
- <Icon size={8} style={{ color: style.color }} />
184
- <span className="text-[7px] barlowcondensedBold tracking-wider uppercase" style={{ color: `${style.color}cc` }}>
185
- {style.label}
186
- </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>
187
136
  </div>
188
137
  <div className="flex gap-1 flex-wrap">
189
- {deduped.map((pool) => (
190
- <PoolChip key={pool.id} pool={pool} color={style.color} onPress={onPoolPress ?? (() => {})} />
191
- ))}
138
+ {deduped.map(p => <PoolChip key={p.id} pool={p} color={s.color} onPress={onPoolPress ?? (() => {})} />)}
192
139
  </div>
193
140
  </div>
194
141
  );
@@ -197,135 +144,92 @@ function MatchGameSection({
197
144
  );
198
145
  }
199
146
 
200
- /* ── Match card ── */
147
+ /* ── match card ── */
201
148
 
202
- function MatchCard({ match, onPoolPress, partnerSource }: { match: ITDMatch; onPoolPress?: (pool: ITDPool, match: ITDMatch) => void; partnerSource?: string }) {
203
- const isLive = match.status === MATCH_STATUS.LIVE;
204
- const isCompleted = match.status === MATCH_STATUS.COMPLETED;
205
- 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)) : "";
206
153
  const venue = match.venue?.city?.trim() || match.venue?.name;
207
- const shineOpacity = getShineOpacity(match.rating_popularity);
208
- const showShine = shineOpacity > 0 && !isCompleted;
154
+ const so = shineOp(match.rating_popularity);
155
+ const shine = so > 0 && !done;
209
156
 
210
157
  return (
211
158
  <div className="relative w-full rounded-xl p-[1px] overflow-hidden">
212
- {showShine && (
213
- <div
214
- className="absolute inset-[-50%] pointer-events-none"
215
- style={{
216
- background: isLive
217
- ? `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%)`
218
- : `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%)`,
219
- animation: `borderShine ${6 - shineOpacity * 2}s linear infinite`,
220
- }}
221
- />
222
- )}
223
- {!showShine && (
224
- <div className="absolute inset-0 rounded-xl" style={{ border: isCompleted ? "1px solid rgba(255,255,255,0.06)" : "1px solid rgba(255,255,255,0.08)" }} />
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
+ }} />
225
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)" }} />}
226
168
 
227
- <motion.div
169
+ <div
228
170
  className="relative flex flex-col gap-1 rounded-xl px-2.5 py-2"
229
- style={{
230
- background: isLive
231
- ? "linear-gradient(160deg, rgba(14,10,24,0.98) 0%, rgba(10,8,20,0.98) 100%)"
232
- : "rgba(10,10,18,0.98)",
233
- }}
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)" }}
234
172
  >
235
- {/* Time row */}
173
+ {/* time */}
236
174
  <div className="flex items-center justify-between">
237
- <span className="text-[9px] font-semibold" style={{ ...OUTFIT, color: isLive ? "#f83cc5" : isCompleted ? "rgba(255,255,255,0.4)" : "rgba(255,255,255,0.6)" }}>
238
- {time}
239
- </span>
240
- {isLive && (
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 && (
241
177
  <div className="flex items-center gap-1">
242
- <span className="relative flex h-1.5 w-1.5">
243
- <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#f83cc5] opacity-75" />
244
- <span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-[#f83cc5]" />
245
- </span>
246
- <span className="text-[7px] barlowcondensedBold tracking-widest" style={{ color: "#f83cc5" }}>LIVE</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>
247
180
  </div>
248
181
  )}
249
- {isCompleted && match.winner && (
250
- <span className="text-[7px]" style={{ ...OUTFIT, color: "rgba(255,255,255,0.45)" }}>
251
- {match.winner.short_name || match.winner.name} won
252
- </span>
253
- )}
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>}
254
183
  </div>
255
184
 
256
- {/* Teams */}
257
- <div className="flex items-center gap-1.5 justify-center">
258
- <span className="barlowcondensedBold text-[18px] leading-none tracking-wide" style={{ color: isCompleted ? "rgba(255,255,255,0.4)" : "#fff" }}>
259
- {getTeamShortName(match, "a")}
260
- </span>
261
- <span className="text-[8px] font-semibold" style={{ ...OUTFIT, color: "rgba(255,255,255,0.4)" }}>vs</span>
262
- <span className="barlowcondensedBold text-[18px] leading-none tracking-wide" style={{ color: isCompleted ? "rgba(255,255,255,0.4)" : "#fff" }}>
263
- {getTeamShortName(match, "b")}
264
- </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>
265
190
  </div>
266
191
 
267
- {/* Venue */}
192
+ {/* venue */}
268
193
  {venue && (
269
194
  <div className="flex items-center gap-0.5 justify-center">
270
- <MapPin size={7} style={{ color: "rgba(255,255,255,0.4)" }} />
271
- <span className="text-[7px] truncate max-w-[110px]" style={{ ...OUTFIT, color: "rgba(255,255,255,0.55)" }}>
272
- {venue}
273
- </span>
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>
274
197
  </div>
275
198
  )}
276
199
 
277
- {/* Pools */}
278
- <MatchGameSection
279
- matchId={match.id}
280
- isCompleted={isCompleted}
281
- onPoolPress={onPoolPress ? (pool) => onPoolPress(pool, match) : undefined}
282
- partnerSource={partnerSource}
283
- />
284
- </motion.div>
200
+ {/* pools */}
201
+ <MatchPools matchId={match.id} isCompleted={done} onPoolPress={onPoolPress ? p => onPoolPress(p, match) : undefined} partnerSource={partnerSource} />
202
+ </div>
285
203
  </div>
286
204
  );
287
205
  }
288
206
 
289
- /* ── Day column ── */
207
+ /* ── day column ── */
290
208
 
291
- function DayColumn({ day, index, onPoolPress, partnerSource }: { day: IMatchDay; index: number; onPoolPress?: (pool: ITDPool, match: ITDMatch) => void; partnerSource?: string }) {
292
- const hasToday = day.label === "TODAY";
293
- const hasLive = day.matches.some((m) => m.status === MATCH_STATUS.LIVE);
294
- 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);
295
213
 
296
214
  return (
297
215
  <motion.div
298
- initial={{ opacity: 0, y: 10 }}
216
+ initial={{ opacity: 0, y: 8 }}
299
217
  animate={{ opacity: 1, y: 0 }}
300
- transition={{ duration: 0.35, delay: index * 0.04, ease: [0.25, 0.46, 0.45, 0.94] }}
218
+ transition={{ duration: 0.3, delay: idx * 0.03 }}
301
219
  className="shrink-0 flex flex-col gap-1.5"
302
220
  style={{ scrollSnapAlign: "start", width: "150px" }}
303
221
  >
304
- <div className="flex flex-col items-center gap-0.5 pb-1">
305
- <span
306
- className="text-[9px] barlowcondensedBold tracking-widest"
307
- style={{ color: hasLive ? "#f83cc5" : hasToday ? "#22E3E8" : allCompleted ? "rgba(255,255,255,0.3)" : "rgba(255,255,255,0.85)" }}
308
- >
309
- {day.label}
310
- </span>
311
- <span
312
- className="text-[11px] font-semibold"
313
- style={{ ...OUTFIT, color: hasLive ? "#f83cc5" : hasToday ? "#22E3E8" : allCompleted ? "rgba(255,255,255,0.3)" : "#fff" }}
314
- >
315
- {day.dayMonth}
316
- </span>
317
- {(hasToday || hasLive) && (
318
- <div className="w-4 h-[1.5px] rounded-full mt-0.5" style={{ background: hasLive ? "#f83cc5" : "#22E3E8" }} />
319
- )}
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" }} />}
320
226
  </div>
321
- {day.matches.map((match) => (
322
- <MatchCard key={match.id} match={match} onPoolPress={onPoolPress} partnerSource={partnerSource} />
323
- ))}
227
+ {day.matches.map(m => <MatchCard key={m.id} match={m} onPoolPress={onPoolPress} partnerSource={ps} />)}
324
228
  </motion.div>
325
229
  );
326
230
  }
327
231
 
328
- /* ── Main calendar ── */
232
+ /* ── main ── */
329
233
 
330
234
  interface MatchCalendarProps {
331
235
  tournamentId?: number;
@@ -334,53 +238,41 @@ interface MatchCalendarProps {
334
238
  }
335
239
 
336
240
  export function MatchCalendar({ tournamentId, onPoolPress, partnerSource }: MatchCalendarProps) {
337
- const scrollRef = useRef<HTMLDivElement>(null);
241
+ const ref = useRef<HTMLDivElement>(null);
338
242
  const { matches, isLoading, error } = useTDMatches({ tournamentId, limit: 100 });
339
243
  const days = useMemo(() => groupByDate(matches), [matches]);
340
244
 
341
- const scrollTargetIndex = useMemo(() => {
342
- const tIdx = days.findIndex((d) => isToday(d.date));
343
- if (tIdx >= 0) return tIdx;
245
+ const scrollIdx = useMemo(() => {
246
+ const ti = days.findIndex(d => today(d.date));
247
+ if (ti >= 0) return ti;
344
248
  const now = new Date();
345
- const uIdx = days.findIndex((d) => d.date >= now);
346
- if (uIdx >= 0) return Math.max(0, uIdx);
347
- return 0;
249
+ const ui = days.findIndex(d => d.date >= now);
250
+ return ui >= 0 ? Math.max(0, ui) : 0;
348
251
  }, [days]);
349
252
 
350
253
  useEffect(() => {
351
- if (scrollRef.current && scrollTargetIndex > 0) {
352
- const target = scrollRef.current.children[scrollTargetIndex] as HTMLElement;
353
- if (target) scrollRef.current.scrollTo({ left: target.offsetLeft - 16, behavior: "smooth" });
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" });
354
257
  }
355
- }, [scrollTargetIndex, days]);
258
+ }, [scrollIdx, days]);
356
259
 
357
- const tournament = matches[0]?.tournament;
358
-
359
- if (isLoading) {
360
- return (
361
- <div className="flex items-center justify-center py-6">
362
- <Loader2 size={16} className="animate-spin" style={{ color: "rgba(255,255,255,0.3)" }} />
363
- </div>
364
- );
365
- }
260
+ const tourney = matches[0]?.tournament;
366
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>;
367
263
  if (error || matches.length === 0) return null;
368
264
 
369
265
  return (
370
266
  <div className="relative z-30 w-full mt-5 mb-3">
371
- {tournament && (
267
+ {tourney && (
372
268
  <div className="px-5 mb-2.5">
373
269
  <div className="flex items-center gap-2 mb-0.5">
374
270
  <Trophy size={13} style={{ color: "#22E3E8" }} />
375
- <span className="barlowcondensedBold text-[15px] tracking-wide text-white">{tournament.name}</span>
271
+ <span className="barlowcondensedBold text-[15px] tracking-wide text-white">{tourney.name}</span>
376
272
  </div>
377
273
  <div className="flex items-center gap-3">
378
- {tournament.start_date && tournament.end_date && (
379
- <span className="text-[10px] text-white/50" style={OUTFIT}>
380
- {formatDayMonth(toLocalDate(tournament.start_date))} – {formatDayMonth(toLocalDate(tournament.end_date))}
381
- </span>
382
- )}
383
- <span className="text-[10px] text-white/50" style={OUTFIT}>{matches.length} matches</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>
384
276
  </div>
385
277
  </div>
386
278
  )}
@@ -388,17 +280,11 @@ export function MatchCalendar({ tournamentId, onPoolPress, partnerSource }: Matc
388
280
  <div className="flex items-center gap-2 px-5 mb-2.5">
389
281
  <Calendar size={12} style={{ color: "#22E3E8" }} />
390
282
  <span className="barlowCondensedSemiBold text-[13px] tracking-wider text-white">SCHEDULE</span>
391
- <div className="flex-1 h-px" style={{ background: "rgba(255,255,255,0.07)" }} />
283
+ <div className="flex-1 h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
392
284
  </div>
393
285
 
394
- <div
395
- ref={scrollRef}
396
- className="flex gap-2.5 overflow-x-auto px-5 pb-3 scrollbar-hide"
397
- style={{ scrollSnapType: "x mandatory", WebkitOverflowScrolling: "touch" }}
398
- >
399
- {days.map((day, i) => (
400
- <DayColumn key={day.dateKey} day={day} index={i} onPoolPress={onPoolPress} partnerSource={partnerSource} />
401
- ))}
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} />)}
402
288
  </div>
403
289
  </div>
404
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) ────────────────────────────────