Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
280 changes: 280 additions & 0 deletions apps/web/app/admin.dub.co/(dashboard)/commissions/client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
"use client";

import { formatDateTooltip } from "@/lib/analytics/format-date-tooltip";
import { AnalyticsLoadingSpinner } from "@/ui/analytics/analytics-loading-spinner";
import SimpleDateRangePicker from "@/ui/shared/simple-date-range-picker";
import {
CrownSmall,
Table,
usePagination,
useRouterStuff,
useTable,
} from "@dub/ui";
import { Areas, TimeSeriesChart, XAxis, YAxis } from "@dub/ui/charts";
import {
cn,
currencyFormatter,
fetcher,
THE_BEGINNING_OF_TIME,
} from "@dub/utils";
import NumberFlow from "@number-flow/react";
import { Fragment, useMemo, useState } from "react";
import useSWR from "swr";

type Tab = {
id: string;
label: string;
colorClassName: string;
};

export default function PayoutsPageClient() {
const { queryParams, getQueryString, searchParamsObj } = useRouterStuff();
const { interval, start, end } = searchParamsObj;

const { data: { programs, timeseries } = {}, isLoading } = useSWR<{
programs: {
id: string;
name: string;
logo: string;
earnings: number;
}[];
timeseries: {
start: Date;
earnings: number;
}[];
}>(
`/api/admin/commissions${getQueryString({
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
})}`,
fetcher,
{
keepPreviousData: true,
},
);

const tabs: Tab[] = [
{
id: "commissions",
label: "Commissions",
colorClassName: "text-teal-500 bg-teal-500/50 border-teal-500",
},
];

const [selectedTab, setSelectedTab] = useState<Tab["id"]>("commissions");
const tab = tabs.find(({ id }) => id === selectedTab) ?? tabs[0];

const chartData =
timeseries?.map(({ start, earnings }) => ({
date: start ? new Date(start) : new Date(),
values: {
value: earnings || 0,
},
})) ?? null;

const totals = useMemo(() => {
return {
commissions: timeseries?.reduce(
(acc, { earnings }) => acc + (earnings || 0),
0,
),
};
}, [timeseries]);

const { pagination, setPagination } = usePagination();

const { table, ...tableProps } = useTable({
data: programs ?? [],
columns: [
{
id: "position",
header: "Position",
size: 12,
minSize: 12,
maxSize: 12,
cell: ({ row }) => {
return (
<div className="flex w-28 items-center justify-start gap-2 tabular-nums">
{row.index + 1}
{row.index <= 2 && (
<CrownSmall
className={cn("size-4", {
"text-amber-400": row.index === 0,
"text-neutral-400": row.index === 1,
"text-yellow-900": row.index === 2,
})}
/>
)}
</div>
);
},
},
{
id: "program",
header: "Program",
cell: ({ row }) => (
<div className="flex items-center gap-1.5">
<img
src={row.original.logo}
alt={row.original.name}
width={20}
height={20}
className="size-4 rounded-full"
/>
<span className="text-sm font-medium">{row.original.name}</span>
</div>
),
},
{
id: "commissions",
header: "Commissions",
accessorKey: "earnings",
cell: ({ row }) =>
currencyFormatter(row.original.earnings / 100, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
},
],
pagination,
onPaginationChange: setPagination,
resourceName: (plural) => `program${plural ? "s" : ""}`,
rowCount: programs?.length ?? 0,
loading: isLoading,
});

return (
<div className="mx-auto flex w-full max-w-screen-xl flex-col space-y-6 p-6">
<SimpleDateRangePicker defaultInterval="mtd" className="w-fit" />
<div className="flex flex-col divide-y divide-neutral-200 rounded-lg border border-neutral-200 bg-white">
<div className="scrollbar-hide grid w-full grid-cols-3 divide-x overflow-y-hidden">
{tabs.map(({ id, label, colorClassName }) => {
return (
<button
key={id}
onClick={() => {
setSelectedTab(id);
queryParams({
set: { tab: id },
});
}}
className={cn(
"border-box relative block h-full w-full flex-none px-4 py-3 sm:px-8 sm:py-6",
"transition-colors hover:bg-neutral-50 focus:outline-none active:bg-neutral-100",
"ring-inset ring-neutral-500 focus-visible:ring-1 sm:first:rounded-tl-xl",
)}
>
{/* Active tab indicator */}
<div
className={cn(
"absolute bottom-0 left-0 h-0.5 w-full bg-black transition-transform duration-100",
selectedTab !== id && "translate-y-[3px]",
)}
/>
<div className="flex items-center gap-2.5 text-sm text-neutral-600">
<div
className={cn(
"h-2 w-2 rounded-sm bg-current shadow-[inset_0_0_0_1px_#00000019]",
colorClassName,
)}
/>
<span>{label}</span>
</div>
<div className="mt-1 flex h-12 items-center">
{totals[id] ? (
<NumberFlow
value={totals[id] / 100}
className="text-xl font-medium sm:text-3xl"
format={{
style: "currency",
currency: "USD",
// @ts-ignore – trailingZeroDisplay is a valid option but TS is outdated
trailingZeroDisplay: "stripIfInteger",
}}
/>
) : (
<div className="h-9 w-16 animate-pulse rounded-md bg-neutral-200" />
)}
</div>
</button>
);
})}
</div>
<div className="p-5 sm:p-10">
<div className="flex h-96 w-full items-center justify-center">
{chartData ? (
chartData.length > 0 ? (
<TimeSeriesChart
data={chartData}
series={[
{
id: "value",
valueAccessor: (d) => d.values.value,
isActive: true,
colorClassName: tab.colorClassName,
},
]}
tooltipClassName="p-0"
tooltipContent={(d) => (
<>
<p className="border-b border-neutral-200 px-4 py-3 text-sm text-neutral-900">
{formatDateTooltip(d.date, {
interval,
start,
end,
})}
</p>
<div className="grid grid-cols-2 gap-x-6 gap-y-2 px-4 py-3 text-sm">
<Fragment>
<div className="flex items-center gap-2">
<div
className={cn(
"h-2 w-2 rounded-sm shadow-[inset_0_0_0_1px_#0003]",
tab.colorClassName,
)}
/>
<p className="capitalize text-neutral-600">
{tab.label}
</p>
</div>
<p className="text-right font-medium text-neutral-900">
{currencyFormatter(d.values.value / 100)}
</p>
</Fragment>
</div>
</>
)}
>
<Areas />
<XAxis
maxTicks={5}
tickFormat={(d) =>
formatDateTooltip(d, {
interval,
start,
end,
dataAvailableFrom: THE_BEGINNING_OF_TIME,
})
}
/>
<YAxis
showGridLines
tickFormat={(value) => currencyFormatter(value / 100)}
/>
</TimeSeriesChart>
) : (
<div className="text-center text-sm text-neutral-600">
No data available.
</div>
)
) : (
<AnalyticsLoadingSpinner />
)}
</div>
</div>
</div>
<div className="w-full">
<Table {...tableProps} table={table} />
</div>
</div>
);
}
10 changes: 10 additions & 0 deletions apps/web/app/admin.dub.co/(dashboard)/commissions/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Suspense } from "react";
import PayoutsPageClient from "./client";

export default async function PayoutsPage() {
return (
<Suspense>
<PayoutsPageClient />
</Suspense>
);
}
8 changes: 4 additions & 4 deletions apps/web/app/admin.dub.co/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ const tabs = [
label: "Analytics",
},
{
href: "/payouts",
label: "Payouts",
href: "/commissions",
label: "Commissions",
},
{
href: "/demo",
label: "Demo",
href: "/payouts",
label: "Payouts",
},
];

Expand Down
26 changes: 26 additions & 0 deletions apps/web/app/api/admin/commissions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { getStartEndDates } from "@/lib/analytics/utils/get-start-end-dates";
import { withAdmin } from "@/lib/auth";
import { THE_BEGINNING_OF_TIME } from "@dub/utils";
import { NextResponse } from "next/server";
import { getCommissionsTimeseries, getTopProgramsByCommissions } from "./utils";

export const GET = withAdmin(async ({ searchParams }) => {
const { interval = "mtd", start, end, timezone = "UTC" } = searchParams;

const { startDate, endDate, granularity } = getStartEndDates({
interval,
start,
end,
dataAvailableFrom: THE_BEGINNING_OF_TIME,
});

const [programs, timeseries] = await Promise.all([
getTopProgramsByCommissions({ startDate, endDate }),
getCommissionsTimeseries({ startDate, endDate, granularity, timezone }),
]);

return NextResponse.json({
programs,
timeseries,
});
});
Loading