Skip to content

Commit 6e2f535

Browse files
authored
[Dashboard] Payment Links Page (#7706)
1 parent 430115c commit 6e2f535

File tree

21 files changed

+1090
-207
lines changed

21 files changed

+1090
-207
lines changed

apps/dashboard/src/@/api/universal-bridge/developer.ts

Lines changed: 141 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"use server";
2+
import type { Address } from "thirdweb";
23
import { getAuthToken } from "@/api/auth-token";
34
import { NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST } from "@/constants/public-envs";
45

@@ -96,6 +97,139 @@ export async function deleteWebhook(props: {
9697
return;
9798
}
9899

100+
type PaymentLink = {
101+
id: string;
102+
link: string;
103+
title: string;
104+
imageUrl: string;
105+
createdAt: string;
106+
updatedAt: string;
107+
destinationToken: {
108+
chainId: number;
109+
address: Address;
110+
symbol: string;
111+
name: string;
112+
decimals: number;
113+
iconUri: string;
114+
};
115+
receiver: Address;
116+
amount: bigint;
117+
};
118+
119+
export async function getPaymentLinks(props: {
120+
clientId: string;
121+
teamId: string;
122+
}): Promise<Array<PaymentLink>> {
123+
const authToken = await getAuthToken();
124+
const res = await fetch(`${UB_BASE_URL}/v1/developer/links`, {
125+
headers: {
126+
Authorization: `Bearer ${authToken}`,
127+
"Content-Type": "application/json",
128+
"x-client-id": props.clientId,
129+
"x-team-id": props.teamId,
130+
},
131+
method: "GET",
132+
});
133+
134+
if (!res.ok) {
135+
const text = await res.text();
136+
throw new Error(text);
137+
}
138+
139+
const json = (await res.json()) as {
140+
data: Array<PaymentLink & { amount: string }>;
141+
};
142+
return json.data.map((link) => ({
143+
id: link.id,
144+
link: link.link,
145+
title: link.title,
146+
imageUrl: link.imageUrl,
147+
createdAt: link.createdAt,
148+
updatedAt: link.updatedAt,
149+
destinationToken: {
150+
chainId: link.destinationToken.chainId,
151+
address: link.destinationToken.address,
152+
symbol: link.destinationToken.symbol,
153+
name: link.destinationToken.name,
154+
decimals: link.destinationToken.decimals,
155+
iconUri: link.destinationToken.iconUri,
156+
},
157+
receiver: link.receiver,
158+
amount: BigInt(link.amount),
159+
}));
160+
}
161+
162+
export async function createPaymentLink(props: {
163+
clientId: string;
164+
teamId: string;
165+
title: string;
166+
imageUrl?: string;
167+
intent: {
168+
destinationChainId: number;
169+
destinationTokenAddress: Address;
170+
receiver: Address;
171+
amount: bigint;
172+
purchaseData?: unknown;
173+
};
174+
}) {
175+
const authToken = await getAuthToken();
176+
177+
const res = await fetch(`${UB_BASE_URL}/v1/developer/links`, {
178+
body: JSON.stringify({
179+
title: props.title,
180+
imageUrl: props.imageUrl,
181+
intent: {
182+
destinationChainId: props.intent.destinationChainId,
183+
destinationTokenAddress: props.intent.destinationTokenAddress,
184+
receiver: props.intent.receiver,
185+
amount: props.intent.amount.toString(),
186+
purchaseData: props.intent.purchaseData,
187+
},
188+
}),
189+
headers: {
190+
Authorization: `Bearer ${authToken}`,
191+
"Content-Type": "application/json",
192+
"x-client-id": props.clientId,
193+
"x-team-id": props.teamId,
194+
},
195+
method: "POST",
196+
});
197+
198+
if (!res.ok) {
199+
const text = await res.text();
200+
throw new Error(text);
201+
}
202+
203+
return;
204+
}
205+
206+
export async function deletePaymentLink(props: {
207+
clientId: string;
208+
teamId: string;
209+
paymentLinkId: string;
210+
}) {
211+
const authToken = await getAuthToken();
212+
const res = await fetch(
213+
`${UB_BASE_URL}/v1/developer/links/${props.paymentLinkId}`,
214+
{
215+
headers: {
216+
Authorization: `Bearer ${authToken}`,
217+
"Content-Type": "application/json",
218+
"x-client-id": props.clientId,
219+
"x-team-id": props.teamId,
220+
},
221+
method: "DELETE",
222+
},
223+
);
224+
225+
if (!res.ok) {
226+
const text = await res.text();
227+
throw new Error(text);
228+
}
229+
230+
return;
231+
}
232+
99233
export type Fee = {
100234
feeRecipient: string;
101235
feeBps: number;
@@ -195,30 +329,28 @@ export type Payment = {
195329
export async function getPayments(props: {
196330
clientId: string;
197331
teamId: string;
332+
paymentLinkId?: string;
198333
limit?: number;
199334
offset?: number;
200335
}) {
201336
const authToken = await getAuthToken();
202337

203338
// Build URL with query parameters if provided
204-
let url = `${UB_BASE_URL}/v1/developer/payments`;
205-
const queryParams = new URLSearchParams();
339+
const url = new URL(`${UB_BASE_URL}/v1/developer/payments`);
206340

207341
if (props.limit) {
208-
queryParams.append("limit", props.limit.toString());
342+
url.searchParams.append("limit", props.limit.toString());
209343
}
210344

211345
if (props.offset) {
212-
queryParams.append("offset", props.offset.toString());
346+
url.searchParams.append("offset", props.offset.toString());
213347
}
214348

215-
// Append query params to URL if any exist
216-
const queryString = queryParams.toString();
217-
if (queryString) {
218-
url = `${url}?${queryString}`;
349+
if (props.paymentLinkId) {
350+
url.searchParams.append("paymentLinkId", props.paymentLinkId);
219351
}
220352

221-
const res = await fetch(url, {
353+
const res = await fetch(url.toString(), {
222354
headers: {
223355
Authorization: `Bearer ${authToken}`,
224356
"Content-Type": "application/json",

apps/dashboard/src/@/api/universal-bridge/tokens.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,18 @@ export type TokenMetadata = {
1313
iconUri?: string;
1414
};
1515

16-
export async function getUniversalBridgeTokens(props: { chainId?: number }) {
16+
export async function getUniversalBridgeTokens(props: {
17+
chainId?: number;
18+
address?: string;
19+
}) {
1720
const url = new URL(`${UB_BASE_URL}/v1/tokens`);
1821

1922
if (props.chainId) {
2023
url.searchParams.append("chainId", String(props.chainId));
2124
}
25+
if (props.address) {
26+
url.searchParams.append("tokenAddress", props.address);
27+
}
2228
url.searchParams.append("limit", "1000");
2329

2430
const res = await fetch(url.toString(), {

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { useTokensData } from "@/hooks/tokens";
1414
import { replaceIpfsUrl } from "@/lib/sdk";
1515
import { cn } from "@/lib/utils";
1616
import { fallbackChainIcon } from "@/utils/chain-icons";
17+
import { Spinner } from "../ui/Spinner/Spinner";
1718

1819
type Option = { label: string; value: string };
1920

@@ -186,9 +187,14 @@ export function TokenSelector(props: {
186187
options={options}
187188
overrideSearchFn={searchFn}
188189
placeholder={
189-
tokensQuery.isPending
190-
? "Loading Tokens"
191-
: props.placeholder || "Select Token"
190+
tokensQuery.isPending ? (
191+
<div className="flex items-center gap-2">
192+
<Spinner className="size-4" />
193+
<span>Loading tokens</span>
194+
</div>
195+
) : (
196+
props.placeholder || "Select Token"
197+
)
192198
}
193199
popoverContentClassName={props.popoverContentClassName}
194200
renderOption={renderOption}

apps/dashboard/src/@/components/blocks/select-with-search.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ interface SelectWithSearchProps
2222
}[];
2323
value: string | undefined;
2424
onValueChange: (value: string) => void;
25-
placeholder: string;
25+
placeholder: string | React.ReactNode;
2626
searchPlaceholder?: string;
2727
className?: string;
2828
overrideSearchFn?: (

apps/dashboard/src/@/components/ui/CopyButton.tsx

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,42 @@ import { ToolTipLabel } from "./tooltip";
88

99
export function CopyButton(props: {
1010
text: string;
11+
label?: string;
1112
className?: string;
1213
iconClassName?: string;
13-
variant?: "ghost" | "primary" | "secondary";
14+
tooltip?: boolean;
15+
variant?: "ghost" | "primary" | "secondary" | "default" | "outline";
1416
}) {
1517
const { hasCopied, onCopy } = useClipboard(props.text, 1000);
16-
return (
17-
<ToolTipLabel label="Copy">
18-
<Button
19-
aria-label="Copy"
20-
className={cn("h-auto w-auto p-1", props.className)}
21-
onClick={onCopy}
22-
variant={props.variant || "ghost"}
23-
>
24-
{hasCopied ? (
25-
<CheckIcon
26-
className={cn("size-4 text-green-500", props.iconClassName)}
27-
/>
28-
) : (
29-
<CopyIcon
30-
className={cn("size-4 text-muted-foreground", props.iconClassName)}
31-
/>
32-
)}
33-
</Button>
34-
</ToolTipLabel>
18+
const showTooltip = props.tooltip ?? true;
19+
20+
const button = (
21+
<Button
22+
aria-label="Copy"
23+
className={cn(
24+
"h-auto w-auto flex items-center gap-2 text-muted-foreground",
25+
props.label ? "p-2" : "p-1",
26+
props.className,
27+
)}
28+
onClick={onCopy}
29+
variant={props.variant || "ghost"}
30+
>
31+
{hasCopied ? (
32+
<CheckIcon
33+
className={cn("size-4 text-green-500", props.iconClassName)}
34+
/>
35+
) : (
36+
<CopyIcon
37+
className={cn("size-4 text-muted-foreground", props.iconClassName)}
38+
/>
39+
)}
40+
{props.label}
41+
</Button>
3542
);
43+
44+
if (!showTooltip) {
45+
return button;
46+
}
47+
48+
return <ToolTipLabel label="Copy">{button}</ToolTipLabel>;
3649
}

apps/dashboard/src/@/components/ui/tabs.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export function TabLinks(props: {
5656
<Link
5757
aria-disabled={tab.isDisabled}
5858
className={cn(
59-
"relative h-auto rounded-lg px-3 font-normal text-muted-foreground text-sm hover:bg-accent lg:text-sm",
59+
"relative h-auto rounded-lg px-3 font-normal text-muted-foreground text-sm lg:text-sm",
6060
!tab.isActive && !tab.isDisabled && "hover:text-foreground",
6161
tab.isDisabled && "pointer-events-none",
6262
tab.isActive && "!text-foreground",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Card } from "@/components/ui/card";
2+
3+
export function EmptyState(props: {
4+
icon: React.FC<{ className?: string }>;
5+
title: string;
6+
description: string;
7+
buttons: Array<React.ReactNode>;
8+
}) {
9+
return (
10+
<Card className="flex flex-col p-16 gap-8 items-center justify-center">
11+
<div className="bg-violet-800/25 text-muted-foreground rounded-full size-16 flex items-center justify-center">
12+
<props.icon className="size-8 text-violet-500" />
13+
</div>
14+
<div className="flex flex-col gap-1 items-center text-center">
15+
<h3 className="text-foreground font-medium text-xl">{props.title}</h3>
16+
<p className="text-muted-foreground text-sm max-w-md">
17+
{props.description}
18+
</p>
19+
</div>
20+
<div className="flex gap-4">{props.buttons.map((button) => button)}</div>
21+
</Card>
22+
);
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { OctagonAlertIcon } from "lucide-react";
2+
import { Card } from "@/components/ui/card";
3+
4+
export function ErrorState(props: {
5+
title: string;
6+
description: string;
7+
buttons: Array<React.ReactNode>;
8+
}) {
9+
return (
10+
<Card className="flex flex-col p-16 gap-8 items-center justify-center">
11+
<OctagonAlertIcon className="size-8 text-red-500" />
12+
<div className="flex flex-col gap-1 items-center text-center">
13+
<h3 className="text-foreground font-medium text-xl">{props.title}</h3>
14+
<p className="text-muted-foreground text-sm max-w-md">
15+
{props.description}
16+
</p>
17+
</div>
18+
{props.buttons && (
19+
<div className="flex gap-4">
20+
{props.buttons.map((button) => button)}
21+
</div>
22+
)}
23+
</Card>
24+
);
25+
}

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/payments/components/FeatureCard.client.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ export function FeatureCard(props: {
2323
<div className="flex-1 flex flex-col items-start gap-6 w-full">
2424
<div className="relative w-full">
2525
<div
26-
className={`${props.color === "green" ? "bg-green-700/25" : "bg-violet-700/25"} rounded-lg size-9 flex items-center justify-center`}
26+
className={
27+
"border bg-background rounded-lg size-9 flex items-center justify-center"
28+
}
2729
>
28-
<props.icon
29-
className={`size-5 ${props.color === "green" ? "text-green-500" : "text-violet-500"}`}
30-
/>
30+
<props.icon className={`size-5 text-foreground`} />
3131
</div>
3232
{props.badge && (
3333
<Badge

0 commit comments

Comments
 (0)