Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions apps/dashboard/src/@/api/universal-bridge/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const UB_BASE_URL = process.env.NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST;
42 changes: 42 additions & 0 deletions apps/dashboard/src/@/api/universal-bridge/tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"use server";
import { getAuthToken } from "app/(app)/api/lib/getAuthToken";
import { UB_BASE_URL } from "./constants";

export type TokenMetadata = {
name: string;
symbol: string;
address: string;
decimals: number;
chainId: number;
iconUri?: string;
};

export async function getUniversalBrigeTokens(props: {
clientId?: string;
chainId?: number;
}) {
const authToken = await getAuthToken();
const url = new URL(`${UB_BASE_URL}/v1/tokens`);

if (props.chainId) {
url.searchParams.append("chainId", String(props.chainId));
}
url.searchParams.append("limit", "1000");

const res = await fetch(url.toString(), {
method: "GET",
headers: {
"Content-Type": "application/json",
"x-client-id-override": props.clientId,
Authorization: `Bearer ${authToken}`,
} as Record<string, string>,
});

if (!res.ok) {
const text = await res.text();
throw new Error(text);
}

const json = await res.json();
return json.data as Array<TokenMetadata>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ export function SingleNetworkSelector(props: {
<SelectWithSearch
searchPlaceholder="Search by Name or Chain ID"
value={String(props.chainId)}
showCheck={false}
options={options}
onValueChange={(chainId) => {
props.onChange(Number(chainId));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import {
BadgeContainer,
storybookThirdwebClient,
} from "../../../stories/utils";
import { TokenSelector } from "./TokenSelector";

const meta = {
title: "blocks/Cards/TokenSelector",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Variants: Story = {
args: {},
};

function Story() {
return (
<div className="container flex max-w-6xl flex-col gap-8 py-10">
<Variant label="No Chains selected by default" />
</div>
);
}

function Variant(props: {
label: string;
selectedChainId?: number;
}) {
const [tokenAddress, setTokenAddress] = useState<string>("");
return (
<BadgeContainer label={props.label}>
<TokenSelector
tokenAddress={tokenAddress}
chainId={props.selectedChainId}
client={storybookThirdwebClient}
onChange={setTokenAddress}
/>
</BadgeContainer>
);
}
122 changes: 122 additions & 0 deletions apps/dashboard/src/@/components/blocks/TokenSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { useCallback, useMemo } from "react";
import type { ThirdwebClient } from "thirdweb";
import { useTokensData } from "../../../hooks/tokens/tokens";
import { replaceIpfsUrl } from "../../../lib/sdk";
import { fallbackChainIcon } from "../../../utils/chain-icons";
import { cn } from "../../lib/utils";
import { Badge } from "../ui/badge";
import { Img } from "./Img";
import { SelectWithSearch } from "./select-with-search";

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

export function TokenSelector(props: {
tokenAddress: string | undefined;
onChange: (tokenAddress: string) => void;
className?: string;
popoverContentClassName?: string;
chainId?: number;
side?: "left" | "right" | "top" | "bottom";
disableChainId?: boolean;
align?: "center" | "start" | "end";
placeholder?: string;
client: ThirdwebClient;
disabled?: boolean;
}) {
const { tokens, isLoading } = useTokensData({
clientId: props.client.clientId,
chainId: props.chainId,
});

const options = useMemo(() => {
return tokens.allTokens.map((token) => {
return {
label: token.symbol,
value: `${token.chainId}:${token.address}`,
};
});
}, [tokens.allTokens]);

const searchFn = useCallback(
(option: Option, searchValue: string) => {
const token = tokens.addressChainToToken.get(option.value);
if (!token) {
return false;
}

if (Number.isInteger(Number.parseInt(searchValue))) {
return String(token.chainId).startsWith(searchValue);
}
return (
token.name.toLowerCase().includes(searchValue.toLowerCase()) ||
token.symbol.toLowerCase().includes(searchValue.toLowerCase()) ||
token.address.toLowerCase().includes(searchValue.toLowerCase())
);
},
[tokens],
);

const renderOption = useCallback(
(option: Option) => {
const token = tokens.addressChainToToken.get(option.value);
if (!token) {
return option.label;
}
const resolvedSrc = token.iconUri
? replaceIpfsUrl(token.iconUri, props.client)
: fallbackChainIcon;

return (
<div className="flex items-center justify-between gap-4">
<span className="flex grow gap-2 truncate text-left">
<Img
// render different image element if src changes to avoid showing old image while loading new one
key={resolvedSrc}
className={cn("size-5 rounded-full object-contain")}
src={resolvedSrc}
loading={"lazy"}
alt=""
// eslint-disable-next-line @next/next/no-img-element
fallback={<img src={fallbackChainIcon} alt="" />}
skeleton={
<div className="animate-pulse rounded-full bg-border" />
}
/>
{token.symbol}
</span>

{!props.disableChainId && (
<Badge variant="outline" className="gap-2 max-sm:hidden">
<span className="text-muted-foreground">Chain ID</span>
{token.chainId}
</Badge>
)}
</div>
);
},
[tokens, props.disableChainId, props.client],
);

return (
<SelectWithSearch
searchPlaceholder="Search by name or symbol"
value={props.tokenAddress}
options={options}
onValueChange={(tokenAddress) => {
props.onChange(tokenAddress);
}}
closeOnSelect={true}
showCheck={false}
placeholder={
isLoading ? "Loading Tokens..." : props.placeholder || "Select Token"
}
overrideSearchFn={searchFn}
renderOption={renderOption}
className={props.className}
popoverContentClassName={props.popoverContentClassName}
disabled={isLoading || props.disabled}
side={props.side}
align={props.align}
/>
);
}
10 changes: 7 additions & 3 deletions apps/dashboard/src/@/components/blocks/select-with-search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ interface SelectWithSearchProps
side?: "left" | "right" | "top" | "bottom";
align?: "center" | "start" | "end";
closeOnSelect?: boolean;
showCheck?: boolean;
}

export const SelectWithSearch = React.forwardRef<
Expand All @@ -52,6 +53,7 @@ export const SelectWithSearch = React.forwardRef<
popoverContentClassName,
searchPlaceholder,
closeOnSelect,
showCheck = true,
...props
},
ref,
Expand Down Expand Up @@ -193,9 +195,11 @@ export const SelectWithSearch = React.forwardRef<
i === optionsToShow.length - 1 ? lastItemRef : undefined
}
>
<div className="flex size-4 items-center justify-center">
{isSelected && <CheckIcon className="size-4" />}
</div>
{showCheck && (
<div className="flex size-4 items-center justify-center">
{isSelected && <CheckIcon className="size-4" />}
</div>
)}

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