Compare commits

...
Author SHA1 Message Date
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
c328aa13a6 Version Packages (#8081)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-22 15:19:02 +07:00
FirekeeperandGitHub a85ef0b222 Add Zephyr testnet to pre-1559 chains (#8092) 2025-09-22 15:07:13 +07:00
sashaodessaandGitHub 51177fbe17 Fix typos in comments and docs across thirdweb packages (#8033)
Signed-off-by: sashaodessa <[email protected]>
2025-09-19 18:59:29 -07:00
Jonas DanielsandGitHub 1338a0cf2c Remove taint from GROWTH_PLAN_SKU and PAYMENT_METHOD_CONFIGURATION (#8089) 2025-09-19 18:58:47 -07:00
MananTank 96ebf378ff Playground: Redirect corrections (#8087)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the routing configuration in the `next.config.mjs` file to allow for dynamic path segments in specific routes.

### Detailed summary
- Changed `source` for `/insight` to `/insight/:path*`, allowing for dynamic paths.
- Changed `source` for `/payments/backend` to `/payments/backend/:path*`, enabling dynamic path handling.
- Both routes maintain their respective `destination` and `permanent` settings.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **Bug Fixes**
  * Fixed redirects for /insight and /payments/backend to handle nested paths, ensuring deep links (e.g., subpages) correctly route to their destinations without 404s.
  * Maintains existing base redirects and leaves other routes untouched, improving navigation reliability without altering overall site behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-20 01:10:38 +00:00
MananTank b7732d3a75 Dashboard: Add search in tokens page (#8088)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR enhances the `Token` and `TokenPage` components by adding search functionality for tokens and improving the bridge network selection process.

### Detailed summary
- Added `query` parameter in `tokens` function for searching tokens by name or symbol.
- Updated `Page` component to fetch supported chains asynchronously.
- Modified `TokenPage` to accept `chains` as a prop and integrated search functionality.
- Enhanced `BridgeNetworkSelector` to utilize passed `chains` prop instead of querying.
- Added search input for token filtering in the `TokenPage`.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **New Features**
  * Added token search on the Tokens page; search by name or symbol with a new inline search field beside sort controls.
  * Search intelligently interacts with sorting: choosing Popular or Trending clears the search.

* **Improvements**
  * Faster, more reliable page load by fetching supported networks server‑side and passing them to the page.
  * Refined network selector UI with a consistent placeholder and smoother option rendering.

* **Refactor**
  * Streamlined components to receive pre-fetched chain data, reducing in-component data fetching and simplifying state.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-20 00:54:37 +00:00
MananTank 6f88d38ad2 [BLD-325] Dashboard: Rename floating chat button label (#8086)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on updating the `CustomChatButton` component and its usage in various layouts by simplifying the button structure, changing icons, and removing unnecessary wrapper elements.

### Detailed summary
- Removed the wrapper `<div>` around the `CustomChatButton` in `layout.tsx` files.
- Changed the icon in `CustomChatButton` from `MessageCircleIcon` to `CircleQuestionMarkIcon`.
- Updated button label from dynamic to static "Need help?".
- Adjusted button class names for styling consistency.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-09-20 00:03:01 +00:00
MananTank d53f8a25b4 [MNY-191] Dashboard: Add header in /bridge (#8085)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the layout and components of the `Bridge` application by improving styling, adding new components, and refactoring existing structures for better usability and aesthetics.

### Detailed summary
- Updated `<body>` class in `layout.tsx` for improved layout.
- Changed width class in `UniversalBridgeEmbed.tsx` for full responsiveness.
- Added `relative z-10` class to the main `<div>` in `BuyAndSwapEmbed.tsx`.
- Introduced a new `PageHeader` component in `header.tsx` with navigation links.
- Refactored `BridgePage` in `page.tsx` to include `PageHeader` and remove unnecessary elements.
- Added `DotsBackgroundPattern` function for a decorative background effect.
- Integrated `AppFooter` in `BridgePage` for consistent footer display.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
2025-09-19 23:35:04 +00:00
MananTank dd693169bc Dashboard: Remove /routes (#8084)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on the removal of several files related to the `bridge` feature in the dashboard and the addition of a new `tokens` page with metadata for improved SEO.

### Detailed summary
- Deleted files related to the `bridge` feature:
  - `utils.ts`
  - `layout.tsx`
  - `route.ts`
  - `page.tsx`
  - `opengraph-image.png`
  - Various components under `routes/components/client` and `routes/components/server`
- Added a new `tokens` page in `page.tsx` with:
  - Metadata for SEO, including title and description
  - Import of `PageHeader` and `TokenPage` components

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- New Features
  - Redirects: Visiting /routes now automatically navigates to /tokens (temporary redirect).
  - Tokens page now includes enriched metadata for improved previews and sharing.

- Refactor
  - Removed the legacy Routes dashboard and associated UI controls, streamlining navigation to the Tokens experience.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-19 23:35:04 +00:00
MananTank 93f913c614 [MNY-189] SDK: SwapWidget UI improvements (#8080)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on UI improvements for the `SwapWidget`, enhancing responsiveness, styling, and functionality, particularly for mobile users. It also refines token selection and formatting for better user experience.

### Detailed summary
- Removed a changeset file related to `SwapWidget` UI improvements.
- Adjusted breakpoints and font sizes in `design-system/index.ts`.
- Enhanced `SearchInput` styling in `SearchInput.tsx`.
- Added `tokenAmountFormatter` utility in `utils.ts`.
- Made `children` prop optional in `basic.tsx`.
- Updated color values in `sdk-component-theme.ts`.
- Enhanced `Input` component styles in `formElements.tsx`.
- Improved `BuyAndSwapEmbed.tsx` with better token handling.
- Introduced `useIsMobile` hook for mobile detection.
- Updated `ArrowUpDownIcon.tsx` SVG for better rendering.
- Added hover background support in `buttons.tsx`.
- Renamed `WithData` to `WithDataDesktop` in `SelectChain.stories.tsx`.
- Added mobile versions of `WithData` and `Loading` functions in `SelectChain.stories.tsx`.
- Updated `SwapWidget` stories to manage token selections and themes.
- Refined `select-chain.tsx` for better mobile handling.
- Improved `swap-ui.tsx` with mobile responsiveness and token selection logic.
- Enhanced `select-token-ui.tsx` with mobile UI improvements and token display logic.
- Added `ActiveWalletDetails` for displaying wallet information.
- Updated various button and token display styles for consistency and better UX.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Swap widget: onDisconnect callback, details modal, max-fill, improved wallet/account display, clearer loading/insufficient-balance states; stories now disable token-selection persistence.
  * Mobile detection hook enabling separate mobile/desktop flows.

* **UI/Style**
  * Enhanced token/chain visuals (gradients, smaller icon size), adjusted spacing and input sizing, customizable button hover backgrounds.

* **Refactor**
  * Unified token amount formatting and simplified numeric displays.

* **Chores**
  * Added changeset entry for UI improvements.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-20 02:22:43 +05:30
MananTank 02502159b6 Dashboard: Add Tokens page (#8073)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces enhancements to the token management system, focusing on adding price-related fields, sorting options, and improving the user interface for token display and selection.

### Detailed summary
- Added `marketCapUsd` and `volume24hUsd` fields to the `Token` type.
- Introduced `sortBy` option in token fetching.
- Updated `PublicPageConnectButton` to include a `detailsButton`.
- Created a new `Page` component for displaying token information.
- Implemented a `PageHeader` component for navigation.
- Added `TokenPage` component for token management with sorting capabilities.
- Developed `TokensTable` component for listing tokens with price, market cap, and volume.
- Introduced `BridgeNetworkSelector` for selecting blockchain networks.
- Enhanced UI with loading states and better token presentation.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- New Features
  - Introduced a Tokens dashboard page with header and hero section.
  - Added network selector for choosing chains.
  - Implemented token list with columns for Token, Price, Market Cap, and 24h Volume.
  - Enabled sorting by Popular (market cap) and Trending (volume), plus pagination.
  - Improved Connect button with an additional details button style.

- Enhancements
  - Loading states and empty-state messaging for token lists.
  - Backend now supports token sorting and exposes market cap and 24h volume for richer displays.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-19 20:02:12 +00:00
Joaquim VergesGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
e9225c3bad Version Packages (#8079)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-19 05:11:46 -07:00
Joaquim VergesandGitHub 5967fb8afa [SDK] Add x402 payment protocol utilities (#8076) 2025-09-19 05:00:05 -07:00
MananTank fddb791f0a Playground: Fix Pay components incomplete code gen, cleanup (#8078)
<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR primarily focuses on refactoring the payment components in the `playground-web` application, updating widget properties and enhancing code generation functions for better modularity and readability.

### Detailed summary
- Changed `lockedWidget` to `widget` in multiple components: `CheckoutPlayground`, `TransactionPlayground`, `BuyPlayground`, `LeftSection`, and `RightSection`.
- Added new functions in `code-gen.ts` for `stringifyProps`, `quotes`, `stringifyImports`, and `stringifyIgnoreFalsy`.
- Updated `CodeGen` components to accept `widget` as a prop.
- Refactored `getCode` function in `CodeGen` to use the new `widget` parameter.
- Removed unused imports and functions from `CodeGen` and `LeftSection`.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- New Features
  - Code generator now supports explicit widget selection (buy, checkout, transaction) with unified, props-driven output.
  - Enhanced theming in generated snippets, including dark/light themes with overrides.

- Refactor
  - Migrated playground code generation to centralized utilities for imports and props.
  - Simplified widget configuration by removing it from options and passing it as a direct prop.
  - Streamlined Payments UI; removed “Sponsor gas fees” section.

- Style
  - Improved code snippet formatting with wider line wrap for cleaner display.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-19 10:12:08 +00:00
90 changed files with 3830 additions and 1838 deletions
+5
View File
@@ -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"
+1 -17
View File
@@ -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

@@ -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>
);
};
@@ -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>
);
}
@@ -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>
);
}
@@ -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;
}
@@ -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>
);
}
+1 -1
View File
@@ -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,
)}
>
+28 -37
View File
@@ -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%)",
}}
/>
);
}
+2 -2
View File
@@ -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,
},
+1
View File
@@ -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}
/>
)}
+4
View File
@@ -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}"`;
}
+43
View File
@@ -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);
}
+36
View File
@@ -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"],
};
+6
View File
@@ -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

+4
View File
@@ -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"],
};
```
+14
View File
@@ -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
+1 -1
View File
@@ -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"
}
+14
View File
@@ -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
+10 -1
View File
@@ -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"
}
+13
View File
@@ -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 & {
+5
View File
@@ -0,0 +1,5 @@
export {
facilitator,
type ThirdwebX402FacilitatorConfig,
} from "../x402/facilitator.js";
export { wrapFetchWithPayment } from "../x402/fetchWithPayment.js";
+1
View File
@@ -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;
}
+1 -1
View File
@@ -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";
+87
View File
@@ -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);
+4
View File
@@ -1,5 +1,9 @@
# @thirdweb-dev/wagmi-adapter
## 0.2.149
## 0.2.148
## 0.2.147
## 0.2.146
+1 -1
View File
@@ -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"
}
+1324 -144
View File
File diff suppressed because it is too large Load Diff