Skip to content

[TOOL-3640] Dashboard: Add sponsored transactions table in team usage page #6434

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 8, 2025
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
26 changes: 15 additions & 11 deletions apps/dashboard/src/@/actions/proxies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ import { API_SERVER_URL } from "../constants/env";

type ProxyActionParams = {
pathname: string;
searchParams?: Record<string, string>;
searchParams?: Record<string, string | undefined>;
method: "GET" | "POST" | "PUT" | "DELETE";
body?: string;
headers?: Record<string, string>;
parseAsText?: boolean;
};

type ProxyActionResult<T extends object> =
type ProxyActionResult<T> =
| {
status: number;
ok: true;
Expand All @@ -23,7 +24,7 @@ type ProxyActionResult<T extends object> =
error: string;
};

async function proxy<T extends object>(
async function proxy<T>(
baseUrl: string,
params: ProxyActionParams,
): Promise<ProxyActionResult<T>> {
Expand All @@ -34,7 +35,10 @@ async function proxy<T extends object>(
url.pathname = params.pathname;
if (params.searchParams) {
for (const key in params.searchParams) {
url.searchParams.append(key, params.searchParams[key] as string);
const value = params.searchParams[key];
if (value) {
url.searchParams.append(key, value);
}
}
}

Expand Down Expand Up @@ -67,23 +71,23 @@ async function proxy<T extends object>(
return {
status: res.status,
ok: true,
data: await res.json(),
data: params.parseAsText ? await res.text() : await res.json(),
};
}

export async function apiServerProxy<T extends object = object>(
params: ProxyActionParams,
) {
export async function apiServerProxy<T>(params: ProxyActionParams) {
return proxy<T>(API_SERVER_URL, params);
}

export async function payServerProxy<T extends object = object>(
params: ProxyActionParams,
) {
export async function payServerProxy<T>(params: ProxyActionParams) {
return proxy<T>(
process.env.NEXT_PUBLIC_PAY_URL
? `https://${process.env.NEXT_PUBLIC_PAY_URL}`
: "https://pay.thirdweb-dev.com",
params,
);
}

export async function analyticsServerProxy<T>(params: ProxyActionParams) {
return proxy<T>(process.env.ANALYTICS_SERVICE_URL || "", params);
}
28 changes: 17 additions & 11 deletions apps/dashboard/src/@/components/blocks/ExportToCSVButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,25 @@ import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { useMutation } from "@tanstack/react-query";
import { DownloadIcon } from "lucide-react";
import Papa from "papaparse";
import { toast } from "sonner";
import { cn } from "../../lib/utils";

export function ExportToCSVButton(props: {
getData: () => Promise<{ header: string[]; rows: string[][] }>;
getData: () => Promise<{ header: string[]; rows: string[][] } | string>;
fileName: string;
disabled?: boolean;
className?: string;
}) {
const exportMutation = useMutation({
mutationFn: async () => {
const data = await props.getData();
exportToCSV(props.fileName, data);
if (typeof data === "string") {
exportToCSV(props.fileName, data);
} else {
const fileContent = convertToCSVFormat(data);
exportToCSV(props.fileName, fileContent);
}
},
onError: () => {
toast.error("Failed to download CSV");
Expand All @@ -29,7 +35,7 @@ export function ExportToCSVButton(props: {
return (
<Button
variant="outline"
disabled={props.disabled}
disabled={props.disabled || exportMutation.isPending}
className={cn("flex items-center gap-2 border text-xs", props.className)}
onClick={async () => {
exportMutation.mutate();
Expand All @@ -50,15 +56,15 @@ export function ExportToCSVButton(props: {
);
}

function exportToCSV(
fileName: string,
data: { header: string[]; rows: string[][] },
) {
const { header, rows } = data;
const csvContent = `data:text/csv;charset=utf-8,${header.join(",")}\n${rows
.map((e) => e.join(","))
.join("\n")}`;
function convertToCSVFormat(data: { header: string[]; rows: string[][] }) {
return Papa.unparse({
fields: data.header,
data: data.rows,
});
}

function exportToCSV(fileName: string, fileContent: string) {
const csvContent = `data:text/csv;charset=utf-8,${fileContent}`;
const encodedUri = encodeURI(csvContent);
const link = document.createElement("a");
link.setAttribute("href", encodedUri);
Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/src/@/components/blocks/Img.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export function Img(props: imgElementProps) {
}, []);

return (
<div className="relative">
<div className="relative shrink-0">
<img
{...restProps}
// avoid setting empty src string to prevent request to the entire page
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export function SingleNetworkSelector(props: {
side?: "left" | "right" | "top" | "bottom";
disableChainId?: boolean;
align?: "center" | "start" | "end";
placeholder?: string;
}) {
const { allChains, idToChain } = useAllChainsData();

Expand Down Expand Up @@ -180,7 +181,11 @@ export function SingleNetworkSelector(props: {
props.onChange(Number(chainId));
}}
closeOnSelect={true}
placeholder={isLoadingChains ? "Loading Chains..." : "Select Chain"}
placeholder={
isLoadingChains
? "Loading Chains..."
: props.placeholder || "Select Chain"
}
overrideSearchFn={searchFn}
renderOption={renderOption}
className={props.className}
Expand Down
14 changes: 12 additions & 2 deletions apps/dashboard/src/@/components/blocks/wallet-address.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ import {
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
import { useClipboard } from "hooks/useClipboard";
import { Check, Copy } from "lucide-react";
import { Check, Copy, XIcon } from "lucide-react";
import { useMemo } from "react";
import { type ThirdwebClient, isAddress } from "thirdweb";
import { ZERO_ADDRESS } from "thirdweb";
import { Blobbie, type SocialProfile, useSocialProfiles } from "thirdweb/react";
import { cn } from "../../lib/utils";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { ToolTipLabel } from "../ui/tooltip";
import { Img } from "./Img";

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

if (!isAddress(address)) {
return <span>Invalid Address ({address})</span>;
return (
<ToolTipLabel label={address} hoverable>
<span className="flex items-center gap-2 underline-offset-4 hover:underline">
<div className="flex size-6 items-center justify-center rounded-full border bg-background">
<XIcon className="size-4 text-muted-foreground" />
</div>
Invalid Address
</span>
</ToolTipLabel>
);
}

// special case for zero address
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"use client";
import { analyticsServerProxy } from "@/actions/proxies";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useState } from "react";
import type { ThirdwebClient } from "thirdweb/dist/types/client/client";
import {
type SponsoredTransaction,
SponsoredTransactionsTableUI,
} from "./SponsoredTransactionsTableUI";

type GetSponsoredTransactionsParams = {
teamId: string;
limit: number;
offset: number;
from: string;
to: string;
// optional
projectId?: string;
chainId?: string;
};

const getSponsoredTransactions = async (
params: GetSponsoredTransactionsParams,
) => {
const res = await analyticsServerProxy<{
data: SponsoredTransaction[];
meta: {
total: number;
};
}>({
pathname: "/v2/bundler/sponsored-transactions",
method: "GET",
searchParams: {
teamId: params.teamId,
limit: params.limit.toString(),
offset: params.offset.toString(),
projectId: params.projectId,
chainId: params.chainId,
from: params.from,
to: params.to,
},
});

if (!res.ok) {
throw new Error(res.error);
}

return res.data;
};

async function getSponsoredTransactionsCSV(params: {
teamId: string;
from: string;
to: string;
}) {
const res = await analyticsServerProxy<string>({
method: "GET",
pathname: "/v2/bundler/sponsored-transactions/export",
parseAsText: true,
searchParams: {
teamId: params.teamId,
from: params.from,
to: params.to,
},
});

if (!res.ok) {
throw new Error(res.error);
}

return res.data;
}

export function SponsoredTransactionsTable(
props: {
client: ThirdwebClient;
teamId: string;
teamSlug: string;
from: string;
to: string;
} & (
| {
variant: "team";
projects: {
id: string;
name: string;
image: string | null;
slug: string;
}[];
}
| {
variant: "project";
projectId: string;
}
),
) {
const pageSize = 10;
const [page, setPage] = useState(1);

const [filters, setFilters] = useState<{
chainId?: string;
projectId?: string;
}>({
projectId: props.variant === "project" ? props.projectId : undefined,
});

const params = {
teamId: props.teamId,
limit: pageSize,
offset: (page - 1) * pageSize,
from: props.from,
to: props.to,
chainId: filters.chainId,
projectId: filters.projectId,
};

const sponsoredTransactionsQuery = useQuery({
queryKey: ["sponsored-transactions", params],
queryFn: () => {
return getSponsoredTransactions(params);
},
placeholderData: keepPreviousData,
refetchOnWindowFocus: false,
});

const totalPages = sponsoredTransactionsQuery.data
? Math.ceil(sponsoredTransactionsQuery.data.meta.total / pageSize)
: 0;

return (
<SponsoredTransactionsTableUI
filters={filters}
variant={props.variant}
projects={props.variant === "team" ? props.projects : []}
setFilters={(v) => {
setFilters(v);
setPage(1);
}}
client={props.client}
isError={sponsoredTransactionsQuery.isError}
getCSV={() => {
return getSponsoredTransactionsCSV({
teamId: props.teamId,
from: props.from,
to: props.to,
});
}}
isPending={sponsoredTransactionsQuery.isFetching}
sponsoredTransactions={sponsoredTransactionsQuery.data?.data ?? []}
pageNumber={page}
setPageNumber={setPage}
pageSize={pageSize}
teamSlug={props.teamSlug}
totalPages={totalPages}
/>
);
}
Loading
Loading