@turtleclub/org-leaderboard 0.1.0-beta.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/CHANGELOG.md +10 -0
- package/README.md +87 -0
- package/package.json +26 -0
- package/src/OrgLeaderboardView.tsx +279 -0
- package/src/constants.ts +29 -0
- package/src/index.ts +19 -0
- package/src/presets/kintsu.ts +20 -0
- package/src/presets/nunchi.ts +24 -0
- package/tsconfig.json +18 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Change Log
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
|
+
|
|
6
|
+
# 0.1.0-beta.1 (2026-03-03)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
- turtle leaderboard tools ([#271](https://github.com/turtle-dao/turtle-tools/issues/271)) ([6e484a6](https://github.com/turtle-dao/turtle-tools/commit/6e484a645f2ce2020084b850dadaa2e2fd1f4e87))
|
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# @turtleclub/org-leaderboard
|
|
2
|
+
|
|
3
|
+
White-label **organization leaderboards** for Turtle. One config per org; one component for all.
|
|
4
|
+
|
|
5
|
+
## Usage (in turtle-app)
|
|
6
|
+
|
|
7
|
+
1. **Install**
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add @turtleclub/org-leaderboard @turtleclub/hooks
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
2. **Define config (or use preset)**
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import {
|
|
17
|
+
getOrgLeaderboardConfigBySlug,
|
|
18
|
+
KINTSU_ORG_LEADERBOARD_CONFIG,
|
|
19
|
+
type OrgLeaderboardConfig,
|
|
20
|
+
} from "@turtleclub/org-leaderboard";
|
|
21
|
+
|
|
22
|
+
export const ORG_LEADERBOARDS: OrgLeaderboardConfig[] = [
|
|
23
|
+
KINTSU_ORG_LEADERBOARD_CONFIG,
|
|
24
|
+
// { orgId: "...", slug: "other", name: "Other", columns: [...] },
|
|
25
|
+
];
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
3. **Route: `/leaderboard/:orgSlug`**
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
import { OrgLeaderboardView, getOrgLeaderboardConfigBySlug } from "@turtleclub/org-leaderboard";
|
|
32
|
+
import { ORG_LEADERBOARDS } from "@/constants/orgLeaderboards";
|
|
33
|
+
import { useOrganizations } from "@turtleclub/hooks";
|
|
34
|
+
|
|
35
|
+
function OrgLeaderboardPage() {
|
|
36
|
+
const { orgSlug } = useParams();
|
|
37
|
+
const config = getOrgLeaderboardConfigBySlug(ORG_LEADERBOARDS, orgSlug);
|
|
38
|
+
const orgs = useOrganizations();
|
|
39
|
+
const org = config ? orgs?.getOrganizationById?.(config.orgId) : null;
|
|
40
|
+
const userId = useCurrentUserId(); // your auth
|
|
41
|
+
|
|
42
|
+
if (!config) return <NotFound />;
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<OrgLeaderboardView
|
|
46
|
+
config={config}
|
|
47
|
+
userId={userId}
|
|
48
|
+
overrides={{
|
|
49
|
+
logoUrl: org?.iconUrl ?? config.logoFallbackUrl,
|
|
50
|
+
displayName: org?.name ?? config.name,
|
|
51
|
+
}}
|
|
52
|
+
renderEligibleOpportunities={(productId) => (
|
|
53
|
+
<YourEligibleOpportunitiesDialog productId={productId} />
|
|
54
|
+
)}
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
4. **Nav**: add links to `/leaderboard/kintsu`, `/leaderboard/other`, etc. (e.g. from `ORG_LEADERBOARDS.map(c => ({ to: `/leaderboard/${c.slug}`, label: c.name }))`).
|
|
61
|
+
|
|
62
|
+
## API (same for all orgs)
|
|
63
|
+
|
|
64
|
+
- **List:** `GET /turtle/leaderboard/organization/:orgId` (query: `limit`, `offset`, `searchUsername`, `sortBy`, `sortOrder`, …)
|
|
65
|
+
- **User:** `GET /turtle/leaderboard/organization/:orgId/user/:userId`
|
|
66
|
+
|
|
67
|
+
Hooks: `useOrgLeaderboard({ orgId, params })`, `useOrgUserLeaderboard({ orgId, userId })` from `@turtleclub/hooks`.
|
|
68
|
+
|
|
69
|
+
## Adding a new org
|
|
70
|
+
|
|
71
|
+
Add one entry to `ORG_LEADERBOARDS` (orgId, slug, name, logoFallbackUrl, optional productId, pointsLabel, rewardPoolDescription, optional pointsDivisor, columns). No new page, hooks, or API.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Exact parity checklist (turtle-app)
|
|
76
|
+
|
|
77
|
+
Use this so the new generic leaderboard looks and behaves like the current Kintsu/Nunchi pages.
|
|
78
|
+
|
|
79
|
+
| Item | turtle-tools | turtle-app |
|
|
80
|
+
|------|--------------|------------|
|
|
81
|
+
| **Nunchi points scaling** | Config supports `pointsDivisor` (e.g. `1e18`). Nunchi preset sets it. Table and user banner format points as `value / divisor`. | Use Nunchi preset as-is, or override `pointsDivisor` in your `ORG_LEADERBOARDS` entry. |
|
|
82
|
+
| **Layout / wrappers** | `OrgLeaderboardView` renders content only; no AnimatedBackground, containerClass, or app-specific borders. | Wrap `<OrgLeaderboardView />` in the same layout you use today (e.g. `AnimatedBackground`, `containerClass`, `border-gradient-white`, `rounded-turtle`). |
|
|
83
|
+
| **Referral block** | Optional `renderReferralBlock={() => <YourReferralUI />}`. Rendered inside the user stats banner when provided. | Pass it for Kintsu (code + copy / “Create referral”). Omit for Nunchi. |
|
|
84
|
+
| **Document title** | View sets `document.title = "{name} Leaderboard"` on mount and restores on unmount (can disable with `setDocumentTitle={false}`). | Optional: set title yourself; otherwise leave default. |
|
|
85
|
+
| **Avatar fallback** | Optional `overrides.avatarFallbackUrl`. Used when avatar fails or is missing (table + user banner). | Pass e.g. `overrides={{ ..., avatarFallbackUrl: "/images/leaderboard/turtle-avatar.png" }}` to match current app. |
|
|
86
|
+
| **Mobile grid** | DataTable uses `grid: { displayAsGrid: true, headerSlot: "username", rightSlot: "totalPoints", excludeColumns: ["userId"] }`. | If your current tables differ (e.g. different slots or excluded columns), we can add a config override or you wrap/adjust in the app. |
|
|
87
|
+
| **Eligible Opportunities** | Optional `renderEligibleOpportunities={(productId) => <Dialog />}`. | Pass your existing Kintsu dialog so behavior is unchanged. |
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@turtleclub/org-leaderboard",
|
|
3
|
+
"version": "0.1.0-beta.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./src/index.ts"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"typecheck": "tsc --noEmit"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@tanstack/react-query": "^5.62.3",
|
|
13
|
+
"@tanstack/react-table": "^8.21.3",
|
|
14
|
+
"@turtleclub/hooks": "0.5.0-beta.88",
|
|
15
|
+
"@turtleclub/ui": "0.7.0-beta.31",
|
|
16
|
+
"react": ">=18.0.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/react": "^19.1.1",
|
|
20
|
+
"typescript": "^5.9.2"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"react": ">=18.0.0"
|
|
24
|
+
},
|
|
25
|
+
"gitHead": "c703afeabcbd109768b06fd5c39cd43b24cfa1d1"
|
|
26
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import type { ColumnDef } from "@tanstack/react-table";
|
|
3
|
+
import type { SortingState } from "@tanstack/react-table";
|
|
4
|
+
import type { PaginationState } from "@tanstack/react-table";
|
|
5
|
+
import {
|
|
6
|
+
useOrgLeaderboard,
|
|
7
|
+
useOrgUserLeaderboard,
|
|
8
|
+
type OrgLeaderboardConfig,
|
|
9
|
+
type OrgLeaderboardRow,
|
|
10
|
+
type OrgLeaderboardColumnId,
|
|
11
|
+
type OrgLeaderboardSortBy,
|
|
12
|
+
type OrgLeaderboardSortOrder,
|
|
13
|
+
} from "@turtleclub/hooks";
|
|
14
|
+
import {
|
|
15
|
+
DataTable,
|
|
16
|
+
Avatar,
|
|
17
|
+
AvatarImage,
|
|
18
|
+
AvatarFallback,
|
|
19
|
+
Card,
|
|
20
|
+
CardContent,
|
|
21
|
+
Button,
|
|
22
|
+
} from "@turtleclub/ui";
|
|
23
|
+
import { columnIdToSortBy } from "./constants";
|
|
24
|
+
|
|
25
|
+
export interface OrgLeaderboardViewOverrides {
|
|
26
|
+
/** Override logo URL (e.g. from organizations API) */
|
|
27
|
+
logoUrl?: string;
|
|
28
|
+
/** Override display name (e.g. from organizations API) */
|
|
29
|
+
displayName?: string;
|
|
30
|
+
/** Fallback image when user avatar fails to load (e.g. /images/leaderboard/turtle-avatar.png) */
|
|
31
|
+
avatarFallbackUrl?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface OrgLeaderboardViewProps {
|
|
35
|
+
config: OrgLeaderboardConfig;
|
|
36
|
+
/** Logged-in user id; when set, the "your stats" banner is shown */
|
|
37
|
+
userId?: string;
|
|
38
|
+
overrides?: OrgLeaderboardViewOverrides;
|
|
39
|
+
/** Optional: custom content for "Eligible Opportunities" (e.g. dialog). If not set, a link to /earn/products/:productId is rendered. */
|
|
40
|
+
renderEligibleOpportunities?: (productId: string) => React.ReactNode;
|
|
41
|
+
/** Optional: referral block (code + copy / "Create referral"). Pass for Kintsu, omit for Nunchi. */
|
|
42
|
+
renderReferralBlock?: () => React.ReactNode;
|
|
43
|
+
/** Base path for product link when renderEligibleOpportunities is not used. Default "/earn/products" */
|
|
44
|
+
productLinkBase?: string;
|
|
45
|
+
/** Set document.title to "{name} Leaderboard" on mount. Default true. */
|
|
46
|
+
setDocumentTitle?: boolean;
|
|
47
|
+
className?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const DEFAULT_PAGE_SIZE = 20;
|
|
51
|
+
|
|
52
|
+
function formatNumber(value: number): string {
|
|
53
|
+
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`;
|
|
54
|
+
if (value >= 1_000) return `${(value / 1_000).toFixed(2)}K`;
|
|
55
|
+
return value.toLocaleString();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Apply points divisor (e.g. 1e18 for Nunchi cHIPs) then format. */
|
|
59
|
+
function formatPointsValue(value: number, divisor: number = 1): string {
|
|
60
|
+
const scaled = divisor !== 1 ? value / divisor : value;
|
|
61
|
+
return formatNumber(scaled);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function UserBannerStat({ label, display }: { label: string; display: string }) {
|
|
65
|
+
return (
|
|
66
|
+
<div className="text-sm">
|
|
67
|
+
<span className="text-muted-foreground">{label}:</span>{" "}
|
|
68
|
+
<span className="font-medium">{display}</span>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function OrgLeaderboardView({
|
|
74
|
+
config,
|
|
75
|
+
userId,
|
|
76
|
+
overrides,
|
|
77
|
+
renderEligibleOpportunities,
|
|
78
|
+
renderReferralBlock,
|
|
79
|
+
productLinkBase = "/earn/products",
|
|
80
|
+
setDocumentTitle = true,
|
|
81
|
+
className,
|
|
82
|
+
}: OrgLeaderboardViewProps) {
|
|
83
|
+
const displayName = overrides?.displayName ?? config.name;
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!setDocumentTitle) return;
|
|
87
|
+
const prev = document.title;
|
|
88
|
+
document.title = `${displayName} Leaderboard`;
|
|
89
|
+
return () => {
|
|
90
|
+
document.title = prev;
|
|
91
|
+
};
|
|
92
|
+
}, [displayName, setDocumentTitle]);
|
|
93
|
+
|
|
94
|
+
const [pagination, setPagination] = useState<PaginationState>({
|
|
95
|
+
pageIndex: 0,
|
|
96
|
+
pageSize: DEFAULT_PAGE_SIZE,
|
|
97
|
+
});
|
|
98
|
+
const [sorting, setSorting] = useState<SortingState>([]);
|
|
99
|
+
const [searchUsername, setSearchUsername] = useState("");
|
|
100
|
+
|
|
101
|
+
const sortBy: OrgLeaderboardSortBy | undefined = sorting[0]
|
|
102
|
+
? columnIdToSortBy(sorting[0].id)
|
|
103
|
+
: undefined;
|
|
104
|
+
const sortOrder: OrgLeaderboardSortOrder | undefined = sorting[0]
|
|
105
|
+
? (sorting[0].desc ? "desc" : "asc")
|
|
106
|
+
: undefined;
|
|
107
|
+
|
|
108
|
+
const { rows, total, totalPages, isLoading } = useOrgLeaderboard({
|
|
109
|
+
orgId: config.orgId,
|
|
110
|
+
params: {
|
|
111
|
+
limit: pagination.pageSize,
|
|
112
|
+
offset: pagination.pageIndex * pagination.pageSize,
|
|
113
|
+
sortBy,
|
|
114
|
+
sortOrder,
|
|
115
|
+
searchUsername: searchUsername || undefined,
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const { userInfo } = useOrgUserLeaderboard({
|
|
120
|
+
orgId: config.orgId,
|
|
121
|
+
userId: userId ?? "",
|
|
122
|
+
enabled: !!userId,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const logoUrl = overrides?.logoUrl ?? config.logoFallbackUrl;
|
|
126
|
+
const pointsLabel = config.pointsLabel ?? "Points";
|
|
127
|
+
const divisor = config.pointsDivisor ?? 1;
|
|
128
|
+
|
|
129
|
+
const tableColumns = useMemo(() => {
|
|
130
|
+
const cols: ColumnDef<OrgLeaderboardRow, unknown>[] = [];
|
|
131
|
+
for (const col of config.columns) {
|
|
132
|
+
if (col.showInTable === false) continue;
|
|
133
|
+
const id = col.id as OrgLeaderboardColumnId;
|
|
134
|
+
const isUsername = id === "username";
|
|
135
|
+
cols.push({
|
|
136
|
+
id,
|
|
137
|
+
accessorKey: id,
|
|
138
|
+
header: col.label,
|
|
139
|
+
enableSorting: true,
|
|
140
|
+
cell: ({ row }) => {
|
|
141
|
+
const value = row.getValue(id);
|
|
142
|
+
if (isUsername) {
|
|
143
|
+
const username = row.original.turtleUser?.username ?? row.original.userId?.slice(0, 8) ?? "—";
|
|
144
|
+
const avatarUrl = row.original.turtleUser?.avatarUrl;
|
|
145
|
+
const avatarFallbackUrl = overrides?.avatarFallbackUrl;
|
|
146
|
+
return (
|
|
147
|
+
<div className="flex items-center gap-2">
|
|
148
|
+
<Avatar className="size-8">
|
|
149
|
+
{avatarUrl && <AvatarImage src={avatarUrl} alt={username} />}
|
|
150
|
+
<AvatarFallback className="text-xs">
|
|
151
|
+
{avatarFallbackUrl ? (
|
|
152
|
+
<img src={avatarFallbackUrl} alt="" className="size-full object-cover rounded-full" />
|
|
153
|
+
) : (
|
|
154
|
+
username.slice(0, 2).toUpperCase()
|
|
155
|
+
)}
|
|
156
|
+
</AvatarFallback>
|
|
157
|
+
</Avatar>
|
|
158
|
+
<span className="truncate">{username}</span>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (typeof value === "number" && (id === "totalPoints" || id === "userTvl" || id === "dailyPoints" || id === "referredValue")) {
|
|
163
|
+
return formatPointsValue(value, divisor);
|
|
164
|
+
}
|
|
165
|
+
return value ?? "—";
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return cols;
|
|
170
|
+
}, [config.columns, divisor, overrides?.avatarFallbackUrl]);
|
|
171
|
+
|
|
172
|
+
const onSortingChange = useCallback(
|
|
173
|
+
(updater: SortingState | ((prev: SortingState) => SortingState)) => {
|
|
174
|
+
setSorting(updater);
|
|
175
|
+
},
|
|
176
|
+
[]
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const pageCount = totalPages ?? (total != null ? Math.ceil(total / pagination.pageSize) : undefined);
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div className={className}>
|
|
183
|
+
{/* Header */}
|
|
184
|
+
<div className="mb-6 flex items-center gap-3">
|
|
185
|
+
{logoUrl && (
|
|
186
|
+
<img
|
|
187
|
+
src={logoUrl}
|
|
188
|
+
alt={displayName}
|
|
189
|
+
className="size-10 rounded-full object-contain"
|
|
190
|
+
/>
|
|
191
|
+
)}
|
|
192
|
+
<h1 className="text-2xl font-semibold">{displayName} Leaderboard</h1>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
{/* User stats banner */}
|
|
196
|
+
{userId && userInfo && (
|
|
197
|
+
<Card className="mb-6" variant="border">
|
|
198
|
+
<CardContent className="flex flex-wrap items-center gap-4 pt-6">
|
|
199
|
+
<div className="flex items-center gap-2">
|
|
200
|
+
<Avatar className="size-8">
|
|
201
|
+
<AvatarFallback className="text-xs">
|
|
202
|
+
{overrides?.avatarFallbackUrl ? (
|
|
203
|
+
<img src={overrides.avatarFallbackUrl} alt="" className="size-full object-cover rounded-full" />
|
|
204
|
+
) : (
|
|
205
|
+
(userInfo.username ?? userInfo.userId?.slice(0, 2) ?? "?").toUpperCase()
|
|
206
|
+
)}
|
|
207
|
+
</AvatarFallback>
|
|
208
|
+
</Avatar>
|
|
209
|
+
<span className="font-medium">{userInfo.username ?? "You"}</span>
|
|
210
|
+
</div>
|
|
211
|
+
{config.columns
|
|
212
|
+
.filter((c) => c.showInUserBanner !== false)
|
|
213
|
+
.map((col) => {
|
|
214
|
+
const key = col.id;
|
|
215
|
+
const value = userInfo[key as keyof typeof userInfo];
|
|
216
|
+
if (value === undefined) return null;
|
|
217
|
+
const display =
|
|
218
|
+
typeof value === "number" &&
|
|
219
|
+
!["rank", "totalUsers"].includes(key as string)
|
|
220
|
+
? formatPointsValue(value, divisor)
|
|
221
|
+
: String(value);
|
|
222
|
+
const label = key === "rank" ? `Rank #${value}/${userInfo.totalUsers}` : col.label;
|
|
223
|
+
return <UserBannerStat key={key} label={label} display={display} />;
|
|
224
|
+
})}
|
|
225
|
+
{renderReferralBlock?.()}
|
|
226
|
+
</CardContent>
|
|
227
|
+
</Card>
|
|
228
|
+
)}
|
|
229
|
+
|
|
230
|
+
{/* Eligible Opportunities */}
|
|
231
|
+
{config.productId && (
|
|
232
|
+
<div className="mb-6">
|
|
233
|
+
{renderEligibleOpportunities ? (
|
|
234
|
+
renderEligibleOpportunities(config.productId)
|
|
235
|
+
) : (
|
|
236
|
+
<Button variant="outline" asChild>
|
|
237
|
+
<a href={`${productLinkBase}/${config.productId}`}>Eligible Opportunities</a>
|
|
238
|
+
</Button>
|
|
239
|
+
)}
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
{/* Stats row */}
|
|
244
|
+
<div className="mb-4 flex flex-wrap items-center gap-6 text-sm text-muted-foreground">
|
|
245
|
+
{total != null && <span>Total Participants: {formatNumber(total)}</span>}
|
|
246
|
+
{config.rewardPoolDescription && (
|
|
247
|
+
<span title="Reward pool">{config.rewardPoolDescription}</span>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
{/* Table */}
|
|
252
|
+
<DataTable<OrgLeaderboardRow, unknown>
|
|
253
|
+
columns={tableColumns}
|
|
254
|
+
data={rows}
|
|
255
|
+
isLoading={isLoading}
|
|
256
|
+
enableSearch
|
|
257
|
+
enableSorting
|
|
258
|
+
enablePagination
|
|
259
|
+
manualPagination
|
|
260
|
+
manualSorting
|
|
261
|
+
manualFiltering
|
|
262
|
+
pagination={pagination}
|
|
263
|
+
onPaginationChange={setPagination}
|
|
264
|
+
sorting={sorting}
|
|
265
|
+
onSortingChange={onSortingChange}
|
|
266
|
+
pageCount={pageCount}
|
|
267
|
+
rowCount={total}
|
|
268
|
+
globalFilter={searchUsername}
|
|
269
|
+
onGlobalFilterChange={setSearchUsername}
|
|
270
|
+
grid={{
|
|
271
|
+
displayAsGrid: true,
|
|
272
|
+
headerSlot: "username",
|
|
273
|
+
rightSlot: "totalPoints",
|
|
274
|
+
excludeColumns: ["userId"],
|
|
275
|
+
}}
|
|
276
|
+
/>
|
|
277
|
+
</div>
|
|
278
|
+
);
|
|
279
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {
|
|
2
|
+
orgLeaderboardSortBySchema,
|
|
3
|
+
type OrgLeaderboardColumnId,
|
|
4
|
+
type OrgLeaderboardSortBy,
|
|
5
|
+
} from "@turtleclub/hooks";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Maps table column id to API sortBy param.
|
|
9
|
+
* API uses "points" for total points; row field is totalPoints.
|
|
10
|
+
*/
|
|
11
|
+
export const COLUMN_ID_TO_API_SORT_BY: Partial<Record<OrgLeaderboardColumnId, OrgLeaderboardSortBy>> =
|
|
12
|
+
{
|
|
13
|
+
totalPoints: "points",
|
|
14
|
+
// rank, username, referredValue, userTvl, dailyPoints are 1:1 with API
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Maps a table column id to the API sortBy param. Validates with schema so invalid
|
|
19
|
+
* ids (e.g. from a misconfigured column) are not sent to the API.
|
|
20
|
+
* Note: We accept string, not keyof COLUMN_ID_TO_API_SORT_BY, because sortable
|
|
21
|
+
* columns include ids that are 1:1 with the API (rank, username, userTvl, etc.)
|
|
22
|
+
* and are not keys of the map—only totalPoints is mapped to "points".
|
|
23
|
+
*/
|
|
24
|
+
export function columnIdToSortBy(columnId: string): OrgLeaderboardSortBy | undefined {
|
|
25
|
+
const sortBy =
|
|
26
|
+
(COLUMN_ID_TO_API_SORT_BY as Record<string, OrgLeaderboardSortBy>)[columnId] ?? columnId;
|
|
27
|
+
const result = orgLeaderboardSortBySchema.safeParse(sortBy);
|
|
28
|
+
return result.success ? result.data : undefined;
|
|
29
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { OrgLeaderboardView } from "./OrgLeaderboardView";
|
|
2
|
+
export type { OrgLeaderboardViewProps, OrgLeaderboardViewOverrides } from "./OrgLeaderboardView";
|
|
3
|
+
export { columnIdToSortBy } from "./constants";
|
|
4
|
+
export { KINTSU_ORG_LEADERBOARD_CONFIG } from "./presets/kintsu";
|
|
5
|
+
export { NUNCHI_ORG_LEADERBOARD_CONFIG } from "./presets/nunchi";
|
|
6
|
+
|
|
7
|
+
// Re-export config type so app can type its registry without depending on hooks internals
|
|
8
|
+
export type { OrgLeaderboardConfig, OrgLeaderboardColumnConfig } from "@turtleclub/hooks";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve org leaderboard config by slug from a registry.
|
|
12
|
+
* Use in the app: getOrgLeaderboardConfigBySlug(ORG_LEADERBOARDS, params.orgSlug)
|
|
13
|
+
*/
|
|
14
|
+
export function getOrgLeaderboardConfigBySlug<T extends { slug: string }>(
|
|
15
|
+
configs: T[],
|
|
16
|
+
slug: string
|
|
17
|
+
): T | undefined {
|
|
18
|
+
return configs.find((c) => c.slug === slug);
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { OrgLeaderboardConfig } from "@turtleclub/hooks";
|
|
2
|
+
|
|
3
|
+
/** Kintsu org leaderboard config. Use in app: ORG_LEADERBOARDS = [KINTSU_ORG_LEADERBOARD_CONFIG] */
|
|
4
|
+
export const KINTSU_ORG_LEADERBOARD_CONFIG: OrgLeaderboardConfig = {
|
|
5
|
+
orgId: "c7c32b42-a823-43ac-80dd-c2e39fc54c22",
|
|
6
|
+
slug: "kintsu",
|
|
7
|
+
name: "Kintsu",
|
|
8
|
+
logoFallbackUrl: undefined,
|
|
9
|
+
productId: "ae0901a9-f0d5-48a1-9cdd-6dfaa651228a",
|
|
10
|
+
pointsLabel: "Kintsu Points",
|
|
11
|
+
rewardPoolDescription: "10,000 Kintsu Points/day",
|
|
12
|
+
columns: [
|
|
13
|
+
{ id: "rank", label: "Rank", showInTable: true, showInUserBanner: true },
|
|
14
|
+
{ id: "username", label: "User", showInTable: true },
|
|
15
|
+
{ id: "userTvl", label: "Current TVL", showInTable: true, showInUserBanner: true },
|
|
16
|
+
{ id: "referredValue", label: "Referred TVL", showInTable: true, showInUserBanner: true },
|
|
17
|
+
{ id: "dailyPoints", label: "Daily Kintsu Points", showInTable: true, showInUserBanner: true },
|
|
18
|
+
{ id: "totalPoints", label: "Points", showInTable: true, showInUserBanner: true },
|
|
19
|
+
],
|
|
20
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { OrgLeaderboardConfig } from "@turtleclub/hooks";
|
|
2
|
+
|
|
3
|
+
export const NUNCHI_ORG_LEADERBOARD_CONFIG: OrgLeaderboardConfig = {
|
|
4
|
+
orgId: "270ab015-0791-48b8-82a6-51bbb432651c",
|
|
5
|
+
slug: "nunchi",
|
|
6
|
+
name: "Nunchi",
|
|
7
|
+
logoFallbackUrl: undefined, // we rely on org.iconUrl
|
|
8
|
+
productId: undefined, // no “eligible opportunities” dialog today
|
|
9
|
+
pointsLabel: "Nunchi cHIPs",
|
|
10
|
+
rewardPoolDescription: "950 Nunchi cHIPs per $1k/day",
|
|
11
|
+
pointsDivisor: 1e18, // API returns raw wei; display as human-readable
|
|
12
|
+
columns: [
|
|
13
|
+
{ id: "rank", label: "Rank", showInTable: true, showInUserBanner: true },
|
|
14
|
+
{ id: "username", label: "User", showInTable: true },
|
|
15
|
+
{ id: "userTvl", label: "TVL", showInTable: true, showInUserBanner: true },
|
|
16
|
+
{
|
|
17
|
+
id: "totalPoints",
|
|
18
|
+
label: "Nunchi cHIPs",
|
|
19
|
+
showInTable: true,
|
|
20
|
+
showInUserBanner: true,
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
6
|
+
"jsx": "react-jsx",
|
|
7
|
+
"moduleResolution": "bundler",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"rootDir": "src",
|
|
14
|
+
"outDir": "dist"
|
|
15
|
+
},
|
|
16
|
+
"include": ["src"],
|
|
17
|
+
"exclude": ["node_modules", "dist"]
|
|
18
|
+
}
|