@chainloyalty/react 1.0.0

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.
@@ -0,0 +1,528 @@
1
+ /**
2
+ * @chainloyalty/react - Leaderboard Component
3
+ *
4
+ * Purpose:
5
+ * A fully-featured, embeddable leaderboard component that displays top users
6
+ * ranked by their points. This component provides real-time updates, filtering,
7
+ * and responsive design for both mobile and desktop.
8
+ *
9
+ * Key Features:
10
+ * - Top users ranked by points
11
+ * - Display: Rank, Wallet (shortened with copy), Points, Badge count
12
+ * - Filter options (All-time / This month)
13
+ * - Refresh button for real-time updates
14
+ * - Fully responsive design
15
+ * - Loading and empty states
16
+ * - Dark theme UI
17
+ * - Professional animations and interactions
18
+ */
19
+
20
+ import React, { useState, useEffect } from 'react';
21
+ import {
22
+ Copy,
23
+ CheckCircle,
24
+ RefreshCw,
25
+ Trophy,
26
+ Calendar,
27
+ Award,
28
+ TrendingUp,
29
+ ExternalLink,
30
+ } from 'lucide-react';
31
+
32
+ // ============================================================================
33
+ // Types & Interfaces
34
+ // ============================================================================
35
+
36
+ export type LeaderboardTimeframe = 'alltime' | 'month';
37
+
38
+ export interface LeaderboardEntry {
39
+ rank: number;
40
+ walletAddress: string;
41
+ points: number;
42
+ badgesCount: number;
43
+ displayName?: string;
44
+ avatar?: string;
45
+ }
46
+
47
+ export interface LeaderboardProps {
48
+ walletAddress?: string | null;
49
+ customColors?: {
50
+ background?: string;
51
+ accent1?: string;
52
+ accent2?: string;
53
+ };
54
+ itemsPerPage?: number;
55
+ compactMode?: boolean;
56
+ hideTimeframeFilter?: boolean;
57
+ }
58
+
59
+ // ============================================================================
60
+ // Mock Data & API Functions
61
+ // ============================================================================
62
+
63
+ /**
64
+ * Generate deterministic mock leaderboard data based on a seed
65
+ * In production, this would be fetched from an API
66
+ */
67
+ const generateLeaderboardData = (timeframe: LeaderboardTimeframe): LeaderboardEntry[] => {
68
+ const baseData = [
69
+ { displayName: 'CryptoKing', points: 45230, badges: 12 },
70
+ { displayName: 'LoyaltyPro', points: 42890, badges: 11 },
71
+ { displayName: 'RewardSeeker', points: 38950, badges: 9 },
72
+ { displayName: 'ChainMaster', points: 35670, badges: 8 },
73
+ { displayName: 'TokenTrader', points: 32450, badges: 7 },
74
+ { displayName: 'PointsCollector', points: 29876, badges: 6 },
75
+ { displayName: 'EarnQuest', points: 27543, badges: 5 },
76
+ { displayName: 'BlockchainBob', points: 24567, badges: 5 },
77
+ { displayName: 'WebThreeWave', points: 21234, badges: 4 },
78
+ { displayName: 'DecentralizedDave', points: 18765, badges: 3 },
79
+ { displayName: 'SmartContractSally', points: 16543, badges: 3 },
80
+ { displayName: 'FutureOfFinance', points: 14321, badges: 2 },
81
+ { displayName: 'CryptoNinja', points: 12876, badges: 2 },
82
+ { displayName: 'BlockBuilder', points: 11234, badges: 2 },
83
+ { displayName: 'RewardRunner', points: 9876, badges: 1 },
84
+ ];
85
+
86
+ // Apply timeframe multiplier for month view
87
+ const multiplier = timeframe === 'month' ? 0.4 : 1;
88
+
89
+ return baseData.map((user, index) => ({
90
+ rank: index + 1,
91
+ walletAddress: `0x${(Math.random().toString(16) + '000000000000000000000000').slice(2, 42)}`,
92
+ points: Math.floor(user.points * multiplier),
93
+ badgesCount: user.badges,
94
+ displayName: user.displayName,
95
+ }));
96
+ };
97
+
98
+ /**
99
+ * Mock API call to fetch leaderboard data
100
+ */
101
+ const fetchLeaderboardData = async (
102
+ timeframe: LeaderboardTimeframe
103
+ ): Promise<LeaderboardEntry[]> => {
104
+ // Simulate network delay
105
+ await new Promise((resolve) => setTimeout(resolve, 600));
106
+
107
+ // Return mock data
108
+ return generateLeaderboardData(timeframe);
109
+ };
110
+
111
+ // ============================================================================
112
+ // Utility Functions
113
+ // ============================================================================
114
+
115
+ const shortenAddress = (address: string, chars = 6): string => {
116
+ if (!address) return '';
117
+ return `${address.substring(0, chars)}...${address.substring(address.length - 4)}`;
118
+ };
119
+
120
+ const copyToClipboard = (text: string): void => {
121
+ navigator.clipboard.writeText(text);
122
+ };
123
+
124
+ const getRankColor = (rank: number): string => {
125
+ if (rank === 1) return '#F59E0B'; // Gold
126
+ if (rank === 2) return '#E5E7EB'; // Silver
127
+ if (rank === 3) return '#FB923C'; // Bronze
128
+ return '#9CA3AF'; // Gray
129
+ };
130
+
131
+ const getRankIcon = (rank: number): React.ReactNode => {
132
+ if (rank === 1) return '🥇';
133
+ if (rank === 2) return '🥈';
134
+ if (rank === 3) return '🥉';
135
+ return null;
136
+ };
137
+
138
+ // ============================================================================
139
+ // Leaderboard Row Component
140
+ // ============================================================================
141
+
142
+ interface LeaderboardRowProps {
143
+ entry: LeaderboardEntry;
144
+ accentColor: string;
145
+ isCurrentUser?: boolean;
146
+ onCopyAddress?: (address: string) => void;
147
+ }
148
+
149
+ const LeaderboardRow: React.FC<LeaderboardRowProps> = ({
150
+ entry,
151
+ accentColor,
152
+ isCurrentUser = false,
153
+ onCopyAddress,
154
+ }) => {
155
+ const [copied, setCopied] = useState(false);
156
+
157
+ const handleCopy = () => {
158
+ copyToClipboard(entry.walletAddress);
159
+ setCopied(true);
160
+ if (onCopyAddress) {
161
+ onCopyAddress(entry.walletAddress);
162
+ }
163
+ setTimeout(() => setCopied(false), 2000);
164
+ };
165
+
166
+ const rankIcon = getRankIcon(entry.rank);
167
+ const rankColor = getRankColor(entry.rank);
168
+
169
+ return (
170
+ <tr
171
+ className={`border-b border-gray-700 transition-all hover:bg-gray-700/30 ${
172
+ isCurrentUser ? 'bg-gray-700/20' : ''
173
+ }`}
174
+ >
175
+ {/* Rank */}
176
+ <td className="px-4 py-3 text-sm">
177
+ <div className="flex items-center justify-center">
178
+ {rankIcon && <span className="text-lg mr-2">{rankIcon}</span>}
179
+ <span
180
+ className="font-bold"
181
+ style={{
182
+ color: rankColor,
183
+ }}
184
+ >
185
+ #{entry.rank}
186
+ </span>
187
+ </div>
188
+ </td>
189
+
190
+ {/* Wallet Address */}
191
+ <td className="px-4 py-3 text-sm">
192
+ <div className="flex items-center gap-2 group">
193
+ <span className="text-white font-mono">
194
+ {shortenAddress(entry.walletAddress)}
195
+ </span>
196
+ <button
197
+ onClick={handleCopy}
198
+ className="p-1.5 rounded-md text-gray-500 group-hover:text-gray-300 hover:bg-gray-700/50 transition-all opacity-0 group-hover:opacity-100"
199
+ title={entry.walletAddress}
200
+ >
201
+ {copied ? (
202
+ <CheckCircle className="w-4 h-4" style={{ color: '#3FB950' }} />
203
+ ) : (
204
+ <Copy className="w-4 h-4" />
205
+ )}
206
+ </button>
207
+ {entry.displayName && (
208
+ <span className="hidden sm:inline text-xs text-gray-400">({entry.displayName})</span>
209
+ )}
210
+ </div>
211
+ </td>
212
+
213
+ {/* Points */}
214
+ <td className="px-4 py-3 text-sm">
215
+ <div className="text-right">
216
+ <p className="font-bold" style={{ color: accentColor }}>
217
+ {entry.points.toLocaleString()}
218
+ </p>
219
+ <p className="text-xs text-gray-400 hidden sm:block">points</p>
220
+ </div>
221
+ </td>
222
+
223
+ {/* Badges Count */}
224
+ <td className="px-4 py-3 text-sm">
225
+ <div
226
+ className="inline-flex items-center gap-1 px-2 py-1 rounded-lg"
227
+ style={{
228
+ backgroundColor: 'rgba(47, 129, 247, 0.1)',
229
+ borderColor: accentColor,
230
+ }}
231
+ >
232
+ <Award className="w-4 h-4" style={{ color: accentColor }} />
233
+ <span className="font-semibold text-white">{entry.badgesCount}</span>
234
+ </div>
235
+ </td>
236
+ </tr>
237
+ );
238
+ };
239
+
240
+ // ============================================================================
241
+ // Main Leaderboard Component
242
+ // ============================================================================
243
+
244
+ export const Leaderboard: React.FC<LeaderboardProps> = ({
245
+ walletAddress = null,
246
+ customColors = {},
247
+ itemsPerPage = 15,
248
+ compactMode = false,
249
+ hideTimeframeFilter = false,
250
+ }) => {
251
+ const [timeframe, setTimeframe] = useState<LeaderboardTimeframe>('alltime');
252
+ const [data, setData] = useState<LeaderboardEntry[]>([]);
253
+ const [loading, setLoading] = useState(false);
254
+ const [error, setError] = useState<string | null>(null);
255
+ const [currentPage, setCurrentPage] = useState(1);
256
+
257
+ const backgroundColor = customColors.background || '#0D1117';
258
+ const accentColor = customColors.accent1 || '#2F81F7';
259
+ const accentColor2 = customColors.accent2 || '#3FB950';
260
+
261
+ // Fetch data when timeframe changes
262
+ useEffect(() => {
263
+ setLoading(true);
264
+ setError(null);
265
+ setCurrentPage(1);
266
+
267
+ fetchLeaderboardData(timeframe)
268
+ .then((result) => {
269
+ setData(result);
270
+ setLoading(false);
271
+ })
272
+ .catch((err) => {
273
+ setError(err.message || 'Failed to load leaderboard');
274
+ setLoading(false);
275
+ });
276
+ }, [timeframe]);
277
+
278
+ const handleRefresh = async () => {
279
+ setLoading(true);
280
+ const result = await fetchLeaderboardData(timeframe);
281
+ setData(result);
282
+ setLoading(false);
283
+ };
284
+
285
+ // Pagination
286
+ const totalPages = Math.ceil(data.length / itemsPerPage);
287
+ const startIndex = (currentPage - 1) * itemsPerPage;
288
+ const endIndex = startIndex + itemsPerPage;
289
+ const displayedData = compactMode ? data.slice(0, 10) : data.slice(startIndex, endIndex);
290
+
291
+ const currentUserRank = walletAddress
292
+ ? data.find((entry) => entry.walletAddress.toLowerCase() === walletAddress.toLowerCase())
293
+ : null;
294
+
295
+ return (
296
+ <div
297
+ className="rounded-lg border border-gray-700 overflow-hidden"
298
+ style={{ backgroundColor }}
299
+ >
300
+ {/* Header */}
301
+ <div className="p-6 border-b border-gray-700">
302
+ <div className="flex items-center justify-between mb-4">
303
+ <div className="flex items-center gap-3">
304
+ <Trophy className="w-6 h-6" style={{ color: accentColor2 }} />
305
+ <h2 className="text-2xl font-bold text-white">Leaderboard</h2>
306
+ </div>
307
+ <button
308
+ onClick={handleRefresh}
309
+ disabled={loading}
310
+ className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-700 transition-all disabled:opacity-50"
311
+ title="Refresh leaderboard"
312
+ >
313
+ <RefreshCw className={`w-5 h-5 ${loading ? 'animate-spin' : ''}`} />
314
+ </button>
315
+ </div>
316
+
317
+ {/* Stats */}
318
+ <div className="grid grid-cols-2 md:grid-cols-3 gap-3 mb-4">
319
+ <div
320
+ className="p-3 rounded-lg border border-gray-700"
321
+ style={{ backgroundColor: 'rgba(31, 41, 55, 0.5)' }}
322
+ >
323
+ <p className="text-xs text-gray-400">Total Users</p>
324
+ <p className="text-2xl font-bold text-white">{data.length.toLocaleString()}</p>
325
+ </div>
326
+
327
+ {currentUserRank && (
328
+ <>
329
+ <div
330
+ className="p-3 rounded-lg border"
331
+ style={{
332
+ borderColor: accentColor,
333
+ backgroundColor: 'rgba(47, 129, 247, 0.1)',
334
+ }}
335
+ >
336
+ <p className="text-xs text-gray-400">Your Rank</p>
337
+ <p className="text-2xl font-bold" style={{ color: accentColor }}>
338
+ #{currentUserRank.rank}
339
+ </p>
340
+ </div>
341
+
342
+ <div
343
+ className="p-3 rounded-lg border"
344
+ style={{
345
+ borderColor: accentColor2,
346
+ backgroundColor: 'rgba(63, 185, 80, 0.1)',
347
+ }}
348
+ >
349
+ <p className="text-xs text-gray-400">Your Points</p>
350
+ <p className="text-2xl font-bold" style={{ color: accentColor2 }}>
351
+ {currentUserRank.points.toLocaleString()}
352
+ </p>
353
+ </div>
354
+ </>
355
+ )}
356
+ </div>
357
+
358
+ {/* Filters */}
359
+ {!hideTimeframeFilter && (
360
+ <div className="flex items-center gap-2">
361
+ <Calendar className="w-4 h-4 text-gray-400" />
362
+ <div className="flex gap-2">
363
+ {(['alltime', 'month'] as const).map((tf) => (
364
+ <button
365
+ key={tf}
366
+ onClick={() => setTimeframe(tf)}
367
+ className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${
368
+ timeframe === tf
369
+ ? 'text-white'
370
+ : 'text-gray-400 hover:text-white hover:bg-gray-700/50'
371
+ }`}
372
+ style={
373
+ timeframe === tf
374
+ ? {
375
+ backgroundColor: accentColor,
376
+ color: 'white',
377
+ }
378
+ : {}
379
+ }
380
+ >
381
+ {tf === 'alltime' ? 'All Time' : 'This Month'}
382
+ </button>
383
+ ))}
384
+ </div>
385
+ </div>
386
+ )}
387
+ </div>
388
+
389
+ {/* Content */}
390
+ <div style={{ backgroundColor: 'rgba(31, 41, 55, 0.2)' }}>
391
+ {/* Loading State */}
392
+ {loading && !data.length && (
393
+ <div className="p-6 space-y-3">
394
+ {[...Array(5)].map((_, i) => (
395
+ <div key={i} className="h-12 bg-gray-700 rounded-lg animate-pulse" />
396
+ ))}
397
+ </div>
398
+ )}
399
+
400
+ {/* Error State */}
401
+ {error && !data.length && (
402
+ <div className="p-6 text-center">
403
+ <p className="text-red-400 mb-4">{error}</p>
404
+ <button
405
+ onClick={handleRefresh}
406
+ className="px-4 py-2 rounded-lg text-white text-sm transition-all"
407
+ style={{ backgroundColor: accentColor }}
408
+ >
409
+ Try Again
410
+ </button>
411
+ </div>
412
+ )}
413
+
414
+ {/* Empty State */}
415
+ {!loading && !error && data.length === 0 && (
416
+ <div className="p-12 text-center">
417
+ <TrendingUp className="w-12 h-12 mx-auto mb-4 text-gray-500" />
418
+ <h3 className="text-lg font-semibold text-white mb-2">No Leaderboard Data</h3>
419
+ <p className="text-gray-400">The leaderboard will be populated as users start earning points.</p>
420
+ </div>
421
+ )}
422
+
423
+ {/* Table */}
424
+ {data.length > 0 && (
425
+ <div className="overflow-x-auto">
426
+ <table className="w-full">
427
+ <thead>
428
+ <tr className="border-b border-gray-700 bg-gray-800/50">
429
+ <th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase">
430
+ Rank
431
+ </th>
432
+ <th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase">
433
+ Wallet
434
+ </th>
435
+ <th className="px-4 py-3 text-right text-xs font-semibold text-gray-400 uppercase">
436
+ Points
437
+ </th>
438
+ <th className="px-4 py-3 text-center text-xs font-semibold text-gray-400 uppercase">
439
+ Badges
440
+ </th>
441
+ </tr>
442
+ </thead>
443
+ <tbody>
444
+ {displayedData.map((entry) => (
445
+ <LeaderboardRow
446
+ key={entry.rank}
447
+ entry={entry}
448
+ accentColor={accentColor}
449
+ isCurrentUser={
450
+ walletAddress
451
+ ? entry.walletAddress.toLowerCase() === walletAddress.toLowerCase()
452
+ : false
453
+ }
454
+ onCopyAddress={() => {
455
+ // Toast notification would go here
456
+ console.log('Copied:', entry.walletAddress);
457
+ }}
458
+ />
459
+ ))}
460
+ </tbody>
461
+ </table>
462
+ </div>
463
+ )}
464
+ </div>
465
+
466
+ {/* Pagination */}
467
+ {!compactMode && data.length > itemsPerPage && (
468
+ <div className="px-6 py-4 border-t border-gray-700 flex items-center justify-between">
469
+ <div className="text-sm text-gray-400">
470
+ Showing {startIndex + 1} to {Math.min(endIndex, data.length)} of {data.length}
471
+ </div>
472
+ <div className="flex gap-2">
473
+ <button
474
+ onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
475
+ disabled={currentPage === 1}
476
+ className="px-3 py-1.5 rounded-lg text-sm font-medium text-gray-400 hover:text-white hover:bg-gray-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
477
+ >
478
+ ← Previous
479
+ </button>
480
+ <div className="flex items-center gap-1">
481
+ {[...Array(Math.min(5, totalPages))].map((_, i) => {
482
+ const pageNum = i + 1;
483
+ return (
484
+ <button
485
+ key={pageNum}
486
+ onClick={() => setCurrentPage(pageNum)}
487
+ className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${
488
+ currentPage === pageNum
489
+ ? 'text-white'
490
+ : 'text-gray-400 hover:text-white hover:bg-gray-700'
491
+ }`}
492
+ style={
493
+ currentPage === pageNum
494
+ ? {
495
+ backgroundColor: accentColor,
496
+ color: 'white',
497
+ }
498
+ : {}
499
+ }
500
+ >
501
+ {pageNum}
502
+ </button>
503
+ );
504
+ })}
505
+ </div>
506
+ <button
507
+ onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
508
+ disabled={currentPage === totalPages}
509
+ className="px-3 py-1.5 rounded-lg text-sm font-medium text-gray-400 hover:text-white hover:bg-gray-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
510
+ >
511
+ Next →
512
+ </button>
513
+ </div>
514
+ </div>
515
+ )}
516
+
517
+ {/* Footer */}
518
+ <div className="px-6 py-3 border-t border-gray-700 flex items-center justify-end text-xs text-gray-500">
519
+ <span className="flex items-center gap-1">
520
+ <ExternalLink className="w-3 h-3" />
521
+ @chainloyalty/react
522
+ </span>
523
+ </div>
524
+ </div>
525
+ );
526
+ };
527
+
528
+ export default Leaderboard;