Skip to content

Commit 585ba2f

Browse files
authored
Feature: Checkout link creation page (#6989)
1 parent a68ec74 commit 585ba2f

File tree

10 files changed

+738
-9
lines changed

10 files changed

+738
-9
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const UB_BASE_URL = process.env.NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST;
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"use server";
2+
import { getAuthToken } from "app/(app)/api/lib/getAuthToken";
3+
import { UB_BASE_URL } from "./constants";
4+
5+
export type TokenMetadata = {
6+
name: string;
7+
symbol: string;
8+
address: string;
9+
decimals: number;
10+
chainId: number;
11+
iconUri?: string;
12+
};
13+
14+
export async function getUniversalBridgeTokens(props: {
15+
clientId?: string;
16+
chainId?: number;
17+
}) {
18+
const authToken = await getAuthToken();
19+
const url = new URL(`${UB_BASE_URL}/v1/tokens`);
20+
21+
if (props.chainId) {
22+
url.searchParams.append("chainId", String(props.chainId));
23+
}
24+
url.searchParams.append("limit", "1000");
25+
26+
const res = await fetch(url.toString(), {
27+
method: "GET",
28+
headers: {
29+
"Content-Type": "application/json",
30+
"x-client-id-override": props.clientId,
31+
Authorization: `Bearer ${authToken}`,
32+
} as Record<string, string>,
33+
});
34+
35+
if (!res.ok) {
36+
const text = await res.text();
37+
throw new Error(text);
38+
}
39+
40+
const json = await res.json();
41+
return json.data as Array<TokenMetadata>;
42+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ export function SingleNetworkSelector(props: {
218218
<SelectWithSearch
219219
searchPlaceholder="Search by Name or Chain ID"
220220
value={String(props.chainId)}
221+
showCheck={false}
221222
options={options}
222223
onValueChange={(chainId) => {
223224
props.onChange(Number(chainId));
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { useState } from "react";
3+
import {
4+
BadgeContainer,
5+
storybookThirdwebClient,
6+
} from "../../../stories/utils";
7+
import { TokenSelector } from "./TokenSelector";
8+
9+
const meta = {
10+
title: "blocks/Cards/TokenSelector",
11+
component: Story,
12+
parameters: {
13+
nextjs: {
14+
appDirectory: true,
15+
},
16+
},
17+
} satisfies Meta<typeof Story>;
18+
19+
export default meta;
20+
type Story = StoryObj<typeof meta>;
21+
22+
export const Variants: Story = {
23+
args: {},
24+
};
25+
26+
function Story() {
27+
return (
28+
<div className="container flex max-w-6xl flex-col gap-8 py-10">
29+
<Variant label="No Chains selected by default" />
30+
</div>
31+
);
32+
}
33+
34+
function Variant(props: {
35+
label: string;
36+
selectedChainId?: number;
37+
}) {
38+
const [tokenAddress, setTokenAddress] = useState<string>("");
39+
return (
40+
<BadgeContainer label={props.label}>
41+
<TokenSelector
42+
tokenAddress={tokenAddress}
43+
chainId={props.selectedChainId}
44+
client={storybookThirdwebClient}
45+
onChange={setTokenAddress}
46+
/>
47+
</BadgeContainer>
48+
);
49+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { useCallback, useMemo } from "react";
2+
import type { ThirdwebClient } from "thirdweb";
3+
import { shortenAddress } from "thirdweb/utils";
4+
import { useTokensData } from "../../../hooks/tokens/tokens";
5+
import { replaceIpfsUrl } from "../../../lib/sdk";
6+
import { fallbackChainIcon } from "../../../utils/chain-icons";
7+
import { cn } from "../../lib/utils";
8+
import { Badge } from "../ui/badge";
9+
import { Img } from "./Img";
10+
import { SelectWithSearch } from "./select-with-search";
11+
12+
type Option = { label: string; value: string };
13+
14+
export function TokenSelector(props: {
15+
tokenAddress: string | undefined;
16+
onChange: (tokenAddress: string) => void;
17+
className?: string;
18+
popoverContentClassName?: string;
19+
chainId?: number;
20+
side?: "left" | "right" | "top" | "bottom";
21+
disableChainId?: boolean;
22+
align?: "center" | "start" | "end";
23+
placeholder?: string;
24+
client: ThirdwebClient;
25+
disabled?: boolean;
26+
enabled?: boolean;
27+
}) {
28+
const { tokens, isFetching } = useTokensData({
29+
clientId: props.client.clientId,
30+
chainId: props.chainId,
31+
enabled: props.enabled,
32+
});
33+
34+
const options = useMemo(() => {
35+
return tokens.allTokens.map((token) => {
36+
return {
37+
label: token.symbol,
38+
value: `${token.chainId}:${token.address}`,
39+
};
40+
});
41+
}, [tokens.allTokens]);
42+
43+
const searchFn = useCallback(
44+
(option: Option, searchValue: string) => {
45+
const token = tokens.addressChainToToken.get(option.value);
46+
if (!token) {
47+
return false;
48+
}
49+
50+
if (Number.isInteger(Number.parseInt(searchValue))) {
51+
return String(token.chainId).startsWith(searchValue);
52+
}
53+
return (
54+
token.name.toLowerCase().includes(searchValue.toLowerCase()) ||
55+
token.symbol.toLowerCase().includes(searchValue.toLowerCase()) ||
56+
token.address.toLowerCase().includes(searchValue.toLowerCase())
57+
);
58+
},
59+
[tokens],
60+
);
61+
62+
const renderOption = useCallback(
63+
(option: Option) => {
64+
const token = tokens.addressChainToToken.get(option.value);
65+
if (!token) {
66+
return option.label;
67+
}
68+
const resolvedSrc = token.iconUri
69+
? replaceIpfsUrl(token.iconUri, props.client)
70+
: fallbackChainIcon;
71+
72+
return (
73+
<div className="flex items-center justify-between gap-4">
74+
<span className="flex grow gap-2 truncate text-left">
75+
<Img
76+
// render different image element if src changes to avoid showing old image while loading new one
77+
key={resolvedSrc}
78+
className={cn("size-5 rounded-full object-contain")}
79+
src={resolvedSrc}
80+
loading={"lazy"}
81+
alt=""
82+
// eslint-disable-next-line @next/next/no-img-element
83+
fallback={<img src={fallbackChainIcon} alt="" />}
84+
skeleton={
85+
<div className="animate-pulse rounded-full bg-border" />
86+
}
87+
/>
88+
{token.symbol}
89+
</span>
90+
91+
{!props.disableChainId && (
92+
<Badge variant="outline" className="gap-2 max-sm:hidden">
93+
<span className="text-muted-foreground">Address</span>
94+
{shortenAddress(token.address, 4)}
95+
</Badge>
96+
)}
97+
</div>
98+
);
99+
},
100+
[tokens, props.disableChainId, props.client],
101+
);
102+
103+
return (
104+
<SelectWithSearch
105+
searchPlaceholder="Search by name or symbol"
106+
value={props.tokenAddress}
107+
options={options}
108+
onValueChange={(tokenAddress) => {
109+
props.onChange(tokenAddress);
110+
}}
111+
closeOnSelect={true}
112+
showCheck={false}
113+
placeholder={
114+
isFetching ? "Loading Tokens..." : props.placeholder || "Select Token"
115+
}
116+
overrideSearchFn={searchFn}
117+
renderOption={renderOption}
118+
className={props.className}
119+
popoverContentClassName={props.popoverContentClassName}
120+
disabled={isFetching || props.disabled}
121+
side={props.side}
122+
align={props.align}
123+
/>
124+
);
125+
}

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ interface SelectWithSearchProps
3434
side?: "left" | "right" | "top" | "bottom";
3535
align?: "center" | "start" | "end";
3636
closeOnSelect?: boolean;
37+
showCheck?: boolean;
3738
}
3839

3940
export const SelectWithSearch = React.forwardRef<
@@ -52,6 +53,7 @@ export const SelectWithSearch = React.forwardRef<
5253
popoverContentClassName,
5354
searchPlaceholder,
5455
closeOnSelect,
56+
showCheck = true,
5557
...props
5658
},
5759
ref,
@@ -126,7 +128,7 @@ export const SelectWithSearch = React.forwardRef<
126128
<span
127129
className={cn(
128130
"truncate text-muted-foreground text-sm",
129-
selectedOption && "text-foreground",
131+
selectedOption && "w-full text-foreground",
130132
)}
131133
>
132134
{renderOption && selectedOption
@@ -193,9 +195,11 @@ export const SelectWithSearch = React.forwardRef<
193195
i === optionsToShow.length - 1 ? lastItemRef : undefined
194196
}
195197
>
196-
<div className="flex size-4 items-center justify-center">
197-
{isSelected && <CheckIcon className="size-4" />}
198-
</div>
198+
{showCheck && (
199+
<div className="flex size-4 items-center justify-center">
200+
{isSelected && <CheckIcon className="size-4" />}
201+
</div>
202+
)}
199203

200204
<div className="min-w-0 grow">
201205
{renderOption ? renderOption(option) : option.label}

0 commit comments

Comments
 (0)