Skip to content

Commit f8eb86d

Browse files
committed
[TOOL-3640] Dashboard: Add sponsored transactions table in team usage page (#6434)
<!-- start pr-codex --> ## PR-Codex overview This PR focuses on enhancing the functionality and user experience of the dashboard by adding new features, improving existing components, and refining data handling, particularly around sponsored transactions and usage analytics. ### Detailed summary - Added `placeholder` prop to `SingleNetworkSelector`. - Integrated `SponsoredTransactionsTable` in `AccountAbstractionAnalytics`. - Enhanced `Usage` component with `client` and `projects` props. - Improved `ExportToCSVButton` to handle different data types. - Introduced `SponsoredTransactionsTable` for displaying sponsored transaction data. - Updated `SponsoredTransactionsTableUI` with pagination and filter options. - Added error handling and loading states in various components. - Refactored proxy actions to support optional search parameters and text parsing. - Introduced `getAuthToken` for authentication in several pages. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex -->
1 parent 2ae778e commit f8eb86d

File tree

12 files changed

+939
-33
lines changed

12 files changed

+939
-33
lines changed

apps/dashboard/src/@/actions/proxies.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@ import { API_SERVER_URL } from "../constants/env";
55

66
type ProxyActionParams = {
77
pathname: string;
8-
searchParams?: Record<string, string>;
8+
searchParams?: Record<string, string | undefined>;
99
method: "GET" | "POST" | "PUT" | "DELETE";
1010
body?: string;
1111
headers?: Record<string, string>;
12+
parseAsText?: boolean;
1213
};
1314

14-
type ProxyActionResult<T extends object> =
15+
type ProxyActionResult<T> =
1516
| {
1617
status: number;
1718
ok: true;
@@ -23,7 +24,7 @@ type ProxyActionResult<T extends object> =
2324
error: string;
2425
};
2526

26-
async function proxy<T extends object>(
27+
async function proxy<T>(
2728
baseUrl: string,
2829
params: ProxyActionParams,
2930
): Promise<ProxyActionResult<T>> {
@@ -34,7 +35,10 @@ async function proxy<T extends object>(
3435
url.pathname = params.pathname;
3536
if (params.searchParams) {
3637
for (const key in params.searchParams) {
37-
url.searchParams.append(key, params.searchParams[key] as string);
38+
const value = params.searchParams[key];
39+
if (value) {
40+
url.searchParams.append(key, value);
41+
}
3842
}
3943
}
4044

@@ -67,23 +71,23 @@ async function proxy<T extends object>(
6771
return {
6872
status: res.status,
6973
ok: true,
70-
data: await res.json(),
74+
data: params.parseAsText ? await res.text() : await res.json(),
7175
};
7276
}
7377

74-
export async function apiServerProxy<T extends object = object>(
75-
params: ProxyActionParams,
76-
) {
78+
export async function apiServerProxy<T>(params: ProxyActionParams) {
7779
return proxy<T>(API_SERVER_URL, params);
7880
}
7981

80-
export async function payServerProxy<T extends object = object>(
81-
params: ProxyActionParams,
82-
) {
82+
export async function payServerProxy<T>(params: ProxyActionParams) {
8383
return proxy<T>(
8484
process.env.NEXT_PUBLIC_PAY_URL
8585
? `https://${process.env.NEXT_PUBLIC_PAY_URL}`
8686
: "https://pay.thirdweb-dev.com",
8787
params,
8888
);
8989
}
90+
91+
export async function analyticsServerProxy<T>(params: ProxyActionParams) {
92+
return proxy<T>(process.env.ANALYTICS_SERVICE_URL || "", params);
93+
}

apps/dashboard/src/@/components/blocks/ExportToCSVButton.tsx

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,25 @@ import { Spinner } from "@/components/ui/Spinner/Spinner";
22
import { Button } from "@/components/ui/button";
33
import { useMutation } from "@tanstack/react-query";
44
import { DownloadIcon } from "lucide-react";
5+
import Papa from "papaparse";
56
import { toast } from "sonner";
67
import { cn } from "../../lib/utils";
78

89
export function ExportToCSVButton(props: {
9-
getData: () => Promise<{ header: string[]; rows: string[][] }>;
10+
getData: () => Promise<{ header: string[]; rows: string[][] } | string>;
1011
fileName: string;
1112
disabled?: boolean;
1213
className?: string;
1314
}) {
1415
const exportMutation = useMutation({
1516
mutationFn: async () => {
1617
const data = await props.getData();
17-
exportToCSV(props.fileName, data);
18+
if (typeof data === "string") {
19+
exportToCSV(props.fileName, data);
20+
} else {
21+
const fileContent = convertToCSVFormat(data);
22+
exportToCSV(props.fileName, fileContent);
23+
}
1824
},
1925
onError: () => {
2026
toast.error("Failed to download CSV");
@@ -29,7 +35,7 @@ export function ExportToCSVButton(props: {
2935
return (
3036
<Button
3137
variant="outline"
32-
disabled={props.disabled}
38+
disabled={props.disabled || exportMutation.isPending}
3339
className={cn("flex items-center gap-2 border text-xs", props.className)}
3440
onClick={async () => {
3541
exportMutation.mutate();
@@ -50,15 +56,15 @@ export function ExportToCSVButton(props: {
5056
);
5157
}
5258

53-
function exportToCSV(
54-
fileName: string,
55-
data: { header: string[]; rows: string[][] },
56-
) {
57-
const { header, rows } = data;
58-
const csvContent = `data:text/csv;charset=utf-8,${header.join(",")}\n${rows
59-
.map((e) => e.join(","))
60-
.join("\n")}`;
59+
function convertToCSVFormat(data: { header: string[]; rows: string[][] }) {
60+
return Papa.unparse({
61+
fields: data.header,
62+
data: data.rows,
63+
});
64+
}
6165

66+
function exportToCSV(fileName: string, fileContent: string) {
67+
const csvContent = `data:text/csv;charset=utf-8,${fileContent}`;
6268
const encodedUri = encodeURI(csvContent);
6369
const link = document.createElement("a");
6470
link.setAttribute("href", encodedUri);

apps/dashboard/src/@/components/blocks/Img.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export function Img(props: imgElementProps) {
4747
}, []);
4848

4949
return (
50-
<div className="relative">
50+
<div className="relative shrink-0">
5151
<img
5252
{...restProps}
5353
// avoid setting empty src string to prevent request to the entire page

apps/dashboard/src/@/components/blocks/NetworkSelectors.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ export function SingleNetworkSelector(props: {
104104
side?: "left" | "right" | "top" | "bottom";
105105
disableChainId?: boolean;
106106
align?: "center" | "start" | "end";
107+
placeholder?: string;
107108
}) {
108109
const { allChains, idToChain } = useAllChainsData();
109110

@@ -180,7 +181,11 @@ export function SingleNetworkSelector(props: {
180181
props.onChange(Number(chainId));
181182
}}
182183
closeOnSelect={true}
183-
placeholder={isLoadingChains ? "Loading Chains..." : "Select Chain"}
184+
placeholder={
185+
isLoadingChains
186+
? "Loading Chains..."
187+
: props.placeholder || "Select Chain"
188+
}
184189
overrideSearchFn={searchFn}
185190
renderOption={renderOption}
186191
className={props.className}

apps/dashboard/src/@/components/blocks/wallet-address.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ import {
88
import { useThirdwebClient } from "@/constants/thirdweb.client";
99
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
1010
import { useClipboard } from "hooks/useClipboard";
11-
import { Check, Copy } from "lucide-react";
11+
import { Check, Copy, XIcon } from "lucide-react";
1212
import { useMemo } from "react";
1313
import { type ThirdwebClient, isAddress } from "thirdweb";
1414
import { ZERO_ADDRESS } from "thirdweb";
1515
import { Blobbie, type SocialProfile, useSocialProfiles } from "thirdweb/react";
1616
import { cn } from "../../lib/utils";
1717
import { Badge } from "../ui/badge";
1818
import { Button } from "../ui/button";
19+
import { ToolTipLabel } from "../ui/tooltip";
1920
import { Img } from "./Img";
2021

2122
export function WalletAddress(props: {
@@ -44,7 +45,16 @@ export function WalletAddress(props: {
4445
const { onCopy, hasCopied } = useClipboard(address, 2000);
4546

4647
if (!isAddress(address)) {
47-
return <span>Invalid Address ({address})</span>;
48+
return (
49+
<ToolTipLabel label={address} hoverable>
50+
<span className="flex items-center gap-2 underline-offset-4 hover:underline">
51+
<div className="flex size-6 items-center justify-center rounded-full border bg-background">
52+
<XIcon className="size-4 text-muted-foreground" />
53+
</div>
54+
Invalid Address
55+
</span>
56+
</ToolTipLabel>
57+
);
4858
}
4959

5060
// special case for zero address
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"use client";
2+
import { analyticsServerProxy } from "@/actions/proxies";
3+
import { keepPreviousData, useQuery } from "@tanstack/react-query";
4+
import { useState } from "react";
5+
import type { ThirdwebClient } from "thirdweb/dist/types/client/client";
6+
import {
7+
type SponsoredTransaction,
8+
SponsoredTransactionsTableUI,
9+
} from "./SponsoredTransactionsTableUI";
10+
11+
type GetSponsoredTransactionsParams = {
12+
teamId: string;
13+
limit: number;
14+
offset: number;
15+
from: string;
16+
to: string;
17+
// optional
18+
projectId?: string;
19+
chainId?: string;
20+
};
21+
22+
const getSponsoredTransactions = async (
23+
params: GetSponsoredTransactionsParams,
24+
) => {
25+
const res = await analyticsServerProxy<{
26+
data: SponsoredTransaction[];
27+
meta: {
28+
total: number;
29+
};
30+
}>({
31+
pathname: "/v2/bundler/sponsored-transactions",
32+
method: "GET",
33+
searchParams: {
34+
teamId: params.teamId,
35+
limit: params.limit.toString(),
36+
offset: params.offset.toString(),
37+
projectId: params.projectId,
38+
chainId: params.chainId,
39+
from: params.from,
40+
to: params.to,
41+
},
42+
});
43+
44+
if (!res.ok) {
45+
throw new Error(res.error);
46+
}
47+
48+
return res.data;
49+
};
50+
51+
async function getSponsoredTransactionsCSV(params: {
52+
teamId: string;
53+
from: string;
54+
to: string;
55+
}) {
56+
const res = await analyticsServerProxy<string>({
57+
method: "GET",
58+
pathname: "/v2/bundler/sponsored-transactions/export",
59+
parseAsText: true,
60+
searchParams: {
61+
teamId: params.teamId,
62+
from: params.from,
63+
to: params.to,
64+
},
65+
});
66+
67+
if (!res.ok) {
68+
throw new Error(res.error);
69+
}
70+
71+
return res.data;
72+
}
73+
74+
export function SponsoredTransactionsTable(
75+
props: {
76+
client: ThirdwebClient;
77+
teamId: string;
78+
teamSlug: string;
79+
from: string;
80+
to: string;
81+
} & (
82+
| {
83+
variant: "team";
84+
projects: {
85+
id: string;
86+
name: string;
87+
image: string | null;
88+
slug: string;
89+
}[];
90+
}
91+
| {
92+
variant: "project";
93+
projectId: string;
94+
}
95+
),
96+
) {
97+
const pageSize = 10;
98+
const [page, setPage] = useState(1);
99+
100+
const [filters, setFilters] = useState<{
101+
chainId?: string;
102+
projectId?: string;
103+
}>({
104+
projectId: props.variant === "project" ? props.projectId : undefined,
105+
});
106+
107+
const params = {
108+
teamId: props.teamId,
109+
limit: pageSize,
110+
offset: (page - 1) * pageSize,
111+
from: props.from,
112+
to: props.to,
113+
chainId: filters.chainId,
114+
projectId: filters.projectId,
115+
};
116+
117+
const sponsoredTransactionsQuery = useQuery({
118+
queryKey: ["sponsored-transactions", params],
119+
queryFn: () => {
120+
return getSponsoredTransactions(params);
121+
},
122+
placeholderData: keepPreviousData,
123+
refetchOnWindowFocus: false,
124+
});
125+
126+
const totalPages = sponsoredTransactionsQuery.data
127+
? Math.ceil(sponsoredTransactionsQuery.data.meta.total / pageSize)
128+
: 0;
129+
130+
return (
131+
<SponsoredTransactionsTableUI
132+
filters={filters}
133+
variant={props.variant}
134+
projects={props.variant === "team" ? props.projects : []}
135+
setFilters={(v) => {
136+
setFilters(v);
137+
setPage(1);
138+
}}
139+
client={props.client}
140+
isError={sponsoredTransactionsQuery.isError}
141+
getCSV={() => {
142+
return getSponsoredTransactionsCSV({
143+
teamId: props.teamId,
144+
from: props.from,
145+
to: props.to,
146+
});
147+
}}
148+
isPending={sponsoredTransactionsQuery.isFetching}
149+
sponsoredTransactions={sponsoredTransactionsQuery.data?.data ?? []}
150+
pageNumber={page}
151+
setPageNumber={setPage}
152+
pageSize={pageSize}
153+
teamSlug={props.teamSlug}
154+
totalPages={totalPages}
155+
/>
156+
);
157+
}

0 commit comments

Comments
 (0)