@devrongx/games 0.4.39 → 0.4.41

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.41",
4
4
  "description": "Game UI components for sports prediction markets",
5
5
  "license": "MIT",
6
6
  "main": "./src/index.ts",
@@ -3,192 +3,155 @@
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
+ /* ── USDC icon (inline SVG) ── */
56
+
57
+ function UsdcIcon({ size = 10 }: { size?: number }) {
58
+ return (
59
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className="shrink-0">
60
+ <path d="M12 22C17.5417 22 22 17.5417 22 12C22 6.4583 17.5417 2 12 2C6.4583 2 2 6.4583 2 12C2 17.5417 6.4583 22 12 22Z" fill="#2775CA"/>
61
+ <path d="M14.7493 13.582C14.7493 12.1237 13.8743 11.6237 12.1243 11.4154C10.8743 11.2487 10.6243 10.9154 10.6243 10.332C10.6243 9.74863 11.041 9.37373 11.8743 9.37373C12.6243 9.37373 13.041 9.62373 13.2493 10.2487C13.291 10.3737 13.416 10.457 13.541 10.457H14.2076C14.3743 10.457 14.4993 10.332 14.4993 10.1654V10.1237C14.3326 9.20703 13.5826 8.49873 12.6243 8.41543V7.41543C12.6243 7.24873 12.4993 7.12373 12.291 7.08203H11.666C11.4993 7.08203 11.3743 7.20703 11.3326 7.41543V8.37373C10.0826 8.54043 9.29102 9.37373 9.29102 10.4154C9.29102 11.7904 10.1243 12.332 11.8743 12.5404C13.041 12.7487 13.416 12.9987 13.416 13.6654C13.416 14.3321 12.8326 14.7904 12.041 14.7904C10.9576 14.7904 10.5826 14.332 10.4576 13.707C10.416 13.5404 10.291 13.457 10.166 13.457H9.45762C9.29102 13.457 9.16602 13.582 9.16602 13.7487V13.7904C9.33262 14.832 9.99932 15.582 11.3743 15.7904V16.7904C11.3743 16.957 11.4993 17.082 11.7076 17.1237H12.3326C12.4993 17.1237 12.6243 16.9987 12.666 16.7904V15.7904C13.916 15.582 14.7493 14.707 14.7493 13.582Z" fill="white"/>
62
+ <path d="M9.87581 17.9596C6.62581 16.793 4.95911 13.168 6.16751 9.95957C6.79251 8.20957 8.16751 6.87627 9.87581 6.25127C10.0425 6.16797 10.1258 6.04297 10.1258 5.83457V5.25127C10.1258 5.08457 10.0425 4.95957 9.87581 4.91797C9.83411 4.91797 9.75081 4.91797 9.70911 4.95957C5.75081 6.20957 3.58411 10.418 4.83411 14.3763C5.58411 16.7096 7.37581 18.5013 9.70911 19.2513C9.87581 19.3346 10.0425 19.2513 10.0841 19.0846C10.1258 19.043 10.1258 19.0013 10.1258 18.918V18.3346C10.1258 18.2096 10.0008 18.043 9.87581 17.9596ZM14.2925 4.95957C14.1258 4.87627 13.9591 4.95957 13.9175 5.12627C13.8758 5.16797 13.8758 5.20957 13.8758 5.29297V5.87627C13.8758 6.04297 14.0008 6.20957 14.1258 6.29297C17.3758 7.45957 19.0425 11.0846 17.8341 14.293C17.2091 16.043 15.8341 17.3763 14.1258 18.0013C13.9591 18.0846 13.8758 18.2096 13.8758 18.418V19.0013C13.8758 19.168 13.9591 19.293 14.1258 19.3346C14.1675 19.3346 14.2508 19.3346 14.2925 19.293C18.2508 18.043 20.4175 13.8346 19.1675 9.87627C18.4175 7.50127 16.5841 5.70957 14.2925 4.95957Z" fill="white"/>
63
+ </svg>
64
+ );
65
+ }
66
+
67
+ /* ── pool chip ── */
68
+
106
69
  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);
70
+ const closed = pool.status === TDPoolStatus.CLOSED || pool.status === TDPoolStatus.RESOLVING || pool.status === TDPoolStatus.COMPLETE;
71
+ const open = pool.status === TDPoolStatus.OPEN;
72
+ const free = isFree(pool);
73
+ const joined = pool.has_entry === true;
74
+ const paid = !free;
110
75
 
111
76
  return (
112
77
  <motion.button
113
78
  onClick={() => onPress(pool)}
114
79
  whileTap={{ scale: 0.92 }}
115
- className="flex items-center gap-1 px-2 py-1 rounded-md shrink-0"
80
+ className="relative flex items-center gap-1 shrink-0 rounded-md overflow-hidden"
116
81
  style={{
117
- background: `${color}${isOpen ? "14" : "0a"}`,
118
- border: `1px solid ${color}${isOpen ? "40" : "20"}`,
119
- opacity: isClosed ? 0.4 : 1,
82
+ padding: "4px 8px",
83
+ background: joined ? `${color}22` : `${color}0a`,
84
+ border: `1px solid ${joined ? `${color}60` : `${color}${open ? "30" : "18"}`}`,
85
+ opacity: closed ? 0.4 : 1,
120
86
  }}
121
87
  >
122
- {isOpen && (
88
+ {/* Joined checkmark */}
89
+ {joined && (
90
+ <Check size={8} strokeWidth={3} style={{ color }} className="shrink-0" />
91
+ )}
92
+
93
+ {/* USDC icon for paid pools (not joined) */}
94
+ {paid && !joined && <UsdcIcon size={10} />}
95
+
96
+ {/* Live dot for free pools (only if open and not joined) */}
97
+ {free && open && !joined && (
123
98
  <span className="relative flex h-1 w-1 shrink-0">
124
99
  <span className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-50" style={{ backgroundColor: color }} />
125
100
  <span className="relative inline-flex rounded-full h-1 w-1" style={{ backgroundColor: color }} />
126
101
  </span>
127
102
  )}
128
- <span className="text-[9px] font-bold" style={{ ...OUTFIT, color: free ? "rgba(255,255,255,0.8)" : "#fff" }}>
103
+
104
+ {/* Price label */}
105
+ <span className="text-[9px] font-semibold leading-none" style={{ ...O, color: free ? "rgba(255,255,255,0.7)" : "#fff" }}>
129
106
  {free ? "Free" : pool.display_price}
130
107
  </span>
108
+
109
+ {/* Entry count */}
131
110
  {pool.entry_count > 0 && (
132
- <span className="text-[7px]" style={{ ...OUTFIT, color: `${color}90` }}>
133
- {humanizeCount(pool.entry_count)}
111
+ <span className="text-[7px] leading-none" style={{ ...O, color: `${color}80` }}>
112
+ {hCount(pool.entry_count)}
134
113
  </span>
135
114
  )}
136
115
  </motion.button>
137
116
  );
138
117
  }
139
118
 
140
- function MatchGameSection({
141
- matchId, isCompleted, onPoolPress, partnerSource,
142
- }: {
143
- matchId: number;
144
- isCompleted: boolean;
145
- onPoolPress?: (pool: ITDPool) => void;
146
- partnerSource?: string;
119
+ /* ── pools section inside a match card ── */
120
+
121
+ function MatchPools({ matchId, isCompleted, onPoolPress, partnerSource }: {
122
+ matchId: number; isCompleted: boolean; onPoolPress?: (p: ITDPool) => void; partnerSource?: string;
147
123
  }) {
148
124
  const { pools, loading } = useTDPools(matchId, partnerSource);
149
125
  if (isCompleted || loading || pools.length === 0) return null;
150
126
 
127
+ // Group by game_type
151
128
  const grouped: Record<number, ITDPool[]> = {};
152
- for (const p of pools) { (grouped[p.game_type] ??= []).push(p); }
129
+ for (const p of pools) (grouped[p.game_type] ??= []).push(p);
153
130
 
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;
131
+ const types = (Object.keys(grouped) as unknown as number[]).map(Number).filter(t => t in GT);
132
+ if (types.length === 0) return null;
158
133
 
159
134
  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
135
+ <div className="flex flex-col gap-1.5 mt-1 pt-1.5" style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }}>
136
+ {types.map(t => {
137
+ const s = GT[t]; const Icon = s.icon;
138
+ // Deduplicate: one free, then paid by price. Backend already sorts correctly.
172
139
  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);
140
+ const deduped = grouped[t].filter(p => {
141
+ const k = isFree(p) ? "free" : p.display_price;
142
+ if (seen.has(k)) return false;
143
+ seen.add(k);
177
144
  return true;
178
145
  });
179
146
 
180
147
  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>
148
+ <div key={t}>
149
+ <div className="flex items-center gap-1 mb-1">
150
+ <Icon size={8} style={{ color: s.color }} />
151
+ <span className="text-[7px] font-bold tracking-wider uppercase" style={{ ...O, color: `${s.color}bb` }}>{s.label}</span>
187
152
  </div>
188
153
  <div className="flex gap-1 flex-wrap">
189
- {deduped.map((pool) => (
190
- <PoolChip key={pool.id} pool={pool} color={style.color} onPress={onPoolPress ?? (() => {})} />
191
- ))}
154
+ {deduped.map(p => <PoolChip key={p.id} pool={p} color={s.color} onPress={onPoolPress ?? (() => {})} />)}
192
155
  </div>
193
156
  </div>
194
157
  );
@@ -197,135 +160,92 @@ function MatchGameSection({
197
160
  );
198
161
  }
199
162
 
200
- /* ── Match card ── */
163
+ /* ── match card ── */
201
164
 
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)) : "";
165
+ function MatchCard({ match, onPoolPress, partnerSource }: { match: ITDMatch; onPoolPress?: (p: ITDPool, m: ITDMatch) => void; partnerSource?: string }) {
166
+ const live = match.status === MS.LIVE;
167
+ const done = match.status === MS.COMPLETED;
168
+ const time = match.scheduled_start_at ? fmtTime(toLocal(match.scheduled_start_at)) : "";
206
169
  const venue = match.venue?.city?.trim() || match.venue?.name;
207
- const shineOpacity = getShineOpacity(match.rating_popularity);
208
- const showShine = shineOpacity > 0 && !isCompleted;
170
+ const so = shineOp(match.rating_popularity);
171
+ const shine = so > 0 && !done;
209
172
 
210
173
  return (
211
174
  <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)" }} />
175
+ {shine && (
176
+ <div className="absolute inset-[-50%] pointer-events-none" style={{
177
+ background: live
178
+ ? `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%)`
179
+ : `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%)`,
180
+ animation: `borderShine ${6 - so * 2}s linear infinite`,
181
+ }} />
225
182
  )}
183
+ {!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
184
 
227
- <motion.div
185
+ <div
228
186
  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
- }}
187
+ 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
188
  >
235
- {/* Time row */}
189
+ {/* time */}
236
190
  <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 && (
191
+ <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>
192
+ {live && (
241
193
  <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>
194
+ <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>
195
+ <span className="text-[7px] font-bold tracking-widest" style={{ ...O, color: "#f83cc5" }}>LIVE</span>
247
196
  </div>
248
197
  )}
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
- )}
198
+ {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
199
  </div>
255
200
 
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>
201
+ {/* teams */}
202
+ <div className="flex items-center gap-1.5 justify-center py-0.5">
203
+ <span className="barlowcondensedBold text-[18px] leading-none tracking-wide" style={{ color: done ? "rgba(255,255,255,0.35)" : "#fff" }}>{teamShort(match, "a")}</span>
204
+ <span className="text-[8px] font-medium" style={{ ...O, color: "rgba(255,255,255,0.35)" }}>vs</span>
205
+ <span className="barlowcondensedBold text-[18px] leading-none tracking-wide" style={{ color: done ? "rgba(255,255,255,0.35)" : "#fff" }}>{teamShort(match, "b")}</span>
265
206
  </div>
266
207
 
267
- {/* Venue */}
208
+ {/* venue */}
268
209
  {venue && (
269
210
  <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>
211
+ <MapPin size={7} style={{ color: "rgba(255,255,255,0.35)" }} />
212
+ <span className="text-[7px] truncate max-w-[110px]" style={{ ...O, color: "rgba(255,255,255,0.5)" }}>{venue}</span>
274
213
  </div>
275
214
  )}
276
215
 
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>
216
+ {/* pools */}
217
+ <MatchPools matchId={match.id} isCompleted={done} onPoolPress={onPoolPress ? p => onPoolPress(p, match) : undefined} partnerSource={partnerSource} />
218
+ </div>
285
219
  </div>
286
220
  );
287
221
  }
288
222
 
289
- /* ── Day column ── */
223
+ /* ── day column ── */
290
224
 
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);
225
+ function DayCol({ day, idx, onPoolPress, ps }: { day: IMatchDay; idx: number; onPoolPress?: (p: ITDPool, m: ITDMatch) => void; ps?: string }) {
226
+ const isToday = day.label === "TODAY";
227
+ const hasLive = day.matches.some(m => m.status === MS.LIVE);
228
+ const allDone = day.matches.every(m => m.status === MS.COMPLETED);
295
229
 
296
230
  return (
297
231
  <motion.div
298
- initial={{ opacity: 0, y: 10 }}
232
+ initial={{ opacity: 0, y: 8 }}
299
233
  animate={{ opacity: 1, y: 0 }}
300
- transition={{ duration: 0.35, delay: index * 0.04, ease: [0.25, 0.46, 0.45, 0.94] }}
234
+ transition={{ duration: 0.3, delay: idx * 0.03 }}
301
235
  className="shrink-0 flex flex-col gap-1.5"
302
236
  style={{ scrollSnapAlign: "start", width: "150px" }}
303
237
  >
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
- )}
238
+ <div className="flex flex-col items-center pb-1">
239
+ <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>
240
+ <span className="text-[11px] font-semibold" style={{ ...O, color: hasLive ? "#f83cc5" : isToday ? "#22E3E8" : allDone ? "rgba(255,255,255,0.25)" : "#fff" }}>{day.dayMonth}</span>
241
+ {(isToday || hasLive) && <div className="w-4 h-[1.5px] rounded-full mt-0.5" style={{ background: hasLive ? "#f83cc5" : "#22E3E8" }} />}
320
242
  </div>
321
- {day.matches.map((match) => (
322
- <MatchCard key={match.id} match={match} onPoolPress={onPoolPress} partnerSource={partnerSource} />
323
- ))}
243
+ {day.matches.map(m => <MatchCard key={m.id} match={m} onPoolPress={onPoolPress} partnerSource={ps} />)}
324
244
  </motion.div>
325
245
  );
326
246
  }
327
247
 
328
- /* ── Main calendar ── */
248
+ /* ── main ── */
329
249
 
330
250
  interface MatchCalendarProps {
331
251
  tournamentId?: number;
@@ -334,53 +254,41 @@ interface MatchCalendarProps {
334
254
  }
335
255
 
336
256
  export function MatchCalendar({ tournamentId, onPoolPress, partnerSource }: MatchCalendarProps) {
337
- const scrollRef = useRef<HTMLDivElement>(null);
257
+ const ref = useRef<HTMLDivElement>(null);
338
258
  const { matches, isLoading, error } = useTDMatches({ tournamentId, limit: 100 });
339
259
  const days = useMemo(() => groupByDate(matches), [matches]);
340
260
 
341
- const scrollTargetIndex = useMemo(() => {
342
- const tIdx = days.findIndex((d) => isToday(d.date));
343
- if (tIdx >= 0) return tIdx;
261
+ const scrollIdx = useMemo(() => {
262
+ const ti = days.findIndex(d => today(d.date));
263
+ if (ti >= 0) return ti;
344
264
  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;
265
+ const ui = days.findIndex(d => d.date >= now);
266
+ return ui >= 0 ? Math.max(0, ui) : 0;
348
267
  }, [days]);
349
268
 
350
269
  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" });
270
+ if (ref.current && scrollIdx > 0) {
271
+ const t = ref.current.children[scrollIdx] as HTMLElement;
272
+ if (t) ref.current.scrollTo({ left: t.offsetLeft - 16, behavior: "smooth" });
354
273
  }
355
- }, [scrollTargetIndex, days]);
356
-
357
- const tournament = matches[0]?.tournament;
274
+ }, [scrollIdx, days]);
358
275
 
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
- }
276
+ const tourney = matches[0]?.tournament;
366
277
 
278
+ 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
279
  if (error || matches.length === 0) return null;
368
280
 
369
281
  return (
370
282
  <div className="relative z-30 w-full mt-5 mb-3">
371
- {tournament && (
283
+ {tourney && (
372
284
  <div className="px-5 mb-2.5">
373
285
  <div className="flex items-center gap-2 mb-0.5">
374
286
  <Trophy size={13} style={{ color: "#22E3E8" }} />
375
- <span className="barlowcondensedBold text-[15px] tracking-wide text-white">{tournament.name}</span>
287
+ <span className="barlowcondensedBold text-[15px] tracking-wide text-white">{tourney.name}</span>
376
288
  </div>
377
289
  <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>
290
+ {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>}
291
+ <span className="text-[10px] text-white/50" style={O}>{matches.length} matches</span>
384
292
  </div>
385
293
  </div>
386
294
  )}
@@ -388,17 +296,11 @@ export function MatchCalendar({ tournamentId, onPoolPress, partnerSource }: Matc
388
296
  <div className="flex items-center gap-2 px-5 mb-2.5">
389
297
  <Calendar size={12} style={{ color: "#22E3E8" }} />
390
298
  <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)" }} />
299
+ <div className="flex-1 h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
392
300
  </div>
393
301
 
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
- ))}
302
+ <div ref={ref} className="flex gap-2.5 overflow-x-auto px-5 pb-3 scrollbar-hide" style={{ scrollSnapType: "x mandatory", WebkitOverflowScrolling: "touch" }}>
303
+ {days.map((d, i) => <DayCol key={d.dateKey} day={d} idx={i} onPoolPress={onPoolPress} ps={partnerSource} />)}
402
304
  </div>
403
305
  </div>
404
306
  );
@@ -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) ────────────────────────────────