@devrongx/games 0.3.0 → 0.3.2

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.2",
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, Target, Users } 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,148 @@ 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">
173
178
  <span
174
- className="text-[8px] truncate max-w-[100px]"
175
- style={{ fontFamily: "Outfit, sans-serif", color: "rgba(255,255,255,0.15)" }}
179
+ className="barlowcondensedBold text-[20px] leading-none"
180
+ style={{ color: isCompleted ? "rgba(255,255,255,0.35)" : "#fff" }}
176
181
  >
177
- {venue}
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")}
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
+
222
+ {/* Game modes */}
223
+ {!isCompleted && (
224
+ <div className="flex flex-col gap-1 mt-1 pt-1.5" style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }}>
225
+ <div
226
+ className="flex items-center gap-1.5 px-2 py-1 rounded-lg"
227
+ style={{ background: "rgba(34,227,232,0.06)" }}
228
+ >
229
+ <Target size={9} style={{ color: "#22E3E8" }} />
230
+ <span className="text-[8px] barlowcondensedBold tracking-wide text-white">
231
+ Pre-Match Bets
232
+ </span>
233
+ </div>
234
+ <div
235
+ className="flex items-center gap-1.5 px-2 py-1 rounded-lg"
236
+ style={{ background: "rgba(153,69,255,0.06)" }}
237
+ >
238
+ <Users size={9} style={{ color: "#9945FF" }} />
239
+ <span className="text-[8px] barlowcondensedBold tracking-wide text-white">
240
+ Fantasy 11
241
+ </span>
242
+ </div>
243
+ </div>
244
+ )}
245
+ </div>
181
246
  </div>
182
247
  );
183
248
  }
@@ -195,55 +260,38 @@ function DayColumn({ day, index }: { day: IMatchDay; index: number }) {
195
260
  animate={{ opacity: 1, y: 0 }}
196
261
  transition={{ duration: 0.3, delay: index * 0.03 }}
197
262
  className="shrink-0 flex flex-col gap-1.5"
198
- style={{ scrollSnapAlign: "start", width: "130px" }}
263
+ style={{ scrollSnapAlign: "start", width: "140px" }}
199
264
  >
200
265
  {/* Day header */}
201
266
  <div className="flex flex-col items-center gap-0.5 pb-1">
202
267
  <span
203
268
  className="text-[10px] barlowcondensedBold tracking-widest"
204
269
  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)",
270
+ color: hasLive ? "#f83cc5" : hasToday ? "#22E3E8" : allCompleted ? "rgba(255,255,255,0.25)" : "#fff",
212
271
  }}
213
272
  >
214
273
  {day.label}
215
274
  </span>
216
275
  <span
217
- className="text-[11px] font-semibold"
276
+ className="text-[12px] font-semibold"
218
277
  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)",
278
+ ...OUTFIT,
279
+ color: hasLive ? "#f83cc5" : hasToday ? "#22E3E8" : allCompleted ? "rgba(255,255,255,0.25)" : "#fff",
227
280
  }}
228
281
  >
229
282
  {day.dayMonth}
230
283
  </span>
231
- {/* Indicator dot */}
232
284
  {(hasToday || hasLive) && (
233
285
  <div
234
286
  className="w-5 h-[2px] rounded-full mt-0.5"
235
- style={{
236
- background: hasLive
237
- ? "#f83cc5"
238
- : "#22E3E8",
239
- }}
287
+ style={{ background: hasLive ? "#f83cc5" : "#22E3E8" }}
240
288
  />
241
289
  )}
242
290
  </div>
243
291
 
244
292
  {/* Match cards for the day */}
245
293
  {day.matches.map((match) => (
246
- <MatchRow key={match.id} match={match} />
294
+ <MatchCard key={match.id} match={match} />
247
295
  ))}
248
296
  </motion.div>
249
297
  );
@@ -264,48 +312,43 @@ export function MatchCalendar({ tournamentId }: MatchCalendarProps) {
264
312
 
265
313
  const days = useMemo(() => groupByDate(matches), [matches]);
266
314
 
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
315
+ // Auto-scroll to today or first upcoming day
316
+ const scrollTargetIndex = useMemo(() => {
271
317
  const tIdx = days.findIndex((d) => isToday(d.date));
272
318
  if (tIdx >= 0) return tIdx;
273
- // Find first upcoming day (date >= today)
319
+ const now = new Date();
274
320
  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
321
+ if (uIdx >= 0) return Math.max(0, uIdx);
276
322
  return 0;
277
323
  }, [days]);
278
324
 
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
- }
325
+ useEffect(() => {
326
+ if (scrollRef.current && scrollTargetIndex > 0) {
327
+ const container = scrollRef.current;
328
+ const target = container.children[scrollTargetIndex] as HTMLElement;
329
+ if (target) {
330
+ container.scrollTo({ left: target.offsetLeft - 20, behavior: "smooth" });
289
331
  }
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
- }
332
+ }
333
+ }, [scrollTargetIndex, days]);
334
+
335
+ // Stage groups
336
+ const stageGroups = useMemo(() => {
337
+ const stages = new Map<number, { name: string; orderNo: number }>();
338
+ for (const m of matches) {
339
+ if (m.stage && !stages.has(m.stage.id)) {
340
+ stages.set(m.stage.id, { name: m.stage.name, orderNo: m.stage.order_no });
297
341
  }
298
342
  }
299
343
  return Array.from(stages.values()).sort((a, b) => a.orderNo - b.orderNo);
300
- }, [days]);
344
+ }, [matches]);
301
345
 
302
- // Tournament date range from first match
303
346
  const tournament = matches[0]?.tournament;
304
347
 
305
348
  if (isLoading) {
306
349
  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)" }} />
350
+ <div className="flex items-center justify-center py-6">
351
+ <Loader2 size={16} className="animate-spin" style={{ color: "rgba(255,255,255,0.3)" }} />
309
352
  </div>
310
353
  );
311
354
  }
@@ -313,54 +356,55 @@ export function MatchCalendar({ tournamentId }: MatchCalendarProps) {
313
356
  if (error || matches.length === 0) return null;
314
357
 
315
358
  return (
316
- <div className="relative z-30 w-full mb-2 mt-2">
359
+ <div className="relative z-30 w-full mt-6 mb-4">
360
+ {/* Tournament info header */}
361
+ {tournament && (
362
+ <div className="px-5 mb-3">
363
+ <div className="flex items-center gap-2 mb-1">
364
+ <Trophy size={14} style={{ color: "#22E3E8" }} />
365
+ <span className="barlowcondensedBold text-[16px] tracking-wide text-white">
366
+ {tournament.name}
367
+ </span>
368
+ </div>
369
+ <div className="flex items-center gap-3">
370
+ {tournament.start_date && tournament.end_date && (
371
+ <span className="text-[11px] text-white" style={OUTFIT}>
372
+ {formatDayMonth(toLocalDate(tournament.start_date))} – {formatDayMonth(toLocalDate(tournament.end_date))}
373
+ </span>
374
+ )}
375
+ <span className="text-[11px] text-white" style={OUTFIT}>
376
+ {matches.length} matches
377
+ </span>
378
+ {stageGroups.length > 0 && (
379
+ <span
380
+ className="text-[9px] barlowcondensedBold tracking-widest px-2 py-0.5 rounded-full"
381
+ style={{
382
+ color: "#22E3E8",
383
+ background: "rgba(34,227,232,0.08)",
384
+ border: "1px solid rgba(34,227,232,0.15)",
385
+ }}
386
+ >
387
+ {stageGroups[0].name.toUpperCase()}
388
+ </span>
389
+ )}
390
+ </div>
391
+ </div>
392
+ )}
393
+
317
394
  {/* 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)" }}>
395
+ <div className="flex items-center gap-2 px-5 mb-2.5">
396
+ <Calendar size={12} style={{ color: "#22E3E8" }} />
397
+ <span className="barlowCondensedSemiBold text-[13px] tracking-wider text-white">
321
398
  SCHEDULE
322
399
  </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>
400
+ <div className="flex-1 h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
336
401
  </div>
337
402
 
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
403
  {/* Scrollable day columns */}
355
404
  <div
356
405
  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
- }}
406
+ className="flex gap-2.5 overflow-x-auto px-5 pb-3 scrollbar-hide"
407
+ style={{ scrollSnapType: "x mandatory", WebkitOverflowScrolling: "touch" }}
364
408
  >
365
409
  {days.map((day, i) => (
366
410
  <DayColumn key={day.dateKey} day={day} index={i} />