Skip to content

Commit 52b987c

Browse files
committed
add program leaderboard
1 parent cf7983f commit 52b987c

7 files changed

Lines changed: 441 additions & 8 deletions

File tree

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
"use client";
2+
3+
import { formatDateTooltip } from "@/lib/analytics/format-date-tooltip";
4+
import { AnalyticsLoadingSpinner } from "@/ui/analytics/analytics-loading-spinner";
5+
import SimpleDateRangePicker from "@/ui/shared/simple-date-range-picker";
6+
import {
7+
CrownSmall,
8+
Table,
9+
usePagination,
10+
useRouterStuff,
11+
useTable,
12+
} from "@dub/ui";
13+
import { Areas, TimeSeriesChart, XAxis, YAxis } from "@dub/ui/charts";
14+
import {
15+
cn,
16+
currencyFormatter,
17+
fetcher,
18+
THE_BEGINNING_OF_TIME,
19+
} from "@dub/utils";
20+
import NumberFlow from "@number-flow/react";
21+
import { Fragment, useMemo, useState } from "react";
22+
import useSWR from "swr";
23+
24+
type Tab = {
25+
id: string;
26+
label: string;
27+
colorClassName: string;
28+
};
29+
30+
export default function PayoutsPageClient() {
31+
const { queryParams, getQueryString, searchParamsObj } = useRouterStuff();
32+
const { interval, start, end } = searchParamsObj;
33+
34+
const { data: { programs, timeseries } = {}, isLoading } = useSWR<{
35+
programs: {
36+
id: string;
37+
name: string;
38+
logo: string;
39+
earnings: number;
40+
}[];
41+
timeseries: {
42+
start: Date;
43+
earnings: number;
44+
}[];
45+
}>(
46+
`/api/admin/commissions${getQueryString({
47+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
48+
})}`,
49+
fetcher,
50+
{
51+
keepPreviousData: true,
52+
},
53+
);
54+
55+
const tabs: Tab[] = [
56+
{
57+
id: "commissions",
58+
label: "Commissions",
59+
colorClassName: "text-teal-500 bg-teal-500/50 border-teal-500",
60+
},
61+
];
62+
63+
const [selectedTab, setSelectedTab] = useState<Tab["id"]>("commissions");
64+
const tab = tabs.find(({ id }) => id === selectedTab) ?? tabs[0];
65+
66+
const chartData =
67+
timeseries?.map(({ start, earnings }) => ({
68+
date: start ? new Date(start) : new Date(),
69+
values: {
70+
value: earnings || 0,
71+
},
72+
})) ?? null;
73+
74+
const totals = useMemo(() => {
75+
return {
76+
commissions: timeseries?.reduce(
77+
(acc, { earnings }) => acc + (earnings || 0),
78+
0,
79+
),
80+
};
81+
}, [timeseries]);
82+
83+
const { pagination, setPagination } = usePagination();
84+
85+
const { table, ...tableProps } = useTable({
86+
data: programs ?? [],
87+
columns: [
88+
{
89+
id: "position",
90+
header: "Position",
91+
size: 12,
92+
minSize: 12,
93+
maxSize: 12,
94+
cell: ({ row }) => {
95+
return (
96+
<div className="flex w-28 items-center justify-start gap-2 tabular-nums">
97+
{row.index + 1}
98+
{row.index <= 2 && (
99+
<CrownSmall
100+
className={cn("size-4", {
101+
"text-amber-400": row.index === 0,
102+
"text-neutral-400": row.index === 1,
103+
"text-yellow-900": row.index === 2,
104+
})}
105+
/>
106+
)}
107+
</div>
108+
);
109+
},
110+
},
111+
{
112+
id: "program",
113+
header: "Program",
114+
cell: ({ row }) => (
115+
<div className="flex items-center gap-1.5">
116+
<img
117+
src={row.original.logo}
118+
alt={row.original.name}
119+
width={20}
120+
height={20}
121+
className="size-4 rounded-full"
122+
/>
123+
<span className="text-sm font-medium">{row.original.name}</span>
124+
</div>
125+
),
126+
},
127+
{
128+
id: "commissions",
129+
header: "Commissions",
130+
accessorKey: "earnings",
131+
cell: ({ row }) =>
132+
currencyFormatter(row.original.earnings / 100, {
133+
minimumFractionDigits: 2,
134+
maximumFractionDigits: 2,
135+
}),
136+
},
137+
],
138+
pagination,
139+
onPaginationChange: setPagination,
140+
resourceName: (plural) => `program${plural ? "s" : ""}`,
141+
rowCount: programs?.length ?? 0,
142+
loading: isLoading,
143+
});
144+
145+
return (
146+
<div className="mx-auto flex w-full max-w-screen-xl flex-col space-y-6 p-6">
147+
<SimpleDateRangePicker defaultInterval="mtd" className="w-fit" />
148+
<div className="flex flex-col divide-y divide-neutral-200 rounded-lg border border-neutral-200 bg-white">
149+
<div className="scrollbar-hide grid w-full grid-cols-3 divide-x overflow-y-hidden">
150+
{tabs.map(({ id, label, colorClassName }) => {
151+
return (
152+
<button
153+
key={id}
154+
onClick={() => {
155+
setSelectedTab(id);
156+
queryParams({
157+
set: { tab: id },
158+
});
159+
}}
160+
className={cn(
161+
"border-box relative block h-full w-full flex-none px-4 py-3 sm:px-8 sm:py-6",
162+
"transition-colors hover:bg-neutral-50 focus:outline-none active:bg-neutral-100",
163+
"ring-inset ring-neutral-500 focus-visible:ring-1 sm:first:rounded-tl-xl",
164+
)}
165+
>
166+
{/* Active tab indicator */}
167+
<div
168+
className={cn(
169+
"absolute bottom-0 left-0 h-0.5 w-full bg-black transition-transform duration-100",
170+
selectedTab !== id && "translate-y-[3px]",
171+
)}
172+
/>
173+
<div className="flex items-center gap-2.5 text-sm text-neutral-600">
174+
<div
175+
className={cn(
176+
"h-2 w-2 rounded-sm bg-current shadow-[inset_0_0_0_1px_#00000019]",
177+
colorClassName,
178+
)}
179+
/>
180+
<span>{label}</span>
181+
</div>
182+
<div className="mt-1 flex h-12 items-center">
183+
{totals[id] ? (
184+
<NumberFlow
185+
value={totals[id] / 100}
186+
className="text-xl font-medium sm:text-3xl"
187+
format={{
188+
style: "currency",
189+
currency: "USD",
190+
// @ts-ignore – trailingZeroDisplay is a valid option but TS is outdated
191+
trailingZeroDisplay: "stripIfInteger",
192+
}}
193+
/>
194+
) : (
195+
<div className="h-9 w-16 animate-pulse rounded-md bg-neutral-200" />
196+
)}
197+
</div>
198+
</button>
199+
);
200+
})}
201+
</div>
202+
<div className="p-5 sm:p-10">
203+
<div className="flex h-96 w-full items-center justify-center">
204+
{chartData ? (
205+
chartData.length > 0 ? (
206+
<TimeSeriesChart
207+
data={chartData}
208+
series={[
209+
{
210+
id: "value",
211+
valueAccessor: (d) => d.values.value,
212+
isActive: true,
213+
colorClassName: tab.colorClassName,
214+
},
215+
]}
216+
tooltipClassName="p-0"
217+
tooltipContent={(d) => (
218+
<>
219+
<p className="border-b border-neutral-200 px-4 py-3 text-sm text-neutral-900">
220+
{formatDateTooltip(d.date, {
221+
interval,
222+
start,
223+
end,
224+
})}
225+
</p>
226+
<div className="grid grid-cols-2 gap-x-6 gap-y-2 px-4 py-3 text-sm">
227+
<Fragment>
228+
<div className="flex items-center gap-2">
229+
<div
230+
className={cn(
231+
"h-2 w-2 rounded-sm shadow-[inset_0_0_0_1px_#0003]",
232+
tab.colorClassName,
233+
)}
234+
/>
235+
<p className="capitalize text-neutral-600">
236+
{tab.label}
237+
</p>
238+
</div>
239+
<p className="text-right font-medium text-neutral-900">
240+
{currencyFormatter(d.values.value / 100)}
241+
</p>
242+
</Fragment>
243+
</div>
244+
</>
245+
)}
246+
>
247+
<Areas />
248+
<XAxis
249+
maxTicks={5}
250+
tickFormat={(d) =>
251+
formatDateTooltip(d, {
252+
interval,
253+
start,
254+
end,
255+
dataAvailableFrom: THE_BEGINNING_OF_TIME,
256+
})
257+
}
258+
/>
259+
<YAxis
260+
showGridLines
261+
tickFormat={(value) => currencyFormatter(value / 100)}
262+
/>
263+
</TimeSeriesChart>
264+
) : (
265+
<div className="text-center text-sm text-neutral-600">
266+
No data available.
267+
</div>
268+
)
269+
) : (
270+
<AnalyticsLoadingSpinner />
271+
)}
272+
</div>
273+
</div>
274+
</div>
275+
<div className="w-full">
276+
<Table {...tableProps} table={table} />
277+
</div>
278+
</div>
279+
);
280+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Suspense } from "react";
2+
import PayoutsPageClient from "./client";
3+
4+
export default async function PayoutsPage() {
5+
return (
6+
<Suspense>
7+
<PayoutsPageClient />
8+
</Suspense>
9+
);
10+
}

apps/web/app/admin.dub.co/(dashboard)/layout.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ const tabs = [
1515
label: "Analytics",
1616
},
1717
{
18-
href: "/payouts",
19-
label: "Payouts",
18+
href: "/commissions",
19+
label: "Commissions",
2020
},
2121
{
22-
href: "/demo",
23-
label: "Demo",
22+
href: "/payouts",
23+
label: "Payouts",
2424
},
2525
];
2626

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { getStartEndDates } from "@/lib/analytics/utils/get-start-end-dates";
2+
import { withAdmin } from "@/lib/auth";
3+
import { THE_BEGINNING_OF_TIME } from "@dub/utils";
4+
import { NextResponse } from "next/server";
5+
import { getCommissionsTimeseries, getTopProgramsByCommissions } from "./utils";
6+
7+
export const GET = withAdmin(async ({ searchParams }) => {
8+
const { interval = "mtd", start, end, timezone = "UTC" } = searchParams;
9+
10+
const { startDate, endDate, granularity } = getStartEndDates({
11+
interval,
12+
start,
13+
end,
14+
dataAvailableFrom: THE_BEGINNING_OF_TIME,
15+
});
16+
17+
const [programs, timeseries] = await Promise.all([
18+
getTopProgramsByCommissions({ startDate, endDate }),
19+
getCommissionsTimeseries({ startDate, endDate, granularity, timezone }),
20+
]);
21+
22+
return NextResponse.json({
23+
programs,
24+
timeseries,
25+
});
26+
});

0 commit comments

Comments
 (0)