@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.
- package/dist/Leaderboard.d.ts +42 -0
- package/dist/Leaderboard.d.ts.map +1 -0
- package/dist/Leaderboard.js +186 -0
- package/dist/Leaderboard.js.map +1 -0
- package/dist/RewardsDashboard.d.ts +67 -0
- package/dist/RewardsDashboard.d.ts.map +1 -0
- package/dist/RewardsDashboard.js +256 -0
- package/dist/RewardsDashboard.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/useTrackEvent.d.ts +75 -0
- package/dist/useTrackEvent.d.ts.map +1 -0
- package/dist/useTrackEvent.js +218 -0
- package/dist/useTrackEvent.js.map +1 -0
- package/package.json +33 -0
- package/src/Leaderboard.tsx +528 -0
- package/src/RewardsDashboard.tsx +602 -0
- package/src/index.ts +18 -0
- package/src/useTrackEvent.ts +273 -0
- package/tsconfig.json +27 -0
|
@@ -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;
|