Skip to content

Commit 832573a

Browse files
committed
feat: adds token selector
1 parent f3c06b7 commit 832573a

File tree

8 files changed

+363
-22
lines changed

8 files changed

+363
-22
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: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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 getUniversalBrigeTokens(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+
25+
console.log(url.toString());
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+
console.error(text);
38+
throw new Error(text);
39+
}
40+
41+
const json = await res.json();
42+
return json.data as Array<TokenMetadata>;
43+
}

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

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

Lines changed: 7 additions & 3 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,
@@ -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}

apps/dashboard/src/app/checkout/components/client/CheckoutLinkForm.client.tsx

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"use client";
22

33
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
4+
import { TokenSelector } from "@/components/blocks/TokenSelector";
45
import { Button } from "@/components/ui/button";
56
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
67
import { Input } from "@/components/ui/input";
8+
import { Label } from "@/components/ui/label";
79
import { useThirdwebClient } from "@/constants/thirdweb.client";
810
import { CreditCardIcon } from "lucide-react";
911
import { useState } from "react";
@@ -90,9 +92,9 @@ export function CheckoutLinkForm() {
9092
<CardContent>
9193
<form onSubmit={handleSubmit} className="space-y-6">
9294
<div className="space-y-2">
93-
<label htmlFor="network" className="font-medium text-sm">
95+
<Label htmlFor="network" className="font-medium text-sm">
9496
Network
95-
</label>
97+
</Label>
9698
<SingleNetworkSelector
9799
chainId={chainId}
98100
onChange={setChainId}
@@ -102,37 +104,36 @@ export function CheckoutLinkForm() {
102104
</div>
103105

104106
<div className="space-y-2">
105-
<label htmlFor="recipient" className="font-medium text-sm">
106-
Recipient Address
107-
</label>
108-
<Input
109-
id="recipient"
110-
value={recipientAddress}
111-
onChange={(e) => setRecipientAddress(e.target.value)}
112-
placeholder="0x..."
113-
required
107+
<Label htmlFor="token" className="font-medium text-sm">
108+
Token
109+
</Label>
110+
<TokenSelector
111+
tokenAddress={tokenAddress}
112+
chainId={chainId ?? undefined}
113+
onChange={setTokenAddress}
114114
className="w-full"
115+
client={client}
115116
/>
116117
</div>
117118

118119
<div className="space-y-2">
119-
<label htmlFor="token" className="font-medium text-sm">
120-
Token Address
121-
</label>
120+
<Label htmlFor="recipient" className="font-medium text-sm">
121+
Recipient Address
122+
</Label>
122123
<Input
123-
id="token"
124-
value={tokenAddress}
125-
onChange={(e) => setTokenAddress(e.target.value)}
124+
id="recipient"
125+
value={recipientAddress}
126+
onChange={(e) => setRecipientAddress(e.target.value)}
126127
placeholder="0x..."
127128
required
128129
className="w-full"
129130
/>
130131
</div>
131132

132133
<div className="space-y-2">
133-
<label htmlFor="amount" className="font-medium text-sm">
134+
<Label htmlFor="amount" className="font-medium text-sm">
134135
Amount
135-
</label>
136+
</Label>
136137
<Input
137138
id="amount"
138139
type="number"

0 commit comments

Comments
 (0)