Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c328aa13a6 | ||
|
|
a85ef0b222 | ||
|
|
51177fbe17 | ||
|
|
1338a0cf2c | ||
|
|
96ebf378ff | ||
|
|
b7732d3a75 | ||
|
|
6f88d38ad2 | ||
|
|
d53f8a25b4 | ||
|
|
dd693169bc | ||
|
|
93f913c614 | ||
|
|
02502159b6 | ||
|
|
e9225c3bad | ||
|
|
5967fb8afa | ||
|
|
fddb791f0a |
@@ -474,6 +474,11 @@ async function redirects() {
|
||||
destination: "/team/~/~/support",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/routes",
|
||||
destination: "/tokens",
|
||||
permanent: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export function BuyAndSwapEmbed(props: {
|
||||
}, [props.pageType]);
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-2xl border overflow-hidden flex flex-col">
|
||||
<div className="bg-card rounded-2xl border overflow-hidden flex flex-col relative z-10">
|
||||
<div className="flex gap-2.5 p-4 border-b border-dashed">
|
||||
<TabButton
|
||||
label="Swap"
|
||||
@@ -107,12 +107,13 @@ export function BuyAndSwapEmbed(props: {
|
||||
theme={themeObj}
|
||||
className="!rounded-2xl !border-none !w-full"
|
||||
prefill={{
|
||||
sellToken: {
|
||||
// buy this token by default
|
||||
buyToken: {
|
||||
chainId: props.chain.id,
|
||||
tokenAddress: props.tokenAddress,
|
||||
},
|
||||
// only set `buyToken` as "Native token" if `sellToken` is not a "native token" already
|
||||
buyToken: props.tokenAddress
|
||||
// sell the native token by default (but if buytoken is a native token, don't set)
|
||||
sellToken: props.tokenAddress
|
||||
? {
|
||||
chainId: props.chain.id,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Bridge, ThirdwebClient } from "thirdweb";
|
||||
import { MultiSelect } from "@/components/blocks/multi-select";
|
||||
import { SelectWithSearch } from "@/components/blocks/select-with-search";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -296,3 +296,89 @@ export function SingleNetworkSelector(props: {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type BridgeNetworkSelectorProps = {
|
||||
chainId: number | undefined;
|
||||
onChange: (chainId: number) => void;
|
||||
className?: string;
|
||||
popoverContentClassName?: string;
|
||||
side?: "left" | "right" | "top" | "bottom";
|
||||
align?: "center" | "start" | "end";
|
||||
placeholder?: string;
|
||||
client: ThirdwebClient;
|
||||
chains: Bridge.chains.Result;
|
||||
};
|
||||
|
||||
export function BridgeNetworkSelector(props: BridgeNetworkSelectorProps) {
|
||||
const options = useMemo(() => {
|
||||
return props.chains.map((chain) => {
|
||||
return {
|
||||
label: cleanChainName(chain.name),
|
||||
value: String(chain.chainId),
|
||||
};
|
||||
});
|
||||
}, [props.chains]);
|
||||
|
||||
const searchFn = useCallback(
|
||||
(option: Option, searchValue: string) => {
|
||||
const chain = props.chains.find(
|
||||
(chain) => chain.chainId === Number(option.value),
|
||||
);
|
||||
if (!chain) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Number.isInteger(Number.parseInt(searchValue))) {
|
||||
return String(chain.chainId).startsWith(searchValue);
|
||||
}
|
||||
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
|
||||
},
|
||||
[props.chains],
|
||||
);
|
||||
|
||||
const renderOption = useCallback(
|
||||
(option: Option) => {
|
||||
const chain = props.chains.find(
|
||||
(chain) => chain.chainId === Number(option.value),
|
||||
);
|
||||
if (!chain) {
|
||||
return option.label;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="flex grow gap-2 truncate text-left">
|
||||
<ChainIconClient
|
||||
className="size-5"
|
||||
client={props.client}
|
||||
src={chain.icon}
|
||||
loading="lazy"
|
||||
/>
|
||||
{cleanChainName(chain.name)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[props.chains, props.client],
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectWithSearch
|
||||
align={props.align}
|
||||
className={props.className}
|
||||
closeOnSelect={true}
|
||||
onValueChange={(chainId) => {
|
||||
props.onChange(Number(chainId));
|
||||
}}
|
||||
options={options}
|
||||
overrideSearchFn={searchFn}
|
||||
placeholder={props.placeholder || "Select Chain"}
|
||||
popoverContentClassName={props.popoverContentClassName}
|
||||
renderOption={renderOption}
|
||||
searchPlaceholder="Search by Name or Chain ID"
|
||||
showCheck={false}
|
||||
side={props.side}
|
||||
value={props.chainId?.toString()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { MessageCircleIcon, XIcon } from "lucide-react";
|
||||
import { CircleQuestionMarkIcon, XIcon } from "lucide-react";
|
||||
import { useSelectedLayoutSegments } from "next/navigation";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
@@ -16,7 +16,6 @@ const client = createThirdwebClient({
|
||||
});
|
||||
|
||||
export function CustomChatButton(props: {
|
||||
label: string;
|
||||
examplePrompts: string[];
|
||||
authToken: string;
|
||||
team: Team;
|
||||
@@ -39,21 +38,25 @@ export function CustomChatButton(props: {
|
||||
<>
|
||||
{/* Inline Button (not floating) */}
|
||||
<Button
|
||||
className="gap-2 rounded-full shadow-lg"
|
||||
className={cn(
|
||||
"gap-2 rounded-full shadow-lg fixed right-6 bottom-6 z-50 bg-card",
|
||||
isOpen && "opacity-0",
|
||||
)}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIsOpen(true);
|
||||
setHasBeenOpened(true);
|
||||
}}
|
||||
variant="default"
|
||||
variant="outline"
|
||||
>
|
||||
<MessageCircleIcon className="size-4" />
|
||||
{props.label}
|
||||
<CircleQuestionMarkIcon className="size-3.5 text-muted-foreground" />
|
||||
Need help?
|
||||
</Button>
|
||||
|
||||
{/* Popup/Modal */}
|
||||
<div
|
||||
className={cn(
|
||||
"slide-in-from-bottom-20 zoom-in-95 fade-in-0 fixed bottom-0 left-0 z-50 flex h-[80vh] w-[100vw] animate-in flex-col overflow-hidden rounded-t-2xl border bg-background shadow-2xl duration-200 lg:right-6 lg:bottom-6 lg:left-auto lg:h-[80vh] lg:max-w-xl lg:rounded-xl",
|
||||
"slide-in-from-bottom-20 zoom-in-95 fade-in-0 fixed bottom-0 left-0 z-50 flex h-[80vh] w-[100vw] animate-in flex-col overflow-hidden rounded-t-2xl border bg-background shadow-2xl duration-200 lg:right-6 lg:bottom-6 lg:left-auto lg:h-[80vh] lg:max-w-xl lg:rounded-2xl",
|
||||
!isOpen && "hidden",
|
||||
)}
|
||||
ref={ref}
|
||||
@@ -61,8 +64,7 @@ export function CustomChatButton(props: {
|
||||
{/* Header with close button */}
|
||||
<div className="flex items-center justify-between border-b px-4 py-4">
|
||||
<div className="flex items-center gap-2 font-semibold text-lg">
|
||||
<MessageCircleIcon className="size-5 text-muted-foreground" />
|
||||
{props.label}
|
||||
Need help?
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Close chat"
|
||||
|
||||
@@ -92,23 +92,7 @@ if (REDIS_URL) {
|
||||
);
|
||||
}
|
||||
|
||||
// DO NOT TAINT THESE VALUES (for now)
|
||||
export const GROWTH_PLAN_SKU = process.env.GROWTH_PLAN_SKU || "";
|
||||
|
||||
if (GROWTH_PLAN_SKU) {
|
||||
experimental_taintUniqueValue(
|
||||
"Do not pass GROWTH_PLAN_SKU to the client",
|
||||
process,
|
||||
GROWTH_PLAN_SKU,
|
||||
);
|
||||
}
|
||||
|
||||
export const PAYMENT_METHOD_CONFIGURATION =
|
||||
process.env.PAYMENT_METHOD_CONFIGURATION || "";
|
||||
|
||||
if (PAYMENT_METHOD_CONFIGURATION) {
|
||||
experimental_taintUniqueValue(
|
||||
"Do not pass PAYMENT_METHOD_CONFIGURATION to the client",
|
||||
process,
|
||||
PAYMENT_METHOD_CONFIGURATION,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,9 +31,9 @@ export function getSDKTheme(theme: "light" | "dark"): Theme {
|
||||
selectedTextBg: "hsl(var(--inverted))",
|
||||
selectedTextColor: "hsl(var(--inverted-foreground))",
|
||||
separatorLine: "hsl(var(--border))",
|
||||
skeletonBg: "hsl(var(--muted))",
|
||||
skeletonBg: "hsl(var(--secondary-foreground)/15%)",
|
||||
success: "hsl(var(--success-text))",
|
||||
tertiaryBg: "hsl(var(--muted)/50%)",
|
||||
tertiaryBg: "hsl(var(--muted)/30%)",
|
||||
tooltipBg: "hsl(var(--popover))",
|
||||
tooltipText: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { TeamHeader } from "../../team/components/TeamHeader/team-header";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<div className="border-border border-b bg-card">
|
||||
<TeamHeader />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 536 KiB |
-37
@@ -1,37 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
|
||||
type ChainlistPaginationProps = {
|
||||
totalPages: number;
|
||||
activePage: number;
|
||||
};
|
||||
|
||||
export const ChainlistPagination: React.FC<ChainlistPaginationProps> = ({
|
||||
activePage,
|
||||
totalPages,
|
||||
}) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const createPageURL = useCallback(
|
||||
(pageNumber: number) => {
|
||||
const params = new URLSearchParams(searchParams || undefined);
|
||||
params.set("page", pageNumber.toString());
|
||||
return `${pathname}?${params.toString()}`;
|
||||
},
|
||||
[pathname, searchParams],
|
||||
);
|
||||
|
||||
return (
|
||||
<PaginationButtons
|
||||
activePage={activePage}
|
||||
onPageClick={(page) => router.push(createPageURL(page))}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,74 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { SearchIcon, XCircleIcon } from "lucide-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
|
||||
function cleanUrl(url: string) {
|
||||
if (url.endsWith("?")) {
|
||||
return url.slice(0, -1);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export const SearchInput: React.FC = () => {
|
||||
const router = useDashboardRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
// reset the input if the query param is removed
|
||||
if (inputRef.current?.value && !searchParams?.get("query")) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleSearch = useDebouncedCallback((term: string) => {
|
||||
const params = new URLSearchParams(searchParams ?? undefined);
|
||||
if (term) {
|
||||
params.set("query", term);
|
||||
} else {
|
||||
params.delete("query");
|
||||
}
|
||||
// always delete the page number when searching
|
||||
params.delete("page");
|
||||
const url = cleanUrl(`${pathname}?${params.toString()}`);
|
||||
router.replace(url);
|
||||
}, 300);
|
||||
|
||||
return (
|
||||
<div className="group relative w-full">
|
||||
<SearchIcon className="-translate-y-1/2 absolute top-[50%] left-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-10 rounded-lg bg-card py-2 pl-9 lg:min-w-[300px]"
|
||||
defaultValue={searchParams?.get("query") || ""}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
placeholder="Search by token name or symbol"
|
||||
ref={inputRef}
|
||||
/>
|
||||
{searchParams?.has("query") && (
|
||||
<Button
|
||||
className="-translate-y-1/2 absolute top-[50%] right-0 bg-background text-muted-foreground opacity-0 transition duration-300 ease-in-out group-hover:opacity-100"
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams(searchParams ?? undefined);
|
||||
params.delete("query");
|
||||
params.delete("page");
|
||||
const url = cleanUrl(`${pathname}?${params.toString()}`);
|
||||
router.replace(url);
|
||||
}}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
>
|
||||
<XCircleIcon className="size-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDownLeftIcon, ArrowUpRightIcon } from "lucide-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
|
||||
type QueryTypeProps = {
|
||||
activeType: "origin" | "destination";
|
||||
};
|
||||
|
||||
export const QueryType: React.FC<QueryTypeProps> = ({ activeType }) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const createPageURL = useCallback(
|
||||
(type: "origin" | "destination") => {
|
||||
const params = new URLSearchParams(searchParams || undefined);
|
||||
params.set("type", type);
|
||||
return `${pathname}?${params.toString()}`;
|
||||
},
|
||||
[pathname, searchParams],
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-row">
|
||||
<ToolTipLabel contentClassName="w-full" label="Origin">
|
||||
<Button
|
||||
className="rounded-r-none"
|
||||
onClick={() => {
|
||||
router.replace(createPageURL("origin"));
|
||||
}}
|
||||
size="icon"
|
||||
variant={activeType === "origin" ? "default" : "outline"}
|
||||
>
|
||||
<ArrowUpRightIcon strokeWidth={1} />
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
<ToolTipLabel contentClassName="w-full" label="Destination">
|
||||
<Button
|
||||
className="rounded-l-none"
|
||||
onClick={() => {
|
||||
router.replace(createPageURL("destination"));
|
||||
}}
|
||||
size="icon"
|
||||
variant={activeType === "destination" ? "default" : "outline"}
|
||||
>
|
||||
<ArrowDownLeftIcon strokeWidth={1} />
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Grid2X2Icon, ListIcon } from "lucide-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
|
||||
type RouteListViewProps = {
|
||||
activeView: "grid" | "table";
|
||||
};
|
||||
|
||||
export const RouteListView: React.FC<RouteListViewProps> = ({ activeView }) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useDashboardRouter();
|
||||
|
||||
const createPageURL = useCallback(
|
||||
(view: "grid" | "table") => {
|
||||
const params = new URLSearchParams(searchParams || undefined);
|
||||
params.set("view", view);
|
||||
return `${pathname}?${params.toString()}`;
|
||||
},
|
||||
[pathname, searchParams],
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
className="rounded-r-none"
|
||||
onClick={() => {
|
||||
router.replace(createPageURL("table"));
|
||||
}}
|
||||
size="icon"
|
||||
variant={activeView === "table" ? "default" : "outline"}
|
||||
>
|
||||
<ListIcon strokeWidth={1} />
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-l-none"
|
||||
onClick={() => {
|
||||
router.replace(createPageURL("grid"));
|
||||
}}
|
||||
size="icon"
|
||||
variant={activeView === "grid" ? "default" : "outline"}
|
||||
>
|
||||
<Grid2X2Icon strokeWidth={1} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { defineChain, getAddress, NATIVE_TOKEN_ADDRESS } from "thirdweb";
|
||||
import { type ChainMetadata, getChainMetadata } from "thirdweb/chains";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
|
||||
type RouteListCardProps = {
|
||||
originChainId: number;
|
||||
originTokenAddress: string;
|
||||
originTokenIconUri?: string | null;
|
||||
originTokenSymbol: string;
|
||||
originTokenName: string;
|
||||
destinationChainId: number;
|
||||
destinationTokenAddress: string;
|
||||
destinationTokenIconUri?: string | null;
|
||||
destinationTokenSymbol: string;
|
||||
destinationTokenName: string;
|
||||
};
|
||||
|
||||
export async function RouteListCard({
|
||||
originChainId,
|
||||
originTokenAddress,
|
||||
originTokenIconUri,
|
||||
originTokenName,
|
||||
destinationChainId,
|
||||
destinationTokenAddress,
|
||||
destinationTokenIconUri,
|
||||
destinationTokenName,
|
||||
}: RouteListCardProps) {
|
||||
const [
|
||||
originChain,
|
||||
destinationChain,
|
||||
resolvedOriginTokenIconUri,
|
||||
resolvedDestinationTokenIconUri,
|
||||
] = await Promise.all([
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getChainMetadata(defineChain(originChainId)),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getChainMetadata(defineChain(destinationChainId)),
|
||||
originTokenIconUri
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client: serverThirdwebClient,
|
||||
uri: originTokenIconUri,
|
||||
})
|
||||
: undefined,
|
||||
destinationTokenIconUri
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client: serverThirdwebClient,
|
||||
uri: destinationTokenIconUri,
|
||||
})
|
||||
: undefined,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<Card className="h-full w-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between p-4">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{resolvedOriginTokenIconUri ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
alt={originTokenAddress}
|
||||
className="size-8 rounded-full border border-muted-foreground"
|
||||
src={resolvedOriginTokenIconUri}
|
||||
/>
|
||||
) : (
|
||||
<div className="size-8 rounded-full bg-muted-foreground" />
|
||||
)}
|
||||
{resolvedDestinationTokenIconUri ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
alt={destinationTokenAddress}
|
||||
className="-translate-x-4 size-8 rounded-full border border-muted-foreground ring-2 ring-card"
|
||||
src={resolvedDestinationTokenIconUri}
|
||||
/>
|
||||
) : (
|
||||
<div className="-translate-x-4 size-8 rounded-full bg-muted-foreground ring-2 ring-card" />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-4 pt-0 pb-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex justify-between gap-2">
|
||||
<TokenName
|
||||
chainMetadata={originChain}
|
||||
tokenAddress={originTokenAddress}
|
||||
tokenName={originTokenName}
|
||||
/>
|
||||
<div className="text-right text-base text-muted-foreground">
|
||||
{originChain.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2">
|
||||
<TokenName
|
||||
chainMetadata={destinationChain}
|
||||
tokenAddress={destinationTokenAddress}
|
||||
tokenName={destinationTokenName}
|
||||
/>
|
||||
<div className="text-right text-base text-muted-foreground">
|
||||
{destinationChain.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
|
||||
|
||||
function TokenName(props: {
|
||||
tokenAddress: string;
|
||||
tokenName: string;
|
||||
chainMetadata: ChainMetadata;
|
||||
}) {
|
||||
const isERC20 = getAddress(props.tokenAddress) !== nativeTokenAddress;
|
||||
|
||||
if (isERC20) {
|
||||
return (
|
||||
<Link
|
||||
className="flex items-center gap-1.5 font-normal text-base group/link hover:underline underline-offset-4 decoration-muted-foreground/50 decoration-dotted"
|
||||
href={`https://thirdweb.com/${props.chainMetadata.slug}/${props.tokenAddress}`}
|
||||
target="_blank"
|
||||
>
|
||||
{props.tokenName}
|
||||
<ExternalLinkIcon className="size-3.5 text-muted-foreground group-hover/link:text-foreground" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-left font-normal text-base">
|
||||
{props.chainMetadata.nativeCurrency.name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-136
@@ -1,136 +0,0 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getAddress, NATIVE_TOKEN_ADDRESS } from "thirdweb";
|
||||
import {
|
||||
type ChainMetadata,
|
||||
defineChain,
|
||||
getChainMetadata,
|
||||
} from "thirdweb/chains";
|
||||
import { shortenAddress } from "thirdweb/utils";
|
||||
import { Img } from "@/components/blocks/Img";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
|
||||
type RouteListRowProps = {
|
||||
originChainId: number;
|
||||
originTokenAddress: string;
|
||||
originTokenIconUri?: string | null;
|
||||
originTokenSymbol?: string;
|
||||
originTokenName?: string;
|
||||
destinationChainId: number;
|
||||
destinationTokenAddress: string;
|
||||
destinationTokenIconUri?: string | null;
|
||||
destinationTokenSymbol?: string;
|
||||
destinationTokenName?: string;
|
||||
};
|
||||
|
||||
export async function RouteListRow({
|
||||
originChainId,
|
||||
originTokenAddress,
|
||||
originTokenIconUri,
|
||||
originTokenSymbol,
|
||||
destinationChainId,
|
||||
destinationTokenAddress,
|
||||
destinationTokenIconUri,
|
||||
destinationTokenSymbol,
|
||||
}: RouteListRowProps) {
|
||||
const [
|
||||
originChain,
|
||||
destinationChain,
|
||||
resolvedOriginTokenIconUri,
|
||||
resolvedDestinationTokenIconUri,
|
||||
] = await Promise.all([
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getChainMetadata(defineChain(originChainId)),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
getChainMetadata(defineChain(destinationChainId)),
|
||||
originTokenIconUri
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client: serverThirdwebClient,
|
||||
uri: originTokenIconUri,
|
||||
})
|
||||
: undefined,
|
||||
destinationTokenIconUri
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client: serverThirdwebClient,
|
||||
uri: destinationTokenIconUri,
|
||||
})
|
||||
: undefined,
|
||||
]);
|
||||
|
||||
return (
|
||||
<TableRow className="group">
|
||||
<TableCell>
|
||||
<TokenInfo
|
||||
chainMetadata={originChain}
|
||||
tokenAddress={originTokenAddress}
|
||||
tokenIconUri={resolvedOriginTokenIconUri}
|
||||
tokenSymbol={originTokenSymbol}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground/90">
|
||||
<span className="font-medium">{originChain.name}</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<TokenInfo
|
||||
chainMetadata={destinationChain}
|
||||
tokenAddress={destinationTokenAddress}
|
||||
tokenIconUri={resolvedDestinationTokenIconUri}
|
||||
tokenSymbol={destinationTokenSymbol}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground/90">
|
||||
<span className="font-medium">{destinationChain.name}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
const nativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
|
||||
|
||||
function TokenInfo(props: {
|
||||
tokenAddress: string;
|
||||
tokenSymbol: string | undefined;
|
||||
chainMetadata: ChainMetadata;
|
||||
tokenIconUri: string | undefined;
|
||||
}) {
|
||||
const isERC20 = getAddress(props.tokenAddress) !== nativeTokenAddress;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{props.tokenIconUri ? (
|
||||
<Img
|
||||
alt={props.tokenAddress}
|
||||
className="size-7 rounded-full border border-border/50"
|
||||
src={props.tokenIconUri}
|
||||
/>
|
||||
) : (
|
||||
<div className="size-7 rounded-full bg-muted-foreground/20" />
|
||||
)}
|
||||
{isERC20 ? (
|
||||
<Button
|
||||
asChild
|
||||
className="h-auto w-auto py-1 px-1.5 gap-2"
|
||||
variant="ghost"
|
||||
>
|
||||
<Link
|
||||
href={`https://thirdweb.com/${props.chainMetadata.slug}/${props.tokenAddress}`}
|
||||
target="_blank"
|
||||
>
|
||||
{props.tokenSymbol || shortenAddress(props.tokenAddress)}
|
||||
<ExternalLinkIcon className="size-3.5 text-muted-foreground" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<span className="font-medium text-sm px-1.5 py-1">
|
||||
{props.chainMetadata.nativeCurrency.symbol}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-176
@@ -1,176 +0,0 @@
|
||||
import type { Address } from "thirdweb";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { getRoutes } from "../../../utils";
|
||||
import { ChainlistPagination } from "../client/pagination";
|
||||
import { RouteListCard } from "./routelist-card";
|
||||
import { RouteListRow } from "./routelist-row";
|
||||
|
||||
export type SearchParams = Partial<{
|
||||
includeDeprecated: boolean;
|
||||
query: string;
|
||||
page: number;
|
||||
type: "origin" | "destination";
|
||||
// maybe later we'll have a page size param?
|
||||
// pageSize: number;
|
||||
view: "table" | "grid";
|
||||
}>;
|
||||
|
||||
// 120 is divisible by 2, 3, and 4 so card layout looks nice
|
||||
const DEFAULT_PAGE_SIZE = 120;
|
||||
|
||||
async function getRoutesToRender(params: SearchParams) {
|
||||
const filters: Partial<{
|
||||
originQuery?: string;
|
||||
destinationQuery?: string;
|
||||
originChainId?: number;
|
||||
originTokenAddress?: Address;
|
||||
destinationChainId?: number;
|
||||
destinationTokenAddress?: Address;
|
||||
}> = {};
|
||||
|
||||
if (params.type === "origin" || typeof params.type === "undefined") {
|
||||
if (params.query?.startsWith("0x")) {
|
||||
filters.originTokenAddress = params.query as Address;
|
||||
} else if (Number.isInteger(Number(params.query))) {
|
||||
filters.originChainId = Number(params.query);
|
||||
} else if (params.query) {
|
||||
filters.originQuery = params.query;
|
||||
}
|
||||
} else if (params.type === "destination") {
|
||||
if (params.query?.startsWith("0x")) {
|
||||
filters.destinationTokenAddress = params.query as Address;
|
||||
} else if (Number.isInteger(Number(params.query))) {
|
||||
filters.destinationChainId = Number(params.query);
|
||||
} else if (params.query) {
|
||||
filters.destinationQuery = params.query;
|
||||
}
|
||||
}
|
||||
const routes = await getRoutes({
|
||||
destinationQuery: filters.destinationQuery,
|
||||
limit: DEFAULT_PAGE_SIZE,
|
||||
offset: DEFAULT_PAGE_SIZE * ((params.page || 1) - 1),
|
||||
originQuery: filters.originQuery,
|
||||
});
|
||||
|
||||
return {
|
||||
filteredCount: routes.meta.filteredCount,
|
||||
routesToRender: routes.data,
|
||||
totalCount: routes.meta.totalCount,
|
||||
};
|
||||
}
|
||||
|
||||
export async function RoutesData(props: {
|
||||
searchParams: SearchParams;
|
||||
activeView: "table" | "grid";
|
||||
isLoggedIn: boolean;
|
||||
}) {
|
||||
const { routesToRender, totalCount, filteredCount } = await getRoutesToRender(
|
||||
props.searchParams,
|
||||
);
|
||||
|
||||
const totalPages = Math.ceil(filteredCount / DEFAULT_PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<>
|
||||
<main>
|
||||
{/* empty state */}
|
||||
{routesToRender.length === 0 ? (
|
||||
<div className="flex h-[300px] items-center justify-center rounded-lg border p-8 lg:h-[500px]">
|
||||
<p className="text-2xl">No Results found</p>
|
||||
</div>
|
||||
) : props.activeView === "table" ? (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Origin Token</TableHead>
|
||||
<TableHead>Origin Chain</TableHead>
|
||||
<TableHead>Destination Token</TableHead>
|
||||
<TableHead>Destination Chain</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{routesToRender.map((route) => (
|
||||
<RouteListRow
|
||||
destinationChainId={route.destinationToken.chainId}
|
||||
destinationTokenAddress={route.destinationToken.address}
|
||||
destinationTokenIconUri={route.destinationToken.iconUri}
|
||||
destinationTokenName={route.destinationToken.name}
|
||||
destinationTokenSymbol={route.destinationToken.symbol}
|
||||
key={`${route.originToken.chainId}:${route.originToken.address}-${route.destinationToken.chainId}:${route.destinationToken.address}`}
|
||||
originChainId={route.originToken.chainId}
|
||||
originTokenAddress={route.originToken.address}
|
||||
originTokenIconUri={route.originToken.iconUri}
|
||||
originTokenName={route.originToken.name}
|
||||
originTokenSymbol={route.originToken.symbol}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<ul className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{routesToRender.map((route) => (
|
||||
<li
|
||||
className="h-full"
|
||||
key={`${route.originToken.chainId}:${route.originToken.address}-${route.destinationToken.chainId}:${route.destinationToken.address}`}
|
||||
>
|
||||
<RouteListCard
|
||||
destinationChainId={route.destinationToken.chainId}
|
||||
destinationTokenAddress={route.destinationToken.address}
|
||||
destinationTokenIconUri={route.destinationToken.iconUri}
|
||||
destinationTokenName={route.destinationToken.name}
|
||||
destinationTokenSymbol={route.destinationToken.symbol}
|
||||
originChainId={route.originToken.chainId}
|
||||
originTokenAddress={route.originToken.address}
|
||||
originTokenIconUri={route.originToken.iconUri}
|
||||
originTokenName={route.originToken.name}
|
||||
originTokenSymbol={route.originToken.symbol}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
<div className="h-10" />
|
||||
{totalPages > 1 && (
|
||||
<ChainlistPagination
|
||||
activePage={Number(props.searchParams.page) || 1}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
)}
|
||||
<div className="h-4" />
|
||||
<p className="text-balance text-center text-muted-foreground text-sm">
|
||||
Showing{" "}
|
||||
<span className="text-accent-foreground">{routesToRender.length}</span>{" "}
|
||||
out of{" "}
|
||||
{filteredCount !== totalCount ? (
|
||||
<>
|
||||
<span className="text-accent-foreground">
|
||||
{filteredCount.toLocaleString()}
|
||||
</span>{" "}
|
||||
routes that match filters. (Total:{" "}
|
||||
<span className="text-accent-foreground">
|
||||
{totalCount.toLocaleString()}
|
||||
</span>
|
||||
)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-accent-foreground">
|
||||
{totalCount.toLocaleString()}
|
||||
</span>{" "}
|
||||
routes.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { ArrowUpRightIcon } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { SearchInput } from "./components/client/search";
|
||||
import { QueryType } from "./components/client/type";
|
||||
import { RouteListView } from "./components/client/view";
|
||||
import {
|
||||
RoutesData,
|
||||
type SearchParams,
|
||||
} from "./components/server/routes-table";
|
||||
|
||||
const title = "Payments Routes: Swap, Bridge, and Onramp";
|
||||
const description =
|
||||
"A list of token routes for swapping, bridging, and on-ramping between EVM chains with thirdweb.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
description,
|
||||
openGraph: {
|
||||
description,
|
||||
title,
|
||||
},
|
||||
title,
|
||||
};
|
||||
|
||||
export default async function RoutesPage(props: {
|
||||
searchParams: Promise<SearchParams>;
|
||||
}) {
|
||||
const authToken = await getAuthToken();
|
||||
const headersList = await headers();
|
||||
const viewportWithHint = Number(
|
||||
headersList.get("Sec-Ch-Viewport-Width") || 0,
|
||||
);
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const activeType = searchParams.type ?? "origin";
|
||||
|
||||
// default is driven by viewport hint
|
||||
const activeView = searchParams.view
|
||||
? searchParams.view
|
||||
: viewportWithHint > 1000
|
||||
? "table"
|
||||
: "grid";
|
||||
|
||||
return (
|
||||
<section className="container mx-auto flex h-full flex-col px-4 py-10">
|
||||
<header className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="font-semibold text-4xl tracking-tighter lg:text-5xl">
|
||||
Routes
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-row items-end gap-4 lg:flex-col">
|
||||
<div className="flex w-full flex-row items-center gap-4">
|
||||
<SearchInput />
|
||||
<QueryType activeType={activeType} />
|
||||
<RouteListView activeView={activeView} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="h-10" />
|
||||
<div className="relative overflow-hidden rounded-lg border-2 border-green-500/20 bg-gradient-to-br from-card/80 to-card/50 p-4 shadow-[inset_0_1px_2px_0_rgba(0,0,0,0.02)]">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-green-500/5 to-transparent" />
|
||||
<div className="relative flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="font-medium text-lg">
|
||||
Get Started with thirdweb Payments
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Simple, instant, and secure payments across any token and chain.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
className="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 font-medium text-sm text-white transition-all hover:bg-green-600/90 hover:shadow-sm"
|
||||
href="https://portal.thirdweb.com/payments"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn More
|
||||
<ArrowUpRightIcon className="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-10" />
|
||||
<RoutesData
|
||||
activeView={activeView}
|
||||
isLoggedIn={!!authToken}
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { Address } from "thirdweb";
|
||||
|
||||
export type Route = {
|
||||
originToken: {
|
||||
address: Address;
|
||||
chainId: number;
|
||||
iconUri?: string;
|
||||
name: string;
|
||||
symbol: string;
|
||||
};
|
||||
destinationToken: {
|
||||
address: Address;
|
||||
chainId: number;
|
||||
iconUri?: string;
|
||||
name: string;
|
||||
symbol: string;
|
||||
};
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST } from "@/constants/public-envs";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import type { Route } from "./types/route";
|
||||
|
||||
export async function getRoutes({
|
||||
limit,
|
||||
offset,
|
||||
originQuery,
|
||||
destinationQuery,
|
||||
}: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
originQuery?: string;
|
||||
destinationQuery?: string;
|
||||
} = {}) {
|
||||
const url = new URL(`${NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST}/v1/routes/search`);
|
||||
if (limit) {
|
||||
url.searchParams.set("limit", limit.toString());
|
||||
}
|
||||
if (offset) {
|
||||
url.searchParams.set("offset", offset.toString());
|
||||
}
|
||||
if (originQuery) {
|
||||
url.searchParams.set("originQuery", originQuery);
|
||||
}
|
||||
if (destinationQuery) {
|
||||
url.searchParams.set("destinationQuery", destinationQuery);
|
||||
}
|
||||
url.searchParams.set("sortBy", "popularity");
|
||||
const routesResponse = await fetch(url, {
|
||||
headers: {
|
||||
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
},
|
||||
next: { revalidate: 60 * 60 },
|
||||
});
|
||||
|
||||
if (!routesResponse.ok) {
|
||||
throw new Error("Failed to fetch routes");
|
||||
}
|
||||
const routes: {
|
||||
data: Route[];
|
||||
meta: { totalCount: number; filteredCount: number };
|
||||
} = await routesResponse.json();
|
||||
|
||||
return routes;
|
||||
}
|
||||
+3
@@ -28,6 +28,9 @@ export function PublicPageConnectButton(props: {
|
||||
connectButton={{
|
||||
className: props.connectButtonClassName,
|
||||
}}
|
||||
detailsButton={{
|
||||
className: props.connectButtonClassName,
|
||||
}}
|
||||
connectModal={{
|
||||
privacyPolicyUrl: "/privacy-policy",
|
||||
showThirdwebBranding: false,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import Link from "next/link";
|
||||
import { ToggleThemeButton } from "@/components/blocks/color-mode-toggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ThirdwebMiniLogo } from "../../../components/ThirdwebMiniLogo";
|
||||
import { PublicPageConnectButton } from "../../(chain)/[chain_id]/[contractAddress]/public-pages/_components/PublicPageConnectButton";
|
||||
|
||||
export function PageHeader(props: { containerClassName?: string }) {
|
||||
return (
|
||||
<div className="border-b">
|
||||
<header
|
||||
className={cn(
|
||||
"container flex max-w-7xl justify-between py-3",
|
||||
props.containerClassName,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link className="flex items-center gap-2" href="/team">
|
||||
<ThirdwebMiniLogo className="h-5" />
|
||||
<span className="hidden font-bold text-2xl tracking-tight lg:block">
|
||||
thirdweb
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-5">
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/bridge"
|
||||
target="_blank"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
<ToggleThemeButton />
|
||||
<PublicPageConnectButton connectButtonClassName="!rounded-full" />
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { cn } from "@workspace/ui/lib/utils";
|
||||
import { ActivityIcon, SearchIcon, TrendingUpIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Bridge } from "thirdweb";
|
||||
import { BridgeNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { TokensTable } from "./tokens-table";
|
||||
|
||||
const client = getClientThirdwebClient();
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
export function TokenPage(props: { chains: Bridge.chains.Result }) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [chainId, setChainId] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sortBy, setSortBy] = useState<"volume" | "market_cap">("volume");
|
||||
|
||||
const tokensQuery = useQuery({
|
||||
queryKey: [
|
||||
"tokens",
|
||||
{
|
||||
page,
|
||||
chainId,
|
||||
sortBy,
|
||||
search,
|
||||
},
|
||||
],
|
||||
queryFn: () => {
|
||||
return Bridge.tokens({
|
||||
client: client,
|
||||
chainId: chainId,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
sortBy: search ? undefined : sortBy,
|
||||
query: search ? search : undefined,
|
||||
});
|
||||
},
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="pb-20 pt-8">
|
||||
<div className="container max-w-7xl">
|
||||
<div className="mb-4 flex gap-4 lg:gap-3 flex-col lg:flex-row">
|
||||
<BridgeNetworkSelector
|
||||
client={client}
|
||||
chainId={chainId}
|
||||
onChange={setChainId}
|
||||
className="rounded-xl bg-card lg:w-fit min-w-[280px]"
|
||||
popoverContentClassName="!w-[300px] rounded-xl overflow-hidden"
|
||||
chains={props.chains}
|
||||
/>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<SortButton
|
||||
label="Popular"
|
||||
onClick={() => {
|
||||
setSortBy("market_cap");
|
||||
setSearch("");
|
||||
}}
|
||||
isSelected={sortBy === "market_cap" && !search}
|
||||
icon={ActivityIcon}
|
||||
/>
|
||||
<SortButton
|
||||
label="Trending"
|
||||
onClick={() => {
|
||||
setSortBy("volume");
|
||||
setSearch("");
|
||||
}}
|
||||
isSelected={sortBy === "volume" && !search}
|
||||
icon={TrendingUpIcon}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full lg:w-[420px] ml-auto">
|
||||
<SearchIcon className="size-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search Tokens"
|
||||
value={search}
|
||||
className="rounded-xl bg-card flex-1 pl-9"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TokensTable
|
||||
pageSize={pageSize}
|
||||
tokens={tokensQuery.data ?? []}
|
||||
isFetching={tokensQuery.isFetching}
|
||||
pagination={{
|
||||
onNext: () => setPage(page + 1),
|
||||
onPrevious: () => setPage(page - 1),
|
||||
nextDisabled: !!(
|
||||
tokensQuery.data && tokensQuery.data.length < pageSize
|
||||
),
|
||||
previousDisabled: page === 1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortButton(props: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
isSelected: boolean;
|
||||
icon: React.FC<{ className?: string }>;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={props.onClick}
|
||||
className={cn(
|
||||
"rounded-xl gap-2 bg-card",
|
||||
props.isSelected && "bg-accent border-active-border",
|
||||
)}
|
||||
>
|
||||
<props.icon className="size-3.5" />
|
||||
{props.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { type Bridge, NATIVE_TOKEN_ADDRESS } from "thirdweb";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
export function TokensTable(props: {
|
||||
tokens: Bridge.TokenWithPrices[];
|
||||
pageSize: number;
|
||||
isFetching: boolean;
|
||||
pagination: {
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
nextDisabled: boolean;
|
||||
previousDisabled: boolean;
|
||||
};
|
||||
}) {
|
||||
const { tokens, isFetching } = props;
|
||||
|
||||
return (
|
||||
<div className="border rounded-xl overflow-hidden">
|
||||
<TableContainer className="border-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Token</TableHead>
|
||||
<TableHead>Price</TableHead>
|
||||
<TableHead>Market cap</TableHead>
|
||||
<TableHead>Volume (24h)</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isFetching
|
||||
? new Array(props.pageSize).fill(0).map((_, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: ok
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-full" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-3 w-36" />
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="ml-auto h-4 w-16" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="ml-auto h-4 w-20" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="ml-auto h-4 w-20" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
: tokens.length > 0
|
||||
? tokens.map((token) => {
|
||||
const price = token.prices.USD;
|
||||
return (
|
||||
<TableRow
|
||||
key={`${token.chainId}-${token.address}`}
|
||||
className="hover:bg-accent/50"
|
||||
linkBox
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="size-10">
|
||||
<AvatarImage
|
||||
src={token.iconUri ?? undefined}
|
||||
alt={token.symbol}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{token.symbol?.slice(0, 2)?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="font-medium leading-none text-base">
|
||||
{token.symbol}
|
||||
</span>
|
||||
<Link
|
||||
href={
|
||||
token.address.toLowerCase() ===
|
||||
NATIVE_TOKEN_ADDRESS.toLowerCase()
|
||||
? `/${token.chainId}`
|
||||
: `/${token.chainId}/${token.address}`
|
||||
}
|
||||
prefetch={false}
|
||||
className="before:absolute before:inset-0"
|
||||
>
|
||||
<span className="text-sm text-muted-foreground leading-none">
|
||||
{token.name}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{price ? formatPrice(price) : "N/A"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{token.marketCapUsd
|
||||
? formatUsdCompact(token.marketCapUsd)
|
||||
: "N/A"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{token.volume24hUsd
|
||||
? formatUsdCompact(token.volume24hUsd)
|
||||
: "N/A"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{tokens.length === 0 && !isFetching && (
|
||||
<div className="flex items-center justify-center py-20 text-muted-foreground w-full text-sm ">
|
||||
No tokens found
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end p-4 gap-3 border-t bg-card">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 rounded-full"
|
||||
size="sm"
|
||||
onClick={() => props.pagination.onPrevious()}
|
||||
disabled={props.pagination.previousDisabled || isFetching}
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 rounded-full"
|
||||
size="sm"
|
||||
onClick={() => props.pagination.onNext()}
|
||||
disabled={props.pagination.nextDisabled || isFetching}
|
||||
>
|
||||
Next
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPrice(value: number): string {
|
||||
if (value < 100) {
|
||||
return smallValueUSDFormatter.format(value);
|
||||
}
|
||||
return largeValueUSDFormatter.format(value);
|
||||
}
|
||||
|
||||
const smallValueUSDFormatter = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 6,
|
||||
roundingMode: "halfEven",
|
||||
});
|
||||
|
||||
const largeValueUSDFormatter = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 2,
|
||||
roundingMode: "halfEven",
|
||||
});
|
||||
|
||||
const compactValueUSDFormatter = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 2,
|
||||
roundingMode: "halfEven",
|
||||
});
|
||||
|
||||
function formatUsdCompact(value: number): string {
|
||||
return compactValueUSDFormatter.format(value);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { BringToFrontIcon } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import Link from "next/link";
|
||||
import { Bridge } from "thirdweb";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { PageHeader } from "./components/header";
|
||||
import { TokenPage } from "./components/token-page";
|
||||
|
||||
const title = "Tokens | thirdweb";
|
||||
const description = "Discover and swap any tokens on any chain, instantly";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
description,
|
||||
openGraph: {
|
||||
description,
|
||||
title,
|
||||
},
|
||||
title,
|
||||
};
|
||||
|
||||
export default async function Page() {
|
||||
const chains = await getBridgeSupportedChains();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader />
|
||||
<div className="border-b py-10 ">
|
||||
<div className="container max-w-7xl">
|
||||
<div className="flex mb-4">
|
||||
<div className="rounded-full bg-card p-2.5 border">
|
||||
<BringToFrontIcon className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tighter mb-1">
|
||||
Discover and swap any tokens on any chain, instantly
|
||||
</h1>
|
||||
<Link
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
href="https://thirdweb.com/monetize/bridge"
|
||||
target="_blank"
|
||||
>
|
||||
Powered by thirdweb bridge
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<TokenPage chains={chains} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getBridgeSupportedChains = unstable_cache(
|
||||
async () => {
|
||||
const chains = await Bridge.chains({ client: serverThirdwebClient });
|
||||
return chains;
|
||||
},
|
||||
["bridge-supported-chains"],
|
||||
{
|
||||
revalidate: 60 * 60, // 1 hour
|
||||
},
|
||||
);
|
||||
@@ -103,15 +103,12 @@ export default async function TeamLayout(props: {
|
||||
>
|
||||
{props.children}
|
||||
</TeamSidebarLayout>
|
||||
<div className="fixed right-4 bottom-4 z-50">
|
||||
<CustomChatButton
|
||||
authToken={authToken}
|
||||
clientId={undefined}
|
||||
examplePrompts={siwaExamplePrompts}
|
||||
label="Get Help"
|
||||
team={team}
|
||||
/>
|
||||
</div>
|
||||
<CustomChatButton
|
||||
authToken={authToken}
|
||||
clientId={undefined}
|
||||
examplePrompts={siwaExamplePrompts}
|
||||
team={team}
|
||||
/>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
|
||||
@@ -77,15 +77,12 @@ export default async function ProjectLayout(props: {
|
||||
{props.children}
|
||||
</ProjectSidebarLayout>
|
||||
</div>
|
||||
<div className="fixed right-4 bottom-4 z-50">
|
||||
<CustomChatButton
|
||||
authToken={authToken}
|
||||
clientId={project.publishableKey}
|
||||
examplePrompts={siwaExamplePrompts}
|
||||
label="Ask AI Assistant"
|
||||
team={team}
|
||||
/>
|
||||
</div>
|
||||
<CustomChatButton
|
||||
authToken={authToken}
|
||||
clientId={project.publishableKey}
|
||||
examplePrompts={siwaExamplePrompts}
|
||||
team={team}
|
||||
/>
|
||||
<SaveLastUsedProject projectId={project.id} teamId={team.id} />
|
||||
</SidebarProvider>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ export function UniversalBridgeEmbed({
|
||||
const chain = useV5DashboardChain(chainId || 1);
|
||||
|
||||
return (
|
||||
<div className="lg:w-[400px]">
|
||||
<div className="w-full lg:w-[400px]">
|
||||
<BuyAndSwapEmbed
|
||||
client={bridgeAppThirdwebClient}
|
||||
chain={chain}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import Link from "next/link";
|
||||
import { ToggleThemeButton } from "@/components/blocks/color-mode-toggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PublicPageConnectButton } from "../../(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/_components/PublicPageConnectButton";
|
||||
import { ThirdwebMiniLogo } from "../../(app)/components/ThirdwebMiniLogo";
|
||||
|
||||
export function PageHeader(props: { containerClassName?: string }) {
|
||||
return (
|
||||
<div className="border-b border-dashed">
|
||||
<header
|
||||
className={cn(
|
||||
"container flex max-w-7xl justify-between py-3",
|
||||
props.containerClassName,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link className="flex items-center gap-2" href="/team">
|
||||
<ThirdwebMiniLogo className="h-5" />
|
||||
<span className="hidden font-bold text-2xl tracking-tight lg:block">
|
||||
thirdweb
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-5">
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/bridge"
|
||||
target="_blank"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
<ToggleThemeButton />
|
||||
<PublicPageConnectButton connectButtonClassName="!rounded-full" />
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export default function BridgeLayout({
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={cn(
|
||||
"h-screen w-screen bg-background font-sans antialiased",
|
||||
"min-h-dvh bg-background font-sans antialiased flex flex-col",
|
||||
fontSans.variable,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { ArrowUpRightIcon } from "lucide-react";
|
||||
import { cn } from "@workspace/ui/lib/utils";
|
||||
import type { Metadata } from "next";
|
||||
import type { Address } from "thirdweb";
|
||||
import { defineChain } from "thirdweb/chains";
|
||||
import { getContract } from "thirdweb/contract";
|
||||
import { getCurrencyMetadata } from "thirdweb/extensions/erc20";
|
||||
import { AppFooter } from "@/components/footers/app-footer";
|
||||
import { UniversalBridgeEmbed } from "./components/client/UniversalBridgeEmbed";
|
||||
import { PageHeader } from "./components/header";
|
||||
import { bridgeAppThirdwebClient } from "./constants";
|
||||
|
||||
const title = "thirdweb Payments: Swap, Bridge, and Onramp";
|
||||
@@ -49,8 +51,11 @@ export default async function BridgePage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto flex h-dvh w-full flex-col items-center justify-center overflow-hidden border py-10">
|
||||
<main className="container z-10 flex justify-center">
|
||||
<div className="grow flex flex-col relative overflow-hidden">
|
||||
<PageHeader />
|
||||
|
||||
<div className="flex grow flex-col items-center justify-center py-36 px-4 min-h-dvh relative overflow-hidden">
|
||||
<DotsBackgroundPattern />
|
||||
<UniversalBridgeEmbed
|
||||
amount={amount as string}
|
||||
chainId={chainId ? Number(chainId) : undefined}
|
||||
@@ -64,42 +69,28 @@ export default async function BridgePage({
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
alt=""
|
||||
className="-bottom-12 -right-12 pointer-events-none absolute lg:right-0 lg:bottom-0 opacity-20"
|
||||
src="/assets/login/background.svg"
|
||||
/>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-8 z-20">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="relative overflow-hidden rounded-lg border-2 border-green-500/20 bg-gradient-to-br from-card/80 to-card/50 p-4 shadow-[inset_0_1px_2px_0_rgba(0,0,0,0.02)]">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-green-500/5 to-transparent" />
|
||||
<div className="relative flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="font-semibold text-base tracking-tight">
|
||||
Get Started with thirdweb Bridge
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
thirdweb Bridge allows developers to easily bridge and swap
|
||||
tokens between chains and wallets.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
className="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 font-medium text-sm text-white transition-all hover:bg-green-600/90 hover:shadow-sm"
|
||||
href="https://portal.thirdweb.com/bridge"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn More
|
||||
<ArrowUpRightIcon className="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-background relative z-10">
|
||||
<AppFooter />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DotsBackgroundPattern(props: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 text-foreground/30 dark:text-foreground/10",
|
||||
props.className,
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: "radial-gradient(currentColor 1px, transparent 1px)",
|
||||
backgroundSize: "24px 24px",
|
||||
maskImage:
|
||||
"radial-gradient(ellipse 100% 100% at 50% 50%, black 30%, transparent 50%)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -148,12 +148,12 @@ const nextConfig = {
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/insight",
|
||||
source: "/insight/:path*",
|
||||
destination: "https://insight.thirdweb.com/reference",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/payments/backend",
|
||||
source: "/payments/backend/:path*",
|
||||
destination: "/reference#tag/payments",
|
||||
permanent: false,
|
||||
},
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"thirdweb": "workspace:*",
|
||||
"use-debounce": "^10.0.5",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
"x402-next": "^0.6.1",
|
||||
"zod": "3.25.75"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
// Allow streaming responses up to 5 minutes
|
||||
export const maxDuration = 300;
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Congratulations! You have accessed the protected route.",
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { LoadingDots } from "@/components/ui/LoadingDots";
|
||||
import { quotes, stringifyImports, stringifyProps } from "@/lib/code-gen";
|
||||
import type { SwapWidgetPlaygroundOptions } from "./types";
|
||||
|
||||
const CodeClient = lazy(() =>
|
||||
@@ -28,7 +29,8 @@ export function CodeGen(props: { options: SwapWidgetPlaygroundOptions }) {
|
||||
|
||||
function getCode(options: SwapWidgetPlaygroundOptions) {
|
||||
const imports = {
|
||||
react: ["SwapWidget"] as string[],
|
||||
thirdweb: ["createThirdwebClient"] as string[],
|
||||
"thirdweb/react": ["SwapWidget"] as string[],
|
||||
};
|
||||
|
||||
let themeProp: string | undefined;
|
||||
@@ -39,7 +41,7 @@ function getCode(options: SwapWidgetPlaygroundOptions) {
|
||||
themeProp = `darkTheme({
|
||||
colors: ${JSON.stringify(options.theme.darkColorOverrides)},
|
||||
})`;
|
||||
imports.react.push("darkTheme");
|
||||
imports["thirdweb/react"].push("darkTheme");
|
||||
}
|
||||
|
||||
if (options.theme.type === "light") {
|
||||
@@ -47,7 +49,7 @@ function getCode(options: SwapWidgetPlaygroundOptions) {
|
||||
themeProp = `lightTheme({
|
||||
colors: ${JSON.stringify(options.theme.lightColorOverrides)},
|
||||
})`;
|
||||
imports.react.push("lightTheme");
|
||||
imports["thirdweb/react"].push("lightTheme");
|
||||
} else {
|
||||
themeProp = quotes("light");
|
||||
}
|
||||
@@ -69,37 +71,15 @@ function getCode(options: SwapWidgetPlaygroundOptions) {
|
||||
};
|
||||
|
||||
return `\
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
import { ${imports.react.join(", ")} } from "thirdweb/react";
|
||||
${stringifyImports(imports)}
|
||||
|
||||
const client = createThirdwebClient({
|
||||
clientId: "....",
|
||||
});
|
||||
|
||||
|
||||
function Example() {
|
||||
return (
|
||||
<SwapWidget
|
||||
${stringifyProps(props)}
|
||||
/>
|
||||
<SwapWidget ${stringifyProps(props)} />
|
||||
);
|
||||
}`;
|
||||
}
|
||||
|
||||
function quotes(value: string) {
|
||||
return `"${value}"`;
|
||||
}
|
||||
|
||||
function stringifyProps(props: Record<string, string | undefined | boolean>) {
|
||||
const _props: Record<string, string | undefined | boolean> = {};
|
||||
|
||||
for (const key in props) {
|
||||
if (props[key] !== undefined && props[key] !== "") {
|
||||
_props[key] = props[key];
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(_props)
|
||||
.map(([key, value]) => `${key}={${value}}`)
|
||||
.join("\n\t ");
|
||||
}
|
||||
|
||||
@@ -59,7 +59,9 @@ export function RightSection(props: { options: SwapWidgetPlaygroundOptions }) {
|
||||
prefill={props.options.prefill}
|
||||
currency={props.options.currency}
|
||||
showThirdwebBranding={props.options.showThirdwebBranding}
|
||||
key={JSON.stringify(props.options)}
|
||||
key={JSON.stringify({
|
||||
prefill: props.options.prefill,
|
||||
})}
|
||||
persistTokenSelections={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -206,6 +206,10 @@ const payments: ShadcnSidebarLink = {
|
||||
href: "/payments/transactions",
|
||||
label: "Onchain Transaction",
|
||||
},
|
||||
{
|
||||
href: "/payments/x402",
|
||||
label: "x402",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ const defaultOptions: BridgeComponentsPlaygroundOptions = {
|
||||
sellerAddress: "0x0000000000000000000000000000000000000000",
|
||||
title: "",
|
||||
transactionData: "",
|
||||
widget: "checkout",
|
||||
currency: "USD",
|
||||
showThirdwebBranding: true,
|
||||
},
|
||||
@@ -37,12 +36,12 @@ export function CheckoutPlayground() {
|
||||
<div className="relative flex flex-col-reverse gap-6 xl:min-h-[900px] xl:flex-row xl:gap-6">
|
||||
<div className="grow border-b pb-10 xl:mb-0 xl:border-r xl:border-b-0 xl:pr-6">
|
||||
<LeftSection
|
||||
lockedWidget="checkout"
|
||||
widget="checkout"
|
||||
options={options}
|
||||
setOptions={setOptions}
|
||||
/>
|
||||
</div>
|
||||
<RightSection lockedWidget="checkout" options={options} />
|
||||
<RightSection widget="checkout" options={options} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { LoadingDots } from "@/components/ui/LoadingDots";
|
||||
import {
|
||||
quotes,
|
||||
stringifyImports,
|
||||
stringifyProps,
|
||||
} from "../../../lib/code-gen";
|
||||
import type { BridgeComponentsPlaygroundOptions } from "./types";
|
||||
|
||||
const CodeClient = lazy(() =>
|
||||
@@ -16,58 +21,114 @@ function CodeLoading() {
|
||||
);
|
||||
}
|
||||
|
||||
export function CodeGen(props: { options: BridgeComponentsPlaygroundOptions }) {
|
||||
export function CodeGen(props: {
|
||||
widget: "buy" | "checkout" | "transaction";
|
||||
options: BridgeComponentsPlaygroundOptions;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full grow flex-col">
|
||||
<Suspense fallback={<CodeLoading />}>
|
||||
<CodeClient className="grow" code={getCode(props.options)} lang="tsx" />
|
||||
<CodeClient
|
||||
className="grow"
|
||||
code={getCode(props.widget, props.options)}
|
||||
lang="tsx"
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getCode(options: BridgeComponentsPlaygroundOptions) {
|
||||
function getCode(
|
||||
widget: "buy" | "checkout" | "transaction",
|
||||
options: BridgeComponentsPlaygroundOptions,
|
||||
) {
|
||||
const componentName =
|
||||
widget === "buy"
|
||||
? "BuyWidget"
|
||||
: widget === "checkout"
|
||||
? "CheckoutWidget"
|
||||
: widget === "transaction"
|
||||
? "TransactionWidget"
|
||||
: "";
|
||||
|
||||
const imports = {
|
||||
chains: [] as string[],
|
||||
react: ["PayEmbed"] as string[],
|
||||
thirdweb: [] as string[],
|
||||
wallets: [] as string[],
|
||||
"thirdweb/chains": [] as string[],
|
||||
"thirdweb/react": [componentName] as string[],
|
||||
thirdweb: ["createThirdwebClient", "defineChain"] as string[],
|
||||
"thirdweb/wallets": ["createWallet"] as string[],
|
||||
};
|
||||
|
||||
// Check if we have a custom chain (not base chain which has id 8453)
|
||||
const isCustomChain =
|
||||
options.payOptions.buyTokenChain &&
|
||||
options.payOptions.buyTokenChain.id !== 8453;
|
||||
|
||||
if (isCustomChain) {
|
||||
// Add defineChain to imports if using a custom chain
|
||||
imports.thirdweb.push("defineChain");
|
||||
} else {
|
||||
// Otherwise use the base chain
|
||||
imports.chains.push("base");
|
||||
let themeProp: string | undefined;
|
||||
if (
|
||||
options.theme.type === "dark" &&
|
||||
Object.keys(options.theme.darkColorOverrides || {}).length > 0
|
||||
) {
|
||||
themeProp = `darkTheme({
|
||||
colors: ${JSON.stringify(options.theme.darkColorOverrides)},
|
||||
})`;
|
||||
imports["thirdweb/react"].push("darkTheme");
|
||||
}
|
||||
|
||||
imports.wallets.push("createWallet");
|
||||
|
||||
const componentName = (() => {
|
||||
switch (options.payOptions.widget) {
|
||||
case "buy":
|
||||
return "BuyWidget";
|
||||
case "checkout":
|
||||
return "CheckoutWidget";
|
||||
case "transaction":
|
||||
return "TransactionWidget";
|
||||
default:
|
||||
return "PayEmbed";
|
||||
if (options.theme.type === "light") {
|
||||
if (Object.keys(options.theme.lightColorOverrides || {}).length > 0) {
|
||||
themeProp = `lightTheme({
|
||||
colors: ${JSON.stringify(options.theme.lightColorOverrides)},
|
||||
})`;
|
||||
imports["thirdweb/react"].push("lightTheme");
|
||||
} else {
|
||||
themeProp = quotes("light");
|
||||
}
|
||||
})();
|
||||
imports.react.push(componentName);
|
||||
imports.chains.push("defineChain");
|
||||
}
|
||||
|
||||
const transaction =
|
||||
widget === "transaction"
|
||||
? `claimTo({
|
||||
contract: nftContract,
|
||||
quantity: 1n,
|
||||
to: account?.address || "",
|
||||
tokenId: 2n,
|
||||
})`
|
||||
: undefined;
|
||||
|
||||
const props: Record<string, string | undefined | boolean> = {
|
||||
theme: themeProp,
|
||||
client: "client",
|
||||
description: options.payOptions.description
|
||||
? quotes(options.payOptions.description)
|
||||
: undefined,
|
||||
image: options.payOptions.image
|
||||
? quotes(options.payOptions.image)
|
||||
: undefined,
|
||||
name: options.payOptions.title
|
||||
? quotes(options.payOptions.title)
|
||||
: undefined,
|
||||
paymentMethods:
|
||||
options.payOptions.paymentMethods.length === 2
|
||||
? undefined
|
||||
: JSON.stringify(options.payOptions.paymentMethods),
|
||||
currency: options.payOptions.currency
|
||||
? quotes(options.payOptions.currency)
|
||||
: undefined,
|
||||
chain: `defineChain(${options.payOptions.buyTokenChain.id})`,
|
||||
showThirdwebBranding:
|
||||
options.payOptions.showThirdwebBranding === false ? false : undefined,
|
||||
amount: options.payOptions.buyTokenAmount
|
||||
? quotes(options.payOptions.buyTokenAmount)
|
||||
: undefined,
|
||||
tokenAddress: options.payOptions.buyTokenAddress
|
||||
? quotes(options.payOptions.buyTokenAddress)
|
||||
: undefined,
|
||||
seller: options.payOptions.sellerAddress
|
||||
? quotes(options.payOptions.sellerAddress)
|
||||
: undefined,
|
||||
buttonLabel: options.payOptions.buttonLabel
|
||||
? quotes(options.payOptions.buttonLabel)
|
||||
: undefined,
|
||||
transaction: transaction,
|
||||
};
|
||||
|
||||
return `\
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
${imports.react.length > 0 ? `import { ${imports.react.join(", ")} } from "thirdweb/react";` : ""}
|
||||
${imports.thirdweb.length > 0 ? `import { ${imports.thirdweb.join(", ")} } from "thirdweb";` : ""}
|
||||
${stringifyImports(imports)}
|
||||
|
||||
const client = createThirdwebClient({
|
||||
clientId: "....",
|
||||
@@ -75,20 +136,7 @@ const client = createThirdwebClient({
|
||||
|
||||
function Example() {
|
||||
return (
|
||||
<${componentName}
|
||||
client={client}
|
||||
chain={defineChain(${options.payOptions.buyTokenChain.id})}
|
||||
amount="${options.payOptions.buyTokenAmount}"${options.payOptions.buyTokenAddress ? `\n\t tokenAddress="${options.payOptions.buyTokenAddress}"` : ""}${options.payOptions.sellerAddress ? `\n\t seller="${options.payOptions.sellerAddress}"` : ""}${options.payOptions.title ? `\n\t ${options.payOptions.widget === "checkout" ? "name" : "title"}="${options.payOptions.title}"` : ""}${options.payOptions.image ? `\n\t image="${options.payOptions.image}"` : ""}${options.payOptions.description ? `\n\t description="${options.payOptions.description}"` : ""}${options.payOptions.buttonLabel ? `\n\t buttonLabel="${options.payOptions.buttonLabel}"` : ""}${options.payOptions.paymentMethods && options.payOptions.paymentMethods.length > 0 ? `\n\t paymentMethods={${JSON.stringify(options.payOptions.paymentMethods)}}` : ""}${options.payOptions.currency ? `\n\t currency="${options.payOptions.currency}"` : ""}${
|
||||
options.payOptions.widget === "transaction"
|
||||
? `\n\t transaction={claimTo({
|
||||
contract: nftContract,
|
||||
quantity: 1n,
|
||||
tokenId: 2n,
|
||||
to: account?.address || "",
|
||||
})}`
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
<${componentName} ${stringifyProps(props)} />
|
||||
);
|
||||
}`;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CreditCardIcon,
|
||||
ExternalLinkIcon,
|
||||
FuelIcon,
|
||||
PaletteIcon,
|
||||
Settings2Icon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { CreditCardIcon, PaletteIcon, Settings2Icon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useId, useState } from "react";
|
||||
import type { Address } from "thirdweb";
|
||||
@@ -30,11 +23,10 @@ export function LeftSection(props: {
|
||||
setOptions: React.Dispatch<
|
||||
React.SetStateAction<BridgeComponentsPlaygroundOptions>
|
||||
>;
|
||||
lockedWidget: "buy" | "checkout" | "transaction";
|
||||
widget: "buy" | "checkout" | "transaction";
|
||||
}) {
|
||||
const { options, setOptions } = props;
|
||||
const { theme, payOptions } = options;
|
||||
const effectiveWidget = props.lockedWidget || payOptions.widget || "buy";
|
||||
const setThemeType = (themeType: "dark" | "light") => {
|
||||
setOptions((v) => ({
|
||||
...v,
|
||||
@@ -130,7 +122,7 @@ export function LeftSection(props: {
|
||||
</section>
|
||||
|
||||
{/* Shared Chain and Token Selection - Always visible for Buy and Checkout modes */}
|
||||
{(effectiveWidget === "buy" || effectiveWidget === "checkout") && (
|
||||
{(props.widget === "buy" || props.widget === "checkout") && (
|
||||
<div className="space-y-4">
|
||||
{/* Chain selection */}
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -164,7 +156,7 @@ export function LeftSection(props: {
|
||||
{/* Mode-specific form fields */}
|
||||
<div className="my-2">
|
||||
{/* Buy Mode - Amount and Payment Methods */}
|
||||
{effectiveWidget === "buy" && (
|
||||
{props.widget === "buy" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={buyTokenAmountId}>Amount</Label>
|
||||
@@ -245,7 +237,7 @@ export function LeftSection(props: {
|
||||
)}
|
||||
|
||||
{/* Checkout Mode - Seller Address, Price and Payment Methods */}
|
||||
{effectiveWidget === "checkout" && (
|
||||
{props.widget === "checkout" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={sellerAddressId}>Seller Address</Label>
|
||||
@@ -347,7 +339,7 @@ export function LeftSection(props: {
|
||||
)}
|
||||
|
||||
{/* Transaction Mode Options */}
|
||||
{effectiveWidget === "transaction" && (
|
||||
{props.widget === "transaction" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Transaction</Label>
|
||||
@@ -495,26 +487,6 @@ export function LeftSection(props: {
|
||||
<Label htmlFor={"branding"}>Show Branding</Label>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection icon={FuelIcon} title="Sponsor gas fees">
|
||||
<div className="mt-4 flex items-start gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="">
|
||||
Abstract away gas fees for users of your app by enabling ERC-4337
|
||||
Account Abstraction
|
||||
</p>
|
||||
|
||||
<Link
|
||||
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground"
|
||||
href="https://portal.thirdweb.com/wallets/sponsor-gas?utm_source=playground"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more about Account Abstraction
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ type Tab = "ui" | "code";
|
||||
export function RightSection(props: {
|
||||
options: BridgeComponentsPlaygroundOptions;
|
||||
tab?: string;
|
||||
lockedWidget?: "buy" | "checkout" | "transaction";
|
||||
widget: "buy" | "checkout" | "transaction";
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const [previewTab, _setPreviewTab] = useState<Tab>(() => {
|
||||
@@ -52,10 +52,8 @@ export function RightSection(props: {
|
||||
colors: props.options.theme.lightColorOverrides,
|
||||
});
|
||||
|
||||
const effectiveWidget = props.lockedWidget || props.options.payOptions.widget;
|
||||
|
||||
let embed: React.ReactNode;
|
||||
if (effectiveWidget === "buy") {
|
||||
if (props.widget === "buy") {
|
||||
embed = (
|
||||
<BuyWidget
|
||||
amount={props.options.payOptions.buyTokenAmount}
|
||||
@@ -76,7 +74,7 @@ export function RightSection(props: {
|
||||
);
|
||||
}
|
||||
|
||||
if (effectiveWidget === "checkout") {
|
||||
if (props.widget === "checkout") {
|
||||
embed = (
|
||||
<CheckoutWidget
|
||||
amount={props.options.payOptions.buyTokenAmount}
|
||||
@@ -104,7 +102,7 @@ export function RightSection(props: {
|
||||
);
|
||||
}
|
||||
|
||||
if (effectiveWidget === "transaction") {
|
||||
if (props.widget === "transaction") {
|
||||
embed = (
|
||||
<TransactionWidget
|
||||
client={THIRDWEB_CLIENT}
|
||||
@@ -155,7 +153,9 @@ export function RightSection(props: {
|
||||
|
||||
{previewTab === "ui" && embed}
|
||||
|
||||
{previewTab === "code" && <CodeGen options={props.options} />}
|
||||
{previewTab === "code" && (
|
||||
<CodeGen widget={props.widget} options={props.options} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -37,7 +37,6 @@ export type BridgeComponentsPlaygroundOptions = {
|
||||
lightColorOverrides: ThemeOverrides["colors"];
|
||||
};
|
||||
payOptions: {
|
||||
widget?: "buy" | "checkout" | "transaction";
|
||||
title: string | undefined;
|
||||
image: string | undefined;
|
||||
description: string | undefined;
|
||||
|
||||
@@ -18,7 +18,6 @@ const defaultOptions: BridgeComponentsPlaygroundOptions = {
|
||||
sellerAddress: "0x0000000000000000000000000000000000000000",
|
||||
title: "",
|
||||
transactionData: "",
|
||||
widget: "buy",
|
||||
currency: "USD",
|
||||
showThirdwebBranding: true,
|
||||
},
|
||||
@@ -36,13 +35,9 @@ export function BuyPlayground() {
|
||||
return (
|
||||
<div className="relative flex flex-col-reverse gap-6 xl:min-h-[900px] xl:flex-row xl:gap-6">
|
||||
<div className="grow border-b pb-10 xl:mb-0 xl:border-r xl:border-b-0 xl:pr-6">
|
||||
<LeftSection
|
||||
lockedWidget="buy"
|
||||
options={options}
|
||||
setOptions={setOptions}
|
||||
/>
|
||||
<LeftSection widget="buy" options={options} setOptions={setOptions} />
|
||||
</div>
|
||||
<RightSection lockedWidget="buy" options={options} />
|
||||
<RightSection widget="buy" options={options} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { OverviewPage } from "@/components/blocks/OverviewPage";
|
||||
import { PayIcon } from "../../icons/PayIcon";
|
||||
import { paymentsFeatureCards } from "../data/pages-metadata";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<OverviewPage
|
||||
icon={PayIcon}
|
||||
title="Payments"
|
||||
description={
|
||||
<>
|
||||
Allow developers and users to receive and spend any token on any EVM
|
||||
chain
|
||||
</>
|
||||
}
|
||||
featureCards={paymentsFeatureCards}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,6 @@ const defaultOptions: BridgeComponentsPlaygroundOptions = {
|
||||
sellerAddress: "0x0000000000000000000000000000000000000000",
|
||||
title: "",
|
||||
transactionData: "",
|
||||
widget: "transaction",
|
||||
currency: "USD",
|
||||
showThirdwebBranding: true,
|
||||
},
|
||||
@@ -37,12 +36,12 @@ export function TransactionPlayground() {
|
||||
<div className="relative flex flex-col-reverse gap-6 xl:min-h-[900px] xl:flex-row xl:gap-6">
|
||||
<div className="grow border-b pb-10 xl:mb-0 xl:border-r xl:border-b-0 xl:pr-6">
|
||||
<LeftSection
|
||||
lockedWidget="transaction"
|
||||
widget="transaction"
|
||||
options={options}
|
||||
setOptions={setOptions}
|
||||
/>
|
||||
</div>
|
||||
<RightSection lockedWidget="transaction" options={options} />
|
||||
<RightSection widget="transaction" options={options} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { CodeClient } from "@workspace/ui/components/code/code.client";
|
||||
import { CodeIcon, LockIcon } from "lucide-react";
|
||||
import { baseSepolia } from "thirdweb/chains";
|
||||
import {
|
||||
ConnectButton,
|
||||
getDefaultToken,
|
||||
useActiveAccount,
|
||||
useActiveWallet,
|
||||
} from "thirdweb/react";
|
||||
import { wrapFetchWithPayment } from "thirdweb/x402";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { THIRDWEB_CLIENT } from "../../../../lib/client";
|
||||
|
||||
const chain = baseSepolia;
|
||||
const token = getDefaultToken(chain, "USDC");
|
||||
|
||||
export function X402ClientPreview() {
|
||||
const activeWallet = useActiveWallet();
|
||||
const activeAccount = useActiveAccount();
|
||||
const paidApiCall = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!activeWallet) {
|
||||
throw new Error("No active wallet");
|
||||
}
|
||||
const fetchWithPay = wrapFetchWithPayment(
|
||||
fetch,
|
||||
THIRDWEB_CLIENT,
|
||||
activeWallet,
|
||||
);
|
||||
const response = await fetchWithPay("/api/paywall");
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
const handlePayClick = async () => {
|
||||
paidApiCall.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full p-4 md:p-12 max-w-lg mx-auto">
|
||||
<ConnectButton
|
||||
client={THIRDWEB_CLIENT}
|
||||
chain={chain}
|
||||
detailsButton={{
|
||||
displayBalanceToken: {
|
||||
[chain.id]: token!.address,
|
||||
},
|
||||
}}
|
||||
supportedTokens={{
|
||||
[chain.id]: [token!],
|
||||
}}
|
||||
/>
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<LockIcon className="w-5 h-5 text-muted-foreground" />
|
||||
<span className="text-lg font-medium">Paid API Call</span>
|
||||
<span className="text-xl font-bold text-red-600">$0.01</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handlePayClick}
|
||||
className="w-full mb-4"
|
||||
size="lg"
|
||||
disabled={paidApiCall.isPending || !activeAccount}
|
||||
>
|
||||
Pay Now
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href={"https://faucet.circle.com/"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Click here to get USDC on {chain.name}
|
||||
</a>
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<CodeIcon className="w-5 h-5 text-muted-foreground" />
|
||||
<span className="text-lg font-medium">API Call Response</span>
|
||||
</div>
|
||||
{paidApiCall.isPending && <div className="text-center">Loading...</div>}
|
||||
{paidApiCall.isError && (
|
||||
<div className="text-center">Error: {paidApiCall.error.message}</div>
|
||||
)}
|
||||
{paidApiCall.data && (
|
||||
<CodeClient
|
||||
code={JSON.stringify(paidApiCall.data, null, 2)}
|
||||
lang="json"
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { CodeServer } from "@workspace/ui/components/code/code.server";
|
||||
import { CircleDollarSignIcon, Code2Icon } from "lucide-react";
|
||||
import { CodeExample, TabName } from "@/components/code/code-example";
|
||||
import ThirdwebProvider from "@/components/thirdweb-provider";
|
||||
import { PageLayout } from "../../../components/blocks/APIHeader";
|
||||
import { createMetadata } from "../../../lib/metadata";
|
||||
import { X402ClientPreview } from "./components/x402-client-preview";
|
||||
|
||||
const title = "x402 Payments";
|
||||
const description =
|
||||
"Use the x402 payment protocol to pay for API calls using any web3 wallet.";
|
||||
const ogDescription =
|
||||
"Use the x402 payment protocol to pay for API calls using any web3 wallet.";
|
||||
|
||||
export const metadata = createMetadata({
|
||||
title,
|
||||
description: ogDescription,
|
||||
image: {
|
||||
icon: "payments",
|
||||
title,
|
||||
},
|
||||
});
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<PageLayout
|
||||
icon={CircleDollarSignIcon}
|
||||
title={title}
|
||||
description={description}
|
||||
docsLink="https://portal.thirdweb.com/payments/x402?utm_source=playground"
|
||||
>
|
||||
<X402Example />
|
||||
<div className="h-8" />
|
||||
<ServerCodeExample />
|
||||
</PageLayout>
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ServerCodeExample() {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<h2 className="font-semibold text-xl tracking-tight">
|
||||
Next.js Server Code Example
|
||||
</h2>
|
||||
<p className="max-w-4xl text-muted-foreground text-balance text-sm md:text-base">
|
||||
Use any x402 middleware + the thirdweb facilitator to settle
|
||||
transactions with our server wallet.
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border bg-card">
|
||||
<div className="flex grow flex-col border-b md:border-r md:border-b-0">
|
||||
<TabName icon={Code2Icon} name="Server Code" />
|
||||
<CodeServer
|
||||
className="h-full rounded-none border-none"
|
||||
code={`// src/middleware.ts
|
||||
|
||||
import { facilitator } from "thirdweb/x402";
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
import { paymentMiddleware } from "x402-next";
|
||||
|
||||
const client = createThirdwebClient({ secretKey: "your-secret-key" });
|
||||
|
||||
export const middleware = paymentMiddleware(
|
||||
"0xYourWalletAddress",
|
||||
{
|
||||
"/api/paid-endpoint": {
|
||||
price: "$0.01",
|
||||
network: "base-sepolia",
|
||||
config: {
|
||||
description: "Access to paid content",
|
||||
},
|
||||
},
|
||||
},
|
||||
facilitator({
|
||||
client,
|
||||
serverWalletAddress: "0xYourServerWalletAddress",
|
||||
}),
|
||||
);
|
||||
|
||||
// Configure which paths the middleware should run on
|
||||
export const config = {
|
||||
matcher: ["/api/paid-endpoint"],
|
||||
};
|
||||
|
||||
`}
|
||||
lang="tsx"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function X402Example() {
|
||||
return (
|
||||
<CodeExample
|
||||
header={{
|
||||
title: "Client Code Example",
|
||||
description:
|
||||
"Wrap your fetch requests with the `wrapFetchWithPayment` function to enable x402 payments.",
|
||||
}}
|
||||
code={`import { createThirdwebClient } from "thirdweb";
|
||||
import { wrapFetchWithPayment } from "thirdweb/x402";
|
||||
import { useActiveWallet } from "thirdweb/react";
|
||||
|
||||
const client = createThirdwebClient({ clientId: "your-client-id" });
|
||||
|
||||
export default function Page() {
|
||||
const wallet = useActiveWallet();
|
||||
|
||||
const onClick = async () => {
|
||||
const fetchWithPay = wrapFetchWithPayment(fetch, client, wallet);
|
||||
const response = await fetchWithPay('/api/paid-endpoint');
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={onClick}>Pay Now</Button>
|
||||
);
|
||||
}`}
|
||||
lang="tsx"
|
||||
preview={<X402ClientPreview />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import {
|
||||
quotes,
|
||||
stringifyIgnoreFalsy,
|
||||
stringifyImports,
|
||||
stringifyProps,
|
||||
} from "@/lib/code-gen";
|
||||
import { LoadingDots } from "../../../../components/ui/LoadingDots";
|
||||
import type { ConnectPlaygroundOptions } from "./types";
|
||||
|
||||
@@ -34,10 +40,10 @@ export function CodeGen(props: { connectOptions: ConnectPlaygroundOptions }) {
|
||||
function getCode(connectOptions: ConnectPlaygroundOptions) {
|
||||
const walletCodes: string[] = [];
|
||||
const imports = {
|
||||
chains: [] as string[],
|
||||
react: [] as string[],
|
||||
thirdweb: [] as string[],
|
||||
wallets: [] as string[],
|
||||
"thirdweb/chains": [] as string[],
|
||||
"thirdweb/react": ["ConnectButton"] as string[],
|
||||
thirdweb: ["createThirdwebClient"] as string[],
|
||||
"thirdweb/wallets": [] as string[],
|
||||
};
|
||||
|
||||
if (
|
||||
@@ -49,7 +55,7 @@ function getCode(connectOptions: ConnectPlaygroundOptions) {
|
||||
options: ${JSON.stringify(connectOptions.inAppWallet.methods)},
|
||||
},
|
||||
})`);
|
||||
imports.wallets.push("inAppWallet");
|
||||
imports["thirdweb/wallets"].push("inAppWallet");
|
||||
}
|
||||
|
||||
for (const wallet of connectOptions.walletIds) {
|
||||
@@ -57,7 +63,7 @@ function getCode(connectOptions: ConnectPlaygroundOptions) {
|
||||
}
|
||||
|
||||
if (connectOptions.walletIds.length > 0) {
|
||||
imports.wallets.push("createWallet");
|
||||
imports["thirdweb/wallets"].push("createWallet");
|
||||
}
|
||||
|
||||
let themeProp: string | undefined;
|
||||
@@ -68,7 +74,7 @@ function getCode(connectOptions: ConnectPlaygroundOptions) {
|
||||
themeProp = `darkTheme({
|
||||
colors: ${JSON.stringify(connectOptions.theme.darkColorOverrides)},
|
||||
})`;
|
||||
imports.react.push("darkTheme");
|
||||
imports["thirdweb/react"].push("darkTheme");
|
||||
}
|
||||
|
||||
if (connectOptions.theme.type === "light") {
|
||||
@@ -78,14 +84,14 @@ function getCode(connectOptions: ConnectPlaygroundOptions) {
|
||||
themeProp = `lightTheme({
|
||||
colors: ${JSON.stringify(connectOptions.theme.lightColorOverrides)},
|
||||
})`;
|
||||
imports.react.push("lightTheme");
|
||||
imports["thirdweb/react"].push("lightTheme");
|
||||
} else {
|
||||
themeProp = quotes("light");
|
||||
}
|
||||
}
|
||||
|
||||
if (connectOptions.enableAccountAbstraction) {
|
||||
imports.chains.push("ethereum");
|
||||
imports["thirdweb/chains"].push("ethereum");
|
||||
}
|
||||
|
||||
const props: Record<string, string | undefined> = {
|
||||
@@ -118,11 +124,7 @@ function getCode(connectOptions: ConnectPlaygroundOptions) {
|
||||
};
|
||||
|
||||
return `\
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
import { ConnectButton } from "thirdweb/react";
|
||||
${imports.react.length > 0 ? `import { ${imports.react.join(", ")} } from "thirdweb/react";` : ""}
|
||||
${imports.wallets.length > 0 ? `import { ${imports.wallets.join(", ")} } from "thirdweb/wallets";` : ""}
|
||||
${imports.chains.length > 0 ? `import { ${imports.chains.join(", ")} } from "thirdweb/chains";` : ""}
|
||||
${stringifyImports(imports)}
|
||||
|
||||
const client = createThirdwebClient({
|
||||
clientId: "....",
|
||||
@@ -160,35 +162,3 @@ const accountAbstractCode = `\
|
||||
sponsorGas: true
|
||||
}
|
||||
`;
|
||||
|
||||
function stringifyIgnoreFalsy(
|
||||
value: Record<string, string | undefined | boolean>,
|
||||
) {
|
||||
const _value: Record<string, string | boolean> = {};
|
||||
|
||||
for (const key in value) {
|
||||
if (value[key] !== undefined && value[key] !== "") {
|
||||
_value[key] = value[key];
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(_value);
|
||||
}
|
||||
|
||||
function stringifyProps(props: Record<string, string | undefined | boolean>) {
|
||||
const _props: Record<string, string | undefined | boolean> = {};
|
||||
|
||||
for (const key in props) {
|
||||
if (props[key] !== undefined && props[key] !== "") {
|
||||
_props[key] = props[key];
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(_props)
|
||||
.map(([key, value]) => `${key}={${value}}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function quotes(value: string) {
|
||||
return `"${value}"`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
export function stringifyProps(
|
||||
props: Record<string, string | undefined | boolean>,
|
||||
) {
|
||||
const _props: Record<string, string | undefined | boolean> = {};
|
||||
|
||||
for (const key in props) {
|
||||
if (props[key] !== undefined && props[key] !== "") {
|
||||
_props[key] = props[key];
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(_props)
|
||||
.map(([key, value]) => `${key}={${value}}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function quotes(value: string) {
|
||||
return `"${value}"`;
|
||||
}
|
||||
|
||||
export function stringifyImports(imports: Record<string, string[]>) {
|
||||
let code = "";
|
||||
for (const key in imports) {
|
||||
if (imports[key].length > 0) {
|
||||
code += `import { ${imports[key].join(", ")} } from "${key}";\n`;
|
||||
}
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
export function stringifyIgnoreFalsy(
|
||||
value: Record<string, string | undefined | boolean>,
|
||||
) {
|
||||
const _value: Record<string, string | boolean> = {};
|
||||
|
||||
for (const key in value) {
|
||||
if (value[key] !== undefined && value[key] !== "") {
|
||||
_value[key] = value[key];
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(_value);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
import { facilitator } from "thirdweb/x402";
|
||||
import { paymentMiddleware } from "x402-next";
|
||||
|
||||
const client = createThirdwebClient({
|
||||
secretKey: process.env.THIRDWEB_SECRET_KEY as string,
|
||||
});
|
||||
|
||||
const BACKEND_WALLET_ADDRESS = process.env.ENGINE_BACKEND_WALLET as string;
|
||||
const ENGINE_VAULT_ACCESS_TOKEN = process.env
|
||||
.ENGINE_VAULT_ACCESS_TOKEN as string;
|
||||
const API_URL = `https://${process.env.NEXT_PUBLIC_API_URL || "api.thirdweb.com"}`;
|
||||
|
||||
export const middleware = paymentMiddleware(
|
||||
"0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024",
|
||||
{
|
||||
"/api/paywall": {
|
||||
price: "$0.01",
|
||||
network: "base-sepolia",
|
||||
config: {
|
||||
description: "Access to paid content",
|
||||
},
|
||||
},
|
||||
},
|
||||
facilitator({
|
||||
baseUrl: `${API_URL}/v1/payments/x402`,
|
||||
client,
|
||||
serverWalletAddress: BACKEND_WALLET_ADDRESS,
|
||||
vaultAccessToken: ENGINE_VAULT_ACCESS_TOKEN,
|
||||
}),
|
||||
);
|
||||
|
||||
// Configure which paths the middleware should run on
|
||||
export const config = {
|
||||
matcher: ["/api/paywall"],
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
TypeScriptIcon,
|
||||
} from "@/icons";
|
||||
import SwapWidgetImage from "./swap-dark.png";
|
||||
import SwapWidgetImageLight from "./swap-light.png";
|
||||
|
||||
export const metadata = createMetadata({
|
||||
image: {
|
||||
@@ -55,7 +56,12 @@ function Example() {
|
||||
}
|
||||
```
|
||||
|
||||
<div className='dark-only'>
|
||||
<DocImage src={SwapWidgetImage} />
|
||||
</div>
|
||||
<div className='light-only'>
|
||||
<DocImage src={SwapWidgetImageLight} />
|
||||
</div>
|
||||
|
||||
## Live Playground
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 136 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
@@ -61,6 +61,10 @@ export const sidebar: SideBar = {
|
||||
href: `${paymentsSlug}/custom-data`,
|
||||
name: "Custom Data",
|
||||
},
|
||||
{
|
||||
href: `${paymentsSlug}/x402`,
|
||||
name: "x402",
|
||||
},
|
||||
],
|
||||
name: "Guides",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { ArticleIconCard } from "@doc";
|
||||
import { ReactIcon } from "@/icons";
|
||||
|
||||
# x402 payments
|
||||
|
||||
Implement paid API calls using the x402 protocol. Every request is paid for by the user with a micro payment onchain.
|
||||
|
||||
<ArticleIconCard
|
||||
title="x402 Playground"
|
||||
description="Try out a x402 payment in our live playground"
|
||||
icon={ReactIcon}
|
||||
href="https://playground.thirdweb.com/payments/x402"
|
||||
/>
|
||||
|
||||
## Client Side
|
||||
|
||||
`wrapFetchWithPayment` wraps the native fetch API to automatically handle `402 Payment Required` responses from any API call. It will:
|
||||
1. Make the initial request
|
||||
2. If a 402 response is received, parse the payment requirements
|
||||
3. Verify the payment amount is within the allowed maximum
|
||||
4. Sign a payment authorization
|
||||
5. Create a payment header using the provided wallet signature
|
||||
6. Retry the request with the payment header
|
||||
|
||||
Here's an example:
|
||||
|
||||
```typescript
|
||||
import { wrapFetchWithPayment } from "thirdweb/x402";
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
import { createWallet } from "thirdweb/wallets";
|
||||
|
||||
const client = createThirdwebClient({ clientId: "your-client-id" });
|
||||
const wallet = createWallet("io.metamask"); // or any other wallet
|
||||
await wallet.connect({ client })
|
||||
|
||||
const fetchWithPay = wrapFetchWithPayment(fetch, client, wallet);
|
||||
|
||||
// Make a request that may require payment
|
||||
const response = await fetchWithPay('https://api.example.com/paid-endpoint');
|
||||
```
|
||||
|
||||
## Server Side
|
||||
|
||||
To make your API calls payable, you can use any x402 middleware library like x402-hono, x402-next, x402-express, etc.
|
||||
|
||||
Then, use the `facilitator` configuratino function settle transactions with your thirdweb server wallet gaslessly and pass it to the middleware.
|
||||
|
||||
Here's an example with Next.js:
|
||||
|
||||
```typescript
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
import { facilitator } from "thirdweb/x402";
|
||||
import { paymentMiddleware } from "x402-next";
|
||||
|
||||
const client = createThirdwebClient({
|
||||
secretKey: process.env.THIRDWEB_SECRET_KEY as string,
|
||||
});
|
||||
|
||||
export const middleware = paymentMiddleware(
|
||||
"0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024",
|
||||
{
|
||||
"/api/paid-endpoint": {
|
||||
price: "$0.01",
|
||||
network: "base-sepolia",
|
||||
config: {
|
||||
description: "Access to paid content",
|
||||
},
|
||||
},
|
||||
},
|
||||
facilitator({
|
||||
client,
|
||||
serverWalletAddress: "0x1234567890123456789012345678901234567890",
|
||||
}),
|
||||
);
|
||||
|
||||
// Configure which paths the middleware should run on
|
||||
export const config = {
|
||||
matcher: ["/api/paid-endpoint"],
|
||||
};
|
||||
```
|
||||
@@ -1,5 +1,19 @@
|
||||
# @thirdweb-dev/nebula
|
||||
|
||||
## 0.2.52
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`93f913c`](https://github.com/thirdweb-dev/js/commit/93f913c614ebbe3db350872bdcff264c07155ce2), [`a85ef0b`](https://github.com/thirdweb-dev/js/commit/a85ef0b222797d38ccd31e72fafda82ceb1faefa)]:
|
||||
- thirdweb@5.107.1
|
||||
|
||||
## 0.2.51
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`5967fb8`](https://github.com/thirdweb-dev/js/commit/5967fb8afad3dbc28230c53423d304e8895f95bb)]:
|
||||
- thirdweb@5.107.0
|
||||
|
||||
## 0.2.50
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -57,5 +57,5 @@
|
||||
"type": "module",
|
||||
"types": "./dist/types/exports/thirdweb.d.ts",
|
||||
"typings": "./dist/types/exports/thirdweb.d.ts",
|
||||
"version": "0.2.50"
|
||||
"version": "0.2.52"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# thirdweb
|
||||
|
||||
## 5.107.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#8080](https://github.com/thirdweb-dev/js/pull/8080) [`93f913c`](https://github.com/thirdweb-dev/js/commit/93f913c614ebbe3db350872bdcff264c07155ce2) Thanks [@MananTank](https://github.com/MananTank)! - SwapWidget UI improvements
|
||||
|
||||
- [#8092](https://github.com/thirdweb-dev/js/pull/8092) [`a85ef0b`](https://github.com/thirdweb-dev/js/commit/a85ef0b222797d38ccd31e72fafda82ceb1faefa) Thanks [@0xFirekeeper](https://github.com/0xFirekeeper)! - add zephyr testnet to pre-1559 chains
|
||||
|
||||
## 5.107.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#8076](https://github.com/thirdweb-dev/js/pull/8076) [`5967fb8`](https://github.com/thirdweb-dev/js/commit/5967fb8afad3dbc28230c53423d304e8895f95bb) Thanks [@joaquim-verges](https://github.com/joaquim-verges)! - x402 utilities
|
||||
|
||||
## 5.106.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"toml": "3.0.0",
|
||||
"uqr": "0.1.2",
|
||||
"viem": "2.33.2",
|
||||
"x402": "0.6.1",
|
||||
"zod": "3.25.75"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -226,6 +227,11 @@
|
||||
"react-native": "./dist/esm/exports/wallets/in-app.native.js",
|
||||
"import": "./dist/esm/exports/wallets/in-app.js",
|
||||
"default": "./dist/cjs/exports/wallets/in-app.js"
|
||||
},
|
||||
"./x402": {
|
||||
"types": "./dist/types/exports/x402.d.ts",
|
||||
"import": "./dist/esm/exports/x402.js",
|
||||
"default": "./dist/cjs/exports/x402.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
@@ -414,9 +420,12 @@
|
||||
],
|
||||
"insight": [
|
||||
"./dist/types/exports/insight.d.ts"
|
||||
],
|
||||
"x402": [
|
||||
"./dist/types/exports/x402.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"typings": "./dist/types/exports/thirdweb.d.ts",
|
||||
"version": "5.106.1"
|
||||
"version": "5.107.1"
|
||||
}
|
||||
|
||||
@@ -141,6 +141,8 @@ export async function tokens<
|
||||
limit,
|
||||
offset,
|
||||
includePrices,
|
||||
sortBy,
|
||||
query,
|
||||
} = options;
|
||||
|
||||
const clientFetch = getClientFetch(client);
|
||||
@@ -167,6 +169,13 @@ export async function tokens<
|
||||
if (includePrices !== undefined) {
|
||||
url.searchParams.set("includePrices", includePrices.toString());
|
||||
}
|
||||
if (sortBy !== undefined) {
|
||||
url.searchParams.set("sortBy", sortBy);
|
||||
}
|
||||
|
||||
if (query !== undefined) {
|
||||
url.searchParams.set("query", query);
|
||||
}
|
||||
|
||||
const response = await clientFetch(url.toString());
|
||||
if (!response.ok) {
|
||||
@@ -204,6 +213,10 @@ export declare namespace tokens {
|
||||
offset?: number | null;
|
||||
/** Whether or not to include prices for the tokens. Setting this to false will speed up the request. */
|
||||
includePrices?: IncludePrices;
|
||||
/** Sort by a specific field. */
|
||||
sortBy?: "newest" | "oldest" | "volume" | "market_cap";
|
||||
/** search for tokens by token name or symbol */
|
||||
query?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,8 @@ export type Token = {
|
||||
symbol: string;
|
||||
name: string;
|
||||
iconUri?: string;
|
||||
marketCapUsd?: number;
|
||||
volume24hUsd?: number;
|
||||
};
|
||||
|
||||
export type TokenWithPrices = Token & {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
facilitator,
|
||||
type ThirdwebX402FacilitatorConfig,
|
||||
} from "../x402/facilitator.js";
|
||||
export { wrapFetchWithPayment } from "../x402/fetchWithPayment.js";
|
||||
@@ -47,6 +47,7 @@ const FORCE_GAS_PRICE_CHAIN_IDS = [
|
||||
2020, // Ronin Mainnet
|
||||
2021, // Ronin Testnet (Saigon)
|
||||
98866, // Plume mainnet
|
||||
1417429182, // Wilderworld Zephyr Testnet
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -200,6 +200,7 @@ export const iconSize = {
|
||||
"4xl": "128",
|
||||
lg: "32",
|
||||
md: "24",
|
||||
"sm+": "20",
|
||||
sm: "16",
|
||||
xl: "48",
|
||||
xs: "12",
|
||||
|
||||
@@ -37,7 +37,7 @@ type UseContractEventsOptions<
|
||||
*
|
||||
* ### Using event extensions
|
||||
*
|
||||
* The `thirdweb/extesions` export contains event definitions for many popular contracts.
|
||||
* The `thirdweb/extensions` export contains event definitions for many popular contracts.
|
||||
* You can use these event definitions to watch for specific events with a type-safe API.
|
||||
*
|
||||
* ```jsx
|
||||
|
||||
@@ -32,7 +32,7 @@ export function useWaitForReceipt(
|
||||
},
|
||||
queryKey: [
|
||||
"waitForReceipt",
|
||||
// TODO: here chain can be undfined so we go to a `-1` chain but this feels wrong
|
||||
// TODO: here chain can be undefined so we go to a `-1` chain but this feels wrong
|
||||
options?.chain.id || -1,
|
||||
options?.transactionHash,
|
||||
] as const,
|
||||
|
||||
@@ -322,7 +322,7 @@ type UIOptionsResult =
|
||||
*
|
||||
* Refer to the [`BuyWidgetConnectOptions`](https://portal.thirdweb.com/references/typescript/v5/BuyWidgetConnectOptions) type for more details.
|
||||
*
|
||||
* @bridge Widgets
|
||||
* @bridge
|
||||
*/
|
||||
export function BuyWidget(props: BuyWidgetProps) {
|
||||
const localeQuery = useConnectLocale(props.locale || "en_US");
|
||||
|
||||
@@ -318,7 +318,7 @@ type UIOptionsResult =
|
||||
*
|
||||
* Refer to the [`CheckoutWidgetConnectOptions`](https://portal.thirdweb.com/references/typescript/v5/CheckoutWidgetConnectOptions) type for more details.
|
||||
*
|
||||
* @bridge Widgets
|
||||
* @bridge
|
||||
*/
|
||||
export function CheckoutWidget(props: CheckoutWidgetProps) {
|
||||
const localeQuery = useConnectLocale(props.locale || "en_US");
|
||||
|
||||
@@ -326,7 +326,7 @@ type UIOptionsResult =
|
||||
*
|
||||
* Refer to the [`TransactionWidgetConnectOptions`](https://portal.thirdweb.com/references/typescript/v5/TransactionWidgetConnectOptions) type for more details.
|
||||
*
|
||||
* @bridge Widgets
|
||||
* @bridge
|
||||
*/
|
||||
export function TransactionWidget(props: TransactionWidgetProps) {
|
||||
const localeQuery = useConnectLocale(props.locale || "en_US");
|
||||
|
||||
@@ -36,6 +36,7 @@ export function SearchInput(props: {
|
||||
variant="outline"
|
||||
placeholder={props.placeholder}
|
||||
value={props.value}
|
||||
sm
|
||||
style={{
|
||||
paddingLeft: "44px",
|
||||
}}
|
||||
|
||||
@@ -162,6 +162,10 @@ export type SwapWidgetProps = {
|
||||
* @default true
|
||||
*/
|
||||
persistTokenSelections?: boolean;
|
||||
/**
|
||||
* Called when the user disconnects the active wallet
|
||||
*/
|
||||
onDisconnect?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -234,6 +238,7 @@ export type SwapWidgetProps = {
|
||||
* }} />
|
||||
* ```
|
||||
*
|
||||
* @bridge
|
||||
*/
|
||||
export function SwapWidget(props: SwapWidgetProps) {
|
||||
return (
|
||||
@@ -324,46 +329,11 @@ function SwapWidgetContent(props: SwapWidgetProps) {
|
||||
});
|
||||
|
||||
const [buyToken, setBuyToken] = useState<TokenSelection | undefined>(() => {
|
||||
if (props.prefill?.buyToken) {
|
||||
return {
|
||||
tokenAddress:
|
||||
props.prefill.buyToken.tokenAddress ||
|
||||
getAddress(NATIVE_TOKEN_ADDRESS),
|
||||
chainId: props.prefill.buyToken.chainId,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isPersistEnabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lastUsedBuyToken = getLastUsedTokens()?.buyToken;
|
||||
|
||||
// the token that will be set as initial value of sell token
|
||||
const sellToken = getInitialSellToken(
|
||||
props.prefill,
|
||||
getLastUsedTokens()?.sellToken,
|
||||
);
|
||||
|
||||
// if both tokens are same, ignore "buyToken", keep "sellToken"
|
||||
if (
|
||||
lastUsedBuyToken &&
|
||||
sellToken &&
|
||||
lastUsedBuyToken.tokenAddress.toLowerCase() ===
|
||||
sellToken.tokenAddress.toLowerCase() &&
|
||||
lastUsedBuyToken.chainId === sellToken.chainId
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return lastUsedBuyToken;
|
||||
return getInitialTokens(props.prefill, isPersistEnabled).buyToken;
|
||||
});
|
||||
|
||||
const [sellToken, setSellToken] = useState<TokenSelection | undefined>(() => {
|
||||
return getInitialSellToken(
|
||||
props.prefill,
|
||||
isPersistEnabled ? getLastUsedTokens()?.sellToken : undefined,
|
||||
);
|
||||
return getInitialTokens(props.prefill, isPersistEnabled).sellToken;
|
||||
});
|
||||
|
||||
// persist selections to localStorage whenever they change
|
||||
@@ -393,6 +363,7 @@ function SwapWidgetContent(props: SwapWidgetProps) {
|
||||
if (screen.id === "1:swap-ui" || !activeWalletInfo) {
|
||||
return (
|
||||
<SwapUI
|
||||
onDisconnect={props.onDisconnect}
|
||||
showThirdwebBranding={
|
||||
props.showThirdwebBranding === undefined
|
||||
? true
|
||||
@@ -532,17 +503,63 @@ function SwapWidgetContent(props: SwapWidgetProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getInitialSellToken(
|
||||
function getInitialTokens(
|
||||
prefill: SwapWidgetProps["prefill"],
|
||||
lastUsedSellToken: TokenSelection | undefined,
|
||||
) {
|
||||
if (prefill?.sellToken) {
|
||||
isPersistEnabled: boolean,
|
||||
): {
|
||||
buyToken: TokenSelection | undefined;
|
||||
sellToken: TokenSelection | undefined;
|
||||
} {
|
||||
const lastUsedTokens = isPersistEnabled ? getLastUsedTokens() : undefined;
|
||||
const buyToken = prefill?.buyToken
|
||||
? {
|
||||
tokenAddress:
|
||||
prefill.buyToken.tokenAddress || getAddress(NATIVE_TOKEN_ADDRESS),
|
||||
chainId: prefill.buyToken.chainId,
|
||||
}
|
||||
: lastUsedTokens?.buyToken;
|
||||
|
||||
const sellToken = prefill?.sellToken
|
||||
? {
|
||||
tokenAddress:
|
||||
prefill.sellToken.tokenAddress || getAddress(NATIVE_TOKEN_ADDRESS),
|
||||
chainId: prefill.sellToken.chainId,
|
||||
}
|
||||
: lastUsedTokens?.sellToken;
|
||||
|
||||
// if both tokens are same
|
||||
if (
|
||||
buyToken &&
|
||||
sellToken &&
|
||||
buyToken.tokenAddress?.toLowerCase() ===
|
||||
sellToken.tokenAddress?.toLowerCase() &&
|
||||
buyToken.chainId === sellToken.chainId
|
||||
) {
|
||||
// if sell token prefill is specified, ignore buy token
|
||||
if (prefill?.sellToken) {
|
||||
return {
|
||||
buyToken: undefined,
|
||||
sellToken: sellToken,
|
||||
};
|
||||
}
|
||||
|
||||
// if buy token prefill is specified, ignore sell token
|
||||
if (prefill?.buyToken) {
|
||||
return {
|
||||
buyToken: buyToken,
|
||||
sellToken: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// if none of the two are specified via prefill, keep buy token
|
||||
return {
|
||||
tokenAddress:
|
||||
prefill.sellToken.tokenAddress || getAddress(NATIVE_TOKEN_ADDRESS),
|
||||
chainId: prefill.sellToken.chainId,
|
||||
buyToken: buyToken,
|
||||
sellToken: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return lastUsedSellToken;
|
||||
return {
|
||||
buyToken: buyToken,
|
||||
sellToken: sellToken,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { Theme } from "../../../../core/design-system/index.js";
|
||||
import { Text } from "../../components/text.js";
|
||||
|
||||
export function DecimalRenderer(props: {
|
||||
value: string;
|
||||
color: keyof Theme["colors"];
|
||||
weight: 400 | 500 | 600 | 700;
|
||||
integerSize: "md" | "sm";
|
||||
fractionSize: "sm" | "xs";
|
||||
}) {
|
||||
if (Number(props.value) > 1000) {
|
||||
return (
|
||||
<Text size={props.integerSize} color={props.color} weight={props.weight}>
|
||||
{compactFormatter.format(Number(props.value))}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
const [integerPart, fractionPart] = props.value.split(".");
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "baseline" }}>
|
||||
<Text size={props.integerSize} color={props.color} weight={props.weight}>
|
||||
{integerPart}
|
||||
</Text>
|
||||
<Text size={props.fractionSize} color={props.color} weight={props.weight}>
|
||||
.{fractionPart || "00"}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const compactFormatter = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import type { ThirdwebClient } from "../../../../../client/client.js";
|
||||
import {
|
||||
fontSize,
|
||||
iconSize,
|
||||
radius,
|
||||
spacing,
|
||||
} from "../../../../core/design-system/index.js";
|
||||
import { Container, Line, ModalHeader } from "../../components/basic.js";
|
||||
@@ -21,6 +22,7 @@ type SelectBuyTokenProps = {
|
||||
client: ThirdwebClient;
|
||||
onSelectChain: (chain: BridgeChain) => void;
|
||||
selectedChain: BridgeChain | undefined;
|
||||
isMobile: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -56,11 +58,15 @@ export function SelectBridgeChainUI(
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Container px="md" py="md+">
|
||||
<ModalHeader onBack={props.onBack} title="Select Chain" />
|
||||
</Container>
|
||||
<Line />
|
||||
<Container fullHeight flex="column">
|
||||
{props.isMobile && (
|
||||
<>
|
||||
<Container px="md" py="md+">
|
||||
<ModalHeader onBack={props.onBack} title="Select Chain" />
|
||||
</Container>
|
||||
<Line />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Spacer y="md" />
|
||||
|
||||
@@ -79,10 +85,12 @@ export function SelectBridgeChainUI(
|
||||
<Spacer y="sm" />
|
||||
|
||||
<Container
|
||||
expand
|
||||
px="md"
|
||||
gap={props.isMobile ? undefined : "xxs"}
|
||||
flex="column"
|
||||
style={{
|
||||
height: "400px",
|
||||
maxHeight: props.isMobile ? "400px" : "none",
|
||||
overflowY: "auto",
|
||||
scrollbarWidth: "none",
|
||||
paddingBottom: spacing.md,
|
||||
@@ -95,6 +103,7 @@ export function SelectBridgeChainUI(
|
||||
client={props.client}
|
||||
onClick={() => props.onSelectChain(chain)}
|
||||
isSelected={chain.chainId === props.selectedChain?.chainId}
|
||||
isMobile={props.isMobile}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -119,7 +128,7 @@ export function SelectBridgeChainUI(
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -144,6 +153,7 @@ function ChainButton(props: {
|
||||
client: ThirdwebClient;
|
||||
onClick: () => void;
|
||||
isSelected: boolean;
|
||||
isMobile: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
@@ -152,18 +162,21 @@ function ChainButton(props: {
|
||||
style={{
|
||||
justifyContent: "flex-start",
|
||||
fontWeight: 500,
|
||||
fontSize: fontSize.md,
|
||||
fontSize: props.isMobile ? fontSize.md : fontSize.sm,
|
||||
border: "1px solid transparent",
|
||||
padding: `${spacing.sm} ${spacing.sm}`,
|
||||
padding: !props.isMobile ? `${spacing.xs} ${spacing.xs}` : undefined,
|
||||
}}
|
||||
gap="sm"
|
||||
onClick={props.onClick}
|
||||
gap="sm"
|
||||
>
|
||||
<Img
|
||||
src={props.chain.icon}
|
||||
client={props.client}
|
||||
width={iconSize.lg}
|
||||
height={iconSize.lg}
|
||||
width={props.isMobile ? iconSize.lg : iconSize.md}
|
||||
height={props.isMobile ? iconSize.lg : iconSize.md}
|
||||
style={{
|
||||
borderRadius: radius.full,
|
||||
}}
|
||||
/>
|
||||
{cleanedChainName(props.chain.name)}
|
||||
</Button>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { DiscIcon } from "@radix-ui/react-icons";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Token } from "../../../../../bridge/index.js";
|
||||
import type { BridgeChain } from "../../../../../bridge/types/Chain.js";
|
||||
import type { ThirdwebClient } from "../../../../../client/client.js";
|
||||
import { toTokens } from "../../../../../utils/units.js";
|
||||
import { useCustomTheme } from "../../../../core/design-system/CustomThemeProvider.js";
|
||||
import {
|
||||
fontSize,
|
||||
iconSize,
|
||||
@@ -12,15 +12,15 @@ import {
|
||||
} from "../../../../core/design-system/index.js";
|
||||
import { CoinsIcon } from "../../ConnectWallet/icons/CoinsIcon.js";
|
||||
import { WalletDotIcon } from "../../ConnectWallet/icons/WalletDotIcon.js";
|
||||
import { formatTokenAmount } from "../../ConnectWallet/screens/formatTokenBalance.js";
|
||||
import { Container, Line, ModalHeader } from "../../components/basic.js";
|
||||
import { Container, noScrollBar } from "../../components/basic.js";
|
||||
import { Button } from "../../components/buttons.js";
|
||||
import { Img } from "../../components/Img.js";
|
||||
import { Skeleton } from "../../components/Skeleton.js";
|
||||
import { Spacer } from "../../components/Spacer.js";
|
||||
import { Spinner } from "../../components/Spinner.js";
|
||||
import { Text } from "../../components/text.js";
|
||||
import { DecimalRenderer } from "./common.js";
|
||||
import { StyledDiv } from "../../design-system/elements.js";
|
||||
import { useIsMobile } from "../../hooks/useisMobile.js";
|
||||
import { SearchInput } from "./SearchInput.js";
|
||||
import { SelectChainButton } from "./SelectChainButton.js";
|
||||
import { SelectBridgeChain } from "./select-chain.js";
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
useTokenBalances,
|
||||
useTokens,
|
||||
} from "./use-tokens.js";
|
||||
import { tokenAmountFormatter } from "./utils.js";
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@@ -138,6 +139,7 @@ function SelectTokenUI(
|
||||
showMore: (() => void) | undefined;
|
||||
},
|
||||
) {
|
||||
const isMobile = useIsMobile();
|
||||
const [screen, setScreen] = useState<"select-chain" | "select-token">(
|
||||
"select-token",
|
||||
);
|
||||
@@ -180,184 +182,70 @@ function SelectTokenUI(
|
||||
});
|
||||
}, [otherTokens]);
|
||||
|
||||
const noTokensFound =
|
||||
!props.isFetching &&
|
||||
sortedOtherTokens.length === 0 &&
|
||||
props.ownedTokens.length === 0;
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<Container
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "300px 1fr",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<LeftContainer>
|
||||
<SelectBridgeChain
|
||||
onBack={() => setScreen("select-token")}
|
||||
client={props.client}
|
||||
isMobile={false}
|
||||
onSelectChain={(chain) => {
|
||||
props.setSelectedChain(chain);
|
||||
setScreen("select-token");
|
||||
}}
|
||||
selectedChain={props.selectedChain}
|
||||
/>
|
||||
</LeftContainer>
|
||||
<Container flex="column" relative scrollY>
|
||||
<TokenSelectionScreen
|
||||
onSelectToken={props.setSelectedToken}
|
||||
isMobile={false}
|
||||
selectedToken={props.selectedToken}
|
||||
isFetching={props.isFetching}
|
||||
ownedTokens={props.ownedTokens}
|
||||
otherTokens={sortedOtherTokens}
|
||||
showMore={props.showMore}
|
||||
selectedChain={props.selectedChain}
|
||||
onSelectChain={() => setScreen("select-chain")}
|
||||
client={props.client}
|
||||
search={props.search}
|
||||
setSearch={props.setSearch}
|
||||
/>
|
||||
</Container>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (screen === "select-token") {
|
||||
return (
|
||||
<Container>
|
||||
<Container px="md" py="md+">
|
||||
<ModalHeader onBack={props.onBack} title="Select Token" />
|
||||
</Container>
|
||||
<Line />
|
||||
|
||||
{!props.selectedChain && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "400px",
|
||||
}}
|
||||
>
|
||||
<Spinner color="secondaryText" size="xl" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.selectedChain && (
|
||||
<>
|
||||
<Container p="md">
|
||||
<SelectChainButton
|
||||
onClick={() => setScreen("select-chain")}
|
||||
selectedChain={props.selectedChain}
|
||||
client={props.client}
|
||||
/>
|
||||
</Container>
|
||||
|
||||
{/* search */}
|
||||
<Container px="md">
|
||||
<SearchInput
|
||||
value={props.search}
|
||||
onChange={props.setSearch}
|
||||
placeholder="Search by Token or Address"
|
||||
/>
|
||||
</Container>
|
||||
|
||||
<Spacer y="sm" />
|
||||
<Container px="md">
|
||||
<Container
|
||||
flex="column"
|
||||
style={{
|
||||
height: "400px",
|
||||
overflowY: "auto",
|
||||
scrollbarWidth: "none",
|
||||
paddingBottom: spacing.md,
|
||||
}}
|
||||
>
|
||||
{props.isFetching &&
|
||||
new Array(20).fill(0).map((_, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: ok
|
||||
<TokenButtonSkeleton key={i} />
|
||||
))}
|
||||
|
||||
{!props.isFetching && sortedOwnedTokens.length > 0 && (
|
||||
<Container
|
||||
px="xs"
|
||||
py="xs"
|
||||
flex="row"
|
||||
gap="xs"
|
||||
center="y"
|
||||
color="secondaryText"
|
||||
>
|
||||
<WalletDotIcon size={iconSize.sm} />
|
||||
<Text
|
||||
size="sm"
|
||||
color="secondaryText"
|
||||
weight={500}
|
||||
style={{
|
||||
overflow: "unset",
|
||||
}}
|
||||
>
|
||||
Your Tokens
|
||||
</Text>
|
||||
</Container>
|
||||
)}
|
||||
|
||||
{!props.isFetching &&
|
||||
sortedOwnedTokens.map((token) => (
|
||||
<TokenButton
|
||||
key={token.token_address}
|
||||
token={token}
|
||||
client={props.client}
|
||||
onSelect={props.setSelectedToken}
|
||||
isSelected={
|
||||
!!props.selectedToken &&
|
||||
props.selectedToken.tokenAddress.toLowerCase() ===
|
||||
token.token_address.toLowerCase() &&
|
||||
token.chain_id === props.selectedToken.chainId
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!props.isFetching && sortedOwnedTokens.length > 0 && (
|
||||
<Container
|
||||
px="xs"
|
||||
py="xs"
|
||||
flex="row"
|
||||
gap="xs"
|
||||
center="y"
|
||||
color="secondaryText"
|
||||
style={{
|
||||
marginTop: spacing.sm,
|
||||
}}
|
||||
>
|
||||
<CoinsIcon size={iconSize.sm} />
|
||||
<Text
|
||||
size="sm"
|
||||
color="secondaryText"
|
||||
weight={500}
|
||||
style={{
|
||||
overflow: "unset",
|
||||
}}
|
||||
>
|
||||
Other Tokens
|
||||
</Text>
|
||||
</Container>
|
||||
)}
|
||||
|
||||
{!props.isFetching &&
|
||||
sortedOtherTokens.map((token) => (
|
||||
<TokenButton
|
||||
key={token.address}
|
||||
token={token}
|
||||
client={props.client}
|
||||
onSelect={props.setSelectedToken}
|
||||
isSelected={
|
||||
props.selectedToken?.tokenAddress.toLowerCase() ===
|
||||
token.address.toLowerCase()
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{props.showMore && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
props.showMore?.();
|
||||
}}
|
||||
>
|
||||
Load More
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{noTokensFound && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text size="md" color="secondaryText">
|
||||
No Tokens Found
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</Container>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
<TokenSelectionScreen
|
||||
onSelectToken={props.setSelectedToken}
|
||||
selectedToken={props.selectedToken}
|
||||
isFetching={props.isFetching}
|
||||
ownedTokens={props.ownedTokens}
|
||||
otherTokens={sortedOtherTokens}
|
||||
showMore={props.showMore}
|
||||
selectedChain={props.selectedChain}
|
||||
isMobile={true}
|
||||
onSelectChain={() => setScreen("select-chain")}
|
||||
client={props.client}
|
||||
search={props.search}
|
||||
setSearch={props.setSearch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (screen === "select-chain") {
|
||||
return (
|
||||
<SelectBridgeChain
|
||||
isMobile={true}
|
||||
onBack={() => setScreen("select-token")}
|
||||
client={props.client}
|
||||
onSelectChain={(chain) => {
|
||||
@@ -379,7 +267,7 @@ function TokenButtonSkeleton() {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
padding: `${spacing.sm} ${spacing.sm}`,
|
||||
padding: `${spacing.xs} ${spacing.xs}`,
|
||||
height: "70px",
|
||||
}}
|
||||
>
|
||||
@@ -398,6 +286,7 @@ function TokenButton(props: {
|
||||
onSelect: (tokenWithPrices: TokenSelection) => void;
|
||||
isSelected: boolean;
|
||||
}) {
|
||||
const theme = useCustomTheme();
|
||||
const tokenBalanceInUnits =
|
||||
"balance" in props.token
|
||||
? toTokens(BigInt(props.token.balance), props.token.decimals)
|
||||
@@ -416,7 +305,7 @@ function TokenButton(props: {
|
||||
fontWeight: 500,
|
||||
fontSize: fontSize.md,
|
||||
border: "1px solid transparent",
|
||||
padding: `${spacing.sm} ${spacing.xs}`,
|
||||
padding: `${spacing.xs} ${spacing.xs}`,
|
||||
textAlign: "left",
|
||||
lineHeight: "1.5",
|
||||
borderRadius: radius.lg,
|
||||
@@ -451,7 +340,14 @@ function TokenButton(props: {
|
||||
}}
|
||||
fallback={
|
||||
<Container color="secondaryText">
|
||||
<DiscIcon width={iconSize.lg} height={iconSize.lg} />
|
||||
<Container
|
||||
style={{
|
||||
background: `linear-gradient(45deg, white, ${theme.colors.accentText})`,
|
||||
borderRadius: radius.full,
|
||||
width: `${iconSize.lg}px`,
|
||||
height: `${iconSize.lg}px`,
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
}
|
||||
/>
|
||||
@@ -473,18 +369,15 @@ function TokenButton(props: {
|
||||
<Text size="md" color="primaryText" weight={500}>
|
||||
{props.token.symbol}
|
||||
</Text>
|
||||
|
||||
{"balance" in props.token && (
|
||||
<DecimalRenderer
|
||||
integerSize="md"
|
||||
fractionSize="sm"
|
||||
value={formatTokenAmount(
|
||||
BigInt(props.token.balance),
|
||||
props.token.decimals,
|
||||
3,
|
||||
<Text size="md" color="primaryText">
|
||||
{tokenAmountFormatter.format(
|
||||
Number(
|
||||
toTokens(BigInt(props.token.balance), props.token.decimals),
|
||||
),
|
||||
)}
|
||||
color="primaryText"
|
||||
weight={500}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</Container>
|
||||
<Container
|
||||
@@ -495,9 +388,8 @@ function TokenButton(props: {
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
size="xs"
|
||||
color="secondaryText"
|
||||
weight={400}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
@@ -509,16 +401,9 @@ function TokenButton(props: {
|
||||
</Text>
|
||||
{usdValue && (
|
||||
<Container flex="row">
|
||||
<Text size="sm" color="secondaryText" weight={400}>
|
||||
$
|
||||
<Text size="xs" color="secondaryText" weight={400}>
|
||||
${usdValue.toFixed(2)}
|
||||
</Text>
|
||||
<DecimalRenderer
|
||||
value={usdValue.toFixed(2)}
|
||||
color="secondaryText"
|
||||
weight={500}
|
||||
integerSize="sm"
|
||||
fractionSize="xs"
|
||||
/>
|
||||
</Container>
|
||||
)}
|
||||
</Container>
|
||||
@@ -526,3 +411,223 @@ function TokenButton(props: {
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenSelectionScreen(props: {
|
||||
selectedChain: BridgeChain | undefined;
|
||||
isMobile: boolean;
|
||||
onSelectChain: () => void;
|
||||
client: ThirdwebClient;
|
||||
search: string;
|
||||
setSearch: (search: string) => void;
|
||||
isFetching: boolean;
|
||||
ownedTokens: TokenBalance[];
|
||||
otherTokens: Token[];
|
||||
showMore: (() => void) | undefined;
|
||||
selectedToken: TokenSelection | undefined;
|
||||
onSelectToken: (token: TokenSelection) => void;
|
||||
}) {
|
||||
const noTokensFound =
|
||||
!props.isFetching &&
|
||||
props.otherTokens.length === 0 &&
|
||||
props.ownedTokens.length === 0;
|
||||
|
||||
return (
|
||||
<Container fullHeight flex="column">
|
||||
<Container px="md" pt="md+">
|
||||
<Text size="lg" weight={600} color="primaryText" trackingTight>
|
||||
Select Token
|
||||
</Text>
|
||||
<Spacer y="3xs" />
|
||||
<Text
|
||||
size="xs"
|
||||
color="secondaryText"
|
||||
multiline
|
||||
style={{
|
||||
textWrap: "pretty",
|
||||
maxWidth: "70%",
|
||||
}}
|
||||
>
|
||||
Select a token from the list or search for a token by symbol or
|
||||
address
|
||||
</Text>
|
||||
</Container>
|
||||
|
||||
{!props.selectedChain && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "400px",
|
||||
}}
|
||||
>
|
||||
<Spinner color="secondaryText" size="xl" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.selectedChain && (
|
||||
<>
|
||||
{props.isMobile ? (
|
||||
<Container p="md">
|
||||
<SelectChainButton
|
||||
onClick={props.onSelectChain}
|
||||
selectedChain={props.selectedChain}
|
||||
client={props.client}
|
||||
/>
|
||||
</Container>
|
||||
) : (
|
||||
<Spacer y="md" />
|
||||
)}
|
||||
|
||||
{/* search */}
|
||||
<Container px="md">
|
||||
<SearchInput
|
||||
value={props.search}
|
||||
onChange={props.setSearch}
|
||||
placeholder="Search by token or address"
|
||||
/>
|
||||
</Container>
|
||||
|
||||
<Spacer y="xs" />
|
||||
|
||||
<Container
|
||||
pb="md"
|
||||
px="md"
|
||||
expand
|
||||
gap="xxs"
|
||||
flex="column"
|
||||
style={{
|
||||
minHeight: "400px",
|
||||
maxHeight: props.isMobile ? "450px" : "none",
|
||||
overflowY: "auto",
|
||||
scrollbarWidth: "none",
|
||||
paddingBottom: spacing.md,
|
||||
}}
|
||||
>
|
||||
{props.isFetching &&
|
||||
new Array(20).fill(0).map((_, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: ok
|
||||
<TokenButtonSkeleton key={i} />
|
||||
))}
|
||||
|
||||
{!props.isFetching && props.ownedTokens.length > 0 && (
|
||||
<Container
|
||||
px="xs"
|
||||
py="xs"
|
||||
flex="row"
|
||||
gap="xs"
|
||||
center="y"
|
||||
color="secondaryText"
|
||||
>
|
||||
<WalletDotIcon size={iconSize.xs} />
|
||||
<Text
|
||||
size="sm"
|
||||
color="secondaryText"
|
||||
style={{
|
||||
overflow: "unset",
|
||||
}}
|
||||
>
|
||||
Your Tokens
|
||||
</Text>
|
||||
</Container>
|
||||
)}
|
||||
|
||||
{!props.isFetching &&
|
||||
props.ownedTokens.map((token) => (
|
||||
<TokenButton
|
||||
key={token.token_address}
|
||||
token={token}
|
||||
client={props.client}
|
||||
onSelect={props.onSelectToken}
|
||||
isSelected={
|
||||
!!props.selectedToken &&
|
||||
props.selectedToken.tokenAddress.toLowerCase() ===
|
||||
token.token_address.toLowerCase() &&
|
||||
token.chain_id === props.selectedToken.chainId
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!props.isFetching && props.ownedTokens.length > 0 && (
|
||||
<Container
|
||||
px="xs"
|
||||
py="xs"
|
||||
flex="row"
|
||||
gap="xs"
|
||||
center="y"
|
||||
color="secondaryText"
|
||||
style={{
|
||||
marginTop: spacing.sm,
|
||||
}}
|
||||
>
|
||||
<CoinsIcon size={iconSize.xs} />
|
||||
<Text
|
||||
size="sm"
|
||||
color="secondaryText"
|
||||
style={{
|
||||
overflow: "unset",
|
||||
}}
|
||||
>
|
||||
Other Tokens
|
||||
</Text>
|
||||
</Container>
|
||||
)}
|
||||
|
||||
{!props.isFetching &&
|
||||
props.otherTokens.map((token) => (
|
||||
<TokenButton
|
||||
key={token.address}
|
||||
token={token}
|
||||
client={props.client}
|
||||
onSelect={props.onSelectToken}
|
||||
isSelected={
|
||||
props.selectedToken?.tokenAddress.toLowerCase() ===
|
||||
token.address.toLowerCase()
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{props.showMore && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
props.showMore?.();
|
||||
}}
|
||||
>
|
||||
Load More
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{noTokensFound && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text size="md" color="secondaryText">
|
||||
No Tokens Found
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const LeftContainer = /* @__PURE__ */ StyledDiv((_) => {
|
||||
const theme = useCustomTheme();
|
||||
return {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflowY: "auto",
|
||||
...noScrollBar,
|
||||
borderRight: `1px solid ${theme.colors.separatorLine}`,
|
||||
position: "relative",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import styled from "@emotion/styled";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
DiscIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { ChevronDownIcon } from "@radix-ui/react-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import type { prepare as BuyPrepare } from "../../../../../bridge/Buy.js";
|
||||
@@ -15,12 +11,10 @@ import { defineChain } from "../../../../../chains/utils.js";
|
||||
import type { ThirdwebClient } from "../../../../../client/client.js";
|
||||
import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js";
|
||||
import { getToken } from "../../../../../pay/convert/get-token.js";
|
||||
import {
|
||||
getFiatSymbol,
|
||||
type SupportedFiatCurrency,
|
||||
} from "../../../../../pay/convert/type.js";
|
||||
import { getAddress } from "../../../../../utils/address.js";
|
||||
import type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js";
|
||||
import { getAddress, shortenAddress } from "../../../../../utils/address.js";
|
||||
import { toTokens, toUnits } from "../../../../../utils/units.js";
|
||||
import { AccountProvider } from "../../../../core/account/provider.js";
|
||||
import { useCustomTheme } from "../../../../core/design-system/CustomThemeProvider.js";
|
||||
import {
|
||||
fontSize,
|
||||
@@ -31,11 +25,16 @@ import {
|
||||
} from "../../../../core/design-system/index.js";
|
||||
import { useWalletBalance } from "../../../../core/hooks/others/useWalletBalance.js";
|
||||
import type { BridgePrepareRequest } from "../../../../core/hooks/useBridgePrepare.js";
|
||||
import { WalletProvider } from "../../../../core/wallet/provider.js";
|
||||
import { ConnectButton } from "../../ConnectWallet/ConnectButton.js";
|
||||
import { DetailsModal } from "../../ConnectWallet/Details.js";
|
||||
import { ArrowUpDownIcon } from "../../ConnectWallet/icons/ArrowUpDownIcon.js";
|
||||
import { WalletDotIcon } from "../../ConnectWallet/icons/WalletDotIcon.js";
|
||||
import connectLocaleEn from "../../ConnectWallet/locale/en.js";
|
||||
import { PoweredByThirdweb } from "../../ConnectWallet/PoweredByTW.js";
|
||||
import { formatTokenAmount } from "../../ConnectWallet/screens/formatTokenBalance.js";
|
||||
import {
|
||||
formatCurrencyAmount,
|
||||
formatTokenAmount,
|
||||
} from "../../ConnectWallet/screens/formatTokenBalance.js";
|
||||
import { Container } from "../../components/basic.js";
|
||||
import { Button } from "../../components/buttons.js";
|
||||
import { Input } from "../../components/formElements.js";
|
||||
@@ -43,8 +42,13 @@ import { Img } from "../../components/Img.js";
|
||||
import { Modal } from "../../components/Modal.js";
|
||||
import { Skeleton } from "../../components/Skeleton.js";
|
||||
import { Spacer } from "../../components/Spacer.js";
|
||||
import { Spinner } from "../../components/Spinner.js";
|
||||
import { Text } from "../../components/text.js";
|
||||
import { DecimalRenderer } from "./common.js";
|
||||
import { useIsMobile } from "../../hooks/useisMobile.js";
|
||||
import { AccountAvatar } from "../../prebuilt/Account/avatar.js";
|
||||
import { AccountBlobbie } from "../../prebuilt/Account/blobbie.js";
|
||||
import { AccountName } from "../../prebuilt/Account/name.js";
|
||||
import { WalletIcon } from "../../prebuilt/Wallet/icon.js";
|
||||
import { SelectToken } from "./select-token-ui.js";
|
||||
import type {
|
||||
ActiveWalletInfo,
|
||||
@@ -82,6 +86,7 @@ type SwapUIProps = {
|
||||
type: "buy" | "sell";
|
||||
amount: string;
|
||||
}) => void;
|
||||
onDisconnect: (() => void) | undefined;
|
||||
};
|
||||
|
||||
function useTokenPrice(options: {
|
||||
@@ -110,9 +115,16 @@ function useTokenPrice(options: {
|
||||
* @internal
|
||||
*/
|
||||
export function SwapUI(props: SwapUIProps) {
|
||||
const [modalState, setModalState] = useState<
|
||||
"select-buy-token" | "select-sell-token" | undefined
|
||||
>(undefined);
|
||||
const [modalState, setModalState] = useState<{
|
||||
screen: "select-buy-token" | "select-sell-token";
|
||||
isOpen: boolean;
|
||||
}>({
|
||||
screen: "select-buy-token",
|
||||
isOpen: false,
|
||||
});
|
||||
|
||||
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Token Prices ----------------------------------------------------------------------------
|
||||
const buyTokenQuery = useTokenPrice({
|
||||
@@ -193,14 +205,19 @@ export function SwapUI(props: SwapUIProps) {
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
const disableContinue =
|
||||
!preparedResultQuery.data ||
|
||||
preparedResultQuery.isFetching ||
|
||||
notEnoughBalance;
|
||||
|
||||
return (
|
||||
<Container p="md">
|
||||
<Modal
|
||||
hide={false}
|
||||
className="tw-modal__swap-widget"
|
||||
size="compact"
|
||||
size={isMobile ? "compact" : "wide"}
|
||||
title="Select Token"
|
||||
open={!!modalState}
|
||||
open={modalState.isOpen}
|
||||
crossContainerStyles={{
|
||||
right: spacing.md,
|
||||
top: spacing["md+"],
|
||||
@@ -208,19 +225,30 @@ export function SwapUI(props: SwapUIProps) {
|
||||
}}
|
||||
setOpen={(v) => {
|
||||
if (!v) {
|
||||
setModalState(undefined);
|
||||
setModalState((v) => ({
|
||||
...v,
|
||||
isOpen: false,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{modalState === "select-buy-token" && (
|
||||
{modalState.screen === "select-buy-token" && (
|
||||
<SelectToken
|
||||
activeWalletInfo={props.activeWalletInfo}
|
||||
onBack={() => setModalState(undefined)}
|
||||
onBack={() =>
|
||||
setModalState((v) => ({
|
||||
...v,
|
||||
isOpen: false,
|
||||
}))
|
||||
}
|
||||
client={props.client}
|
||||
selectedToken={props.buyToken}
|
||||
setSelectedToken={(token) => {
|
||||
props.setBuyToken(token);
|
||||
setModalState(undefined);
|
||||
setModalState((v) => ({
|
||||
...v,
|
||||
isOpen: false,
|
||||
}));
|
||||
// if buy token is same as sell token, unset sell token
|
||||
if (
|
||||
props.sellToken &&
|
||||
@@ -234,14 +262,22 @@ export function SwapUI(props: SwapUIProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalState === "select-sell-token" && (
|
||||
{modalState.screen === "select-sell-token" && (
|
||||
<SelectToken
|
||||
onBack={() => setModalState(undefined)}
|
||||
onBack={() =>
|
||||
setModalState((v) => ({
|
||||
...v,
|
||||
isOpen: false,
|
||||
}))
|
||||
}
|
||||
client={props.client}
|
||||
selectedToken={props.sellToken}
|
||||
setSelectedToken={(token) => {
|
||||
props.setSellToken(token);
|
||||
setModalState(undefined);
|
||||
setModalState((v) => ({
|
||||
...v,
|
||||
isOpen: false,
|
||||
}));
|
||||
// if sell token is same as buy token, unset buy token
|
||||
if (
|
||||
props.buyToken &&
|
||||
@@ -257,10 +293,35 @@ export function SwapUI(props: SwapUIProps) {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{detailsModalOpen && (
|
||||
<DetailsModal
|
||||
client={props.client}
|
||||
locale={connectLocaleEn}
|
||||
detailsModal={undefined}
|
||||
theme={props.theme}
|
||||
closeModal={() => {
|
||||
setDetailsModalOpen(false);
|
||||
}}
|
||||
onDisconnect={() => {
|
||||
props.onDisconnect?.();
|
||||
}}
|
||||
chains={[]}
|
||||
connectOptions={props.connectOptions}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sell */}
|
||||
<TokenSection
|
||||
onMaxClick={() => {
|
||||
if (sellTokenBalanceQuery.data) {
|
||||
props.setAmountSelection({
|
||||
type: "sell",
|
||||
amount: sellTokenBalanceQuery.data.displayValue,
|
||||
});
|
||||
}
|
||||
}}
|
||||
activeWalletInfo={props.activeWalletInfo}
|
||||
isConnected={!!props.activeWalletInfo}
|
||||
notEnoughBalance={notEnoughBalance}
|
||||
balance={{
|
||||
data: sellTokenBalanceQuery.data?.value,
|
||||
isFetching: sellTokenBalanceQuery.isFetching,
|
||||
@@ -269,7 +330,7 @@ export function SwapUI(props: SwapUIProps) {
|
||||
data: sellTokenAmount,
|
||||
isFetching: isSellAmountFetching,
|
||||
}}
|
||||
label="Sell"
|
||||
type="sell"
|
||||
setAmount={(value) => {
|
||||
props.setAmountSelection({ type: "sell", amount: value });
|
||||
}}
|
||||
@@ -283,7 +344,15 @@ export function SwapUI(props: SwapUIProps) {
|
||||
}
|
||||
client={props.client}
|
||||
currency={props.currency}
|
||||
onSelectToken={() => setModalState("select-sell-token")}
|
||||
onSelectToken={() =>
|
||||
setModalState({
|
||||
screen: "select-sell-token",
|
||||
isOpen: true,
|
||||
})
|
||||
}
|
||||
onWalletClick={() => {
|
||||
setDetailsModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Switch */}
|
||||
@@ -302,8 +371,12 @@ export function SwapUI(props: SwapUIProps) {
|
||||
|
||||
{/* Buy */}
|
||||
<TokenSection
|
||||
onMaxClick={undefined}
|
||||
onWalletClick={() => {
|
||||
setDetailsModalOpen(true);
|
||||
}}
|
||||
activeWalletInfo={props.activeWalletInfo}
|
||||
isConnected={!!props.activeWalletInfo}
|
||||
notEnoughBalance={false}
|
||||
balance={{
|
||||
data: buyTokenBalanceQuery.data?.value,
|
||||
isFetching: buyTokenBalanceQuery.isFetching,
|
||||
@@ -312,7 +385,7 @@ export function SwapUI(props: SwapUIProps) {
|
||||
data: buyTokenAmount,
|
||||
isFetching: isBuyAmountFetching,
|
||||
}}
|
||||
label="Buy"
|
||||
type="buy"
|
||||
selectedToken={
|
||||
props.buyToken
|
||||
? {
|
||||
@@ -326,7 +399,12 @@ export function SwapUI(props: SwapUIProps) {
|
||||
}}
|
||||
client={props.client}
|
||||
currency={props.currency}
|
||||
onSelectToken={() => setModalState("select-buy-token")}
|
||||
onSelectToken={() =>
|
||||
setModalState({
|
||||
screen: "select-buy-token",
|
||||
isOpen: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{/* error message */}
|
||||
@@ -342,7 +420,7 @@ export function SwapUI(props: SwapUIProps) {
|
||||
Failed to get a quote
|
||||
</Text>
|
||||
) : (
|
||||
<Spacer y="lg" />
|
||||
<Spacer y="md" />
|
||||
)}
|
||||
|
||||
{/* Button */}
|
||||
@@ -361,11 +439,7 @@ export function SwapUI(props: SwapUIProps) {
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
disabled={
|
||||
!preparedResultQuery.data ||
|
||||
preparedResultQuery.isFetching ||
|
||||
notEnoughBalance
|
||||
}
|
||||
disabled={disableContinue}
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
if (
|
||||
@@ -385,14 +459,20 @@ export function SwapUI(props: SwapUIProps) {
|
||||
});
|
||||
}
|
||||
}}
|
||||
gap="xs"
|
||||
style={{
|
||||
fontSize: fontSize.md,
|
||||
padding: `${spacing.sm} ${spacing.md}`,
|
||||
borderRadius: radius.lg,
|
||||
borderRadius: radius.full,
|
||||
opacity: disableContinue ? 0.5 : 1,
|
||||
}}
|
||||
variant="primary"
|
||||
>
|
||||
Swap
|
||||
{preparedResultQuery.isFetching && <Spinner size="sm" />}
|
||||
{preparedResultQuery.isFetching
|
||||
? "Fetching Quote"
|
||||
: notEnoughBalance
|
||||
? "Insufficient Balance"
|
||||
: "Swap"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -599,10 +679,12 @@ function DecimalInput(props: {
|
||||
style={{
|
||||
border: "none",
|
||||
boxShadow: "none",
|
||||
fontSize: fontSize.xxl,
|
||||
fontSize: fontSize.xl,
|
||||
fontWeight: 500,
|
||||
paddingInline: 0,
|
||||
paddingBlock: 0,
|
||||
letterSpacing: "-0.025em",
|
||||
height: "30px",
|
||||
}}
|
||||
type="text"
|
||||
value={props.value}
|
||||
@@ -612,13 +694,13 @@ function DecimalInput(props: {
|
||||
}
|
||||
|
||||
function TokenSection(props: {
|
||||
label: string;
|
||||
notEnoughBalance: boolean;
|
||||
type: "buy" | "sell";
|
||||
amount: {
|
||||
data: string;
|
||||
isFetching: boolean;
|
||||
};
|
||||
setAmount: (amount: string) => void;
|
||||
activeWalletInfo: ActiveWalletInfo | undefined;
|
||||
selectedToken:
|
||||
| {
|
||||
data: TokenWithPrices | undefined;
|
||||
@@ -633,7 +715,10 @@ function TokenSection(props: {
|
||||
data: bigint | undefined;
|
||||
isFetching: boolean;
|
||||
};
|
||||
onWalletClick: () => void;
|
||||
onMaxClick: (() => void) | undefined;
|
||||
}) {
|
||||
const theme = useCustomTheme();
|
||||
const chainQuery = useBridgeChains(props.client);
|
||||
const chain = chainQuery.data?.find(
|
||||
(chain) => chain.chainId === props.selectedToken?.data?.chainId,
|
||||
@@ -648,140 +733,191 @@ function TokenSection(props: {
|
||||
|
||||
return (
|
||||
<Container
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: radius.xl,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
borderColor="borderColor"
|
||||
bg="tertiaryBg"
|
||||
style={{ borderRadius: radius.lg, borderWidth: 1, borderStyle: "solid" }}
|
||||
>
|
||||
{/* row1 : label */}
|
||||
<Text size="md" color="primaryText" weight={500}>
|
||||
{props.label}
|
||||
</Text>
|
||||
|
||||
{/* row2 : amount and select token */}
|
||||
<div
|
||||
{/* make the background semi-transparent */}
|
||||
<Container
|
||||
bg="tertiaryBg"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.sm,
|
||||
paddingBottom: spacing.sm,
|
||||
paddingTop: spacing.sm,
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
opacity: 0.5,
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Container
|
||||
style={{
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{props.amount.isFetching ? (
|
||||
<Skeleton
|
||||
height={fontSize.xxl}
|
||||
width="140px"
|
||||
style={{
|
||||
borderRadius: radius.lg,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DecimalInput value={props.amount.data} setValue={props.setAmount} />
|
||||
)}
|
||||
{/* row1 : label */}
|
||||
<Container
|
||||
px="md"
|
||||
py="sm"
|
||||
relative
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Container flex="row" center="y" gap="3xs" color="secondaryText">
|
||||
<Text
|
||||
size="xs"
|
||||
color="primaryText"
|
||||
style={{
|
||||
letterSpacing: "0.07em",
|
||||
}}
|
||||
>
|
||||
{props.type === "buy" ? "BUY" : "SELL"}
|
||||
</Text>
|
||||
</Container>
|
||||
{props.activeWalletInfo && (
|
||||
<WalletButton
|
||||
variant="ghost-solid"
|
||||
style={{
|
||||
paddingInline: spacing.xxs,
|
||||
paddingBlock: "2px",
|
||||
}}
|
||||
onClick={props.onWalletClick}
|
||||
>
|
||||
<ActiveWalletDetails
|
||||
activeWalletInfo={props.activeWalletInfo}
|
||||
client={props.client}
|
||||
/>
|
||||
</WalletButton>
|
||||
)}
|
||||
</Container>
|
||||
|
||||
{!props.selectedToken ? (
|
||||
<Button
|
||||
variant="accent"
|
||||
style={{
|
||||
borderRadius: radius.full,
|
||||
gap: spacing.xxs,
|
||||
paddingBlock: spacing.sm,
|
||||
paddingInline: spacing.sm,
|
||||
}}
|
||||
onClick={props.onSelectToken}
|
||||
>
|
||||
{" "}
|
||||
Select Token
|
||||
<ChevronDownIcon width={iconSize.sm} height={iconSize.sm} />
|
||||
</Button>
|
||||
) : (
|
||||
<Container
|
||||
bg="tertiaryBg"
|
||||
style={{
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
borderRadius: radius.xl,
|
||||
borderTop: `1px solid ${theme.colors.borderColor}`,
|
||||
}}
|
||||
>
|
||||
<SelectedTokenButton
|
||||
selectedToken={props.selectedToken}
|
||||
client={props.client}
|
||||
onSelectToken={props.onSelectToken}
|
||||
chain={chain}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* row3 : fiat value/error and balance */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
{/* Exceeds Balance / Fiat Value */}
|
||||
{props.notEnoughBalance ? (
|
||||
<Text size="md" color="danger" weight={500}>
|
||||
{" "}
|
||||
Exceeds Balance{" "}
|
||||
</Text>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
}}
|
||||
>
|
||||
<Text size="md" color="secondaryText" weight={500}>
|
||||
{getFiatSymbol(props.currency)}
|
||||
</Text>
|
||||
{props.amount.isFetching ? (
|
||||
<Skeleton height={fontSize.md} width="50px" />
|
||||
) : (
|
||||
<div>
|
||||
<DecimalRenderer
|
||||
integerSize="md"
|
||||
fractionSize="sm"
|
||||
value={totalFiatValue?.toFixed(5) || "0.00"}
|
||||
color="secondaryText"
|
||||
weight={500}
|
||||
<Container px="md" py="md">
|
||||
<Container
|
||||
flex="row"
|
||||
gap="xs"
|
||||
center="y"
|
||||
style={{
|
||||
flexWrap: "nowrap",
|
||||
}}
|
||||
>
|
||||
{props.amount.isFetching ? (
|
||||
<div style={{ flexGrow: 1 }}>
|
||||
<Skeleton
|
||||
height="30px"
|
||||
width="140px"
|
||||
style={{
|
||||
borderRadius: radius.lg,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DecimalInput
|
||||
value={props.amount.data}
|
||||
setValue={props.setAmount}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Balance */}
|
||||
{props.isConnected && props.selectedToken && (
|
||||
<div>
|
||||
{props.balance.data === undefined ||
|
||||
props.selectedToken.data === undefined ? (
|
||||
<Skeleton height={fontSize.md} width="50px" />
|
||||
) : (
|
||||
<Text
|
||||
size="md"
|
||||
color="secondaryText"
|
||||
weight={500}
|
||||
{props.activeWalletInfo &&
|
||||
props.onMaxClick &&
|
||||
props.selectedToken && (
|
||||
<Button
|
||||
variant="outline"
|
||||
style={{
|
||||
paddingInline: spacing.xs,
|
||||
paddingBlock: spacing.xxs,
|
||||
borderRadius: radius.full,
|
||||
fontSize: fontSize.xs,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
onClick={props.onMaxClick}
|
||||
>
|
||||
Max
|
||||
</Button>
|
||||
)}
|
||||
</Container>
|
||||
|
||||
<Spacer y="sm" />
|
||||
|
||||
{/* row3 : fiat value and balance */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: spacing.xxs,
|
||||
gap: "3px",
|
||||
}}
|
||||
>
|
||||
<WalletDotIcon size={fontSize.xs} />
|
||||
<DecimalRenderer
|
||||
integerSize="md"
|
||||
fractionSize="sm"
|
||||
color="secondaryText"
|
||||
weight={500}
|
||||
value={formatTokenAmount(
|
||||
props.balance.data,
|
||||
props.selectedToken.data.decimals,
|
||||
5,
|
||||
{props.amount.isFetching ? (
|
||||
<Skeleton height={fontSize.xs} width="50px" />
|
||||
) : (
|
||||
<Text size="xs" color="secondaryText">
|
||||
{formatCurrencyAmount(props.currency, totalFiatValue || 0)}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Balance */}
|
||||
{props.isConnected && props.selectedToken && (
|
||||
<div>
|
||||
{props.balance.data === undefined ||
|
||||
props.selectedToken.data === undefined ? (
|
||||
<Skeleton height={fontSize.xs} width="100px" />
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "3px",
|
||||
}}
|
||||
>
|
||||
<Text size="xs" color="secondaryText">
|
||||
Balance:
|
||||
</Text>
|
||||
<Text size="xs" color="primaryText">
|
||||
{formatTokenAmount(
|
||||
props.balance.data,
|
||||
props.selectedToken.data.decimals,
|
||||
5,
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</Container>
|
||||
</Container>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -797,92 +933,141 @@ function SelectedTokenButton(props: {
|
||||
onSelectToken: () => void;
|
||||
chain: BridgeChain | undefined;
|
||||
}) {
|
||||
const theme = useCustomTheme();
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
bg="secondaryButtonBg"
|
||||
variant="ghost-solid"
|
||||
hoverBg="secondaryButtonBg"
|
||||
fullWidth
|
||||
onClick={props.onSelectToken}
|
||||
gap="xs"
|
||||
gap="sm"
|
||||
style={{
|
||||
borderRadius: radius.full,
|
||||
paddingBlock: spacing.xxs,
|
||||
paddingInline: spacing.xs,
|
||||
borderBottom: `1px dashed ${theme.colors.borderColor}`,
|
||||
justifyContent: "space-between",
|
||||
paddingInline: spacing.md,
|
||||
paddingBlock: spacing.md,
|
||||
borderRadius: 0,
|
||||
}}
|
||||
>
|
||||
{/* icons */}
|
||||
<Container relative color="secondaryText">
|
||||
{/* token icon */}
|
||||
<Img
|
||||
key={props.selectedToken?.data?.iconUri}
|
||||
src={
|
||||
props.selectedToken?.data === undefined
|
||||
? undefined
|
||||
: props.selectedToken.data.iconUri || ""
|
||||
}
|
||||
client={props.client}
|
||||
width={iconSize.lg}
|
||||
height={iconSize.lg}
|
||||
skeletonColor="modalBg"
|
||||
fallback={<DiscIcon width={iconSize.lg} height={iconSize.lg} />}
|
||||
style={{
|
||||
borderRadius: radius.full,
|
||||
}}
|
||||
/>
|
||||
<Container gap="sm" flex="row" center="y">
|
||||
{/* icons */}
|
||||
<Container relative color="secondaryText">
|
||||
{/* token icon */}
|
||||
{props.selectedToken ? (
|
||||
<Img
|
||||
key={props.selectedToken?.data?.iconUri}
|
||||
src={
|
||||
props.selectedToken?.data === undefined
|
||||
? undefined
|
||||
: props.selectedToken.data.iconUri || ""
|
||||
}
|
||||
client={props.client}
|
||||
width="40"
|
||||
height="40"
|
||||
fallback={
|
||||
<Container
|
||||
style={{
|
||||
background: `linear-gradient(45deg, white, ${theme.colors.accentText})`,
|
||||
borderRadius: radius.full,
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
style={{
|
||||
objectFit: "cover",
|
||||
borderRadius: radius.full,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Container
|
||||
style={{
|
||||
border: `1px solid ${theme.colors.borderColor}`,
|
||||
background: `linear-gradient(45deg, white, ${theme.colors.accentText})`,
|
||||
borderRadius: radius.full,
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* chain icon */}
|
||||
<Container
|
||||
bg="modalBg"
|
||||
style={{
|
||||
padding: "2px",
|
||||
position: "absolute",
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
display: "flex",
|
||||
borderRadius: radius.full,
|
||||
}}
|
||||
>
|
||||
<Img
|
||||
src={props.chain?.icon}
|
||||
client={props.client}
|
||||
width={iconSize.sm}
|
||||
height={iconSize.sm}
|
||||
style={{
|
||||
borderRadius: radius.full,
|
||||
}}
|
||||
/>
|
||||
{/* chain icon */}
|
||||
{props.chain && (
|
||||
<Container
|
||||
bg="modalBg"
|
||||
style={{
|
||||
padding: "2px",
|
||||
position: "absolute",
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
display: "flex",
|
||||
borderRadius: radius.full,
|
||||
}}
|
||||
>
|
||||
<Img
|
||||
src={props.chain?.icon}
|
||||
client={props.client}
|
||||
width={iconSize.sm}
|
||||
height={iconSize.sm}
|
||||
style={{
|
||||
borderRadius: radius.full,
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
)}
|
||||
</Container>
|
||||
</Container>
|
||||
|
||||
{/* token symbol and chain name */}
|
||||
<Container flex="column" style={{ gap: "2px" }}>
|
||||
{props.selectedToken?.isFetching ? (
|
||||
<Skeleton width="40px" height={fontSize.sm} color="modalBg" />
|
||||
) : (
|
||||
<Text size="sm" color="primaryText" weight={500}>
|
||||
{props.selectedToken?.data?.symbol}
|
||||
</Text>
|
||||
)}
|
||||
{/* token symbol and chain name */}
|
||||
{props.selectedToken ? (
|
||||
<Container flex="column" style={{ gap: "3px" }}>
|
||||
{props.selectedToken?.isFetching ? (
|
||||
<Skeleton width="60px" height={fontSize.md} />
|
||||
) : (
|
||||
<Text size="md" color="primaryText" weight={500}>
|
||||
{props.selectedToken?.data?.symbol}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{props.chain ? (
|
||||
<Text
|
||||
size="xs"
|
||||
color="secondaryText"
|
||||
weight={500}
|
||||
style={{
|
||||
maxWidth: "100px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{cleanedChainName(props.chain.name)}
|
||||
</Text>
|
||||
{props.chain ? (
|
||||
<Text
|
||||
size="xs"
|
||||
color="secondaryText"
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{cleanedChainName(props.chain.name)}
|
||||
</Text>
|
||||
) : (
|
||||
<Skeleton width="140px" height={fontSize.sm} />
|
||||
)}
|
||||
</Container>
|
||||
) : (
|
||||
<Skeleton width="70px" height={fontSize.xs} color="modalBg" />
|
||||
<Container flex="column" style={{ gap: "3px" }}>
|
||||
<Text size="md" color="primaryText" weight={500}>
|
||||
Select Token
|
||||
</Text>
|
||||
<Text size="xs" color="secondaryText">
|
||||
Required
|
||||
</Text>
|
||||
</Container>
|
||||
)}
|
||||
</Container>
|
||||
<Container color="secondaryText">
|
||||
<ChevronRightIcon width={iconSize.xs} height={iconSize.xs} />
|
||||
<Container
|
||||
color="secondaryText"
|
||||
flex="row"
|
||||
center="both"
|
||||
borderColor="borderColor"
|
||||
style={{
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
padding: spacing.xs,
|
||||
}}
|
||||
>
|
||||
<ChevronDownIcon width={iconSize.sm} height={iconSize.sm} />
|
||||
</Container>
|
||||
</Button>
|
||||
);
|
||||
@@ -894,11 +1079,13 @@ function SwitchButton(props: { onClick: () => void }) {
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
marginBlock: `-14px`,
|
||||
marginBlock: `-13px`,
|
||||
zIndex: 2,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<SwitchButtonInner
|
||||
variant="outline"
|
||||
variant="ghost-solid"
|
||||
onClick={(e) => {
|
||||
props.onClick();
|
||||
const node = e.currentTarget.querySelector("svg");
|
||||
@@ -912,7 +1099,7 @@ function SwitchButton(props: { onClick: () => void }) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowUpDownIcon size={iconSize.md} />
|
||||
<ArrowUpDownIcon size={iconSize["sm+"]} />
|
||||
</SwitchButtonInner>
|
||||
</div>
|
||||
);
|
||||
@@ -922,10 +1109,11 @@ const SwitchButtonInner = /* @__PURE__ */ styled(Button)(() => {
|
||||
const theme = useCustomTheme();
|
||||
return {
|
||||
"&:hover": {
|
||||
background: theme.colors.modalBg,
|
||||
background: theme.colors.secondaryButtonBg,
|
||||
},
|
||||
borderRadius: radius.lg,
|
||||
borderRadius: radius.full,
|
||||
padding: spacing.xs,
|
||||
color: theme.colors.primaryText,
|
||||
background: theme.colors.modalBg,
|
||||
border: `1px solid ${theme.colors.borderColor}`,
|
||||
};
|
||||
@@ -948,3 +1136,78 @@ function useTokenBalance(props: {
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function ActiveWalletDetails(props: {
|
||||
activeWalletInfo: ActiveWalletInfo;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const wallet = props.activeWalletInfo.activeWallet;
|
||||
const account = props.activeWalletInfo.activeAccount;
|
||||
|
||||
const accountBlobbie = (
|
||||
<AccountBlobbie
|
||||
style={{
|
||||
width: `${iconSize.xs}px`,
|
||||
height: `${iconSize.xs}px`,
|
||||
borderRadius: radius.full,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const accountAvatarFallback = (
|
||||
<WalletIcon
|
||||
style={{
|
||||
width: `${iconSize.xs}px`,
|
||||
height: `${iconSize.xs}px`,
|
||||
}}
|
||||
fallbackComponent={accountBlobbie}
|
||||
loadingComponent={accountBlobbie}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<WalletProvider id={props.activeWalletInfo.activeWallet.id}>
|
||||
<AccountProvider address={account.address} client={props.client}>
|
||||
<WalletProvider id={wallet.id}>
|
||||
<Container flex="row" gap="xxs" center="y">
|
||||
<AccountAvatar
|
||||
style={{
|
||||
width: `${iconSize.xs}px`,
|
||||
height: `${iconSize.xs}px`,
|
||||
borderRadius: radius.full,
|
||||
}}
|
||||
fallbackComponent={accountAvatarFallback}
|
||||
loadingComponent={accountAvatarFallback}
|
||||
/>
|
||||
|
||||
<span
|
||||
style={{
|
||||
fontSize: fontSize.xs,
|
||||
letterSpacing: "0.025em",
|
||||
}}
|
||||
>
|
||||
<AccountName
|
||||
fallbackComponent={
|
||||
<span>{shortenAddress(account.address)}</span>
|
||||
}
|
||||
loadingComponent={
|
||||
<span>{shortenAddress(account.address)}</span>
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</Container>
|
||||
</WalletProvider>
|
||||
</AccountProvider>
|
||||
</WalletProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const WalletButton = /* @__PURE__ */ styled(Button)(() => {
|
||||
const theme = useCustomTheme();
|
||||
return {
|
||||
color: theme.colors.secondaryText,
|
||||
transition: "color 200ms ease",
|
||||
"&:hover": {
|
||||
color: theme.colors.primaryText,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
export function cleanedChainName(name: string) {
|
||||
return name.replace("Mainnet", "");
|
||||
}
|
||||
|
||||
export const tokenAmountFormatter = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 5,
|
||||
minimumFractionDigits: 2,
|
||||
});
|
||||
|
||||
@@ -3,21 +3,21 @@ import type { IconFC } from "./types.js";
|
||||
export const ArrowUpDownIcon: IconFC = (props) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="presentation"
|
||||
width={props.size}
|
||||
height={props.size}
|
||||
>
|
||||
<path d="m21 16-4 4-4-4" />
|
||||
<path d="M17 20V4" />
|
||||
<path d="m3 8 4-4 4 4" />
|
||||
<path d="M7 4v16" />
|
||||
<g
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M9.599 2.4v11.2l3.2-3.476M6.4 13.6V2.4L3.2 5.875"></path>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -84,7 +84,7 @@ export function Container(props: {
|
||||
expand?: boolean;
|
||||
center?: "x" | "y" | "both";
|
||||
gap?: keyof typeof spacing;
|
||||
children: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
p?: keyof typeof spacing;
|
||||
px?: keyof typeof spacing;
|
||||
|
||||
@@ -21,6 +21,7 @@ type ButtonProps = {
|
||||
fullWidth?: boolean;
|
||||
gap?: keyof typeof spacing;
|
||||
bg?: keyof Theme["colors"];
|
||||
hoverBg?: keyof Theme["colors"];
|
||||
};
|
||||
|
||||
export const Button = /* @__PURE__ */ StyledButton((props: ButtonProps) => {
|
||||
@@ -95,6 +96,9 @@ export const Button = /* @__PURE__ */ StyledButton((props: ButtonProps) => {
|
||||
transition: "border 200ms ease",
|
||||
WebkitTapHighlightColor: "transparent",
|
||||
width: props.fullWidth ? "100%" : undefined,
|
||||
"&:hover": {
|
||||
background: props.hoverBg ? theme.colors[props.hoverBg] : undefined,
|
||||
},
|
||||
...(() => {
|
||||
if (props.variant === "outline") {
|
||||
return {
|
||||
@@ -120,7 +124,7 @@ export const Button = /* @__PURE__ */ StyledButton((props: ButtonProps) => {
|
||||
if (props.variant === "ghost-solid") {
|
||||
return {
|
||||
"&:hover": {
|
||||
background: theme.colors.tertiaryBg,
|
||||
background: theme.colors[props.hoverBg || "tertiaryBg"],
|
||||
},
|
||||
border: "1px solid transparent",
|
||||
};
|
||||
@@ -137,7 +141,7 @@ export const Button = /* @__PURE__ */ StyledButton((props: ButtonProps) => {
|
||||
if (props.variant === "secondary") {
|
||||
return {
|
||||
"&:hover": {
|
||||
background: theme.colors.secondaryButtonHoverBg,
|
||||
background: theme.colors[props.hoverBg || "secondaryButtonHoverBg"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useCustomTheme } from "../../../core/design-system/CustomThemeProvider.js";
|
||||
import {
|
||||
fontSize,
|
||||
media,
|
||||
radius,
|
||||
spacing,
|
||||
type Theme,
|
||||
@@ -97,11 +98,14 @@ export const Input = /* @__PURE__ */ StyledInput<InputProps>((props) => {
|
||||
color: theme.colors.primaryText,
|
||||
display: "block",
|
||||
fontFamily: "inherit",
|
||||
fontSize: fontSize.md,
|
||||
fontSize: fontSize.sm,
|
||||
outline: "none",
|
||||
padding: props.sm ? spacing.sm : fontSize.sm,
|
||||
WebkitAppearance: "none",
|
||||
width: "100%",
|
||||
[media.mobile]: {
|
||||
fontSize: fontSize.md,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 640;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile;
|
||||
}
|
||||
@@ -125,7 +125,7 @@ export function getRpcClient(
|
||||
inflight.request.id = index;
|
||||
// also assign the jsonrpc version
|
||||
inflight.request.jsonrpc = "2.0";
|
||||
// assing the request to the requests array (so we don't have to map it again later)
|
||||
// assign the request to the requests array (so we don't have to map it again later)
|
||||
requests[index] = inflight.request;
|
||||
return inflight;
|
||||
});
|
||||
|
||||
@@ -16,13 +16,14 @@ const meta = {
|
||||
} satisfies Meta<typeof SelectBridgeChain>;
|
||||
export default meta;
|
||||
|
||||
export function WithData() {
|
||||
export function WithDataDesktop() {
|
||||
const [selectedChain, setSelectedChain] = useState<BridgeChain | undefined>(
|
||||
undefined,
|
||||
);
|
||||
return (
|
||||
<SwapWidgetContainer theme="dark" className="w-full">
|
||||
<SelectBridgeChain
|
||||
isMobile={false}
|
||||
client={storyClient}
|
||||
onSelectChain={setSelectedChain}
|
||||
onBack={() => {}}
|
||||
@@ -32,13 +33,50 @@ export function WithData() {
|
||||
);
|
||||
}
|
||||
|
||||
export function Loading() {
|
||||
export function LoadingDesktop() {
|
||||
const [selectedChain, setSelectedChain] = useState<BridgeChain | undefined>(
|
||||
undefined,
|
||||
);
|
||||
return (
|
||||
<SwapWidgetContainer theme="dark" className="w-full">
|
||||
<SelectBridgeChainUI
|
||||
isMobile={false}
|
||||
client={storyClient}
|
||||
onSelectChain={setSelectedChain}
|
||||
onBack={() => {}}
|
||||
isPending={true}
|
||||
chains={[]}
|
||||
selectedChain={selectedChain}
|
||||
/>
|
||||
</SwapWidgetContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function WithDataMobile() {
|
||||
const [selectedChain, setSelectedChain] = useState<BridgeChain | undefined>(
|
||||
undefined,
|
||||
);
|
||||
return (
|
||||
<SwapWidgetContainer theme="dark" className="w-full">
|
||||
<SelectBridgeChain
|
||||
isMobile={true}
|
||||
client={storyClient}
|
||||
onSelectChain={setSelectedChain}
|
||||
onBack={() => {}}
|
||||
selectedChain={selectedChain}
|
||||
/>
|
||||
</SwapWidgetContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingMobile() {
|
||||
const [selectedChain, setSelectedChain] = useState<BridgeChain | undefined>(
|
||||
undefined,
|
||||
);
|
||||
return (
|
||||
<SwapWidgetContainer theme="dark" className="w-full">
|
||||
<SelectBridgeChainUI
|
||||
isMobile={true}
|
||||
client={storyClient}
|
||||
onSelectChain={setSelectedChain}
|
||||
onBack={() => {}}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Meta } from "@storybook/react";
|
||||
import { lightTheme } from "../../../react/core/design-system/index.js";
|
||||
import { SwapWidget } from "../../../react/web/ui/Bridge/swap-widget/SwapWidget.js";
|
||||
import { ConnectButton } from "../../../react/web/ui/ConnectWallet/ConnectButton.js";
|
||||
import { storyClient } from "../../utils.js";
|
||||
|
||||
const meta: Meta<typeof SwapWidget> = {
|
||||
@@ -14,15 +13,6 @@ const meta: Meta<typeof SwapWidget> = {
|
||||
return (
|
||||
<div>
|
||||
<Story />
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "20px",
|
||||
right: "20px",
|
||||
}}
|
||||
>
|
||||
<ConnectButton client={storyClient} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -31,15 +21,28 @@ const meta: Meta<typeof SwapWidget> = {
|
||||
export default meta;
|
||||
|
||||
export function BasicUsage() {
|
||||
return <SwapWidget client={storyClient} />;
|
||||
return <SwapWidget client={storyClient} persistTokenSelections={false} />;
|
||||
}
|
||||
|
||||
export function CurrencySet() {
|
||||
return <SwapWidget client={storyClient} currency="JPY" />;
|
||||
return (
|
||||
<SwapWidget
|
||||
client={storyClient}
|
||||
currency="JPY"
|
||||
persistTokenSelections={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LightMode() {
|
||||
return <SwapWidget client={storyClient} currency="JPY" theme="light" />;
|
||||
return (
|
||||
<SwapWidget
|
||||
client={storyClient}
|
||||
currency="JPY"
|
||||
theme="light"
|
||||
persistTokenSelections={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoThirdwebBranding() {
|
||||
@@ -48,6 +51,7 @@ export function NoThirdwebBranding() {
|
||||
client={storyClient}
|
||||
currency="JPY"
|
||||
showThirdwebBranding={false}
|
||||
persistTokenSelections={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -57,6 +61,7 @@ export function CustomTheme() {
|
||||
<SwapWidget
|
||||
client={storyClient}
|
||||
currency="JPY"
|
||||
persistTokenSelections={false}
|
||||
theme={lightTheme({
|
||||
colors: {
|
||||
modalBg: "#FFFFF0",
|
||||
|
||||
@@ -48,7 +48,7 @@ import type { EcosystemWallet } from "../core/wallet/types.js";
|
||||
*
|
||||
* ### Connect to a restricted ecosystem wallet with your designated partner ID
|
||||
*
|
||||
* The parnter ID will be provided to you by the ecosystem with which you're integrating.
|
||||
* The partner ID will be provided to you by the ecosystem with which you're integrating.
|
||||
*
|
||||
* ```ts
|
||||
* import { ecosystemWallet } from "thirdweb/wallets";
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { FacilitatorConfig } from "x402/types";
|
||||
import type { ThirdwebClient } from "../client/client.js";
|
||||
|
||||
export type ThirdwebX402FacilitatorConfig = {
|
||||
client: ThirdwebClient;
|
||||
serverWalletAddress: string;
|
||||
vaultAccessToken?: string;
|
||||
baseUrl?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_BASE_URL = "https://api.thirdweb.com/v1/payments/x402";
|
||||
|
||||
/**
|
||||
* Creates a facilitator for the x402 payment protocol.
|
||||
* Use this with any x402 middleware to enable settling transactions with your thirdweb server wallet.
|
||||
*
|
||||
* @param config - The configuration for the facilitator
|
||||
* @returns a x402 compatible FacilitatorConfig
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { facilitator } from "thirdweb/x402";
|
||||
* import { createThirdwebClient } from "thirdweb";
|
||||
*
|
||||
* const client = createThirdwebClient({
|
||||
* secretKey: "your-secret-key",
|
||||
* });
|
||||
* const thirdwebX402Facilitator = facilitator({
|
||||
* client: client,
|
||||
* serverWalletAddress: "0x1234567890123456789012345678901234567890",
|
||||
* });
|
||||
*
|
||||
* // add the facilitator to any x402 payment middleware
|
||||
* const middleware = paymentMiddleware(
|
||||
* "0x1234567890123456789012345678901234567890",
|
||||
* {
|
||||
* "/api/paywall": {
|
||||
* price: "$0.01",
|
||||
* network: "base-sepolia",
|
||||
* config: {
|
||||
* description: "Access to paid content",
|
||||
* },
|
||||
* },
|
||||
* },
|
||||
* thirdwebX402Facilitator,
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* @bridge x402
|
||||
*/
|
||||
export function facilitator(
|
||||
config: ThirdwebX402FacilitatorConfig,
|
||||
): FacilitatorConfig {
|
||||
const secretKey = config.client.secretKey;
|
||||
if (!secretKey) {
|
||||
throw new Error("Client secret key is required for the x402 facilitator");
|
||||
}
|
||||
const serverWalletAddress = config.serverWalletAddress;
|
||||
if (!serverWalletAddress) {
|
||||
throw new Error(
|
||||
"Server wallet address is required for the x402 facilitator",
|
||||
);
|
||||
}
|
||||
return {
|
||||
url: (config.baseUrl ?? DEFAULT_BASE_URL) as `${string}://${string}`,
|
||||
createAuthHeaders: async () => {
|
||||
return {
|
||||
verify: {
|
||||
"x-secret-key": secretKey,
|
||||
},
|
||||
settle: {
|
||||
"x-secret-key": secretKey,
|
||||
"x-settlement-wallet-address": serverWalletAddress,
|
||||
...(config.vaultAccessToken
|
||||
? { "x-vault-access-token": config.vaultAccessToken }
|
||||
: {}),
|
||||
},
|
||||
supported: {
|
||||
"x-secret-key": secretKey,
|
||||
},
|
||||
list: {
|
||||
"x-secret-key": secretKey,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { createPaymentHeader } from "x402/client";
|
||||
import {
|
||||
ChainIdToNetwork,
|
||||
EvmNetworkToChainId,
|
||||
type PaymentRequirements,
|
||||
PaymentRequirementsSchema,
|
||||
type Signer,
|
||||
} from "x402/types";
|
||||
import { viemAdapter } from "../adapters/viem.js";
|
||||
import { getCachedChain } from "../chains/utils.js";
|
||||
import type { ThirdwebClient } from "../client/client.js";
|
||||
import type { Wallet } from "../wallets/interfaces/wallet.js";
|
||||
|
||||
/**
|
||||
* Enables the payment of APIs using the x402 payment protocol.
|
||||
*
|
||||
* This function wraps the native fetch API to automatically handle 402 Payment Required responses
|
||||
* by creating and sending a payment header. It will:
|
||||
* 1. Make the initial request
|
||||
* 2. If a 402 response is received, parse the payment requirements
|
||||
* 3. Verify the payment amount is within the allowed maximum
|
||||
* 4. Create a payment header using the provided wallet client
|
||||
* 5. Retry the request with the payment header
|
||||
*
|
||||
* @param fetch - The fetch function to wrap (typically globalThis.fetch)
|
||||
* @param client - The thirdweb client used to access RPC infrastructure
|
||||
* @param wallet - The wallet used to sign payment messages
|
||||
* @param maxValue - The maximum allowed payment amount in base units (defaults to 1 USDC)
|
||||
* @returns A wrapped fetch function that handles 402 responses automatically
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { wrapFetchWithPayment } from "thirdweb/x402";
|
||||
* import { createThirdwebClient } from "thirdweb";
|
||||
* import { createWallet } from "thirdweb/wallets";
|
||||
*
|
||||
* const client = createThirdwebClient({ clientId: "your-client-id" });
|
||||
* const wallet = createWallet("io.metamask");
|
||||
* await wallet.connect({ client })
|
||||
*
|
||||
* const fetchWithPay = wrapFetchWithPayment(fetch, client, wallet);
|
||||
*
|
||||
* // Make a request that may require payment
|
||||
* const response = await fetchWithPay('https://api.example.com/paid-endpoint');
|
||||
* ```
|
||||
*
|
||||
* @throws {Error} If the payment amount exceeds the maximum allowed value
|
||||
* @throws {Error} If a payment has already been attempted for this request
|
||||
* @throws {Error} If there's an error creating the payment header
|
||||
*
|
||||
* @bridge x402
|
||||
*/
|
||||
export function wrapFetchWithPayment(
|
||||
fetch: typeof globalThis.fetch,
|
||||
client: ThirdwebClient,
|
||||
wallet: Wallet,
|
||||
maxValue: bigint = BigInt(1 * 10 ** 6), // Default to 1 USDC
|
||||
) {
|
||||
return async (input: RequestInfo, init?: RequestInit) => {
|
||||
const response = await fetch(input, init);
|
||||
|
||||
if (response.status !== 402) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const { x402Version, accepts } = (await response.json()) as {
|
||||
x402Version: number;
|
||||
accepts: unknown[];
|
||||
};
|
||||
const parsedPaymentRequirements = accepts
|
||||
.map((x) => PaymentRequirementsSchema.parse(x))
|
||||
.filter((x) => x.scheme === "exact"); // TODO (402): accept other schemes
|
||||
|
||||
const account = wallet.getAccount();
|
||||
let chain = wallet.getChain();
|
||||
|
||||
if (!account || !chain) {
|
||||
throw new Error(
|
||||
"Wallet not connected. Please connect your wallet to continue.",
|
||||
);
|
||||
}
|
||||
const selectedPaymentRequirements = defaultPaymentRequirementsSelector(
|
||||
parsedPaymentRequirements,
|
||||
chain.id,
|
||||
"exact",
|
||||
);
|
||||
|
||||
if (BigInt(selectedPaymentRequirements.maxAmountRequired) > maxValue) {
|
||||
throw new Error("Payment amount exceeds maximum allowed");
|
||||
}
|
||||
|
||||
const paymentChainId = EvmNetworkToChainId.get(
|
||||
selectedPaymentRequirements.network,
|
||||
);
|
||||
|
||||
if (!paymentChainId) {
|
||||
throw new Error(
|
||||
`No chain found for the selected payment requirement: ${selectedPaymentRequirements.network}`,
|
||||
);
|
||||
}
|
||||
|
||||
// switch to the payment chain if it's not the current chain
|
||||
if (paymentChainId !== chain.id) {
|
||||
await wallet.switchChain(getCachedChain(paymentChainId));
|
||||
chain = wallet.getChain();
|
||||
if (!chain) {
|
||||
throw new Error(`Failed to switch chain (${paymentChainId})`);
|
||||
}
|
||||
}
|
||||
|
||||
const walletClient = viemAdapter.wallet.toViem({
|
||||
wallet: wallet,
|
||||
chain,
|
||||
client,
|
||||
}) as Signer;
|
||||
|
||||
const paymentHeader = await createPaymentHeader(
|
||||
walletClient,
|
||||
x402Version,
|
||||
selectedPaymentRequirements,
|
||||
);
|
||||
|
||||
const initParams = init || {};
|
||||
|
||||
if ((initParams as { __is402Retry?: boolean }).__is402Retry) {
|
||||
throw new Error("Payment already attempted");
|
||||
}
|
||||
|
||||
const newInit = {
|
||||
...initParams,
|
||||
headers: {
|
||||
...(initParams.headers || {}),
|
||||
"X-PAYMENT": paymentHeader,
|
||||
"Access-Control-Expose-Headers": "X-PAYMENT-RESPONSE",
|
||||
},
|
||||
__is402Retry: true,
|
||||
};
|
||||
|
||||
const secondResponse = await fetch(input, newInit);
|
||||
return secondResponse;
|
||||
};
|
||||
}
|
||||
|
||||
function defaultPaymentRequirementsSelector(
|
||||
paymentRequirements: PaymentRequirements[],
|
||||
chainId: number,
|
||||
scheme: "exact",
|
||||
) {
|
||||
if (!paymentRequirements.length) {
|
||||
throw new Error(
|
||||
"No valid payment requirements found in server 402 response",
|
||||
);
|
||||
}
|
||||
const currentWalletNetwork = ChainIdToNetwork[chainId];
|
||||
// find the payment requirements matching the connected wallet chain
|
||||
const matchingPaymentRequirements = paymentRequirements.find(
|
||||
(x) => x.network === currentWalletNetwork && x.scheme === scheme,
|
||||
);
|
||||
|
||||
if (matchingPaymentRequirements) {
|
||||
return matchingPaymentRequirements;
|
||||
} else {
|
||||
// if no matching payment requirements, use the first payment requirement
|
||||
// and switch the wallet to that chain
|
||||
const firstPaymentRequirement = paymentRequirements.find(
|
||||
(x) => x.scheme === scheme,
|
||||
);
|
||||
if (!firstPaymentRequirement) {
|
||||
throw new Error("No suitable payment requirements found");
|
||||
}
|
||||
return firstPaymentRequirement;
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export async function getCodeHtml(
|
||||
? await format(code, {
|
||||
parser: "typescript",
|
||||
plugins: [estreePlugin, typescriptPlugin],
|
||||
printWidth: 60,
|
||||
printWidth: 80,
|
||||
}).catch((e) => {
|
||||
if (!options?.ignoreFormattingErrors) {
|
||||
console.error(e);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @thirdweb-dev/wagmi-adapter
|
||||
|
||||
## 0.2.149
|
||||
|
||||
## 0.2.148
|
||||
|
||||
## 0.2.147
|
||||
|
||||
## 0.2.146
|
||||
|
||||
@@ -55,5 +55,5 @@
|
||||
"type": "module",
|
||||
"types": "./dist/types/exports/thirdweb.d.ts",
|
||||
"typings": "./dist/types/exports/thirdweb.d.ts",
|
||||
"version": "0.2.147"
|
||||
"version": "0.2.149"
|
||||
}
|
||||
|
||||
Generated
+1324
-144
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user