@devrongx/games 0.3.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devrongx/games",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Game UI components for sports prediction markets",
5
5
  "license": "MIT",
6
6
  "main": "./src/index.ts",
@@ -1,9 +1,9 @@
1
1
  // @devrongx/games — matches/MatchCalendar.tsx
2
2
  "use client";
3
3
 
4
- import { useRef, useMemo } from "react";
4
+ import { useRef, useMemo, useEffect } from "react";
5
5
  import { motion } from "framer-motion";
6
- import { Calendar, MapPin, Loader2, Zap, ChevronRight } 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,6 +14,8 @@ const MATCH_STATUS = {
14
14
  COMPLETED: 5,
15
15
  } as const;
16
16
 
17
+ const OUTFIT = { fontFamily: "Outfit, sans-serif" };
18
+
17
19
  /* ── Date helpers ── */
18
20
 
19
21
  function toLocalDate(iso: string): Date {
@@ -57,13 +59,19 @@ function getTeamShortName(match: ITDMatch, team: "a" | "b"): string {
57
59
  return t.short_name || t.name.slice(0, 3).toUpperCase();
58
60
  }
59
61
 
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);
66
+ }
67
+
60
68
  /* ── Grouping: by date ── */
61
69
 
62
70
  interface IMatchDay {
63
- dateKey: string; // YYYY-MM-DD
71
+ dateKey: string;
64
72
  date: Date;
65
- label: string; // "TODAY", "TOMORROW", "SAT", etc
66
- dayMonth: string; // "28 MAR"
73
+ label: string;
74
+ dayMonth: string;
67
75
  matches: ITDMatch[];
68
76
  }
69
77
 
@@ -93,91 +101,124 @@ function groupByDate(matches: ITDMatch[]): IMatchDay[] {
93
101
  }));
94
102
  }
95
103
 
96
- /* ── Mini match row inside a day column ── */
104
+ /* ── Match card with border shine animation ── */
97
105
 
98
- function MatchRow({ match }: { match: ITDMatch }) {
106
+ function MatchCard({ match }: { match: ITDMatch }) {
99
107
  const isLive = match.status === MATCH_STATUS.LIVE;
100
108
  const isCompleted = match.status === MATCH_STATUS.COMPLETED;
101
109
  const time = match.scheduled_start_at ? formatTime(toLocalDate(match.scheduled_start_at)) : "";
102
- const venue = match.venue?.city || match.venue?.name;
110
+ const venue = match.venue?.city?.trim() || match.venue?.name;
111
+ const shineOpacity = getShineOpacity(match.rating_popularity);
112
+ const showShine = shineOpacity > 0 && !isCompleted;
103
113
 
104
114
  return (
105
- <div
106
- className="flex flex-col gap-1 rounded-xl px-3 py-2.5"
107
- style={{
108
- background: isLive
109
- ? "rgba(248,60,197,0.08)"
110
- : isCompleted
111
- ? "rgba(255,255,255,0.02)"
112
- : "rgba(255,255,255,0.03)",
113
- border: isLive
114
- ? "1px solid rgba(248,60,197,0.2)"
115
- : "1px solid rgba(255,255,255,0.04)",
116
- }}
117
- >
118
- {/* Time + status row */}
119
- <div className="flex items-center justify-between">
120
- <span
121
- className="text-[10px]"
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"
122
120
  style={{
123
- fontFamily: "Outfit, sans-serif",
124
- color: isLive ? "#f83cc5" : isCompleted ? "rgba(255,255,255,0.2)" : "rgba(255,255,255,0.4)",
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
125
  }}
126
- >
127
- {time}
128
- </span>
129
- {isLive && (
130
- <div className="flex items-center gap-1">
131
- <span className="relative flex h-1.5 w-1.5">
132
- <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#f83cc5] opacity-75" />
133
- <span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-[#f83cc5]" />
134
- </span>
135
- <span className="text-[8px] barlowcondensedBold tracking-widest" style={{ color: "#f83cc5" }}>
136
- LIVE
137
- </span>
138
- </div>
139
- )}
140
- {isCompleted && match.winner && (
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
+
141
+ <div
142
+ className="relative flex flex-col gap-1.5 rounded-xl px-3 py-2.5"
143
+ style={{
144
+ background: isLive ? "rgba(10,10,18,0.95)" : "rgba(10,10,18,0.98)",
145
+ }}
146
+ >
147
+ {/* Time + live indicator */}
148
+ <div className="flex items-center justify-between">
141
149
  <span
142
- className="text-[8px] tracking-wide"
143
- style={{ fontFamily: "Outfit, sans-serif", color: "rgba(255,255,255,0.2)" }}
150
+ className="text-[10px]"
151
+ style={{
152
+ ...OUTFIT,
153
+ color: isLive ? "#f83cc5" : isCompleted ? "rgba(255,255,255,0.35)" : "#fff",
154
+ }}
144
155
  >
145
- {match.winner.short_name || match.winner.name} won
156
+ {time}
146
157
  </span>
147
- )}
148
- </div>
149
-
150
- {/* Teams */}
151
- <div className="flex items-center gap-1.5 justify-center">
152
- <span
153
- className="barlowcondensedBold text-[18px] leading-none"
154
- style={{ color: isCompleted ? "rgba(255,255,255,0.3)" : "#fff" }}
155
- >
156
- {getTeamShortName(match, "a")}
157
- </span>
158
- <span className="text-[9px]" style={{ color: "rgba(255,255,255,0.2)" }}>
159
- v
160
- </span>
161
- <span
162
- className="barlowcondensedBold text-[18px] leading-none"
163
- style={{ color: isCompleted ? "rgba(255,255,255,0.3)" : "#fff" }}
164
- >
165
- {getTeamShortName(match, "b")}
166
- </span>
167
- </div>
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]" />
163
+ </span>
164
+ <span className="text-[8px] barlowcondensedBold tracking-widest" style={{ color: "#f83cc5" }}>
165
+ LIVE
166
+ </span>
167
+ </div>
168
+ )}
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
172
+ </span>
173
+ )}
174
+ </div>
168
175
 
169
- {/* Venue */}
170
- {venue && (
171
- <div className="flex items-center gap-1 justify-center">
172
- <MapPin size={7} style={{ color: "rgba(255,255,255,0.15)" }} />
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>
173
187
  <span
174
- className="text-[8px] truncate max-w-[100px]"
175
- style={{ fontFamily: "Outfit, sans-serif", color: "rgba(255,255,255,0.15)" }}
188
+ className="barlowcondensedBold text-[20px] leading-none"
189
+ style={{ color: isCompleted ? "rgba(255,255,255,0.35)" : "#fff" }}
176
190
  >
177
- {venue}
191
+ {getTeamShortName(match, "b")}
178
192
  </span>
179
193
  </div>
180
- )}
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)" }} />
199
+ <span
200
+ className="text-[8px] truncate max-w-[100px]"
201
+ style={{ ...OUTFIT, color: "rgba(255,255,255,0.3)" }}
202
+ >
203
+ {venue}
204
+ </span>
205
+ </div>
206
+ )}
207
+
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
+ ))}
219
+ </div>
220
+ )}
221
+ </div>
181
222
  </div>
182
223
  );
183
224
  }
@@ -195,55 +236,38 @@ function DayColumn({ day, index }: { day: IMatchDay; index: number }) {
195
236
  animate={{ opacity: 1, y: 0 }}
196
237
  transition={{ duration: 0.3, delay: index * 0.03 }}
197
238
  className="shrink-0 flex flex-col gap-1.5"
198
- style={{ scrollSnapAlign: "start", width: "130px" }}
239
+ style={{ scrollSnapAlign: "start", width: "140px" }}
199
240
  >
200
241
  {/* Day header */}
201
242
  <div className="flex flex-col items-center gap-0.5 pb-1">
202
243
  <span
203
244
  className="text-[10px] barlowcondensedBold tracking-widest"
204
245
  style={{
205
- color: hasLive
206
- ? "#f83cc5"
207
- : hasToday
208
- ? "#22E3E8"
209
- : allCompleted
210
- ? "rgba(255,255,255,0.15)"
211
- : "rgba(255,255,255,0.4)",
246
+ color: hasLive ? "#f83cc5" : hasToday ? "#22E3E8" : allCompleted ? "rgba(255,255,255,0.25)" : "#fff",
212
247
  }}
213
248
  >
214
249
  {day.label}
215
250
  </span>
216
251
  <span
217
- className="text-[11px] font-semibold"
252
+ className="text-[12px] font-semibold"
218
253
  style={{
219
- fontFamily: "Outfit, sans-serif",
220
- color: hasLive
221
- ? "rgba(248,60,197,0.8)"
222
- : hasToday
223
- ? "rgba(34,227,232,0.7)"
224
- : allCompleted
225
- ? "rgba(255,255,255,0.15)"
226
- : "rgba(255,255,255,0.5)",
254
+ ...OUTFIT,
255
+ color: hasLive ? "#f83cc5" : hasToday ? "#22E3E8" : allCompleted ? "rgba(255,255,255,0.25)" : "#fff",
227
256
  }}
228
257
  >
229
258
  {day.dayMonth}
230
259
  </span>
231
- {/* Indicator dot */}
232
260
  {(hasToday || hasLive) && (
233
261
  <div
234
262
  className="w-5 h-[2px] rounded-full mt-0.5"
235
- style={{
236
- background: hasLive
237
- ? "#f83cc5"
238
- : "#22E3E8",
239
- }}
263
+ style={{ background: hasLive ? "#f83cc5" : "#22E3E8" }}
240
264
  />
241
265
  )}
242
266
  </div>
243
267
 
244
268
  {/* Match cards for the day */}
245
269
  {day.matches.map((match) => (
246
- <MatchRow key={match.id} match={match} />
270
+ <MatchCard key={match.id} match={match} />
247
271
  ))}
248
272
  </motion.div>
249
273
  );
@@ -264,48 +288,43 @@ export function MatchCalendar({ tournamentId }: MatchCalendarProps) {
264
288
 
265
289
  const days = useMemo(() => groupByDate(matches), [matches]);
266
290
 
267
- // Find the index of today or the first upcoming day to auto-scroll
268
- const todayIndex = useMemo(() => {
269
- const now = new Date();
270
- // Find today
291
+ // Auto-scroll to today or first upcoming day
292
+ const scrollTargetIndex = useMemo(() => {
271
293
  const tIdx = days.findIndex((d) => isToday(d.date));
272
294
  if (tIdx >= 0) return tIdx;
273
- // Find first upcoming day (date >= today)
295
+ const now = new Date();
274
296
  const uIdx = days.findIndex((d) => d.date >= now);
275
- if (uIdx >= 0) return Math.max(0, uIdx - 1); // show one before so user sees context
297
+ if (uIdx >= 0) return Math.max(0, uIdx);
276
298
  return 0;
277
299
  }, [days]);
278
300
 
279
- // Stage info from first match (all matches in a tournamentId query share same stage typically)
280
- const stageGroups = useMemo(() => {
281
- const stages = new Map<number, { name: string; orderNo: number; days: IMatchDay[] }>();
282
- for (const day of days) {
283
- for (const m of day.matches) {
284
- if (m.stage) {
285
- if (!stages.has(m.stage.id)) {
286
- stages.set(m.stage.id, { name: m.stage.name, orderNo: m.stage.order_no, days: [] });
287
- }
288
- }
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" });
289
307
  }
290
- // Assign day to stage (use first match's stage)
291
- const firstStage = day.matches.find((m) => m.stage)?.stage;
292
- if (firstStage && stages.has(firstStage.id)) {
293
- const group = stages.get(firstStage.id);
294
- if (group && !group.days.includes(day)) {
295
- group.days.push(day);
296
- }
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 });
297
317
  }
298
318
  }
299
319
  return Array.from(stages.values()).sort((a, b) => a.orderNo - b.orderNo);
300
- }, [days]);
320
+ }, [matches]);
301
321
 
302
- // Tournament date range from first match
303
322
  const tournament = matches[0]?.tournament;
304
323
 
305
324
  if (isLoading) {
306
325
  return (
307
- <div className="flex items-center justify-center py-4">
308
- <Loader2 size={16} className="animate-spin" style={{ color: "rgba(255,255,255,0.2)" }} />
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)" }} />
309
328
  </div>
310
329
  );
311
330
  }
@@ -313,54 +332,55 @@ export function MatchCalendar({ tournamentId }: MatchCalendarProps) {
313
332
  if (error || matches.length === 0) return null;
314
333
 
315
334
  return (
316
- <div className="relative z-30 w-full mb-2 mt-2">
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
+
317
370
  {/* Section header */}
318
- <div className="flex items-center gap-2 px-5 mb-1">
319
- <Calendar size={12} style={{ color: "rgba(34,227,232,0.6)" }} />
320
- <span className="barlowCondensedSemiBold text-[13px] tracking-wider" style={{ color: "rgba(255,255,255,0.45)" }}>
371
+ <div className="flex items-center gap-2 px-5 mb-2.5">
372
+ <Calendar size={12} style={{ color: "#22E3E8" }} />
373
+ <span className="barlowCondensedSemiBold text-[13px] tracking-wider text-white">
321
374
  SCHEDULE
322
375
  </span>
323
- {/* Tournament date range */}
324
- {tournament?.start_date && tournament?.end_date && (
325
- <>
326
- <ChevronRight size={10} style={{ color: "rgba(255,255,255,0.15)" }} />
327
- <span className="text-[10px]" style={{ fontFamily: "Outfit, sans-serif", color: "rgba(255,255,255,0.2)" }}>
328
- {formatDayMonth(toLocalDate(tournament.start_date))} – {formatDayMonth(toLocalDate(tournament.end_date))}
329
- </span>
330
- </>
331
- )}
332
- <div className="flex-1" />
333
- <span className="text-[10px]" style={{ fontFamily: "Outfit, sans-serif", color: "rgba(255,255,255,0.2)" }}>
334
- {matches.length} matches
335
- </span>
376
+ <div className="flex-1 h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
336
377
  </div>
337
378
 
338
- {/* Stage label if exists */}
339
- {stageGroups.length > 0 && stageGroups[0].name && (
340
- <div className="px-5 mb-2">
341
- <span
342
- className="text-[9px] barlowcondensedBold tracking-widest px-2 py-0.5 rounded-full"
343
- style={{
344
- color: "rgba(34,227,232,0.5)",
345
- background: "rgba(34,227,232,0.06)",
346
- border: "1px solid rgba(34,227,232,0.1)",
347
- }}
348
- >
349
- {stageGroups[0].name.toUpperCase()}
350
- </span>
351
- </div>
352
- )}
353
-
354
379
  {/* Scrollable day columns */}
355
380
  <div
356
381
  ref={scrollRef}
357
- className="flex gap-2 overflow-x-auto px-5 pb-2 scrollbar-hidden"
358
- style={{
359
- scrollSnapType: "x mandatory",
360
- WebkitOverflowScrolling: "touch",
361
- // Auto-scroll to today/upcoming
362
- ...(todayIndex > 0 ? {} : {}),
363
- }}
382
+ className="flex gap-2.5 overflow-x-auto px-5 pb-3 scrollbar-hide"
383
+ style={{ scrollSnapType: "x mandatory", WebkitOverflowScrolling: "touch" }}
364
384
  >
365
385
  {days.map((day, i) => (
366
386
  <DayColumn key={day.dateKey} day={day} index={i} />