@devrongx/games 0.2.1 → 0.3.1
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 +1 -1
- package/src/index.ts +1 -1
- package/src/matches/MatchCalendar.tsx +311 -135
- package/src/matches/index.ts +1 -0
- package/src/matches/types.ts +15 -2
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -11,4 +11,4 @@ export type { IChallengeConfig, IChallengeMarket, IChallengeOption, IChallengeTe
|
|
|
11
11
|
|
|
12
12
|
// Matches — TD match calendar, data fetching, and types
|
|
13
13
|
export { configureTDClient, fetchTDMatches, useTDMatches, MatchCalendar } from "./matches";
|
|
14
|
-
export type { ITDClientConfig, ITDMatch, ITDTeam, ITDPlayer, ITDVenue, ITDTournament, ITDMatchesResponse, ITDMatchesParams, UseTDMatchesResult } from "./matches";
|
|
14
|
+
export type { ITDClientConfig, ITDMatch, ITDTeam, ITDPlayer, ITDVenue, ITDTournament, ITDStage, ITDMatchesResponse, ITDMatchesParams, UseTDMatchesResult } from "./matches";
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// @devrongx/games — matches/MatchCalendar.tsx
|
|
2
2
|
"use client";
|
|
3
3
|
|
|
4
|
-
import { useRef } from "react";
|
|
4
|
+
import { useRef, useMemo, useEffect } from "react";
|
|
5
5
|
import { motion } from "framer-motion";
|
|
6
|
-
import { Calendar, MapPin, Loader2, Zap } from "lucide-react";
|
|
6
|
+
import { Calendar, MapPin, Loader2, Trophy, Zap } from "lucide-react";
|
|
7
7
|
import { useTDMatches } from "./useTDMatches";
|
|
8
8
|
import type { ITDMatch } from "./types";
|
|
9
9
|
|
|
@@ -14,12 +14,44 @@ const MATCH_STATUS = {
|
|
|
14
14
|
COMPLETED: 5,
|
|
15
15
|
} as const;
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return
|
|
17
|
+
const OUTFIT = { fontFamily: "Outfit, sans-serif" };
|
|
18
|
+
|
|
19
|
+
/* ── Date helpers ── */
|
|
20
|
+
|
|
21
|
+
function toLocalDate(iso: string): Date {
|
|
22
|
+
return new Date(iso);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function formatDayMonth(d: Date): string {
|
|
26
|
+
return `${d.getDate()} ${d.toLocaleString("en-US", { month: "short" }).toUpperCase()}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function formatWeekday(d: Date): string {
|
|
30
|
+
return d.toLocaleString("en-US", { weekday: "short" }).toUpperCase();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function formatTime(d: Date): string {
|
|
34
|
+
return d.toLocaleString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isSameDay(a: Date, b: Date): boolean {
|
|
38
|
+
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isToday(d: Date): boolean {
|
|
42
|
+
return isSameDay(d, new Date());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isTomorrow(d: Date): boolean {
|
|
46
|
+
const t = new Date();
|
|
47
|
+
t.setDate(t.getDate() + 1);
|
|
48
|
+
return isSameDay(d, t);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getDayLabel(d: Date): string {
|
|
52
|
+
if (isToday(d)) return "TODAY";
|
|
53
|
+
if (isTomorrow(d)) return "TOMORROW";
|
|
54
|
+
return formatWeekday(d);
|
|
23
55
|
}
|
|
24
56
|
|
|
25
57
|
function getTeamShortName(match: ITDMatch, team: "a" | "b"): string {
|
|
@@ -27,144 +59,222 @@ function getTeamShortName(match: ITDMatch, team: "a" | "b"): string {
|
|
|
27
59
|
return t.short_name || t.name.slice(0, 3).toUpperCase();
|
|
28
60
|
}
|
|
29
61
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
62
|
+
/** Map popularity 0-10 to border shine opacity (0 = no shine, 10 = full intensity) */
|
|
63
|
+
function getShineOpacity(popularity: number): number {
|
|
64
|
+
if (popularity <= 0) return 0;
|
|
65
|
+
return Math.min(popularity / 10, 1);
|
|
33
66
|
}
|
|
34
67
|
|
|
35
|
-
|
|
36
|
-
|
|
68
|
+
/* ── Grouping: by date ── */
|
|
69
|
+
|
|
70
|
+
interface IMatchDay {
|
|
71
|
+
dateKey: string;
|
|
72
|
+
date: Date;
|
|
73
|
+
label: string;
|
|
74
|
+
dayMonth: string;
|
|
75
|
+
matches: ITDMatch[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function groupByDate(matches: ITDMatch[]): IMatchDay[] {
|
|
79
|
+
const map = new Map<string, { date: Date; matches: ITDMatch[] }>();
|
|
80
|
+
|
|
81
|
+
for (const m of matches) {
|
|
82
|
+
if (!m.scheduled_start_at) continue;
|
|
83
|
+
const d = toLocalDate(m.scheduled_start_at);
|
|
84
|
+
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
85
|
+
const existing = map.get(key);
|
|
86
|
+
if (existing) {
|
|
87
|
+
existing.matches.push(m);
|
|
88
|
+
} else {
|
|
89
|
+
map.set(key, { date: d, matches: [m] });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return Array.from(map.entries())
|
|
94
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
95
|
+
.map(([dateKey, { date, matches: dayMatches }]) => ({
|
|
96
|
+
dateKey,
|
|
97
|
+
date,
|
|
98
|
+
label: getDayLabel(date),
|
|
99
|
+
dayMonth: formatDayMonth(date),
|
|
100
|
+
matches: dayMatches,
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* ── Match card with border shine animation ── */
|
|
105
|
+
|
|
106
|
+
function MatchCard({ match }: { match: ITDMatch }) {
|
|
37
107
|
const isLive = match.status === MATCH_STATUS.LIVE;
|
|
38
108
|
const isCompleted = match.status === MATCH_STATUS.COMPLETED;
|
|
39
|
-
const
|
|
40
|
-
const venue = match.venue?.city || match.venue?.name;
|
|
109
|
+
const time = match.scheduled_start_at ? formatTime(toLocalDate(match.scheduled_start_at)) : "";
|
|
110
|
+
const venue = match.venue?.city?.trim() || match.venue?.name;
|
|
111
|
+
const shineOpacity = getShineOpacity(match.rating_popularity);
|
|
112
|
+
const showShine = shineOpacity > 0 && !isCompleted;
|
|
41
113
|
|
|
42
114
|
return (
|
|
43
|
-
<
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
115
|
+
<div className="relative w-full rounded-xl p-[1px] overflow-hidden">
|
|
116
|
+
{/* Spinning border shine — intensity based on popularity */}
|
|
117
|
+
{showShine && (
|
|
118
|
+
<div
|
|
119
|
+
className="absolute inset-[-50%] pointer-events-none"
|
|
120
|
+
style={{
|
|
121
|
+
background: isLive
|
|
122
|
+
? `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%)`
|
|
123
|
+
: `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%)`,
|
|
124
|
+
animation: `borderShine ${6 - shineOpacity * 2}s linear infinite`,
|
|
125
|
+
}}
|
|
126
|
+
/>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
{/* Static border for no-shine / completed */}
|
|
130
|
+
{!showShine && (
|
|
131
|
+
<div
|
|
132
|
+
className="absolute inset-0 rounded-xl"
|
|
133
|
+
style={{
|
|
134
|
+
border: isCompleted
|
|
135
|
+
? "1px solid rgba(255,255,255,0.06)"
|
|
136
|
+
: "1px solid rgba(255,255,255,0.08)",
|
|
137
|
+
}}
|
|
138
|
+
/>
|
|
139
|
+
)}
|
|
140
|
+
|
|
50
141
|
<div
|
|
51
|
-
className="relative
|
|
142
|
+
className="relative flex flex-col gap-1.5 rounded-xl px-3 py-2.5"
|
|
52
143
|
style={{
|
|
53
|
-
background: isLive
|
|
54
|
-
? "linear-gradient(160deg, rgba(248,60,197,0.15), rgba(248,60,197,0.03))"
|
|
55
|
-
: isCompleted
|
|
56
|
-
? "rgba(255,255,255,0.03)"
|
|
57
|
-
: "linear-gradient(160deg, rgba(34,227,232,0.08), rgba(153,69,255,0.04))",
|
|
58
|
-
border: isLive
|
|
59
|
-
? "1px solid rgba(248,60,197,0.3)"
|
|
60
|
-
: isCompleted
|
|
61
|
-
? "1px solid rgba(255,255,255,0.06)"
|
|
62
|
-
: "1px solid rgba(34,227,232,0.12)",
|
|
144
|
+
background: isLive ? "rgba(10,10,18,0.95)" : "rgba(10,10,18,0.98)",
|
|
63
145
|
}}
|
|
64
146
|
>
|
|
65
|
-
{/*
|
|
66
|
-
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
{
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
className="
|
|
81
|
-
style={{
|
|
82
|
-
fontFamily: "Outfit, sans-serif",
|
|
83
|
-
color: isLive ? "#f83cc5" : isCompleted ? "rgba(255,255,255,0.25)" : "rgba(34,227,232,0.7)",
|
|
84
|
-
}}
|
|
85
|
-
>
|
|
86
|
-
{date.dayMonth}
|
|
147
|
+
{/* Time + live indicator */}
|
|
148
|
+
<div className="flex items-center justify-between">
|
|
149
|
+
<span
|
|
150
|
+
className="text-[10px]"
|
|
151
|
+
style={{
|
|
152
|
+
...OUTFIT,
|
|
153
|
+
color: isLive ? "#f83cc5" : isCompleted ? "rgba(255,255,255,0.35)" : "#fff",
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
{time}
|
|
157
|
+
</span>
|
|
158
|
+
{isLive && (
|
|
159
|
+
<div className="flex items-center gap-1">
|
|
160
|
+
<span className="relative flex h-1.5 w-1.5">
|
|
161
|
+
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#f83cc5] opacity-75" />
|
|
162
|
+
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-[#f83cc5]" />
|
|
87
163
|
</span>
|
|
88
|
-
<span
|
|
89
|
-
|
|
90
|
-
style={{
|
|
91
|
-
fontFamily: "Outfit, sans-serif",
|
|
92
|
-
color: isCompleted ? "rgba(255,255,255,0.2)" : "rgba(255,255,255,0.35)",
|
|
93
|
-
}}
|
|
94
|
-
>
|
|
95
|
-
{date.time}
|
|
164
|
+
<span className="text-[8px] barlowcondensedBold tracking-widest" style={{ color: "#f83cc5" }}>
|
|
165
|
+
LIVE
|
|
96
166
|
</span>
|
|
97
167
|
</div>
|
|
98
168
|
)}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
<span
|
|
103
|
-
className="barlowcondensedBold text-[22px] leading-none"
|
|
104
|
-
style={{ color: isCompleted ? "rgba(255,255,255,0.35)" : "#fff" }}
|
|
105
|
-
>
|
|
106
|
-
{getTeamShortName(match, "a")}
|
|
107
|
-
</span>
|
|
108
|
-
<span
|
|
109
|
-
className="text-[9px] barlowCondensedSemiBold"
|
|
110
|
-
style={{ color: isCompleted ? "rgba(255,255,255,0.15)" : "rgba(255,255,255,0.25)" }}
|
|
111
|
-
>
|
|
112
|
-
v
|
|
169
|
+
{isCompleted && match.winner && (
|
|
170
|
+
<span className="text-[8px]" style={{ ...OUTFIT, color: "rgba(255,255,255,0.35)" }}>
|
|
171
|
+
{match.winner.short_name || match.winner.name} won
|
|
113
172
|
</span>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
{/* Teams */}
|
|
177
|
+
<div className="flex items-center gap-1.5 justify-center">
|
|
178
|
+
<span
|
|
179
|
+
className="barlowcondensedBold text-[20px] leading-none"
|
|
180
|
+
style={{ color: isCompleted ? "rgba(255,255,255,0.35)" : "#fff" }}
|
|
181
|
+
>
|
|
182
|
+
{getTeamShortName(match, "a")}
|
|
183
|
+
</span>
|
|
184
|
+
<span className="text-[9px]" style={{ color: "rgba(255,255,255,0.3)" }}>
|
|
185
|
+
v
|
|
186
|
+
</span>
|
|
187
|
+
<span
|
|
188
|
+
className="barlowcondensedBold text-[20px] leading-none"
|
|
189
|
+
style={{ color: isCompleted ? "rgba(255,255,255,0.35)" : "#fff" }}
|
|
190
|
+
>
|
|
191
|
+
{getTeamShortName(match, "b")}
|
|
192
|
+
</span>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
{/* Venue */}
|
|
196
|
+
{venue && (
|
|
197
|
+
<div className="flex items-center gap-1 justify-center">
|
|
198
|
+
<MapPin size={8} style={{ color: "rgba(255,255,255,0.3)" }} />
|
|
114
199
|
<span
|
|
115
|
-
className="
|
|
116
|
-
style={{ color:
|
|
200
|
+
className="text-[8px] truncate max-w-[100px]"
|
|
201
|
+
style={{ ...OUTFIT, color: "rgba(255,255,255,0.3)" }}
|
|
117
202
|
>
|
|
118
|
-
{
|
|
203
|
+
{venue}
|
|
119
204
|
</span>
|
|
120
205
|
</div>
|
|
206
|
+
)}
|
|
121
207
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
<
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
{
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
)}
|
|
134
|
-
|
|
135
|
-
{/* Status */}
|
|
136
|
-
<div className="mt-2">
|
|
137
|
-
{isLive ? (
|
|
138
|
-
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full" style={{ background: "rgba(248,60,197,0.15)" }}>
|
|
139
|
-
<Zap size={8} style={{ color: "#f83cc5" }} />
|
|
140
|
-
<span className="text-[8px] barlowcondensedBold tracking-widest" style={{ color: "#f83cc5" }}>
|
|
141
|
-
LIVE
|
|
142
|
-
</span>
|
|
143
|
-
</div>
|
|
144
|
-
) : isCompleted ? (
|
|
145
|
-
<span
|
|
146
|
-
className="text-[9px] tracking-wide"
|
|
147
|
-
style={{ fontFamily: "Outfit, sans-serif", color: "rgba(255,255,255,0.2)" }}
|
|
148
|
-
>
|
|
149
|
-
{match.winner ? `${match.winner.short_name || match.winner.name} won` : "Completed"}
|
|
150
|
-
</span>
|
|
151
|
-
) : isUpcoming ? (
|
|
152
|
-
<div
|
|
153
|
-
className="px-2 py-0.5 rounded-full"
|
|
154
|
-
style={{ background: "rgba(34,227,232,0.08)", border: "1px solid rgba(34,227,232,0.15)" }}
|
|
155
|
-
>
|
|
156
|
-
<span className="text-[8px] barlowcondensedBold tracking-widest" style={{ color: "rgba(34,227,232,0.6)" }}>
|
|
157
|
-
UPCOMING
|
|
158
|
-
</span>
|
|
159
|
-
</div>
|
|
160
|
-
) : null}
|
|
208
|
+
{/* Popularity indicator */}
|
|
209
|
+
{match.rating_popularity > 0 && !isCompleted && (
|
|
210
|
+
<div className="flex items-center gap-0.5 justify-center mt-0.5">
|
|
211
|
+
{Array.from({ length: Math.min(Math.ceil(match.rating_popularity / 2), 5) }).map((_, i) => (
|
|
212
|
+
<Zap
|
|
213
|
+
key={i}
|
|
214
|
+
size={7}
|
|
215
|
+
fill={isLive ? "#f83cc5" : "#22E3E8"}
|
|
216
|
+
style={{ color: isLive ? "#f83cc5" : "#22E3E8", opacity: 0.4 + i * 0.15 }}
|
|
217
|
+
/>
|
|
218
|
+
))}
|
|
161
219
|
</div>
|
|
162
|
-
|
|
220
|
+
)}
|
|
163
221
|
</div>
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/* ── Day column ── */
|
|
227
|
+
|
|
228
|
+
function DayColumn({ day, index }: { day: IMatchDay; index: number }) {
|
|
229
|
+
const hasToday = day.label === "TODAY";
|
|
230
|
+
const hasLive = day.matches.some((m) => m.status === MATCH_STATUS.LIVE);
|
|
231
|
+
const allCompleted = day.matches.every((m) => m.status === MATCH_STATUS.COMPLETED);
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<motion.div
|
|
235
|
+
initial={{ opacity: 0, y: 10 }}
|
|
236
|
+
animate={{ opacity: 1, y: 0 }}
|
|
237
|
+
transition={{ duration: 0.3, delay: index * 0.03 }}
|
|
238
|
+
className="shrink-0 flex flex-col gap-1.5"
|
|
239
|
+
style={{ scrollSnapAlign: "start", width: "140px" }}
|
|
240
|
+
>
|
|
241
|
+
{/* Day header */}
|
|
242
|
+
<div className="flex flex-col items-center gap-0.5 pb-1">
|
|
243
|
+
<span
|
|
244
|
+
className="text-[10px] barlowcondensedBold tracking-widest"
|
|
245
|
+
style={{
|
|
246
|
+
color: hasLive ? "#f83cc5" : hasToday ? "#22E3E8" : allCompleted ? "rgba(255,255,255,0.25)" : "#fff",
|
|
247
|
+
}}
|
|
248
|
+
>
|
|
249
|
+
{day.label}
|
|
250
|
+
</span>
|
|
251
|
+
<span
|
|
252
|
+
className="text-[12px] font-semibold"
|
|
253
|
+
style={{
|
|
254
|
+
...OUTFIT,
|
|
255
|
+
color: hasLive ? "#f83cc5" : hasToday ? "#22E3E8" : allCompleted ? "rgba(255,255,255,0.25)" : "#fff",
|
|
256
|
+
}}
|
|
257
|
+
>
|
|
258
|
+
{day.dayMonth}
|
|
259
|
+
</span>
|
|
260
|
+
{(hasToday || hasLive) && (
|
|
261
|
+
<div
|
|
262
|
+
className="w-5 h-[2px] rounded-full mt-0.5"
|
|
263
|
+
style={{ background: hasLive ? "#f83cc5" : "#22E3E8" }}
|
|
264
|
+
/>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
{/* Match cards for the day */}
|
|
269
|
+
{day.matches.map((match) => (
|
|
270
|
+
<MatchCard key={match.id} match={match} />
|
|
271
|
+
))}
|
|
164
272
|
</motion.div>
|
|
165
273
|
);
|
|
166
274
|
}
|
|
167
275
|
|
|
276
|
+
/* ── Main calendar component ── */
|
|
277
|
+
|
|
168
278
|
interface MatchCalendarProps {
|
|
169
279
|
tournamentId?: number;
|
|
170
280
|
}
|
|
@@ -173,13 +283,48 @@ export function MatchCalendar({ tournamentId }: MatchCalendarProps) {
|
|
|
173
283
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
174
284
|
const { matches, isLoading, error } = useTDMatches({
|
|
175
285
|
tournamentId,
|
|
176
|
-
limit:
|
|
286
|
+
limit: 100,
|
|
177
287
|
});
|
|
178
288
|
|
|
289
|
+
const days = useMemo(() => groupByDate(matches), [matches]);
|
|
290
|
+
|
|
291
|
+
// Auto-scroll to today or first upcoming day
|
|
292
|
+
const scrollTargetIndex = useMemo(() => {
|
|
293
|
+
const tIdx = days.findIndex((d) => isToday(d.date));
|
|
294
|
+
if (tIdx >= 0) return tIdx;
|
|
295
|
+
const now = new Date();
|
|
296
|
+
const uIdx = days.findIndex((d) => d.date >= now);
|
|
297
|
+
if (uIdx >= 0) return Math.max(0, uIdx);
|
|
298
|
+
return 0;
|
|
299
|
+
}, [days]);
|
|
300
|
+
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
if (scrollRef.current && scrollTargetIndex > 0) {
|
|
303
|
+
const container = scrollRef.current;
|
|
304
|
+
const target = container.children[scrollTargetIndex] as HTMLElement;
|
|
305
|
+
if (target) {
|
|
306
|
+
container.scrollTo({ left: target.offsetLeft - 20, behavior: "smooth" });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}, [scrollTargetIndex, days]);
|
|
310
|
+
|
|
311
|
+
// Stage groups
|
|
312
|
+
const stageGroups = useMemo(() => {
|
|
313
|
+
const stages = new Map<number, { name: string; orderNo: number }>();
|
|
314
|
+
for (const m of matches) {
|
|
315
|
+
if (m.stage && !stages.has(m.stage.id)) {
|
|
316
|
+
stages.set(m.stage.id, { name: m.stage.name, orderNo: m.stage.order_no });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return Array.from(stages.values()).sort((a, b) => a.orderNo - b.orderNo);
|
|
320
|
+
}, [matches]);
|
|
321
|
+
|
|
322
|
+
const tournament = matches[0]?.tournament;
|
|
323
|
+
|
|
179
324
|
if (isLoading) {
|
|
180
325
|
return (
|
|
181
|
-
<div className="flex items-center justify-center py-
|
|
182
|
-
<Loader2 size={16} className="animate-spin" style={{ color: "rgba(255,255,255,0.
|
|
326
|
+
<div className="flex items-center justify-center py-6">
|
|
327
|
+
<Loader2 size={16} className="animate-spin" style={{ color: "rgba(255,255,255,0.3)" }} />
|
|
183
328
|
</div>
|
|
184
329
|
);
|
|
185
330
|
}
|
|
@@ -187,27 +332,58 @@ export function MatchCalendar({ tournamentId }: MatchCalendarProps) {
|
|
|
187
332
|
if (error || matches.length === 0) return null;
|
|
188
333
|
|
|
189
334
|
return (
|
|
190
|
-
<div className="relative z-30 w-full
|
|
335
|
+
<div className="relative z-30 w-full mt-6 mb-4">
|
|
336
|
+
{/* Tournament info header */}
|
|
337
|
+
{tournament && (
|
|
338
|
+
<div className="px-5 mb-3">
|
|
339
|
+
<div className="flex items-center gap-2 mb-1">
|
|
340
|
+
<Trophy size={14} style={{ color: "#22E3E8" }} />
|
|
341
|
+
<span className="barlowcondensedBold text-[16px] tracking-wide text-white">
|
|
342
|
+
{tournament.name}
|
|
343
|
+
</span>
|
|
344
|
+
</div>
|
|
345
|
+
<div className="flex items-center gap-3">
|
|
346
|
+
{tournament.start_date && tournament.end_date && (
|
|
347
|
+
<span className="text-[11px] text-white" style={OUTFIT}>
|
|
348
|
+
{formatDayMonth(toLocalDate(tournament.start_date))} – {formatDayMonth(toLocalDate(tournament.end_date))}
|
|
349
|
+
</span>
|
|
350
|
+
)}
|
|
351
|
+
<span className="text-[11px] text-white" style={OUTFIT}>
|
|
352
|
+
{matches.length} matches
|
|
353
|
+
</span>
|
|
354
|
+
{stageGroups.length > 0 && (
|
|
355
|
+
<span
|
|
356
|
+
className="text-[9px] barlowcondensedBold tracking-widest px-2 py-0.5 rounded-full"
|
|
357
|
+
style={{
|
|
358
|
+
color: "#22E3E8",
|
|
359
|
+
background: "rgba(34,227,232,0.08)",
|
|
360
|
+
border: "1px solid rgba(34,227,232,0.15)",
|
|
361
|
+
}}
|
|
362
|
+
>
|
|
363
|
+
{stageGroups[0].name.toUpperCase()}
|
|
364
|
+
</span>
|
|
365
|
+
)}
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
)}
|
|
369
|
+
|
|
191
370
|
{/* Section header */}
|
|
192
371
|
<div className="flex items-center gap-2 px-5 mb-2.5">
|
|
193
|
-
<Calendar size={12} style={{ color: "
|
|
194
|
-
<span className="barlowCondensedSemiBold text-[13px] tracking-wider
|
|
195
|
-
|
|
196
|
-
</span>
|
|
197
|
-
<div className="flex-1 h-px" style={{ background: "rgba(255,255,255,0.04)" }} />
|
|
198
|
-
<span className="text-[10px]" style={{ fontFamily: "Outfit, sans-serif", color: "rgba(255,255,255,0.2)" }}>
|
|
199
|
-
{matches.length}
|
|
372
|
+
<Calendar size={12} style={{ color: "#22E3E8" }} />
|
|
373
|
+
<span className="barlowCondensedSemiBold text-[13px] tracking-wider text-white">
|
|
374
|
+
SCHEDULE
|
|
200
375
|
</span>
|
|
376
|
+
<div className="flex-1 h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
|
|
201
377
|
</div>
|
|
202
378
|
|
|
203
|
-
{/* Scrollable
|
|
379
|
+
{/* Scrollable day columns */}
|
|
204
380
|
<div
|
|
205
381
|
ref={scrollRef}
|
|
206
|
-
className="flex gap-2.5 overflow-x-auto px-5 pb-
|
|
382
|
+
className="flex gap-2.5 overflow-x-auto px-5 pb-3 scrollbar-hide"
|
|
207
383
|
style={{ scrollSnapType: "x mandatory", WebkitOverflowScrolling: "touch" }}
|
|
208
384
|
>
|
|
209
|
-
{
|
|
210
|
-
<
|
|
385
|
+
{days.map((day, i) => (
|
|
386
|
+
<DayColumn key={day.dateKey} day={day} index={i} />
|
|
211
387
|
))}
|
|
212
388
|
</div>
|
|
213
389
|
</div>
|
package/src/matches/index.ts
CHANGED
package/src/matches/types.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// @devrongx/games — matches/types.ts
|
|
2
2
|
// Types matching the Prisma response from TD's GET /api/v2/matches exactly.
|
|
3
3
|
// listMatches includes: { team_a: true, team_b: true, winner: true, venue: true,
|
|
4
|
-
// man_of_the_match: true, tournament: { select: { id, name
|
|
4
|
+
// man_of_the_match: true, tournament: { select: { id, name, start_date, end_date } },
|
|
5
|
+
// stage: { select: { id, name, stage_type, order_no } } }
|
|
5
6
|
|
|
6
7
|
// ── Prisma `teams` model (full row, returned by `include: { team_a: true }`) ──
|
|
7
8
|
export interface ITDTeam {
|
|
@@ -52,10 +53,20 @@ export interface ITDVenue {
|
|
|
52
53
|
updated_at: string;
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
// ── Prisma `tournaments` model (partial, returned by `select: { id, name }`) ──
|
|
56
|
+
// ── Prisma `tournaments` model (partial, returned by `select: { id, name, start_date, end_date }`) ──
|
|
56
57
|
export interface ITDTournament {
|
|
57
58
|
id: number;
|
|
58
59
|
name: string;
|
|
60
|
+
start_date: string | null;
|
|
61
|
+
end_date: string | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Prisma `stages` model (partial, returned by `select: { id, name, stage_type, order_no }`) ──
|
|
65
|
+
export interface ITDStage {
|
|
66
|
+
id: number;
|
|
67
|
+
name: string;
|
|
68
|
+
stage_type: number;
|
|
69
|
+
order_no: number;
|
|
59
70
|
}
|
|
60
71
|
|
|
61
72
|
// ── Prisma `matches` model with included relations ──
|
|
@@ -103,6 +114,7 @@ export interface ITDMatch {
|
|
|
103
114
|
venue: ITDVenue | null;
|
|
104
115
|
man_of_the_match: ITDPlayer | null;
|
|
105
116
|
tournament: ITDTournament | null;
|
|
117
|
+
stage: ITDStage | null;
|
|
106
118
|
}
|
|
107
119
|
|
|
108
120
|
// ── API response shape ──
|
|
@@ -115,6 +127,7 @@ export interface ITDMatchesResponse {
|
|
|
115
127
|
|
|
116
128
|
export interface ITDMatchesParams {
|
|
117
129
|
tournamentId?: number;
|
|
130
|
+
stageId?: number;
|
|
118
131
|
status?: number;
|
|
119
132
|
from?: string;
|
|
120
133
|
to?: string;
|